Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(material/button-toggle): allow disabled buttons to be interactive #29550

Merged
merged 1 commit into from
Aug 7, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
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
Loading