2026-10-03 18:52:57 -04:00
|
|
|
import { fireEvent, render, screen, within } from '@testing-library/react'
|
|
|
|
|
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
|
2026-10-04 08:46:40 -04:00
|
|
|
import { AppRoutes } from '../App.tsx'
|
2026-10-06 12:45:05 -04:00
|
|
|
import { ApiProviders, SETTINGS_API } from '../test/ApiProviders.tsx'
|
2026-10-03 18:52:57 -04:00
|
|
|
import { declaredStyle } from '../test/cssRules.ts'
|
2026-10-06 12:45:05 -04:00
|
|
|
import { InDataRouter } from '../test/dataRouter.tsx'
|
|
|
|
|
import { stubFetchByPath } from '../test/fetchStub.ts'
|
2026-10-03 18:52:57 -04:00
|
|
|
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<HTMLElement>(`.${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<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']>
|
|
|
|
|
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
drawnPill = null
|
|
|
|
|
translated = new Map()
|
|
|
|
|
// jsdom has no Web Animations API.
|
|
|
|
|
animate = vi.fn<Element['animate']>(() => ({ 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')
|
2026-10-06 12:45:05 -04:00
|
|
|
vi.unstubAllGlobals()
|
2026-10-03 18:52:57 -04:00
|
|
|
})
|
|
|
|
|
|
2026-10-06 21:28:23 -04:00
|
|
|
async function renderAt(path: string, reducedMotion = false) {
|
2026-10-03 18:52:57 -04:00
|
|
|
mockViewport(MOBILE, { reducedMotion })
|
2026-10-06 12:45:05 -04:00
|
|
|
stubFetchByPath(SETTINGS_API)
|
2026-10-03 18:52:57 -04:00
|
|
|
render(
|
2026-10-06 12:45:05 -04:00
|
|
|
<InDataRouter path={path}>
|
|
|
|
|
<ApiProviders>
|
|
|
|
|
<AppRoutes />
|
|
|
|
|
</ApiProviders>
|
|
|
|
|
</InDataRouter>,
|
2026-10-03 18:52:57 -04:00
|
|
|
)
|
2026-10-06 21:28:23 -04:00
|
|
|
// The routes wait for the household settings (OnboardingGate).
|
|
|
|
|
await screen.findByRole('navigation', { name: 'Primary' })
|
2026-10-03 18:52:57 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function nav() {
|
|
|
|
|
return screen.getByRole('navigation', { name: 'Primary' })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function pill(): HTMLElement {
|
|
|
|
|
return nav().querySelector<HTMLElement>(`.${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', () => {
|
2026-10-06 21:28:23 -04:00
|
|
|
it('places the pill on the active item without animating on first render', async () => {
|
|
|
|
|
await renderAt('/plan')
|
2026-10-03 18:52:57 -04:00
|
|
|
expect(pill().style.left).toBe('54px')
|
|
|
|
|
expect(pill().style.width).toBe('110px')
|
|
|
|
|
expect(pill()).toBeVisible()
|
|
|
|
|
expect(animate).not.toHaveBeenCalled()
|
|
|
|
|
})
|
|
|
|
|
|
2026-10-06 21:28:23 -04:00
|
|
|
it('hides the pill when no item is active', async () => {
|
|
|
|
|
await renderAt('/cook-from-pantry')
|
2026-10-03 18:52:57 -04:00
|
|
|
expect(pill()).not.toBeVisible()
|
|
|
|
|
})
|
|
|
|
|
|
2026-10-06 21:28:23 -04:00
|
|
|
it('slides the pill from the old item to the new one over 420 ms on the spring curve', async () => {
|
|
|
|
|
await renderAt('/')
|
2026-10-03 18:52:57 -04:00
|
|
|
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')
|
|
|
|
|
})
|
|
|
|
|
|
2026-10-06 21:28:23 -04:00
|
|
|
it('FLIPs every item that moved from its old left to its new one with the same timing', async () => {
|
|
|
|
|
await renderAt('/')
|
2026-10-03 18:52:57 -04:00
|
|
|
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([])
|
|
|
|
|
})
|
|
|
|
|
|
2026-10-06 21:28:23 -04:00
|
|
|
it('fades the new label in over 220 ms ease-out after 120 ms', async () => {
|
|
|
|
|
await renderAt('/')
|
2026-10-03 18:52:57 -04:00
|
|
|
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' },
|
|
|
|
|
},
|
|
|
|
|
])
|
|
|
|
|
})
|
|
|
|
|
|
2026-10-06 21:28:23 -04:00
|
|
|
it('does not animate when the active item stays the same', async () => {
|
|
|
|
|
await renderAt('/plan')
|
2026-10-03 18:52:57 -04:00
|
|
|
fireEvent.click(link('Plan'))
|
|
|
|
|
expect(animate).not.toHaveBeenCalled()
|
|
|
|
|
})
|
|
|
|
|
|
2026-10-06 21:28:23 -04:00
|
|
|
it('jumps the pill to the new item with no animation under reduced motion', async () => {
|
|
|
|
|
await renderAt('/', true)
|
2026-10-03 18:52:57 -04:00
|
|
|
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')
|
|
|
|
|
})
|
|
|
|
|
|
2026-10-06 21:28:23 -04:00
|
|
|
it('re-places the pill when the viewport resizes', async () => {
|
|
|
|
|
await renderAt('/discover')
|
2026-10-03 18:52:57 -04:00
|
|
|
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()
|
|
|
|
|
})
|
|
|
|
|
|
2026-10-06 21:28:23 -04:00
|
|
|
it('starts a second slide from where the first one is on screen, not from its destination', async () => {
|
|
|
|
|
await renderAt('/')
|
2026-10-03 18:52:57 -04:00
|
|
|
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 },
|
|
|
|
|
},
|
|
|
|
|
])
|
|
|
|
|
})
|
|
|
|
|
|
2026-10-06 21:28:23 -04:00
|
|
|
it('stops a running slide and places the pill on the active item when the viewport resizes', async () => {
|
|
|
|
|
await renderAt('/')
|
2026-10-03 18:52:57 -04:00
|
|
|
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<void>((resolve) => (fontsReady = resolve))
|
|
|
|
|
Object.defineProperty(document, 'fonts', { configurable: true, value: { ready } })
|
|
|
|
|
try {
|
2026-10-06 21:28:23 -04:00
|
|
|
await renderAt('/discover')
|
2026-10-03 18:52:57 -04:00
|
|
|
vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(124)
|
|
|
|
|
fontsReady()
|
|
|
|
|
await ready
|
|
|
|
|
expect(pill().style.width).toBe('124px')
|
|
|
|
|
} finally {
|
|
|
|
|
Reflect.deleteProperty(document, 'fonts')
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
2026-10-06 21:28:23 -04:00
|
|
|
it('crossfades the icon colour with a .2s colour transition', async () => {
|
|
|
|
|
await renderAt('/')
|
2026-10-03 18:52:57 -04:00
|
|
|
for (const item of within(nav()).getAllByRole('link')) {
|
|
|
|
|
expect(declaredStyle(item).transition).toBe('color .2s')
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
})
|