Skip to content
Open
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
60 changes: 60 additions & 0 deletions src/app/common/celebrate/_motion.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
// Motion tokens for milestone moments. Celebrations are rare, so they may take
// a little longer than everyday UI, but every single piece stays under 300ms and
// a whole sequence finishes inside about 1.2s. Exits are quicker than entries.

$ease-out: cubic-bezier(0.23, 1, 0.32, 1);
$ease-in-out: cubic-bezier(0.77, 0, 0.175, 1);

$duration-exit: 150ms;
$duration-enter: 220ms;
$duration-draw: 280ms;
$stagger: 50ms;

// Expose the same values as custom properties on a host, so templates can set
// per-item delays with calc() against one source of truth.
@mixin tokens {
--ease-out: #{$ease-out};
--ease-in-out: #{$ease-in-out};
--celebrate-duration-exit: #{$duration-exit};
--celebrate-duration-enter: #{$duration-enter};
--celebrate-duration-draw: #{$duration-draw};
--celebrate-stagger: #{$stagger};
}

@mixin reduced-motion {
@media (prefers-reduced-motion: reduce) {
@content;
}
}

@mixin press {
transition: transform 120ms $ease-out;

&:active {
transform: scale(0.97);
}
}

// A soft rise used by labels and rows. Never starts from nothing: a small offset
// and a near-full scale, so it reads as settling in place.
@keyframes celebrate-rise {
from {
opacity: 0;
transform: translateY(6px);
}

to {
opacity: 1;
transform: none;
}
}

@keyframes celebrate-fade {
from {
opacity: 0;
}

to {
opacity: 1;
}
}
35 changes: 35 additions & 0 deletions src/app/common/celebrate/animated-check.component.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<svg
aria-hidden="true"
focusable="false"
viewBox="0 0 52 52"
[attr.height]="size()"
[attr.width]="size()"
>
<circle
class="ring-track"
cx="26"
cy="26"
fill="none"
r="23"
[attr.stroke-width]="strokeWidth()"
/>
<circle
class="ring"
cx="26"
cy="26"
fill="none"
pathLength="1"
r="23"
transform="rotate(-90 26 26)"
[attr.stroke-width]="strokeWidth()"
/>
<path
class="tick"
d="M16 27 l7 7 l13 -14"
fill="none"
pathLength="1"
stroke-linecap="round"
stroke-linejoin="round"
[attr.stroke-width]="strokeWidth()"
/>
</svg>
106 changes: 106 additions & 0 deletions src/app/common/celebrate/animated-check.component.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
@use './motion' as motion;

:host {
@include motion.tokens;

--check-color: var(--ot-color-success);
// Multiplies every duration and delay below. A rare, full-surface moment is
// worth drawing more slowly than the same mark inside a toast.
--check-pace: 1;
display: inline-block;
flex: none;
line-height: 0;
color: var(--check-color);
}

:host(.tone-primary) {
--check-color: var(--ot-color-primary);
}

:host(.tone-neutral) {
--check-color: var(--ot-color-text-muted);
}

svg {
display: block;
overflow: visible;
// The small settle after the tick lands. Crisp, not bouncy: 6% at most.
animation: check-settle calc(240ms * var(--check-pace)) motion.$ease-out both;
animation-delay: calc(var(--check-delay, 0ms) + 520ms * var(--check-pace));
}

.ring-track {
stroke: currentColor;
opacity: 0.16;
}

.ring,
.tick {
stroke: currentColor;
stroke-dasharray: 1;
stroke-dashoffset: 1;
}

.ring {
stroke-linecap: round;
animation: check-draw calc(#{motion.$duration-draw} * var(--check-pace)) motion.$ease-out forwards;
animation-delay: var(--check-delay, 0ms);
}

.tick {
animation: check-draw calc(240ms * var(--check-pace)) motion.$ease-out forwards;
animation-delay: calc(var(--check-delay, 0ms) + 260ms * var(--check-pace));
}

@keyframes check-draw {
to {
stroke-dashoffset: 0;
}
}

@keyframes check-settle {
0% {
transform: scale(0.95);
}

60% {
transform: scale(1.06);
}

100% {
transform: scale(1);
}
}

@mixin static-end-state {
svg {
animation: celebrate-check-fade 120ms linear both;
animation-delay: var(--check-delay, 0ms);
}

.ring,
.tick {
animation: none;
stroke-dashoffset: 0;
}
}

@keyframes celebrate-check-fade {
from {
opacity: 0;
}

to {
opacity: 1;
}
}

:host(.reduced-motion) {
@include static-end-state;
}

@include motion.reduced-motion {
:host {
@include static-end-state;
}
}
64 changes: 64 additions & 0 deletions src/app/common/celebrate/animated-check.component.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import {afterEach, describe, expect, it, vi} from 'vitest';
import {TestBed} from '@angular/core/testing';
import {AnimatedCheckComponent} from './animated-check.component';
import {CelebrationParticlesComponent} from './celebration-particles.component';

describe('AnimatedCheckComponent reduced motion', () => {
const original = window.matchMedia;

const mockMatchMedia = (reduce: boolean) => {
(window as unknown as {matchMedia: unknown}).matchMedia = vi.fn((query: string) => ({
matches: reduce && query === '(prefers-reduced-motion: reduce)',
media: query,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
}));
};

afterEach(() => {
(window as unknown as {matchMedia: unknown}).matchMedia = original;
TestBed.resetTestingModule();
});

it('draws normally when the viewer has not asked for less motion', () => {
mockMatchMedia(false);
const fixture = TestBed.createComponent(AnimatedCheckComponent);
fixture.detectChanges();

expect(fixture.componentInstance.reducedMotion).toBe(false);
expect(fixture.nativeElement.classList.contains('reduced-motion')).toBe(false);
});

it('exposes the reduced motion flag and marks the host for the static end state', () => {
mockMatchMedia(true);
const fixture = TestBed.createComponent(AnimatedCheckComponent);
fixture.componentRef.setInput('tone', 'primary');
fixture.componentRef.setInput('size', 24);
fixture.detectChanges();

const host = fixture.nativeElement as HTMLElement;
expect(fixture.componentInstance.reducedMotion).toBe(true);
expect(host.classList.contains('reduced-motion')).toBe(true);
expect(host.classList.contains('tone-primary')).toBe(true);
expect(host.querySelector('svg')?.getAttribute('aria-hidden')).toBe('true');
});

it('falls back to full motion when matchMedia is unavailable', () => {
(window as unknown as {matchMedia: unknown}).matchMedia = undefined;
const fixture = TestBed.createComponent(AnimatedCheckComponent);

expect(fixture.componentInstance.reducedMotion).toBe(false);
});

it('renders no particles under reduced motion', () => {
mockMatchMedia(true);
const reduced = TestBed.createComponent(CelebrationParticlesComponent);
reduced.detectChanges();
expect(reduced.nativeElement.querySelectorAll('.particle').length).toBe(0);

mockMatchMedia(false);
const full = TestBed.createComponent(CelebrationParticlesComponent);
full.detectChanges();
expect(full.nativeElement.querySelectorAll('.particle').length).toBe(12);
});
});
41 changes: 41 additions & 0 deletions src/app/common/celebrate/animated-check.component.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import {ChangeDetectionStrategy, Component, computed, input} from '@angular/core';
import {prefersReducedMotion} from './reduced-motion';

export type CelebrateTone = 'success' | 'primary' | 'neutral';

/**
* A ring that draws itself, then a tick that draws inside it. Purely decorative,
* so it is hidden from assistive technology and the host announces the meaning.
*/
@Component({
selector: 'f-animated-check',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './animated-check.component.html',
styleUrl: './animated-check.component.scss',
host: {
'class': 'f-animated-check',
'[class.tone-success]': "tone() === 'success'",
'[class.tone-primary]': "tone() === 'primary'",
'[class.tone-neutral]': "tone() === 'neutral'",
'[class.reduced-motion]': 'reducedMotion',
'[style.--check-delay]': 'delayCss()',
'[style.--check-pace]': 'pace()',
'[style.width.px]': 'size()',
'[style.height.px]': 'size()',
},
})
export class AnimatedCheckComponent {
public readonly size = input(40);
public readonly tone = input<CelebrateTone>('success');
/** Milliseconds to wait before the ring starts drawing. */
public readonly delay = input(0);
/** Multiplies every duration in the draw. Above 1 draws more slowly. */
public readonly pace = input(1);

/** Read once. A viewer who wants less motion gets the finished tick with a quick fade. */
public readonly reducedMotion = prefersReducedMotion();

protected readonly delayCss = computed(() => `${Math.max(0, this.delay())}ms`);
protected readonly strokeWidth = computed(() => (this.size() <= 20 ? 5 : 3.5));
}
13 changes: 13 additions & 0 deletions src/app/common/celebrate/celebration-particles.component.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
@if (!reducedMotion) {
@for (particle of particles(); track $index) {
<span
class="particle"
[style.--angle]="particle.angle + 'deg'"
[style.--distance]="particle.distance + 'px'"
[style.--particle-delay]="particle.delay + 'ms'"
[style.background]="particle.colour"
[style.height.px]="particle.size"
[style.width.px]="particle.size"
></span>
}
}
44 changes: 44 additions & 0 deletions src/app/common/celebrate/celebration-particles.component.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
@use './motion' as motion;

:host {
position: absolute;
inset: 0;
display: block;
overflow: visible;
pointer-events: none;
}

.particle {
position: absolute;
top: 50%;
left: 50%;
border-radius: 999px;
opacity: 0;
transform: translate(-50%, -50%) rotate(var(--angle)) translateY(0) scale(0.9);
animation: particle-burst 620ms motion.$ease-out forwards;
animation-delay: calc(var(--burst-delay, 0ms) + var(--particle-delay, 0ms));
will-change: transform, opacity;
}

@keyframes particle-burst {
0% {
opacity: 0;
transform: translate(-50%, -50%) rotate(var(--angle)) translateY(-8px) scale(0.9);
}

15% {
opacity: 0.9;
}

100% {
opacity: 0;
transform: translate(-50%, -50%) rotate(var(--angle)) translateY(calc(var(--distance) * -1))
scale(0.5);
}
}

@include motion.reduced-motion {
.particle {
display: none;
}
}
Loading