From 7ec70c4c2d8863fa1e1d38724d77a9b6d4e6a20c Mon Sep 17 00:00:00 2001 From: Material Web Team Date: Mon, 15 Jul 2024 16:50:12 -0700 Subject: [PATCH] feat(chips): add label slot PiperOrigin-RevId: 652636936 --- chips/demo/stories.ts | 114 +++++++++----------- chips/internal/chip.ts | 7 +- chips/internal/multi-action-chip.ts | 11 +- chips/internal/multi-action-chip_test.ts | 132 +++++++++++++++++++++-- chips/internal/trailing-icons.ts | 9 +- testing/templates.ts | 2 +- 6 files changed, 193 insertions(+), 82 deletions(-) diff --git a/chips/demo/stories.ts b/chips/demo/stories.ts index 10b77bc705..7668a9bad2 100644 --- a/chips/demo/stories.ts +++ b/chips/demo/stories.ts @@ -50,28 +50,22 @@ const assist: MaterialStoryInit = { const classes = {'scrolling': scrolling}; return html` - - + + ${label || 'Assist chip'} + + local_laundry_service + ${label || 'Assist chip with icon'} ${GOOGLE_LOGO} - + target="_blank"> + ${GOOGLE_LOGO} ${label || 'Assist link chip'} + + + ${label || 'Soft-disabled assist chip (focusable)'} + `; }, @@ -84,26 +78,23 @@ const filters: MaterialStoryInit = { const classes = {'scrolling': scrolling}; return html` - - + + ${label || 'Filter chip'} + + local_laundry_service + ${label || 'Filter chip with icon'} + + + ${label || 'Removable filter chip'} - + removable> + ${label || 'Soft-disabled filter chip (focusable)'} + `; }, @@ -116,35 +107,28 @@ const inputs: MaterialStoryInit = { const classes = {'scrolling': scrolling}; return html` - - + + ${label || 'Input chip'} + + local_laundry_service + ${label || 'Input chip with icon'} - + + ${label || 'Input chip with avatar'} - ${GOOGLE_LOGO}${GOOGLE_LOGO} ${label || 'Input link chip'} - - + + ${label || 'Remove-only input chip'} + + + ${label || 'Soft-disabled input chip (focusable)'} + `; }, @@ -157,27 +141,25 @@ const suggestions: MaterialStoryInit = { const classes = {'scrolling': scrolling}; return html` - - + + ${label || 'Suggestion chip'} + + local_laundry_service + ${label || 'Suggestion chip with icon'} ${GOOGLE_LOGO}${GOOGLE_LOGO} ${label || 'Suggestion link chip'} + always-focusable + ?elevated=${elevated}> + ${label || 'Soft-disabled suggestion chip (focusable)'} + `; }, diff --git a/chips/internal/chip.ts b/chips/internal/chip.ts index a38399c3a9..07e968274b 100644 --- a/chips/internal/chip.ts +++ b/chips/internal/chip.ts @@ -58,8 +58,11 @@ export abstract class Chip extends chipBaseClass { @property({type: Boolean, attribute: 'always-focusable'}) alwaysFocusable = false; + // TODO(b/350810013): remove the label property. /** * The label of the chip. + * + * @deprecated Set text as content of the chip instead. */ @property() label = ''; @@ -149,7 +152,9 @@ export abstract class Chip extends chipBaseClass { ${this.renderLeadingIcon()} - ${this.label} + + ${this.label ? this.label : html``} + `; diff --git a/chips/internal/multi-action-chip.ts b/chips/internal/multi-action-chip.ts index 21310c82db..2d655862e1 100644 --- a/chips/internal/multi-action-chip.ts +++ b/chips/internal/multi-action-chip.ts @@ -16,14 +16,21 @@ const ARIA_LABEL_REMOVE = 'aria-label-remove'; * A chip component with multiple actions. */ export abstract class MultiActionChip extends Chip { - get ariaLabelRemove(): string { + get ariaLabelRemove(): string | null { if (this.hasAttribute(ARIA_LABEL_REMOVE)) { return this.getAttribute(ARIA_LABEL_REMOVE)!; } const {ariaLabel} = this as ARIAMixinStrict; - return `Remove ${ariaLabel || this.label}`; + + // TODO(b/350810013): remove `this.label` when label property is removed. + if (ariaLabel || this.label) { + return `Remove ${ariaLabel || this.label}`; + } + + return null; } + set ariaLabelRemove(ariaLabel: string | null) { const prev = this.ariaLabelRemove; if (ariaLabel === prev) { diff --git a/chips/internal/multi-action-chip_test.ts b/chips/internal/multi-action-chip_test.ts index af84133818..ef7b24ddfe 100644 --- a/chips/internal/multi-action-chip_test.ts +++ b/chips/internal/multi-action-chip_test.ts @@ -29,8 +29,8 @@ class TestMultiActionChip extends MultiActionChip { protected primaryId = 'primary'; - protected override renderPrimaryAction() { - return html``; + protected override renderPrimaryAction(content: unknown) { + return html``; } protected override renderTrailingAction(focusListener: EventListener) { @@ -49,10 +49,18 @@ class TestMultiActionChip extends MultiActionChip { describe('Multi-action chips', () => { const env = new Environment(); - async function setupTest() { - const chip = new TestMultiActionChip(); - env.render(html`${chip}`); + async function setupTest( + template = html``, + ): Promise { + const root = env.render(template); await env.waitForStability(); + const chip = root.querySelector( + 'test-multi-action-chip', + ); + if (!chip) { + throw new Error('Failed to query the rendered '); + } + return chip; } @@ -222,28 +230,132 @@ describe('Multi-action chips', () => { }); it('should provide a default "ariaLabelRemove" value', async () => { + const label = 'Label'; + const chip = await setupTest( + html`${label}`, + ); + + expect(getA11yLabelForChipRemoveButton(chip)).toEqual(`Remove ${label}`); + }); + + it('should provide a default "ariaLabelRemove" when "ariaLabel" is provided', async () => { + const label = 'Label'; + const chip = await setupTest( + html` + ${label} + `, + ); + + expect(getA11yLabelForChipRemoveButton(chip)).toEqual( + `Remove ${chip.ariaLabel}`, + ); + }); + + it('should allow setting a custom "ariaLabelRemove"', async () => { + const label = 'Label'; + const customAriaLabelRemove = 'Remove custom label'; + const chip = await setupTest( + html` + ${label} + `, + ); + + expect(getA11yLabelForChipRemoveButton(chip)).toEqual( + customAriaLabelRemove, + ); + }); + + // TODO(b/350810013): remove test when label property is removed. + it('should provide a default "ariaLabelRemove" value (using the label property)', async () => { const chip = await setupTest(); chip.label = 'Label'; + await env.waitForStability(); - expect(chip.ariaLabelRemove).toEqual(`Remove ${chip.label}`); + expect(getA11yLabelForChipRemoveButton(chip)).toEqual( + `Remove ${chip.label}`, + ); }); - it('should provide a default "ariaLabelRemove" when "ariaLabel" is provided', async () => { + // TODO(b/350810013): remove test when label property is removed. + it('should provide a default "ariaLabelRemove" when "ariaLabel" is provided (using the label property)', async () => { const chip = await setupTest(); chip.label = 'Label'; chip.ariaLabel = 'Descriptive label'; + await env.waitForStability(); - expect(chip.ariaLabelRemove).toEqual(`Remove ${chip.ariaLabel}`); + expect(getA11yLabelForChipRemoveButton(chip)).toEqual( + `Remove ${chip.ariaLabel}`, + ); }); - it('should allow setting a custom "ariaLabelRemove"', async () => { + // TODO(b/350810013): remove test when label property is removed. + it('should allow setting a custom "ariaLabelRemove" (using the label property)', async () => { const chip = await setupTest(); chip.label = 'Label'; chip.ariaLabel = 'Descriptive label'; const customAriaLabelRemove = 'Remove custom label'; chip.ariaLabelRemove = customAriaLabelRemove; + await env.waitForStability(); - expect(chip.ariaLabelRemove).toEqual(customAriaLabelRemove); + expect(getA11yLabelForChipRemoveButton(chip)).toEqual( + customAriaLabelRemove, + ); }); }); }); + +/** + * Returns the text content of a slot. + */ +function getSlotTextContent(slot: HTMLSlotElement) { + // Remove any newlines, comments, and whitespace from the label slot. + let text = ''; + for (const node of slot.assignedNodes() ?? []) { + if (node.nodeType === Node.TEXT_NODE) { + text += node.textContent?.trim() || ''; + } + } + return text; +} + +/** + * Returns the a11y label of the remove button. If the button has an aria-label, + * it will return that. If it has aria-labelledby, it will return the text + * content of the elements it is labelled by. + */ +function getA11yLabelForChipRemoveButton(chip: TestMultiActionChip): string { + const removeButton = chip.shadowRoot!.querySelector( + 'button.trailing.action', + )!; + + if (removeButton.ariaLabel) { + return removeButton.ariaLabel; + } + + // If the remove button is not aria-labelled, it should be aria-labelledby. + const removeButtonAriaLabelledBy = + removeButton.getAttribute('aria-labelledby')!; + const elementsLabelledBy: HTMLElement[] = []; + removeButtonAriaLabelledBy.split(' ').forEach((id) => { + const labelledByElement = chip.shadowRoot?.getElementById(id); + if (!labelledByElement) { + throw new Error( + `Cannot find element with ID "#{id}" in the chip's shadow root`, + ); + } + elementsLabelledBy.push(labelledByElement); + }); + const textFromAriaLabelledBy: string[] = []; + elementsLabelledBy.forEach((element) => { + const unnamedSlotChildElement = + element.querySelector('slot:not([name])'); + if (unnamedSlotChildElement) { + textFromAriaLabelledBy.push(getSlotTextContent(unnamedSlotChildElement)); + } else { + textFromAriaLabelledBy.push(element.textContent ?? ''); + } + }); + return textFromAriaLabelledBy.join(' '); +} diff --git a/chips/internal/trailing-icons.ts b/chips/internal/trailing-icons.ts index 76cd7a1f07..ce5f79494b 100644 --- a/chips/internal/trailing-icons.ts +++ b/chips/internal/trailing-icons.ts @@ -12,7 +12,7 @@ import {html, nothing} from 'lit'; import {Chip} from './chip.js'; interface RemoveButtonProperties { - ariaLabel: string; + ariaLabel: string | null; disabled: boolean; focusListener: EventListener; tabbable?: boolean; @@ -25,10 +25,15 @@ export function renderRemoveButton({ focusListener, tabbable = false, }: RemoveButtonProperties) { + // When an aria-label is not provided, we use two spans with aria-labelledby + // to create the "Remove " label for the remove button. The first + // is this #remove-label span, the second is the chip's #label slot span. return html` +