diff --git a/client/src/components/shared/TripLoadingSplash.tsx b/client/src/components/shared/TripLoadingSplash.tsx index 47f626432..c3bcdec9a 100644 --- a/client/src/components/shared/TripLoadingSplash.tsx +++ b/client/src/components/shared/TripLoadingSplash.tsx @@ -1,6 +1,6 @@ -import { useEffect, useState } from 'react' import { useTranslation } from '../../i18n' -import MDancingTrek, { type TrekScene } from '../../mobile/components/MDancingTrek' +import MDancingTrek from '../../mobile/components/MDancingTrek' +import { useTripSplashBeats } from './useTripSplashBeats' /** * Trip-open splash for desktop — the same little journey the mobile splash @@ -15,15 +15,6 @@ import MDancingTrek, { type TrekScene } from '../../mobile/components/MDancingTr * freezes and the mascot parks on "loading photos". Palette + glass tint swap * for light/dark via the .dark class the app puts on . */ -const STEPS: { scene: TrekScene; key: string }[] = [ - { scene: 'packing', key: 'trip.loadingSteps.pack' }, - { scene: 'transport', key: 'trip.loadingSteps.road' }, - { scene: 'dashboard', key: 'trip.loadingPhotos' }, - { scene: 'collections', key: 'trip.loadingSteps.arrive' }, -] -const STEP_MS = 1400 -const STILL_INDEX = 2 - const SPLASH_CSS = ` .m-splash-root { background: #eef0fb; } .dark .m-splash-root { background: #0c0c12; } @@ -158,18 +149,7 @@ const SPLASH_CSS = ` export default function TripLoadingSplash({ title }: { title?: string }) { const { t } = useTranslation() - const reduceMotion = - typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches - - const [index, setIndex] = useState(0) - useEffect(() => { - if (reduceMotion) return - const id = setInterval(() => setIndex((n) => (n + 1) % STEPS.length), STEP_MS) - return () => clearInterval(id) - }, [reduceMotion]) - - const activeIndex = reduceMotion ? STILL_INDEX : index - const step = STEPS[activeIndex] + const { reduceMotion, activeIndex, step, steps } = useTripSplashBeats({ liveReducedMotion: false }) return (
- {STEPS.map((_, i) => ( + {steps.map((_, i) => ( void; + +function stubReducedMotion(initial: boolean) { + const mq = { + matches: initial, + listeners: new Set(), + addEventListener: vi.fn((_: string, fn: Listener) => mq.listeners.add(fn)), + removeEventListener: vi.fn((_: string, fn: Listener) => mq.listeners.delete(fn)), + }; + Object.defineProperty(window, 'matchMedia', { + writable: true, + configurable: true, + value: vi.fn(() => mq), + }); + return { + mq, + flip(next: boolean) { + mq.matches = next; + mq.listeners.forEach((fn) => fn()); + }, + }; +} + +const originalMatchMedia = Object.getOwnPropertyDescriptor(window, 'matchMedia'); + +beforeEach(() => { + vi.useFakeTimers(); +}); +afterEach(() => { + vi.useRealTimers(); + if (originalMatchMedia) Object.defineProperty(window, 'matchMedia', originalMatchMedia); + else delete (window as { matchMedia?: unknown }).matchMedia; +}); + +describe('useTripSplashBeats', () => { + it('FE-COMP-SPLASHBEATS-001: holds the four beats, 1400 ms apart, parking on the third', () => { + expect(SPLASH_STEPS.map((s) => s.scene)).toEqual(['packing', 'transport', 'dashboard', 'collections']); + expect(SPLASH_STEP_MS).toBe(1400); + expect(SPLASH_STILL_INDEX).toBe(2); + expect(SPLASH_STEPS[SPLASH_STILL_INDEX].key).toBe('trip.loadingPhotos'); + }); + + it.each([false, true])( + 'FE-COMP-SPLASHBEATS-002: steps through the beats and loops (liveReducedMotion %s)', + (liveReducedMotion) => { + stubReducedMotion(false); + const { result } = renderHook(() => useTripSplashBeats({ liveReducedMotion })); + expect(result.current.activeIndex).toBe(0); + expect(result.current.step).toBe(SPLASH_STEPS[0]); + expect(result.current.steps).toBe(SPLASH_STEPS); + act(() => vi.advanceTimersByTime(SPLASH_STEP_MS - 1)); + expect(result.current.activeIndex).toBe(0); + act(() => vi.advanceTimersByTime(1)); + expect(result.current.activeIndex).toBe(1); + act(() => vi.advanceTimersByTime(SPLASH_STEP_MS * 3)); + expect(result.current.activeIndex).toBe(0); + expect(result.current.reduceMotion).toBeFalsy(); + } + ); + + it.each([false, true])( + 'FE-COMP-SPLASHBEATS-003: under reduced motion parks on the still beat (liveReducedMotion %s)', + (liveReducedMotion) => { + stubReducedMotion(true); + const { result } = renderHook(() => useTripSplashBeats({ liveReducedMotion })); + expect(result.current.reduceMotion).toBe(true); + expect(result.current.activeIndex).toBe(SPLASH_STILL_INDEX); + act(() => vi.advanceTimersByTime(SPLASH_STEP_MS * 5)); + expect(result.current.activeIndex).toBe(SPLASH_STILL_INDEX); + expect(result.current.step).toBe(SPLASH_STEPS[SPLASH_STILL_INDEX]); + } + ); + + it('FE-COMP-SPLASHBEATS-004: live, follows the setting flipped while the splash is up and unsubscribes', () => { + const media = stubReducedMotion(false); + const { result, unmount } = renderHook(() => useTripSplashBeats({ liveReducedMotion: true })); + expect(media.mq.addEventListener).toHaveBeenCalledWith('change', expect.any(Function)); + act(() => media.flip(true)); + expect(result.current.reduceMotion).toBe(true); + expect(result.current.activeIndex).toBe(SPLASH_STILL_INDEX); + act(() => vi.advanceTimersByTime(SPLASH_STEP_MS * 2)); + expect(result.current.activeIndex).toBe(SPLASH_STILL_INDEX); + act(() => media.flip(false)); + expect(result.current.reduceMotion).toBe(false); + unmount(); + expect(media.mq.removeEventListener).toHaveBeenCalledWith('change', expect.any(Function)); + expect(media.mq.listeners.size).toBe(0); + }); + + it('FE-COMP-SPLASHBEATS-005: not live, never subscribes and picks up the setting on the next render', () => { + const media = stubReducedMotion(false); + const { result, rerender } = renderHook(() => useTripSplashBeats({ liveReducedMotion: false })); + expect(media.mq.addEventListener).not.toHaveBeenCalled(); + act(() => media.flip(true)); + expect(result.current.reduceMotion).toBe(false); + rerender(); + expect(result.current.reduceMotion).toBe(true); + expect(result.current.activeIndex).toBe(SPLASH_STILL_INDEX); + }); + + it('FE-COMP-SPLASHBEATS-006: live without matchMedia, plays the beats', () => { + Object.defineProperty(window, 'matchMedia', { writable: true, configurable: true, value: undefined }); + const { result } = renderHook(() => useTripSplashBeats({ liveReducedMotion: true })); + expect(result.current.reduceMotion).toBe(false); + act(() => vi.advanceTimersByTime(SPLASH_STEP_MS)); + expect(result.current.activeIndex).toBe(1); + }); +}); diff --git a/client/src/components/shared/useTripSplashBeats.ts b/client/src/components/shared/useTripSplashBeats.ts new file mode 100644 index 000000000..73c1c51ea --- /dev/null +++ b/client/src/components/shared/useTripSplashBeats.ts @@ -0,0 +1,55 @@ +import { useEffect, useState } from 'react'; + +/** The beats of the trip-open splash: packing, hitting the road, cruising in, dropping the pin. */ +export const SPLASH_STEPS = [ + { scene: 'packing', key: 'trip.loadingSteps.pack' }, + { scene: 'transport', key: 'trip.loadingSteps.road' }, + { scene: 'dashboard', key: 'trip.loadingPhotos' }, + { scene: 'collections', key: 'trip.loadingSteps.arrive' }, +] as const; + +export const SPLASH_STEP_MS = 1400; +// Reduced motion parks on the paper-plane / "loading photos" beat. +export const SPLASH_STILL_INDEX = 2; +const REDUCE_MOTION = '(prefers-reduced-motion: reduce)'; + +interface TripSplashBeatsOptions { + /** + * Follow the OS reduced motion setting while the splash is up (the phone, whose splash can be + * up long enough for the setting to be flipped under it). Off, the setting is read on each render. + */ + liveReducedMotion: boolean; +} + +/** + * The beat cycle both trip-open splashes play: steps through SPLASH_STEPS every SPLASH_STEP_MS + * and loops; under reduced motion it holds SPLASH_STILL_INDEX instead. + */ +export function useTripSplashBeats({ liveReducedMotion }: TripSplashBeatsOptions) { + const [liveReduce, setLiveReduce] = useState(() => + liveReducedMotion ? (window.matchMedia?.(REDUCE_MOTION)?.matches ?? false) : false + ); + useEffect(() => { + if (!liveReducedMotion) return; + const mq = window.matchMedia?.(REDUCE_MOTION); + if (!mq) return; + const sync = () => setLiveReduce(mq.matches); + sync(); + mq.addEventListener('change', sync); + return () => mq.removeEventListener('change', sync); + }, [liveReducedMotion]); + + const reduceMotion = liveReducedMotion + ? liveReduce + : typeof window !== 'undefined' && window.matchMedia?.(REDUCE_MOTION).matches; + + const [index, setIndex] = useState(0); + useEffect(() => { + if (reduceMotion) return; + const id = setInterval(() => setIndex((n) => (n + 1) % SPLASH_STEPS.length), SPLASH_STEP_MS); + return () => clearInterval(id); + }, [reduceMotion]); + + const activeIndex = reduceMotion ? SPLASH_STILL_INDEX : index; + return { reduceMotion, activeIndex, step: SPLASH_STEPS[activeIndex], steps: SPLASH_STEPS }; +} diff --git a/client/src/mobile/screens/trip/MTripLoadingSplash.tsx b/client/src/mobile/screens/trip/MTripLoadingSplash.tsx index 3eabc739a..6987fd021 100644 --- a/client/src/mobile/screens/trip/MTripLoadingSplash.tsx +++ b/client/src/mobile/screens/trip/MTripLoadingSplash.tsx @@ -1,6 +1,6 @@ -import { useEffect, useState } from 'react' import { useTranslation } from '../../../i18n' -import MDancingTrek, { type TrekScene } from '../../components/MDancingTrek' +import MDancingTrek from '../../components/MDancingTrek' +import { useTripSplashBeats } from '../../../components/shared/useTripSplashBeats' /** * Trip-open splash — the TREK mascot acts out a little journey while the trip @@ -10,40 +10,9 @@ import MDancingTrek, { type TrekScene } from '../../components/MDancingTrek' * progress dots track which beat is on. Under reduced motion it holds a single * frame (the real "loading photos" one) with no transitions. */ -const STEPS: { scene: TrekScene; key: string }[] = [ - { scene: 'packing', key: 'trip.loadingSteps.pack' }, - { scene: 'transport', key: 'trip.loadingSteps.road' }, - { scene: 'dashboard', key: 'trip.loadingPhotos' }, - { scene: 'collections', key: 'trip.loadingSteps.arrive' }, -] - -const STEP_MS = 1400 -// Reduced motion parks on the paper-plane / "loading photos" beat. -const STILL_INDEX = 2 -const REDUCE_MOTION = '(prefers-reduced-motion: reduce)' - export default function MTripLoadingSplash({ title }: { title: string }) { const { t } = useTranslation() - const [reduceMotion, setReduceMotion] = useState(() => window.matchMedia?.(REDUCE_MOTION)?.matches ?? false) - // The splash can be up long enough for the OS setting to be flipped under it. - useEffect(() => { - const mq = window.matchMedia?.(REDUCE_MOTION) - if (!mq) return - const sync = () => setReduceMotion(mq.matches) - sync() - mq.addEventListener('change', sync) - return () => mq.removeEventListener('change', sync) - }, []) - - const [index, setIndex] = useState(0) - useEffect(() => { - if (reduceMotion) return - const id = setInterval(() => setIndex(n => (n + 1) % STEPS.length), STEP_MS) - return () => clearInterval(id) - }, [reduceMotion]) - - const activeIndex = reduceMotion ? STILL_INDEX : index - const step = STEPS[activeIndex] + const { reduceMotion, activeIndex, step, steps } = useTripSplashBeats({ liveReducedMotion: true }) return (
@@ -64,7 +33,7 @@ export default function MTripLoadingSplash({ title }: { title: string }) { {/* Beat dots — the active stage widens into an accent pill. */}
- {STEPS.map((_, i) => ( + {steps.map((_, i) => (