Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(chips): add label slot #5669

Merged
merged 1 commit into from
Jul 15, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 48 additions & 66 deletions chips/demo/stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,28 +50,22 @@ const assist: MaterialStoryInit<StoryKnobs> = {
const classes = {'scrolling': scrolling};
return html`
<md-chip-set class=${classMap(classes)} aria-label="Assist chips">
<md-assist-chip
label=${label || 'Assist chip'}
?disabled=${disabled}
?elevated=${elevated}></md-assist-chip>
<md-assist-chip
label=${label || 'Assist chip with icon'}
?disabled=${disabled}
?elevated=${elevated}>
<md-assist-chip ?disabled=${disabled} ?elevated=${elevated}>
${label || 'Assist chip'}
</md-assist-chip>
<md-assist-chip ?disabled=${disabled} ?elevated=${elevated}>
<md-icon slot="icon">local_laundry_service</md-icon>
${label || 'Assist chip with icon'}
</md-assist-chip>
<md-assist-chip
label=${label || 'Assist link chip'}
?elevated=${elevated}
href="https://google.com"
target="_blank"
>${GOOGLE_LOGO}</md-assist-chip
>
<md-assist-chip
label=${label || 'Soft-disabled assist chip (focusable)'}
soft-disabled
always-focusable
?elevated=${elevated}></md-assist-chip>
target="_blank">
${GOOGLE_LOGO} ${label || 'Assist link chip'}
</md-assist-chip>
<md-assist-chip soft-disabled always-focusable ?elevated=${elevated}>
${label || 'Soft-disabled assist chip (focusable)'}
</md-assist-chip>
</md-chip-set>
`;
},
Expand All @@ -84,26 +78,23 @@ const filters: MaterialStoryInit<StoryKnobs> = {
const classes = {'scrolling': scrolling};
return html`
<md-chip-set class=${classMap(classes)} aria-label="Filter chips">
<md-filter-chip
label=${label || 'Filter chip'}
?disabled=${disabled}
?elevated=${elevated}></md-filter-chip>
<md-filter-chip
label=${label || 'Filter chip with icon'}
?disabled=${disabled}
?elevated=${elevated}>
<md-filter-chip ?disabled=${disabled} ?elevated=${elevated}>
${label || 'Filter chip'}
</md-filter-chip>
<md-filter-chip ?disabled=${disabled} ?elevated=${elevated}>
<md-icon slot="icon">local_laundry_service</md-icon>
${label || 'Filter chip with icon'}
</md-filter-chip>
<md-filter-chip ?disabled=${disabled} ?elevated=${elevated} removable>
${label || 'Removable filter chip'}
</md-filter-chip>
<md-filter-chip
label=${label || 'Removable filter chip'}
?disabled=${disabled}
?elevated=${elevated}
removable></md-filter-chip>
<md-filter-chip
label=${label || 'Soft-disabled filter chip (focusable)'}
soft-disabled
always-focusable
?elevated=${elevated}
removable></md-filter-chip>
removable>
${label || 'Soft-disabled filter chip (focusable)'}
</md-filter-chip>
</md-chip-set>
`;
},
Expand All @@ -116,35 +107,28 @@ const inputs: MaterialStoryInit<StoryKnobs> = {
const classes = {'scrolling': scrolling};
return html`
<md-chip-set class=${classMap(classes)} aria-label="Input chips">
<md-input-chip
label=${label || 'Input chip'}
?disabled=${disabled}></md-input-chip>
<md-input-chip
label=${label || 'Input chip with icon'}
?disabled=${disabled}>
<md-input-chip ?disabled=${disabled}>
${label || 'Input chip'}
</md-input-chip>
<md-input-chip ?disabled=${disabled}>
<md-icon slot="icon">local_laundry_service</md-icon>
${label || 'Input chip with icon'}
</md-input-chip>
<md-input-chip
label=${label || 'Input chip with avatar'}
?disabled=${disabled}
avatar>
<md-input-chip ?disabled=${disabled} avatar>
<img
slot="icon"
src="https://lh3.googleusercontent.com/a/default-user=s48" />
${label || 'Input chip with avatar'}
</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 href="https://google.com" target="_blank"
>${GOOGLE_LOGO} ${label || 'Input link chip'}</md-input-chip
>
<md-input-chip
label=${label || 'Remove-only input chip'}
?disabled=${disabled}
remove-only></md-input-chip>
<md-input-chip
label=${label || 'Soft-disabled input chip (focusable)'}
soft-disabled></md-input-chip>
<md-input-chip ?disabled=${disabled} remove-only>
${label || 'Remove-only input chip'}
</md-input-chip>
<md-input-chip soft-disabled always-focusable>
${label || 'Soft-disabled input chip (focusable)'}
</md-input-chip>
</md-chip-set>
`;
},
Expand All @@ -157,27 +141,25 @@ const suggestions: MaterialStoryInit<StoryKnobs> = {
const classes = {'scrolling': scrolling};
return html`
<md-chip-set class=${classMap(classes)} aria-label="Suggestion chips">
<md-suggestion-chip
label=${label || 'Suggestion chip'}
?disabled=${disabled}
?elevated=${elevated}></md-suggestion-chip>
<md-suggestion-chip
label=${label || 'Suggestion chip with icon'}
?disabled=${disabled}
?elevated=${elevated}>
<md-suggestion-chip ?disabled=${disabled} ?elevated=${elevated}>
${label || 'Suggestion chip'}
</md-suggestion-chip>
<md-suggestion-chip ?disabled=${disabled} ?elevated=${elevated}>
<md-icon slot="icon">local_laundry_service</md-icon>
${label || 'Suggestion chip with icon'}
</md-suggestion-chip>
<md-suggestion-chip
label=${label || 'Suggestion link chip'}
?elevated=${elevated}
href="https://google.com"
target="_blank"
>${GOOGLE_LOGO}</md-suggestion-chip
>${GOOGLE_LOGO} ${label || 'Suggestion link chip'}</md-suggestion-chip
>
<md-suggestion-chip
label=${label || 'Soft-disabled suggestion chip (focusable)'}
soft-disabled
?elevated=${elevated}></md-suggestion-chip>
always-focusable
?elevated=${elevated}>
${label || 'Soft-disabled suggestion chip (focusable)'}
</md-suggestion-chip>
</md-chip-set>
`;
},
Expand Down
7 changes: 6 additions & 1 deletion chips/internal/chip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '';

Expand Down Expand Up @@ -149,7 +152,9 @@ export abstract class Chip extends chipBaseClass {
${this.renderLeadingIcon()}
</span>
<span class="label">
<span class="label-text">${this.label}</span>
<span class="label-text" id="label">
${this.label ? this.label : html`<slot></slot>`}
</span>
</span>
<span class="touch"></span>
`;
Expand Down
11 changes: 9 additions & 2 deletions chips/internal/multi-action-chip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
132 changes: 122 additions & 10 deletions chips/internal/multi-action-chip_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@ class TestMultiActionChip extends MultiActionChip {

protected primaryId = 'primary';

protected override renderPrimaryAction() {
return html`<button id="primary"></button>`;
protected override renderPrimaryAction(content: unknown) {
return html`<button id="primary">${content}</button>`;
}

protected override renderTrailingAction(focusListener: EventListener) {
Expand All @@ -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`<test-multi-action-chip></test-multi-action-chip>`,
): Promise<TestMultiActionChip> {
const root = env.render(template);
await env.waitForStability();
const chip = root.querySelector<TestMultiActionChip>(
'test-multi-action-chip',
);
if (!chip) {
throw new Error('Failed to query the rendered <test-multi-action-chip>');
}

return chip;
}

Expand Down Expand Up @@ -222,28 +230,132 @@ describe('Multi-action chips', () => {
});

it('should provide a default "ariaLabelRemove" value', async () => {
const label = 'Label';
const chip = await setupTest(
html`<test-multi-action-chip>${label}</test-multi-action-chip>`,
);

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`<test-multi-action-chip aria-label=${'Descriptive label'}>
${label}
</test-multi-action-chip>`,
);

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`<test-multi-action-chip
aria-label=${'Descriptive label'}
aria-label-remove=${customAriaLabelRemove}>
${label}
</test-multi-action-chip>`,
);

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<HTMLButtonElement>(
'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<HTMLSlotElement>('slot:not([name])');
if (unnamedSlotChildElement) {
textFromAriaLabelledBy.push(getSlotTextContent(unnamedSlotChildElement));
} else {
textFromAriaLabelledBy.push(element.textContent ?? '');
}
});
return textFromAriaLabelledBy.join(' ');
}
Loading
Loading