diff --git a/web/src/shell/AppShell.test.tsx b/web/src/shell/AppShell.test.tsx index a7ba038..48e0bc8 100644 --- a/web/src/shell/AppShell.test.tsx +++ b/web/src/shell/AppShell.test.tsx @@ -74,20 +74,24 @@ describe('AppShell', () => { expect(bar.borderRadius).toBe('var(--radius-pill)') }) - it('shows the active mobile item as icon and label on a grape pill, inactive items as neutral icons', () => { + it('shows the active mobile item as icon and label over the one grape pill, inactive items as neutral icons', () => { renderAt('/discover', MOBILE) + const pills = nav().querySelectorAll(`.${bottomBarStyles.pill}`) + expect(pills).toHaveLength(1) + const pill = declaredStyle(pills[0]!) + expect(pill.position).toBe('absolute') + expect(pill.top).toBe('10px') + expect(pill.height).toBe('42px') + expect(pill.background).toBe('var(--color-accent)') + expect(Number(pill.zIndex)).toBeLessThan(Number(declaredStyle(navLinks()[0]!).zIndex)) + const active = screen.getByRole('link', { name: 'Discover' }) - const pill = active.firstElementChild! - expect(pill).toHaveClass(bottomBarStyles.active!) - expect(declaredStyle(pill).height).toBe('42px') - expect(declaredStyle(pill).background).toBe('var(--color-accent)') - expect(declaredStyle(pill).color).toBe('var(--on-accent)') + expect(declaredStyle(active).color).toBe('var(--on-accent)') expect(within(active).getByText('Discover')).toHaveClass(bottomBarStyles.label!) expect(declaredStyle(within(active).getByText('Discover')).fontSize).toBe('13px') const inactive = screen.getByRole('link', { name: 'Plan' }) expect(declaredStyle(inactive).color).toBe('var(--color-neutral-400)') - expect(inactive.firstElementChild).not.toHaveClass(bottomBarStyles.active!) expect(within(inactive).getByText('Plan')).toHaveClass(bottomBarStyles.hiddenLabel!) }) diff --git a/web/src/shell/BottomBar.module.css b/web/src/shell/BottomBar.module.css index 6d4e044..97effa3 100644 --- a/web/src/shell/BottomBar.module.css +++ b/web/src/shell/BottomBar.module.css @@ -13,28 +13,38 @@ background: var(--color-text); } -/* The whole bar height is the tap target; the 42 px pill sits inside it. */ +/* The one grape pill, shared by every item: BottomBar sets its left and width + to the active item's and slides it between items. */ +.pill { + position: absolute; + top: 10px; + height: 42px; + z-index: 0; + border-radius: var(--radius-pill); + background: var(--color-accent); + pointer-events: none; +} + +/* The whole bar height is the tap target; the 42 px pill sits behind it. + Items stay above the pill. */ .item { + position: relative; + z-index: 1; display: flex; align-items: center; justify-content: center; + gap: 6px; height: 62px; min-width: 44px; + padding: 0 13px; + box-sizing: border-box; color: var(--color-neutral-400); text-decoration: none; + white-space: nowrap; + transition: color .2s; } -.pill { - display: flex; - align-items: center; - gap: 6px; - height: 42px; - padding: 0 13px; - border-radius: var(--radius-pill); -} - -.active { - background: var(--color-accent); +.item[aria-current='page'] { color: var(--on-accent); } diff --git a/web/src/shell/BottomBar.test.tsx b/web/src/shell/BottomBar.test.tsx new file mode 100644 index 0000000..6f93884 --- /dev/null +++ b/web/src/shell/BottomBar.test.tsx @@ -0,0 +1,282 @@ +import { fireEvent, render, screen, within } from '@testing-library/react' +import { MemoryRouter } from 'react-router' +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import App from '../App.tsx' +import { declaredStyle } from '../test/cssRules.ts' +import { mockViewport } from '../test/matchMedia.ts' +import styles from './BottomBar.module.css' + +const MOBILE = 390 +const SPRING = 'cubic-bezier(.34, 1.4, .64, 1)' + +// jsdom does no layout, so lay the bar out here: items sit side by side from +// 6 px in, inactive items 48 px wide and the active one (icon + label) 110 px. +function layOut(element: HTMLElement): { left: number; width: number } { + const bar = element.parentElement! + const items = Array.from(bar.querySelectorAll(`.${styles.item}`)) + let left = 6 + for (const item of items) { + const width = item.getAttribute('aria-current') === 'page' ? 110 : 48 + if (item === element) return { left, width } + left += width + } + return { left: 0, width: 0 } +} + +// jsdom paints nothing either, so bounding boxes come from the laid-out +// offsets with the bar at the viewport's left edge. A test mid-slide sets +// where the pill is drawn and how far each item is still translated. +let drawnPill: { left: number; width: number } | null +let translated: Map + +function box(left: number, width: number): DOMRect { + return { left, width, x: left, right: left + width, top: 0, y: 0, bottom: 0, height: 0 } as DOMRect +} + +let animate: Mock + +beforeEach(() => { + drawnPill = null + translated = new Map() + // jsdom has no Web Animations API. + animate = vi.fn(() => ({ cancel: vi.fn() }) as unknown as Animation) + Element.prototype.animate = animate + vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(function (this: Element) { + if (!(this instanceof HTMLElement)) return box(0, 0) + if (this.classList.contains(styles.pill!)) { + return drawnPill + ? box(drawnPill.left, drawnPill.width) + : box(parseFloat(this.style.left), parseFloat(this.style.width)) + } + if (this.classList.contains(styles.item!)) { + return box(this.offsetLeft + (translated.get(this) ?? 0), this.offsetWidth) + } + return box(0, 0) + }) + vi.spyOn(HTMLElement.prototype, 'offsetLeft', 'get').mockImplementation(function (this: HTMLElement) { + return layOut(this).left + }) + vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockImplementation(function (this: HTMLElement) { + return layOut(this).width + }) +}) + +afterEach(() => { + vi.restoreAllMocks() + Reflect.deleteProperty(Element.prototype, 'animate') + Reflect.deleteProperty(window, 'matchMedia') +}) + +function renderAt(path: string, reducedMotion = false) { + mockViewport(MOBILE, { reducedMotion }) + render( + + + , + ) +} + +function nav() { + return screen.getByRole('navigation', { name: 'Primary' }) +} + +function pill(): HTMLElement { + return nav().querySelector(`.${styles.pill}`)! +} + +function link(name: string): HTMLElement { + return within(nav()).getByRole('link', { name }) +} + +function runningOn(element: Element): Animation[] { + return animate.mock.contexts + .map((target, i) => ({ target, animation: animate.mock.results[i]!.value as Animation })) + .filter(({ target }) => target === element) + .map(({ animation }) => animation) +} + +function animationsOf(element: Element) { + return animate.mock.contexts + .map((target, i) => ({ target, args: animate.mock.calls[i]! })) + .filter(({ target }) => target === element) + .map(({ args }) => ({ keyframes: args[0], options: args[1] as KeyframeAnimationOptions })) +} + +describe('BottomBar', () => { + it('places the pill on the active item without animating on first render', () => { + renderAt('/plan') + expect(pill().style.left).toBe('54px') + expect(pill().style.width).toBe('110px') + expect(pill()).toBeVisible() + expect(animate).not.toHaveBeenCalled() + }) + + it('hides the pill when no item is active', () => { + renderAt('/onboarding') + expect(pill()).not.toBeVisible() + }) + + it('slides the pill from the old item to the new one over 420 ms on the spring curve', () => { + renderAt('/') + fireEvent.click(link('Quick')) + + expect(animationsOf(pill())).toEqual([ + { + keyframes: [ + { left: '6px', width: '110px' }, + { left: '150px', width: '110px' }, + ], + options: { duration: 420, easing: SPRING }, + }, + ]) + expect(pill().style.left).toBe('150px') + expect(pill().style.width).toBe('110px') + }) + + it('FLIPs every item that moved from its old left to its new one with the same timing', () => { + renderAt('/') + fireEvent.click(link('Quick')) + + // Home shrinks from 110 to 48 px, so Plan and Discover shift 62 px left + // and Quick grows in place; Settings ends where it started. + for (const name of ['Plan', 'Discover']) { + expect(animationsOf(link(name))).toEqual([ + { + keyframes: [{ transform: 'translateX(62px)' }, { transform: 'translateX(0)' }], + options: { duration: 420, easing: SPRING }, + }, + ]) + } + expect(animationsOf(link('Quick'))).toEqual([ + { + keyframes: [{ transform: 'translateX(62px)' }, { transform: 'translateX(0)' }], + options: { duration: 420, easing: SPRING }, + }, + ]) + expect(animationsOf(link('Home'))).toEqual([]) + expect(animationsOf(link('Settings'))).toEqual([]) + }) + + it('fades the new label in over 220 ms ease-out after 120 ms', () => { + renderAt('/') + fireEvent.click(link('Discover')) + + const label = within(link('Discover')).getByText('Discover') + expect(label).toHaveClass(styles.label!) + expect(animationsOf(label)).toEqual([ + { + keyframes: [ + { opacity: 0, transform: 'translateX(-4px)' }, + { opacity: 1, transform: 'none' }, + ], + options: { duration: 220, delay: 120, easing: 'ease-out', fill: 'backwards' }, + }, + ]) + }) + + it('does not animate when the active item stays the same', () => { + renderAt('/plan') + fireEvent.click(link('Plan')) + expect(animate).not.toHaveBeenCalled() + }) + + it('jumps the pill to the new item with no animation under reduced motion', () => { + renderAt('/', true) + fireEvent.click(link('Settings')) + + expect(link('Settings')).toHaveAttribute('aria-current', 'page') + expect(animate).not.toHaveBeenCalled() + expect(pill().style.left).toBe('198px') + expect(pill().style.width).toBe('110px') + }) + + it('re-places the pill when the viewport resizes', () => { + renderAt('/discover') + expect(pill().style.left).toBe('102px') + vi.spyOn(HTMLElement.prototype, 'offsetLeft', 'get').mockReturnValue(120) + vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(96) + fireEvent(window, new Event('resize')) + expect(pill().style.left).toBe('120px') + expect(pill().style.width).toBe('96px') + expect(animate).not.toHaveBeenCalled() + }) + + it('starts a second slide from where the first one is on screen, not from its destination', () => { + renderAt('/') + fireEvent.click(link('Quick')) + const [firstPill] = runningOn(pill()) + const firstPlan = runningOn(link('Plan'))[0]! + + // Midway: the pill is drawn at 78 px on its way from 6 to 150, and Plan + // still carries 31 of its 62 px FLIP translation. + drawnPill = { left: 78, width: 110 } + translated.set(link('Plan'), 31) + animate.mockClear() + fireEvent.click(link('Settings')) + + expect(firstPill!.cancel).toHaveBeenCalled() + expect(firstPlan.cancel).toHaveBeenCalled() + expect(animationsOf(pill())).toEqual([ + { + keyframes: [ + { left: '78px', width: '110px' }, + { left: '198px', width: '110px' }, + ], + options: { duration: 420, easing: SPRING }, + }, + ]) + // Plan's layout does not move between Quick and Settings, so it only + // finishes the 31 px it had left. Settings moves 62 px left. + expect(animationsOf(link('Plan'))).toEqual([ + { + keyframes: [{ transform: 'translateX(31px)' }, { transform: 'translateX(0)' }], + options: { duration: 420, easing: SPRING }, + }, + ]) + expect(animationsOf(link('Settings'))).toEqual([ + { + keyframes: [{ transform: 'translateX(62px)' }, { transform: 'translateX(0)' }], + options: { duration: 420, easing: SPRING }, + }, + ]) + }) + + it('stops a running slide and places the pill on the active item when the viewport resizes', () => { + renderAt('/') + fireEvent.click(link('Quick')) + const slide = [pill(), link('Plan'), link('Discover'), link('Quick')].flatMap(runningOn) + expect(slide).toHaveLength(4) + + animate.mockClear() + vi.spyOn(HTMLElement.prototype, 'offsetLeft', 'get').mockReturnValue(140) + vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(96) + fireEvent(window, new Event('resize')) + + for (const animation of slide) expect(animation.cancel).toHaveBeenCalled() + expect(animate).not.toHaveBeenCalled() + expect(pill().style.left).toBe('140px') + expect(pill().style.width).toBe('96px') + }) + + it('re-places the pill once the web fonts are ready', async () => { + let fontsReady!: () => void + const ready = new Promise((resolve) => (fontsReady = resolve)) + Object.defineProperty(document, 'fonts', { configurable: true, value: { ready } }) + try { + renderAt('/discover') + vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(124) + fontsReady() + await ready + expect(pill().style.width).toBe('124px') + } finally { + Reflect.deleteProperty(document, 'fonts') + } + }) + + it('crossfades the icon colour with a .2s colour transition', () => { + renderAt('/') + for (const item of within(nav()).getAllByRole('link')) { + expect(declaredStyle(item).transition).toBe('color .2s') + } + }) +}) diff --git a/web/src/shell/BottomBar.tsx b/web/src/shell/BottomBar.tsx index 14843d4..f6af58b 100644 --- a/web/src/shell/BottomBar.tsx +++ b/web/src/shell/BottomBar.tsx @@ -1,23 +1,152 @@ -import { NavLink } from 'react-router' +import { useLayoutEffect, useRef } from 'react' +import { NavLink, useLocation } from 'react-router' import { Icon } from '../ui/Icon.tsx' import { navItems } from './navItems.ts' import styles from './BottomBar.module.css' +// The handoff's Motion ยง1 timing. SPRING is the --ease-spring token's value. +const SLIDE_MS = 420 +const SPRING = 'cubic-bezier(.34, 1.4, .64, 1)' +const LABEL_MS = 220 +const LABEL_DELAY_MS = 120 +const REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)' + +// The active item and every item's layout offset within the bar when the pill +// was last placed (offsets ignore transforms, so a running FLIP does not skew +// them). +type Geometry = { + active: HTMLElement | null + itemLefts: number[] +} + // Mobile nav: a dark pill bar floating over the bottom of the screen. Inactive -// items show only their icon; the active item adds its label on a grape pill. +// items show only their icon; the active item adds its label. One shared +// grape pill sits behind the items and slides to the active one. export function BottomBar() { + const barRef = useRef(null) + const pillRef = useRef(null) + const placed = useRef(null) + // The pill slide and item FLIPs currently running. + const sliding = useRef([]) + const stopSlide = () => { + sliding.current.forEach((animation) => animation.cancel()) + sliding.current = [] + } + const { pathname } = useLocation() + + // After each navigation: if the active item changed, move the pill to it and, + // motion allowed, slide the pill, FLIP the items and fade the label in. A + // slide still running is measured where it is on screen, then stopped, so + // the next one starts from there instead of from its destination. + useLayoutEffect(() => { + const bar = barRef.current! + const pill = pillRef.current! + const active = activeItem(bar) + const before = placed.current + if (before && before.active === active) return + const from = before && onScreen(bar, pill, before) + stopSlide() + placed.current = place(bar, pill, active) + if (!before || !from || !active || window.matchMedia(REDUCED_MOTION_QUERY).matches) return + + const after = placed.current + if (before.active) { + sliding.current.push( + pill.animate( + [ + { left: `${from.pill.left}px`, width: `${from.pill.width}px` }, + { left: `${active.offsetLeft}px`, width: `${active.offsetWidth}px` }, + ], + { duration: SLIDE_MS, easing: SPRING }, + ), + ) + } + items(bar).forEach((item, i) => { + const dx = from.itemLefts[i]! - after.itemLefts[i]! + if (dx === 0) return + sliding.current.push( + item.animate([{ transform: `translateX(${dx}px)` }, { transform: 'translateX(0)' }], { + duration: SLIDE_MS, + easing: SPRING, + }), + ) + }) + active.querySelector(`.${styles.label}`)!.animate( + [ + { opacity: 0, transform: 'translateX(-4px)' }, + { opacity: 1, transform: 'none' }, + ], + { duration: LABEL_MS, delay: LABEL_DELAY_MS, easing: 'ease-out', fill: 'backwards' }, + ) + }, [pathname]) + + // Item widths change with the viewport and once the web fonts arrive, so + // stop any running slide and re-place the pill (without animating) on both. A failed font load + // rejects fonts.ready and is left to surface as an unhandled rejection. + useLayoutEffect(() => { + const bar = barRef.current! + const pill = pillRef.current! + let mounted = true + const replace = () => { + if (!mounted) return + stopSlide() + placed.current = place(bar, pill, activeItem(bar)) + } + window.addEventListener('resize', replace) + // jsdom has no document.fonts; every browser the app targets does. + if (document.fonts) void document.fonts.ready.then(replace) + return () => { + mounted = false + window.removeEventListener('resize', replace) + } + }, []) + return ( -