diff --git a/DESIGN.md b/DESIGN.md index 9824488..fb237bd 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -123,6 +123,24 @@ then review the diffs in `e2e/design-system-visual.spec.ts-snapshots/` in your P When you add a new component to `packages/ui`, add it to the gallery in the same PR — see [`packages/ui/CONTRIBUTING.md`](./packages/ui/CONTRIBUTING.md) for the full checklist. +## Separators and dividers + +Borders are the cheapest way to make a layout look organised and the fastest way to make it look noisy. Linear's calm hierarchy comes from *space* and *surface* doing the grouping, with lines reserved for the few places where a relationship genuinely needs marking. [`packages/ui/src/components/separator.tsx`](./packages/ui/src/components/separator.tsx) (DS-080) exists so those few places look the same everywhere. + +**Reach for these first, in order:** + +1. **Spacing** — a bigger gap between two groups than within them. This is the default answer for stacked form fields, stat rows, and list items. +2. **Surface contrast** — `bg-surface-raised` / `bg-card` against `bg-surface-canvas`. A card edge already says "this is one thing", so an internal separator repeating that boundary is redundant. +3. **A separator** — only when two adjacent blocks are different *kinds* of thing and spacing alone leaves that ambiguous: a panel and its action footer, a menu's items and its destructive item, a table and its summary row. + +**Tones.** `subtle` inside an already-grouped surface (card, popover, form), `default` between page or list sections, `strong` for the rare structural cut between regions belonging to different tasks. Anything heavier than `strong` is a sign the layout wants a surface change instead of a line. + +**Semantics.** Pass `decorative` whenever the line is purely visual — which is most of the time. Headings, lists, and landmarks usually already carry the structure, and every announced separator is one more thing a screen-reader user listens past. Leave it semantic only when the line is the *only* thing expressing the grouping. + +**Labelled dividers.** `` names a break instead of just drawing one — grouped form sections, "or" between auth methods, date breaks in a feed. The rules are decorative and the label is ordinary text, so assistive technology reads the words rather than the geometry. Label contrast comes from `text-text-secondary`, which holds up in light, dark, and high-contrast themes. + +Existing page borders were left alone in DS-080; migrate them opportunistically when you're already touching the markup. + ## Audit history - **DS-050** (this pass): found and fixed 268 arbitrary-value violations (229 arbitrary font sizes, 38 raw hex colors, 1 arbitrary radius) across ~40 files. Of these: diff --git a/apps/web/src/features/gallery/components/gallery-page.tsx b/apps/web/src/features/gallery/components/gallery-page.tsx index 64e4c05..200367d 100644 --- a/apps/web/src/features/gallery/components/gallery-page.tsx +++ b/apps/web/src/features/gallery/components/gallery-page.tsx @@ -6,14 +6,17 @@ import { Button } from "@workspace/ui/components/button" import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@workspace/ui/components/card" import { Input } from "@workspace/ui/components/input" import { KeyboardShortcut } from "@workspace/ui/components/keyboard-shortcut" +import { LiveRegion } from "@workspace/ui/components/live-region" import { PageHeader } from "@workspace/ui/components/page-header" import { ProgressIndicator } from "@workspace/ui/components/progress-indicator" -import { Separator } from "@workspace/ui/components/separator" +import { ScrollArea } from "@workspace/ui/components/scroll-area" +import { Divider, Separator } from "@workspace/ui/components/separator" import { Skeleton } from "@workspace/ui/components/skeleton" import { Slider } from "@workspace/ui/components/slider" import { Spinner } from "@workspace/ui/components/spinner" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@workspace/ui/components/tabs" import { Tooltip, TooltipContent, TooltipTrigger } from "@workspace/ui/components/tooltip" +import { VisuallyHidden } from "@workspace/ui/components/visually-hidden" const BUTTON_VARIANTS = ["default", "outline", "secondary", "ghost", "destructive", "link"] as const const BUTTON_SIZES = ["xs", "sm", "default", "lg"] as const @@ -21,6 +24,7 @@ const BADGE_VARIANTS = ["default", "secondary", "destructive", "outline", "ghost const AVATAR_SIZES = ["xs", "sm", "md", "lg", "xl", "2xl"] as const const PROGRESS_SIZES = ["sm", "md", "lg"] as const const PROGRESS_TONES = ["neutral", "accent", "success", "danger"] as const +const SEPARATOR_TONES = ["subtle", "default", "strong"] as const function Section({ title, children }: { title: string; children: React.ReactNode }) { return ( @@ -45,6 +49,7 @@ function Section({ title, children }: { title: string; children: React.ReactNode */ export function GalleryPage() { const [sliderValue, setSliderValue] = useState>([40]) + const [announceCount, setAnnounceCount] = useState(0) return (
@@ -139,6 +144,106 @@ export function GalleryPage() {

Above the separator

Below the separator

+ +
+ {SEPARATOR_TONES.map((tone) => ( +
+

{tone}

+ +
+ ))} +
+ +
+ Vertical + + in a flex row + + without a fixed height +
+ + +
+
+ + + +
+
+ +
+
+
+

+ Vertical — edge shadows follow scroll position +

+ +
+ {Array.from({ length: 20 }, (_, i) => ( +

+ Row {i + 1} +

+ ))} +
+
+
+ +
+

Horizontal

+ +
+ {Array.from({ length: 16 }, (_, i) => ( + + Market {i + 1} + + ))} +
+
+
+
+
+ +
+
+

+ VisuallyHidden and LiveRegion render nothing visible by default — the + button below carries a hidden label, and the counter announces itself + politely. See packages/ui/ACCESSIBILITY_PRIMITIVES.md. +

+
+ + +
+
+ + Focusable hidden content (tab to reveal): + + + + +
+
@@ -159,6 +264,10 @@ export function GalleryPage() { landmark + // and the skip-link target, and neither may be duplicated. + landmark={false} + skipLink={false} navbar={
Navbar slot @@ -182,6 +291,8 @@ export function GalleryPage() {
Navbar slot diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index e1b7bde..5903761 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -1,7 +1,14 @@ -import { HeadContent, Scripts, createRootRoute, Link } from "@tanstack/react-router" +import { + HeadContent, + Link, + Outlet, + Scripts, + createRootRoute, +} from "@tanstack/react-router" import { Toaster } from "sonner" import appCss from "@workspace/ui/globals.css?url" import { AppProviders } from "../app/providers" +import { RouteAnnouncer } from "../shared/components/RouteAnnouncer" import { useTheme } from "../ui/theme-provider" // Update this to your production domain before going live. @@ -134,9 +141,21 @@ export const Route = createRootRoute({
), + component: RootComponent, shellComponent: RootDocument, }) +// Sits above every route so the post-navigation focus/announcement handoff +// (DS-078) is installed exactly once, inside router context. +function RootComponent() { + return ( + <> + + + + ) +} + // Minified blocking script — runs synchronously before first paint. // Reads localStorage and sets dark/light class on so CSS variables // resolve correctly before React hydrates. Prevents the flash of wrong theme. diff --git a/apps/web/src/routes/routes.test.tsx b/apps/web/src/routes/routes.test.tsx index 1536a3a..5d7c271 100644 --- a/apps/web/src/routes/routes.test.tsx +++ b/apps/web/src/routes/routes.test.tsx @@ -50,6 +50,10 @@ vi.mock("@tanstack/react-router", () => { useLoaderData: () => ({}), useParams: () => ({}), }), + // __root now renders the route announcer above (DS-078) + Outlet: () => null, + useLocation: (opts?: { select?: (location: { pathname: string }) => unknown }) => + opts?.select ? opts.select({ pathname: "/" }) : { pathname: "/" }, } }) diff --git a/apps/web/src/shared/components/RouteAnnouncer.test.tsx b/apps/web/src/shared/components/RouteAnnouncer.test.tsx new file mode 100644 index 0000000..940c433 --- /dev/null +++ b/apps/web/src/shared/components/RouteAnnouncer.test.tsx @@ -0,0 +1,145 @@ +import { describe, expect, it } from "vitest" +import { act, render, screen, waitFor } from "@testing-library/react" +import { + Outlet, + RouterProvider, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, +} from "@tanstack/react-router" +import { AppShell } from "@workspace/ui/components/app-shell" + +import { RouteAnnouncer } from "./RouteAnnouncer" + +function Page({ title }: { title: string }) { + return ( + }> +

{title}

+ +
+ ) +} + +function renderApp() { + const rootRoute = createRootRoute({ + component: () => ( + <> + + + + ), + }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "/", + component: () => , + }) + const poolsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "/pools", + validateSearch: (search: Record) => ({ + tab: typeof search.tab === "string" ? search.tab : undefined, + }), + component: () => , + }) + + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, poolsRoute]), + history: createMemoryHistory({ initialEntries: ["/"] }), + }) + + const view = render() + return { router, view } +} + +/** Live-region text with the re-announcement marker stripped. */ +function announcement() { + return document + .querySelector("[data-slot='live-region']") + ?.textContent?.replace(/\u200B/g, "") +} + +describe("RouteAnnouncer", () => { + it("mounts an empty polite live region", async () => { + renderApp() + await waitFor(() => expect(screen.getByRole("heading", { level: 1 })).toBeInTheDocument()) + + const region = document.querySelector("[data-slot='live-region']") + expect(region).toHaveAttribute("aria-live", "polite") + expect(announcement()).toBe("") + }) + + it("does not steal focus on the initial page load", async () => { + renderApp() + await waitFor(() => expect(screen.getByRole("heading", { level: 1 })).toBeInTheDocument()) + + expect(document.body).toHaveFocus() + }) + + it("announces the new page title after navigation", async () => { + const { router } = renderApp() + await waitFor(() => expect(screen.getByText("Home")).toBeInTheDocument()) + + await act(async () => { + await router.navigate({ to: "/pools" }) + }) + + await waitFor(() => expect(announcement()).toBe("Pools")) + }) + + it("moves focus to the new page heading after navigation", async () => { + const { router } = renderApp() + await waitFor(() => expect(screen.getByText("Home")).toBeInTheDocument()) + + await act(async () => { + await router.navigate({ to: "/pools" }) + }) + + await waitFor(() => + expect(screen.getByRole("heading", { level: 1, name: "Pools" })).toHaveFocus() + ) + expect(screen.getByRole("heading", { level: 1 })).toHaveAttribute( + "tabindex", + "-1" + ) + }) + + it("leaves focus alone on query-only changes", async () => { + const { router } = renderApp() + await act(async () => { + await router.navigate({ to: "/pools" }) + }) + await waitFor(() => expect(announcement()).toBe("Pools")) + + const filter = screen.getByRole("button", { name: "Filter" }) + act(() => filter.focus()) + + await act(async () => { + await router.navigate({ to: "/pools", search: { tab: "orders" } }) + }) + + // Same pathname: the tab/filter state changed, not the page. + expect(filter).toHaveFocus() + expect(announcement()).toBe("Pools") + }) + + it("announces every pathname change", async () => { + const { router } = renderApp() + + await act(async () => { + await router.navigate({ to: "/pools" }) + }) + await waitFor(() => expect(announcement()).toBe("Pools")) + + await act(async () => { + await router.navigate({ to: "/" }) + }) + await waitFor(() => expect(announcement()).toBe("Home")) + + await act(async () => { + await router.navigate({ to: "/pools" }) + }) + await waitFor(() => expect(announcement()).toBe("Pools")) + }) +}) diff --git a/apps/web/src/shared/components/RouteAnnouncer.tsx b/apps/web/src/shared/components/RouteAnnouncer.tsx new file mode 100644 index 0000000..e955ac4 --- /dev/null +++ b/apps/web/src/shared/components/RouteAnnouncer.tsx @@ -0,0 +1,97 @@ +"use client" + +import * as React from "react" +import { useLocation } from "@tanstack/react-router" +import { LiveRegion, useAnnouncer } from "@workspace/ui/components/live-region" +import { MAIN_CONTENT_ID } from "@workspace/ui/components/skip-link" + +// --------------------------------------------------------------------------- +// RouteAnnouncer (DS-078) — focus + announcement handoff after navigation +// --------------------------------------------------------------------------- + +export interface RouteAnnouncerProps { + /** + * Id of the shell's main region. + * @default "main-content" + */ + targetId?: string +} + +/** + * Client-side navigation replaces the page without the browser's own "new + * document" handling: focus stays on whatever link was clicked, and nothing + * tells a screen reader that the page changed. This restores both. + * + * Mounted once, at the root route. On every *pathname* change it moves focus to + * the new page's heading (falling back to the main region) and announces the + * page name in a polite live region. + * + * Search-param and hash changes are deliberately ignored — filters, tab state, + * and sort order all live in the query string, and yanking focus out of a + * control the user is still operating would be far worse than saying nothing. + * `useLocation`'s selector means those updates do not even re-render this + * component. + */ +export function RouteAnnouncer({ + targetId = MAIN_CONTENT_ID, +}: RouteAnnouncerProps) { + const pathname = useLocation({ select: (location) => location.pathname }) + const { message, announcementKey, announce } = useAnnouncer() + const isInitialRender = React.useRef(true) + + React.useEffect(() => { + // The first paint is a real document load — the browser already announced + // the title, and stealing focus on arrival is hostile. + if (isInitialRender.current) { + isInitialRender.current = false + return + } + + // The heading and for the new route commit after this effect, so + // read them a frame later or the announcement describes the old page. + return afterNextFrame(() => { + const label = focusMainRegion(targetId) + announce(label) + }) + }, [pathname, targetId, announce]) + + return <LiveRegion message={message} announcementKey={announcementKey} /> +} + +/** + * Moves focus into the new page's content and returns the label that describes + * it. Prefers the page heading so a screen reader starts reading where a + * sighted user starts looking. + */ +function focusMainRegion(targetId: string): string { + const main = document.getElementById(targetId) + const heading = main?.querySelector<HTMLElement>("h1") ?? null + const target = heading ?? main + + if (target) { + // Focusable on demand only — the region must not become a tab stop. + if (!target.hasAttribute("tabindex")) { + target.setAttribute("tabindex", "-1") + } + // preventScroll: the router owns scroll restoration, and a pointer user + // clicking a nav link should not be jerked around. Programmatic focus also + // does not match :focus-visible, so no ring is drawn. + target.focus({ preventScroll: true }) + } + + return ( + heading?.textContent?.trim() || + document.title.trim() || + "New page" + ) +} + +/** rAF where it exists (browsers, jsdom in visual mode), a task otherwise. */ +function afterNextFrame(run: () => void): () => void { + if (typeof requestAnimationFrame === "function") { + const frame = requestAnimationFrame(run) + return () => cancelAnimationFrame(frame) + } + const timeout = setTimeout(run, 0) + return () => clearTimeout(timeout) +} diff --git a/e2e/skip-link.spec.ts b/e2e/skip-link.spec.ts new file mode 100644 index 0000000..71c9b00 --- /dev/null +++ b/e2e/skip-link.spec.ts @@ -0,0 +1,83 @@ +import { expect, test } from "@playwright/test" + +// Skip link + post-navigation focus (DS-078). +// +// Prerequisites for local runs outside CI: +// npx playwright install --with-deps chromium + +const MAIN = "#main-content" + +test("skip link is the first tab stop and becomes visible on focus", async ({ page }) => { + await page.goto("/pools") + + const skipLink = page.getByRole("link", { name: "Skip to main content" }) + await expect(skipLink).toBeAttached() + + // `sr-only` clips the link to 1x1px until it takes focus, so the box size is + // what actually distinguishes "hidden" from "revealed". + const clipped = await skipLink.boundingBox() + expect(clipped?.width ?? 0).toBeLessThanOrEqual(2) + + await page.keyboard.press("Tab") + await expect(skipLink).toBeFocused() + + const revealed = await skipLink.boundingBox() + expect(revealed?.width ?? 0).toBeGreaterThan(50) + await expect(skipLink).toBeInViewport() +}) + +test("activating the skip link focuses the main region without touching the URL", async ({ + page, +}) => { + await page.goto("/pools") + const url = page.url() + + await page.keyboard.press("Tab") + await page.keyboard.press("Enter") + + await expect(page.locator(MAIN)).toBeFocused() + expect(page.url()).toBe(url) + + // The next tab stop is inside the page content, not back in the navbar. + await page.keyboard.press("Tab") + const insideMain = await page.evaluate(() => { + const active = document.activeElement + const main = document.querySelector("#main-content") + return Boolean(active && main && main.contains(active)) + }) + expect(insideMain).toBe(true) +}) + +test("route navigation moves focus to the new page heading and announces it", async ({ + page, +}) => { + await page.goto("/pools") + await expect(page.getByRole("heading", { level: 1, name: /pools/i })).toBeVisible() + + await page.getByRole("link", { name: "Earn", exact: true }).first().click() + + const heading = page.getByRole("heading", { level: 1, name: /earn/i }) + await expect(heading).toBeFocused() + + // Announced once, in the single polite region the announcer owns. + const region = page.locator("[data-slot='live-region']") + await expect(region).toHaveCount(1) + await expect(region).toHaveAttribute("aria-live", "polite") + await expect(region).toHaveText(/earn/i) +}) + +test("pointer navigation does not draw a focus ring on the new page", async ({ + page, +}) => { + await page.goto("/pools") + + await page.getByRole("link", { name: "Referrals", exact: true }).first().click() + await expect(page.getByRole("heading", { level: 1, name: /referrals/i })).toBeFocused() + + // Focus is handed over programmatically, which never matches :focus-visible — + // so a mouse user sees no outline even though the heading holds focus. + const focusVisible = await page.evaluate( + () => document.activeElement?.matches(":focus-visible") ?? false + ) + expect(focusVisible).toBe(false) +}) diff --git a/packages/ui/ACCESSIBILITY_PRIMITIVES.md b/packages/ui/ACCESSIBILITY_PRIMITIVES.md new file mode 100644 index 0000000..98b26b7 --- /dev/null +++ b/packages/ui/ACCESSIBILITY_PRIMITIVES.md @@ -0,0 +1,128 @@ +# Accessibility primitives + +The shared building blocks for screen-reader-only content, status announcements, +and keyboard bypass. Added in DS-077 (`VisuallyHidden`, `LiveRegion`) and +DS-078 (`SkipLink`, post-navigation focus). + +Before this pass, `sr-only` markup and `aria-live` regions were hand-rolled at +each call site — a dozen slightly different spellings, a few of them silent in +practice. These primitives are the one spelling. + +--- + +## `VisuallyHidden` + +`packages/ui/src/components/visually-hidden.tsx` + +Content for assistive technology only. Clipped by `sr-only`, which is +absolutely positioned, so it can never shift the layout around it. + +```tsx +<button> + <IconTrash aria-hidden="true" /> + <VisuallyHidden>Delete position</VisuallyHidden> +</button> + +{/* Semantic elements survive via `render`, so the outline stays intact */} +<VisuallyHidden render={<h2 />}>Open positions</VisuallyHidden> + +{/* Hidden interactive content must be visible once focused */} +<VisuallyHidden focusable> + <a href="#orders">Jump to orders</a> +</VisuallyHidden> +``` + +### Use it for + +- The verb behind an icon-only control, when `aria-label` would lose markup. +- Table column meaning a sighted user reads from position. +- Units and qualifiers a sighted user reads from a nearby heading. +- A heading that structures a region for screen readers where visible design + already makes the grouping obvious. + +### Do **not** use it for + +- **Anything the user must act on**: form labels, validation errors, prices, + balances, confirmation copy. Hiding those helps screen readers and hurts + low-vision, cognitive-load, and translation users. +- **Replacing a visible label** on a control that would otherwise be + ambiguous on screen. A hidden label supplements visible context; it does not + substitute for it. +- **Pointer-reachable content.** Hidden content is 1×1px — only keyboard focus + can reach it, which is what `focusable` is for. + +--- + +## `LiveRegion` + +`packages/ui/src/components/live-region.tsx` + +Status text that reaches screen readers without stealing focus. + +```tsx +<LiveRegion message={`${rows.length} positions`} /> // polite → role=status +<LiveRegion mode="assertive" message={submitError} /> // assertive → role=alert +<LiveRegion mode="off" message={draft} /> // mounted, silent +<LiveRegion visible message="Saving…" /> // also rendered on screen +``` + +| Prop | Effect | +| --- | --- | +| `mode` | `polite` (default) waits its turn, `assertive` interrupts, `off` stays silent | +| `atomic` | `true` (default) announces the whole region; `false` for append-only logs | +| `relevant` | maps to `aria-relevant` | +| `visible` | also render the message on screen | +| `announcementKey` | change it to re-announce identical text | + +### Two rules that decide whether it works at all + +1. **Mount the region before the message exists.** An empty region that later + fills in is announced; a region inserted together with its text is + frequently missed, because the assistive technology never observed a change. +2. **Identical text is silent.** "3 results" after "3 results" is not a change. + To repeat it deliberately, bump `announcementKey` — or use `useAnnouncer`, + which keeps the counter for you: + +```tsx +const { message, announcementKey, announce } = useAnnouncer() + +<LiveRegion message={message} announcementKey={announcementKey} /> +<button onClick={() => announce("Order submitted")}>Submit</button> +``` + +Under the hood a zero-width space is toggled onto the end of the string, which +changes the text node without changing what the user hears. + +Reserve `assertive` for messages whose loss costs money or data — order +rejected, transaction failed, session expired. Everything else is `polite`. + +This is not a notification queue: each region owns one message. Toasts remain +the job of the toast layer. + +--- + +## `SkipLink` and post-navigation focus + +`packages/ui/src/components/skip-link.tsx`, +`apps/web/src/shared/components/RouteAnnouncer.tsx` + +`AppShell` renders the skip link as its first focusable element and marks its +content area as `<main id="main-content" tabindex="-1">`. Both are on by +default; pass `skipLink={false}` / `landmark={false}` only for a shell nested +inside another one (gallery previews), where a second landmark or a duplicate +id would be invalid. + +- The link is clipped until focused, then pins itself above the sticky navbar. +- Activating it moves focus into the main region **without** navigating to + `#main-content`, so the hash never lands in the history entry. +- `RouteAnnouncer`, mounted once at the root route, moves focus to the new + page's `<h1>` after each **pathname** change and announces its title in a + polite region. +- Search-param and hash changes are ignored on purpose: filters, tabs, and sort + order live in the query string, and pulling focus out of a control the user is + still operating is worse than saying nothing. +- Focus is handed over with `preventScroll` and lands on a `tabindex="-1"` + target, so a pointer user gets neither a jump nor a focus ring + (programmatic focus does not match `:focus-visible`). + +Focus *inside* dialogs, menus, and other overlays stays with those components. diff --git a/packages/ui/FORCED_COLORS_CHECKLIST.md b/packages/ui/FORCED_COLORS_CHECKLIST.md index 491b6d2..00b7d70 100644 --- a/packages/ui/FORCED_COLORS_CHECKLIST.md +++ b/packages/ui/FORCED_COLORS_CHECKLIST.md @@ -200,10 +200,30 @@ This checklist helps verify that SO4's interface remains operable when Windows H - [ ] Horizontal rules (`<hr>`) are visible - [ ] Section dividers are visible - [ ] Card borders are visible +- [ ] `Separator` subtle/default/strong tones are all still visible (all three + collapse to `CanvasText` — that is expected; the *presence* of the line is + what matters, not the tone) +- [ ] `Divider` labels remain readable against `Canvas` **Test locations:** - Between major sections - Within complex forms + +### ✅ Scroll Areas (DS-079) + +The gradient edge affordances are dropped in forced-colors mode (background +images are removed), so the scrollbar itself has to carry the "there is more +content" signal. + +- [ ] Scrollbars are visible on every `ScrollArea` that overflows +- [ ] Scrollbar thumb (`CanvasText`) is distinguishable from its track (`Canvas`) +- [ ] Scroll position is still discoverable without the edge shadows +- [ ] Keyboard scrolling (arrows, Page Up/Down) still works on focusable areas + +**Test locations:** +- Command menu (`Cmd/Ctrl+K`) — long, filtered result list +- `/trade` — bottom tabs and trade panel +- Any horizontally overflowing tab row - Card components ## Additional Checks diff --git a/packages/ui/README.md b/packages/ui/README.md index b70ab50..91d6186 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -16,7 +16,10 @@ All components are located in `src/components/` and exported through the package - **Tooltip** - Contextual information on hover - **Sheet** - Side panel component - **Skeleton** - Content placeholder loaders -- **Separator** - Divider component +- **Separator / Divider** - Rules in `subtle`/`default`/`strong` tones, semantic or decorative, plus a labelled divider (see [DESIGN.md](../../DESIGN.md#separators-and-dividers) for when *not* to use one) +- **ScrollArea** - Native overflow container with scroll-position edge shadows and themed scrollbars +- **VisuallyHidden / LiveRegion** - Screen-reader-only content and polite/assertive announcements ([ACCESSIBILITY_PRIMITIVES.md](./ACCESSIBILITY_PRIMITIVES.md)) +- **SkipLink** - Keyboard bypass for global chrome; wired into `AppShell`'s `<main>` target - **TokenAvatar** - Token icon with fallback initials - **TokenPair** - Overlapping token pair visual - **TransactionStatus** - Transaction state indicator with actions diff --git a/packages/ui/src/components/app-shell.test.tsx b/packages/ui/src/components/app-shell.test.tsx index a02f2f6..50131f2 100644 --- a/packages/ui/src/components/app-shell.test.tsx +++ b/packages/ui/src/components/app-shell.test.tsx @@ -1,5 +1,6 @@ import { describe, it, expect } from "vitest" import { render, screen } from "@testing-library/react" +import userEvent from "@testing-library/user-event" import { axe } from "vitest-axe" import { AppShell } from "./app-shell" @@ -53,10 +54,56 @@ describe("AppShell", () => { expect(content).toBeTruthy() }) + it("renders the content area as the main landmark", () => { + render( + <AppShell navbar={<nav />}> + <h1>Pools</h1> + </AppShell> + ) + const main = screen.getByRole("main") + expect(main).toHaveAttribute("id", "main-content") + // Programmatically focusable for the skip link and post-navigation focus, + // without becoming a tab stop. + expect(main).toHaveAttribute("tabindex", "-1") + }) + + it("accepts a custom main id", () => { + render( + <AppShell navbar={<nav />} mainId="page-content"> + Content + </AppShell> + ) + expect(screen.getByRole("main")).toHaveAttribute("id", "page-content") + }) + + it("renders the skip link ahead of the navbar", async () => { + const user = userEvent.setup() + render( + <AppShell navbar={<nav><a href="/pools">Pools</a></nav>}> + <h1>Pools</h1> + </AppShell> + ) + + await user.tab() + expect( + screen.getByRole("link", { name: "Skip to main content" }) + ).toHaveFocus() + }) + + it("skips the skip link and landmark when nested", () => { + render( + <AppShell navbar={<nav />} skipLink={false} landmark={false}> + Content + </AppShell> + ) + expect(screen.queryByRole("link", { name: /skip to main content/i })).toBeNull() + expect(screen.queryByRole("main")).toBeNull() + }) + it("has no accessibility violations", async () => { const { container } = render( <AppShell navbar={<nav aria-label="Main" />}> - <main>Content</main> + <h1>Content</h1> </AppShell> ) const results = await axe(container) diff --git a/packages/ui/src/components/app-shell.tsx b/packages/ui/src/components/app-shell.tsx index 008f120..5024ffb 100644 --- a/packages/ui/src/components/app-shell.tsx +++ b/packages/ui/src/components/app-shell.tsx @@ -1,6 +1,7 @@ import * as React from "react" import { cva } from "class-variance-authority" +import { MAIN_CONTENT_ID, SkipLink } from "@workspace/ui/components/skip-link" import { cn } from "@workspace/ui/lib/utils" import type { VariantProps } from "class-variance-authority" @@ -60,6 +61,25 @@ export interface AppShellProps * @default "320" */ maxWidth?: MaxWidthKey + /** + * Render the skip link as the shell's first focusable element (DS-078). + * Turn it off only when the shell is nested inside another one — a demo or a + * gallery preview — where a second skip link would duplicate the target id. + * @default true + */ + skipLink?: boolean + /** + * Render the content area as the page's `<main>` landmark. Turn it off when + * the page already owns its own `<main>`, since two are invalid. + * @default true + */ + landmark?: boolean + /** + * Id of the content area — the stable target for the skip link and for + * post-navigation focus. + * @default "main-content" + */ + mainId?: string } function AppShell({ @@ -67,10 +87,15 @@ function AppShell({ maxWidth = "320", navbar, banner, + skipLink = true, + landmark = true, + mainId = MAIN_CONTENT_ID, className, children, ...props }: AppShellProps) { + const Content = landmark ? "main" : "div" + return ( <div data-slot="app-shell" @@ -78,18 +103,26 @@ function AppShell({ className={cn(shellVariants({ variant }), className)} {...props} > + {skipLink && <SkipLink targetId={mainId} />} {navbar} {banner} - <div + <Content + // `tabIndex={-1}` makes the region programmatically focusable — for the + // skip link and for focus handoff after client-side navigation — + // without adding a tab stop. `outline-none` keeps that programmatic + // focus from drawing a ring around the whole page. + id={landmark ? mainId : undefined} + tabIndex={landmark ? -1 : undefined} data-slot="app-shell-content" className={cn( contentVariants({ variant }), + landmark && "outline-none", variant === "constrained" && MAX_WIDTH_CLASS[maxWidth], variant === "constrained" && "lg:px-8" )} > {children} - </div> + </Content> </div> ) } diff --git a/packages/ui/src/components/command-menu.tsx b/packages/ui/src/components/command-menu.tsx index 3c937e3..222128a 100644 --- a/packages/ui/src/components/command-menu.tsx +++ b/packages/ui/src/components/command-menu.tsx @@ -146,7 +146,7 @@ export function CommandMenu({ /> </div> - <ScrollArea className="max-h-80"> + <ScrollArea className="max-h-80" tone="overlay"> <ComboboxList className="space-y-3 p-2"> {filteredGroups.map((group) => { const groupItems = group.items.map((item) => { diff --git a/packages/ui/src/components/live-region.test.tsx b/packages/ui/src/components/live-region.test.tsx new file mode 100644 index 0000000..527a635 --- /dev/null +++ b/packages/ui/src/components/live-region.test.tsx @@ -0,0 +1,166 @@ +import { describe, expect, it } from "vitest" +import { render, screen } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import { axe } from "vitest-axe" + +import { LiveRegion, ZERO_WIDTH_SPACE, useAnnouncer } from "./live-region" + +function getRegion(container: HTMLElement) { + const region = container.querySelector("[data-slot='live-region']") + if (!region) throw new Error("live region not rendered") + return region +} + +/** Drops the re-announcement marker so assertions can compare the words. */ +function strip(text: string) { + return text.split(ZERO_WIDTH_SPACE).join("") +} + +describe("LiveRegion", () => { + it("announces politely by default", () => { + const { container } = render(<LiveRegion message="3 positions" />) + const region = getRegion(container) + + expect(region).toHaveAttribute("aria-live", "polite") + expect(region).toHaveAttribute("role", "status") + expect(region).toHaveAttribute("aria-atomic", "true") + expect(region).toHaveTextContent("3 positions") + }) + + it("uses the alert role in assertive mode", () => { + const { container } = render( + <LiveRegion mode="assertive" message="Order rejected" /> + ) + const region = getRegion(container) + + expect(region).toHaveAttribute("aria-live", "assertive") + expect(region).toHaveAttribute("role", "alert") + }) + + it("stays mounted but silent in off mode", () => { + const { container } = render(<LiveRegion mode="off" message="Idle" />) + const region = getRegion(container) + + expect(region).toHaveAttribute("aria-live", "off") + expect(region).not.toHaveAttribute("role") + expect(region).toHaveTextContent("Idle") + }) + + it("supports non-atomic append-only regions", () => { + const { container } = render( + <LiveRegion atomic={false} relevant="additions" message="Fill received" /> + ) + const region = getRegion(container) + + expect(region).toHaveAttribute("aria-atomic", "false") + expect(region).toHaveAttribute("aria-relevant", "additions") + }) + + it("allows an explicit role override", () => { + const { container } = render(<LiveRegion role="log" message="Entry" />) + expect(getRegion(container)).toHaveAttribute("role", "log") + }) + + it("is visually hidden unless a visible fallback is requested", () => { + const { container, rerender } = render(<LiveRegion message="Saving…" />) + expect(getRegion(container)).toHaveClass("sr-only") + + rerender(<LiveRegion visible message="Saving…" />) + expect(getRegion(container)).not.toHaveClass("sr-only") + }) + + it("renders children when no message is given", () => { + const { container } = render( + <LiveRegion> + <span>Custom content</span> + </LiveRegion> + ) + expect(getRegion(container)).toHaveTextContent("Custom content") + }) + + it("updates its text when the message changes", () => { + const { container, rerender } = render(<LiveRegion message="1 result" />) + expect(getRegion(container)).toHaveTextContent("1 result") + + rerender(<LiveRegion message="2 results" />) + expect(getRegion(container)).toHaveTextContent("2 results") + }) + + it("leaves identical messages untouched without a new announcement key", () => { + const { container, rerender } = render( + <LiveRegion announcementKey={1} message="2 results" /> + ) + const before = getRegion(container).textContent + + rerender(<LiveRegion announcementKey={1} message="2 results" />) + expect(getRegion(container).textContent).toBe(before) + }) + + it("re-announces an identical message when the announcement key changes", () => { + const { container, rerender } = render( + <LiveRegion announcementKey={1} message="2 results" /> + ) + expect(getRegion(container).textContent).toBe("2 results") + + rerender(<LiveRegion announcementKey={2} message="2 results" />) + expect(getRegion(container).textContent).toBe( + `2 results${ZERO_WIDTH_SPACE}` + ) + + rerender(<LiveRegion announcementKey={3} message="2 results" />) + expect(getRegion(container).textContent).toBe("2 results") + }) +}) + +describe("useAnnouncer", () => { + function Harness() { + const { message, announcementKey, announce, clear } = useAnnouncer() + return ( + <> + <LiveRegion message={message} announcementKey={announcementKey} /> + <button type="button" onClick={() => announce("Order submitted")}> + Submit + </button> + <button type="button" onClick={clear}> + Clear + </button> + </> + ) + } + + it("starts empty", () => { + const { container } = render(<Harness />) + expect(getRegion(container).textContent).toBe("") + }) + + it("announces and re-announces the same message", async () => { + const user = userEvent.setup() + const { container } = render(<Harness />) + + await user.click(screen.getByRole("button", { name: "Submit" })) + const first = getRegion(container).textContent ?? "" + expect(strip(first)).toBe("Order submitted") + + await user.click(screen.getByRole("button", { name: "Submit" })) + const second = getRegion(container).textContent ?? "" + expect(strip(second)).toBe("Order submitted") + // Same words, different text node — which is what makes a screen reader + // speak the message a second time. + expect(second).not.toBe(first) + }) + + it("clears the region", async () => { + const user = userEvent.setup() + const { container } = render(<Harness />) + + await user.click(screen.getByRole("button", { name: "Submit" })) + await user.click(screen.getByRole("button", { name: "Clear" })) + expect(strip(getRegion(container).textContent ?? "")).toBe("") + }) + + it("has no accessibility violations", async () => { + const { container } = render(<Harness />) + const results = await axe(container) + expect(results).toHaveNoViolations() + }) +}) diff --git a/packages/ui/src/components/live-region.tsx b/packages/ui/src/components/live-region.tsx new file mode 100644 index 0000000..0c00837 --- /dev/null +++ b/packages/ui/src/components/live-region.tsx @@ -0,0 +1,191 @@ +"use client" + +import * as React from "react" + +import { cn } from "@workspace/ui/lib/utils" + +// --------------------------------------------------------------------------- +// LiveRegion (DS-077) — non-disruptive status announcements +// --------------------------------------------------------------------------- + +/** + * `polite` waits for the screen reader to finish what it is saying, `assertive` + * interrupts immediately, `off` keeps the node mounted but silent. + * + * Reach for `assertive` only when ignoring the message costs the user money or + * data (order rejected, transaction failed, session expired). Everything else + * — "copied", "filters applied", "3 results" — is `polite`. + */ +type LiveRegionMode = "polite" | "assertive" | "off" + +/** + * A zero-width space is appended to force a text-node change when the same + * message is announced twice. It is invisible, adds no layout, and is skipped + * by every mainstream screen reader — but it makes the region's text differ + * from its previous value, which is what actually triggers re-announcement. + */ +const ZERO_WIDTH_SPACE = "\u200B" + +const ROLE_FOR_MODE: Record<LiveRegionMode, "status" | "alert" | undefined> = { + polite: "status", + assertive: "alert", + off: undefined, +} + +interface LiveRegionProps extends React.ComponentProps<"div"> { + /** + * The text to announce. Prefer this over `children` — it is a plain string, + * so the region can guarantee a real text-node change on re-announcement. + */ + message?: string + /** @default "polite" */ + mode?: LiveRegionMode + /** + * Announce the region as a whole rather than just the changed words. + * Keep this on for short status sentences; turn it off for append-only logs. + * @default true + */ + atomic?: boolean + /** Which mutations are announced. Maps to `aria-relevant`. */ + relevant?: React.AriaAttributes["aria-relevant"] + /** + * Also render the message on screen. Off by default — a live region is a + * companion to visible UI, not a substitute for it. Turn it on when the + * region *is* the visible status text (a form's "Saving…" line, say), so + * sighted and screen-reader users read the same string. + * @default false + */ + visible?: boolean + /** + * Change this to re-announce the current `message` even when the text is + * identical to what was announced before. Screen readers only speak a live + * region when its content changes, so "3 results" after "3 results" is + * silent unless the announcement is explicitly re-keyed. + * + * Pass a counter you bump per event, or use {@link useAnnouncer} which keeps + * one for you. + */ + announcementKey?: number | string +} + +/** + * An ARIA live region for status text that must reach screen readers without + * stealing focus. + * + * ```tsx + * <LiveRegion message={`${rows.length} positions`} /> + * <LiveRegion mode="assertive" message={submitError} /> + * ``` + * + * Mount the region *before* the message exists (empty string is fine) and then + * fill it in. A region that is inserted into the DOM together with its text is + * frequently missed, because the assistive technology never observed a change. + * + * The node is never a focus target and, unless `visible` is set, occupies no + * space, so it cannot affect the layout it sits in. + */ +function LiveRegion({ + message, + mode = "polite", + atomic = true, + relevant, + visible = false, + announcementKey, + className, + children, + role, + ...props +}: LiveRegionProps) { + const repeats = useRepeatCount(announcementKey) + + // Only the string form can be safely re-keyed; arbitrary children are + // rendered as-is and re-announce on their own DOM changes. + const content = + message == null + ? children + : repeats % 2 === 1 + ? `${message}${ZERO_WIDTH_SPACE}` + : message + + return ( + <div + data-slot="live-region" + data-mode={mode} + role={role ?? ROLE_FOR_MODE[mode]} + aria-live={mode} + aria-atomic={atomic} + aria-relevant={relevant} + className={cn(!visible && "sr-only", className)} + {...props} + > + {content} + </div> + ) +} + +/** + * Counts how many times `key` has changed. Derived during render (rather than + * in an effect) so the re-announcement lands in the same commit as the message + * it belongs to. + */ +function useRepeatCount(key: number | string | undefined) { + const [count, setCount] = React.useState(0) + const [seen, setSeen] = React.useState(key) + + if (key !== seen) { + setSeen(key) + setCount((current) => current + 1) + } + + return count +} + +interface Announcer { + /** Current message. Spread into `<LiveRegion message={…} />`. */ + message: string + /** Spread into `<LiveRegion announcementKey={…} />`. */ + announcementKey: number + /** Announce `message`, re-announcing it even if the text is unchanged. */ + announce: (message: string) => void + /** Empty the region so a later identical message reads as new. */ + clear: () => void +} + +/** + * Message state for a {@link LiveRegion}, with re-announcement handled. + * + * ```tsx + * const { message, announcementKey, announce } = useAnnouncer() + * // … + * <LiveRegion message={message} announcementKey={announcementKey} /> + * <button onClick={() => announce("Order submitted")}>Submit</button> + * ``` + * + * Calling `announce` with the same string twice announces it twice — that is + * the documented way to repeat an identical message. + */ +function useAnnouncer(initialMessage = ""): Announcer { + const [state, setState] = React.useState({ + message: initialMessage, + announcementKey: 0, + }) + + const announce = React.useCallback((message: string) => { + setState((current) => ({ + message, + announcementKey: current.announcementKey + 1, + })) + }, []) + + const clear = React.useCallback(() => { + setState((current) => ({ + message: "", + announcementKey: current.announcementKey + 1, + })) + }, []) + + return { ...state, announce, clear } +} + +export { LiveRegion, useAnnouncer, ZERO_WIDTH_SPACE } +export type { LiveRegionMode, LiveRegionProps, Announcer } diff --git a/packages/ui/src/components/scroll-area.test.tsx b/packages/ui/src/components/scroll-area.test.tsx new file mode 100644 index 0000000..04e8e9e --- /dev/null +++ b/packages/ui/src/components/scroll-area.test.tsx @@ -0,0 +1,320 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { act, render, screen, waitFor } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import { axe } from "vitest-axe" + +import { ScrollArea } from "./scroll-area" + +/** + * jsdom has no layout engine, so scroll metrics have to be supplied. These + * helpers stand in for "content taller/wider than the viewport". + */ +function setMetrics( + element: HTMLElement, + metrics: Partial< + Record< + | "scrollTop" + | "scrollLeft" + | "scrollHeight" + | "clientHeight" + | "scrollWidth" + | "clientWidth", + number + > + > +) { + for (const [key, value] of Object.entries(metrics)) { + Object.defineProperty(element, key, { + configurable: true, + writable: true, + value, + }) + } +} + +function getRoot() { + const root = document.querySelector<HTMLElement>("[data-slot='scroll-area']") + if (!root) throw new Error("scroll area not rendered") + return root +} + +function getViewport() { + const viewport = document.querySelector<HTMLElement>( + "[data-slot='scroll-area-viewport']" + ) + if (!viewport) throw new Error("scroll area viewport not rendered") + return viewport +} + +async function scrollTo(viewport: HTMLElement, metrics: Parameters<typeof setMetrics>[1]) { + setMetrics(viewport, metrics) + await act(async () => { + viewport.dispatchEvent(new Event("scroll")) + }) +} + +/** Records observers so tests can fire them the way a real resize would. */ +class ResizeObserverMock { + static instances: Array<ResizeObserverMock> = [] + constructor(private callback: () => void) { + ResizeObserverMock.instances.push(this) + } + observe() {} + unobserve() {} + disconnect() {} + static triggerAll() { + for (const instance of ResizeObserverMock.instances) instance.callback() + } +} + +beforeEach(() => { + ResizeObserverMock.instances = [] + vi.stubGlobal("ResizeObserver", ResizeObserverMock) +}) + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe("ScrollArea", () => { + it("renders content in a native scroll container", () => { + render( + <ScrollArea> + <p>Row</p> + </ScrollArea> + ) + expect(screen.getByText("Row")).toBeInTheDocument() + expect(getViewport()).toHaveClass("overflow-y-auto") + }) + + it("defaults to the vertical axis and clips the cross axis", () => { + render(<ScrollArea>Content</ScrollArea>) + expect(getRoot()).toHaveAttribute("data-orientation", "vertical") + expect(getViewport()).toHaveClass("overflow-x-hidden") + }) + + it("supports the horizontal axis", () => { + render(<ScrollArea orientation="horizontal">Content</ScrollArea>) + expect(getRoot()).toHaveAttribute("data-orientation", "horizontal") + expect(getViewport()).toHaveClass("overflow-x-auto") + expect(getViewport()).toHaveClass("overflow-y-hidden") + }) + + it("supports both axes", () => { + render(<ScrollArea orientation="both">Content</ScrollArea>) + expect(getViewport()).toHaveClass("overflow-auto") + }) + + it("renders edge affordances per axis and hides them from assistive tech", () => { + const { rerender } = render(<ScrollArea>Content</ScrollArea>) + const edges = () => + Array.from( + document.querySelectorAll("[data-slot='scroll-area-edge']") + ).map((edge) => edge.getAttribute("data-edge")) + + expect(edges()).toEqual(["top", "bottom"]) + expect( + document.querySelector("[data-slot='scroll-area-edge']") + ).toHaveAttribute("aria-hidden", "true") + + rerender(<ScrollArea orientation="horizontal">Content</ScrollArea>) + expect(edges()).toEqual(["left", "right"]) + + rerender(<ScrollArea orientation="both">Content</ScrollArea>) + expect(edges()).toEqual(["top", "bottom", "left", "right"]) + + rerender(<ScrollArea edgeShadows={false}>Content</ScrollArea>) + expect(edges()).toEqual([]) + }) + + it("detects overflow below the fold on mount", async () => { + render(<ScrollArea>Content</ScrollArea>) + const viewport = getViewport() + setMetrics(viewport, { + scrollTop: 0, + scrollHeight: 500, + clientHeight: 100, + }) + ResizeObserverMock.triggerAll() + + await waitFor(() => { + expect(getRoot()).toHaveAttribute("data-overflow-bottom", "true") + }) + // Nothing above the top edge yet. + expect(getRoot()).toHaveAttribute("data-overflow-top", "false") + }) + + it("flips the edge states as the viewport scrolls", async () => { + render(<ScrollArea>Content</ScrollArea>) + const viewport = getViewport() + + await scrollTo(viewport, { + scrollTop: 200, + scrollHeight: 500, + clientHeight: 100, + }) + await waitFor(() => { + expect(getRoot()).toHaveAttribute("data-overflow-top", "true") + }) + expect(getRoot()).toHaveAttribute("data-overflow-bottom", "true") + + // Scrolled to the very bottom — only the start edge has content behind it. + await scrollTo(viewport, { scrollTop: 400 }) + await waitFor(() => { + expect(getRoot()).toHaveAttribute("data-overflow-bottom", "false") + }) + expect(getRoot()).toHaveAttribute("data-overflow-top", "true") + }) + + it("reports horizontal edges only on a horizontal axis", async () => { + render(<ScrollArea orientation="horizontal">Content</ScrollArea>) + const viewport = getViewport() + + await scrollTo(viewport, { + scrollLeft: 40, + scrollWidth: 800, + clientWidth: 200, + scrollTop: 100, + scrollHeight: 500, + clientHeight: 100, + }) + + await waitFor(() => { + expect(getRoot()).toHaveAttribute("data-overflow-right", "true") + }) + expect(getRoot()).toHaveAttribute("data-overflow-left", "true") + // Vertical metrics are ignored on this axis. + expect(getRoot()).toHaveAttribute("data-overflow-top", "false") + expect(getRoot()).toHaveAttribute("data-overflow-bottom", "false") + }) + + it("re-measures when the container or its content resizes", async () => { + render(<ScrollArea>Content</ScrollArea>) + const viewport = getViewport() + setMetrics(viewport, { scrollTop: 0, scrollHeight: 100, clientHeight: 100 }) + ResizeObserverMock.triggerAll() + await waitFor(() => { + expect(getRoot()).toHaveAttribute("data-overflow-bottom", "false") + }) + + // Content grew — rows arrived — without any scrolling happening. + setMetrics(viewport, { scrollHeight: 900 }) + await act(async () => { + ResizeObserverMock.triggerAll() + }) + await waitFor(() => { + expect(getRoot()).toHaveAttribute("data-overflow-bottom", "true") + }) + }) + + it("re-measures on window resize", async () => { + render(<ScrollArea>Content</ScrollArea>) + setMetrics(getViewport(), { + scrollTop: 0, + scrollHeight: 400, + clientHeight: 100, + }) + + await act(async () => { + window.dispatchEvent(new Event("resize")) + }) + + await waitFor(() => { + expect(getRoot()).toHaveAttribute("data-overflow-bottom", "true") + }) + }) + + it("keeps scroll updates out of React renders", async () => { + const renders = vi.fn() + function Counted() { + renders() + return <ScrollArea>Content</ScrollArea> + } + render(<Counted />) + const before = renders.mock.calls.length + const viewport = getViewport() + + for (const scrollTop of [10, 20, 30, 40, 50]) { + await scrollTo(viewport, { scrollTop, scrollHeight: 500, clientHeight: 100 }) + } + await waitFor(() => { + expect(getRoot()).toHaveAttribute("data-overflow-top", "true") + }) + + // Edge state lives in DOM attributes, so scrolling renders nothing. + expect(renders.mock.calls.length).toBe(before) + }) + + it("notifies opt-in subscribers of edge changes", async () => { + const onEdgesChange = vi.fn() + render(<ScrollArea onEdgesChange={onEdgesChange}>Content</ScrollArea>) + + await scrollTo(getViewport(), { + scrollTop: 50, + scrollHeight: 500, + clientHeight: 100, + }) + + await waitFor(() => { + expect(onEdgesChange).toHaveBeenCalledWith( + expect.objectContaining({ top: true, bottom: true }) + ) + }) + }) + + it("is not a tab stop unless asked to be", () => { + render(<ScrollArea>Content</ScrollArea>) + expect(getViewport()).not.toHaveAttribute("tabindex") + expect(getViewport()).not.toHaveAttribute("role") + }) + + it("becomes a named, keyboard-scrollable region when focusable", async () => { + const user = userEvent.setup() + render( + <ScrollArea focusable aria-label="Order log"> + Content + </ScrollArea> + ) + const viewport = getViewport() + expect(viewport).toHaveAttribute("tabindex", "0") + expect(viewport).toHaveAttribute("role", "region") + + await user.tab() + expect(viewport).toHaveFocus() + }) + + it("keeps focusable content reachable by keyboard", async () => { + const user = userEvent.setup() + render( + <ScrollArea> + <button type="button">First</button> + <button type="button">Second</button> + </ScrollArea> + ) + + await user.tab() + expect(screen.getByRole("button", { name: "First" })).toHaveFocus() + await user.tab() + expect(screen.getByRole("button", { name: "Second" })).toHaveFocus() + }) + + it("merges caller classes on the wrapper and the viewport", () => { + render( + <ScrollArea className="max-h-80" viewportClassName="p-2"> + Content + </ScrollArea> + ) + expect(getRoot()).toHaveClass("max-h-80") + expect(getViewport()).toHaveClass("p-2") + }) + + it("has no accessibility violations", async () => { + const { container } = render( + <ScrollArea focusable aria-label="Positions"> + <p>Row</p> + </ScrollArea> + ) + const results = await axe(container) + expect(results).toHaveNoViolations() + }) +}) diff --git a/packages/ui/src/components/scroll-area.tsx b/packages/ui/src/components/scroll-area.tsx index b4c96a6..37f653a 100644 --- a/packages/ui/src/components/scroll-area.tsx +++ b/packages/ui/src/components/scroll-area.tsx @@ -1,55 +1,196 @@ "use client" import * as React from "react" -import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area" +import { cva } from "class-variance-authority" +import { useOverflowEdges } from "@workspace/ui/hooks/use-overflow-edges" import { cn } from "@workspace/ui/lib/utils" +import type { + OverflowEdges, + ScrollAxis, +} from "@workspace/ui/hooks/use-overflow-edges" +import type { VariantProps } from "class-variance-authority" +// --------------------------------------------------------------------------- +// ScrollArea (DS-079) — overflow container with edge affordances +// --------------------------------------------------------------------------- + +const viewportVariants = cva("min-h-0 min-w-0 flex-1 scroll-area-scrollbars", { + variants: { + orientation: { + vertical: "overflow-x-hidden overflow-y-auto", + horizontal: "overflow-x-auto overflow-y-hidden", + both: "overflow-auto", + }, + }, + defaultVariants: { orientation: "vertical" }, +}) + +/** + * Edge shadows are driven entirely by the `data-overflow-*` attributes the + * hook writes, so they cost no renders. The gradient fades to the surface the + * area sits on — pick the one that matches the container, or the shadow reads + * as a grey smear. + */ +const edgeVariants = cva( + "pointer-events-none absolute z-10 opacity-0 transition-opacity duration-150 motion-reduce:transition-none", + { + variants: { + tone: { + canvas: "from-surface-canvas", + raised: "from-surface-raised", + overlay: "from-surface-overlay", + card: "from-card", + }, + edge: { + top: "inset-x-0 top-0 h-4 bg-gradient-to-b to-transparent group-data-[overflow-top=true]/scroll-area:opacity-100", + bottom: + "inset-x-0 bottom-0 h-4 bg-gradient-to-t to-transparent group-data-[overflow-bottom=true]/scroll-area:opacity-100", + left: "inset-y-0 left-0 w-4 bg-gradient-to-r to-transparent group-data-[overflow-left=true]/scroll-area:opacity-100", + right: + "inset-y-0 right-0 w-4 bg-gradient-to-l to-transparent group-data-[overflow-right=true]/scroll-area:opacity-100", + }, + }, + defaultVariants: { tone: "canvas", edge: "top" }, + } +) + +export interface ScrollAreaProps + extends React.ComponentProps<"div">, + Pick<VariantProps<typeof edgeVariants>, "tone"> { + /** + * Which axes may overflow. The cross axis is clipped, so a vertical list + * cannot be dragged sideways by a stray trackpad gesture. + * @default "vertical" + */ + orientation?: ScrollAxis + /** + * Fade the edges that still have content behind them. + * @default true + */ + edgeShadows?: boolean + /** + * Make the viewport itself a tab stop so arrow keys and Page Up/Down can + * scroll it. Needed when the content has no focusable children of its own + * (a long block of text, a read-only log); pair it with `aria-label` so the + * stop is announced. Content that already contains links, rows, or inputs is + * reachable without it — the browser scrolls focused elements into view. + * @default false + */ + focusable?: boolean + /** Classes for the scrolling element rather than the outer wrapper. */ + viewportClassName?: string + /** + * Notified when the set of overflowing edges changes. Opt-in: subscribing + * moves the edge state into React and re-renders on it, which the CSS-driven + * default deliberately avoids. + */ + onEdgesChange?: (edges: OverflowEdges) => void +} + +/** + * A native scroll container with optional edge shadows that appear only when + * there is more content in that direction. + * + * Scrolling stays native — momentum, overscroll, wheel, trackpad, and the + * user's own scrollbar preference are all untouched; only the scrollbar's + * colours are themed (see the `scroll-area-scrollbars` utility). Nothing is + * virtualized: long tables should still be windowed by the table layer. + * + * ```tsx + * <ScrollArea className="max-h-80" tone="overlay"> + * <CommandList /> + * </ScrollArea> + * + * <ScrollArea orientation="horizontal" tone="card"> + * <TabsList /> + * </ScrollArea> + * ``` + */ function ScrollArea({ + orientation = "vertical", + edgeShadows = true, + tone = "canvas", + focusable = false, className, + viewportClassName, + onEdgesChange, children, + "aria-label": ariaLabel, + "aria-labelledby": ariaLabelledBy, ...props -}: ScrollAreaPrimitive.Root.Props) { +}: ScrollAreaProps) { + const { rootRef, viewportRef } = useOverflowEdges(orientation, onEdgesChange) + + const showVertical = edgeShadows && orientation !== "horizontal" + const showHorizontal = edgeShadows && orientation !== "vertical" + const labelled = Boolean(ariaLabel || ariaLabelledBy) + return ( - <ScrollAreaPrimitive.Root + <div + ref={rootRef} data-slot="scroll-area" - className={cn("relative", className)} + data-orientation={orientation} + className={cn( + "group/scroll-area relative flex min-h-0 min-w-0 flex-col", + className + )} {...props} > - <ScrollAreaPrimitive.Viewport + <div + ref={viewportRef} data-slot="scroll-area-viewport" - className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1" + // A focusable scroll region needs a name to be a useful tab stop; an + // unnamed `role="region"` is worse than no role at all. + role={focusable && labelled ? "region" : undefined} + tabIndex={focusable ? 0 : undefined} + aria-label={ariaLabel} + aria-labelledby={ariaLabelledBy} + className={cn( + viewportVariants({ orientation }), + focusable && + "focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none", + viewportClassName + )} > {children} - </ScrollAreaPrimitive.Viewport> - <ScrollBar /> - <ScrollAreaPrimitive.Corner /> - </ScrollAreaPrimitive.Root> - ) -} + </div> -function ScrollBar({ - className, - orientation = "vertical", - ...props -}: ScrollAreaPrimitive.Scrollbar.Props) { - return ( - <ScrollAreaPrimitive.Scrollbar - data-slot="scroll-area-scrollbar" - data-orientation={orientation} - orientation={orientation} - className={cn( - "flex touch-none p-px transition-colors select-none data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent", - className + {showVertical && ( + <> + <span + aria-hidden="true" + data-slot="scroll-area-edge" + data-edge="top" + className={edgeVariants({ edge: "top", tone })} + /> + <span + aria-hidden="true" + data-slot="scroll-area-edge" + data-edge="bottom" + className={edgeVariants({ edge: "bottom", tone })} + /> + </> )} - {...props} - > - <ScrollAreaPrimitive.Thumb - data-slot="scroll-area-thumb" - className="relative flex-1 rounded-full bg-border" - /> - </ScrollAreaPrimitive.Scrollbar> + + {showHorizontal && ( + <> + <span + aria-hidden="true" + data-slot="scroll-area-edge" + data-edge="left" + className={edgeVariants({ edge: "left", tone })} + /> + <span + aria-hidden="true" + data-slot="scroll-area-edge" + data-edge="right" + className={edgeVariants({ edge: "right", tone })} + /> + </> + )} + </div> ) } -export { ScrollArea, ScrollBar } +export { ScrollArea, edgeVariants, viewportVariants } diff --git a/packages/ui/src/components/separator.test.tsx b/packages/ui/src/components/separator.test.tsx new file mode 100644 index 0000000..b9c555d --- /dev/null +++ b/packages/ui/src/components/separator.test.tsx @@ -0,0 +1,143 @@ +import { describe, expect, it } from "vitest" +import { render, screen } from "@testing-library/react" +import { axe } from "vitest-axe" + +import { Divider, Separator, separatorVariants } from "./separator" + +describe("Separator", () => { + it("exposes the separator role and horizontal orientation by default", () => { + render(<Separator />) + const separator = screen.getByRole("separator") + expect(separator).toHaveAttribute("aria-orientation", "horizontal") + expect(separator).toHaveAttribute("data-orientation", "horizontal") + }) + + it("exposes vertical orientation", () => { + render(<Separator orientation="vertical" />) + expect(screen.getByRole("separator")).toHaveAttribute( + "aria-orientation", + "vertical" + ) + }) + + it("sizes itself per orientation", () => { + const { rerender } = render(<Separator />) + expect(screen.getByRole("separator")).toHaveClass("h-px", "w-full") + + rerender(<Separator orientation="vertical" />) + const vertical = screen.getByRole("separator") + // Stretches in a flex row, and stays visible when the parent has no height. + expect(vertical).toHaveClass("w-px", "self-stretch", "min-h-4") + }) + + it("hides decorative separators from assistive technology", () => { + const { container } = render(<Separator decorative />) + expect(screen.queryByRole("separator")).toBeNull() + + const separator = container.querySelector("[data-slot='separator']") + expect(separator).toHaveAttribute("role", "none") + expect(separator).toHaveAttribute("aria-hidden", "true") + expect(separator).toHaveAttribute("data-decorative", "true") + expect(separator).not.toHaveAttribute("aria-orientation") + }) + + it("applies each tone", () => { + const { rerender } = render(<Separator tone="subtle" />) + expect(screen.getByRole("separator")).toHaveClass("bg-border/50") + + rerender(<Separator tone="default" />) + expect(screen.getByRole("separator")).toHaveClass("bg-border") + + rerender(<Separator tone="strong" />) + expect(screen.getByRole("separator")).toHaveClass("bg-text-tertiary") + }) + + it("merges caller classes", () => { + render(<Separator className="my-4" />) + expect(screen.getByRole("separator")).toHaveClass("my-4") + }) + + it("produces classes for every tone/orientation pair", () => { + for (const tone of ["subtle", "default", "strong"] as const) { + for (const orientation of ["horizontal", "vertical"] as const) { + expect(separatorVariants({ tone, orientation })).toBeTruthy() + } + } + }) + + it("has no accessibility violations", async () => { + const { container } = render( + <div> + <p>Above</p> + <Separator /> + <p>Below</p> + </div> + ) + const results = await axe(container) + expect(results).toHaveNoViolations() + }) +}) + +describe("Divider", () => { + it("renders its label as text", () => { + render(<Divider label="or" />) + expect(screen.getByText("or")).toBeInTheDocument() + }) + + it("splits the rule around a centered label", () => { + const { container } = render(<Divider label="or" />) + const divider = container.querySelector("[data-slot='divider']") + expect(divider).toHaveAttribute("data-align", "center") + expect(container.querySelectorAll("[data-slot='separator']")).toHaveLength(2) + }) + + it("keeps a single rule for start and end alignment", () => { + const { container, rerender } = render(<Divider label="Advanced" align="start" />) + expect(container.querySelectorAll("[data-slot='separator']")).toHaveLength(1) + expect(container.querySelector("[data-slot='divider']")).toHaveAttribute( + "data-align", + "start" + ) + + rerender(<Divider label="Advanced" align="end" />) + expect(container.querySelectorAll("[data-slot='separator']")).toHaveLength(1) + }) + + it("keeps its rules out of the accessibility tree", () => { + render(<Divider label="or" />) + expect(screen.queryByRole("separator")).toBeNull() + }) + + it("uses a contrast-checked label token", () => { + const { container } = render(<Divider label="or" />) + expect(container.querySelector("[data-slot='divider-label']")).toHaveClass( + "text-text-secondary" + ) + }) + + it("falls back to a plain decorative separator without a label", () => { + const { container } = render(<Divider />) + expect(container.querySelector("[data-slot='divider']")).toBeNull() + expect(container.querySelector("[data-slot='separator']")).toHaveAttribute( + "aria-hidden", + "true" + ) + }) + + it("passes the tone through to its rules", () => { + const { container } = render(<Divider label="or" tone="subtle" />) + expect(container.querySelector("[data-slot='separator']")).toHaveClass( + "bg-border/50" + ) + }) + + it("has no accessibility violations", async () => { + const { container } = render( + <form> + <Divider label="or" /> + </form> + ) + const results = await axe(container) + expect(results).toHaveNoViolations() + }) +}) diff --git a/packages/ui/src/components/separator.tsx b/packages/ui/src/components/separator.tsx index eae1e67..d393a58 100644 --- a/packages/ui/src/components/separator.tsx +++ b/packages/ui/src/components/separator.tsx @@ -1,23 +1,157 @@ -import { Separator as SeparatorPrimitive } from "@base-ui/react/separator" +import * as React from "react" +import { cva } from "class-variance-authority" import { cn } from "@workspace/ui/lib/utils" +import type { VariantProps } from "class-variance-authority" +// --------------------------------------------------------------------------- +// Separator / Divider (DS-080) +// --------------------------------------------------------------------------- + +/** + * A separator earns its place only when two adjacent blocks are genuinely + * different kinds of thing and spacing alone leaves that ambiguous. Reach for + * space or a surface change first — see DESIGN.md, "Separators and dividers". + * + * - `subtle` — inside a card or a form, where the surface already groups things + * and the line is only a hint. + * - `default` — between sections of a page or rows of a list. + * - `strong` — the rare structural cut between two regions that belong to + * different tasks (a panel and its footer of destructive actions). + */ +const separatorVariants = cva("shrink-0", { + variants: { + tone: { + subtle: "bg-border/50", + default: "bg-border", + strong: "bg-text-tertiary", + }, + orientation: { + horizontal: "h-px w-full", + // `self-stretch` handles the common flex row; `min-h-4` keeps the line + // visible when the parent has no height of its own and stretch does not + // apply (align-items other than stretch, or a grid cell). + vertical: "w-px min-h-4 self-stretch", + }, + }, + defaultVariants: { + tone: "default", + orientation: "horizontal", + }, +}) + +/** Label styling for {@link Divider}. Contrast-checked in all three themes. */ +const dividerLabelClass = + "shrink-0 text-11 font-medium tracking-wide text-text-secondary uppercase" + +type SeparatorOrientation = "horizontal" | "vertical" + +export interface SeparatorProps + extends Omit<React.ComponentProps<"div">, "children">, + Omit<VariantProps<typeof separatorVariants>, "orientation"> { + /** @default "horizontal" */ + orientation?: SeparatorOrientation + /** + * Hide the separator from assistive technology. Use it whenever the line is + * purely visual — which is most of the time. A semantic separator is only + * warranted when the grouping it expresses is not already carried by + * headings, lists, or landmarks, since every announced separator is one more + * thing a screen-reader user has to listen past. + * @default false + */ + decorative?: boolean +} + +/** + * A one-pixel rule between groups of content. + * + * ```tsx + * <Separator /> // semantic, default tone + * <Separator decorative tone="subtle" /> // visual only + * <Separator orientation="vertical" /> // in a flex row + * ``` + * + * Rendered as a `div` rather than through a primitive: the whole component is + * its role and its ARIA orientation, and both are worth having stated in one + * place instead of inherited from a library default. + */ function Separator({ className, orientation = "horizontal", + tone = "default", + decorative = false, ...props -}: SeparatorPrimitive.Props) { +}: SeparatorProps) { return ( - <SeparatorPrimitive + <div data-slot="separator" - orientation={orientation} - className={cn( - "shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch", - className - )} + data-orientation={orientation} + data-decorative={decorative ? "true" : undefined} + role={decorative ? "none" : "separator"} + aria-orientation={decorative ? undefined : orientation} + aria-hidden={decorative ? true : undefined} + className={cn(separatorVariants({ tone, orientation }), className)} {...props} /> ) } -export { Separator } +export interface DividerProps + extends Omit<React.ComponentProps<"div">, "children">, + Omit<VariantProps<typeof separatorVariants>, "orientation"> { + /** + * Text shown in the rule. Without it a `Divider` is just a horizontal + * decorative `Separator`. + */ + label?: React.ReactNode + /** + * Where the label sits. `center` splits the rule in two; `start` and `end` + * keep a single rule beside the label. + * @default "center" + */ + align?: "start" | "center" | "end" +} + +/** + * A horizontal rule with a label in it — "or", "Advanced", "Yesterday" — for + * naming a break in a form or a list instead of just drawing one. + * + * The rules are decorative and the label is ordinary text, so assistive + * technology reads the words rather than a pile of separator announcements. + * + * ```tsx + * <Divider label="or" /> + * <Divider label="Advanced" align="start" tone="subtle" /> + * ``` + */ +function Divider({ + label, + align = "center", + tone = "default", + className, + ...props +}: DividerProps) { + if (label == null) { + return <Separator decorative tone={tone} className={className} {...props} /> + } + + const rule = <Separator decorative tone={tone} className="flex-1" /> + + return ( + <div + data-slot="divider" + data-align={align} + className={cn("flex w-full items-center gap-3", className)} + {...props} + > + {align !== "start" && rule} + <span data-slot="divider-label" className={dividerLabelClass}> + {label} + </span> + {align !== "end" && rule} + </div> + ) +} + +export { Separator, Divider, separatorVariants, dividerLabelClass } +export type { SeparatorOrientation } diff --git a/packages/ui/src/components/skip-link.test.tsx b/packages/ui/src/components/skip-link.test.tsx new file mode 100644 index 0000000..00026c9 --- /dev/null +++ b/packages/ui/src/components/skip-link.test.tsx @@ -0,0 +1,151 @@ +import { afterEach, describe, expect, it, vi } from "vitest" +import { render, screen } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import { axe } from "vitest-axe" + +import { MAIN_CONTENT_ID, SkipLink } from "./skip-link" + +const listeners: Array<(event: MouseEvent) => void> = [] + +/** Records clicks at the document level — after React's delegated handler. */ +function trackClicks() { + const seen: Array<MouseEvent> = [] + const listener = (event: MouseEvent) => seen.push(event) + document.addEventListener("click", listener) + listeners.push(listener) + return { last: () => seen.at(-1) } +} + +afterEach(() => { + for (const listener of listeners.splice(0)) { + document.removeEventListener("click", listener) + } +}) + +function Page({ targetId = MAIN_CONTENT_ID }: { targetId?: string }) { + return ( + <> + <SkipLink targetId={targetId} /> + <nav> + <a href="/pools">Pools</a> + <a href="/earn">Earn</a> + </nav> + <main id={targetId}> + <h1>Pools</h1> + </main> + </> + ) +} + +describe("SkipLink", () => { + it("labels itself and points at the main region", () => { + render(<SkipLink />) + const link = screen.getByRole("link", { name: "Skip to main content" }) + expect(link).toHaveAttribute("href", `#${MAIN_CONTENT_ID}`) + expect(link).toHaveAttribute("data-slot", "skip-link") + }) + + it("is hidden until focused", () => { + render(<SkipLink />) + const link = screen.getByRole("link") + expect(link).toHaveClass("sr-only") + // Revealing happens through `focus:` so it needs no state and cannot shift + // the layout of the shell it sits in. + expect(link.className).toContain("focus:fixed") + expect(link.className).toContain("focus:[clip:auto]") + }) + + it("is the first keyboard-focusable element on the page", async () => { + const user = userEvent.setup() + render(<Page />) + + await user.tab() + expect(screen.getByRole("link", { name: "Skip to main content" })).toHaveFocus() + }) + + it("moves focus to the main region when activated", async () => { + const user = userEvent.setup() + render(<Page />) + + await user.tab() + await user.keyboard("{Enter}") + + const main = document.getElementById(MAIN_CONTENT_ID) + expect(main).toHaveFocus() + // Focusable on demand, but never a tab stop. + expect(main).toHaveAttribute("tabindex", "-1") + }) + + it("does not navigate to the hash when the target exists", async () => { + const user = userEvent.setup() + render(<Page />) + + // Listen on the document, i.e. after React's own delegated handler, so the + // assertion sees the final state of the event. + const events = trackClicks() + await user.click(screen.getByRole("link", { name: "Skip to main content" })) + + expect(events.last()?.defaultPrevented).toBe(true) + }) + + it("respects an existing tabindex on the target", async () => { + const user = userEvent.setup() + render( + <> + <SkipLink /> + <main id={MAIN_CONTENT_ID} tabIndex={0}> + Content + </main> + </> + ) + + await user.click(screen.getByRole("link")) + expect(document.getElementById(MAIN_CONTENT_ID)).toHaveAttribute( + "tabindex", + "0" + ) + }) + + it("falls back to native hash behaviour when the target is missing", async () => { + const user = userEvent.setup() + render(<SkipLink targetId="does-not-exist" />) + + const events = trackClicks() + await user.click(screen.getByRole("link")) + + expect(events.last()?.defaultPrevented).toBe(false) + }) + + it("supports a custom target and label", async () => { + const user = userEvent.setup() + render( + <> + <SkipLink targetId="orders">Skip to orders</SkipLink> + <section id="orders">Orders</section> + </> + ) + + await user.click(screen.getByRole("link", { name: "Skip to orders" })) + expect(document.getElementById("orders")).toHaveFocus() + }) + + it("still runs a caller-supplied onClick", async () => { + const user = userEvent.setup() + const onClick = vi.fn() + render( + <> + <SkipLink onClick={onClick} /> + <main id={MAIN_CONTENT_ID}>Content</main> + </> + ) + + await user.click(screen.getByRole("link")) + expect(onClick).toHaveBeenCalledOnce() + }) + + it("has no accessibility violations", async () => { + const { container } = render(<Page />) + const results = await axe(container) + expect(results).toHaveNoViolations() + }) +}) diff --git a/packages/ui/src/components/skip-link.tsx b/packages/ui/src/components/skip-link.tsx new file mode 100644 index 0000000..dfb6ab0 --- /dev/null +++ b/packages/ui/src/components/skip-link.tsx @@ -0,0 +1,85 @@ +"use client" + +import * as React from "react" + +import { cn } from "@workspace/ui/lib/utils" + +// --------------------------------------------------------------------------- +// SkipLink (DS-078) — bypass repeated navigation chrome +// --------------------------------------------------------------------------- + +/** Shell-wide id of the page's main region. Kept in one place so the link and + * the landmark can never drift apart. */ +const MAIN_CONTENT_ID = "main-content" + +export interface SkipLinkProps extends React.ComponentProps<"a"> { + /** + * Id of the element to jump to. + * @default "main-content" + */ + targetId?: string + /** @default "Skip to main content" */ + children?: React.ReactNode +} + +/** + * The first focusable element on the page: hidden until focused, then pinned to + * the top-left corner above the sticky navigation. + * + * Render it before the navbar — `AppShell` already does. Activating it moves + * focus into the main region instead of navigating to `#main-content`, so the + * URL is left alone (a hash would otherwise become part of the history entry + * and break back-button behaviour on client-side routes). + * + * The target does not need `tabindex` up front; the link adds `tabindex="-1"` + * on demand so the region can hold focus without becoming a tab stop. + */ +function SkipLink({ + targetId = MAIN_CONTENT_ID, + className, + children = "Skip to main content", + onClick, + ...props +}: SkipLinkProps) { + function handleClick(event: React.MouseEvent<HTMLAnchorElement>) { + onClick?.(event) + if (event.defaultPrevented) return + + const target = document.getElementById(targetId) + // No target on this page — fall through to the browser's own hash jump. + if (!target) return + + event.preventDefault() + + if (!target.hasAttribute("tabindex")) { + target.setAttribute("tabindex", "-1") + } + target.focus() + + // Guarded: jsdom (and older Safari) has no scrollIntoView options support. + if (typeof target.scrollIntoView === "function") { + target.scrollIntoView({ block: "start" }) + } + } + + return ( + <a + data-slot="skip-link" + href={`#${targetId}`} + onClick={handleClick} + className={cn( + "sr-only rounded-md border border-border bg-surface-raised px-4 py-2 text-sm font-medium text-foreground shadow-md", + // `sr-only` is already absolute; focus only has to undo the clipping + // and pin the link somewhere visible above the sticky navbar. + "focus:fixed focus:top-4 focus:left-4 focus:z-100 focus:m-0 focus:size-auto focus:overflow-visible focus:whitespace-nowrap focus:[clip:auto] focus:[clip-path:none]", + "focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background focus:outline-none", + className + )} + {...props} + > + {children} + </a> + ) +} + +export { SkipLink, MAIN_CONTENT_ID } diff --git a/packages/ui/src/components/visually-hidden.test.tsx b/packages/ui/src/components/visually-hidden.test.tsx new file mode 100644 index 0000000..c7c633e --- /dev/null +++ b/packages/ui/src/components/visually-hidden.test.tsx @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vitest" +import { render, screen } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import { axe } from "vitest-axe" + +import { VisuallyHidden } from "./visually-hidden" + +describe("VisuallyHidden", () => { + it("keeps content in the accessibility tree", () => { + render(<VisuallyHidden>Delete position</VisuallyHidden>) + expect(screen.getByText("Delete position")).toBeInTheDocument() + }) + + it("renders a span by default", () => { + render(<VisuallyHidden>Hidden</VisuallyHidden>) + expect(screen.getByText("Hidden").tagName).toBe("SPAN") + }) + + it("marks the element with its slot", () => { + render(<VisuallyHidden>Hidden</VisuallyHidden>) + expect(screen.getByText("Hidden")).toHaveAttribute( + "data-slot", + "visually-hidden" + ) + }) + + it("clips content out of flow so layout is unaffected", () => { + render(<VisuallyHidden>Hidden</VisuallyHidden>) + // `sr-only` is absolutely positioned and 1x1px — it cannot take part in + // the surrounding layout. + expect(screen.getByText("Hidden")).toHaveClass("sr-only") + }) + + it("preserves semantic elements passed via render", () => { + render(<VisuallyHidden render={<h2 />}>Open positions</VisuallyHidden>) + expect( + screen.getByRole("heading", { level: 2, name: "Open positions" }) + ).toBeInTheDocument() + }) + + it("wraps semantic elements without losing the hidden treatment", () => { + render(<VisuallyHidden render={<h2 />}>Open positions</VisuallyHidden>) + expect(screen.getByRole("heading", { level: 2 })).toHaveClass("sr-only") + }) + + it("merges caller classes", () => { + render(<VisuallyHidden className="custom">Hidden</VisuallyHidden>) + expect(screen.getByText("Hidden")).toHaveClass("custom") + }) + + it("is not focusable by default", () => { + render(<VisuallyHidden>Hidden</VisuallyHidden>) + expect(screen.getByText("Hidden")).not.toHaveAttribute("data-focusable") + }) + + it("keeps interactive content reachable by keyboard when focusable", async () => { + const user = userEvent.setup() + render( + <VisuallyHidden focusable> + <a href="#main-content">Jump to content</a> + </VisuallyHidden> + ) + + await user.tab() + expect(screen.getByRole("link", { name: "Jump to content" })).toHaveFocus() + }) + + it("reveals focusable content while focus is inside it", () => { + render( + <VisuallyHidden focusable> + <a href="#main-content">Jump to content</a> + </VisuallyHidden> + ) + + const wrapper = screen.getByRole("link").parentElement + expect(wrapper).toHaveAttribute("data-focusable", "true") + // The reveal is applied through `focus-within:` so it never depends on + // React state, and stays absolutely positioned while visible. + expect(wrapper?.className).toContain("focus-within:size-auto") + expect(wrapper?.className).toContain("focus-within:[clip:auto]") + }) + + it("has no accessibility violations", async () => { + const { container } = render( + <button type="button"> + <span aria-hidden="true">×</span> + <VisuallyHidden>Close</VisuallyHidden> + </button> + ) + const results = await axe(container) + expect(results).toHaveNoViolations() + }) +}) diff --git a/packages/ui/src/components/visually-hidden.tsx b/packages/ui/src/components/visually-hidden.tsx new file mode 100644 index 0000000..d252446 --- /dev/null +++ b/packages/ui/src/components/visually-hidden.tsx @@ -0,0 +1,77 @@ +import * as React from "react" +import { mergeProps } from "@base-ui/react/merge-props" +import { useRender } from "@base-ui/react/use-render" + +import { cn } from "@workspace/ui/lib/utils" + +// --------------------------------------------------------------------------- +// VisuallyHidden (DS-077) — screen-reader-only content +// --------------------------------------------------------------------------- + +/** + * Renders content for assistive technology only. + * + * Use it for the parts of a label that a sighted user gets from context but a + * screen-reader user does not — table column meaning, an icon-only control's + * verb, the unit behind a bare number: + * + * ```tsx + * <button> + * <IconTrash aria-hidden="true" /> + * <VisuallyHidden>Delete position</VisuallyHidden> + * </button> + * ``` + * + * Semantic elements survive the wrapper via `render`, so a hidden heading + * still lands in the document outline: + * + * ```tsx + * <VisuallyHidden render={<h2 />}>Open positions</VisuallyHidden> + * ``` + * + * **When visible text is still required** + * - Anything the user must act on: form labels, error text, prices, balances, + * confirmation copy. Hiding those hurts low-vision, cognitive-load, and + * translation users while only helping screen readers. + * - Any label whose absence would leave a control ambiguous on screen. A + * hidden label is a supplement to visible context, never a replacement for + * it — see `aria-label` on the control itself if there is genuinely no text. + * - Anything that needs to be pointer-clickable: hidden content is 1×1px, so + * only keyboard focus can reach it (that is what `focusable` is for). + * + * @param focusable - Reveal the content while focus is inside it. Required for + * hidden interactive content (skip links, "jump to" affordances) so keyboard + * users can see what they are about to activate. Revealing keeps the element + * absolutely positioned, so surrounding layout never moves. + */ +function VisuallyHidden({ + className, + focusable = false, + render, + ...props +}: useRender.ComponentProps<"span"> & { focusable?: boolean }) { + // `sr-only` is already `position: absolute`, so revealing only has to undo + // the size/clip resets — the element stays out of normal flow either way and + // can never shift the layout around it. + const ownProps = { + "data-slot": "visually-hidden", + ...(focusable ? { "data-focusable": "true" } : {}), + className: cn( + "sr-only", + focusable && [ + "focus-within:z-50 focus-within:rounded-md focus-within:border focus-within:border-border focus-within:bg-surface-raised focus-within:px-2 focus-within:py-1 focus-within:text-sm focus-within:text-foreground focus-within:shadow-md", + "focus-within:m-0 focus-within:size-auto focus-within:overflow-visible focus-within:whitespace-normal focus-within:[clip:auto] focus-within:[clip-path:none]", + ], + className + ), + } as React.ComponentProps<"span"> + + return useRender({ + defaultTagName: "span", + props: mergeProps<"span">(ownProps, props), + render, + state: { slot: "visually-hidden", focusable }, + }) +} + +export { VisuallyHidden } diff --git a/packages/ui/src/hooks/use-overflow-edges.ts b/packages/ui/src/hooks/use-overflow-edges.ts new file mode 100644 index 0000000..67a2e03 --- /dev/null +++ b/packages/ui/src/hooks/use-overflow-edges.ts @@ -0,0 +1,141 @@ +"use client" + +import * as React from "react" + +// --------------------------------------------------------------------------- +// useOverflowEdges (DS-079) — scroll-position affordances without re-renders +// --------------------------------------------------------------------------- + +type ScrollAxis = "vertical" | "horizontal" | "both" + +/** Which directions still have content the user has not scrolled to. */ +interface OverflowEdges { + top: boolean + bottom: boolean + left: boolean + right: boolean +} + +const EDGES = ["top", "bottom", "left", "right"] as const + +const EDGE_ATTRIBUTE: Record<keyof OverflowEdges, string> = { + top: "data-overflow-top", + bottom: "data-overflow-bottom", + left: "data-overflow-left", + right: "data-overflow-right", +} + +/** + * Fractional scroll offsets are normal (zoom levels, HiDPI, sub-pixel layout), + * so comparing against an exact 0 makes the end-of-scroll state flicker. + */ +const EDGE_TOLERANCE = 1 + +/** + * Tracks whether a scroll container has content beyond each of its edges and + * publishes the result as `data-overflow-*` attributes on the outer element. + * + * The attributes are written straight to the DOM instead of going through + * state: scrolling a dense table fires scroll events at display rate, and + * re-rendering the subtree on each one is exactly the cost this avoids. Edge + * affordances are then pure CSS (`group-data-[overflow-bottom=true]`), so the + * React tree renders once no matter how far the user scrolls. + * + * Updates come from three sources — the scroll event, a `ResizeObserver` on the + * viewport and its content (rows arriving, panels collapsing), and window + * resize — all coalesced into one measurement per frame. + * + * @param axis - Which axes to report. Edges on an unwatched axis stay `false`. + * @param onEdgesChange - Optional notification for callers that genuinely need + * the values in React. Opt-in, because subscribing re-introduces renders. + */ +function useOverflowEdges( + axis: ScrollAxis = "vertical", + onEdgesChange?: (edges: OverflowEdges) => void +) { + const rootRef = React.useRef<HTMLDivElement | null>(null) + const viewportRef = React.useRef<HTMLDivElement | null>(null) + const onEdgesChangeRef = React.useRef(onEdgesChange) + + React.useEffect(() => { + onEdgesChangeRef.current = onEdgesChange + }, [onEdgesChange]) + + React.useEffect(() => { + const root = rootRef.current + const viewport = viewportRef.current + if (!root || !viewport) return + + let frame: number | null = null + let previous: OverflowEdges | null = null + + const measure = () => { + frame = null + + const watchVertical = axis !== "horizontal" + const watchHorizontal = axis !== "vertical" + const edges: OverflowEdges = { + top: watchVertical && viewport.scrollTop > EDGE_TOLERANCE, + bottom: + watchVertical && + viewport.scrollHeight - viewport.clientHeight - viewport.scrollTop > + EDGE_TOLERANCE, + left: watchHorizontal && viewport.scrollLeft > EDGE_TOLERANCE, + right: + watchHorizontal && + viewport.scrollWidth - viewport.clientWidth - viewport.scrollLeft > + EDGE_TOLERANCE, + } + + const unchanged = + previous !== null && + EDGES.every((edge) => previous?.[edge] === edges[edge]) + if (unchanged) return + previous = edges + + for (const edge of EDGES) { + root.setAttribute(EDGE_ATTRIBUTE[edge], edges[edge] ? "true" : "false") + } + onEdgesChangeRef.current?.(edges) + } + + const schedule = () => { + if (typeof requestAnimationFrame !== "function") { + measure() + return + } + if (frame !== null) return + frame = requestAnimationFrame(measure) + } + + // Initial state, before the user has scrolled anything. + measure() + + viewport.addEventListener("scroll", schedule, { passive: true }) + window.addEventListener("resize", schedule) + + let observer: ResizeObserver | undefined + if (typeof ResizeObserver === "function") { + observer = new ResizeObserver(schedule) + observer.observe(viewport) + // The viewport can keep its size while the content inside it grows. + for (const child of Array.from(viewport.children)) { + observer.observe(child) + } + } + + return () => { + if (frame !== null && typeof cancelAnimationFrame === "function") { + cancelAnimationFrame(frame) + } + viewport.removeEventListener("scroll", schedule) + window.removeEventListener("resize", schedule) + observer?.disconnect() + } + }, [axis]) + + return { rootRef, viewportRef } +} + +export { useOverflowEdges, EDGE_ATTRIBUTE, EDGE_TOLERANCE } +export type { OverflowEdges, ScrollAxis } diff --git a/packages/ui/src/styles/globals.css b/packages/ui/src/styles/globals.css index 14cdfff..12d5008 100644 --- a/packages/ui/src/styles/globals.css +++ b/packages/ui/src/styles/globals.css @@ -350,6 +350,41 @@ font-variant-numeric: tabular-nums slashed-zero; } +/* + * Themed scrollbars for ScrollArea (DS-079) + * + * Colors only — width, momentum, and overlay-vs-classic behaviour stay with the + * platform, so a user who has asked for permanently visible scrollbars keeps + * them. `scrollbar-color` covers Firefox and Chromium 121+; the ::-webkit rules + * cover older Chromium and Safari. + */ +@utility scroll-area-scrollbars { + scrollbar-width: thin; + scrollbar-color: var(--border) transparent; + + &::-webkit-scrollbar { + width: 0.5rem; + height: 0.5rem; + } + + &::-webkit-scrollbar-track { + background-color: var(--surface-sunken); + } + + &::-webkit-scrollbar-thumb { + background-color: var(--border); + border-radius: var(--radius-full); + } + + &::-webkit-scrollbar-thumb:hover { + background-color: var(--text-tertiary); + } + + &::-webkit-scrollbar-corner { + background-color: transparent; + } +} + /* * Windows forced-colors mode support (DS-067) * @@ -560,4 +595,24 @@ color: CanvasText !important; border: 1px solid CanvasText !important; } + + /* + * Scroll areas (DS-079). The blanket `* { background-color: Canvas }` above + * would otherwise flatten the scrollbar into its track, and the gradient edge + * affordances are dropped in forced colors — so the scrollbar itself has to + * carry the "there is more content" signal. + */ + [data-slot="scroll-area-viewport"] { + forced-color-adjust: none; + scrollbar-color: CanvasText Canvas !important; + } + + [data-slot="scroll-area-viewport"]::-webkit-scrollbar-track { + background-color: Canvas !important; + border: 1px solid CanvasText !important; + } + + [data-slot="scroll-area-viewport"]::-webkit-scrollbar-thumb { + background-color: CanvasText !important; + } }