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}
+ ) +} diff --git a/frontend/src/components/toaster.tsx b/frontend/src/components/toaster.tsx index 184a17a5..9da6f566 100644 --- a/frontend/src/components/toaster.tsx +++ b/frontend/src/components/toaster.tsx @@ -42,24 +42,26 @@ const iconColors = { */ export function Toaster() { // React Aria lists the newest toast first. flex-col-reverse keeps it nearest the bottom edge. - // Below 768px the region spans the screen, and a toast is at most 500px wide. pointer-events-none lets a click beside - // or between the toasts reach the page. Each toast takes pointer events again, so hover still pauses the timers. + // Below 768px the region is centered and at most 500px wide, the width of a toast, so its focus ring fits the toast. + // pointer-events-none lets a click beside or between the toasts reach the page. Each toast takes pointer events again, + // so hover still pauses the timers. // index.css moves the region to the top while a bottom sheet is open. return ( {({ toast }) => ( 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/index.css b/frontend/src/index.css index 462ad161..9e60d4ca 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -244,6 +244,11 @@ flex-direction: column; } + /* React Aria lists the newest toast first. */ + [data-slot="toast-region"] li:not(:first-child) > [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/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 ( +
diff --git a/frontend/src/routes/components.tsx b/frontend/src/routes/components.tsx index b1742f2a..1b0ed721 100644 --- a/frontend/src/routes/components.tsx +++ b/frontend/src/routes/components.tsx @@ -159,7 +159,7 @@ function ComponentsPage() { return ( <> -
+
{/* Header */}
@@ -205,7 +205,7 @@ function ComponentsPage() {
-
+
diff --git a/frontend/src/routes/index.tsx b/frontend/src/routes/index.tsx index f4f2df4b..665e4b20 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 */}
@@ -176,7 +176,7 @@ function Index() { {/* Loading State */} {isLoading && displayData.length === 0 && ( -
+
{[...Array(6)].map((_, i) => ( 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..f2e195d4 --- /dev/null +++ b/frontend/tests/features/app-shell/app-shell.locators.ts @@ -0,0 +1,9 @@ +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'), + 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 new file mode 100644 index 00000000..6be41163 --- /dev/null +++ b/frontend/tests/features/app-shell/app-shell.spec.ts @@ -0,0 +1,57 @@ +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) + }) +}) + +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 new file mode 100644 index 00000000..ea6572d0 --- /dev/null +++ b/frontend/tests/features/app-shell/app-shell.tasks.ts @@ -0,0 +1,38 @@ +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) + }, + } +} diff --git a/frontend/tests/features/filter-demo/filter-demo.locators.ts b/frontend/tests/features/filter-demo/filter-demo.locators.ts index 8b6d04f5..9254bf18 100644 --- a/frontend/tests/features/filter-demo/filter-demo.locators.ts +++ b/frontend/tests/features/filter-demo/filter-demo.locators.ts @@ -10,6 +10,8 @@ export const filterDemo = (page: Page) => { property: (propertyLabel: string) => page.getByRole('menuitem', { name: propertyLabel, exact: true }), control, loadingControl: page.getByRole('status', { name: 'Loading filter' }), + valueGroup: (propertyLabel: string) => control(propertyLabel).getByRole('group', { name: propertyLabel, exact: true }), + controlLabel: (propertyLabel: string) => control(propertyLabel).getByText(propertyLabel, { exact: true }), quickSelect: (propertyLabel: string, name: string) => control(propertyLabel).getByRole('button', { name, exact: true }), addFilter: (propertyLabel: string) => control(propertyLabel).getByRole('button', { name: 'Add Filter', exact: true }), removeFilter: (propertyLabel: string) => page.getByRole('button', { name: new RegExp(`^Remove filter: ${propertyLabel}`) }), diff --git a/frontend/tests/features/filter-demo/filter-demo.spec.ts b/frontend/tests/features/filter-demo/filter-demo.spec.ts index 434654cd..5e17d185 100644 --- a/frontend/tests/features/filter-demo/filter-demo.spec.ts +++ b/frontend/tests/features/filter-demo/filter-demo.spec.ts @@ -1,4 +1,5 @@ import { test, expect } from '@tests/support/fixtures' +import { takesUpSpace } from '@tests/support/screen' import { filterDemoPage } from './filter-demo.tasks' const phone = { width: 390, height: 844 } @@ -19,6 +20,16 @@ test.describe('the Add Filter menu on a phone', () => { }) } + test('names the Is Blocked control only in the title of the sheet', async ({ page }) => { + const demo = filterDemoPage(page) + await demo.open() + + await demo.chooseProperty('Is Blocked') + + expect(await takesUpSpace(demo.at.controlLabel('Is Blocked'))).toBe(false) + await expect(demo.at.valueGroup('Is Blocked')).toBeVisible() + }) + test('adds a date filter from its control', async ({ page }) => { const demo = filterDemoPage(page) await demo.open() @@ -54,6 +65,16 @@ for (const screen of [ test.describe('the Add Filter menu on a desktop', () => { test.use({ viewport: { width: 1280, height: 800 } }) + test('names the Is Blocked control in the menu', async ({ page }) => { + const demo = filterDemoPage(page) + await demo.open() + + await demo.chooseProperty('Is Blocked') + + expect(await takesUpSpace(demo.at.controlLabel('Is Blocked'))).toBe(true) + await expect(demo.at.valueGroup('Is Blocked')).toBeVisible() + }) + test('shows the Created Date control beside the open menu', async ({ page }) => { const demo = filterDemoPage(page) await demo.open() diff --git a/frontend/tests/features/toasts/toasts.locators.ts b/frontend/tests/features/toasts/toasts.locators.ts new file mode 100644 index 00000000..32c880d3 --- /dev/null +++ b/frontend/tests/features/toasts/toasts.locators.ts @@ -0,0 +1,13 @@ +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 }), + 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 new file mode 100644 index 00000000..288ec0a2 --- /dev/null +++ b/frontend/tests/features/toasts/toasts.spec.ts @@ -0,0 +1,43 @@ +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) + }) +}) + +// 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 new file mode 100644 index 00000000..35033d1b --- /dev/null +++ b/frontend/tests/features/toasts/toasts.tasks.ts @@ -0,0 +1,52 @@ +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) { + 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 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) + 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) + }) + }, + } +} 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() }, 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 +}