From ebfd20a334d572103961ca8df0acb1ceb8e935ff Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Mon, 18 Nov 2024 10:54:55 +0100 Subject: [PATCH] fix(material/button-toggle): animate checkbox Currently the checkbox inside button toggle is a bit jarring. These changes add an animation to it. --- src/material/button-toggle/button-toggle.html | 31 +++---- src/material/button-toggle/button-toggle.scss | 89 ++++++++++++++++--- .../button-toggle/button-toggle.spec.ts | 12 ++- src/material/button-toggle/button-toggle.ts | 11 ++- 4 files changed, 111 insertions(+), 32 deletions(-) diff --git a/src/material/button-toggle/button-toggle.html b/src/material/button-toggle/button-toggle.html index 9452213fa907..a6d1e83d0502 100644 --- a/src/material/button-toggle/button-toggle.html +++ b/src/material/button-toggle/button-toggle.html @@ -11,25 +11,20 @@ [attr.aria-labelledby]="ariaLabelledby" [attr.aria-disabled]="disabled && disabledInteractive ? 'true' : null" (click)="_onButtonClick()"> - - - @if (buttonToggleGroup && checked && !buttonToggleGroup.multiple && !buttonToggleGroup.hideSingleSelectionIndicator) { - - } - - @if (buttonToggleGroup && checked && buttonToggleGroup.multiple && !buttonToggleGroup.hideMultipleSelectionIndicator) { + @if (buttonToggleGroup && ( + !buttonToggleGroup.multiple && !buttonToggleGroup.hideSingleSelectionIndicator || + buttonToggleGroup.multiple && !buttonToggleGroup.hideMultipleSelectionIndicator) + ) { +
- } + [disabled]="disabled" + state="checked" + aria-hidden="true" + appearance="minimal"/> +
+ } + + diff --git a/src/material/button-toggle/button-toggle.scss b/src/material/button-toggle/button-toggle.scss index 4df361e165f3..778df32843af 100644 --- a/src/material/button-toggle/button-toggle.scss +++ b/src/material/button-toggle/button-toggle.scss @@ -7,9 +7,11 @@ @use '../core/tokens/m2/mat/legacy-button-toggle' as tokens-mat-legacy-button-toggle; @use '../core/tokens/m2/mat/standard-button-toggle' as tokens-mat-standard-button-toggle; -$standard-padding: 0 12px !default; -$legacy-padding: 0 16px !default; -$checkmark-padding: 12px !default; +$standard-padding: 12px !default; +$legacy-padding: 16px !default; +$_checkmark-size: 18px !default; +$_checkmark-margin: 12px; +$_checkmark-transition: 150ms 45ms cubic-bezier(0.4, 0, 0.2, 1); // TODO(crisbeto): these variables aren't used anymore and should be removed. $legacy-height: 36px !default; @@ -104,13 +106,51 @@ $_standard-tokens: ( .mat-icon svg { vertical-align: top; } +} - .mat-pseudo-checkbox { - margin-right: $checkmark-padding; - [dir='rtl'] & { - margin-right: 0; - margin-left: $checkmark-padding; - } +.mat-button-toggle-checkbox-wrapper { + display: inline-block; + justify-content: flex-start; + align-items: center; + width: 0; + height: $_checkmark-size; + line-height: $_checkmark-size; + overflow: hidden; + box-sizing: border-box; + position: absolute; + top: 50%; + left: $legacy-padding; + + // Uses a 3d transform, because otherwise Safari has some some of rendering + // artifact that adds a small gap between the two parts of the checkmark. + transform: translate3d(0, -50%, 0); + + [dir='rtl'] & { + left: auto; + right: $legacy-padding; + } + + .mat-button-toggle-appearance-standard & { + left: $standard-padding; + } + + [dir='rtl'] .mat-button-toggle-appearance-standard & { + left: auto; + right: $standard-padding; + } + + .mat-button-toggle-checked & { + width: $_checkmark-size; + } + + .mat-button-toggle-animations-enabled & { + transition: width $_checkmark-transition; + } + + // Disable the transition in vertical mode since it looks weird. + // There should be a limited amount of usages anyway. + .mat-button-toggle-vertical & { + transition: none; } } @@ -219,7 +259,7 @@ $_standard-tokens: ( .mat-button-toggle-label-content { @include vendor-prefixes.user-select(none); display: inline-block; - padding: $legacy-padding; + padding: 0 $legacy-padding; @include token-utils.use-tokens($_legacy-tokens...) { @include token-utils.create-token-slot(line-height, height); @@ -229,7 +269,7 @@ $_standard-tokens: ( position: relative; .mat-button-toggle-appearance-standard & { - padding: $standard-padding; + padding: 0 $standard-padding; @include token-utils.use-tokens($_standard-tokens...) { @include token-utils.create-token-slot(line-height, height); @@ -292,6 +332,7 @@ $_standard-tokens: ( } .mat-button-toggle-button { + $checkmark-spacing: $_checkmark-size + $_checkmark-margin; border: 0; background: none; color: inherit; @@ -302,6 +343,16 @@ $_standard-tokens: ( width: 100%; // Stretch the button in case the consumer set a custom width. cursor: pointer; + .mat-button-toggle-animations-enabled & { + transition: padding $_checkmark-transition; + } + + // Disable the transition in vertical mode since it looks weird. + // There should be a limited amount of usages anyway. + .mat-button-toggle-vertical & { + transition: none; + } + .mat-button-toggle-disabled & { cursor: default; } @@ -310,6 +361,22 @@ $_standard-tokens: ( &::-moz-focus-inner { border: 0; } + + // Note that we use padding and `position: absolute` to show/hide the checkmark, instead of + // just transitioning it between `width: 18px` and `width: 0`, because it was being shown/hidden + // with `@if` before the transition was added and leaving it in the DOM while hidden can break + // some pre-existing layouts. + &:has(.mat-button-toggle-checkbox-wrapper) { + .mat-button-toggle-checked & { + padding-left: $checkmark-spacing; + } + + [dir='rtl'] .mat-button-toggle-checked & { + padding-left: 0; + padding-right: $checkmark-spacing; + } + } + } // Change the border-radius of the focus indicator to match the diff --git a/src/material/button-toggle/button-toggle.spec.ts b/src/material/button-toggle/button-toggle.spec.ts index 2ef8a9b44924..90c3776028cd 100644 --- a/src/material/button-toggle/button-toggle.spec.ts +++ b/src/material/button-toggle/button-toggle.spec.ts @@ -598,7 +598,11 @@ describe('MatButtonToggle without forms', () => { buttonToggleLabelElements[0].click(); fixture.detectChanges(); - expect(document.querySelectorAll('.mat-pseudo-checkbox').length).toBe(1); + expect( + fixture.nativeElement.querySelectorAll( + '.mat-button-toggle-checked .mat-button-toggle-checkbox-wrapper', + ).length, + ).toBe(1); }); }); @@ -763,7 +767,11 @@ describe('MatButtonToggle without forms', () => { buttonToggleLabelElements[1].click(); fixture.detectChanges(); - expect(document.querySelectorAll('.mat-pseudo-checkbox').length).toBe(2); + expect( + fixture.nativeElement.querySelectorAll( + '.mat-button-toggle-checked .mat-button-toggle-checkbox-wrapper', + ).length, + ).toBe(2); }); }); diff --git a/src/material/button-toggle/button-toggle.ts b/src/material/button-toggle/button-toggle.ts index 31d45dbb1568..6f3b618dd5f4 100644 --- a/src/material/button-toggle/button-toggle.ts +++ b/src/material/button-toggle/button-toggle.ts @@ -31,6 +31,7 @@ import { booleanAttribute, inject, HostAttributeToken, + ANIMATION_MODULE_TYPE, } from '@angular/core'; import {Direction, Directionality} from '@angular/cdk/bidi'; import {ControlValueAccessor, NG_VALUE_ACCESSOR} from '@angular/forms'; @@ -560,7 +561,7 @@ export class MatButtonToggle implements OnInit, AfterViewInit, OnDestroy { private _elementRef = inject>(ElementRef); private _focusMonitor = inject(FocusMonitor); private _idGenerator = inject(_IdGenerator); - + private _animationMode = inject(ANIMATION_MODULE_TYPE, {optional: true}); private _checked = false; /** @@ -699,6 +700,14 @@ export class MatButtonToggle implements OnInit, AfterViewInit, OnDestroy { } ngAfterViewInit() { + // This serves two purposes: + // 1. We don't want the animation to fire on the first render for pre-checked toggles so we + // delay adding the class until the view is rendered. + // 2. We don't want animation if the `NoopAnimationsModule` is provided. + if (this._animationMode !== 'NoopAnimations') { + this._elementRef.nativeElement.classList.add('mat-button-toggle-animations-enabled'); + } + this._focusMonitor.monitor(this._elementRef, true); }