Start a slide from the pill's on-screen position and stop it on resize
CI / ci (push) Successful in 1m32s
Independent review Codex gpt-6-sol high: APPROVED (0 findings)
CI / ci (pull_request) Successful in 1m32s

A second tab change during the 420 ms slide now measures where the pill
and items are drawn (bounding boxes, which include the running animation)
before cancelling the running pill slide and item FLIPs, and starts the
next slide from there rather than from the previous destination. Resize and
document.fonts.ready also cancel any running slide before re-placing the
pill, so it sits on the active item immediately instead of being held by
the old keyframes. Tests cover both interruptions; timing, label fade and
the reduced-motion jump are unchanged.

Maestro task d340ae00-3c2a-48cf-9ac6-35e0b8ed5a7c

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 18:48:50 -04:00
co-authored by Claude Opus 5.5
parent 004a81b854
commit 06041c8458
2 changed files with 141 additions and 28 deletions
+89 -1
View File
@@ -23,12 +23,36 @@ function layOut(element: HTMLElement): { left: number; width: number } {
return { left: 0, width: 0 } 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<Element, number>
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<Element['animate']> let animate: Mock<Element['animate']>
beforeEach(() => { beforeEach(() => {
drawnPill = null
translated = new Map()
// jsdom has no Web Animations API. // jsdom has no Web Animations API.
animate = vi.fn<Element['animate']>() animate = vi.fn<Element['animate']>(() => ({ cancel: vi.fn() }) as unknown as Animation)
Element.prototype.animate = animate 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) { vi.spyOn(HTMLElement.prototype, 'offsetLeft', 'get').mockImplementation(function (this: HTMLElement) {
return layOut(this).left return layOut(this).left
}) })
@@ -64,6 +88,13 @@ function link(name: string): HTMLElement {
return within(nav()).getByRole('link', { name }) 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) { function animationsOf(element: Element) {
return animate.mock.contexts return animate.mock.contexts
.map((target, i) => ({ target, args: animate.mock.calls[i]! })) .map((target, i) => ({ target, args: animate.mock.calls[i]! }))
@@ -170,6 +201,63 @@ describe('BottomBar', () => {
expect(animate).not.toHaveBeenCalled() 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 () => { it('re-places the pill once the web fonts are ready', async () => {
let fontsReady!: () => void let fontsReady!: () => void
const ready = new Promise<void>((resolve) => (fontsReady = resolve)) const ready = new Promise<void>((resolve) => (fontsReady = resolve))
+52 -27
View File
@@ -11,12 +11,11 @@ const LABEL_MS = 220
const LABEL_DELAY_MS = 120 const LABEL_DELAY_MS = 120
const REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)' const REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)'
// Where the pill and every item sat when the pill was last placed, as layout // The active item and every item's layout offset within the bar when the pill
// offsets within the bar (offsets ignore transforms, so a running FLIP does // was last placed (offsets ignore transforms, so a running FLIP does not skew
// not skew them). // them).
type Geometry = { type Geometry = {
active: HTMLElement | null active: HTMLElement | null
pill: { left: number; width: number }
itemLefts: number[] itemLefts: number[]
} }
@@ -27,36 +26,50 @@ export function BottomBar() {
const barRef = useRef<HTMLElement>(null) const barRef = useRef<HTMLElement>(null)
const pillRef = useRef<HTMLSpanElement>(null) const pillRef = useRef<HTMLSpanElement>(null)
const placed = useRef<Geometry | null>(null) const placed = useRef<Geometry | null>(null)
// The pill slide and item FLIPs currently running.
const sliding = useRef<Animation[]>([])
const stopSlide = () => {
sliding.current.forEach((animation) => animation.cancel())
sliding.current = []
}
const { pathname } = useLocation() const { pathname } = useLocation()
// After each navigation: if the active item changed, move the pill to it and, // 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(() => { useLayoutEffect(() => {
const bar = barRef.current! const bar = barRef.current!
const pill = pillRef.current! const pill = pillRef.current!
const active = activeItem(bar) const active = activeItem(bar)
const before = placed.current const before = placed.current
if (before && before.active === active) return if (before && before.active === active) return
const from = before && onScreen(bar, pill, before)
stopSlide()
placed.current = place(bar, pill, active) 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 const after = placed.current
if (before.active) { if (before.active) {
pill.animate( sliding.current.push(
[ pill.animate(
{ left: `${before.pill.left}px`, width: `${before.pill.width}px` }, [
{ left: `${after.pill.left}px`, width: `${after.pill.width}px` }, { left: `${from.pill.left}px`, width: `${from.pill.width}px` },
], { left: `${active.offsetLeft}px`, width: `${active.offsetWidth}px` },
{ duration: SLIDE_MS, easing: SPRING }, ],
{ duration: SLIDE_MS, easing: SPRING },
),
) )
} }
items(bar).forEach((item, i) => { 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 if (dx === 0) return
item.animate([{ transform: `translateX(${dx}px)` }, { transform: 'translateX(0)' }], { sliding.current.push(
duration: SLIDE_MS, item.animate([{ transform: `translateX(${dx}px)` }, { transform: 'translateX(0)' }], {
easing: SPRING, duration: SLIDE_MS,
}) easing: SPRING,
}),
)
}) })
active.querySelector(`.${styles.label}`)!.animate( active.querySelector(`.${styles.label}`)!.animate(
[ [
@@ -68,14 +81,16 @@ export function BottomBar() {
}, [pathname]) }, [pathname])
// Item widths change with the viewport and once the web fonts arrive, so // 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. // rejects fonts.ready and is left to surface as an unhandled rejection.
useLayoutEffect(() => { useLayoutEffect(() => {
const bar = barRef.current! const bar = barRef.current!
const pill = pillRef.current! const pill = pillRef.current!
let mounted = true let mounted = true
const replace = () => { 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) window.addEventListener('resize', replace)
// jsdom has no document.fonts; every browser the app targets does. // jsdom has no document.fonts; every browser the app targets does.
@@ -112,16 +127,26 @@ function activeItem(bar: HTMLElement): HTMLElement | null {
return bar.querySelector<HTMLElement>(`.${styles.item}[aria-current="page"]`) return bar.querySelector<HTMLElement>(`.${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 // Puts the pill on the active item (hidden when no item is active) and
// returns the resulting geometry. // returns the resulting geometry.
function place(bar: HTMLElement, pill: HTMLElement, active: HTMLElement | null): 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.hidden = !active
pill.style.left = `${geometry.pill.left}px` pill.style.left = `${active?.offsetLeft ?? 0}px`
pill.style.width = `${geometry.pill.width}px` pill.style.width = `${active?.offsetWidth ?? 0}px`
return geometry return { active, itemLefts: items(bar).map((item) => item.offsetLeft) }
} }