Skip to content

Commit

Permalink
[combo-box / dropdown] Improve accessibility (#11421)
Browse files Browse the repository at this point in the history
### Related Ticket(s)

Closes #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 9c94338 commit 1c854c0
Show file tree
Hide file tree
Showing 9 changed files with 134 additions and 370 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';

This comment has been minimized.

Copy link
@gyalogmixi

gyalogmixi Mar 14, 2024

Contributor

@m4olivei @kennylam .js extension is missing from ifDefined import.

This comment has been minimized.

Copy link
@m4olivei

m4olivei Mar 14, 2024

Author Contributor

Hi @gyalogmixi . My understanding is that bundlers will append that as necessary. Is this the cause of an issue for you? If so, could you file an issue with steps to reproduce?

This comment has been minimized.

Copy link
@gyalogmixi

gyalogmixi Mar 14, 2024

Contributor

I'm using laravel-mix to compile my assets and it fails here.
image

And all the other iDefined imports throughout the packages are called like this.
import { ifDefined } from 'lit/directives/if-defined.js';
If I call it like that, everything is good.

This comment has been minimized.

Copy link
@m4olivei

m4olivei Mar 15, 2024

Author Contributor

Ahh, I see. Makes sense to me to update that. I see you filed an issue: #11635.

I can throw up a quick PR for that.

This comment has been minimized.

Copy link
@m4olivei

m4olivei Mar 15, 2024

Author Contributor

Heres a PR: #11639. Thanks for reporting!

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
129 changes: 60 additions & 69 deletions packages/carbon-web-components/src/components/dropdown/dropdown.ts
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 1c854c0

Please sign in to comment.