feat(chips): swap to toolbar a11y pattern

BREAKING CHANGE: chips now follow the [aria toolbar pattern](https://www.w3.org/WAI/ARIA/apg/patterns/toolbar/examples/toolbar/). Chip sets are toolbars and chips are buttons or links. Filter chips are toggle buttons.
What to change:
- Remove `type` attribute from `<md-chip-set>` (you can mix and match chip types!)
- Remove `single-select` from `<md-chip-set>`. Use JS to control filter chips if single selection is required. Radio filter chips will come in a future update.
- Disabled chips CAN be focused with the keyboard if `always-focusable` is set.
- Filter chips no longer dispatch a `"selected"` event. Listen to `"click"` and use `event.target.selected` instead.
- ArrowUp and ArrowDown no longer navigate between chips. These are reserved for chip actions, like dropdown menu chips.

PiperOrigin-RevId: 566352830
This commit is contained in:
Elizabeth Mitchell 2023-09-18 10:57:21 -07:00 committed by Copybara-Service
parent 1f31df818b
commit 16bfac1343
15 changed files with 297 additions and 440 deletions

View File

@ -17,7 +17,6 @@ const collection =
new Knob('label', {defaultValue: '', ui: textInput()}),
new Knob('elevated', {defaultValue: false, ui: boolInput()}),
new Knob('disabled', {defaultValue: false, ui: boolInput()}),
new Knob('singleSelect', {defaultValue: false, ui: boolInput()}),
new Knob('scrolling', {defaultValue: false, ui: boolInput()}),
]);

View File

@ -20,7 +20,6 @@ export interface StoryKnobs {
label: string;
elevated: boolean;
disabled: boolean;
singleSelect: boolean;
scrolling: boolean;
}
@ -44,14 +43,13 @@ const styles = css`
}
`;
const standard: MaterialStoryInit<StoryKnobs> = {
const assist: MaterialStoryInit<StoryKnobs> = {
name: 'Assist chips',
styles,
render({label, elevated, disabled, scrolling}) {
const classes = {scrolling};
const classes = {'scrolling': scrolling};
return html`
<md-chip-set type="assist" class=${classMap(classes)}
aria-label="Assist chips">
<md-chip-set class=${classMap(classes)} aria-label="Assist chips">
<md-assist-chip
label=${label || 'Assist chip'}
?disabled=${disabled}
@ -64,26 +62,18 @@ const standard: MaterialStoryInit<StoryKnobs> = {
>
<md-icon slot="icon">local_laundry_service</md-icon>
</md-assist-chip>
</md-chip-set>
`;
}
};
const links: MaterialStoryInit<StoryKnobs> = {
name: 'Assist link chips',
styles,
render({label, elevated, disabled, scrolling}) {
const classes = {scrolling};
return html`
<md-chip-set type="assist" class=${classMap(classes)}
aria-label="Assist link chips">
<md-assist-chip
label=${label || 'Assist link chip'}
?disabled=${disabled}
?elevated=${elevated}
href="https://google.com"
target="_blank"
>${GOOGLE_LOGO}</md-assist-chip>
<md-assist-chip
label=${label || 'Disabled assist chip (focusable)'}
disabled
always-focusable
?elevated=${elevated}
></md-assist-chip>
</md-chip-set>
`;
}
@ -92,12 +82,11 @@ const links: MaterialStoryInit<StoryKnobs> = {
const filters: MaterialStoryInit<StoryKnobs> = {
name: 'Filter chips',
styles,
render({label, elevated, disabled, scrolling, singleSelect}) {
const classes = {scrolling};
render({label, elevated, disabled, scrolling}) {
const classes = {'scrolling': scrolling};
return html`
<md-chip-set type="filter" class=${classMap(classes)}
aria-label="Filter chips"
?single-select=${singleSelect}>
<md-chip-set class=${classMap(classes)}
aria-label="Filter chips">
<md-filter-chip
label=${label || 'Filter chip'}
?disabled=${disabled}
@ -116,6 +105,13 @@ const filters: MaterialStoryInit<StoryKnobs> = {
?elevated=${elevated}
removable
></md-filter-chip>
<md-filter-chip
label=${label || 'Disabled filter chip (focusable)'}
disabled
always-focusable
?elevated=${elevated}
removable
></md-filter-chip>
</md-chip-set>
`;
}
@ -125,10 +121,9 @@ const inputs: MaterialStoryInit<StoryKnobs> = {
name: 'Input chips',
styles,
render({label, disabled, scrolling}) {
const classes = {scrolling};
const classes = {'scrolling': scrolling};
return html`
<md-chip-set type="input" class=${classMap(classes)}
aria-label="Input chips">
<md-chip-set class=${classMap(classes)} aria-label="Input chips">
<md-input-chip
label=${label || 'Input chip'}
?disabled=${disabled}
@ -146,30 +141,21 @@ const inputs: MaterialStoryInit<StoryKnobs> = {
>
<img slot="icon" src="https://lh3.googleusercontent.com/a/default-user=s48">
</md-input-chip>
<md-input-chip
label=${label || 'Input link chip'}
href="https://google.com"
target="_blank"
>${GOOGLE_LOGO}</md-input-chip>
<md-input-chip
label=${label || 'Remove-only input chip'}
?disabled=${disabled}
remove-only
></md-input-chip>
</md-chip-set>
`;
}
};
const inputLinks: MaterialStoryInit<StoryKnobs> = {
name: 'Input link chips',
styles,
render({label, disabled, scrolling}) {
const classes = {scrolling};
return html`
<md-chip-set type="input" class=${classMap(classes)}
aria-label="Input link chips">
<md-input-chip
label=${label || 'Input link chip'}
?disabled=${disabled}
href="https://google.com"
target="_blank"
>${GOOGLE_LOGO}</md-input-chip>
label=${label || 'Disabled input chip (focusable)'}
disabled
always-focusable
></md-input-chip>
</md-chip-set>
`;
}
@ -179,10 +165,9 @@ const suggestions: MaterialStoryInit<StoryKnobs> = {
name: 'Suggestion chips',
styles,
render({label, elevated, disabled, scrolling}) {
const classes = {scrolling};
const classes = {'scrolling': scrolling};
return html`
<md-chip-set type="suggestion" class=${classMap(classes)}
aria-label="Suggestion chips">
<md-chip-set class=${classMap(classes)} aria-label="Suggestion chips">
<md-suggestion-chip
label=${label || 'Suggestion chip'}
?disabled=${disabled}
@ -195,32 +180,22 @@ const suggestions: MaterialStoryInit<StoryKnobs> = {
>
<md-icon slot="icon">local_laundry_service</md-icon>
</md-suggestion-chip>
</md-chip-set>
`;
}
};
const suggestionLinks: MaterialStoryInit<StoryKnobs> = {
name: 'Suggestion link chips',
styles,
render({label, elevated, disabled, scrolling}) {
const classes = {scrolling};
return html`
<md-chip-set type="suggestion" class=${classMap(classes)}
aria-label="Suggestion link chips">
<md-suggestion-chip
label=${label || 'Suggestion link chip'}
?disabled=${disabled}
?elevated=${elevated}
href="https://google.com"
target="_blank"
>${GOOGLE_LOGO}</md-suggestion-chip>
<md-suggestion-chip
label=${label || 'Disabled suggestion chip (focusable)'}
disabled
always-focusable
?elevated=${elevated}
></md-suggestion-chip>
</md-chip-set>
`;
}
};
/** Chips stories. */
export const stories = [
standard, links, filters, inputs, inputLinks, suggestions, suggestionLinks
];
export const stories = [assist, filters, inputs, suggestions];

View File

@ -9,10 +9,4 @@
flex-wrap: wrap;
gap: 8px;
}
.content {
display: flex;
flex-wrap: inherit;
gap: inherit;
}
}

View File

@ -11,7 +11,7 @@ import {property} from 'lit/decorators.js';
import {ARIAMixinStrict} from '../../internal/aria/aria.js';
import {Chip, renderGridAction, renderGridContainer} from './chip.js';
import {Chip} from './chip.js';
/**
* An assist chip component.
@ -30,10 +30,6 @@ export class AssistChip extends Chip {
return !this.href && this.disabled;
}
protected override renderContainer(content: unknown) {
return renderGridContainer(content, this.getContainerClasses());
}
protected override getContainerClasses() {
return {
...super.getContainerClasses(),
@ -47,24 +43,24 @@ export class AssistChip extends Chip {
protected override renderPrimaryAction(content: unknown) {
const {ariaLabel} = this as ARIAMixinStrict;
if (this.href) {
return renderGridAction(html`
return html`
<a class="primary action"
id="link"
aria-label=${ariaLabel || nothing}
href=${this.href}
target=${this.target || nothing}
>${content}</a>
`);
`;
}
return renderGridAction(html`
return html`
<button class="primary action"
id="button"
aria-label=${ariaLabel || nothing}
?disabled=${this.disabled}
?disabled=${this.disabled && !this.alwaysFocusable}
type="button"
>${content}</button>
`);
`;
}
protected override renderOutline() {

View File

@ -4,25 +4,19 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {html, isServer, LitElement, nothing, PropertyValues} from 'lit';
import {property, queryAssignedElements} from 'lit/decorators.js';
import {html, isServer, LitElement} from 'lit';
import {queryAssignedElements} from 'lit/decorators.js';
import {ARIAMixinStrict} from '../../internal/aria/aria.js';
import {requestUpdateOnAriaChange} from '../../internal/aria/delegate.js';
import {polyfillElementInternalsAria, setupHostAria} from '../../internal/aria/aria.js';
import {Chip} from './chip.js';
/**
* The type of chip a chip set controls.
*/
export type ChipSetType = 'assist'|'suggestion'|'filter'|'input'|'';
/**
* A chip set component.
*/
export class ChipSet extends LitElement {
static {
requestUpdateOnAriaChange(ChipSet);
setupHostAria(ChipSet, {focusable: false});
}
get chips() {
@ -30,60 +24,31 @@ export class ChipSet extends LitElement {
(child): child is Chip => child instanceof Chip);
}
@property() type: ChipSetType = '';
@property({type: Boolean, attribute: 'single-select'}) singleSelect = false;
@queryAssignedElements() private readonly childElements!: HTMLElement[];
private readonly internals = polyfillElementInternalsAria(
this, (this as HTMLElement /* needed for closure */).attachInternals());
constructor() {
super();
if (!isServer) {
this.addEventListener('focusin', this.updateTabIndices.bind(this));
this.addEventListener('update-focus', this.updateTabIndices.bind(this));
this.addEventListener('keydown', this.handleKeyDown.bind(this));
this.addEventListener('selected', this.handleSelected.bind(this));
}
}
protected override updated(changed: PropertyValues<ChipSet>) {
if (changed.has('singleSelect') && this.singleSelect) {
let hasSelectedChip = false;
for (const chip of this.chips as MaybeSelectableChip[]) {
if (chip.selected === true) {
if (!hasSelectedChip) {
hasSelectedChip = true;
continue;
}
chip.selected = false;
}
}
this.internals.role = 'toolbar';
}
}
protected override render() {
const {ariaLabel} = this as ARIAMixinStrict;
const isFilter = this.type === 'filter';
const role = isFilter ? 'listbox' : 'grid';
const multiselectable = isFilter ? !this.singleSelect : nothing;
return html`
<div class="content"
role=${role}
aria-label=${ariaLabel || nothing}
aria-multiselectable=${multiselectable}>
<slot @slotchange=${this.updateTabIndices}></slot>
</div>
`;
return html`<slot @slotchange=${this.updateTabIndices}></slot>`;
}
private handleKeyDown(event: KeyboardEvent) {
const isDown = event.key === 'ArrowDown';
const isUp = event.key === 'ArrowUp';
const isLeft = event.key === 'ArrowLeft';
const isRight = event.key === 'ArrowRight';
const isHome = event.key === 'Home';
const isEnd = event.key === 'End';
// Ignore non-navigation keys
if (!isLeft && !isRight && !isDown && !isUp && !isHome && !isEnd) {
if (!isLeft && !isRight && !isHome && !isEnd) {
return;
}
@ -105,7 +70,7 @@ export class ChipSet extends LitElement {
// Check if moving forwards or backwards
const isRtl = getComputedStyle(this).direction === 'rtl';
const forwards = isRtl ? isLeft || isUp : isRight || isDown;
const forwards = isRtl ? isLeft : isRight;
const focusedChip = chips.find(chip => chip.matches(':focus-within'));
if (!focusedChip) {
// If there is not already a chip focused, select the first or last chip
@ -131,8 +96,11 @@ export class ChipSet extends LitElement {
// Check if the next sibling is disabled. If so,
// move the index and continue searching.
//
// Some toolbar items may be focusable when disabled for increased
// visibility.
const nextChip = chips[nextIndex];
if (nextChip.disabled) {
if (nextChip.disabled && !nextChip.alwaysFocusable) {
if (forwards) {
nextIndex++;
} else {
@ -149,33 +117,31 @@ export class ChipSet extends LitElement {
}
private updateTabIndices() {
// The chip that should be focusable is either the chip that currently has
// focus or the first chip that can be focused.
const {chips} = this;
let hasFocusedChip = false;
let chipToFocus: Chip|undefined;
for (const chip of chips) {
if (chip.matches(':focus-within')) {
chip.removeAttribute('tabindex');
hasFocusedChip = true;
} else {
chip.tabIndex = -1;
const isChipFocusable = chip.alwaysFocusable || !chip.disabled;
const chipIsFocused = chip.matches(':focus-within');
if (chipIsFocused && isChipFocusable) {
// Found the first chip that is actively focused. This overrides the
// first focusable chip found.
chipToFocus = chip;
continue;
}
}
if (!hasFocusedChip) {
chips[0]?.removeAttribute('tabindex');
}
}
private handleSelected(event: Event) {
if (!this.singleSelect) {
return;
}
if ((event.target as MaybeSelectableChip).selected === true) {
for (const chip of this.chips as MaybeSelectableChip[]) {
if (chip !== event.target && chip.selected) {
chip.selected = false;
}
if (isChipFocusable && !chipToFocus) {
chipToFocus = chip;
}
// Disable non-focused chips. If we disable all of them, we'll grant focus
// to the first focusable child that was found.
chip.tabIndex = -1;
}
if (chipToFocus) {
chipToFocus.tabIndex = 0;
}
}
}
@ -183,7 +149,3 @@ export class ChipSet extends LitElement {
interface MaybeMultiActionChip extends Chip {
focus(options?: FocusOptions&{trailing?: boolean}): void;
}
interface MaybeSelectableChip extends Chip {
selected?: boolean;
}

View File

@ -16,7 +16,6 @@ import {ChipHarness} from '../harness.js';
import {AssistChip} from './assist-chip.js';
import {Chip} from './chip.js';
import {ChipSet} from './chip-set.js';
import {FilterChip} from './filter-chip.js';
import {InputChip} from './input-chip.js';
@customElement('test-chip-set')
@ -28,9 +27,6 @@ class TestAssistChip extends AssistChip {
@customElement('test-chip-set-input-chip')
class TestInputChip extends InputChip {
}
@customElement('test-chip-set-filter-chip')
class TestFilterChip extends FilterChip {
}
describe('Chip set', () => {
const env = new Environment();
@ -68,9 +64,9 @@ describe('Chip set', () => {
const chipSet = await setupTest(
[new TestAssistChip(), new TestAssistChip(), new TestAssistChip()]);
expect(chipSet.chips[0].hasAttribute('tabindex'))
.withContext('first chip does not have tabindex')
.toBeFalse();
expect(chipSet.chips[0].getAttribute('tabindex'))
.withContext('first tabindex')
.toBe('0');
expect(chipSet.chips[1].getAttribute('tabindex'))
.withContext('second tabindex')
.toBe('-1');
@ -117,20 +113,6 @@ describe('Chip set', () => {
});
});
it('should navigate forward on vertical arrow keys', async () => {
const first = new TestAssistChip();
const second = new TestAssistChip();
const third = new TestAssistChip();
const chipSet = await setupTest([first, second, third]);
await testNavigation({
chipSet,
ltrKey: 'ArrowDown',
rtlKey: 'ArrowUp',
current: first,
next: second
});
});
it('should navigate backward on horizontal keys', async () => {
const first = new TestAssistChip();
const second = new TestAssistChip();
@ -145,20 +127,6 @@ describe('Chip set', () => {
});
});
it('should navigate backward on vertical keys', async () => {
const first = new TestAssistChip();
const second = new TestAssistChip();
const third = new TestAssistChip();
const chipSet = await setupTest([first, second, third]);
await testNavigation({
chipSet,
ltrKey: 'ArrowUp',
rtlKey: 'ArrowDown',
current: second,
next: first
});
});
it('should navigate to the first chip on Home', async () => {
const first = new TestAssistChip();
const second = new TestAssistChip();
@ -232,6 +200,22 @@ describe('Chip set', () => {
});
});
it('should NOT skip over disabled always focusable chips', async () => {
const first = new TestAssistChip();
const second = new TestAssistChip();
second.disabled = true;
second.alwaysFocusable = true;
const third = new TestAssistChip();
const chipSet = await setupTest([first, second, third]);
await testNavigation({
chipSet,
ltrKey: 'ArrowRight',
rtlKey: 'ArrowLeft',
current: first,
next: second
});
});
it('should focus trailing actions when navigating backwards', async () => {
const first = new TestInputChip();
const second = new TestInputChip();
@ -277,93 +261,4 @@ describe('Chip set', () => {
.toBeTrue();
});
});
describe('single selection', () => {
it('should allow multi-selection if singleSelect is false', async () => {
const first = new TestFilterChip();
const second = new TestFilterChip();
const chipSet = await setupTest([first, second]);
chipSet.singleSelect = false;
await new ChipHarness(first).clickWithMouse();
await new ChipHarness(second).clickWithMouse();
expect(first.selected).withContext('first chip is selected').toBeTrue();
expect(second.selected).withContext('second chip is selected').toBeTrue();
});
it('should deselect other chips when another chip is selected',
async () => {
const first = new TestFilterChip();
const second = new TestFilterChip();
second.selected = true;
const chipSet = await setupTest([first, second]);
chipSet.singleSelect = true;
await new ChipHarness(first).clickWithMouse();
expect(first.selected)
.withContext('first chip is selected')
.toBeTrue();
expect(second.selected)
.withContext('second chip is not selected')
.toBeFalse();
});
it('should not do anything if all chips are deselected and the property changes',
async () => {
const first = new TestFilterChip();
const second = new TestFilterChip();
const chipSet = await setupTest([first, second]);
chipSet.singleSelect = true;
await env.waitForStability();
expect(first.selected)
.withContext('first chip is still unselected')
.toBeFalse();
expect(second.selected)
.withContext('second chip is still unselected')
.toBeFalse();
});
it('should ensure one chip is selected when property changes', async () => {
const first = new TestFilterChip();
first.selected = true;
const second = new TestFilterChip();
second.selected = true;
const chipSet = await setupTest([first, second]);
chipSet.singleSelect = true;
await env.waitForStability();
expect(first.selected).withContext('first chip is selected').toBeTrue();
expect(second.selected)
.withContext('second chip is deselected')
.toBeFalse();
});
it('should prefer setting the first selected chip as the single selected chip when property changes',
async () => {
const first = new TestFilterChip();
const second = new TestFilterChip();
second.selected = true;
const third = new TestFilterChip();
second.selected = true;
const chipSet = await setupTest([first, second, third]);
chipSet.singleSelect = true;
await env.waitForStability();
expect(first.selected)
.withContext('first chip is still unselected')
.toBeFalse();
expect(second.selected)
.withContext('second chip remains selected')
.toBeTrue();
expect(third.selected)
.withContext('third chip is deselected')
.toBeFalse();
});
});
});

View File

@ -7,7 +7,7 @@
import '../../focus/md-focus-ring.js';
import '../../ripple/ripple.js';
import {html, LitElement, nothing, TemplateResult} from 'lit';
import {html, LitElement, PropertyValues, TemplateResult} from 'lit';
import {property} from 'lit/decorators.js';
import {ClassInfo, classMap} from 'lit/directives/class-map.js';
@ -27,7 +27,26 @@ export abstract class Chip extends LitElement {
delegatesFocus: true
};
/**
* Whether or not the chip is disabled.
*
* Disabled chips are not focusable, unless `always-focusable` is set.
*/
@property({type: Boolean}) disabled = false;
/**
* When true, allow disabled chips to be focused with arrow keys.
*
* Add this when a chip needs increased visibility when disabled. See
* https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/#kbd_disabled_controls
* for more guidance on when this is needed.
*/
@property({type: Boolean, attribute: 'always-focusable'})
alwaysFocusable = false;
/**
* The label of the chip.
*/
@property() label = '';
/**
@ -43,15 +62,31 @@ export abstract class Chip extends LitElement {
return this.disabled;
}
protected override render() {
return this.renderContainer(this.renderContainerContent());
override focus(options?: FocusOptions) {
if (this.disabled && !this.alwaysFocusable) {
return;
}
super.focus(options);
}
protected abstract renderContainer(content: unknown): unknown;
protected override render() {
return html`
<div class="container ${classMap(this.getContainerClasses())}">
${this.renderContainerContent()}
</div>
`;
}
protected getContainerClasses() {
protected override updated(changed: PropertyValues<Chip>) {
if (changed.has('disabled') && changed.get('disabled') !== undefined) {
this.dispatchEvent(new Event('update-focus', {bubbles: true}));
}
}
protected getContainerClasses(): ClassInfo {
return {
disabled: this.disabled,
'disabled': this.disabled,
};
}
@ -86,27 +121,3 @@ export abstract class Chip extends LitElement {
`;
}
}
/**
* Renders a chip container that follows the grid/row/cell a11y pattern.
*
* This renders the container with `role="row"`.
*/
export function renderGridContainer(content: unknown, classes: ClassInfo) {
return html`
<div class="container ${classMap(classes)}" role="row">${content}</div>
`;
}
/**
* Renders a chip action that follows the grid/row/cell a11y pattern.
*
* This wraps actions in a `role="cell"` div.
*/
export function renderGridAction(content: unknown) {
if (content === nothing) {
return content;
}
return html`<div class="cell" role="cell">${content}</div>`;
}

View File

@ -0,0 +1,46 @@
/**
* @license
* Copyright 2023 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
// import 'jasmine'; (google3-only)
import {html} from 'lit';
import {customElement} from 'lit/decorators.js';
import {Environment} from '../../testing/environment.js';
import {ChipHarness} from '../harness.js';
import {Chip} from './chip.js';
@customElement('test-chip')
class TestChip extends Chip {
primaryId = 'button';
override renderPrimaryAction() {
return html`<button id=${this.primaryId}>Chip</button>`;
}
}
describe('Chip', () => {
const env = new Environment();
async function setupTest() {
const chip = new TestChip();
env.render(html`${chip}`);
await env.waitForStability();
return {chip, harness: new ChipHarness(chip)};
}
it('should dispatch `update-focus` for chip set when disabled changes',
async () => {
const {chip} = await setupTest();
const updateFocusListener = jasmine.createSpy('updateFocusListener');
chip.addEventListener('update-focus', updateFocusListener);
chip.disabled = true;
await env.waitForStability();
expect(updateFocusListener).toHaveBeenCalled();
});
});

View File

@ -6,9 +6,8 @@
import '../../elevation/elevation.js';
import {html, nothing, PropertyValues, svg} from 'lit';
import {html, nothing} from 'lit';
import {property, query} from 'lit/decorators.js';
import {classMap} from 'lit/directives/class-map.js';
import {ARIAMixinStrict} from '../../internal/aria/aria.js';
import {redispatchEvent} from '../../internal/controller/events.js';
@ -25,28 +24,13 @@ export class FilterChip extends MultiActionChip {
@property({type: Boolean, reflect: true}) selected = false;
protected get primaryId() {
return 'option';
return 'button';
}
@query('.primary.action') protected readonly primaryAction!: HTMLElement|null;
@query('.trailing.action')
protected readonly trailingAction!: HTMLElement|null;
protected override updated(changed: PropertyValues<FilterChip>) {
if (changed.has('selected') && changed.get('selected') !== undefined) {
// Dispatch when `selected` changes, except for the first update.
this.dispatchEvent(new Event('selected', {bubbles: true}));
}
}
protected override renderContainer(content: unknown) {
const classes = this.getContainerClasses();
// Note: an explicit `role="presentation"` is needed for VoiceOver. Without
// it, linear navigation gets stuck on the first filter chip.
return html`<div class="container ${
classMap(classes)}" role="presentation">${content}</div>`;
}
protected override getContainerClasses() {
return {
...super.getContainerClasses(),
@ -60,11 +44,10 @@ export class FilterChip extends MultiActionChip {
const {ariaLabel} = this as ARIAMixinStrict;
return html`
<button class="primary action"
id="option"
id="button"
aria-label=${ariaLabel || nothing}
aria-selected=${this.selected}
?disabled=${this.disabled || nothing}
role="option"
aria-pressed=${this.selected}
?disabled=${this.disabled && !this.alwaysFocusable}
@click=${this.handleClick}
>${content}</button>
`;
@ -75,17 +58,20 @@ export class FilterChip extends MultiActionChip {
return super.renderLeadingIcon();
}
return svg`
return html`
<svg class="checkmark" viewBox="0 0 18 18" aria-hidden="true">
<path d="M6.75012 12.1274L3.62262 8.99988L2.55762 10.0574L6.75012 14.2499L15.7501 5.24988L14.6926 4.19238L6.75012 12.1274Z" />
</svg>
`;
}
protected override renderTrailingAction() {
protected override renderTrailingAction(focusListener: EventListener) {
if (this.removable) {
return renderRemoveButton(
{ariaLabel: this.ariaLabelRemove, disabled: this.disabled});
return renderRemoveButton({
focusListener,
ariaLabel: this.ariaLabelRemove,
disabled: this.disabled
});
}
return nothing;

View File

@ -53,30 +53,6 @@ describe('Filter chip', () => {
expect(chip.selected).withContext('chip.selected').toBeFalse();
});
it('should dispatch "selected" event when selected changes programmatically',
async () => {
const {chip} = await setupTest();
const handler = jasmine.createSpy();
chip.addEventListener('selected', handler);
chip.selected = true;
await env.waitForStability();
chip.selected = false;
await env.waitForStability();
expect(handler).toHaveBeenCalledTimes(2);
});
it('should dispatch "selected" event when selected changes by click',
async () => {
const {chip, harness} = await setupTest();
const handler = jasmine.createSpy();
chip.addEventListener('selected', handler);
await harness.clickWithMouse();
await harness.clickWithMouse();
expect(handler).toHaveBeenCalledTimes(2);
});
it('can prevent default', async () => {
const {chip, harness} = await setupTest();
const handler = jasmine.createSpy();

View File

@ -9,7 +9,6 @@ import {property, query} from 'lit/decorators.js';
import {ARIAMixinStrict} from '../../internal/aria/aria.js';
import {renderGridAction, renderGridContainer} from './chip.js';
import {MultiActionChip} from './multi-action-chip.js';
import {renderRemoveButton} from './trailing-icons.js';
@ -53,10 +52,6 @@ export class InputChip extends MultiActionChip {
@query('.trailing.action')
protected readonly trailingAction!: HTMLElement|null;
protected override renderContainer(content: unknown) {
return renderGridContainer(content, this.getContainerClasses());
}
protected override getContainerClasses() {
return {
...super.getContainerClasses(),
@ -72,39 +67,40 @@ export class InputChip extends MultiActionChip {
protected override renderPrimaryAction(content: unknown) {
const {ariaLabel} = this as ARIAMixinStrict;
if (this.href) {
return renderGridAction(html`
return html`
<a class="primary action"
id="link"
aria-label=${ariaLabel || nothing}
href=${this.href}
target=${this.target || nothing}
>${content}</a>
`);
`;
}
if (this.removeOnly) {
return renderGridAction(html`
return html`
<span class="primary action" aria-label=${ariaLabel || nothing}>
${content}
</span>
`);
`;
}
return renderGridAction(html`
return html`
<button class="primary action"
id="button"
aria-label=${ariaLabel || nothing}
?disabled=${this.disabled}
?disabled=${this.disabled && !this.alwaysFocusable}
type="button"
>${content}</button>
`);
`;
}
protected override renderTrailingAction() {
return renderGridAction(renderRemoveButton({
protected override renderTrailingAction(focusListener: EventListener) {
return renderRemoveButton({
focusListener,
ariaLabel: this.ariaLabelRemove,
disabled: !this.href && this.disabled,
tabbable: this.removeOnly,
}));
});
}
}

View File

@ -44,28 +44,31 @@ export abstract class MultiActionChip extends Chip {
constructor() {
super();
this.handleTrailingActionFocus = this.handleTrailingActionFocus.bind(this);
if (!isServer) {
this.addEventListener('keydown', this.handleKeyDown.bind(this));
}
}
override focus(options?: FocusOptions&{trailing?: boolean}) {
if (options?.trailing && this.trailingAction) {
const isFocusable = this.alwaysFocusable || !this.disabled;
if (isFocusable && options?.trailing && this.trailingAction) {
this.trailingAction.focus(options);
return;
}
super.focus(options);
super.focus(options as FocusOptions);
}
protected override renderContainerContent() {
return html`
${super.renderContainerContent()}
${this.renderTrailingAction()}
${this.renderTrailingAction(this.handleTrailingActionFocus)}
`;
}
protected abstract renderTrailingAction(): unknown;
protected abstract renderTrailingAction(focusListener: EventListener):
unknown;
private handleKeyDown(event: KeyboardEvent) {
const isLeft = event.key === 'ArrowLeft';
@ -98,4 +101,19 @@ export abstract class MultiActionChip extends Chip {
const actionToFocus = forwards ? this.trailingAction : this.primaryAction;
actionToFocus.focus();
}
private handleTrailingActionFocus() {
const {primaryAction, trailingAction} = this;
if (!primaryAction || !trailingAction) {
return;
}
// Temporarily turn off the primary action's focusability. This allows
// shift+tab from the trailing action to move to the previous chip rather
// than the primary action in the same chip.
primaryAction.tabIndex = -1;
trailingAction.addEventListener('focusout', () => {
primaryAction.tabIndex = 0;
}, {once: true});
}
}

View File

@ -25,21 +25,20 @@ class TestMultiActionChip extends MultiActionChip {
protected primaryId = 'primary';
protected override renderContainer(content: unknown) {
return html`<div>${content}</div>`;
}
protected override renderPrimaryAction() {
return html`<button id="primary"></button>`;
}
protected override renderTrailingAction() {
protected override renderTrailingAction(focusListener: EventListener) {
if (this.noTrailingAction) {
return nothing;
}
return renderRemoveButton(
{ariaLabel: this.ariaLabelRemove, disabled: this.disabled});
return renderRemoveButton({
focusListener,
ariaLabel: this.ariaLabelRemove,
disabled: this.disabled
});
}
}

View File

@ -14,22 +14,24 @@ import {Chip} from './chip.js';
interface RemoveButtonProperties {
ariaLabel: string;
disabled: boolean;
focusListener: EventListener;
tabbable?: boolean;
}
/** @protected */
export function renderRemoveButton(
{ariaLabel, disabled, tabbable = false}: RemoveButtonProperties) {
{ariaLabel, disabled, focusListener, tabbable = false}:
RemoveButtonProperties) {
return html`
<button class="trailing action"
aria-label=${ariaLabel}
?disabled=${disabled}
tabindex=${!tabbable ? -1 : nothing}
@click=${handleRemoveClick}
@focus=${focusListener}
>
<md-focus-ring part="trailing-focus-ring"></md-focus-ring>
<md-ripple></md-ripple>
<svg class="trailing icon" viewBox="0 96 960 960">
<md-ripple ?disabled=${disabled}></md-ripple>
<svg class="trailing icon" viewBox="0 96 960 960" aria-hidden="true">
<path d="m249 849-42-42 231-231-231-231 42-42 231 231 231-231 42 42-231 231 231 231-42 42-231-231-231 231Z" />
</svg>
<span class="touch"></span>

View File

@ -83,18 +83,21 @@ Choose the type of chip based on its purpose and author.
<!-- TODO: catalog-include "figures/<component>/usage.html" -->
```html
<md-assist-chip label="Assist"></md-assist-chip>
<md-filter-chip label="Filter"></md-filter-chip>
<md-input-chip label="Input"></md-input-chip>
<md-suggestion-chip label="Suggestion"></md-suggestion-chip>
<md-chip-set>
<md-assist-chip label="Assist"></md-assist-chip>
<md-filter-chip label="Filter"></md-filter-chip>
<md-input-chip label="Input"></md-input-chip>
<md-suggestion-chip label="Suggestion"></md-suggestion-chip>
</md-chip-set>
```
### Chip sets
<!-- go/md-chip-set -->
Chips should always appear in a set. Use the same type of chip for chip set
children.
Chips should always appear in a set. Chip sets are
[toolbars](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/toolbar_role)<!-- {.external} -->
that can display any type of chip or other toolbar items.
<!-- no-catalog-start -->
<!-- TODO: add image -->
@ -103,17 +106,11 @@ children.
```html
<h3>New event</h3>
<md-chip-set type="assist">
<md-chip-set>
<md-filter-chip label="All day"></md-filter-chip>
<md-assist-chip label="Add to calendar"></md-assist-chip>
<md-assist-chip label="Set a reminder"></md-assist-chip>
</md-chip-set>
<h3>Favorite foods</h3>
<md-chip-set type="filter" single-select>
<md-filter-chip label="Pizza"></md-filter-chip>
<md-filter-chip label="Ice cream"></md-filter-chip>
<md-filter-chip label="Sandwich"></md-filter-chip>
</md-chip-set>
```
### Icons
@ -128,13 +125,15 @@ picture is displayed.
<!-- catalog-only-end -->
```html
<md-assist-chip label="Add to calendar">
<md-icon slot="icon">event</md-icon>
</md-assist-chip>
<md-chip-set>
<md-assist-chip label="Add to calendar">
<md-icon slot="icon">event</md-icon>
</md-assist-chip>
<md-input-chip label="Ping Qiang" avatar>
<img slot="icon" src="...">
</md-input-chip>
<md-input-chip label="Ping Qiang" avatar>
<img slot="icon" src="...">
</md-input-chip>
</md-chip-set>
```
### Elevated
@ -151,7 +150,7 @@ protection, such as on top of an image.
```html
<div>
<img src="...">
<md-chip-set type="suggestion">
<md-chip-set>
<md-suggestion-chip label="Share" elevated></md-suggestion-chip>
<md-suggestion-chip label="Favorite" elevated></md-suggestion-chip>
</md-chip-set>
@ -162,18 +161,41 @@ protection, such as on top of an image.
Add an
[`aria-label`](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-label)<!-- {.external} -->
attribute to chip sets without labels or chips whose labels need to be more
descriptive.
attribute to chip sets or reference a label with
[`aria-labelledby`](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-labelledby)<!-- {.external} -->.
Add an `aria-label` to chips whose labels need to be more descriptive.
```html
<md-chip-set type="filter" aria-label="Select dates">
<h3 id="dates-label">Dates</h3>
<md-chip-set aria-labelledby="dates-label">
<md-filter-chip label="Mon" aria-label="Monday"></md-filter-chip>
<md-filter-chip label="Tue" aria-label="Tuesday"></md-filter-chip>
<md-filter-chip label="Wed" aria-label="Wednesday"></md-filter-chip>
<!-- ... -->
</md-chip-set>
```
### Focusable and disabled
By default, disabled chips are not focusable with the keyboard. Some use cases
encourage focusability of disabled toolbar items to increase their
discoverability.
See the
[ARIA guidelines on focusability of disabled controls](https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/#kbd_disabled_controls)<!-- {.external} -->
for guidance on when this is recommended.
```html
<md-chip-set aria-label="Actions">
<!--
Disabled until text is selected. Since both are disabled by default, keep
them focusable so that screen readers can discover the actions available.
-->
<md-assist-chip label="Copy" disabled always-focusable></md-assist-chip>
<md-assist-chip label="Paste" disabled always-focusable></md-assist-chip>
</md-chip-set>
<md-outlined-text-field type="textarea"></md-outlined-text-field>
```
## Assist chip
<!-- go/md-assist-chip -->
@ -193,7 +215,7 @@ action. They should appear dynamically and contextually in a UI.
```html
<h3>A restaraunt location</h3>
<md-chip-set type="assist">
<md-chip-set>
<md-assist-chip label="Add to my itinerary">
<md-icon slot="icon">calendar</md-icon>
</md-assist-chip>
@ -219,7 +241,7 @@ to toggle buttons or checkboxes.
```html
<h3>Choose where to share</h3>
<md-chip-set type="filter">
<md-chip-set>
<md-filter-chip label="Docs"></md-filter-chip>
<md-filter-chip label="Slides" selected></md-filter-chip>
<md-filter-chip label="Sheets" selected></md-filter-chip>
@ -227,26 +249,6 @@ to toggle buttons or checkboxes.
</md-chip-set>
```
### Single select
Filter chip sets can add a `single-select` attribute to only allow a single
filter chip to be selected at one time.
<!-- no-catalog-start -->
<!-- TODO: add image -->
<!-- no-catalog-end -->
<!-- TODO: catalog-include "figures/<component>/usage-scenario-one.html" -->
<!-- catalog-only-end -->
```html
<h3>Shopping category</h3>
<md-chip-set type="filter" single-select>
<md-filter-chip label="Cameras" selected></md-filter-chip>
<md-filter-chip label="Laptops"></md-filter-chip>
<md-filter-chip label="Phones"></md-filter-chip>
</md-chip-set>
```
### Removable
Filter chips can optionally be removable from the chip set. Removable chips have
@ -260,7 +262,7 @@ a trailing remove icon.
```html
<h3>Colors</h3>
<md-chip-set type="filter">
<md-chip-set>
<md-filter-chip label="Red" removable selected></md-filter-chip>
<md-filter-chip label="Yellow" removable></md-filter-chip>
<md-filter-chip label="Blue" removable></md-filter-chip>
@ -288,7 +290,7 @@ display the image in a larger circle.
```html
<md-outlined-text-field label="Attendees" type="email"></md-outlined-text-field>
<md-chip-set type="input">
<md-chip-set>
<md-input-chip label="Ping Qiang" avatar>
<img slot="icon" src="...">
</md-input-chip>
@ -311,7 +313,7 @@ associated with clicking on it, it may be marked as `remove-only`.
```html
<h3>Favorite movies</h3>
<md-chip-set type="input">
<md-chip-set>
<md-input-chip label="Star Wars" remove-only></md-input-chip>
<md-input-chip label="Star Trek" remove-only></md-input-chip>
</md-chip-set>
@ -333,7 +335,7 @@ such as possible responses or search filters.
```html
<h3>Suggested reply</h3>
<md-chip-set type="suggestion">
<md-chip-set>
<md-suggestion-chip label="I agree"></md-suggestion-chip>
<md-suggestion-chip label="Looks good to me"></md-suggestion-chip>
<md-suggestion-chip label="Thank you"></md-suggestion-chip>