Skip to content

Commit 8417601

Browse files
authored
feat(material/checkbox): add the ability to interact with disabled checkboxes (#29474)
Adds the `disabledInteractive` input to the checkbox that allows users to opt into having disabled checkboxes be interactive. The disabled state is communicated through `aria-disabled` instead.
1 parent 9ca2a0a commit 8417601

File tree

10 files changed

+127
-37
lines changed

10 files changed

+127
-37
lines changed

src/dev-app/checkbox/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ ng_module(
1616
"//src/material/form-field",
1717
"//src/material/input",
1818
"//src/material/select",
19+
"//src/material/tooltip",
1920
"@npm//@angular/forms",
2021
],
2122
)

src/dev-app/checkbox/checkbox-demo.html

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,19 +26,20 @@ <h1>mat-checkbox: Basic Example</h1>
2626
(change)="isIndeterminate = false"
2727
[indeterminate]="isIndeterminate"
2828
[disabled]="isDisabled"
29-
[labelPosition]="labelPosition">
29+
[disabledInteractive]="isDisabledInteractive"
30+
[labelPosition]="labelPosition"
31+
[matTooltip]="isDisabled ? 'Tooltip that only shows up when disabled' : null">
3032
Do you want to <em>foobar</em> the <em>bazquux</em>?
3133

3234
</mat-checkbox> - <strong>{{printResult()}}</strong>
3335
</form>
3436
<div class="demo-checkbox">
35-
<input id="indeterminate-toggle"
36-
type="checkbox"
37-
[(ngModel)]="isIndeterminate"
38-
[disabled]="isDisabled">
37+
<input id="indeterminate-toggle" type="checkbox" [(ngModel)]="isIndeterminate">
3938
<label for="indeterminate-toggle">Toggle Indeterminate</label>
4039
<input id="disabled-toggle" type="checkbox" [(ngModel)]="isDisabled">
4140
<label for="disabled-toggle">Toggle Disabled</label>
41+
<input id="disabled-interactive-toggle" type="checkbox" [(ngModel)]="isDisabledInteractive">
42+
<label for="disabled-interactive-toggle">Toggle Disabled Interactive</label>
4243
<input id="color-toggle" type="checkbox" [(ngModel)]="useAlternativeColor">
4344
<label for="color-toggle">Toggle Color</label>
4445
</div>

src/dev-app/checkbox/checkbox-demo.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {MAT_CHECKBOX_DEFAULT_OPTIONS, MatCheckboxModule} from '@angular/material
1313
import {MatPseudoCheckboxModule, ThemePalette} from '@angular/material/core';
1414
import {MatInputModule} from '@angular/material/input';
1515
import {MatSelectModule} from '@angular/material/select';
16+
import {MatTooltip} from '@angular/material/tooltip';
1617

1718
export interface Task {
1819
name: string;
@@ -114,15 +115,17 @@ export class MatCheckboxDemoNestedChecklist {
114115
ClickActionNoop,
115116
ClickActionCheck,
116117
AnimationsNoop,
118+
MatTooltip,
117119
],
118120
changeDetection: ChangeDetectionStrategy.OnPush,
119121
})
120122
export class CheckboxDemo {
121-
isIndeterminate: boolean = false;
122-
isChecked: boolean = false;
123-
isDisabled: boolean = false;
123+
isIndeterminate = false;
124+
isChecked = false;
125+
isDisabled = false;
126+
isDisabledInteractive = false;
124127
labelPosition: 'before' | 'after' = 'after';
125-
useAlternativeColor: boolean = false;
128+
useAlternativeColor = false;
126129

127130
demoRequired = false;
128131
demoLabelAfter = false;

src/material/checkbox/_checkbox-common.scss

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,21 @@ $_fallback-size: 40px;
138138
@include token-utils.create-token-slot(border-color, selected-focus-icon-color);
139139
@include token-utils.create-token-slot(background-color, selected-focus-icon-color);
140140
}
141+
142+
// Needs extra specificity to override the focus, hover, active states.
143+
.mdc-checkbox--disabled.mat-mdc-checkbox-disabled-interactive {
144+
.mdc-checkbox:hover .mdc-checkbox__native-control ~ .mdc-checkbox__background,
145+
.mdc-checkbox .mdc-checkbox__native-control:focus ~ .mdc-checkbox__background,
146+
.mdc-checkbox__background {
147+
@include token-utils.create-token-slot(border-color, disabled-unselected-icon-color);
148+
}
149+
150+
.mdc-checkbox__native-control:checked ~ .mdc-checkbox__background,
151+
.mdc-checkbox__native-control:indeterminate ~ .mdc-checkbox__background {
152+
@include token-utils.create-token-slot(background-color, disabled-selected-icon-color);
153+
border-color: transparent;
154+
}
155+
}
141156
}
142157

143158
.mdc-checkbox__checkmark {
@@ -158,8 +173,12 @@ $_fallback-size: 40px;
158173
}
159174

160175
@include token-utils.use-tokens($prefix, $slots) {
161-
.mdc-checkbox--disabled .mdc-checkbox__checkmark {
162-
@include token-utils.create-token-slot(color, disabled-selected-checkmark-color);
176+
.mdc-checkbox--disabled {
177+
&, &.mat-mdc-checkbox-disabled-interactive {
178+
.mdc-checkbox__checkmark {
179+
@include token-utils.create-token-slot(color, disabled-selected-checkmark-color);
180+
}
181+
}
163182
}
164183
}
165184

@@ -193,8 +212,12 @@ $_fallback-size: 40px;
193212
}
194213

195214
@include token-utils.use-tokens($prefix, $slots) {
196-
.mdc-checkbox--disabled .mdc-checkbox__mixedmark {
197-
@include token-utils.create-token-slot(border-color, disabled-selected-checkmark-color);
215+
.mdc-checkbox--disabled {
216+
&, &.mat-mdc-checkbox-disabled-interactive {
217+
.mdc-checkbox__mixedmark {
218+
@include token-utils.create-token-slot(border-color, disabled-selected-checkmark-color);
219+
}
220+
}
198221
}
199222
}
200223

@@ -520,4 +543,15 @@ $_fallback-size: 40px;
520543
);
521544
}
522545
}
546+
547+
// Needs extra specificity to override the focus, hover, active states.
548+
.mdc-checkbox--disabled.mat-mdc-checkbox-disabled-interactive & {
549+
.mdc-checkbox__native-control ~ .mat-mdc-checkbox-ripple .mat-ripple-element,
550+
.mdc-checkbox__native-control ~ .mdc-checkbox__ripple {
551+
@include token-utils.create-token-slot(
552+
background-color,
553+
unselected-hover-state-layer-color
554+
);
555+
}
556+
}
523557
}

src/material/checkbox/checkbox-config.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,12 @@ export interface MatCheckboxDefaultOptions {
1818
* https://material.angular.io/guide/theming#using-component-color-variants
1919
*/
2020
color?: ThemePalette;
21+
2122
/** Default checkbox click action for checkboxes. */
2223
clickAction?: MatCheckboxClickAction;
24+
25+
/** Whether disabled checkboxes should be interactive. */
26+
disabledInteractive?: boolean;
2327
}
2428

2529
/** Injection token to be used to override the default options for `mat-checkbox`. */
@@ -36,6 +40,7 @@ export function MAT_CHECKBOX_DEFAULT_OPTIONS_FACTORY(): MatCheckboxDefaultOption
3640
return {
3741
color: 'accent',
3842
clickAction: 'check-indeterminate',
43+
disabledInteractive: false,
3944
};
4045
}
4146

src/material/checkbox/checkbox.html

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,15 @@
1010
[attr.aria-labelledby]="ariaLabelledby"
1111
[attr.aria-describedby]="ariaDescribedby"
1212
[attr.aria-checked]="indeterminate ? 'mixed' : null"
13+
[attr.aria-disabled]="disabled && disabledInteractive ? true : null"
1314
[attr.name]="name"
1415
[attr.value]="value"
1516
[checked]="checked"
1617
[indeterminate]="indeterminate"
17-
[disabled]="disabled"
18+
[disabled]="disabled && !disabledInteractive"
1819
[id]="inputId"
1920
[required]="required"
20-
[tabIndex]="disabled ? -1 : tabIndex"
21+
[tabIndex]="disabled && !disabledInteractive ? -1 : tabIndex"
2122
(blur)="_onBlur()"
2223
(click)="_onInputClick()"
2324
(change)="_onInteractionEvent($event)"/>
@@ -43,9 +44,7 @@
4344
(#14385). Putting a click handler on the <label/> caused this bug because the browser produced
4445
an unnecessary accessibility tree node.
4546
-->
46-
<label class="mdc-label"
47-
#label
48-
[for]="inputId">
47+
<label class="mdc-label" #label [for]="inputId">
4948
<ng-content></ng-content>
5049
</label>
5150
</div>

src/material/checkbox/checkbox.scss

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -42,14 +42,24 @@
4242
}
4343
}
4444

45-
&.mat-mdc-checkbox-disabled label {
46-
cursor: default;
45+
&.mat-mdc-checkbox-disabled {
46+
&.mat-mdc-checkbox-disabled-interactive {
47+
pointer-events: auto;
4748

48-
@include token-utils.use-tokens(
49-
tokens-mat-checkbox.$prefix,
50-
tokens-mat-checkbox.get-token-slots()
51-
) {
52-
@include token-utils.create-token-slot(color, disabled-label-color);
49+
input {
50+
cursor: default;
51+
}
52+
}
53+
54+
label {
55+
cursor: default;
56+
57+
@include token-utils.use-tokens(
58+
tokens-mat-checkbox.$prefix,
59+
tokens-mat-checkbox.get-token-slots()
60+
) {
61+
@include token-utils.create-token-slot(color, disabled-label-color);
62+
}
5363
}
5464
}
5565

src/material/checkbox/checkbox.spec.ts

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -444,6 +444,28 @@ describe('MDC-based MatCheckbox', () => {
444444
expect(checkboxNativeElement.querySelector('svg')!.getAttribute('focusable')).toBe('false');
445445
}));
446446

447+
it('should be able to mark a checkbox as disabled while keeping it interactive', fakeAsync(() => {
448+
testComponent.isDisabled = true;
449+
fixture.changeDetectorRef.markForCheck();
450+
fixture.detectChanges();
451+
452+
expect(checkboxNativeElement.classList).not.toContain(
453+
'mat-mdc-checkbox-disabled-interactive',
454+
);
455+
expect(inputElement.hasAttribute('aria-disabled')).toBe(false);
456+
expect(inputElement.tabIndex).toBe(-1);
457+
expect(inputElement.disabled).toBe(true);
458+
459+
testComponent.disabledInteractive = true;
460+
fixture.changeDetectorRef.markForCheck();
461+
fixture.detectChanges();
462+
463+
expect(checkboxNativeElement.classList).toContain('mat-mdc-checkbox-disabled-interactive');
464+
expect(inputElement.getAttribute('aria-disabled')).toBe('true');
465+
expect(inputElement.tabIndex).toBe(0);
466+
expect(inputElement.disabled).toBe(false);
467+
}));
468+
447469
describe('ripple elements', () => {
448470
it('should show ripples on label mousedown', fakeAsync(() => {
449471
const rippleSelector = '.mat-ripple-element:not(.mat-checkbox-persistent-ripple)';
@@ -1111,6 +1133,7 @@ describe('MatCheckboxDefaultOptions', () => {
11111133
[color]="checkboxColor"
11121134
[disableRipple]="disableRipple"
11131135
[value]="checkboxValue"
1136+
[disabledInteractive]="disabledInteractive"
11141137
(change)="onCheckboxChange($event)">
11151138
Simple checkbox
11161139
</mat-checkbox>
@@ -1120,13 +1143,14 @@ describe('MatCheckboxDefaultOptions', () => {
11201143
})
11211144
class SingleCheckbox {
11221145
labelPos: 'before' | 'after' = 'after';
1123-
isChecked: boolean = false;
1124-
isRequired: boolean = false;
1125-
isIndeterminate: boolean = false;
1126-
isDisabled: boolean = false;
1127-
disableRipple: boolean = false;
1128-
parentElementClicked: boolean = false;
1129-
parentElementKeyedUp: boolean = false;
1146+
isChecked = false;
1147+
isRequired = false;
1148+
isIndeterminate = false;
1149+
isDisabled = false;
1150+
disableRipple = false;
1151+
parentElementClicked = false;
1152+
parentElementKeyedUp = false;
1153+
disabledInteractive = false;
11301154
checkboxId: string | null = 'simple-check';
11311155
checkboxColor: ThemePalette = 'primary';
11321156
checkboxValue: string = 'single_checkbox';
@@ -1143,9 +1167,9 @@ class SingleCheckbox {
11431167
imports: [MatCheckbox, FormsModule],
11441168
})
11451169
class CheckboxWithNgModel {
1146-
isGood: boolean = false;
1147-
isRequired: boolean = true;
1148-
isDisabled: boolean = false;
1170+
isGood = false;
1171+
isRequired = true;
1172+
isDisabled = false;
11491173
}
11501174

11511175
@Component({

src/material/checkbox/checkbox.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ const defaults = MAT_CHECKBOX_DEFAULT_OPTIONS_FACTORY();
9898
// Add classes that users can use to more easily target disabled or checked checkboxes.
9999
'[class.mat-mdc-checkbox-disabled]': 'disabled',
100100
'[class.mat-mdc-checkbox-checked]': 'checked',
101+
'[class.mat-mdc-checkbox-disabled-interactive]': 'disabledInteractive',
101102
'[class]': 'color ? "mat-" + color : "mat-accent"',
102103
},
103104
providers: [
@@ -211,6 +212,10 @@ export class MatCheckbox
211212
*/
212213
@Input() color: string | undefined;
213214

215+
/** Whether the checkbox should remain interactive when it is disabled. */
216+
@Input({transform: booleanAttribute})
217+
disabledInteractive: boolean;
218+
214219
/**
215220
* Reference to the MatRipple instance of the checkbox.
216221
* @deprecated Considered an implementation detail. To be removed.
@@ -241,6 +246,7 @@ export class MatCheckbox
241246
this.color = this._options.color || defaults.color;
242247
this.tabIndex = parseInt(tabIndex) || 0;
243248
this.id = this._uniqueId = `mat-mdc-checkbox-${++nextUniqueId}`;
249+
this.disabledInteractive = _options?.disabledInteractive ?? false;
244250
}
245251

246252
ngOnChanges(changes: SimpleChanges) {
@@ -422,7 +428,10 @@ export class MatCheckbox
422428
// It is important to only emit it, if the native input triggered one, because
423429
// we don't want to trigger a change event, when the `checked` variable changes for example.
424430
this._emitChangeEvent();
425-
} else if (!this.disabled && clickAction === 'noop') {
431+
} else if (
432+
(this.disabled && this.disabledInteractive) ||
433+
(!this.disabled && clickAction === 'noop')
434+
) {
426435
// Reset native input when clicked with noop. The native checkbox becomes checked after
427436
// click, reset it to be align with `checked` value of `mat-checkbox`.
428437
this._inputElement.nativeElement.checked = this.checked;

tools/public_api_guard/material/checkbox.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ export class MatCheckbox implements AfterViewInit, OnChanges, ControlValueAccess
5959
protected _createChangeEvent(isChecked: boolean): MatCheckboxChange;
6060
get disabled(): boolean;
6161
set disabled(value: boolean);
62+
disabledInteractive: boolean;
6263
disableRipple: boolean;
6364
// (undocumented)
6465
_elementRef: ElementRef<HTMLElement>;
@@ -82,6 +83,8 @@ export class MatCheckbox implements AfterViewInit, OnChanges, ControlValueAccess
8283
// (undocumented)
8384
static ngAcceptInputType_disabled: unknown;
8485
// (undocumented)
86+
static ngAcceptInputType_disabledInteractive: unknown;
87+
// (undocumented)
8588
static ngAcceptInputType_disableRipple: unknown;
8689
// (undocumented)
8790
static ngAcceptInputType_indeterminate: unknown;
@@ -123,7 +126,7 @@ export class MatCheckbox implements AfterViewInit, OnChanges, ControlValueAccess
123126
// (undocumented)
124127
writeValue(value: any): void;
125128
// (undocumented)
126-
static ɵcmp: i0.ɵɵComponentDeclaration<MatCheckbox, "mat-checkbox", ["matCheckbox"], { "ariaLabel": { "alias": "aria-label"; "required": false; }; "ariaLabelledby": { "alias": "aria-labelledby"; "required": false; }; "ariaDescribedby": { "alias": "aria-describedby"; "required": false; }; "id": { "alias": "id"; "required": false; }; "required": { "alias": "required"; "required": false; }; "labelPosition": { "alias": "labelPosition"; "required": false; }; "name": { "alias": "name"; "required": false; }; "value": { "alias": "value"; "required": false; }; "disableRipple": { "alias": "disableRipple"; "required": false; }; "tabIndex": { "alias": "tabIndex"; "required": false; }; "color": { "alias": "color"; "required": false; }; "checked": { "alias": "checked"; "required": false; }; "disabled": { "alias": "disabled"; "required": false; }; "indeterminate": { "alias": "indeterminate"; "required": false; }; }, { "change": "change"; "indeterminateChange": "indeterminateChange"; }, never, ["*"], true, never>;
129+
static ɵcmp: i0.ɵɵComponentDeclaration<MatCheckbox, "mat-checkbox", ["matCheckbox"], { "ariaLabel": { "alias": "aria-label"; "required": false; }; "ariaLabelledby": { "alias": "aria-labelledby"; "required": false; }; "ariaDescribedby": { "alias": "aria-describedby"; "required": false; }; "id": { "alias": "id"; "required": false; }; "required": { "alias": "required"; "required": false; }; "labelPosition": { "alias": "labelPosition"; "required": false; }; "name": { "alias": "name"; "required": false; }; "value": { "alias": "value"; "required": false; }; "disableRipple": { "alias": "disableRipple"; "required": false; }; "tabIndex": { "alias": "tabIndex"; "required": false; }; "color": { "alias": "color"; "required": false; }; "disabledInteractive": { "alias": "disabledInteractive"; "required": false; }; "checked": { "alias": "checked"; "required": false; }; "disabled": { "alias": "disabled"; "required": false; }; "indeterminate": { "alias": "indeterminate"; "required": false; }; }, { "change": "change"; "indeterminateChange": "indeterminateChange"; }, never, ["*"], true, never>;
127130
// (undocumented)
128131
static ɵfac: i0.ɵɵFactoryDeclaration<MatCheckbox, [null, null, null, { attribute: "tabindex"; }, { optional: true; }, { optional: true; }]>;
129132
}
@@ -141,6 +144,7 @@ export type MatCheckboxClickAction = 'noop' | 'check' | 'check-indeterminate' |
141144
export interface MatCheckboxDefaultOptions {
142145
clickAction?: MatCheckboxClickAction;
143146
color?: ThemePalette;
147+
disabledInteractive?: boolean;
144148
}
145149

146150
// @public (undocumented)

0 commit comments

Comments
 (0)