Skip to content

Commit

Permalink
fix(material/chips): allow focusing disabled listbox options (#25771)
Browse files Browse the repository at this point in the history
Allow user to focus to disabled listbox options. Unlike other chips,
disabled `MatChipOption` remains in the tab order, but it cannot be
clicked. This aligns with WAI ARIA documented best practice to allow
focusing disabled listbox options. Fix issue where screen reader does
not announce disabled item in single selection list.

Summary of API and behavior changes:
 - when disabled, `MatChipOption` sets `aria-disabled="true"` and omits
   `disabled` attribute.
 - Add private `@Input _alowFocusWithDisabled` to `MatChipAction` to
   support focusing the action when it is disabled.
 - `MatChipSet` defines `_skipPredicate` as an instance member which
   dervied classes may override.

`_alowFocusWithDisabled` and `_skipPredicate` are internal API to the
chips.
  • Loading branch information
zarend authored Oct 13, 2022
1 parent e3a5603 commit 3f68996
Show file tree
Hide file tree
Showing 7 changed files with 63 additions and 8 deletions.
2 changes: 1 addition & 1 deletion src/dev-app/chips/chips-demo.html
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ <h4>Single selection</h4>
<mat-chip-listbox multiple="false" [disabled]="disabledListboxes">
<mat-chip-option>Extra Small</mat-chip-option>
<mat-chip-option>Small</mat-chip-option>
<mat-chip-option>Medium</mat-chip-option>
<mat-chip-option disabled>Medium</mat-chip-option>
<mat-chip-option>Large</mat-chip-option>
</mat-chip-listbox>

Expand Down
28 changes: 26 additions & 2 deletions src/material/chips/chip-action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@ const _MatChipActionMixinBase = mixinTabIndex(_MatChipActionBase, -1);
// in order to avoid some super-specific `:hover` styles from MDC.
'[class.mdc-evolution-chip__action--presentational]': '_isPrimary',
'[class.mdc-evolution-chip__action--trailing]': '!_isPrimary',
'[attr.tabindex]': '(disabled || !isInteractive) ? null : tabIndex',
'[attr.disabled]': "disabled ? '' : null",
'[attr.tabindex]': '_getTabindex()',
'[attr.disabled]': '_getDisabledAttribute()',
'[attr.aria-disabled]': 'disabled',
'(click)': '_handleClick($event)',
'(keydown)': '_handleKeydown($event)',
Expand All @@ -56,6 +56,30 @@ export class MatChipAction extends _MatChipActionMixinBase implements HasTabInde
}
private _disabled = false;

/**
* Private API to allow focusing this chip when it is disabled.
*/
@Input()
private _allowFocusWhenDisabled = false;

/**
* Determine the value of the disabled attribute for this chip action.
*/
protected _getDisabledAttribute(): string | null {
// When this chip action is disabled and focusing disabled chips is not permitted, return empty
// string to indicate that disabled attribute should be included.
return this.disabled && !this._allowFocusWhenDisabled ? '' : null;
}

/**
* Determine the value of the tabindex attribute for this chip action.
*/
protected _getTabindex(): string | null {
return (this.disabled && !this._allowFocusWhenDisabled) || !this.isInteractive
? null
: this.tabIndex.toString();
}

constructor(
public _elementRef: ElementRef<HTMLElement>,
@Inject(MAT_CHIP)
Expand Down
18 changes: 18 additions & 0 deletions src/material/chips/chip-listbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
*/

import {BooleanInput, coerceBooleanProperty} from '@angular/cdk/coercion';
import {MatChipAction} from './chip-action';
import {TAB} from '@angular/cdk/keycodes';
import {
AfterContentInit,
Expand Down Expand Up @@ -364,4 +365,21 @@ export class MatChipListbox
return this.selected;
}
}

/**
* Determines if key manager should avoid putting a given chip action in the tab index. Skip
* non-interactive actions since the user can't do anything with them.
*/
protected override _skipPredicate(action: MatChipAction): boolean {
// Override the skip predicate in the base class to avoid skipping disabled chips. Allow
// disabled chip options to receive focus to align with WAI ARIA recommendation. Normally WAI
// ARIA's instructions are to exclude disabled items from the tab order, but it makes a few
// exceptions for compound widgets.
//
// From [Developing a Keyboard Interface](
// https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/):
// "For the following composite widget elements, keep them focusable when disabled: Options in a
// Listbox..."
return !action.isInteractive;
}
}
2 changes: 1 addition & 1 deletion src/material/chips/chip-option.html
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
<button
matChipAction
[tabIndex]="tabIndex"
[disabled]="disabled"
[_allowFocusWhenDisabled]="true"
[attr.aria-selected]="ariaSelected"
[attr.aria-label]="ariaLabel"
role="option">
Expand Down
6 changes: 4 additions & 2 deletions src/material/chips/chip-option.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,10 @@ export class MatChipSelectionChange {
}

/**
* An extension of the MatChip component that supports chip selection.
* Used with MatChipListbox.
* An extension of the MatChip component that supports chip selection. Used with MatChipListbox.
*
* Unlike other chips, the user can focus on disabled chip options inside a MatChipListbox. The
* user cannot click disabled chips.
*/
@Component({
selector: 'mat-basic-chip-option, mat-chip-option',
Expand Down
13 changes: 11 additions & 2 deletions src/material/chips/chip-set.ts
Original file line number Diff line number Diff line change
Expand Up @@ -249,8 +249,7 @@ export class MatChipSet
.withVerticalOrientation()
.withHorizontalOrientation(this._dir ? this._dir.value : 'ltr')
.withHomeAndEnd()
// Skip non-interactive and disabled actions since the user can't do anything with them.
.skipPredicate(action => !action.isInteractive || action.disabled);
.skipPredicate(action => this._skipPredicate(action));

// Keep the manager active index in sync so that navigation picks
// up from the current chip if the user clicks into the list directly.
Expand All @@ -267,6 +266,16 @@ export class MatChipSet
.subscribe(direction => this._keyManager.withHorizontalOrientation(direction));
}

/**
* Determines if key manager should avoid putting a given chip action in the tab index. Skip
* non-interactive and disabled actions since the user can't do anything with them.
*/
protected _skipPredicate(action: MatChipAction): boolean {
// Skip chips that the user cannot interact with. `mat-chip-set` does not permit focusing disabled
// chips.
return !action.isInteractive || action.disabled;
}

/** Listens to changes in the chip set and syncs up the state of the individual chips. */
private _trackChipSetChanges() {
this._chips.changes.pipe(startWith(null), takeUntil(this._destroyed)).subscribe(() => {
Expand Down
2 changes: 2 additions & 0 deletions tools/public_api_guard/material/chips.md
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,7 @@ export class MatChipListbox extends MatChipSet implements AfterContentInit, OnDe
get selected(): MatChipOption[] | MatChipOption;
setDisabledState(isDisabled: boolean): void;
_setSelectionByValue(value: any, isUserInput?: boolean): void;
protected _skipPredicate(action: MatChipAction): boolean;
get value(): any;
set value(value: any);
// (undocumented)
Expand Down Expand Up @@ -449,6 +450,7 @@ export class MatChipSet extends _MatChipSetMixinBase implements AfterViewInit, H
protected _originatesFromChip(event: Event): boolean;
get role(): string | null;
set role(value: string | null);
protected _skipPredicate(action: MatChipAction): boolean;
protected _syncChipsState(): void;
// (undocumented)
static ɵcmp: i0.ɵɵComponentDeclaration<MatChipSet, "mat-chip-set", never, { "disabled": "disabled"; "role": "role"; }, {}, ["_chips"], ["*"], false, never>;
Expand Down

0 comments on commit 3f68996

Please sign in to comment.