feat: add paper-style mobile navigation
ci / gitleaks (push) Successful in 8s
ci / docker (push) Successful in 3m35s

This commit is contained in:
2026-09-18 08:34:36 +08:00
parent a4c1ac5978
commit 7bfc5d2a44
6 changed files with 155 additions and 12 deletions
+123
View File
@@ -10,6 +10,129 @@ async function openTaskComposer(page: Page) {
return page.getByRole('dialog', { name: /添加(?:今天)?任务/ })
}
test('bottom navigation uses the paper baseline geometry', async ({ page }) => {
await page.goto('/')
const metrics = await page.locator('.bottom').evaluate(element => {
const nav = element as HTMLElement
const rect = nav.getBoundingClientRect()
const buttons = [...nav.querySelectorAll<HTMLElement>('button')]
const active = nav.querySelector<HTMLElement>('button.active')!
const activeStyle = getComputedStyle(active)
const marker = getComputedStyle(active, '::before')
const fab = document.querySelector<HTMLElement>('.unified-fab')!
const fabRect = fab.getBoundingClientRect()
return {
viewportWidth: innerWidth,
documentOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth,
nav: { left: rect.left, right: rect.right, bottom: rect.bottom, width: rect.width, height: rect.height },
buttonWidths: buttons.map(button => button.getBoundingClientRect().width),
buttonHeights: buttons.map(button => button.getBoundingClientRect().height),
background: getComputedStyle(nav).backgroundColor,
borderTopWidth: getComputedStyle(nav).borderTopWidth,
borderRadius: getComputedStyle(nav).borderRadius,
boxShadow: getComputedStyle(nav).boxShadow,
activeBackground: activeStyle.backgroundColor,
activeColor: activeStyle.color,
marker: { height: marker.height, background: marker.backgroundColor },
fabGap: rect.top - fabRect.bottom,
}
})
expect(metrics.documentOverflow).toBe(0)
expect(metrics.nav).toEqual({ left: 0, right: metrics.viewportWidth, bottom: page.viewportSize()!.height, width: metrics.viewportWidth, height: 64 })
expect(Math.max(...metrics.buttonWidths) - Math.min(...metrics.buttonWidths)).toBeLessThanOrEqual(1)
expect(metrics.buttonHeights.every(height => height >= 44)).toBeTruthy()
expect(metrics.background).toBe('rgb(255, 253, 248)')
expect(metrics.borderTopWidth).toBe('1px')
expect(metrics.borderRadius).toBe('0px')
expect(metrics.boxShadow).toBe('none')
expect(metrics.activeBackground).toBe('rgba(0, 0, 0, 0)')
expect(metrics.activeColor).toBe('rgb(241, 90, 41)')
expect(metrics.marker).toEqual({ height: '3px', background: 'rgb(241, 90, 41)' })
expect(metrics.fabGap).toBeGreaterThanOrEqual(12)
const fab = page.locator('.unified-fab')
const fabBox = await fab.boundingBox()
expect(fabBox).not.toBeNull()
await page.mouse.move(fabBox!.x + fabBox!.width / 2, fabBox!.y + fabBox!.height / 2)
await page.mouse.down()
await page.mouse.move(fabBox!.x + fabBox!.width / 2, page.viewportSize()!.height - 1, { steps: 8 })
await page.mouse.up()
const draggedGap = await page.evaluate(() => {
const nav = document.querySelector<HTMLElement>('.bottom')!.getBoundingClientRect()
const button = document.querySelector<HTMLElement>('.unified-fab')!.getBoundingClientRect()
return nav.top - button.bottom
})
expect(draggedGap).toBeGreaterThanOrEqual(12)
})
test('bottom navigation keeps its safe-area gap after dragging', async ({ page }) => {
await page.goto('/')
const nav = page.locator('.bottom')
await expect(nav).toBeVisible()
await page.locator('html').evaluate(element => element.style.setProperty('--safe-area-bottom', '34px', 'important'))
await expect.poll(() => nav.evaluate(element => getComputedStyle(element).height)).toBe('98px')
const navBox = await nav.boundingBox()
expect(navBox).not.toBeNull()
expect(navBox!.height).toBe(98)
const fab = page.locator('.unified-fab')
const fabBox = await fab.boundingBox()
expect(fabBox).not.toBeNull()
await page.mouse.move(fabBox!.x + fabBox!.width / 2, fabBox!.y + fabBox!.height / 2)
await page.mouse.down()
await page.mouse.move(fabBox!.x + fabBox!.width / 2, page.viewportSize()!.height - 1, { steps: 8 })
await page.mouse.up()
const gap = await page.evaluate(() => {
const navigation = document.querySelector<HTMLElement>('.bottom')!.getBoundingClientRect()
const button = document.querySelector<HTMLElement>('.unified-fab')!.getBoundingClientRect()
return navigation.top - button.bottom
})
expect(gap).toBeGreaterThanOrEqual(12)
})
test('all bottom destinations expose one active page and desktop layout stays unchanged', async ({ page }) => {
await page.goto('/')
const navigation = page.getByRole('navigation', { name: '主要导航' })
for (const label of ['今天', '习惯', '倒数日', '设置']) {
await bottomTab(page, label).click()
await expect(navigation.locator('[aria-current="page"]')).toHaveCount(1)
await expect(bottomTab(page, label)).toHaveAttribute('aria-current', 'page')
}
await bottomTab(page, '今天').click()
await page.setViewportSize({ width: 1440, height: 900 })
const desktop = await page.locator('.shell').evaluate(element => {
const shell = getComputedStyle(element)
const navigation = document.querySelector<HTMLElement>('.bottom')!
const navStyle = getComputedStyle(navigation)
const fab = document.querySelector<HTMLElement>('.unified-fab')!
const fabStyle = getComputedStyle(fab)
const fabRect = fab.getBoundingClientRect()
return {
shellDisplay: shell.display,
shellColumns: shell.gridTemplateColumns,
navDisplay: navStyle.display,
fab: { position: fabStyle.position, width: fabRect.width, height: fabRect.height },
}
})
expect(desktop.shellDisplay).toBe('grid')
expect(desktop.shellColumns.split(' ').length).toBe(3)
expect(desktop.navDisplay).toBe('none')
expect(desktop.fab).toEqual({ position: 'fixed', width: 56, height: 56 })
const desktopFab = page.locator('.unified-fab')
const desktopFabBox = await desktopFab.boundingBox()
expect(desktopFabBox).not.toBeNull()
await page.mouse.move(desktopFabBox!.x + desktopFabBox!.width / 2, desktopFabBox!.y + desktopFabBox!.height / 2)
await page.mouse.down()
await page.mouse.move(desktopFabBox!.x + desktopFabBox!.width / 2, 899, { steps: 8 })
await page.mouse.up()
const desktopBottomGap = await desktopFab.evaluate(element => innerHeight - element.getBoundingClientRect().bottom)
expect(desktopBottomGap).toBe(84)
})
test('settings are continuous, fit viewport, and controls are touch sized', async ({ page }) => {
await page.goto('/')
await bottomTab(page, '设置').click()