diff --git a/jest-setup.ts b/jest-setup.ts index fce87adc..739cf17e 100644 --- a/jest-setup.ts +++ b/jest-setup.ts @@ -12,3 +12,18 @@ window.ResizeObserver = class ResizeObserver { unobserve() {} disconnect() {} }; + +window.matchMedia = + window.matchMedia || + function (query: string): MediaQueryList { + return { + matches: false, + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false + } as unknown as MediaQueryList; + }; diff --git a/projects/cps-ui-kit/src/lib/components/cps-expansion-panel/cps-expansion-panel.component.html b/projects/cps-ui-kit/src/lib/components/cps-expansion-panel/cps-expansion-panel.component.html index 74480366..d50aecb9 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-expansion-panel/cps-expansion-panel.component.html +++ b/projects/cps-ui-kit/src/lib/components/cps-expansion-panel/cps-expansion-panel.component.html @@ -12,16 +12,14 @@ [attr.aria-expanded]="isExpanded" [attr.aria-disabled]="disabled ? true : null" [attr.aria-controls]="contentPanelId" - [@panelHeader]=" - isExpanded - ? { - value: 'visible', - params: { - borderStyle: bordered ? '1px solid ' + cvtBorderColor : '' - } - } - : 'hidden' - " + [@panelHeader]="{ + value: isExpanded ? 'visible' : 'hidden', + params: { + borderStyle: + isExpanded && bordered ? '1px solid ' + cvtBorderColor : '', + transitionParams: resolvedTransitionType + } + }" [class.disabled]="disabled" [class.keyboard-active]="isKeyboardActive" [style.cursor]="disabled ? 'default' : 'pointer'" @@ -40,10 +38,7 @@
{{ headerTitle }}
@if (showChevron && !disabled) { - + } diff --git a/projects/cps-ui-kit/src/lib/components/cps-expansion-panel/cps-expansion-panel.component.spec.ts b/projects/cps-ui-kit/src/lib/components/cps-expansion-panel/cps-expansion-panel.component.spec.ts index 3a63cc81..33ab0ba3 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-expansion-panel/cps-expansion-panel.component.spec.ts +++ b/projects/cps-ui-kit/src/lib/components/cps-expansion-panel/cps-expansion-panel.component.spec.ts @@ -303,4 +303,28 @@ describe('CpsExpansionPanelComponent', () => { expect(header.classList).toContain('keyboard-active'); }); }); + + describe('reduced motion', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should use the default transition duration by default', () => { + jest + .spyOn(window, 'matchMedia') + .mockReturnValue({ matches: false } as MediaQueryList); + + expect(component.resolvedTransitionType).toBe( + '0.2s cubic-bezier(0.4, 0, 0.2, 1)' + ); + }); + + it('should use a near-instant transition when the OS prefers reduced motion', () => { + jest + .spyOn(window, 'matchMedia') + .mockReturnValue({ matches: true } as MediaQueryList); + + expect(component.resolvedTransitionType).toBe('1ms'); + }); + }); }); diff --git a/projects/cps-ui-kit/src/lib/components/cps-expansion-panel/cps-expansion-panel.component.ts b/projects/cps-ui-kit/src/lib/components/cps-expansion-panel/cps-expansion-panel.component.ts index 9dc350d2..3e317770 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-expansion-panel/cps-expansion-panel.component.ts +++ b/projects/cps-ui-kit/src/lib/components/cps-expansion-panel/cps-expansion-panel.component.ts @@ -4,13 +4,13 @@ import { Component, ElementRef, EventEmitter, - Inject, Input, OnChanges, OnInit, Output, Renderer2, ViewChild, + inject, type SimpleChanges } from '@angular/core'; import { CpsIconComponent, IconType } from '../cps-icon/cps-icon.component'; @@ -27,6 +27,10 @@ import { transition, trigger } from '@angular/animations'; +import { + prefersReducedMotion, + REDUCED_MOTION_DURATION +} from '../../utils/internal/motion-utils'; const transitionType = '0.2s cubic-bezier(0.4, 0, 0.2, 1)'; @@ -54,7 +58,9 @@ const transitionType = '0.2s cubic-bezier(0.4, 0, 0.2, 1)'; }), { params: { borderStyle: '' } } ), - transition('visible <=> hidden', [animate(transitionType)]), + transition('visible <=> hidden', [animate('{{transitionParams}}')], { + params: { transitionParams: transitionType } + }), transition('void => *', animate(0)) ]) ] @@ -138,8 +144,6 @@ export class CpsExpansionPanelComponent @ViewChild('panelContentElem') panelContentElem!: ElementRef; - private _contentExpandAnimation: AnimationFactory; - private _contentCollapseAnimation: AnimationFactory; private _contentAnimationPlayer: AnimationPlayer | undefined; readonly contentPanelId = generateUniqueId('cps-expansion-panel-content'); @@ -150,29 +154,35 @@ export class CpsExpansionPanelComponent cvtBackgroundColor = ''; cvtBorderRadius = ''; - constructor( - private _animationBuilder: AnimationBuilder, - @Inject(DOCUMENT) private document: Document, - private _renderer: Renderer2 - ) { - this._contentCollapseAnimation = this._animationBuilder.build([ + private readonly _animationBuilder = inject(AnimationBuilder); + private readonly _document = inject(DOCUMENT); + private readonly _renderer = inject(Renderer2); + + get resolvedTransitionType(): string { + return prefersReducedMotion() ? REDUCED_MOTION_DURATION : transitionType; + } + + private _buildContentCollapseAnimation(): AnimationFactory { + return this._animationBuilder.build([ style({ height: '*' }), animate( - transitionType, + this.resolvedTransitionType, style({ height: 0 }) ) ]); + } - this._contentExpandAnimation = this._animationBuilder.build([ + private _buildContentExpandAnimation(): AnimationFactory { + return this._animationBuilder.build([ style({ height: 0 }), animate( - transitionType, + this.resolvedTransitionType, style({ height: '*' }) @@ -181,8 +191,8 @@ export class CpsExpansionPanelComponent } ngOnInit(): void { - this.cvtBorderColor = getCSSColor(this.borderColor, this.document); - this.cvtBackgroundColor = getCSSColor(this.backgroundColor, this.document); + this.cvtBorderColor = getCSSColor(this.borderColor, this._document); + this.cvtBackgroundColor = getCSSColor(this.backgroundColor, this._document); this.cvtBorderRadius = convertSize(this.borderRadius); this.cvtWidth = convertSize(this.width); @@ -191,12 +201,12 @@ export class CpsExpansionPanelComponent ngOnChanges(changes: SimpleChanges): void { if (changes.borderColor) { - this.cvtBorderColor = getCSSColor(this.borderColor, this.document); + this.cvtBorderColor = getCSSColor(this.borderColor, this._document); } if (changes.backgroundColor) { this.cvtBackgroundColor = getCSSColor( this.backgroundColor, - this.document + this._document ); } if (changes.borderRadius) { @@ -236,13 +246,15 @@ export class CpsExpansionPanelComponent const el = this.panelContentElem?.nativeElement; if (this.isExpanded) { - this._contentAnimationPlayer = this._contentCollapseAnimation.create(el); + this._contentAnimationPlayer = + this._buildContentCollapseAnimation().create(el); this._contentAnimationPlayer.onDone(() => { this._updateContentVisibilityStyles(false, el); }); } else { this._updateContentVisibilityStyles(true, el); - this._contentAnimationPlayer = this._contentExpandAnimation.create(el); + this._contentAnimationPlayer = + this._buildContentExpandAnimation().create(el); } this._contentAnimationPlayer.onStart(() => { diff --git a/projects/cps-ui-kit/src/lib/components/cps-loader/cps-loader.component.scss b/projects/cps-ui-kit/src/lib/components/cps-loader/cps-loader.component.scss index 250fcc4d..4107edbb 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-loader/cps-loader.component.scss +++ b/projects/cps-ui-kit/src/lib/components/cps-loader/cps-loader.component.scss @@ -71,6 +71,19 @@ $color-inner: var(--cps-color-energy); } } + // Keep indicating progress under reduced motion, just slower, instead of freezing + @media (prefers-reduced-motion: reduce) { + &-text { + animation-duration: 8s !important; + animation-iteration-count: infinite !important; + } + + &-circles-circle { + animation-duration: 3000ms !important; + animation-iteration-count: infinite !important; + } + } + @keyframes cps-loader-text-animation { 0%, 100% { diff --git a/projects/cps-ui-kit/src/lib/components/cps-menu/cps-menu.component.html b/projects/cps-ui-kit/src/lib/components/cps-menu/cps-menu.component.html index 27afcac5..93b834fc 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-menu/cps-menu.component.html +++ b/projects/cps-ui-kit/src/lib/components/cps-menu/cps-menu.component.html @@ -7,8 +7,8 @@ [@animation]="{ value: overlayVisible ? 'open' : 'close', params: { - showTransitionParams: showTransitionOptions, - hideTransitionParams: hideTransitionOptions + showTransitionParams: resolvedShowTransitionOptions, + hideTransitionParams: resolvedHideTransitionOptions } }" (@animation.start)="onAnimationStart($event)" diff --git a/projects/cps-ui-kit/src/lib/components/cps-menu/cps-menu.component.spec.ts b/projects/cps-ui-kit/src/lib/components/cps-menu/cps-menu.component.spec.ts index 74ea3811..cbb6731b 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-menu/cps-menu.component.spec.ts +++ b/projects/cps-ui-kit/src/lib/components/cps-menu/cps-menu.component.spec.ts @@ -799,4 +799,32 @@ describe('CpsMenuComponent', () => { expect(component.scrollHandler).toBeNull(); }); }); + + describe('reduced motion', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should use the configured transition options by default', () => { + jest + .spyOn(window, 'matchMedia') + .mockReturnValue({ matches: false } as MediaQueryList); + + expect(component.resolvedShowTransitionOptions).toBe( + component.showTransitionOptions + ); + expect(component.resolvedHideTransitionOptions).toBe( + component.hideTransitionOptions + ); + }); + + it('should use a near-instant transition when the OS prefers reduced motion', () => { + jest + .spyOn(window, 'matchMedia') + .mockReturnValue({ matches: true } as MediaQueryList); + + expect(component.resolvedShowTransitionOptions).toBe('1ms'); + expect(component.resolvedHideTransitionOptions).toBe('1ms'); + }); + }); }); diff --git a/projects/cps-ui-kit/src/lib/components/cps-menu/cps-menu.component.ts b/projects/cps-ui-kit/src/lib/components/cps-menu/cps-menu.component.ts index 32e5b814..70430207 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-menu/cps-menu.component.ts +++ b/projects/cps-ui-kit/src/lib/components/cps-menu/cps-menu.component.ts @@ -37,6 +37,10 @@ import { CpsIconComponent } from '../cps-icon/cps-icon.component'; import { CpsProgressCircularComponent } from '../cps-progress-circular/cps-progress-circular.component'; import { PrimeNG } from 'primeng/config'; import { CPS_FOCUS_SERVICE } from '../../services/cps-focus/cps-focus.service'; +import { + prefersReducedMotion, + REDUCED_MOTION_DURATION +} from '../../utils/internal/motion-utils'; type Nullable = T | null | undefined; type VoidListener = () => void | null | undefined; @@ -219,6 +223,18 @@ export class CpsMenuComponent implements AfterViewInit, OnDestroy, OnChanges { */ @Output() containerMouseLeave = new EventEmitter(); + get resolvedShowTransitionOptions(): string { + return prefersReducedMotion() + ? REDUCED_MOTION_DURATION + : this.showTransitionOptions; + } + + get resolvedHideTransitionOptions(): string { + return prefersReducedMotion() + ? REDUCED_MOTION_DURATION + : this.hideTransitionOptions; + } + withIcons = true; autoZIndex = true; baseZIndex = 0; diff --git a/projects/cps-ui-kit/src/lib/components/cps-progress-circular/cps-progress-circular.component.scss b/projects/cps-ui-kit/src/lib/components/cps-progress-circular/cps-progress-circular.component.scss index 58554b6a..16fde8ab 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-progress-circular/cps-progress-circular.component.scss +++ b/projects/cps-ui-kit/src/lib/components/cps-progress-circular/cps-progress-circular.component.scss @@ -11,6 +11,14 @@ animation: cps-progress-circular-animation 0.8s linear infinite; } + // Keep indicating progress under reduced motion, just slower, instead of freezing + @media (prefers-reduced-motion: reduce) { + .cps-progress-circular { + animation-duration: 2.4s !important; + animation-iteration-count: infinite !important; + } + } + @keyframes cps-progress-circular-animation { to { transform: rotate(360deg); diff --git a/projects/cps-ui-kit/src/lib/components/cps-progress-linear/cps-progress-linear.component.scss b/projects/cps-ui-kit/src/lib/components/cps-progress-linear/cps-progress-linear.component.scss index 034f342f..6dbb69bf 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-progress-linear/cps-progress-linear.component.scss +++ b/projects/cps-ui-kit/src/lib/components/cps-progress-linear/cps-progress-linear.component.scss @@ -17,6 +17,20 @@ } } + // Keep indicating progress under reduced motion, just slower, instead of freezing + @media (prefers-reduced-motion: reduce) { + .cps-progress-linear { + .inc { + animation-duration: 6s !important; + animation-iteration-count: infinite !important; + } + .dec { + animation-duration: 6s !important; + animation-iteration-count: infinite !important; + } + } + } + @keyframes increase { from { left: -5%; diff --git a/projects/cps-ui-kit/src/lib/components/cps-sidebar-menu/cps-sidebar-menu.component.html b/projects/cps-ui-kit/src/lib/components/cps-sidebar-menu/cps-sidebar-menu.component.html index d009b78d..ab008646 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-sidebar-menu/cps-sidebar-menu.component.html +++ b/projects/cps-ui-kit/src/lib/components/cps-sidebar-menu/cps-sidebar-menu.component.html @@ -21,7 +21,10 @@ + [@onExpand]="{ + value: isExpanded ? 'expanded' : 'collapsed', + params: { transitionParams: resolvedTransitionParams } + }"> {{ item.title }} @@ -59,7 +62,10 @@ + [@onExpand]="{ + value: isExpanded ? 'expanded' : 'collapsed', + params: { transitionParams: resolvedTransitionParams } + }"> {{ item.title }} diff --git a/projects/cps-ui-kit/src/lib/components/cps-sidebar-menu/cps-sidebar-menu.component.spec.ts b/projects/cps-ui-kit/src/lib/components/cps-sidebar-menu/cps-sidebar-menu.component.spec.ts index 3042f308..565f3b95 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-sidebar-menu/cps-sidebar-menu.component.spec.ts +++ b/projects/cps-ui-kit/src/lib/components/cps-sidebar-menu/cps-sidebar-menu.component.spec.ts @@ -662,4 +662,28 @@ describe('CpsSidebarMenuComponent', () => { expect(menuItems.length).toBe(sampleItems.length); }); }); + + describe('reduced motion', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should use the default transition duration by default', () => { + jest + .spyOn(window, 'matchMedia') + .mockReturnValue({ matches: false } as MediaQueryList); + + expect(component.resolvedTransitionParams).toBe( + '0.2s cubic-bezier(0.4, 0, 0.2, 1)' + ); + }); + + it('should use a near-instant transition when the OS prefers reduced motion', () => { + jest + .spyOn(window, 'matchMedia') + .mockReturnValue({ matches: true } as MediaQueryList); + + expect(component.resolvedTransitionParams).toBe('1ms'); + }); + }); }); diff --git a/projects/cps-ui-kit/src/lib/components/cps-sidebar-menu/cps-sidebar-menu.component.ts b/projects/cps-ui-kit/src/lib/components/cps-sidebar-menu/cps-sidebar-menu.component.ts index 197561bd..fbebcda9 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-sidebar-menu/cps-sidebar-menu.component.ts +++ b/projects/cps-ui-kit/src/lib/components/cps-sidebar-menu/cps-sidebar-menu.component.ts @@ -23,6 +23,10 @@ import { transition, trigger } from '@angular/animations'; +import { + prefersReducedMotion, + REDUCED_MOTION_DURATION +} from '../../utils/internal/motion-utils'; /** * CpsSidebarMenuItem is used to define the items of the CpsSidebarMenuComponent. @@ -64,9 +68,9 @@ export type CpsSidebarMenuItem = { opacity: '1' }) ), - transition('expanded <=> collapsed', [ - animate('0.2s cubic-bezier(0.4, 0, 0.2, 1)') - ]) + transition('expanded <=> collapsed', [animate('{{transitionParams}}')], { + params: { transitionParams: '0.2s cubic-bezier(0.4, 0, 0.2, 1)' } + }) ]) ] }) @@ -83,6 +87,12 @@ export class CpsSidebarMenuComponent implements AfterViewInit { */ @Input() isExpanded = true; + get resolvedTransitionParams(): string { + return prefersReducedMotion() + ? REDUCED_MOTION_DURATION + : '0.2s cubic-bezier(0.4, 0, 0.2, 1)'; + } + /** * Determines whether the menu items should allow activating only exact links. * @group Props diff --git a/projects/cps-ui-kit/src/lib/components/cps-tab-group/cps-tab-group.component.html b/projects/cps-ui-kit/src/lib/components/cps-tab-group/cps-tab-group.component.html index 090ae286..dc7984d6 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-tab-group/cps-tab-group.component.html +++ b/projects/cps-ui-kit/src/lib/components/cps-tab-group/cps-tab-group.component.html @@ -109,12 +109,22 @@ (mousedown)="onMouseDown($event)"> @if (tab.active) { @if (animationType === 'slide') { -
+
} @if (animationType === 'fade') { -
+
} diff --git a/projects/cps-ui-kit/src/lib/components/cps-tab-group/cps-tab-group.component.spec.ts b/projects/cps-ui-kit/src/lib/components/cps-tab-group/cps-tab-group.component.spec.ts index 68dde4e9..f2fe9181 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-tab-group/cps-tab-group.component.spec.ts +++ b/projects/cps-ui-kit/src/lib/components/cps-tab-group/cps-tab-group.component.spec.ts @@ -428,4 +428,28 @@ describe('CpsTabGroupComponent', () => { component.ngOnDestroy(); expect(unsubSpy).toHaveBeenCalled(); }); + + describe('reduced motion', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should use the default transition durations by default', () => { + jest + .spyOn(window, 'matchMedia') + .mockReturnValue({ matches: false } as MediaQueryList); + + expect(component.resolvedTransitionParams).toBe('200ms ease-in'); + expect(component.resolvedFadeTransitionParams).toBe('100ms ease-in'); + }); + + it('should use a near-instant transition when the OS prefers reduced motion', () => { + jest + .spyOn(window, 'matchMedia') + .mockReturnValue({ matches: true } as MediaQueryList); + + expect(component.resolvedTransitionParams).toBe('1ms'); + expect(component.resolvedFadeTransitionParams).toBe('1ms'); + }); + }); }); diff --git a/projects/cps-ui-kit/src/lib/components/cps-tab-group/cps-tab-group.component.ts b/projects/cps-ui-kit/src/lib/components/cps-tab-group/cps-tab-group.component.ts index 808a7468..38d23325 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-tab-group/cps-tab-group.component.ts +++ b/projects/cps-ui-kit/src/lib/components/cps-tab-group/cps-tab-group.component.ts @@ -34,6 +34,10 @@ import { CpsTooltipDirective } from '../../directives/cps-tooltip/cps-tooltip.di import { getCSSColor } from '../../utils/colors-utils'; import { generateUniqueId } from '../../utils/internal/accessibility-utils'; import { CpsFocusService } from '../../services/cps-focus/cps-focus.service'; +import { + prefersReducedMotion, + REDUCED_MOTION_DURATION +} from '../../utils/internal/motion-utils'; import { Subscription, debounceTime, @@ -81,23 +85,32 @@ export type CpsTabsAlignmentType = 'left' | 'center' | 'right'; trigger('slideInOut', [ state('slideLeft', style({ transform: 'translateX(0)' })), state('slideRight', style({ transform: 'translateX(0)' })), - transition('* => slideLeft', [ - style({ transform: 'translateX(-100%)' }), - animate('200ms ease-in') - ]), - transition('* => slideRight', [ - style({ transform: 'translateX(100%)' }), - animate('200ms ease-in') - ]), + transition( + '* => slideLeft', + [ + style({ transform: 'translateX(-100%)' }), + animate('{{transitionParams}}') + ], + { params: { transitionParams: '200ms ease-in' } } + ), + transition( + '* => slideRight', + [ + style({ transform: 'translateX(100%)' }), + animate('{{transitionParams}}') + ], + { params: { transitionParams: '200ms ease-in' } } + ), transition('void => *', animate(0)) ]), trigger('fadeInOut', [ state('fadeIn', style({ opacity: 1 })), state('fadeOut', style({ opacity: 0 })), - transition('fadeOut => fadeIn', [ - style({ opacity: 0 }), - animate('100ms ease-in') - ]), + transition( + 'fadeOut => fadeIn', + [style({ opacity: 0 }), animate('{{transitionParams}}')], + { params: { transitionParams: '100ms ease-in' } } + ), transition('fadeIn => fadeOut', [ animate('0ms ease-out', style({ opacity: 0 })) ]), @@ -201,6 +214,14 @@ export class CpsTabGroupComponent forwardBtnVisible = false; animationState: 'slideLeft' | 'slideRight' | 'fadeIn' | 'fadeOut' = 'fadeIn'; + get resolvedTransitionParams(): string { + return prefersReducedMotion() ? REDUCED_MOTION_DURATION : '200ms ease-in'; + } + + get resolvedFadeTransitionParams(): string { + return prefersReducedMotion() ? REDUCED_MOTION_DURATION : '100ms ease-in'; + } + readonly tabGroupId = generateUniqueId('cps-tab-group'); windowResize$: Subscription = Subscription.EMPTY; diff --git a/projects/cps-ui-kit/src/lib/services/cps-dialog/internal/components/cps-dialog/cps-dialog.component.html b/projects/cps-ui-kit/src/lib/services/cps-dialog/internal/components/cps-dialog/cps-dialog.component.html index 09d99c0e..0060ba4a 100644 --- a/projects/cps-ui-kit/src/lib/services/cps-dialog/internal/components/cps-dialog/cps-dialog.component.html +++ b/projects/cps-ui-kit/src/lib/services/cps-dialog/internal/components/cps-dialog/cps-dialog.component.html @@ -35,8 +35,7 @@ value: 'visible', params: { transform: transformOptions, - transition: - config.transitionOptions || '150ms cubic-bezier(0, 0, 0.2, 1)' + transition: resolvedTransitionOptions } }" (@animation.start)="onAnimationStart($event)" diff --git a/projects/cps-ui-kit/src/lib/services/cps-dialog/internal/components/cps-dialog/cps-dialog.component.spec.ts b/projects/cps-ui-kit/src/lib/services/cps-dialog/internal/components/cps-dialog/cps-dialog.component.spec.ts index 2f54500f..0e97e33b 100644 --- a/projects/cps-ui-kit/src/lib/services/cps-dialog/internal/components/cps-dialog/cps-dialog.component.spec.ts +++ b/projects/cps-ui-kit/src/lib/services/cps-dialog/internal/components/cps-dialog/cps-dialog.component.spec.ts @@ -827,4 +827,39 @@ describe('CpsDialogComponent', () => { expect(() => fixture.destroy()).not.toThrow(); }); }); + + describe('reduced motion', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should use the configured transition options by default', () => { + setup({ headerTitle: 'Test', transitionOptions: '150ms ease' }); + jest + .spyOn(window, 'matchMedia') + .mockReturnValue({ matches: false } as MediaQueryList); + + expect(component.resolvedTransitionOptions).toBe('150ms ease'); + }); + + it('should fall back to the default transition options when unset', () => { + setup({ headerTitle: 'Test' }); + jest + .spyOn(window, 'matchMedia') + .mockReturnValue({ matches: false } as MediaQueryList); + + expect(component.resolvedTransitionOptions).toBe( + '150ms cubic-bezier(0, 0, 0.2, 1)' + ); + }); + + it('should use a near-instant transition when the OS prefers reduced motion', () => { + setup({ headerTitle: 'Test', transitionOptions: '150ms ease' }); + jest + .spyOn(window, 'matchMedia') + .mockReturnValue({ matches: true } as MediaQueryList); + + expect(component.resolvedTransitionOptions).toBe('1ms'); + }); + }); }); diff --git a/projects/cps-ui-kit/src/lib/services/cps-dialog/internal/components/cps-dialog/cps-dialog.component.ts b/projects/cps-ui-kit/src/lib/services/cps-dialog/internal/components/cps-dialog/cps-dialog.component.ts index 6ab640a5..8b9b324e 100644 --- a/projects/cps-ui-kit/src/lib/services/cps-dialog/internal/components/cps-dialog/cps-dialog.component.ts +++ b/projects/cps-ui-kit/src/lib/services/cps-dialog/internal/components/cps-dialog/cps-dialog.component.ts @@ -44,6 +44,10 @@ import { CpsInfoCircleComponent } from '../../../../../components/cps-info-circl import { CpsIconComponent } from '../../../../../components/cps-icon/cps-icon.component'; import { CPS_FOCUS_SERVICE } from '../../../../cps-focus/cps-focus.service'; import { CPS_ROOT_FONT_SIZE_SERVICE } from '../../../../cps-root-font-size/cps-root-font-size.service'; +import { + prefersReducedMotion, + REDUCED_MOTION_DURATION +} from '../../../../../utils/internal/motion-utils'; const showAnimation = animation([ style({ transform: '{{transform}}', opacity: 0 }), @@ -177,6 +181,11 @@ export class CpsDialogComponent implements OnInit, AfterViewInit, OnDestroy { return this.maximized ? '' : convertSize(this.config.maxHeight); } + get resolvedTransitionOptions(): string { + if (prefersReducedMotion()) return REDUCED_MOTION_DURATION; + return this.config.transitionOptions || '150ms cubic-bezier(0, 0, 0.2, 1)'; + } + get minX(): number { return this._toPx(this.config.minX); } diff --git a/projects/cps-ui-kit/src/lib/services/cps-notification/internal/components/cps-toast/cps-toast.component.html b/projects/cps-ui-kit/src/lib/services/cps-notification/internal/components/cps-toast/cps-toast.component.html index dcc0f6d2..effeee5c 100644 --- a/projects/cps-ui-kit/src/lib/services/cps-notification/internal/components/cps-toast/cps-toast.component.html +++ b/projects/cps-ui-kit/src/lib/services/cps-notification/internal/components/cps-toast/cps-toast.component.html @@ -11,7 +11,11 @@ (focusout)="initiateTimeout()" [class]="data.type" [@toastState]="{ - value: 'visible' + value: 'visible', + params: { + showTiming: resolvedShowTiming, + hideTiming: resolvedHideTiming + } }" [style.max-width]="maxWidth" class="cps-toast-content"> diff --git a/projects/cps-ui-kit/src/lib/services/cps-notification/internal/components/cps-toast/cps-toast.component.spec.ts b/projects/cps-ui-kit/src/lib/services/cps-notification/internal/components/cps-toast/cps-toast.component.spec.ts index 4349f5c5..4a59163b 100644 --- a/projects/cps-ui-kit/src/lib/services/cps-notification/internal/components/cps-toast/cps-toast.component.spec.ts +++ b/projects/cps-ui-kit/src/lib/services/cps-notification/internal/components/cps-toast/cps-toast.component.spec.ts @@ -330,4 +330,30 @@ describe('CpsToastComponent', () => { expect(spy).toHaveBeenCalled(); })); }); + + describe('reduced motion', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should use the default timing by default', () => { + setup(); + jest + .spyOn(window, 'matchMedia') + .mockReturnValue({ matches: false } as MediaQueryList); + + expect(component.resolvedShowTiming).toBe('200ms ease-out'); + expect(component.resolvedHideTiming).toBe('200ms ease-in'); + }); + + it('should use a near-instant timing when the OS prefers reduced motion', () => { + setup(); + jest + .spyOn(window, 'matchMedia') + .mockReturnValue({ matches: true } as MediaQueryList); + + expect(component.resolvedShowTiming).toBe('1ms'); + expect(component.resolvedHideTiming).toBe('1ms'); + }); + }); }); diff --git a/projects/cps-ui-kit/src/lib/services/cps-notification/internal/components/cps-toast/cps-toast.component.ts b/projects/cps-ui-kit/src/lib/services/cps-notification/internal/components/cps-toast/cps-toast.component.ts index af0b805d..02f84eb5 100644 --- a/projects/cps-ui-kit/src/lib/services/cps-notification/internal/components/cps-toast/cps-toast.component.ts +++ b/projects/cps-ui-kit/src/lib/services/cps-notification/internal/components/cps-toast/cps-toast.component.ts @@ -27,6 +27,10 @@ import { transition, trigger } from '@angular/animations'; +import { + prefersReducedMotion, + REDUCED_MOTION_DURATION +} from '../../../../../utils/internal/motion-utils'; @Component({ imports: [CommonModule, CpsButtonComponent, CpsIconComponent], @@ -42,20 +46,28 @@ import { opacity: 1 }) ), - transition('void => *', [ - style({ transform: 'translateY(100%)', opacity: 0 }), - animate('200ms ease-out') - ]), - transition('* => void', [ - animate( - '200ms ease-in', - style({ - height: 0, - opacity: 0, - transform: 'translateY(-100%)' - }) - ) - ]) + transition( + 'void => *', + [ + style({ transform: 'translateY(100%)', opacity: 0 }), + animate('{{showTiming}}') + ], + { params: { showTiming: '200ms ease-out' } } + ), + transition( + '* => void', + [ + animate( + '{{hideTiming}}', + style({ + height: 0, + opacity: 0, + transform: 'translateY(-100%)' + }) + ) + ], + { params: { hideTiming: '200ms ease-in' } } + ) ]) ] }) @@ -89,6 +101,14 @@ export class CpsToastComponent implements OnInit, AfterViewInit, OnDestroy { return `Close ${type ? type + ' ' : ''}notification`; } + get resolvedShowTiming(): string { + return prefersReducedMotion() ? REDUCED_MOTION_DURATION : '200ms ease-out'; + } + + get resolvedHideTiming(): string { + return prefersReducedMotion() ? REDUCED_MOTION_DURATION : '200ms ease-in'; + } + // eslint-disable-next-line no-useless-constructor constructor(private zone: NgZone) {} diff --git a/projects/cps-ui-kit/src/lib/utils/internal/motion-utils.spec.ts b/projects/cps-ui-kit/src/lib/utils/internal/motion-utils.spec.ts new file mode 100644 index 00000000..059a3d68 --- /dev/null +++ b/projects/cps-ui-kit/src/lib/utils/internal/motion-utils.spec.ts @@ -0,0 +1,37 @@ +import { REDUCED_MOTION_DURATION, prefersReducedMotion } from './motion-utils'; + +describe('motion-utils', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should export a near-instant reduced motion duration', () => { + expect(REDUCED_MOTION_DURATION).toBe('1ms'); + }); + + it('should return false when the OS has no reduced-motion preference', () => { + jest.spyOn(window, 'matchMedia').mockReturnValue({ + matches: false + } as MediaQueryList); + + expect(prefersReducedMotion()).toBe(false); + }); + + it('should return true when the OS prefers reduced motion', () => { + jest.spyOn(window, 'matchMedia').mockReturnValue({ + matches: true + } as MediaQueryList); + + expect(prefersReducedMotion()).toBe(true); + }); + + it('should query the correct media feature', () => { + const spy = jest + .spyOn(window, 'matchMedia') + .mockReturnValue({ matches: false } as MediaQueryList); + + prefersReducedMotion(); + + expect(spy).toHaveBeenCalledWith('(prefers-reduced-motion: reduce)'); + }); +}); diff --git a/projects/cps-ui-kit/src/lib/utils/internal/motion-utils.ts b/projects/cps-ui-kit/src/lib/utils/internal/motion-utils.ts new file mode 100644 index 00000000..3968fad1 --- /dev/null +++ b/projects/cps-ui-kit/src/lib/utils/internal/motion-utils.ts @@ -0,0 +1,6 @@ +export const REDUCED_MOTION_DURATION = '1ms'; + +export const prefersReducedMotion = (): boolean => + typeof window !== 'undefined' && + typeof window.matchMedia === 'function' && + window.matchMedia('(prefers-reduced-motion: reduce)').matches; diff --git a/projects/cps-ui-kit/styles/styles.scss b/projects/cps-ui-kit/styles/styles.scss index 2b5ff8fb..13e15d1b 100644 --- a/projects/cps-ui-kit/styles/styles.scss +++ b/projects/cps-ui-kit/styles/styles.scss @@ -32,6 +32,23 @@ box-shadow var(--cps-motion-base) var(--cps-motion-easing) !important; } +@media (prefers-reduced-motion: reduce) { + :root { + --cps-motion-fast: 0ms; + --cps-motion-base: 0ms; + --cps-motion-slow: 0ms; + } + + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-property: none !important; + scroll-behavior: auto !important; + } +} + // Visually hidden but accessible to screen readers .cps-sr-only { position: absolute;