+
}
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;