Skip to content

Commit

Permalink
feat(material/button-toggle): allow disabled buttons to be interactive
Browse files Browse the repository at this point in the history
Adds the `disabledInteractive` input to the button toggle that allows users to opt into supporting focus on disabled button toggles.
  • Loading branch information
crisbeto committed Aug 7, 2024
1 parent d22a24d commit b42847a
Show file tree
Hide file tree
Showing 9 changed files with 134 additions and 21 deletions.
46 changes: 39 additions & 7 deletions src/dev-app/button-toggle/button-toggle-demo.html
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@
<mat-checkbox (change)="isDisabled = $event.checked">Disable Button Toggle Items</mat-checkbox>
</p>

<p>
<mat-checkbox (change)="disabledInteractive = $event.checked">Allow Interaction with Disabled Button Toggles</mat-checkbox>
</p>

<p>
<mat-checkbox (change)="hideSingleSelectionIndicator = $event.checked">Hide Single Selection Indicator</mat-checkbox>
</p>
Expand All @@ -17,7 +21,11 @@
<h1>Exclusive Selection</h1>

<section>
<mat-button-toggle-group name="alignment" [vertical]="isVertical" [hideSingleSelectionIndicator]="hideSingleSelectionIndicator">
<mat-button-toggle-group
name="alignment"
[vertical]="isVertical"
[hideSingleSelectionIndicator]="hideSingleSelectionIndicator"
[disabledInteractive]="disabledInteractive">
<mat-button-toggle value="left" [disabled]="isDisabled">
<mat-icon>format_align_left</mat-icon>
</mat-button-toggle>
Expand All @@ -34,7 +42,12 @@ <h1>Exclusive Selection</h1>
</section>

<section>
<mat-button-toggle-group appearance="legacy" name="alignment" [vertical]="isVertical" [hideSingleSelectionIndicator]="hideSingleSelectionIndicator">
<mat-button-toggle-group
appearance="legacy"
name="alignment"
[vertical]="isVertical"
[hideSingleSelectionIndicator]="hideSingleSelectionIndicator"
[disabledInteractive]="disabledInteractive">
<mat-button-toggle value="left" [disabled]="isDisabled">
<mat-icon>format_align_left</mat-icon>
</mat-button-toggle>
Expand All @@ -53,30 +66,44 @@ <h1>Exclusive Selection</h1>
<h1>Disabled Group</h1>

<section>
<mat-button-toggle-group name="checkbox" [vertical]="isVertical" [disabled]="isDisabled" [hideSingleSelectionIndicator]="hideSingleSelectionIndicator">
<mat-button-toggle-group
name="checkbox"
[vertical]="isVertical"
[disabled]="isDisabled"
[hideSingleSelectionIndicator]="hideSingleSelectionIndicator"
[disabledInteractive]="disabledInteractive">
<mat-button-toggle value="bold">
<mat-icon>format_bold</mat-icon>
</mat-button-toggle>
<mat-button-toggle value="italic">
<mat-icon>format_italic</mat-icon>
</mat-button-toggle>
<mat-button-toggle value="underline">
<mat-icon>format_underline</mat-icon>
<mat-icon>format_underlined</mat-icon>
</mat-button-toggle>
</mat-button-toggle-group>
</section>

<h1>Multiple Selection</h1>
<section>
<mat-button-toggle-group multiple [vertical]="isVertical" [hideMultipleSelectionIndicator]="hideMultipleSelectionIndicator">
<mat-button-toggle-group
multiple
[vertical]="isVertical"
[hideMultipleSelectionIndicator]="hideMultipleSelectionIndicator"
[disabledInteractive]="disabledInteractive">
<mat-button-toggle>Flour</mat-button-toggle>
<mat-button-toggle>Eggs</mat-button-toggle>
<mat-button-toggle>Sugar</mat-button-toggle>
<mat-button-toggle [disabled]="isDisabled">Milk</mat-button-toggle>
</mat-button-toggle-group>
</section>
<section>
<mat-button-toggle-group appearance="legacy" multiple [vertical]="isVertical" [hideMultipleSelectionIndicator]="hideMultipleSelectionIndicator">
<mat-button-toggle-group
appearance="legacy"
multiple
[vertical]="isVertical"
[hideMultipleSelectionIndicator]="hideMultipleSelectionIndicator"
[disabledInteractive]="disabledInteractive">
<mat-button-toggle>Flour</mat-button-toggle>
<mat-button-toggle>Eggs</mat-button-toggle>
<mat-button-toggle>Sugar</mat-button-toggle>
Expand All @@ -90,7 +117,12 @@ <h1>Single Toggle</h1>

<h1>Dynamic Exclusive Selection</h1>
<section>
<mat-button-toggle-group name="pies" [(ngModel)]="favoritePie" [vertical]="isVertical" [hideSingleSelectionIndicator]="hideSingleSelectionIndicator">
<mat-button-toggle-group
name="pies"
[(ngModel)]="favoritePie"
[vertical]="isVertical"
[hideSingleSelectionIndicator]="hideSingleSelectionIndicator"
[disabledInteractive]="disabledInteractive">
@for (pie of pieOptions; track pie) {
<mat-button-toggle [value]="pie">{{pie}}</mat-button-toggle>
}
Expand Down
1 change: 1 addition & 0 deletions src/dev-app/button-toggle/button-toggle-demo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {MatIconModule} from '@angular/material/icon';
export class ButtonToggleDemo {
isVertical = false;
isDisabled = false;
disabledInteractive = false;
hideSingleSelectionIndicator = false;
hideMultipleSelectionIndicator = false;
favoritePie = 'Apple';
Expand Down
5 changes: 3 additions & 2 deletions src/material/button-toggle/button-toggle.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,14 @@
type="button"
[id]="buttonId"
[attr.role]="isSingleSelector() ? 'radio' : 'button'"
[attr.tabindex]="disabled ? -1 : tabIndex"
[attr.tabindex]="disabled && !disabledInteractive ? -1 : tabIndex"
[attr.aria-pressed]="!isSingleSelector() ? checked : null"
[attr.aria-checked]="isSingleSelector() ? checked : null"
[disabled]="disabled || null"
[disabled]="(disabled && !disabledInteractive) || null"
[attr.name]="_getButtonName()"
[attr.aria-label]="ariaLabel"
[attr.aria-labelledby]="ariaLabelledby"
[attr.aria-disabled]="disabled && disabledInteractive ? 'true' : null"
(click)="_onButtonClick()">
<span class="mat-button-toggle-label-content">
<!-- Render checkmark at the beginning for single-selection. -->
Expand Down
15 changes: 10 additions & 5 deletions src/material/button-toggle/button-toggle.scss
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,8 @@ $_standard-tokens: (
}

.mat-button-toggle-disabled {
pointer-events: none;

@include token-utils.use-tokens($_legacy-tokens...) {
@include token-utils.create-token-slot(color, disabled-state-text-color);
@include token-utils.create-token-slot(background-color, disabled-state-background-color);
Expand All @@ -134,6 +136,10 @@ $_standard-tokens: (
}
}

.mat-button-toggle-disabled-interactive {
pointer-events: auto;
}

.mat-button-toggle-appearance-standard {
@include token-utils.use-tokens($_standard-tokens...) {
$divider-color: token-utils.get-token-variable(divider-color);
Expand Down Expand Up @@ -185,16 +191,15 @@ $_standard-tokens: (
@include token-utils.create-token-slot(background-color, state-layer-color);
}

&:not(.mat-button-toggle-disabled):hover .mat-button-toggle-focus-overlay {
&:hover .mat-button-toggle-focus-overlay {
@include token-utils.create-token-slot(opacity, hover-state-layer-opacity);
}

// Similar to components like the checkbox, slide-toggle and radio, we cannot show the focus
// overlay for `.cdk-program-focused` because mouse clicks on the <label> element would be
// always treated as programmatic focus. Note that it needs the extra `:not` in order to have
// more specificity than the `:hover` above.
// always treated as programmatic focus.
// TODO(paul): support `program` as well. See https://github.com/angular/components/issues/9889
&.cdk-keyboard-focused:not(.mat-button-toggle-disabled) .mat-button-toggle-focus-overlay {
&.cdk-keyboard-focused .mat-button-toggle-focus-overlay {
@include token-utils.create-token-slot(opacity, focus-state-layer-opacity);
}
}
Expand All @@ -204,7 +209,7 @@ $_standard-tokens: (
// because we still want to preserve the keyboard focus state for hybrid devices that have
// a keyboard and a touchscreen.
@media (hover: none) {
&:not(.mat-button-toggle-disabled):hover .mat-button-toggle-focus-overlay {
&:hover .mat-button-toggle-focus-overlay {
display: none;
}
}
Expand Down
28 changes: 25 additions & 3 deletions src/material/button-toggle/button-toggle.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,25 @@ describe('MatButtonToggle without forms', () => {
expect(buttons.every(input => input.disabled)).toBe(true);
});

it('should be able to keep the button interactive while disabled', () => {
const button = buttonToggleNativeElements[0].querySelector('button')!;
testComponent.isGroupDisabled = true;
fixture.changeDetectorRef.markForCheck();
fixture.detectChanges();

expect(button.hasAttribute('disabled')).toBe(true);
expect(button.hasAttribute('aria-disabled')).toBe(false);
expect(button.getAttribute('tabindex')).toBe('-1');

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

expect(button.hasAttribute('disabled')).toBe(false);
expect(button.getAttribute('aria-disabled')).toBe('true');
expect(button.getAttribute('tabindex')).toBe('0');
});

it('should update the group value when one of the toggles changes', () => {
expect(groupInstance.value).toBeFalsy();
buttonToggleLabelElements[0].click();
Expand Down Expand Up @@ -1052,9 +1071,11 @@ describe('MatButtonToggle without forms', () => {

@Component({
template: `
<mat-button-toggle-group [disabled]="isGroupDisabled"
[vertical]="isVertical"
[(value)]="groupValue">
<mat-button-toggle-group
[disabled]="isGroupDisabled"
[disabledInteractive]="disabledIntearctive"
[vertical]="isVertical"
[(value)]="groupValue">
@if (renderFirstToggle) {
<mat-button-toggle value="test1">Test1</mat-button-toggle>
}
Expand All @@ -1067,6 +1088,7 @@ describe('MatButtonToggle without forms', () => {
})
class ButtonTogglesInsideButtonToggleGroup {
isGroupDisabled: boolean = false;
disabledIntearctive = false;
isVertical: boolean = false;
groupValue: string;
renderFirstToggle = true;
Expand Down
33 changes: 33 additions & 0 deletions src/material/button-toggle/button-toggle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ export interface MatButtonToggleDefaultOptions {
hideSingleSelectionIndicator?: boolean;
/** Whether icon indicators should be hidden for multiple-selection button toggle groups. */
hideMultipleSelectionIndicator?: boolean;
/** Whether disabled toggle buttons should be interactive. */
disabledInteractive?: boolean;
}

/**
Expand All @@ -78,6 +80,7 @@ export function MAT_BUTTON_TOGGLE_GROUP_DEFAULT_OPTIONS_FACTORY(): MatButtonTogg
return {
hideSingleSelectionIndicator: false,
hideMultipleSelectionIndicator: false,
disabledInteractive: false,
};
}

Expand Down Expand Up @@ -136,6 +139,7 @@ export class MatButtonToggleChange {
export class MatButtonToggleGroup implements ControlValueAccessor, OnInit, AfterContentInit {
private _multiple = false;
private _disabled = false;
private _disabledInteractive = false;
private _selectionModel: SelectionModel<MatButtonToggle>;

/**
Expand Down Expand Up @@ -229,6 +233,16 @@ export class MatButtonToggleGroup implements ControlValueAccessor, OnInit, After
this._markButtonsForCheck();
}

/** Whether buttons in the group should be interactive while they're disabled. */
@Input({transform: booleanAttribute})
get disabledInteractive(): boolean {
return this._disabledInteractive;
}
set disabledInteractive(value: boolean) {
this._disabledInteractive = value;
this._markButtonsForCheck();
}

/** The layout direction of the toggle button group. */
get dir(): Direction {
return this._dir && this._dir.value === 'rtl' ? 'rtl' : 'ltr';
Expand Down Expand Up @@ -529,6 +543,7 @@ export class MatButtonToggleGroup implements ControlValueAccessor, OnInit, After
'[class.mat-button-toggle-standalone]': '!buttonToggleGroup',
'[class.mat-button-toggle-checked]': 'checked',
'[class.mat-button-toggle-disabled]': 'disabled',
'[class.mat-button-toggle-disabled-interactive]': 'disabledInteractive',
'[class.mat-button-toggle-appearance-standard]': 'appearance === "standard"',
'class': 'mat-button-toggle',
'[attr.aria-label]': 'null',
Expand Down Expand Up @@ -626,6 +641,19 @@ export class MatButtonToggle implements OnInit, AfterViewInit, OnDestroy {
}
private _disabled: boolean = false;

/** Whether the button should remain interactive when it is disabled. */
@Input({transform: booleanAttribute})
get disabledInteractive(): boolean {
return (
this._disabledInteractive ||
(this.buttonToggleGroup !== null && this.buttonToggleGroup.disabledInteractive)
);
}
set disabledInteractive(value: boolean) {
this._disabledInteractive = value;
}
private _disabledInteractive: boolean;

/** Event emitted when the group value changes. */
@Output() readonly change: EventEmitter<MatButtonToggleChange> =
new EventEmitter<MatButtonToggleChange>();
Expand All @@ -645,6 +673,7 @@ export class MatButtonToggle implements OnInit, AfterViewInit, OnDestroy {
this.buttonToggleGroup = toggleGroup;
this.appearance =
defaultOptions && defaultOptions.appearance ? defaultOptions.appearance : 'standard';
this.disabledInteractive = defaultOptions?.disabledInteractive ?? false;
}

ngOnInit() {
Expand Down Expand Up @@ -687,6 +716,10 @@ export class MatButtonToggle implements OnInit, AfterViewInit, OnDestroy {

/** Checks the button toggle due to an interaction with the underlying native button. */
_onButtonClick() {
if (this.disabled) {
return;
}

const newChecked = this.isSingleSelector() ? true : !this._checked;

if (newChecked !== this._checked) {
Expand Down
10 changes: 10 additions & 0 deletions src/material/button-toggle/testing/button-toggle-harness.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,14 @@ describe('MatButtonToggleHarness', () => {
expect(await disabledToggle.isDisabled()).toBe(true);
});

it('should get the disabled state for an interactive disabled button', async () => {
fixture.componentInstance.disabledInteractive = true;
fixture.changeDetectorRef.markForCheck();

const disabledToggle = (await loader.getAllHarnesses(MatButtonToggleHarness))[1];
expect(await disabledToggle.isDisabled()).toBe(true);
});

it('should get the toggle name', async () => {
const toggle = await loader.getHarness(MatButtonToggleHarness.with({text: 'First'}));
expect(await toggle.getName()).toBe('first-name');
Expand Down Expand Up @@ -141,6 +149,7 @@ describe('MatButtonToggleHarness', () => {
checked>First</mat-button-toggle>
<mat-button-toggle
[disabled]="disabled"
[disabledInteractive]="disabledInteractive"
aria-labelledby="second-label"
appearance="legacy">Second</mat-button-toggle>
<span id="second-label">Second toggle</span>
Expand All @@ -150,4 +159,5 @@ describe('MatButtonToggleHarness', () => {
})
class ButtonToggleHarnessTest {
disabled = true;
disabledInteractive = false;
}
4 changes: 2 additions & 2 deletions src/material/button-toggle/testing/button-toggle-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,8 @@ export class MatButtonToggleHarness extends ComponentHarness {

/** Gets a boolean promise indicating if the button toggle is disabled. */
async isDisabled(): Promise<boolean> {
const disabled = (await this._button()).getAttribute('disabled');
return coerceBooleanProperty(await disabled);
const host = await this.host();
return host.hasClass('mat-button-toggle-disabled');
}

/** Gets a promise for the button toggle's name. */
Expand Down
Loading

0 comments on commit b42847a

Please sign in to comment.