Skip to content

Commit 750b886

Browse files
zelliottcopybara-github
authored andcommitted
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
1 parent aea7781 commit 750b886

File tree

11 files changed

+238
-26
lines changed

11 files changed

+238
-26
lines changed

chips/demo/stories.ts

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -68,8 +68,8 @@ const assist: MaterialStoryInit<StoryKnobs> = {
6868
>${GOOGLE_LOGO}</md-assist-chip
6969
>
7070
<md-assist-chip
71-
label=${label || 'Disabled assist chip (focusable)'}
72-
disabled
71+
label=${label || 'Soft-disabled assist chip (focusable)'}
72+
soft-disabled
7373
always-focusable
7474
?elevated=${elevated}></md-assist-chip>
7575
</md-chip-set>
@@ -100,9 +100,8 @@ const filters: MaterialStoryInit<StoryKnobs> = {
100100
?elevated=${elevated}
101101
removable></md-filter-chip>
102102
<md-filter-chip
103-
label=${label || 'Disabled filter chip (focusable)'}
104-
disabled
105-
always-focusable
103+
label=${label || 'Soft-disabled filter chip (focusable)'}
104+
soft-disabled
106105
?elevated=${elevated}
107106
removable></md-filter-chip>
108107
</md-chip-set>
@@ -144,9 +143,8 @@ const inputs: MaterialStoryInit<StoryKnobs> = {
144143
?disabled=${disabled}
145144
remove-only></md-input-chip>
146145
<md-input-chip
147-
label=${label || 'Disabled input chip (focusable)'}
148-
disabled
149-
always-focusable></md-input-chip>
146+
label=${label || 'Soft-disabled input chip (focusable)'}
147+
soft-disabled></md-input-chip>
150148
</md-chip-set>
151149
`;
152150
},
@@ -177,9 +175,8 @@ const suggestions: MaterialStoryInit<StoryKnobs> = {
177175
>${GOOGLE_LOGO}</md-suggestion-chip
178176
>
179177
<md-suggestion-chip
180-
label=${label || 'Disabled suggestion chip (focusable)'}
181-
disabled
182-
always-focusable
178+
label=${label || 'Soft-disabled suggestion chip (focusable)'}
179+
soft-disabled
183180
?elevated=${elevated}></md-suggestion-chip>
184181
</md-chip-set>
185182
`;

chips/internal/_shared.scss

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333
);
3434
}
3535

36-
:host([disabled]) {
36+
:host(:is([disabled], [soft-disabled])) {
3737
pointer-events: none;
3838
}
3939

@@ -242,7 +242,7 @@
242242
}
243243

244244
a,
245-
button:not(:disabled) {
245+
button:not(:disabled, [aria-disabled='true']) {
246246
cursor: inherit;
247247
}
248248
}

chips/internal/assist-chip.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,14 +27,14 @@ export class AssistChip extends Chip {
2727

2828
protected override get rippleDisabled() {
2929
// Link chips cannot be disabled
30-
return !this.href && this.disabled;
30+
return !this.href && (this.disabled || this.softDisabled);
3131
}
3232

3333
protected override getContainerClasses() {
3434
return {
3535
...super.getContainerClasses(),
3636
// Link chips cannot be disabled
37-
disabled: !this.href && this.disabled,
37+
disabled: !this.href && (this.disabled || this.softDisabled),
3838
elevated: this.elevated,
3939
link: !!this.href,
4040
};
@@ -60,6 +60,7 @@ export class AssistChip extends Chip {
6060
class="primary action"
6161
id="button"
6262
aria-label=${ariaLabel || nothing}
63+
aria-disabled=${this.softDisabled || nothing}
6364
?disabled=${this.disabled && !this.alwaysFocusable}
6465
type="button"
6566
>${content}</button

chips/internal/assist-chip_test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,5 +45,61 @@ describe('Assist chip', () => {
4545
.withContext('should not have any disabled styling or behavior')
4646
.toBeNull();
4747
});
48+
49+
it('should not allow link chips to be soft-disabled', async () => {
50+
// Arrange
51+
// Act
52+
const chip = await setupTest();
53+
chip.href = 'link';
54+
chip.softDisabled = true;
55+
await chip.updateComplete;
56+
57+
// Assert
58+
expect(chip.renderRoot.querySelector('.disabled,:disabled'))
59+
.withContext('should not have any disabled styling or behavior')
60+
.toBeNull();
61+
});
62+
});
63+
64+
it('should use aria-disabled when soft-disabled', async () => {
65+
// Arrange
66+
// Act
67+
const chip = await setupTest();
68+
chip.softDisabled = true;
69+
await chip.updateComplete;
70+
71+
// Assert
72+
expect(chip.renderRoot.querySelector('button[aria-disabled="true"]'))
73+
.withContext('should have aria-disabled="true"')
74+
.not.toBeNull();
75+
});
76+
77+
it('should be focusable when soft-disabled', async () => {
78+
// Arrange
79+
const chip = await setupTest();
80+
chip.softDisabled = true;
81+
await chip.updateComplete;
82+
83+
// Act
84+
chip.focus();
85+
86+
// Assert
87+
expect(document.activeElement)
88+
.withContext('soft-disabled chip should be focused')
89+
.toBe(chip);
90+
});
91+
92+
it('should not be clickable when soft-disabled', async () => {
93+
// Arrange
94+
const clickListener = jasmine.createSpy('clickListener');
95+
const chip = await setupTest();
96+
chip.softDisabled = true;
97+
chip.addEventListener('click', clickListener);
98+
99+
// Act
100+
chip.click();
101+
102+
// Assert
103+
expect(clickListener).not.toHaveBeenCalled();
48104
});
49105
});

chips/internal/chip-set_test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,21 @@ describe('Chip set', () => {
220220
});
221221
});
222222

223+
it('should NOT skip over soft-disabled chips', async () => {
224+
const first = new TestAssistChip();
225+
const second = new TestAssistChip();
226+
second.softDisabled = true;
227+
const third = new TestAssistChip();
228+
const chipSet = await setupTest([first, second, third]);
229+
await testNavigation({
230+
chipSet,
231+
ltrKey: 'ArrowRight',
232+
rtlKey: 'ArrowLeft',
233+
current: first,
234+
next: second,
235+
});
236+
});
237+
223238
it('should focus trailing actions when navigating backwards', async () => {
224239
const first = new TestInputChip();
225240
const second = new TestInputChip();

chips/internal/chip.ts

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import '../../focus/md-focus-ring.js';
88
import '../../ripple/ripple.js';
99

10-
import {html, LitElement, PropertyValues, TemplateResult} from 'lit';
10+
import {html, isServer, LitElement, PropertyValues, TemplateResult} from 'lit';
1111
import {property} from 'lit/decorators.js';
1212
import {ClassInfo, classMap} from 'lit/directives/class-map.js';
1313

@@ -35,12 +35,25 @@ export abstract class Chip extends chipBaseClass {
3535
*/
3636
@property({type: Boolean, reflect: true}) disabled = false;
3737

38+
/**
39+
* Whether or not the chip is "soft-disabled" (disabled but still
40+
* focusable).
41+
*
42+
* Use this when a chip needs increased visibility when disabled. See
43+
* https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/#kbd_disabled_controls
44+
* for more guidance on when this is needed.
45+
*/
46+
@property({type: Boolean, attribute: 'soft-disabled', reflect: true})
47+
softDisabled = false;
48+
3849
/**
3950
* When true, allow disabled chips to be focused with arrow keys.
4051
*
4152
* Add this when a chip needs increased visibility when disabled. See
4253
* https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/#kbd_disabled_controls
4354
* for more guidance on when this is needed.
55+
*
56+
* @deprecated Use `softDisabled` instead of `alwaysFocusable` + `disabled`.
4457
*/
4558
@property({type: Boolean, attribute: 'always-focusable'})
4659
alwaysFocusable = false;
@@ -70,7 +83,14 @@ export abstract class Chip extends chipBaseClass {
7083
* Some chip actions such as links cannot be disabled.
7184
*/
7285
protected get rippleDisabled() {
73-
return this.disabled;
86+
return this.disabled || this.softDisabled;
87+
}
88+
89+
constructor() {
90+
super();
91+
if (!isServer) {
92+
this.addEventListener('click', this.handleClick.bind(this));
93+
}
7494
}
7595

7696
override focus(options?: FocusOptions) {
@@ -97,7 +117,7 @@ export abstract class Chip extends chipBaseClass {
97117

98118
protected getContainerClasses(): ClassInfo {
99119
return {
100-
'disabled': this.disabled,
120+
'disabled': this.disabled || this.softDisabled,
101121
'has-icon': this.hasIcon,
102122
};
103123
}
@@ -139,4 +159,15 @@ export abstract class Chip extends chipBaseClass {
139159
const slot = event.target as HTMLSlotElement;
140160
this.hasIcon = slot.assignedElements({flatten: true}).length > 0;
141161
}
162+
163+
private handleClick(event: Event) {
164+
// If the chip is soft-disabled or disabled + always-focusable, we need to
165+
// explicitly prevent the click from propagating to other event listeners
166+
// as well as prevent the default action.
167+
if (this.softDisabled || (this.disabled && this.alwaysFocusable)) {
168+
event.stopImmediatePropagation();
169+
event.preventDefault();
170+
return;
171+
}
172+
}
142173
}

chips/internal/filter-chip.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -61,8 +61,9 @@ export class FilterChip extends MultiActionChip {
6161
id="button"
6262
aria-label=${ariaLabel || nothing}
6363
aria-pressed=${this.selected}
64+
aria-disabled=${this.softDisabled || nothing}
6465
?disabled=${this.disabled && !this.alwaysFocusable}
65-
@click=${this.handleClick}
66+
@click=${this.handleClickOnChild}
6667
>${content}</button
6768
>
6869
`;
@@ -88,7 +89,7 @@ export class FilterChip extends MultiActionChip {
8889
return renderRemoveButton({
8990
focusListener,
9091
ariaLabel: this.ariaLabelRemove,
91-
disabled: this.disabled,
92+
disabled: this.disabled || this.softDisabled,
9293
});
9394
}
9495

@@ -103,8 +104,8 @@ export class FilterChip extends MultiActionChip {
103104
return super.renderOutline();
104105
}
105106

106-
private handleClick(event: MouseEvent) {
107-
if (this.disabled) {
107+
private handleClickOnChild(event: MouseEvent) {
108+
if (this.disabled || this.softDisabled) {
108109
return;
109110
}
110111

chips/internal/filter-chip_test.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,18 @@ describe('Filter chip', () => {
5353
expect(chip.selected).withContext('chip.selected').toBeFalse();
5454
});
5555

56+
it('should not select on click when soft-disabled', async () => {
57+
// Arrange
58+
const {chip, harness} = await setupTest();
59+
chip.softDisabled = true;
60+
61+
// Act
62+
await harness.clickWithMouse();
63+
64+
// Assert
65+
expect(chip.selected).withContext('chip.selected').toBeFalse();
66+
});
67+
5668
it('can prevent default', async () => {
5769
const {chip, harness} = await setupTest();
5870
const handler = jasmine.createSpy();
@@ -100,4 +112,46 @@ describe('Filter chip', () => {
100112
.toBeTrue();
101113
});
102114
});
115+
116+
it('should be focusable when soft-disabled', async () => {
117+
// Arrange
118+
const {chip} = await setupTest();
119+
chip.softDisabled = true;
120+
await chip.updateComplete;
121+
122+
// Act
123+
chip.focus();
124+
125+
// Assert
126+
expect(document.activeElement)
127+
.withContext('soft-disabled chip should be focused')
128+
.toBe(chip);
129+
});
130+
131+
it('should not be clickable when soft-disabled', async () => {
132+
// Arrange
133+
const clickListener = jasmine.createSpy('clickListener');
134+
const {chip, harness} = await setupTest();
135+
chip.softDisabled = true;
136+
chip.addEventListener('click', clickListener);
137+
138+
// Act
139+
await harness.clickWithMouse();
140+
141+
// Assert
142+
expect(clickListener).not.toHaveBeenCalled();
143+
});
144+
145+
it('should use aria-disabled when soft-disabled', async () => {
146+
// Arrange
147+
// Act
148+
const {chip} = await setupTest();
149+
chip.softDisabled = true;
150+
await chip.updateComplete;
151+
152+
// Assert
153+
expect(chip.renderRoot.querySelector('button[aria-disabled="true"]'))
154+
.withContext('should have aria-disabled="true"')
155+
.not.toBeNull();
156+
});
103157
});

chips/internal/input-chip.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ export class InputChip extends MultiActionChip {
3838

3939
protected override get rippleDisabled() {
4040
// Link chips cannot be disabled
41-
return !this.href && this.disabled;
41+
return !this.href && (this.disabled || this.softDisabled);
4242
}
4343

4444
protected get primaryAction() {
@@ -59,7 +59,7 @@ export class InputChip extends MultiActionChip {
5959
...super.getContainerClasses(),
6060
avatar: this.avatar,
6161
// Link chips cannot be disabled
62-
disabled: !this.href && this.disabled,
62+
disabled: !this.href && (this.disabled || this.softDisabled),
6363
link: !!this.href,
6464
selected: this.selected,
6565
'has-trailing': true,
@@ -94,6 +94,7 @@ export class InputChip extends MultiActionChip {
9494
class="primary action"
9595
id="button"
9696
aria-label=${ariaLabel || nothing}
97+
aria-disabled=${this.softDisabled || nothing}
9798
?disabled=${this.disabled && !this.alwaysFocusable}
9899
type="button"
99100
>${content}</button
@@ -105,7 +106,7 @@ export class InputChip extends MultiActionChip {
105106
return renderRemoveButton({
106107
focusListener,
107108
ariaLabel: this.ariaLabelRemove,
108-
disabled: !this.href && this.disabled,
109+
disabled: !this.href && (this.disabled || this.softDisabled),
109110
tabbable: this.removeOnly,
110111
});
111112
}

0 commit comments

Comments
 (0)