From 750b886acfa15960d66f83a08599d2a2155ae659 Mon Sep 17 00:00:00 2001 From: Zack Elliott Date: Mon, 15 Jul 2024 07:24:22 -0700 Subject: [PATCH] feat(chips): add new `soft-disabled` attribute for focusable disabled chips The `always-focusable` attribute is also now deprecated in favor of this new attribute. It'll be removed in some upcoming major version change. PiperOrigin-RevId: 652472686 --- chips/demo/stories.ts | 19 +++++----- chips/internal/_shared.scss | 4 +-- chips/internal/assist-chip.ts | 5 +-- chips/internal/assist-chip_test.ts | 56 ++++++++++++++++++++++++++++++ chips/internal/chip-set_test.ts | 15 ++++++++ chips/internal/chip.ts | 37 ++++++++++++++++++-- chips/internal/filter-chip.ts | 9 ++--- chips/internal/filter-chip_test.ts | 54 ++++++++++++++++++++++++++++ chips/internal/input-chip.ts | 7 ++-- chips/internal/input-chip_test.ts | 56 ++++++++++++++++++++++++++++++ chips/internal/trailing-icons.ts | 2 +- 11 files changed, 238 insertions(+), 26 deletions(-) diff --git a/chips/demo/stories.ts b/chips/demo/stories.ts index 98e0a68fb7..10b77bc705 100644 --- a/chips/demo/stories.ts +++ b/chips/demo/stories.ts @@ -68,8 +68,8 @@ const assist: MaterialStoryInit = { >${GOOGLE_LOGO} @@ -100,9 +100,8 @@ const filters: MaterialStoryInit = { ?elevated=${elevated} removable> @@ -144,9 +143,8 @@ const inputs: MaterialStoryInit = { ?disabled=${disabled} remove-only> + label=${label || 'Soft-disabled input chip (focusable)'} + soft-disabled> `; }, @@ -177,9 +175,8 @@ const suggestions: MaterialStoryInit = { >${GOOGLE_LOGO} `; diff --git a/chips/internal/_shared.scss b/chips/internal/_shared.scss index fd5489f06a..4c73665721 100644 --- a/chips/internal/_shared.scss +++ b/chips/internal/_shared.scss @@ -33,7 +33,7 @@ ); } - :host([disabled]) { + :host(:is([disabled], [soft-disabled])) { pointer-events: none; } @@ -242,7 +242,7 @@ } a, - button:not(:disabled) { + button:not(:disabled, [aria-disabled='true']) { cursor: inherit; } } diff --git a/chips/internal/assist-chip.ts b/chips/internal/assist-chip.ts index d6dcf7ed8f..731e1b9a4b 100644 --- a/chips/internal/assist-chip.ts +++ b/chips/internal/assist-chip.ts @@ -27,14 +27,14 @@ export class AssistChip extends Chip { protected override get rippleDisabled() { // Link chips cannot be disabled - return !this.href && this.disabled; + return !this.href && (this.disabled || this.softDisabled); } protected override getContainerClasses() { return { ...super.getContainerClasses(), // Link chips cannot be disabled - disabled: !this.href && this.disabled, + disabled: !this.href && (this.disabled || this.softDisabled), elevated: this.elevated, link: !!this.href, }; @@ -60,6 +60,7 @@ export class AssistChip extends Chip { class="primary action" id="button" aria-label=${ariaLabel || nothing} + aria-disabled=${this.softDisabled || nothing} ?disabled=${this.disabled && !this.alwaysFocusable} type="button" >${content} { .withContext('should not have any disabled styling or behavior') .toBeNull(); }); + + it('should not allow link chips to be soft-disabled', async () => { + // Arrange + // Act + const chip = await setupTest(); + chip.href = 'link'; + chip.softDisabled = true; + await chip.updateComplete; + + // Assert + expect(chip.renderRoot.querySelector('.disabled,:disabled')) + .withContext('should not have any disabled styling or behavior') + .toBeNull(); + }); + }); + + it('should use aria-disabled when soft-disabled', async () => { + // Arrange + // Act + const chip = await setupTest(); + chip.softDisabled = true; + await chip.updateComplete; + + // Assert + expect(chip.renderRoot.querySelector('button[aria-disabled="true"]')) + .withContext('should have aria-disabled="true"') + .not.toBeNull(); + }); + + it('should be focusable when soft-disabled', async () => { + // Arrange + const chip = await setupTest(); + chip.softDisabled = true; + await chip.updateComplete; + + // Act + chip.focus(); + + // Assert + expect(document.activeElement) + .withContext('soft-disabled chip should be focused') + .toBe(chip); + }); + + it('should not be clickable when soft-disabled', async () => { + // Arrange + const clickListener = jasmine.createSpy('clickListener'); + const chip = await setupTest(); + chip.softDisabled = true; + chip.addEventListener('click', clickListener); + + // Act + chip.click(); + + // Assert + expect(clickListener).not.toHaveBeenCalled(); }); }); diff --git a/chips/internal/chip-set_test.ts b/chips/internal/chip-set_test.ts index 69379512a1..c93254d6f1 100644 --- a/chips/internal/chip-set_test.ts +++ b/chips/internal/chip-set_test.ts @@ -220,6 +220,21 @@ describe('Chip set', () => { }); }); + it('should NOT skip over soft-disabled chips', async () => { + const first = new TestAssistChip(); + const second = new TestAssistChip(); + second.softDisabled = 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(); diff --git a/chips/internal/chip.ts b/chips/internal/chip.ts index a5ad833e1b..a38399c3a9 100644 --- a/chips/internal/chip.ts +++ b/chips/internal/chip.ts @@ -7,7 +7,7 @@ import '../../focus/md-focus-ring.js'; import '../../ripple/ripple.js'; -import {html, LitElement, PropertyValues, TemplateResult} from 'lit'; +import {html, isServer, LitElement, PropertyValues, TemplateResult} from 'lit'; import {property} from 'lit/decorators.js'; import {ClassInfo, classMap} from 'lit/directives/class-map.js'; @@ -35,12 +35,25 @@ export abstract class Chip extends chipBaseClass { */ @property({type: Boolean, reflect: true}) disabled = false; + /** + * Whether or not the chip is "soft-disabled" (disabled but still + * focusable). + * + * Use 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: 'soft-disabled', reflect: true}) + softDisabled = 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. + * + * @deprecated Use `softDisabled` instead of `alwaysFocusable` + `disabled`. */ @property({type: Boolean, attribute: 'always-focusable'}) alwaysFocusable = false; @@ -70,7 +83,14 @@ export abstract class Chip extends chipBaseClass { * Some chip actions such as links cannot be disabled. */ protected get rippleDisabled() { - return this.disabled; + return this.disabled || this.softDisabled; + } + + constructor() { + super(); + if (!isServer) { + this.addEventListener('click', this.handleClick.bind(this)); + } } override focus(options?: FocusOptions) { @@ -97,7 +117,7 @@ export abstract class Chip extends chipBaseClass { protected getContainerClasses(): ClassInfo { return { - 'disabled': this.disabled, + 'disabled': this.disabled || this.softDisabled, 'has-icon': this.hasIcon, }; } @@ -139,4 +159,15 @@ export abstract class Chip extends chipBaseClass { const slot = event.target as HTMLSlotElement; this.hasIcon = slot.assignedElements({flatten: true}).length > 0; } + + private handleClick(event: Event) { + // If the chip is soft-disabled or disabled + always-focusable, we need to + // explicitly prevent the click from propagating to other event listeners + // as well as prevent the default action. + if (this.softDisabled || (this.disabled && this.alwaysFocusable)) { + event.stopImmediatePropagation(); + event.preventDefault(); + return; + } + } } diff --git a/chips/internal/filter-chip.ts b/chips/internal/filter-chip.ts index bd0f07922d..762394f095 100644 --- a/chips/internal/filter-chip.ts +++ b/chips/internal/filter-chip.ts @@ -61,8 +61,9 @@ export class FilterChip extends MultiActionChip { id="button" aria-label=${ariaLabel || nothing} aria-pressed=${this.selected} + aria-disabled=${this.softDisabled || nothing} ?disabled=${this.disabled && !this.alwaysFocusable} - @click=${this.handleClick} + @click=${this.handleClickOnChild} >${content} `; @@ -88,7 +89,7 @@ export class FilterChip extends MultiActionChip { return renderRemoveButton({ focusListener, ariaLabel: this.ariaLabelRemove, - disabled: this.disabled, + disabled: this.disabled || this.softDisabled, }); } @@ -103,8 +104,8 @@ export class FilterChip extends MultiActionChip { return super.renderOutline(); } - private handleClick(event: MouseEvent) { - if (this.disabled) { + private handleClickOnChild(event: MouseEvent) { + if (this.disabled || this.softDisabled) { return; } diff --git a/chips/internal/filter-chip_test.ts b/chips/internal/filter-chip_test.ts index ae894b1be8..dd7f8dddd1 100644 --- a/chips/internal/filter-chip_test.ts +++ b/chips/internal/filter-chip_test.ts @@ -53,6 +53,18 @@ describe('Filter chip', () => { expect(chip.selected).withContext('chip.selected').toBeFalse(); }); + it('should not select on click when soft-disabled', async () => { + // Arrange + const {chip, harness} = await setupTest(); + chip.softDisabled = true; + + // Act + await harness.clickWithMouse(); + + // Assert + expect(chip.selected).withContext('chip.selected').toBeFalse(); + }); + it('can prevent default', async () => { const {chip, harness} = await setupTest(); const handler = jasmine.createSpy(); @@ -100,4 +112,46 @@ describe('Filter chip', () => { .toBeTrue(); }); }); + + it('should be focusable when soft-disabled', async () => { + // Arrange + const {chip} = await setupTest(); + chip.softDisabled = true; + await chip.updateComplete; + + // Act + chip.focus(); + + // Assert + expect(document.activeElement) + .withContext('soft-disabled chip should be focused') + .toBe(chip); + }); + + it('should not be clickable when soft-disabled', async () => { + // Arrange + const clickListener = jasmine.createSpy('clickListener'); + const {chip, harness} = await setupTest(); + chip.softDisabled = true; + chip.addEventListener('click', clickListener); + + // Act + await harness.clickWithMouse(); + + // Assert + expect(clickListener).not.toHaveBeenCalled(); + }); + + it('should use aria-disabled when soft-disabled', async () => { + // Arrange + // Act + const {chip} = await setupTest(); + chip.softDisabled = true; + await chip.updateComplete; + + // Assert + expect(chip.renderRoot.querySelector('button[aria-disabled="true"]')) + .withContext('should have aria-disabled="true"') + .not.toBeNull(); + }); }); diff --git a/chips/internal/input-chip.ts b/chips/internal/input-chip.ts index 16616e9693..22a56e1e7e 100644 --- a/chips/internal/input-chip.ts +++ b/chips/internal/input-chip.ts @@ -38,7 +38,7 @@ export class InputChip extends MultiActionChip { protected override get rippleDisabled() { // Link chips cannot be disabled - return !this.href && this.disabled; + return !this.href && (this.disabled || this.softDisabled); } protected get primaryAction() { @@ -59,7 +59,7 @@ export class InputChip extends MultiActionChip { ...super.getContainerClasses(), avatar: this.avatar, // Link chips cannot be disabled - disabled: !this.href && this.disabled, + disabled: !this.href && (this.disabled || this.softDisabled), link: !!this.href, selected: this.selected, 'has-trailing': true, @@ -94,6 +94,7 @@ export class InputChip extends MultiActionChip { class="primary action" id="button" aria-label=${ariaLabel || nothing} + aria-disabled=${this.softDisabled || nothing} ?disabled=${this.disabled && !this.alwaysFocusable} type="button" >${content} { .withContext('should not have any disabled styling or behavior') .toBeNull(); }); + + it('should not allow link chips to be soft-disabled', async () => { + // Arrange + // Act + const chip = await setupTest(); + chip.href = 'link'; + chip.softDisabled = true; + await chip.updateComplete; + + // Assert + expect(chip.renderRoot.querySelector('.disabled,:disabled')) + .withContext('should not have any disabled styling or behavior') + .toBeNull(); + }); + }); + + it('should use aria-disabled when soft-disabled', async () => { + // Arrange + // Act + const chip = await setupTest(); + chip.softDisabled = true; + await chip.updateComplete; + + // Assert + expect(chip.renderRoot.querySelector('button[aria-disabled="true"]')) + .withContext('should have aria-disabled="true"') + .not.toBeNull(); + }); + + it('should be focusable when soft-disabled', async () => { + // Arrange + const chip = await setupTest(); + chip.softDisabled = true; + await chip.updateComplete; + + // Act + chip.focus(); + + // Assert + expect(document.activeElement) + .withContext('soft-disabled chip should be focused') + .toBe(chip); + }); + + it('should not be clickable when soft-disabled', async () => { + // Arrange + const clickListener = jasmine.createSpy('clickListener'); + const chip = await setupTest(); + chip.softDisabled = true; + chip.addEventListener('click', clickListener); + + // Act + chip.click(); + + // Assert + expect(clickListener).not.toHaveBeenCalled(); }); }); diff --git a/chips/internal/trailing-icons.ts b/chips/internal/trailing-icons.ts index bd5d147301..76cd7a1f07 100644 --- a/chips/internal/trailing-icons.ts +++ b/chips/internal/trailing-icons.ts @@ -48,7 +48,7 @@ export function renderRemoveButton({ } function handleRemoveClick(this: Chip, event: Event) { - if (this.disabled) { + if (this.disabled || this.softDisabled) { return; }