From 3f183ff1b93d6aa6d966f5164722ac7c50685dd3 Mon Sep 17 00:00:00 2001 From: Elizabeth Judd Date: Mon, 5 Aug 2019 18:52:31 -0400 Subject: [PATCH] Dropdown a11y updates (#3586) --- .../src/components/dropdown/_dropdown.scss | 26 +- .../components/dropdown/dropdown.config.js | 20 ++ .../src/components/dropdown/dropdown.hbs | 188 ++++++-------- .../src/components/dropdown/dropdown.js | 127 +++++++-- .../components/tests/spec/dropdown_spec.js | 245 +++++++++++++++++- 5 files changed, 475 insertions(+), 131 deletions(-) diff --git a/packages/components/src/components/dropdown/_dropdown.scss b/packages/components/src/components/dropdown/_dropdown.scss index e2a37597863d..ba36a515d32e 100644 --- a/packages/components/src/components/dropdown/_dropdown.scss +++ b/packages/components/src/components/dropdown/_dropdown.scss @@ -122,6 +122,18 @@ transform-origin: 50% 45%; } + button.#{$prefix}--dropdown-text { + // button-reset mixin contradicts with bx--dropdown-text styles + background: none; + border: none; + width: 100%; + text-align: left; + + &:focus { + @include focus-outline('outline'); + } + } + .#{$prefix}--dropdown-text { @include type-style('body-short-01'); display: block; @@ -137,6 +149,7 @@ .#{$prefix}--dropdown-list { @include reset; + @include focus-outline('reset'); @include layer('overlay'); @include type-style('body-short-01'); background-color: $ui-01; @@ -200,18 +213,19 @@ overflow: hidden; white-space: nowrap; - &:focus { - @include focus-outline('outline'); - margin: 0; - padding: rem(11px) rem(16px); - } - &:hover { color: $text-01; border-color: transparent; } } + .#{$prefix}--dropdown--focused, + .#{$prefix}--dropdown-link:focus { + @include focus-outline('outline'); + margin: 0; + padding: rem(11px) rem(16px); + } + .#{$prefix}--dropdown-item:hover .#{$prefix}--dropdown-link { border-bottom-color: $hover-ui; } diff --git a/packages/components/src/components/dropdown/dropdown.config.js b/packages/components/src/components/dropdown/dropdown.config.js index 67f268b2be3e..c43c2fbe5865 100644 --- a/packages/components/src/components/dropdown/dropdown.config.js +++ b/packages/components/src/components/dropdown/dropdown.config.js @@ -40,6 +40,26 @@ const items = [ module.exports = { context: { prefix, + default: { + idSuffix: `example-${Math.random() + .toString(36) + .substr(2)}`, + }, + helper: { + idSuffix: `example-${Math.random() + .toString(36) + .substr(2)}`, + }, + disabled: { + idSuffix: `example-${Math.random() + .toString(36) + .substr(2)}`, + }, + invalid: { + idSuffix: `example-${Math.random() + .toString(36) + .substr(2)}`, + }, }, variants: [ { diff --git a/packages/components/src/components/dropdown/dropdown.hbs b/packages/components/src/components/dropdown/dropdown.hbs index e030947d73a0..a5a22112931a 100644 --- a/packages/components/src/components/dropdown/dropdown.hbs +++ b/packages/components/src/components/dropdown/dropdown.hbs @@ -4,150 +4,120 @@ This source code is licensed under the Apache-2.0 license found in the LICENSE file in the root directory of this source tree. --> -
- + Dropdown label {{#unless inline}}
Optional helper text.
{{/unless}} - +
+ + +
{{#unless inline}}
- + Dropdown label
Optional helper text here; if message is more than one line text should wrap (~100 character count maximum)
- +
+ + +
{{/unless}}
- + Dropdown label {{#unless inline}} -
Optional helper text +
Optional helper text here; if message is more than one line text should wrap (~100 character count maximum)
{{/unless}} - +
+ + + +
-
- +
+ Dropdown label {{#unless inline}}
Optional helper text here; if message is more than one line text should wrap (~100 character count maximum)
{{/unless}} - - {{#if inline}} + data-invalid> + + + {{#if inline}} +
+ This is not a validation message +
+ {{/if}} +
+ {{#unless inline}}
This is not a validation message
- {{/if}} -
- {{#unless inline}} -
- This is not a validation message + {{/unless}}
- {{/unless}}
diff --git a/packages/components/src/components/dropdown/dropdown.js b/packages/components/src/components/dropdown/dropdown.js index 5ea6b6b51031..2aae903315c2 100644 --- a/packages/components/src/components/dropdown/dropdown.js +++ b/packages/components/src/components/dropdown/dropdown.js @@ -81,6 +81,8 @@ class Dropdown extends mixin( /** * Opens and closes the dropdown menu. * @param {Event} [event] The event triggering this method. + * + * @todo https://github.com/carbon-design-system/carbon/issues/3641 */ _toggle(event) { const isDisabled = this.element.classList.contains( @@ -91,35 +93,93 @@ class Dropdown extends mixin( return; } + const triggerNode = this.element.querySelector( + this.options.selectorTrigger + ); + if ( - ([13, 32, 40].indexOf(event.which) >= 0 && + // User presses down arrow + (event.which === 40 && + !event.target.matches(this.options.selectorItem)) || + // User presses space or enter and the trigger is not a button + (!triggerNode && + [13, 32].indexOf(event.which) >= 0 && !event.target.matches(this.options.selectorItem)) || + // User presses esc event.which === 27 || + // User clicks event.type === 'click' ) { const isOpen = this.element.classList.contains(this.options.classOpen); const isOfSelf = this.element.contains(event.target); + // Determine if the open className should be added, removed, or toggled const actions = { add: isOfSelf && event.which === 40 && !isOpen, remove: (!isOfSelf || event.which === 27) && isOpen, toggle: isOfSelf && event.which !== 27 && event.which !== 40, }; + let changedState = false; Object.keys(actions).forEach(action => { if (actions[action]) { + changedState = true; this.element.classList[action](this.options.classOpen); - this.element.focus(); } }); + const listItems = toArray( this.element.querySelectorAll(this.options.selectorItem) ); - listItems.forEach(item => { - if (this.element.classList.contains(this.options.classOpen)) { - item.tabIndex = 0; - } else { - item.tabIndex = -1; + // only want to grab the listNode IF it's using the latest a11y HTML structure + const listNode = triggerNode + ? this.element.querySelector(this.options.selectorMenu) + : null; + + // @todo remove conditionals for elements existing once legacy structure is depreciated + if ( + changedState && + this.element.classList.contains(this.options.classOpen) + ) { + // toggled open + if (triggerNode) { + triggerNode.setAttribute('aria-expanded', 'true'); } - }); + (listNode || this.element).focus(); + if (listNode) { + const selectedNode = listNode.querySelector( + this.options.selectorItemSelected + ); + listNode.setAttribute( + 'aria-activedescendant', + (selectedNode || listItems[0]).id + ); + (selectedNode || listItems[0]).classList.add( + this.options.classFocused + ); + } + } else if (changedState && (isOfSelf || actions.remove)) { + // toggled close + (triggerNode || this.element).focus(); + if (triggerNode) { + triggerNode.setAttribute('aria-expanded', 'false'); + } + if (listNode) { + listNode.removeAttribute('aria-activedescendant'); + this.element + .querySelector(this.options.selectorItemFocused) + .classList.remove(this.options.classFocused); + } + } + + // @todo remove once legacy structure is depreciated + if (!triggerNode) { + listItems.forEach(item => { + if (this.element.classList.contains(this.options.classOpen)) { + item.tabIndex = 0; + } else { + item.tabIndex = -1; + } + }); + } } } @@ -127,17 +187,31 @@ class Dropdown extends mixin( * @returns {Element} Currently highlighted element. */ getCurrentNavigation() { - const focused = this.element.ownerDocument.activeElement; - return focused.nodeType === Node.ELEMENT_NODE && - focused.matches(this.options.selectorItem) - ? focused - : null; + let focusedNode; + + // Using the latest semantic markup structure where trigger is a button + // @todo remove conditional once legacy structure is depreciated + if (this.element.querySelector(this.options.selectorTrigger)) { + const listNode = this.element.querySelector(this.options.selectorMenu); + const focusedId = listNode.getAttribute('aria-activedescendant'); + focusedNode = focusedId ? listNode.querySelector(`#${focusedId}`) : null; + } else { + const focused = this.element.ownerDocument.activeElement; + focusedNode = + focused.nodeType === Node.ELEMENT_NODE && + focused.matches(this.options.selectorItem) + ? focused + : null; + } + + return focusedNode; } /** * Moves up/down the focus. * @param {number} direction The direction of navigating. */ + // @todo create issue it's a better UX to move the focus when the user hovers so they stay in sync navigate(direction) { const items = toArray( this.element.querySelectorAll(this.options.selectorItem) @@ -164,7 +238,21 @@ class Dropdown extends mixin( !current.parentNode.matches(this.options.selectorItemHidden) && !current.matches(this.options.selectorItemSelected) ) { - current.focus(); + // Using the latest semantic markup structure where trigger is a button + // @todo remove conditional once legacy structure is depreciated + if (this.element.querySelector(this.options.selectorTrigger)) { + const listNode = this.element.querySelector( + this.options.selectorMenu + ); + const previouslyFocused = listNode.querySelector( + this.options.selectorItemFocused + ); + current.classList.add(this.options.classFocused); + listNode.setAttribute('aria-activedescendant', current.id); + previouslyFocused.classList.remove(this.options.classFocused); + } else { + current.focus(); + } break; } } @@ -187,6 +275,7 @@ class Dropdown extends mixin( if (this.element.dispatchEvent(eventStart)) { if (this.element.dataset.dropdownType !== 'navigation') { const selectorText = + !this.element.querySelector(this.options.selectorTrigger) && this.element.dataset.dropdownType !== 'inline' ? this.options.selectorText : this.options.selectorTextInner; @@ -233,10 +322,12 @@ class Dropdown extends mixin( /** * The component options. * If `options` is specified in the constructor, {@linkcode Dropdown.create .create()}, or {@linkcode Dropdown.init .init()}, - * properties in this object are overriden for the instance being create and how {@linkcode Dropdown.init .init()} works. + * properties in this object are overridden for the instance being create and how {@linkcode Dropdown.init .init()} works. * @member Dropdown.options * @type {object} * @property {string} selectorInit The CSS selector to find selectors. + * @property {string} [selectorTrigger] The CSS selector to find trigger button when using a11y compliant markup. + * @property {string} [selectorMenu] The CSS selector to find menu list when using a11y compliant markup. * @property {string} [selectorText] The CSS selector to find the element showing the selected item. * @property {string} [selectorTextInner] The CSS selector to find the element showing the selected item, used for inline mode. * @property {string} [selectorItem] The CSS selector to find clickable areas in dropdown items. @@ -244,7 +335,9 @@ class Dropdown extends mixin( * The CSS selector to find hidden dropdown items. * Used to skip dropdown items for keyboard navigation. * @property {string} [selectorItemSelected] The CSS selector to find the clickable area in the selected dropdown item. + * @property {string} [selectorItemFocused] The CSS selector to find the clickable area in the focused dropdown item. * @property {string} [classSelected] The CSS class for the selected dropdown item. + * @property {string} [classFocused] The CSS class for the focused dropdown item. * @property {string} [classOpen] The CSS class for the open state. * @property {string} [classDisabled] The CSS class for the disabled state. * @property {string} [eventBeforeSelected] @@ -256,12 +349,16 @@ class Dropdown extends mixin( const { prefix } = settings; return { selectorInit: '[data-dropdown]', + selectorTrigger: `button.${prefix}--dropdown-text`, + selectorMenu: `.${prefix}--dropdown-list`, selectorText: `.${prefix}--dropdown-text`, selectorTextInner: `.${prefix}--dropdown-text__inner`, selectorItem: `.${prefix}--dropdown-link`, selectorItemSelected: `.${prefix}--dropdown--selected`, + selectorItemFocused: `.${prefix}--dropdown--focused`, selectorItemHidden: `[hidden],[aria-hidden="true"]`, classSelected: `${prefix}--dropdown--selected`, + classFocused: `${prefix}--dropdown--focused`, classOpen: `${prefix}--dropdown--open`, classDisabled: `${prefix}--dropdown--disabled`, eventBeforeSelected: 'dropdown-beingselected', diff --git a/packages/components/tests/spec/dropdown_spec.js b/packages/components/tests/spec/dropdown_spec.js index 1a4dfd39861f..c7fc175b6040 100644 --- a/packages/components/tests/spec/dropdown_spec.js +++ b/packages/components/tests/spec/dropdown_spec.js @@ -29,6 +29,7 @@ describe('Dropdown', function() { }); }); + // test backwards compatibility with legacy markup describe('Toggle', function() { let dropdown; let element; @@ -133,7 +134,7 @@ describe('Dropdown', function() { expect(element.focus, 'Focus requested').toHaveBeenCalledTimes(1); }); - it('Shouldn not close dropdown with space key on an item', function() { + it('Should not close dropdown with space key on an item', function() { spyOn(element, 'focus'); element.classList.add('bx--dropdown--open'); itemNode.dispatchEvent( @@ -208,6 +209,79 @@ describe('Dropdown', function() { }); }); + describe('Toggle with semantic markup', function() { + let dropdown; + let element; + let itemNode; + let trigger; + let list; + + beforeAll(function() { + element = document.createElement('div'); + element.classList.add('bx--dropdown'); + + trigger = element.appendChild(document.createElement('button')); + trigger.classList.add('bx--dropdown-text'); + list = element.appendChild(document.createElement('ul')); + list.classList.add('bx--dropdown-list'); + + const itemContainerNode = document.createElement('li'); + itemContainerNode.dataset.option = ''; + + itemNode = document.createElement('a'); + itemNode.id = 'item-0'; + itemNode.textContent = 0; + itemNode.classList.add('bx--dropdown-link'); + itemNode.classList.add('bx--dropdown--selected'); + + itemContainerNode.appendChild(itemNode); + list.appendChild(itemContainerNode); + + dropdown = new Dropdown(element); + document.body.appendChild(element); + }); + + it('Should add "open" stateful modifier class', function() { + element.dispatchEvent(new CustomEvent('click', { bubbles: true })); + expect(element.classList.contains('bx--dropdown--open')).toBe(true); + expect(element.getAttribute('class')).toBe( + 'bx--dropdown bx--dropdown--open' + ); + }); + + it('Should setup active descendent when open', function() { + trigger.dispatchEvent(new CustomEvent('click', { bubbles: true })); + expect(list.getAttribute('aria-activedescendant')).toBe(itemNode.id); + expect(itemNode.classList.contains('bx--dropdown--focused')).toBe(true); + }); + + it('Should remove "open" stateful modifier class (closed default state)', function() { + element.classList.add('bx--dropdown--open'); + element.dispatchEvent(new CustomEvent('click', { bubbles: true })); + expect(element.classList.contains('bx--dropdown--open')).toBe(false); + expect(element.getAttribute('class')).toBe('bx--dropdown'); + }); + + it('Should remove active descendent setup when closed', function() { + // Open the dropdown + trigger.dispatchEvent(new CustomEvent('click', { bubbles: true })); + // Close the dropdown + trigger.dispatchEvent(new CustomEvent('click', { bubbles: true })); + expect(list.hasAttribute('aria-activedescendant')).toBe(false); + expect(itemNode.classList.contains('bx--dropdown--focused')).toBe(false); + }); + + afterEach(function() { + element.classList.remove('bx--dropdown--disabled'); + element.classList.remove('bx--dropdown--open'); + }); + + afterAll(function() { + dropdown.release(); + document.body.removeChild(element); + }); + }); + describe('Selecting an item', function() { let dropdown; let element; @@ -322,6 +396,7 @@ describe('Dropdown', function() { }); }); + // test backwards compatibility with legacy markup describe('Navigating focus', function() { let dropdown; let element; @@ -607,6 +682,174 @@ describe('Dropdown', function() { }); }); + describe('Navigating focus with semantic markup', function() { + let dropdown; + let element; + let itemNodes; + let list; + let trigger; + + const events = new EventManager(); + + beforeAll(function() { + element = document.createElement('div'); + element.classList.add('bx--dropdown'); + + trigger = element.appendChild(document.createElement('button')); + trigger.classList.add('bx--dropdown-text'); + list = element.appendChild(document.createElement('ul')); + list.classList.add('bx--dropdown-list'); + + itemNodes = [...new Array(3)].map((item, i) => { + const itemContainerNode = document.createElement('li'); + itemContainerNode.dataset.option = ''; + + const itemNode = document.createElement('a'); + itemNode.textContent = i; + itemNode.classList.add('bx--dropdown-link'); + itemNode.id = `item-${i}`; + + itemContainerNode.appendChild(itemNode); + list.appendChild(itemContainerNode); + return itemNode; + }); + + dropdown = new Dropdown(element); + document.body.appendChild(element); + }); + + beforeEach(function() { + itemNodes.forEach(item => { + item.classList.remove('bx--dropdown--selected'); + item.classList.remove('bx--dropdown--focused'); + item.removeAttribute('hidden'); + item.parentNode.removeAttribute('hidden'); + item.removeAttribute('aria-hidden'); + item.parentNode.removeAttribute('aria-hidden'); + }); + }); + + it('Should focus the first item with no selection', function() { + trigger.click(); + expect(list.getAttribute('aria-activedescendant')).toBe(itemNodes[0].id); + expect( + itemNodes[0].classList.contains('bx--dropdown--focused'), + 'Focus on 1st item' + ).toBe(true); + expect( + itemNodes[1].classList.contains('bx--dropdown--focused'), + 'Focus on 2nd item' + ).toBe(false); + expect( + itemNodes[2].classList.contains('bx--dropdown--focused'), + 'Focus on 3rd item' + ).toBe(false); + }); + + it('Should start with selection for forward navigation', function() { + itemNodes[0].classList.add('bx--dropdown--selected'); + trigger.click(); + const defaultPrevented = !element.dispatchEvent( + Object.assign(new CustomEvent('keydown', { cancelable: true }), { + which: 40, + }) + ); + expect(defaultPrevented, 'Canceling event').toBe(true); + expect(list.getAttribute('aria-activedescendant')).toBe(itemNodes[1].id); + expect( + itemNodes[0].classList.contains('bx--dropdown--focused'), + 'Focus on 1st item' + ).toBe(false); + expect( + itemNodes[1].classList.contains('bx--dropdown--focused'), + 'Focus on 2nd item' + ).toBe(true); + expect( + itemNodes[2].classList.contains('bx--dropdown--focused'), + 'Focus on 3rd item' + ).toBe(false); + }); + + it('Should start with selection for backward navigation', function() { + itemNodes[2].classList.add('bx--dropdown--selected'); + trigger.click(); + const defaultPrevented = !element.dispatchEvent( + Object.assign(new CustomEvent('keydown', { cancelable: true }), { + which: 38, + }) + ); + expect(defaultPrevented, 'Canceling event').toBe(true); + expect(list.getAttribute('aria-activedescendant')).toBe(itemNodes[1].id); + expect( + itemNodes[0].classList.contains('bx--dropdown--focused'), + 'Focus on 1st item' + ).toBe(false); + expect( + itemNodes[1].classList.contains('bx--dropdown--focused'), + 'Focus on 2nd item' + ).toBe(true); + expect( + itemNodes[2].classList.contains('bx--dropdown--focused'), + 'Focus on 3rd item' + ).toBe(false); + }); + + it('Should handle overflow for forward navigation', function() { + itemNodes[2].classList.add('bx--dropdown--selected'); + trigger.click(); + const defaultPrevented = !element.dispatchEvent( + Object.assign(new CustomEvent('keydown', { cancelable: true }), { + which: 40, + }) + ); + expect(defaultPrevented, 'Canceling event').toBe(true); + expect(list.getAttribute('aria-activedescendant')).toBe(itemNodes[0].id); + expect( + itemNodes[0].classList.contains('bx--dropdown--focused'), + 'Focus on 1st item' + ).toBe(true); + expect( + itemNodes[1].classList.contains('bx--dropdown--focused'), + 'Focus on 2nd item' + ).toBe(false); + expect( + itemNodes[2].classList.contains('bx--dropdown--focused'), + 'Focus on 3rd item' + ).toBe(false); + }); + + it('Should handle underflow for backward navigation', function() { + itemNodes[0].classList.add('bx--dropdown--selected'); + trigger.click(); + element.dispatchEvent( + Object.assign(new CustomEvent('keydown'), { which: 38 }) + ); + expect(list.getAttribute('aria-activedescendant')).toBe(itemNodes[2].id); + expect( + itemNodes[0].classList.contains('bx--dropdown--focused'), + 'Focus on 1st item' + ).toBe(false); + expect( + itemNodes[1].classList.contains('bx--dropdown--focused'), + 'Focus on 2nd item' + ).toBe(false); + expect( + itemNodes[2].classList.contains('bx--dropdown--focused'), + 'Focus on 3rd item' + ).toBe(true); + }); + + afterEach(function() { + events.reset(); + trigger.click(); + }); + + afterAll(function() { + dropdown.release(); + document.body.removeChild(element); + }); + }); + describe('Close on blur', function() { let dropdown; let element;