diff --git a/web/src/shell/BottomBar.test.tsx b/web/src/shell/BottomBar.test.tsx index 2cbf9a3..6f93884 100644 --- a/web/src/shell/BottomBar.test.tsx +++ b/web/src/shell/BottomBar.test.tsx @@ -23,12 +23,36 @@ function layOut(element: HTMLElement): { left: number; width: number } { 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() + 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 }) @@ -64,6 +88,13 @@ 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]! })) @@ -170,6 +201,63 @@ describe('BottomBar', () => { 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)) diff --git a/web/src/shell/BottomBar.tsx b/web/src/shell/BottomBar.tsx index 7070f53..f6af58b 100644 --- a/web/src/shell/BottomBar.tsx +++ b/web/src/shell/BottomBar.tsx @@ -11,12 +11,11 @@ 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). +// 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 - pill: { left: number; width: number } itemLefts: number[] } @@ -27,36 +26,50 @@ 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. + // 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 || !active || window.matchMedia(REDUCED_MOTION_QUERY).matches) return + if (!before || !from || !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 }, + 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 = before.itemLefts[i]! - after.itemLefts[i]! + const dx = from.itemLefts[i]! - after.itemLefts[i]! if (dx === 0) return - item.animate([{ transform: `translateX(${dx}px)` }, { transform: 'translateX(0)' }], { - duration: SLIDE_MS, - easing: SPRING, - }) + sliding.current.push( + item.animate([{ transform: `translateX(${dx}px)` }, { transform: 'translateX(0)' }], { + duration: SLIDE_MS, + easing: SPRING, + }), + ) }) active.querySelector(`.${styles.label}`)!.animate( [ @@ -68,14 +81,16 @@ export function BottomBar() { }, [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 + // 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) placed.current = place(bar, pill, activeItem(bar)) + 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. @@ -112,16 +127,26 @@ function activeItem(bar: HTMLElement): HTMLElement | null { return bar.querySelector(`.${styles.item}[aria-current="page"]`) } +// Where the pill and items are on screen right now, as offsets within the +// bar. Bounding boxes include a running slide: the pill's box is its animated +// left and width, and an item's box is its current layout plus whatever FLIP +// translation remains, which applies to the layout it had when last placed. +function onScreen(bar: HTMLElement, pill: HTMLElement, before: Geometry) { + const origin = bar.getBoundingClientRect().left + bar.clientLeft + const pillBox = pill.getBoundingClientRect() + return { + pill: { left: pillBox.left - origin, width: pillBox.width }, + itemLefts: items(bar).map( + (item, i) => before.itemLefts[i]! + item.getBoundingClientRect().left - origin - item.offsetLeft, + ), + } +} + // Puts the pill on the active item (hidden when no item is active) and // returns the resulting geometry. function place(bar: HTMLElement, pill: HTMLElement, active: HTMLElement | null): Geometry { - const geometry = { - active, - pill: { left: active?.offsetLeft ?? 0, width: active?.offsetWidth ?? 0 }, - itemLefts: items(bar).map((item) => item.offsetLeft), - } pill.hidden = !active - pill.style.left = `${geometry.pill.left}px` - pill.style.width = `${geometry.pill.width}px` - return geometry + pill.style.left = `${active?.offsetLeft ?? 0}px` + pill.style.width = `${active?.offsetWidth ?? 0}px` + return { active, itemLefts: items(bar).map((item) => item.offsetLeft) } }