diff --git a/src/components/dropdown/README.md b/src/components/dropdown/README.md
index aa3a82a40cc..fccb8db3052 100644
--- a/src/components/dropdown/README.md
+++ b/src/components/dropdown/README.md
@@ -572,6 +572,16 @@ export default {
Refer to the [Events](/docs/components/dropdown#component-reference) section of documentation for
the full list of events.
+## Optionally scoped default slot
+
+NEW in 2.0.0-rc.20
+
+The default slot is optionally scoped with the following scope available:
+
+| Property or Method | Description |
+| ------------------ | ------------------------------------------------------------------------------------------------------------------------------- |
+| `hide()` | Can be used to close the dropdown menu. Accepts an optional boolean argument, which if `true` returns focus to the toggle button |
+
## Accessibility
Providing a unique `id` prop ensures ARIA compliance by automatically adding the appropriate
diff --git a/src/components/dropdown/_dropdown-form.scss b/src/components/dropdown/_dropdown-form.scss
index 79cb48f390b..e6b247d89a4 100644
--- a/src/components/dropdown/_dropdown-form.scss
+++ b/src/components/dropdown/_dropdown-form.scss
@@ -6,19 +6,35 @@ $bv-dropdown-form-defined: false !default;
// Custom styles for
// Based on class `.dropdown-item`
- .b-dropdown-form {
- display: inline-block;
- padding: $dropdown-item-padding-y $dropdown-item-padding-x;
- width: 100%;
- clear: both;
- font-weight: $font-weight-normal;
+ .dropdown.b-dropdown {
+ .b-dropdown-form {
+ display: inline-block;
+ padding: $dropdown-item-padding-y $dropdown-item-padding-x;
+ width: 100%;
+ clear: both;
+ font-weight: $font-weight-normal;
- &:first-child {
- @include border-top-radius($dropdown-inner-border-radius);
- }
+ &:focus {
+ // From https://github.com/twbs/bootstrap/blob/master/scss/_reboot.scss
+ // mimicking button:focus styling.
+ // We add important here as anything with tabindex `-1` and focused will not
+ // have a focus ring due to reboot.scss and it's `!important` override.
+ // Needed for keyboard navigation high-lighting
+ outline: 1px dotted !important;
+ outline: 5px auto -webkit-focus-ring-color !important;
+ }
- &:last-child {
- @include border-bottom-radius($dropdown-inner-border-radius);
+ &.disabled,
+ &:disabled {
+ outline: 0 !important;
+ color: $dropdown-link-disabled-color;
+ pointer-events: none;
+ // background-color: transparent;
+ // Remove CSS gradients if they're enabled
+ // @if $enable-gradients {
+ // background-image: none;
+ // }
+ }
}
}
}
diff --git a/src/components/dropdown/_dropdown-text.scss b/src/components/dropdown/_dropdown-text.scss
index 74a1092279d..d7cb337f8ae 100644
--- a/src/components/dropdown/_dropdown-text.scss
+++ b/src/components/dropdown/_dropdown-text.scss
@@ -13,13 +13,5 @@ $bv-dropdown-text-defined: false !default;
width: 100%;
clear: both;
font-weight: $font-weight-lighter;
-
- &:first-child {
- @include border-top-radius($dropdown-inner-border-radius);
- }
-
- &:last-child {
- @include border-bottom-radius($dropdown-inner-border-radius);
- }
}
}
diff --git a/src/components/dropdown/_dropdown.scss b/src/components/dropdown/_dropdown.scss
index 4c411b4f5fe..d14a2191103 100644
--- a/src/components/dropdown/_dropdown.scss
+++ b/src/components/dropdown/_dropdown.scss
@@ -24,5 +24,28 @@ $bv-dropdown-defined: false !default;
}
}
}
+
+ // Prevent dropdown background overflow if there's no padding
+ // See https://github.com/twbs/bootstrap/pull/27703
+ // Added here to address wrapping of items
+ @if $dropdown-padding-y == 0 {
+ .dropdown-menu {
+ > :first-child {
+ .dropdown-item,
+ .dropdown-form,
+ .dropdown-text {
+ @include border-top-radius($dropdown-inner-border-radius);
+ }
+ }
+
+ > :last-child {
+ .dropdown-item,
+ .dropdown-form,
+ .dropdown-text {
+ @include border-bottom-radius($dropdown-inner-border-radius);
+ }
+ }
+ }
+ }
}
}
diff --git a/src/components/dropdown/dropdown-form.js b/src/components/dropdown/dropdown-form.js
index 8925370dbc6..9c623397bcc 100644
--- a/src/components/dropdown/dropdown-form.js
+++ b/src/components/dropdown/dropdown-form.js
@@ -6,15 +6,27 @@ export default Vue.extend({
name: 'BDropdownForm',
functional: true,
inheritAttrs: false,
- props: { ...formProps },
+ props: {
+ ...formProps,
+ disabled: {
+ type: Boolean,
+ default: false
+ }
+ },
render(h, { props, data, children }) {
return h('li', [
h(
BForm,
mergeData(data, {
+ ref: 'form',
staticClass: 'b-dropdown-form',
+ class: { disabled: props.disabled },
props,
- ref: 'form'
+ attrs: {
+ disabled: props.disabled,
+ // Tab index of -1 for keyboard navigation
+ tabindex: props.disabled ? null : '-1'
+ }
}),
children
)
diff --git a/src/components/dropdown/dropdown-form.spec.js b/src/components/dropdown/dropdown-form.spec.js
index b1411ccc5d7..d8801af57d5 100644
--- a/src/components/dropdown/dropdown-form.spec.js
+++ b/src/components/dropdown/dropdown-form.spec.js
@@ -10,23 +10,47 @@ describe('dropdown-form', () => {
expect(form.is('form')).toBe(true)
})
- it('has custom class "b-dropdown-form"', async () => {
+ it('default has expected classes', async () => {
const wrapper = mount(BDropdownForm)
expect(wrapper.is('li')).toBe(true)
const form = wrapper.find('form')
expect(form.classes()).toContain('b-dropdown-form')
expect(form.classes()).not.toContain('was-validated')
+ expect(form.classes()).not.toContain('disabled')
})
- it('has class "was-validated" when validated=true', async () => {
+ it('has tabindex on form', async () => {
+ const wrapper = mount(BDropdownForm)
+ expect(wrapper.is('li')).toBe(true)
+
+ const form = wrapper.find('form')
+ expect(form.is('form')).toBe(true)
+ expect(form.attributes('tabindex')).toBeDefined()
+ expect(form.attributes('tabindex')).toEqual('-1')
+ })
+
+ it('does not have tabindex on form when disabled', async () => {
const wrapper = mount(BDropdownForm, {
- context: {
- props: { validated: true }
+ propsData: {
+ disabled: true
}
})
expect(wrapper.is('li')).toBe(true)
+ const form = wrapper.find('form')
+ expect(form.is('form')).toBe(true)
+ expect(form.attributes('tabindex')).not.toBeDefined()
+ expect(form.attributes('disabled')).toBeDefined()
+ expect(form.classes()).toContain('disabled')
+ })
+
+ it('has class "was-validated" when validated=true', async () => {
+ const wrapper = mount(BDropdownForm, {
+ propsData: { validated: true }
+ })
+ expect(wrapper.is('li')).toBe(true)
+
const form = wrapper.find('form')
expect(form.classes()).toContain('was-validated')
expect(form.classes()).toContain('b-dropdown-form')
@@ -42,9 +66,7 @@ describe('dropdown-form', () => {
it('has attribute novalidate when novalidate=true', async () => {
const wrapper = mount(BDropdownForm, {
- context: {
- props: { novalidate: true }
- }
+ propsData: { novalidate: true }
})
expect(wrapper.is('li')).toBe(true)
diff --git a/src/components/dropdown/dropdown.js b/src/components/dropdown/dropdown.js
index fb6e4fe4542..5fb3830986b 100644
--- a/src/components/dropdown/dropdown.js
+++ b/src/components/dropdown/dropdown.js
@@ -1,6 +1,7 @@
import Vue from '../../utils/vue'
import { stripTags } from '../../utils/html'
import { getComponentConfig } from '../../utils/config'
+import { HTMLElement } from '../../utils/safe-types'
import idMixin from '../../mixins/id'
import dropdownMixin from '../../mixins/dropdown'
import normalizeSlotMixin from '../../mixins/normalize-slot'
@@ -60,8 +61,8 @@ export const props = {
},
boundary: {
// String: `scrollParent`, `window` or `viewport`
- // Object: HTML Element reference
- type: [String, Object],
+ // HTMLElement: HTML Element reference
+ type: [String, HTMLElement],
default: 'scrollParent'
}
}
@@ -73,40 +74,33 @@ export default Vue.extend({
props,
computed: {
dropdownClasses() {
- // Position `static` is needed to allow menu to "breakout" of the scrollParent boundaries
- // when boundary is anything other than `scrollParent`
- // See https://github.com/twbs/bootstrap/issues/24251#issuecomment-341413786
- const positionStatic = this.boundary !== 'scrollParent' || !this.boundary
-
return [
- 'btn-group',
- 'b-dropdown',
- 'dropdown',
this.directionClass,
{
show: this.visible,
- 'position-static': positionStatic
+ // Position `static` is needed to allow menu to "breakout" of the scrollParent boundaries
+ // when boundary is anything other than `scrollParent`
+ // See https://github.com/twbs/bootstrap/issues/24251#issuecomment-341413786
+ 'position-static': this.boundary !== 'scrollParent' || !this.boundary
}
]
},
menuClasses() {
return [
- 'dropdown-menu',
+ this.menuClass,
{
'dropdown-menu-right': this.right,
show: this.visible
- },
- this.menuClass
+ }
]
},
toggleClasses() {
return [
- 'dropdown-toggle',
+ this.toggleClass,
{
'dropdown-toggle-split': this.split,
'dropdown-toggle-no-caret': this.noCaret && !this.split
- },
- this.toggleClass
+ }
]
}
},
@@ -149,6 +143,7 @@ export default Vue.extend({
BButton,
{
ref: 'toggle',
+ staticClass: 'dropdown-toggle',
class: this.toggleClasses,
props: {
variant: this.variant,
@@ -172,6 +167,7 @@ export default Vue.extend({
'ul',
{
ref: 'menu',
+ staticClass: 'dropdown-menu',
class: this.menuClasses,
attrs: {
role: this.role,
@@ -179,16 +175,19 @@ export default Vue.extend({
'aria-labelledby': this.safeId(this.split ? '_BV_button_' : '_BV_toggle_')
},
on: {
- mouseover: this.onMouseOver,
- keydown: this.onKeydown // tab, up, down, esc
+ keydown: this.onKeydown // up, down, esc
}
},
- this.normalizeSlot('default')
+ this.normalizeSlot('default', { hide: this.hide })
+ )
+ return h(
+ 'div',
+ {
+ staticClass: 'dropdown btn-group b-dropdown',
+ class: this.dropdownClasses,
+ attrs: { id: this.safeId() }
+ },
+ [split, toggle, menu]
)
- return h('div', { attrs: { id: this.safeId() }, class: this.dropdownClasses }, [
- split,
- toggle,
- menu
- ])
}
})
diff --git a/src/components/nav/README.md b/src/components/nav/README.md
index ff6be39f245..dcc025dbcc9 100644
--- a/src/components/nav/README.md
+++ b/src/components/nav/README.md
@@ -204,6 +204,16 @@ add them (like above) which will produce something like:
Refer to [``](/docs/components/dropdown) for a list of supported sub-components.
+### Optionally scoped default slot
+
+NEW in 2.0.0-rc.20
+
+The dropdown default slot is optionally scoped with the following scope available:
+
+| Property or Method | Description |
+| ------------------ | ------------------------------------------------------------------------------------------------------------------------------- |
+| `hide()` | Can be used to close the dropdown menu. Accepts an optional boolean argument, which if `true` returns focus to the toggle button |
+
## Using in navbar
Prop `is-nav-bar` has been deprecated and will be removed in a future release.
diff --git a/src/components/nav/nav-item-dropdown.js b/src/components/nav/nav-item-dropdown.js
index 965d97331c1..a243932ffb6 100644
--- a/src/components/nav/nav-item-dropdown.js
+++ b/src/components/nav/nav-item-dropdown.js
@@ -38,36 +38,23 @@ export default Vue.extend({
return true
},
dropdownClasses() {
- return [
- 'nav-item',
- 'b-nav-dropdown',
- 'dropdown',
- this.directionClass,
- {
- show: this.visible
- }
- ]
+ return [this.directionClass, { show: this.visible }]
},
menuClasses() {
return [
- 'dropdown-menu',
+ this.extraMenuClasses, // Deprecated
+ this.menuClass,
{
'dropdown-menu-right': this.right,
show: this.visible
- },
- this.extraMenuClasses, // Deprecated
- this.menuClass
+ }
]
},
toggleClasses() {
return [
- 'nav-link',
- 'dropdown-toggle',
- {
- 'dropdown-toggle-no-caret': this.noCaret
- },
this.extraToggleClasses, // Deprecated
- this.toggleClass
+ this.toggleClass,
+ { 'dropdown-toggle-no-caret': this.noCaret }
]
}
},
@@ -75,8 +62,9 @@ export default Vue.extend({
const button = h(
'a',
{
- class: this.toggleClasses,
ref: 'toggle',
+ staticClass: 'nav-link dropdown-toggle',
+ class: this.toggleClasses,
attrs: {
href: '#',
id: this.safeId('_BV_button_'),
@@ -98,6 +86,7 @@ export default Vue.extend({
const menu = h(
'ul',
{
+ staticClass: 'dropdown-menu',
class: this.menuClasses,
ref: 'menu',
attrs: {
@@ -105,12 +94,19 @@ export default Vue.extend({
'aria-labelledby': this.safeId('_BV_button_')
},
on: {
- mouseover: this.onMouseOver,
- keydown: this.onKeydown // tab, up, down, esc
+ keydown: this.onKeydown // up, down, esc
}
},
- [this.normalizeSlot('default')]
+ [this.normalizeSlot('default', { hide: this.hide })]
+ )
+ return h(
+ 'li',
+ {
+ staticClass: 'nav-item b-nav-dropdown dropdown',
+ class: this.dropdownClasses,
+ attrs: { id: this.safeId() }
+ },
+ [button, menu]
)
- return h('li', { attrs: { id: this.safeId() }, class: this.dropdownClasses }, [button, menu])
}
})
diff --git a/src/mixins/dropdown.js b/src/mixins/dropdown.js
index 0a5f955e8e6..6a299a4a584 100644
--- a/src/mixins/dropdown.js
+++ b/src/mixins/dropdown.js
@@ -2,22 +2,22 @@ import Popper from 'popper.js'
import BvEvent from '../utils/bv-event.class'
import KeyCodes from '../utils/key-codes'
import warn from '../utils/warn'
-import { closest, contains, getAttr, isVisible, selectAll } from '../utils/dom'
+import { closest, contains, isVisible, selectAll } from '../utils/dom'
import { isNull } from '../utils/inspect'
import clickOutMixin from './click-out'
import focusInMixin from './focus-in'
// Return an Array of visible items
-function filterVisible(els) {
+function filterVisibles(els) {
return (els || []).filter(isVisible)
}
// Dropdown item CSS selectors
-// TODO: .dropdown-form handling
const Selector = {
FORM_CHILD: '.dropdown form',
- NAVBAR_NAV: '.navbar-nav',
- ITEM_SELECTOR: '.dropdown-item:not(.disabled):not([disabled])'
+ ITEM_SELECTOR: ['.dropdown-item', '.b-dropdown-form']
+ .map(selector => `${selector}:not(.disabled):not([disabled])`)
+ .join(', ')
}
// Popper attachment positions
@@ -190,6 +190,7 @@ export default {
// Are we in a navbar ?
if (isNull(this.inNavbar) && this.isNav) {
+ // We should use an injection for this
/* istanbul ignore next */
this.inNavbar = Boolean(closest('.navbar', this.$el))
}
@@ -346,10 +347,6 @@ export default {
if (key === KeyCodes.ESC) {
// Close on ESC
this.onEsc(evt)
- } else if (key === KeyCodes.TAB) {
- // Close on tab out
- /* istanbul ignore next: not used and should be removed */
- this.onTab(evt)
} else if (key === KeyCodes.DOWN) {
// Down Arrow
this.focusNext(evt, false)
@@ -367,14 +364,6 @@ export default {
this.$once('hidden', this.focusToggler)
}
},
- onTab(evt) /* istanbul ignore next: not easy to test */ {
- // TODO: Need special handler for dealing with form inputs
- // Tab, if in a text-like input, we should just focus next item in the dropdown
- // Note: Inputs are in a special .dropdown-form container
- },
- onMouseOver(evt) /* istanbul ignore next: not easy to test */ {
- // Removed mouseover focus handler
- },
// Document click out listener
clickOutHandler() {
if (this.visible) {
@@ -394,7 +383,8 @@ export default {
},
// Keyboard nav
focusNext(evt, up) {
- if (!this.visible) {
+ if (!this.visible || (evt && closest(Selector.FORM_CHILD, evt.target))) {
+ // Ignore key up/down on form elements
/* istanbul ignore next: should never happen */
return
}
@@ -421,13 +411,13 @@ export default {
},
focusItem(idx, items) {
let el = items.find((el, i) => i === idx)
- if (el && getAttr(el, 'tabindex') !== '-1') {
+ if (el && el.focus) {
el.focus()
}
},
getItems() {
// Get all items
- return filterVisible(selectAll(Selector.ITEM_SELECTOR, this.$refs.menu))
+ return filterVisibles(selectAll(Selector.ITEM_SELECTOR, this.$refs.menu))
},
focusMenu() {
this.$refs.menu.focus && this.$refs.menu.focus()