Skip to content

Commit

Permalink
[combo-box / dropdown] Improve accessibility (carbon-design-system#11421
Browse files Browse the repository at this point in the history
)

### Related Ticket(s)

Closes carbon-design-system#11268
[Jira ticket](https://jsw.ibm.com/browse/ADCMS-4401)

### Description

Fixes accessibility issues with Combo-box, and by extension Dropdown.

Used both React package (which uses [Downshift](https://www.downshift-js.com/)), and [ARIA APG](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/) as references. Tested with VoiceOver on Mac OS.

### Testing

* Use both dropdown and combo-box components. Ensure there are no regressions for sighted users
* Using a screenreader, test both dropdown and combo-box components. Should work well. See [Select-Only Combobox Example](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/examples/combobox-select-only/) and [Editable Combobox With List Autocomplete Example](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/examples/combobox-autocomplete-list/) are great reference examples.
* Regression test the Multi select component (it extends the Dropdown component)

### Changelog

**Changed**

- Improved dropdown and combo-box accessibility.

<!-- React and Web Component deploy previews are enabled by default. -->
<!-- To enable additional available deploy previews, apply the following -->
<!-- labels for the corresponding package: -->
<!-- *** "test: e2e": Codesandbox examples and e2e integration tests -->
<!-- *** "package: services": Services -->
<!-- *** "package: utilities": Utilities -->
<!-- *** "RTL": React / Web Components (RTL) -->
<!-- *** "feature flag": React / Web Components (experimental) -->
  • Loading branch information
m4olivei authored Feb 29, 2024
1 parent 1ff5520 commit 24b6f2e
Show file tree
Hide file tree
Showing 5 changed files with 114 additions and 95 deletions.
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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';

Expand Down Expand Up @@ -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);
}
Expand All @@ -214,8 +217,10 @@ class CDSComboBox extends CDSDropdown {
disabled,
inputLabel,
label,
open,
readOnly,
value,
_activeDescendant: activeDescendant,
_filterInputValue: filterInputValue,
_handleInput: handleInput,
} = this;
Expand All @@ -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`
<input
id="trigger-label"
id="trigger-button"
class="${inputClasses}"
?disabled=${disabled}
placeholder="${label}"
.value=${filterInputValue}
role="combobox"
aria-label="${inputLabel}"
aria-label="${ifNonEmpty(inputLabel)}"
aria-controls="menu-body"
aria-haspopup="listbox"
aria-autocomplete="list"
aria-expanded="${String(open)}"
aria-activedescendant="${ifDefined(
open ? activeDescendant ?? activeDescendantFallback : ''
)}"
?readonly=${readOnly}
@input=${handleInput} />
`;
Expand Down Expand Up @@ -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 `<input>` for filtering.
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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`
Expand All @@ -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;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -143,6 +143,7 @@ export const WithLayer = () => {

export const Playground = (args) => {
const {
ariaLabel,
open,
direction,
disabled,
Expand All @@ -162,6 +163,7 @@ export const Playground = (args) => {

return html`
<cds-dropdown
aria-label=${ifDefined(ariaLabel)}
?open=${open}
?disabled="${disabled}"
?hide-label=${hideLabel}
Expand Down Expand Up @@ -191,6 +193,7 @@ export const Playground = (args) => {
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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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);
}
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 */
Expand Down Expand Up @@ -453,8 +436,10 @@ class CDSDropdown extends ValidityMixin(

return html`
<label
id="dropdown-label"
part="title-text"
class="${labelClasses}"
for="trigger-button"
?hidden="${!hasTitleText}">
<slot name="title-text" @slotchange="${handleSlotchangeLabelText}"
>${titleText}</slot
Expand Down Expand Up @@ -564,19 +549,6 @@ class CDSDropdown extends ValidityMixin(
@property({ attribute: 'required-validity-message' })
requiredValidityMessage = 'Please fill out this field.';

/**
* An assistive text for screen reader to announce, telling the open state.
*/
@property({ attribute: 'selecting-items-assistive-text' })
selectingItemsAssistiveText =
'Selecting items. Use up and down arrow keys to navigate.';

/**
* An assistive text for screen reader to announce, telling that an item is selected.
*/
@property({ attribute: 'selected-item-assistive-text' })
selectedItemAssistiveText = 'Selected an item.';

/**
* Dropdown size.
*/
Expand Down Expand Up @@ -724,7 +696,7 @@ class CDSDropdown extends ValidityMixin(
type,
warn,
warnText,
_assistiveStatusText: assistiveStatusText,
_activeDescendant: activeDescendant,
_shouldTriggerBeFocusable: shouldTriggerBeFocusable,
_handleClickInner: handleClickInner,
_handleKeydownInner: handleKeydownInner,
Expand All @@ -735,6 +707,13 @@ class CDSDropdown extends ValidityMixin(
} = this;
const inline = type === DROPDOWN_TYPE.INLINE;

let activeDescendantFallback: string | undefined;
if (open && !activeDescendant) {
const constructor = this.constructor as typeof CDSDropdown;
const items = this.querySelectorAll(constructor.selectorItem);
activeDescendantFallback = items[0]?.id;
}

const helperClasses = classMap({
[`${prefix}--form__helper-text`]: true,
[`${prefix}--form__helper-text--disabled`]: disabled,
Expand Down Expand Up @@ -764,38 +743,56 @@ class CDSDropdown extends ValidityMixin(
'aria-label': toggleLabel,
});
const helperMessage = invalid ? invalidText : warn ? warnText : helperText;
const menuBody = !open
? undefined
: html`
<div
aria-label="${ariaLabel}"
id="menu-body"
part="menu-body"
class="${prefix}--list-box__menu"
role="listbox"
tabindex="-1">
<slot></slot>
</div>
`;
const menuBody = html`
<div
aria-labelledby="${ifDefined(ariaLabel ? undefined : 'dropdown-label')}"
aria-label="${ifDefined(ariaLabel ? ariaLabel : undefined)}"
id="menu-body"
part="menu-body"
class="${prefix}--list-box__menu"
role="listbox"
tabindex="-1"
?hidden=${!open}>
<slot></slot>
</div>
`;
return html`
${this._renderTitleLabel()}
<div
role="listbox"
class="${classes}"
?data-invalid=${invalid}
@click=${handleClickInner}
@keydown=${handleKeydownInner}
@keypress=${handleKeypressInner}>
<div
part="trigger-button"
role="${ifDefined(!shouldTriggerBeFocusable ? undefined : 'button')}"
id="${ifDefined(
!shouldTriggerBeFocusable ? undefined : 'trigger-button'
)}"
class="${prefix}--list-box__field"
part="trigger-button"
tabindex="${ifDefined(!shouldTriggerBeFocusable ? undefined : '0')}"
aria-labelledby="trigger-label"
aria-expanded="${String(open)}"
aria-haspopup="listbox"
aria-owns="menu-body"
aria-controls="menu-body">
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()}
<div id="trigger-caret" class="${iconContainerClasses}">
${ChevronDown16({ 'aria-label': toggleLabel })}
Expand All @@ -812,13 +809,6 @@ class CDSDropdown extends ValidityMixin(
>${helperMessage}</slot
>
</div>
<div
class="${prefix}--assistive-text"
role="status"
aria-live="assertive"
aria-relevant="additions text">
${assistiveStatusText}
</div>
`;
}

Expand Down Expand Up @@ -889,6 +879,7 @@ class CDSDropdown extends ValidityMixin(
...LitElement.shadowRootOptions,
delegatesFocus: true,
};

static styles = styles;

/**
Expand Down
Loading

0 comments on commit 24b6f2e

Please sign in to comment.