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()