diff --git a/packages/carbon-web-components/src/components/combo-box/combo-box.ts b/packages/carbon-web-components/src/components/combo-box/combo-box.ts index f4995d67e0b..47f1fb30701 100644 --- a/packages/carbon-web-components/src/components/combo-box/combo-box.ts +++ b/packages/carbon-web-components/src/components/combo-box/combo-box.ts @@ -1,7 +1,7 @@ /** * @license * - * Copyright IBM Corp. 2019, 2023 + * Copyright IBM Corp. 2019, 2024 * * This source code is licensed under the Apache-2.0 license found in the * LICENSE file in the root directory of this source tree. @@ -17,6 +17,8 @@ import CDSDropdown, { DROPDOWN_KEYBOARD_ACTION } from '../dropdown/dropdown'; import CDSComboBoxItem from './combo-box-item'; import styles from './combo-box.scss'; import { carbonElement as customElement } from '../../globals/decorators/carbon-element'; +import { ifDefined } from 'lit/directives/if-defined'; +import ifNonEmpty from '../../globals/directives/if-non-empty'; export { DROPDOWN_DIRECTION, DROPDOWN_SIZE } from '../dropdown/dropdown'; @@ -200,11 +202,12 @@ class CDSComboBox extends CDSDropdown { ), (item) => { (item as CDSComboBoxItem).selected = false; + item.setAttribute('aria-selected', 'false'); } ); if (itemToSelect) { itemToSelect.selected = true; - this._assistiveStatusText = this.selectedItemAssistiveText; + itemToSelect.setAttribute('aria-selected', 'true'); } this._handleUserInitiatedToggle(false); } @@ -214,8 +217,10 @@ class CDSComboBox extends CDSDropdown { disabled, inputLabel, label, + open, readOnly, value, + _activeDescendant: activeDescendant, _filterInputValue: filterInputValue, _handleInput: handleInput, } = this; @@ -225,17 +230,29 @@ class CDSComboBox extends CDSDropdown { [`${prefix}--text-input--empty`]: !value, }); + let activeDescendantFallback: string | undefined; + if (open && !activeDescendant) { + const constructor = this.constructor as typeof CDSDropdown; + const items = this.querySelectorAll(constructor.selectorItem); + activeDescendantFallback = items[0]?.id; + } + return html` `; @@ -268,7 +285,7 @@ class CDSComboBox extends CDSDropdown { * The `aria-label` attribute for the icon to clear selection. */ @property({ attribute: 'clear-selection-label' }) - clearSelectionLabel = ''; + clearSelectionLabel = 'Clear selection'; /** * The `aria-label` attribute for the `` for filtering. diff --git a/packages/carbon-web-components/src/components/dropdown/dropdown-item.ts b/packages/carbon-web-components/src/components/dropdown/dropdown-item.ts index 736770aabe3..8a29d6d5c81 100644 --- a/packages/carbon-web-components/src/components/dropdown/dropdown-item.ts +++ b/packages/carbon-web-components/src/components/dropdown/dropdown-item.ts @@ -1,7 +1,7 @@ /** * @license * - * Copyright IBM Corp. 2019, 2023 + * Copyright IBM Corp. 2019, 2024 * * This source code is licensed under the Apache-2.0 license found in the * LICENSE file in the root directory of this source tree. @@ -58,6 +58,21 @@ class CDSDropdownItem extends LitElement { @property() value = ''; + connectedCallback() { + super.connectedCallback(); + if (!this.hasAttribute('role')) { + this.setAttribute('role', 'option'); + } + if (!this.hasAttribute('id')) { + this.setAttribute( + 'id', + `${prefix}-dropdown-item-${(this.constructor as typeof CDSDropdownItem) + .id++}` + ); + } + this.setAttribute('aria-selected', String(this.selected)); + } + render() { const { selected } = this; return html` @@ -73,6 +88,13 @@ class CDSDropdownItem extends LitElement { `; } + /** + * Store an identifier for use in composing this item's id. + * + * Auto-increments anytime a new dropdown-item appears. + */ + static id = 0; + static styles = styles; } diff --git a/packages/carbon-web-components/src/components/dropdown/dropdown-story.ts b/packages/carbon-web-components/src/components/dropdown/dropdown-story.ts index b02a7dbb52d..e6870404f4a 100644 --- a/packages/carbon-web-components/src/components/dropdown/dropdown-story.ts +++ b/packages/carbon-web-components/src/components/dropdown/dropdown-story.ts @@ -1,7 +1,7 @@ /** * @license * - * Copyright IBM Corp. 2019, 2023 + * Copyright IBM Corp. 2019, 2024 * * This source code is licensed under the Apache-2.0 license found in the * LICENSE file in the root directory of this source tree. @@ -143,6 +143,7 @@ export const WithLayer = () => { export const Playground = (args) => { const { + ariaLabel, open, direction, disabled, @@ -162,6 +163,7 @@ export const Playground = (args) => { return html` { Playground.parameters = { knobs: { [`${prefix}-dropdown`]: () => ({ + ariaLabel: textNullable('aria-label (aria-label)', ''), open: boolean('Open (open)', false), direction: select('Direction', directionOptions, null), disabled: boolean('Disabled (disabled)', false), diff --git a/packages/carbon-web-components/src/components/dropdown/dropdown.ts b/packages/carbon-web-components/src/components/dropdown/dropdown.ts index 6f1ebbc4425..a322a4e18ec 100644 --- a/packages/carbon-web-components/src/components/dropdown/dropdown.ts +++ b/packages/carbon-web-components/src/components/dropdown/dropdown.ts @@ -10,7 +10,7 @@ import { classMap } from 'lit/directives/class-map.js'; import { ifDefined } from 'lit/directives/if-defined.js'; import { LitElement, html, TemplateResult } from 'lit'; -import { property, query } from 'lit/decorators.js'; +import { property, query, state } from 'lit/decorators.js'; import { prefix } from '../../globals/settings'; import ChevronDown16 from '@carbon/icons/lib/chevron--down/16'; import WarningFilled16 from '@carbon/icons/lib/warning--filled/16'; @@ -71,10 +71,8 @@ class CDSDropdown extends ValidityMixin( */ protected _hasSlug = false; - /** - * The latest status of this dropdown, for screen reader to accounce. - */ - protected _assistiveStatusText?: string; + @state() + protected _activeDescendant?: string; /** * The content of the selected item. @@ -123,16 +121,18 @@ class CDSDropdown extends ValidityMixin( protected _selectionDidChange(itemToSelect?: CDSDropdownItem) { if (itemToSelect) { this.value = itemToSelect.value; + this._activeDescendant = itemToSelect.id; forEach( this.querySelectorAll( (this.constructor as typeof CDSDropdown).selectorItemSelected ), (item) => { (item as CDSDropdownItem).selected = false; + item.setAttribute('aria-selected', 'false'); } ); itemToSelect.selected = true; - this._assistiveStatusText = this.selectedItemAssistiveText; + itemToSelect.setAttribute('aria-selected', 'true'); this._handleUserInitiatedToggle(false); } } @@ -322,23 +322,7 @@ class CDSDropdown extends ValidityMixin( if (!disabled) { if (this.dispatchEvent(new CustomEvent(eventBeforeToggle, init))) { this.open = force; - if (this.open) { - this._assistiveStatusText = this.selectingItemsAssistiveText; - } else { - const { - selectedItemAssistiveText, - label, - _assistiveStatusText: assistiveStatusText, - _selectedItemContent: selectedItemContent, - } = this; - const selectedItemText = - (selectedItemContent && selectedItemContent.textContent) || label; - if ( - selectedItemText && - assistiveStatusText !== selectedItemAssistiveText - ) { - this._assistiveStatusText = selectedItemText; - } + if (!this.open) { forEach( this.querySelectorAll( (this.constructor as typeof CDSDropdown).selectorItemHighlighted @@ -401,11 +385,10 @@ class CDSDropdown extends ValidityMixin( // IE falls back to the old behavior. nextItem.scrollIntoView({ block: 'nearest' }); - const nextItemText = nextItem.textContent; - if (nextItemText) { - this._assistiveStatusText = nextItemText; + const nextItemId = nextItem.id; + if (nextItemId) { + this._activeDescendant = nextItemId; } - this.requestUpdate(); } /* eslint-disable class-methods-use-this */ @@ -453,8 +436,10 @@ class CDSDropdown extends ValidityMixin( return html` ${titleText} - - - `; + const menuBody = html` + + + + `; return html` ${this._renderTitleLabel()} + role="${ifDefined( + !shouldTriggerBeFocusable ? undefined : 'combobox' + )}" + aria-labelledby="${ifDefined( + !shouldTriggerBeFocusable ? undefined : 'dropdown-label' + )}" + aria-expanded="${ifDefined( + !shouldTriggerBeFocusable ? undefined : String(open) + )}" + aria-haspopup="${ifDefined( + !shouldTriggerBeFocusable ? undefined : 'listbox' + )}" + aria-controls="${ifDefined( + !shouldTriggerBeFocusable ? undefined : 'menu-body' + )}" + aria-activedescendant="${ifDefined( + !shouldTriggerBeFocusable + ? undefined + : open + ? activeDescendant ?? activeDescendantFallback + : '' + )}"> ${this._renderPrecedingLabel()}${this._renderLabel()}${validityIcon}${warningIcon}${this._renderFollowingLabel()} ${ChevronDown16({ 'aria-label': toggleLabel })} @@ -812,13 +809,6 @@ class CDSDropdown extends ValidityMixin( >${helperMessage} - - ${assistiveStatusText} - `; } @@ -889,6 +879,7 @@ class CDSDropdown extends ValidityMixin( ...LitElement.shadowRootOptions, delegatesFocus: true, }; + static styles = styles; /** diff --git a/packages/carbon-web-components/src/components/multi-select/multi-select.ts b/packages/carbon-web-components/src/components/multi-select/multi-select.ts index fbea4d53ba6..b4778ff3567 100644 --- a/packages/carbon-web-components/src/components/multi-select/multi-select.ts +++ b/packages/carbon-web-components/src/components/multi-select/multi-select.ts @@ -1,7 +1,7 @@ /** * @license * - * Copyright IBM Corp. 2020, 2023 + * Copyright IBM Corp. 2020, 2024 * * This source code is licensed under the Apache-2.0 license found in the * LICENSE file in the root directory of this source tree. @@ -96,9 +96,6 @@ class CDSMultiSelect extends CDSDropdown { protected _selectionDidChange(itemToSelect?: CDSMultiSelectItem) { if (itemToSelect) { itemToSelect.selected = !itemToSelect.selected; - this._assistiveStatusText = itemToSelect.selected - ? this.selectedItemAssistiveText - : this.unselectedItemAssistiveText; } else { forEach( this.querySelectorAll( @@ -109,7 +106,6 @@ class CDSMultiSelect extends CDSDropdown { } ); this._handleUserInitiatedToggle(false); - this._assistiveStatusText = this.unselectedAllAssistiveText; } // Change in `.selected` hasn't been reflected to the corresponding attribute yet this.value = filter( @@ -432,18 +428,6 @@ class CDSMultiSelect extends CDSDropdown { @property() locale = 'en'; - /** - * An assistive text for screen reader to announce, telling that an item is unselected. - */ - @property({ attribute: 'unselected-item-assistive-text' }) - unselectedItemAssistiveText = 'Unselected an item.'; - - /** - * An assistive text for screen reader to announce, telling that all items are unselected. - */ - @property({ attribute: 'unselected-all-assistive-text' }) - unselectedAllAssistiveText = 'Unselected all items.'; - /** * Specify feedback (mode) of the selection. * `top`: selected item jumps to top @@ -552,7 +536,8 @@ class CDSMultiSelect extends CDSDropdown { }); slug ? sortedMenuItems.unshift(slug as Node) : ''; - this.replaceChildren(...sortedMenuItems); + // @todo remove typecast once we've updated to Typescript. + (this as any).replaceChildren(...sortedMenuItems); } } if (changedProperties.has('open')) { @@ -566,7 +551,8 @@ class CDSMultiSelect extends CDSDropdown { }); slug ? sortedMenuItems.unshift(slug as Node) : ''; - this.replaceChildren(...sortedMenuItems); + // @todo remove typecast once we've updated to Typescript. + (this as any).replaceChildren(...sortedMenuItems); } } return true;