Skip to content

Commit

Permalink
feat(material/checkbox): add the ability to interact with disabled ch…
Browse files Browse the repository at this point in the history
…eckboxes (#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.
  • Loading branch information
crisbeto authored Jul 24, 2024
1 parent 9ca2a0a commit 8417601
Show file tree
Hide file tree
Showing 10 changed files with 127 additions and 37 deletions.
1 change: 1 addition & 0 deletions src/dev-app/checkbox/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ ng_module(
"//src/material/form-field",
"//src/material/input",
"//src/material/select",
"//src/material/tooltip",
"@npm//@angular/forms",
],
)
Expand Down
11 changes: 6 additions & 5 deletions src/dev-app/checkbox/checkbox-demo.html
Original file line number Diff line number Diff line change
Expand Up @@ -26,19 +26,20 @@ <h1>mat-checkbox: Basic Example</h1>
(change)="isIndeterminate = false"
[indeterminate]="isIndeterminate"
[disabled]="isDisabled"
[labelPosition]="labelPosition">
[disabledInteractive]="isDisabledInteractive"
[labelPosition]="labelPosition"
[matTooltip]="isDisabled ? 'Tooltip that only shows up when disabled' : null">
Do you want to <em>foobar</em> the <em>bazquux</em>?

</mat-checkbox> - <strong>{{printResult()}}</strong>
</form>
<div class="demo-checkbox">
<input id="indeterminate-toggle"
type="checkbox"
[(ngModel)]="isIndeterminate"
[disabled]="isDisabled">
<input id="indeterminate-toggle" type="checkbox" [(ngModel)]="isIndeterminate">
<label for="indeterminate-toggle">Toggle Indeterminate</label>
<input id="disabled-toggle" type="checkbox" [(ngModel)]="isDisabled">
<label for="disabled-toggle">Toggle Disabled</label>
<input id="disabled-interactive-toggle" type="checkbox" [(ngModel)]="isDisabledInteractive">
<label for="disabled-interactive-toggle">Toggle Disabled Interactive</label>
<input id="color-toggle" type="checkbox" [(ngModel)]="useAlternativeColor">
<label for="color-toggle">Toggle Color</label>
</div>
Expand Down
11 changes: 7 additions & 4 deletions src/dev-app/checkbox/checkbox-demo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {MAT_CHECKBOX_DEFAULT_OPTIONS, MatCheckboxModule} from '@angular/material
import {MatPseudoCheckboxModule, ThemePalette} from '@angular/material/core';
import {MatInputModule} from '@angular/material/input';
import {MatSelectModule} from '@angular/material/select';
import {MatTooltip} from '@angular/material/tooltip';

export interface Task {
name: string;
Expand Down Expand Up @@ -114,15 +115,17 @@ export class MatCheckboxDemoNestedChecklist {
ClickActionNoop,
ClickActionCheck,
AnimationsNoop,
MatTooltip,
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class CheckboxDemo {
isIndeterminate: boolean = false;
isChecked: boolean = false;
isDisabled: boolean = false;
isIndeterminate = false;
isChecked = false;
isDisabled = false;
isDisabledInteractive = false;
labelPosition: 'before' | 'after' = 'after';
useAlternativeColor: boolean = false;
useAlternativeColor = false;

demoRequired = false;
demoLabelAfter = false;
Expand Down
42 changes: 38 additions & 4 deletions src/material/checkbox/_checkbox-common.scss
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,21 @@ $_fallback-size: 40px;
@include token-utils.create-token-slot(border-color, selected-focus-icon-color);
@include token-utils.create-token-slot(background-color, selected-focus-icon-color);
}

// Needs extra specificity to override the focus, hover, active states.
.mdc-checkbox--disabled.mat-mdc-checkbox-disabled-interactive {
.mdc-checkbox:hover .mdc-checkbox__native-control ~ .mdc-checkbox__background,
.mdc-checkbox .mdc-checkbox__native-control:focus ~ .mdc-checkbox__background,
.mdc-checkbox__background {
@include token-utils.create-token-slot(border-color, disabled-unselected-icon-color);
}

.mdc-checkbox__native-control:checked ~ .mdc-checkbox__background,
.mdc-checkbox__native-control:indeterminate ~ .mdc-checkbox__background {
@include token-utils.create-token-slot(background-color, disabled-selected-icon-color);
border-color: transparent;
}
}
}

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

@include token-utils.use-tokens($prefix, $slots) {
.mdc-checkbox--disabled .mdc-checkbox__checkmark {
@include token-utils.create-token-slot(color, disabled-selected-checkmark-color);
.mdc-checkbox--disabled {
&, &.mat-mdc-checkbox-disabled-interactive {
.mdc-checkbox__checkmark {
@include token-utils.create-token-slot(color, disabled-selected-checkmark-color);
}
}
}
}

Expand Down Expand Up @@ -193,8 +212,12 @@ $_fallback-size: 40px;
}

@include token-utils.use-tokens($prefix, $slots) {
.mdc-checkbox--disabled .mdc-checkbox__mixedmark {
@include token-utils.create-token-slot(border-color, disabled-selected-checkmark-color);
.mdc-checkbox--disabled {
&, &.mat-mdc-checkbox-disabled-interactive {
.mdc-checkbox__mixedmark {
@include token-utils.create-token-slot(border-color, disabled-selected-checkmark-color);
}
}
}
}

Expand Down Expand Up @@ -520,4 +543,15 @@ $_fallback-size: 40px;
);
}
}

// Needs extra specificity to override the focus, hover, active states.
.mdc-checkbox--disabled.mat-mdc-checkbox-disabled-interactive & {
.mdc-checkbox__native-control ~ .mat-mdc-checkbox-ripple .mat-ripple-element,
.mdc-checkbox__native-control ~ .mdc-checkbox__ripple {
@include token-utils.create-token-slot(
background-color,
unselected-hover-state-layer-color
);
}
}
}
5 changes: 5 additions & 0 deletions src/material/checkbox/checkbox-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,12 @@ export interface MatCheckboxDefaultOptions {
* https://material.angular.io/guide/theming#using-component-color-variants
*/
color?: ThemePalette;

/** Default checkbox click action for checkboxes. */
clickAction?: MatCheckboxClickAction;

/** Whether disabled checkboxes should be interactive. */
disabledInteractive?: boolean;
}

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

Expand Down
9 changes: 4 additions & 5 deletions src/material/checkbox/checkbox.html
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,15 @@
[attr.aria-labelledby]="ariaLabelledby"
[attr.aria-describedby]="ariaDescribedby"
[attr.aria-checked]="indeterminate ? 'mixed' : null"
[attr.aria-disabled]="disabled && disabledInteractive ? true : null"
[attr.name]="name"
[attr.value]="value"
[checked]="checked"
[indeterminate]="indeterminate"
[disabled]="disabled"
[disabled]="disabled && !disabledInteractive"
[id]="inputId"
[required]="required"
[tabIndex]="disabled ? -1 : tabIndex"
[tabIndex]="disabled && !disabledInteractive ? -1 : tabIndex"
(blur)="_onBlur()"
(click)="_onInputClick()"
(change)="_onInteractionEvent($event)"/>
Expand All @@ -43,9 +44,7 @@
(#14385). Putting a click handler on the <label/> caused this bug because the browser produced
an unnecessary accessibility tree node.
-->
<label class="mdc-label"
#label
[for]="inputId">
<label class="mdc-label" #label [for]="inputId">
<ng-content></ng-content>
</label>
</div>
24 changes: 17 additions & 7 deletions src/material/checkbox/checkbox.scss
Original file line number Diff line number Diff line change
Expand Up @@ -42,14 +42,24 @@
}
}

&.mat-mdc-checkbox-disabled label {
cursor: default;
&.mat-mdc-checkbox-disabled {
&.mat-mdc-checkbox-disabled-interactive {
pointer-events: auto;

@include token-utils.use-tokens(
tokens-mat-checkbox.$prefix,
tokens-mat-checkbox.get-token-slots()
) {
@include token-utils.create-token-slot(color, disabled-label-color);
input {
cursor: default;
}
}

label {
cursor: default;

@include token-utils.use-tokens(
tokens-mat-checkbox.$prefix,
tokens-mat-checkbox.get-token-slots()
) {
@include token-utils.create-token-slot(color, disabled-label-color);
}
}
}

Expand Down
44 changes: 34 additions & 10 deletions src/material/checkbox/checkbox.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -444,6 +444,28 @@ describe('MDC-based MatCheckbox', () => {
expect(checkboxNativeElement.querySelector('svg')!.getAttribute('focusable')).toBe('false');
}));

it('should be able to mark a checkbox as disabled while keeping it interactive', fakeAsync(() => {
testComponent.isDisabled = true;
fixture.changeDetectorRef.markForCheck();
fixture.detectChanges();

expect(checkboxNativeElement.classList).not.toContain(
'mat-mdc-checkbox-disabled-interactive',
);
expect(inputElement.hasAttribute('aria-disabled')).toBe(false);
expect(inputElement.tabIndex).toBe(-1);
expect(inputElement.disabled).toBe(true);

testComponent.disabledInteractive = true;
fixture.changeDetectorRef.markForCheck();
fixture.detectChanges();

expect(checkboxNativeElement.classList).toContain('mat-mdc-checkbox-disabled-interactive');
expect(inputElement.getAttribute('aria-disabled')).toBe('true');
expect(inputElement.tabIndex).toBe(0);
expect(inputElement.disabled).toBe(false);
}));

describe('ripple elements', () => {
it('should show ripples on label mousedown', fakeAsync(() => {
const rippleSelector = '.mat-ripple-element:not(.mat-checkbox-persistent-ripple)';
Expand Down Expand Up @@ -1111,6 +1133,7 @@ describe('MatCheckboxDefaultOptions', () => {
[color]="checkboxColor"
[disableRipple]="disableRipple"
[value]="checkboxValue"
[disabledInteractive]="disabledInteractive"
(change)="onCheckboxChange($event)">
Simple checkbox
</mat-checkbox>
Expand All @@ -1120,13 +1143,14 @@ describe('MatCheckboxDefaultOptions', () => {
})
class SingleCheckbox {
labelPos: 'before' | 'after' = 'after';
isChecked: boolean = false;
isRequired: boolean = false;
isIndeterminate: boolean = false;
isDisabled: boolean = false;
disableRipple: boolean = false;
parentElementClicked: boolean = false;
parentElementKeyedUp: boolean = false;
isChecked = false;
isRequired = false;
isIndeterminate = false;
isDisabled = false;
disableRipple = false;
parentElementClicked = false;
parentElementKeyedUp = false;
disabledInteractive = false;
checkboxId: string | null = 'simple-check';
checkboxColor: ThemePalette = 'primary';
checkboxValue: string = 'single_checkbox';
Expand All @@ -1143,9 +1167,9 @@ class SingleCheckbox {
imports: [MatCheckbox, FormsModule],
})
class CheckboxWithNgModel {
isGood: boolean = false;
isRequired: boolean = true;
isDisabled: boolean = false;
isGood = false;
isRequired = true;
isDisabled = false;
}

@Component({
Expand Down
11 changes: 10 additions & 1 deletion src/material/checkbox/checkbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ const defaults = MAT_CHECKBOX_DEFAULT_OPTIONS_FACTORY();
// Add classes that users can use to more easily target disabled or checked checkboxes.
'[class.mat-mdc-checkbox-disabled]': 'disabled',
'[class.mat-mdc-checkbox-checked]': 'checked',
'[class.mat-mdc-checkbox-disabled-interactive]': 'disabledInteractive',
'[class]': 'color ? "mat-" + color : "mat-accent"',
},
providers: [
Expand Down Expand Up @@ -211,6 +212,10 @@ export class MatCheckbox
*/
@Input() color: string | undefined;

/** Whether the checkbox should remain interactive when it is disabled. */
@Input({transform: booleanAttribute})
disabledInteractive: boolean;

/**
* Reference to the MatRipple instance of the checkbox.
* @deprecated Considered an implementation detail. To be removed.
Expand Down Expand Up @@ -241,6 +246,7 @@ export class MatCheckbox
this.color = this._options.color || defaults.color;
this.tabIndex = parseInt(tabIndex) || 0;
this.id = this._uniqueId = `mat-mdc-checkbox-${++nextUniqueId}`;
this.disabledInteractive = _options?.disabledInteractive ?? false;
}

ngOnChanges(changes: SimpleChanges) {
Expand Down Expand Up @@ -422,7 +428,10 @@ export class MatCheckbox
// It is important to only emit it, if the native input triggered one, because
// we don't want to trigger a change event, when the `checked` variable changes for example.
this._emitChangeEvent();
} else if (!this.disabled && clickAction === 'noop') {
} else if (
(this.disabled && this.disabledInteractive) ||
(!this.disabled && clickAction === 'noop')
) {
// Reset native input when clicked with noop. The native checkbox becomes checked after
// click, reset it to be align with `checked` value of `mat-checkbox`.
this._inputElement.nativeElement.checked = this.checked;
Expand Down
6 changes: 5 additions & 1 deletion tools/public_api_guard/material/checkbox.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export class MatCheckbox implements AfterViewInit, OnChanges, ControlValueAccess
protected _createChangeEvent(isChecked: boolean): MatCheckboxChange;
get disabled(): boolean;
set disabled(value: boolean);
disabledInteractive: boolean;
disableRipple: boolean;
// (undocumented)
_elementRef: ElementRef<HTMLElement>;
Expand All @@ -82,6 +83,8 @@ export class MatCheckbox implements AfterViewInit, OnChanges, ControlValueAccess
// (undocumented)
static ngAcceptInputType_disabled: unknown;
// (undocumented)
static ngAcceptInputType_disabledInteractive: unknown;
// (undocumented)
static ngAcceptInputType_disableRipple: unknown;
// (undocumented)
static ngAcceptInputType_indeterminate: unknown;
Expand Down Expand Up @@ -123,7 +126,7 @@ export class MatCheckbox implements AfterViewInit, OnChanges, ControlValueAccess
// (undocumented)
writeValue(value: any): void;
// (undocumented)
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>;
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>;
// (undocumented)
static ɵfac: i0.ɵɵFactoryDeclaration<MatCheckbox, [null, null, null, { attribute: "tabindex"; }, { optional: true; }, { optional: true; }]>;
}
Expand All @@ -141,6 +144,7 @@ export type MatCheckboxClickAction = 'noop' | 'check' | 'check-indeterminate' |
export interface MatCheckboxDefaultOptions {
clickAction?: MatCheckboxClickAction;
color?: ThemePalette;
disabledInteractive?: boolean;
}

// @public (undocumented)
Expand Down

0 comments on commit 8417601

Please sign in to comment.