Skip to content
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -7,6 +8,7 @@ type BooleanFilterProps = Pick<FilterControlProps, 'config' | 'onSubmit'>

export function BooleanFilter({ config, onSubmit }: BooleanFilterProps) {
const { propertyKey, propertyLabel } = config
const labelId = useId()
const handleSubmit = (selectedValue: boolean) => {
onSubmit({
propertyKey,
Expand All @@ -18,8 +20,8 @@ export function BooleanFilter({ config, onSubmit }: BooleanFilterProps) {
}

return (
<div className="flex flex-col gap-2 w-full md:w-48 p-3">
<div className="text-sm font-medium mb-2">{propertyLabel}</div>
<div role="group" aria-labelledby={labelId} className="flex flex-col gap-2 w-full md:w-48 p-3">
<div id={labelId} className="text-sm font-medium mb-2 in-data-[slot=responsive-dialog]:sr-only">{propertyLabel}</div>
<div className="flex gap-2">
<Button
variant="outline"
Expand Down
16 changes: 16 additions & 0 deletions frontend/src/components/not-found.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Link } from 'react-aria-components'

import { buttonVariants } from '@/components/ui/button-variants'

export function NotFound() {
return (
<div className="mx-auto flex max-w-md flex-col items-center px-4 py-16 text-center">
<p className="text-sm font-medium text-muted-foreground">404</p>
<h1 className="mt-2 text-3xl font-bold tracking-tight">Page not found</h1>
<p className="mt-2 text-muted-foreground">No page exists at this address.</p>
<Link href="/" className={buttonVariants({ className: 'mt-6' })}>
Go to the home page
</Link>
</div>
)
}
10 changes: 6 additions & 4 deletions frontend/src/components/toaster.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<ToastRegion
queue={toastQueue}
data-slot="toast-region"
className="pointer-events-none fixed inset-x-4 bottom-4 z-[100] flex flex-col-reverse items-center gap-2 outline-none md:right-6 md:bottom-6 md:left-auto"
className="pointer-events-none fixed inset-x-4 bottom-4 z-[100] mx-auto flex max-w-[500px] flex-col-reverse items-center gap-2 rounded-lg outline-none focus-visible:ring-3 focus-visible:ring-ring/50 md:right-6 md:bottom-6 md:left-auto"
>
{({ toast }) => (
<Toast
toast={toast}
data-slot="toast"
style={{ viewTransitionName: toast.key, viewTransitionClass: 'toast' } as CSSProperties}
className={cn(
'bg-card text-card-foreground border border-border',
'shadow-lg rounded-lg px-4 py-3',
'pointer-events-auto flex w-full max-w-[500px] items-start gap-3 md:min-w-[300px]',
'outline-none focus-visible:ring-2 focus-visible:ring-ring'
'outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50'
)}
>
<ToastContent className="flex items-start gap-3 flex-1 min-w-0">
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/ui/skeleton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("animate-pulse rounded-md bg-muted", className)}
className={cn("motion-safe:animate-pulse rounded-md bg-muted", className)}
{...props}
/>
)
Expand Down
5 changes: 5 additions & 0 deletions frontend/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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' {
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/routes/__root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<RouterProvider
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/routes/about.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export const Route = createFileRoute('/about')({

function About() {
return (
<div className="min-h-screen bg-background">
<div className="bg-background">
<main className="max-w-2xl mx-auto px-4 py-8">
<Card>
<CardHeader>
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/routes/components.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ function ComponentsPage() {

return (
<>
<div className="w-full min-h-screen bg-linear-to-br from-bg-gradient-start to-bg-gradient-end text-text-primary">
<div className="w-full">
<div className="max-w-[1200px] mx-auto px-8 py-10 max-md:px-4">
{/* Header */}
<div className="mb-10">
Expand Down Expand Up @@ -205,7 +205,7 @@ function ComponentsPage() {
<Button variant="link">Link</Button>
</div>
<Separator className="my-4" />
<div className="flex flex-wrap gap-4">
<div className="flex flex-wrap items-center gap-4">
<Button size="sm">Small</Button>
<Button size="default">Default</Button>
<Button size="lg">Large</Button>
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/routes/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ function Index() {
const handleLogout = () => auth.logoutUrl && logout(auth.logoutUrl)

return (
<div className="min-h-screen bg-background">
<div className="bg-background">
<main className="max-w-4xl mx-auto px-4 py-8">
{/* Header */}
<div className="flex items-center justify-between mb-8">
Expand Down Expand Up @@ -176,7 +176,7 @@ function Index() {

{/* Loading State */}
{isLoading && displayData.length === 0 && (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
<div role="status" aria-label="Loading forecast" className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{[...Array(6)].map((_, i) => (
<Card key={i}>
<CardContent>
Expand Down
9 changes: 9 additions & 0 deletions frontend/tests/features/app-shell/app-shell.locators.ts
Original file line number Diff line number Diff line change
@@ -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' }),
})
57 changes: 57 additions & 0 deletions frontend/tests/features/app-shell/app-shell.spec.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
38 changes: 38 additions & 0 deletions frontend/tests/features/app-shell/app-shell.tasks.ts
Original file line number Diff line number Diff line change
@@ -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)
},
}
}
2 changes: 2 additions & 0 deletions frontend/tests/features/filter-demo/filter-demo.locators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`) }),
Expand Down
21 changes: 21 additions & 0 deletions frontend/tests/features/filter-demo/filter-demo.spec.ts
Original file line number Diff line number Diff line change
@@ -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 }
Expand All @@ -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()
Expand Down Expand Up @@ -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()
Expand Down
13 changes: 13 additions & 0 deletions frontend/tests/features/toasts/toasts.locators.ts
Original file line number Diff line number Diff line change
@@ -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 }),
})
43 changes: 43 additions & 0 deletions frontend/tests/features/toasts/toasts.spec.ts
Original file line number Diff line number Diff line change
@@ -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'])
})
})
Loading
Loading