From 004a81b854509cb78e293e844cc4103f324ba6ca Mon Sep 17 00:00:00 2001 From: dev_agent Date: Sat, 3 Oct 2026 18:44:44 -0400 Subject: [PATCH] Slide one shared grape pill between mobile nav items The mobile bottom bar now draws a single absolutely positioned grape pill (top 10 px, height 42 px) behind its items and implements handoff Motion section 1: on a tab change it slides the pill from the old item's left and width to the new one's over 420 ms on the --ease-spring curve, FLIPs every moved item with the same timing, and fades the new label in (220 ms ease-out after 120 ms). Icon colour crossfades via transition color .2s. Under prefers-reduced-motion the pill jumps with no animation. The pill is re-placed on resize and once document.fonts.ready resolves. Each item keeps the full 62 px bar height as its tap target. Maestro task d340ae00-3c2a-48cf-9ac6-35e0b8ed5a7c Co-Authored-By: Claude Opus 5.5 --- web/src/shell/AppShell.test.tsx | 18 +-- web/src/shell/BottomBar.module.css | 34 +++-- web/src/shell/BottomBar.test.tsx | 194 +++++++++++++++++++++++++++++ web/src/shell/BottomBar.tsx | 114 ++++++++++++++++- web/src/test/matchMedia.ts | 10 +- 5 files changed, 342 insertions(+), 28 deletions(-) create mode 100644 web/src/shell/BottomBar.test.tsx 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..2cbf9a3 --- /dev/null +++ b/web/src/shell/BottomBar.test.tsx @@ -0,0 +1,194 @@ +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 } +} + +let animate: Mock + +beforeEach(() => { + // jsdom has no Web Animations API. + animate = vi.fn() + Element.prototype.animate = animate + 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 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('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..7070f53 100644 --- a/web/src/shell/BottomBar.tsx +++ b/web/src/shell/BottomBar.tsx @@ -1,23 +1,127 @@ -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)' + +// Where the pill and every item sat when the pill was last placed, as layout +// offsets within the bar (offsets ignore transforms, so a running FLIP does +// not skew them). +type Geometry = { + active: HTMLElement | null + pill: { left: number; width: number } + 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) + 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. + useLayoutEffect(() => { + const bar = barRef.current! + const pill = pillRef.current! + const active = activeItem(bar) + const before = placed.current + if (before && before.active === active) return + placed.current = place(bar, pill, active) + if (!before || !active || window.matchMedia(REDUCED_MOTION_QUERY).matches) return + + const after = placed.current + if (before.active) { + pill.animate( + [ + { left: `${before.pill.left}px`, width: `${before.pill.width}px` }, + { left: `${after.pill.left}px`, width: `${after.pill.width}px` }, + ], + { duration: SLIDE_MS, easing: SPRING }, + ) + } + items(bar).forEach((item, i) => { + const dx = before.itemLefts[i]! - after.itemLefts[i]! + if (dx === 0) return + 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 + // 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) 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 ( -