From 9f577cef76845d54c10c48c7da9b21e18124934a Mon Sep 17 00:00:00 2001 From: Paul DeVito Date: Sun, 25 Oct 2026 14:21:48 +0200 Subject: [PATCH 1/8] fix(frontend): stop the short pages from scrolling The Home and About routes and the component library set min-h-screen on their wrappers. The wrapper is below the 64 px header and above the 16 px bottom padding of the layout, and on a desktop the inset also has an 8 px margin above and below. So each page was one screen high plus 96 px (80 px on a phone) and scrolled when its content was short. The layout already fills the screen, so the wrappers do not set a height any more. --- frontend/src/routes/about.tsx | 2 +- frontend/src/routes/components.tsx | 2 +- frontend/src/routes/index.tsx | 2 +- .../features/app-shell/app-shell.locators.ts | 5 +++ .../features/app-shell/app-shell.spec.ts | 34 +++++++++++++++++++ .../features/app-shell/app-shell.tasks.ts | 19 +++++++++++ 6 files changed, 61 insertions(+), 3 deletions(-) create mode 100644 frontend/tests/features/app-shell/app-shell.locators.ts create mode 100644 frontend/tests/features/app-shell/app-shell.spec.ts create mode 100644 frontend/tests/features/app-shell/app-shell.tasks.ts diff --git a/frontend/src/routes/about.tsx b/frontend/src/routes/about.tsx index f3362f4e..372a9b51 100644 --- a/frontend/src/routes/about.tsx +++ b/frontend/src/routes/about.tsx @@ -7,7 +7,7 @@ export const Route = createFileRoute('/about')({ function About() { return ( -
+
diff --git a/frontend/src/routes/components.tsx b/frontend/src/routes/components.tsx index b1742f2a..12e74b04 100644 --- a/frontend/src/routes/components.tsx +++ b/frontend/src/routes/components.tsx @@ -159,7 +159,7 @@ function ComponentsPage() { return ( <> -
+
{/* Header */}
diff --git a/frontend/src/routes/index.tsx b/frontend/src/routes/index.tsx index f4f2df4b..ac32e876 100644 --- a/frontend/src/routes/index.tsx +++ b/frontend/src/routes/index.tsx @@ -64,7 +64,7 @@ function Index() { const handleLogout = () => auth.logoutUrl && logout(auth.logoutUrl) return ( -
+
{/* Header */}
diff --git a/frontend/tests/features/app-shell/app-shell.locators.ts b/frontend/tests/features/app-shell/app-shell.locators.ts new file mode 100644 index 00000000..9fcced13 --- /dev/null +++ b/frontend/tests/features/app-shell/app-shell.locators.ts @@ -0,0 +1,5 @@ +import type { Page } from '@playwright/test' + +export const appShell = (page: Page) => ({ + aboutText: page.getByText('This is a demo application built with .NET Aspire and React'), +}) diff --git a/frontend/tests/features/app-shell/app-shell.spec.ts b/frontend/tests/features/app-shell/app-shell.spec.ts new file mode 100644 index 00000000..bb2d91d7 --- /dev/null +++ b/frontend/tests/features/app-shell/app-shell.spec.ts @@ -0,0 +1,34 @@ +import { weatherApi } from '@tests/support/api/weather-api' +import { aForecast } from '@tests/support/builders/forecast-builder' +import { expect, test } from '@tests/support/fixtures' +import { weatherForecastPage } from '../weather/weather-forecast.tasks' +import { appShellPage } from './app-shell.tasks' + +for (const viewport of [ + { width: 1280, height: 800 }, + { width: 390, height: 844 }, +]) { + test.describe(`a short page at ${viewport.width}x${viewport.height}`, () => { + test.use({ viewport }) + + test('does not scroll', async ({ page }) => { + const shell = appShellPage(page) + + await shell.openAbout() + + expect(await shell.pageScrollRange()).toBe(0) + }) + }) +} + +test.describe('the home page on a desktop', () => { + test.use({ viewport: { width: 1280, height: 800 } }) + + test('does not scroll when the forecast fits', async ({ page, api }) => { + api.mock(weatherApi.listReturns(aForecast().on('2026-03-14'), aForecast().on('2026-03-15'), aForecast().on('2026-03-16'))) + + await weatherForecastPage(page).open() + + expect(await appShellPage(page).pageScrollRange()).toBe(0) + }) +}) diff --git a/frontend/tests/features/app-shell/app-shell.tasks.ts b/frontend/tests/features/app-shell/app-shell.tasks.ts new file mode 100644 index 00000000..96cd59cf --- /dev/null +++ b/frontend/tests/features/app-shell/app-shell.tasks.ts @@ -0,0 +1,19 @@ +import { expect, type Page } from '@playwright/test' +import { appShell } from './app-shell.locators' + +export function appShellPage(page: Page) { + const at = appShell(page) + + return { + at, + + async openAbout() { + await page.goto('/about') + await expect(at.aboutText).toBeVisible() + }, + + async pageScrollRange() { + return page.evaluate(() => document.documentElement.scrollHeight - document.documentElement.clientHeight) + }, + } +} From 432babe0d0ae2718c7e629e59eb62fcd70c75b4e Mon Sep 17 00:00:00 2001 From: Paul DeVito Date: Sun, 25 Oct 2026 14:21:48 +0200 Subject: [PATCH 2/8] fix(frontend): show an app page for an unknown address The router had no not-found component. For an address that no route matches, it showed the bare "Not Found" text of TanStack Router, with no link out, and it logged a warning two times. The breadcrumb showed "Page". The router now uses NotFound as its default not-found component. The page has a heading and a link to the home page, and the breadcrumb shows "Not Found" when the root match is the global not-found match. --- frontend/src/components/not-found.tsx | 16 +++++++++++++ frontend/src/main.tsx | 3 ++- frontend/src/routes/__root.tsx | 3 ++- .../features/app-shell/app-shell.locators.ts | 4 ++++ .../features/app-shell/app-shell.spec.ts | 23 +++++++++++++++++++ .../features/app-shell/app-shell.tasks.ts | 19 +++++++++++++++ 6 files changed, 66 insertions(+), 2 deletions(-) create mode 100644 frontend/src/components/not-found.tsx diff --git a/frontend/src/components/not-found.tsx b/frontend/src/components/not-found.tsx new file mode 100644 index 00000000..650ad56d --- /dev/null +++ b/frontend/src/components/not-found.tsx @@ -0,0 +1,16 @@ +import { Link } from 'react-aria-components' + +import { buttonVariants } from '@/components/ui/button-variants' + +export function NotFound() { + return ( +
+

404

+

Page not found

+

No page exists at this address.

+ + Go to the home page + +
+ ) +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index ddc36cc1..0735ec86 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -2,6 +2,7 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { RouterProvider, createRouter } from '@tanstack/react-router' +import { NotFound } from '@/components/not-found' import { ThemeProvider } from '@/components/theme-provider' import { Toaster } from '@/components/toaster' // Connects the generated API clients to apiClient before the first request. @@ -12,7 +13,7 @@ import './index.css' import { routeTree } from './routeTree.gen' // Create a new router instance -const router = createRouter({ routeTree }) +const router = createRouter({ routeTree, defaultNotFoundComponent: NotFound }) // Register the router instance for type safety declare module '@tanstack/react-router' { diff --git a/frontend/src/routes/__root.tsx b/frontend/src/routes/__root.tsx index 90aa1a5a..9b16e30c 100644 --- a/frontend/src/routes/__root.tsx +++ b/frontend/src/routes/__root.tsx @@ -45,7 +45,8 @@ function RootComponent() { const router = useRouter() const routerState = useRouterState() const currentPath = routerState.location.pathname - const pageName = routeNames[currentPath] ?? 'Page' + const isNotFound = routerState.matches.some((match) => match.globalNotFound) + const pageName = isNotFound ? 'Not Found' : (routeNames[currentPath] ?? 'Page') return ( ({ aboutText: page.getByText('This is a demo application built with .NET Aspire and React'), + currentPage: page.getByRole('navigation', { name: 'breadcrumb' }).getByRole('link'), + notFoundHeading: page.getByRole('heading', { name: 'Page not found' }), + homeLink: page.getByRole('link', { name: 'Go to the home page' }), + weatherHeading: page.getByRole('heading', { name: 'Weather Forecast' }), }) diff --git a/frontend/tests/features/app-shell/app-shell.spec.ts b/frontend/tests/features/app-shell/app-shell.spec.ts index bb2d91d7..6be41163 100644 --- a/frontend/tests/features/app-shell/app-shell.spec.ts +++ b/frontend/tests/features/app-shell/app-shell.spec.ts @@ -32,3 +32,26 @@ test.describe('the home page on a desktop', () => { expect(await appShellPage(page).pageScrollRange()).toBe(0) }) }) + +test.describe('an unknown address', () => { + test('shows a page not found message in the app layout', async ({ page }) => { + const shell = appShellPage(page) + + await shell.openMissingPage() + + await expect(shell.at.notFoundHeading).toBeVisible() + await expect(shell.at.currentPage).toHaveText('Not Found') + expect(shell.warnings.filter((warning) => warning.includes('notFound'))).toEqual([]) + }) + + test('links to the home page', async ({ page, api }) => { + api.mock(weatherApi.listReturns(aForecast())) + const shell = appShellPage(page) + await shell.openMissingPage() + + await shell.goHome() + + await expect(shell.at.weatherHeading).toBeVisible() + await expect(shell.at.currentPage).toHaveText('Home') + }) +}) diff --git a/frontend/tests/features/app-shell/app-shell.tasks.ts b/frontend/tests/features/app-shell/app-shell.tasks.ts index 96cd59cf..ea6572d0 100644 --- a/frontend/tests/features/app-shell/app-shell.tasks.ts +++ b/frontend/tests/features/app-shell/app-shell.tasks.ts @@ -1,17 +1,36 @@ import { expect, type Page } from '@playwright/test' +import { weatherPaths } from '@tests/support/api/weather-api' import { appShell } from './app-shell.locators' export function appShellPage(page: Page) { const at = appShell(page) + const warnings: string[] = [] + page.on('console', (message) => { + if (message.type() === 'warning') warnings.push(message.text()) + }) return { at, + warnings, async openAbout() { await page.goto('/about') await expect(at.aboutText).toBeVisible() }, + async openMissingPage() { + await page.goto('/no-such-page') + await expect(at.currentPage).toBeVisible() + }, + + async goHome() { + const settled = page.waitForResponse( + (r) => r.request().method() === 'GET' && new URL(r.url()).pathname === weatherPaths.list, + ) + await at.homeLink.click() + await settled + }, + async pageScrollRange() { return page.evaluate(() => document.documentElement.scrollHeight - document.documentElement.clientHeight) }, From 90468bc9d4029efb2bacca6dca12e6aa8c61ff21 Mon Sep 17 00:00:00 2001 From: Paul DeVito Date: Sun, 25 Oct 2026 14:21:48 +0200 Subject: [PATCH 3/8] fix(frontend): hold the skeletons still with reduced motion Skeleton used animate-pulse, so the loading placeholders pulsed for a user who asks the system for reduced motion. It now uses motion-safe:animate-pulse. The loading grid of the weather page is now a status named "Loading forecast", as the loading filter control already is, so a screen reader and the test can find it. The motion.dev warning about reduced motion stays in development. Motion logs it only in a development build, whenever it honors the setting, and the only way to remove it is to ignore the setting. --- frontend/src/components/ui/skeleton.tsx | 2 +- frontend/src/routes/index.tsx | 2 +- .../weather/weather-forecast.locators.ts | 1 + .../features/weather/weather-forecast.spec.ts | 26 +++++++++++++++++++ .../weather/weather-forecast.tasks.ts | 11 +++++++- 5 files changed, 39 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/ui/skeleton.tsx b/frontend/src/components/ui/skeleton.tsx index 0118624f..f5403108 100644 --- a/frontend/src/components/ui/skeleton.tsx +++ b/frontend/src/components/ui/skeleton.tsx @@ -4,7 +4,7 @@ function Skeleton({ className, ...props }: React.ComponentProps<"div">) { return (
) diff --git a/frontend/src/routes/index.tsx b/frontend/src/routes/index.tsx index ac32e876..665e4b20 100644 --- a/frontend/src/routes/index.tsx +++ b/frontend/src/routes/index.tsx @@ -176,7 +176,7 @@ function Index() { {/* Loading State */} {isLoading && displayData.length === 0 && ( -
+
{[...Array(6)].map((_, i) => ( diff --git a/frontend/tests/features/weather/weather-forecast.locators.ts b/frontend/tests/features/weather/weather-forecast.locators.ts index 12547ef3..0266971e 100644 --- a/frontend/tests/features/weather/weather-forecast.locators.ts +++ b/frontend/tests/features/weather/weather-forecast.locators.ts @@ -13,6 +13,7 @@ export const weatherForecast = (page: Page) => { celsius: page.getByRole('button', { name: '°C', exact: true }), refresh: page.getByRole('button', { name: 'Refresh', exact: true }), tryAgain: page.getByRole('button', { name: 'Try Again', exact: true }), + loading: page.getByRole('status', { name: 'Loading forecast' }), emptyMessage: page.getByText('No weather data available'), error: page.getByRole('alert').filter({ hasText: 'Error loading weather data' }), toast: (text: string) => page.getByRole('alertdialog').filter({ hasText: text }), diff --git a/frontend/tests/features/weather/weather-forecast.spec.ts b/frontend/tests/features/weather/weather-forecast.spec.ts index b5527c16..3a60c0aa 100644 --- a/frontend/tests/features/weather/weather-forecast.spec.ts +++ b/frontend/tests/features/weather/weather-forecast.spec.ts @@ -140,3 +140,29 @@ test.describe('a signed-in user', () => { expect(api.last('get', bffPaths.logout)?.query.get('sid')).toBe('test-session') }) }) + +test.describe('the loading placeholders', () => { + const slowList = { ...weatherApi.listReturns(aForecast()), delayMs: 2_000 } + + test('hold still for a user who asks for reduced motion', async ({ page, api }) => { + api.mock(slowList) + const weather = weatherForecastPage(page) + + await weather.openWhileLoading() + + expect(await weather.runningAnimationsIn(weather.at.loading)).toBe(0) + }) + + test.describe('without a reduced motion preference', () => { + test.use({ reducedMotion: 'no-preference' }) + + test('pulse', async ({ page, api }) => { + api.mock(slowList) + const weather = weatherForecastPage(page) + + await weather.openWhileLoading() + + expect(await weather.runningAnimationsIn(weather.at.loading)).toBeGreaterThan(0) + }) + }) +}) diff --git a/frontend/tests/features/weather/weather-forecast.tasks.ts b/frontend/tests/features/weather/weather-forecast.tasks.ts index d888b5e7..26278395 100644 --- a/frontend/tests/features/weather/weather-forecast.tasks.ts +++ b/frontend/tests/features/weather/weather-forecast.tasks.ts @@ -1,4 +1,4 @@ -import { expect, type Page } from '@playwright/test' +import { expect, type Locator, type Page } from '@playwright/test' import { bffPaths } from '@tests/support/api/bff-api' import { weatherPaths } from '@tests/support/api/weather-api' import { weatherForecast } from './weather-forecast.locators' @@ -19,6 +19,15 @@ export function weatherForecastPage(page: Page) { await settled }, + async openWhileLoading() { + await page.goto('/') + await expect(at.loading).toBeVisible() + }, + + async runningAnimationsIn(element: Locator) { + return element.evaluate((node) => node.getAnimations({ subtree: true }).length) + }, + async showInCelsius() { await at.celsius.click() }, From 8c3d17879812b6342a99c5be1edd27ef25464b03 Mon Sep 17 00:00:00 2001 From: Paul DeVito Date: Sun, 25 Oct 2026 14:21:48 +0200 Subject: [PATCH 4/8] fix(frontend): show a focus ring on the toast region F6 moves the focus to the toast region, but the region had outline-none and no other focus style, so a keyboard user could not see where the focus was. The region now shows the focus ring of a button. The ring of a focused toast was 2 px in the full ring color. It now also matches the button: 3 px at 50 percent, with the ring color on the border. --- frontend/src/components/toaster.tsx | 4 +-- .../tests/features/toasts/toasts.locators.ts | 10 ++++++ frontend/tests/features/toasts/toasts.spec.ts | 14 ++++++++ .../tests/features/toasts/toasts.tasks.ts | 34 +++++++++++++++++++ 4 files changed, 60 insertions(+), 2 deletions(-) create mode 100644 frontend/tests/features/toasts/toasts.locators.ts create mode 100644 frontend/tests/features/toasts/toasts.spec.ts create mode 100644 frontend/tests/features/toasts/toasts.tasks.ts diff --git a/frontend/src/components/toaster.tsx b/frontend/src/components/toaster.tsx index 184a17a5..51d8e452 100644 --- a/frontend/src/components/toaster.tsx +++ b/frontend/src/components/toaster.tsx @@ -49,7 +49,7 @@ export function Toaster() { {({ toast }) => ( diff --git a/frontend/tests/features/toasts/toasts.locators.ts b/frontend/tests/features/toasts/toasts.locators.ts new file mode 100644 index 00000000..57bf2488 --- /dev/null +++ b/frontend/tests/features/toasts/toasts.locators.ts @@ -0,0 +1,10 @@ +import type { Page } from '@playwright/test' + +export type ToastButton = 'Success' | 'Error' | 'Info' | 'Warning' + +export const toastDemo = (page: Page) => ({ + heading: page.getByRole('heading', { name: 'Component Library' }), + showToast: (name: ToastButton) => page.getByRole('button', { name, exact: true }), + region: page.getByRole('region', { name: /notification/ }), + toasts: page.getByRole('alertdialog'), +}) diff --git a/frontend/tests/features/toasts/toasts.spec.ts b/frontend/tests/features/toasts/toasts.spec.ts new file mode 100644 index 00000000..05532488 --- /dev/null +++ b/frontend/tests/features/toasts/toasts.spec.ts @@ -0,0 +1,14 @@ +import { expect, test } from '@tests/support/fixtures' +import { toastDemoPage } from './toasts.tasks' + +test.describe('the toast region', () => { + test('shows a focus ring when F6 moves the focus to it', async ({ page }) => { + const demo = toastDemoPage(page) + await demo.open() + await demo.showToastsByKeyboard('Success') + + await demo.moveFocusToToasts() + + expect(await demo.hasFocusRing(demo.at.region)).toBe(true) + }) +}) diff --git a/frontend/tests/features/toasts/toasts.tasks.ts b/frontend/tests/features/toasts/toasts.tasks.ts new file mode 100644 index 00000000..090a25d3 --- /dev/null +++ b/frontend/tests/features/toasts/toasts.tasks.ts @@ -0,0 +1,34 @@ +import { expect, type Locator, type Page } from '@playwright/test' +import { toastDemo, type ToastButton } from './toasts.locators' + +export function toastDemoPage(page: Page) { + const at = toastDemo(page) + + return { + at, + + async open() { + await page.goto('/components') + await expect(at.heading).toBeVisible() + }, + + async showToastsByKeyboard(...buttons: ToastButton[]) { + for (const button of buttons) await at.showToast(button).press('Enter') + await expect(at.toasts).toHaveCount(buttons.length) + }, + + async moveFocusToToasts() { + await page.keyboard.press('F6') + await expect(at.region).toBeFocused() + }, + + async hasFocusRing(element: Locator) { + return element.evaluate((node) => { + const { outlineStyle, outlineWidth, boxShadow } = getComputedStyle(node) + if (outlineStyle !== 'none' && parseFloat(outlineWidth) > 0) return true + const spreads = [...boxShadow.matchAll(/-?[\d.]+px -?[\d.]+px [\d.]+px (?-?[\d.]+)px/g)].map((m) => Number(m.groups?.spread)) + return spreads.some((spread) => spread >= 1) + }) + }, + } +} From 979b5bf79ac4066d4c72f0e0ac16f3e7b5c370c8 Mon Sep 17 00:00:00 2001 From: Paul DeVito Date: Sun, 25 Oct 2026 14:21:48 +0200 Subject: [PATCH 5/8] fix(frontend): show one toast at a time over a bottom sheet on a phone While a bottom sheet is open on a phone, the toasts move to the top of the screen. On a phone on its side (667x375), a tall sheet such as the date filter starts 56 px from the top, and three stacked toasts covered its title and its first controls. In that state only the newest toast now shows. React Aria lists the newest toast first, so the rule hides each later toast visually. The hidden toasts stay in the accessibility tree, and a toast shows again while the focus is in it. --- frontend/src/components/toaster.tsx | 1 + frontend/src/index.css | 5 ++++ .../tests/features/toasts/toasts.locators.ts | 3 ++ frontend/tests/features/toasts/toasts.spec.ts | 29 +++++++++++++++++++ .../tests/features/toasts/toasts.tasks.ts | 18 ++++++++++++ frontend/tests/support/screen.ts | 9 ++++++ 6 files changed, 65 insertions(+) create mode 100644 frontend/tests/support/screen.ts diff --git a/frontend/src/components/toaster.tsx b/frontend/src/components/toaster.tsx index 51d8e452..e706d1df 100644 --- a/frontend/src/components/toaster.tsx +++ b/frontend/src/components/toaster.tsx @@ -54,6 +54,7 @@ export function Toaster() { {({ toast }) => ( [data-slot="toast"]:not(:focus-within) { + @apply sr-only; + } + &::view-transition-new(.toast):only-child { animation-name: toast-slide-in-top; } diff --git a/frontend/tests/features/toasts/toasts.locators.ts b/frontend/tests/features/toasts/toasts.locators.ts index 57bf2488..32c880d3 100644 --- a/frontend/tests/features/toasts/toasts.locators.ts +++ b/frontend/tests/features/toasts/toasts.locators.ts @@ -5,6 +5,9 @@ export type ToastButton = 'Success' | 'Error' | 'Info' | 'Warning' export const toastDemo = (page: Page) => ({ heading: page.getByRole('heading', { name: 'Component Library' }), showToast: (name: ToastButton) => page.getByRole('button', { name, exact: true }), + openDialog: page.getByRole('button', { name: 'Open Dialog', exact: true }), + dialog: page.getByRole('dialog', { name: 'Dialog Title' }), region: page.getByRole('region', { name: /notification/ }), toasts: page.getByRole('alertdialog'), + toast: (message: string) => page.getByRole('alertdialog').filter({ hasText: message }), }) diff --git a/frontend/tests/features/toasts/toasts.spec.ts b/frontend/tests/features/toasts/toasts.spec.ts index 05532488..288ec0a2 100644 --- a/frontend/tests/features/toasts/toasts.spec.ts +++ b/frontend/tests/features/toasts/toasts.spec.ts @@ -12,3 +12,32 @@ test.describe('the toast region', () => { expect(await demo.hasFocusRing(demo.at.region)).toBe(true) }) }) + +// React Aria lists the newest toast first. +test.describe('the toasts on a phone on its side while a bottom sheet is open', () => { + test.use({ viewport: { width: 667, height: 375 } }) + + test('show only the newest toast, and keep the others for a screen reader', async ({ page }) => { + const demo = toastDemoPage(page) + await demo.open() + await demo.showToastsByKeyboard('Success', 'Error', 'Info') + + await demo.openBottomSheet() + + expect(await demo.shownToasts()).toEqual(['A new version is available']) + expect(await demo.at.toasts.count()).toBe(3) + }) + + test('show an older toast while it has the keyboard focus', async ({ page }) => { + const demo = toastDemoPage(page) + await demo.open() + await demo.showToastsByKeyboard('Success', 'Error', 'Info') + await demo.openBottomSheet() + + await demo.moveFocusToToasts() + await demo.pressTab(3) + + await expect(demo.at.toast('Something went wrong')).toBeFocused() + expect(await demo.shownToasts()).toEqual(['A new version is available', 'Something went wrong']) + }) +}) diff --git a/frontend/tests/features/toasts/toasts.tasks.ts b/frontend/tests/features/toasts/toasts.tasks.ts index 090a25d3..35033d1b 100644 --- a/frontend/tests/features/toasts/toasts.tasks.ts +++ b/frontend/tests/features/toasts/toasts.tasks.ts @@ -1,4 +1,5 @@ import { expect, type Locator, type Page } from '@playwright/test' +import { takesUpSpace } from '@tests/support/screen' import { toastDemo, type ToastButton } from './toasts.locators' export function toastDemoPage(page: Page) { @@ -17,11 +18,28 @@ export function toastDemoPage(page: Page) { await expect(at.toasts).toHaveCount(buttons.length) }, + async openBottomSheet() { + await at.openDialog.press('Enter') + await expect(at.dialog).toBeVisible() + }, + async moveFocusToToasts() { await page.keyboard.press('F6') await expect(at.region).toBeFocused() }, + async pressTab(times: number) { + for (let i = 0; i < times; i++) await page.keyboard.press('Tab') + }, + + async shownToasts() { + const shown: string[] = [] + for (const toast of await at.toasts.all()) { + if (await takesUpSpace(toast)) shown.push((await toast.innerText()).split('\n')[0]) + } + return shown + }, + async hasFocusRing(element: Locator) { return element.evaluate((node) => { const { outlineStyle, outlineWidth, boxShadow } = getComputedStyle(node) diff --git a/frontend/tests/support/screen.ts b/frontend/tests/support/screen.ts new file mode 100644 index 00000000..12cd0521 --- /dev/null +++ b/frontend/tests/support/screen.ts @@ -0,0 +1,9 @@ +import type { Locator } from '@playwright/test' + +// A visually hidden element (Tailwind sr-only) is 1 px by 1 px, and Playwright counts it as visible. +const visuallyHiddenSizePx = 1 + +export async function takesUpSpace(element: Locator): Promise { + const box = await element.boundingBox() + return box !== null && box.width > visuallyHiddenSizePx && box.height > visuallyHiddenSizePx +} From e68743514a0e50b937c49633df462977c8b58d7b Mon Sep 17 00:00:00 2001 From: Paul DeVito Date: Sun, 25 Oct 2026 14:21:48 +0200 Subject: [PATCH 6/8] fix(frontend): center the button sizes in their demo row The row of button sizes on the component page is a flex row with the default align-items, so the 28, 32, 36, and 32 px buttons lined up at the top and their centers were up to 4 px apart. The row now centers its items. The other demo rows have items of one height and do not change. --- frontend/src/routes/components.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/routes/components.tsx b/frontend/src/routes/components.tsx index 12e74b04..05083edc 100644 --- a/frontend/src/routes/components.tsx +++ b/frontend/src/routes/components.tsx @@ -205,7 +205,7 @@ function ComponentsPage() {
-
+
From 18210dfc069232546e0afa3755b94e3c0bd78362 Mon Sep 17 00:00:00 2001 From: Paul DeVito Date: Sun, 25 Oct 2026 14:21:48 +0200 Subject: [PATCH 7/8] fix(frontend): show the boolean filter label only in the submenu In the filter dialog, the title "Add Is Blocked Filter" already names the property, and the boolean control showed "Is Blocked" again below it. The label is now visually hidden in the dialog and stays visible in the desktop submenu, where it is the only name. The True and False buttons are now in a group that the label names, so a screen reader gives them a name in both places and reads the name once. --- .../controls/boolean-filter.tsx | 6 ++++-- .../filter-demo/filter-demo.locators.ts | 2 ++ .../features/filter-demo/filter-demo.spec.ts | 21 +++++++++++++++++++ 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/filter-builder/controls/boolean-filter.tsx b/frontend/src/components/filter-builder/controls/boolean-filter.tsx index a8102aa2..ff158904 100644 --- a/frontend/src/components/filter-builder/controls/boolean-filter.tsx +++ b/frontend/src/components/filter-builder/controls/boolean-filter.tsx @@ -1,3 +1,4 @@ +import { useId } from 'react' import { Button } from '@/components/ui/button' import type { Filter, FilterControlProps } from '../types' import { Operators } from '../utils/operators' @@ -7,6 +8,7 @@ type BooleanFilterProps = Pick export function BooleanFilter({ config, onSubmit }: BooleanFilterProps) { const { propertyKey, propertyLabel } = config + const labelId = useId() const handleSubmit = (selectedValue: boolean) => { onSubmit({ propertyKey, @@ -18,8 +20,8 @@ export function BooleanFilter({ config, onSubmit }: BooleanFilterProps) { } return ( -
-
{propertyLabel}
+
+
{propertyLabel}