Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions packages/ui/tests/heading-ids.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/**
* Heading ids of a docs page are unique (#548): the markdown body, `<Demo>` and `<Props>` share one
* registry. `site/tests/dist-html.spec.ts` proves it on the built pages; this proves the rules.
*/
import { describe, expect, it } from "bun:test";
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { demoTitles, headingId, slugOf, startPage } from "../../../site/src/lib/heading-ids.ts";

describe("heading ids", () => {
it("numbers the repeated <Props> titles of one page", () => {
startPage("/a/", [], "");
expect([1, 2, 3].map(() => headingId("/a/", "Props"))).toEqual(["props", "props-1", "props-2"]);
});

it("gives a body heading precedence and numbers the component twin", () => {
startPage("/b/", ["props"], "");
expect(headingId("/b/", "Props")).toBe("props-1");
});

it("numbers a repeated demo title and keeps the first on the catalog slug", () => {
startPage("/c/", [], '<Demo title="Autoplay" src={x}>');
expect(headingId("/c/", "Autoplay", true)).toBe("autoplay");
expect(headingId("/c/", "Autoplay")).toBe("autoplay-1");
});

it("reserves a demo slug so a <Props> title cannot take it", () => {
startPage("/d/", [], '<Demo title="Props" src={x}>');
expect(headingId("/d/", "Props")).toBe("props-1");
expect(headingId("/d/", "Props", true)).toBe("props");
});

it("is independent per page", () => {
startPage("/e/", [], "");
startPage("/f/", [], "");
expect(headingId("/e/", "Props")).toBe("props");
expect(headingId("/f/", "Props")).toBe("props");
});

it("slugs like the catalog", () => {
expect(slugOf("Dense, pinned and grouped")).toBe("dense-pinned-and-grouped");
expect(demoTitles('<Demo title="A b" x /><Demo\n title="C">')).toEqual(["A b", "C"]);
});
});

describe("docs sources", () => {
it("never title a body heading like a <Demo> of the same page", () => {
const walk = (d: string): string[] =>
readdirSync(d).flatMap((f) => {
const p = join(d, f);
return statSync(p).isDirectory() ? walk(p) : p.endsWith(".mdx") ? [p] : [];
});
const root = join(import.meta.dir, "../../../site/src/content/docs");
const clashes: string[] = [];
for (const f of walk(root)) {
const src = readFileSync(f, "utf8").replace(/^[ \t]*```[\s\S]*?^[ \t]*```/gm, "");
const demos = new Set(demoTitles(src).map(slugOf));
for (const m of src.matchAll(/^#{2,4}\s+(.+)$/gm))
if (demos.has(slugOf(m[1].replace(/[`*]/g, ""))))
clashes.push(`${f.slice(root.length)}: ${m[1]}`);
}
expect(clashes).toEqual([]);
});
});
9 changes: 6 additions & 3 deletions site/src/components/docs/Demo.astro
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
---
import { CodeSnippet } from "@codeatlasdev/ui/code";
import catalog from "../../../public/api/v1/catalog.json";
import { headingId, slugOf } from "../../lib/heading-ids";

interface Props {
title: string;
Expand All @@ -11,11 +12,13 @@ interface Props {
stage?: boolean;
}
const { title, description, src, stage = false } = Astro.props;
// Same transform as the catalog slug (packages/ui/scripts/build-manifest.ts): heading id = catalog slug.
const id = title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
// The catalog slug (packages/ui/scripts/build-manifest.ts) is slugOf(title); the heading id is that slug
// unless the page already uses it (see lib/heading-ids.ts).
const slug = slugOf(title);
const id = headingId(Astro.url.pathname, title, true);
// `/docs/<group>/<page>/` identifies the page; with the slug it identifies the numbered demo.
const [, , group, page] = Astro.url.pathname.split("/");
const num = catalog.elements.find((e) => e.group === group && e.page === page && e.slug === id)?.id;
const num = catalog.elements.find((e) => e.group === group && e.page === page && e.slug === slug)?.id;
---
<section class="demo sk-page" aria-labelledby={id}>
{num && <span class="sk-page__num">#{num}</span>}
Expand Down
3 changes: 2 additions & 1 deletion site/src/components/docs/Props.astro
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
---
import { SimpleTable } from "@codeatlasdev/ui";
import { headingId } from "../../lib/heading-ids";

interface Row {
name: string;
Expand All @@ -12,7 +13,7 @@ interface Props {
title?: string;
}
const { rows, title = "Props" } = Astro.props;
const id = title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
const id = headingId(Astro.url.pathname, title);
---
<section class="demo" aria-labelledby={id}>
<h2 id={id}>{title}</h2>
Expand Down
2 changes: 1 addition & 1 deletion site/src/content/docs/display/carousel.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ The replacement for shadcn `Carousel` (Embla). The strip is native `scroll-snap`

It follows the WAI-ARIA carousel pattern. The carousel is a labelled `role="region"` with `aria-roledescription="carousel"`; each slide is a `role="group"` named "2 of 4". Slides outside the view are `inert`, so their links and buttons leave the tab order and the reading order. The dots are labelled buttons with `aria-current` on the current one (the slides are not tab panels, so they are not a tablist). The slide strip is the one tab stop (it scrolls, so it must be reachable without a pointer, even when no slide holds a control). With it focused, Left and Right (mirrored in RTL), Home and End move the slides.

## Autoplay
## Autoplay behavior

Off unless you set `autoplay` (milliseconds). When on, a Pause / Start button appears, rotation stops while focus or the pointer is inside, and the strip is `aria-live="off"` while it rotates (a rotating live region would read every slide) and `polite` once it stops. A manual move stops it for good. Under `prefers-reduced-motion: reduce` it never runs, the button is not shown and scrolling is instant. Without `loop` it stops on the last slide.

Expand Down
2 changes: 1 addition & 1 deletion site/src/content/docs/feedback/skeleton.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import ImageSrc from "~/demos/feedback/skeleton/Image.svelte?raw";

Skeletons are `aria-hidden`. Announce the loading state on the container, for example with `aria-busy="true"`, and leave the placeholders silent.

## The `skeleton` prop
## Keeping the layout

`Avatar`, `KpiCard`, `TextField`, `Table`, `Card` and `GlassCard` take `skeleton`. The component keeps its real DOM, so the placeholder has exactly the loaded size (no layout shift): content is laid out but hidden, the host paints the same shimmer as `<Skeleton>`, and it is announced with `aria-busy="true"`. A skeleton `TextField` is disabled and an `Avatar` makes no image request. For `Table`, render placeholder rows so the height matches. Under `prefers-reduced-motion` the sweep stops and the placeholder is dimmed.

Expand Down
2 changes: 1 addition & 1 deletion site/src/content/docs/foundation/drag-and-drop.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ Lists that share a `group` accept each other's items. `applyMove` applies a drop

<Demo title="Board" description="Cards reorder inside a column and move between columns. With the keyboard, Space lifts, the arrows move and Enter drops." src={BoardSrc}><Board client:visible /></Demo>

## Drop zones
## Drop targets

A drop zone is a target that is not a sortable list: a calendar day, a folder, a trash can. A standalone item uses `dnd.draggable`; the zone gets `data-dnd-over` while an item is above it. Return `false` from `accepts` to refuse an item.

Expand Down
2 changes: 1 addition & 1 deletion site/src/content/docs/foundation/typography.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export const theme = defineTheme({
});
```

## Type roles
## Roles

A screen needs the same few text roles everywhere. Each role is a bundle of size, leading, tracking and weight, so you pick one name instead of four loose properties: `title`, `heading`, `body`, `label`, `caption` and `num` (counts, times and prices, with tabular digits).

Expand Down
2 changes: 1 addition & 1 deletion site/src/content/docs/getting-started/theming.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ Pass the same object to `caThemeHandle` and `caThemePlugin` (SvelteKit), `caUI`
| `border` | `{ width: 1, strong: 2 }` | Border weights in px (`--ca-border-width`, `--ca-border-width-strong`). |
| `elevation` | `"soft"` | How shadows are drawn: `"soft"`, `"none"`, `"hard"` (offset, no blur) or `"glow"`. |
| `typography` | `{}` | `scale`, `weights` (`heading` defaults to `600`), `headingTracking`, `headingCase`, `displayTracking`, `displayLeading`, `labelTracking`, `labelCase`, `lineHeight` and `measure`. |
| `type` | `{}` | The type scale by role: `title`, `heading`, `body`, `label`, `caption`, `num`, each with `size`, `leading`, `tracking` and `weight`. Overrides the role everywhere, library components included. See [Typography](/docs/foundation/typography/#type-roles). |
| `type` | `{}` | The type scale by role: `title`, `heading`, `body`, `label`, `caption`, `num`, each with `size`, `leading`, `tracking` and `weight`. Overrides the role everywhere, library components included. See [Typography](/docs/foundation/typography/#roles). |
| `motion` | `{}` | `scale` (multiplier on every duration), `easing` (`"standard"`, `"emphasized"`, `"spring"`, `"linear"` or `"steps"`) and `enter` (`"default"` or `"none"`). `enter: "none"` makes dialogs, drawers, sheets, menus, popovers, tooltips and toasts appear and leave in place while hover and focus color transitions keep `scale` (it sets `--ca-enter-scale: 0`); `MotionStage` demos render their final state. |
| `noise` | `0` | Film-grain over the page background, from `0` to `0.12`. |
| `components` | `{}` | [Component tokens](#component-tokens) of `button`, `iconButton`, `kbd`, `avatar`, `field`, `textArea`, `card`, `segmented` and `emptyState`. |
Expand Down
4 changes: 2 additions & 2 deletions site/src/content/docs/getting-started/workarounds.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,8 @@ Imports are from `@codeatlasdev/ui` unless the last column names a subpath.
| A `domain.css` of `--app-status-*`, `--app-tier-*` or `--app-channel-*` colors | `tokens: { status: { new: { light, dark } } }` on the preset: `--ca-token-status-new` (+ `-subtle`, `-contrast`) follows the scheme and the preset | [Domain tokens](/docs/getting-started/theming/#domain-tokens-status-tier-channel) |
| A 1x1 `<canvas>` that resolves `getComputedStyle(root).getPropertyValue(token)` to `[r, g, b, a]` | `tokenRgba("status.new")` or `tokenRgba("--ca-brand-main")` | [Domain tokens](/docs/getting-started/theming/#domain-tokens-status-tier-channel), `@codeatlasdev/ui/tokens` |
| A hex computed by hand to flatten a translucent text or border color onto one background | The CSS color as written: `textSecondary: "oklch(1 0 0 / 0.62)"` or `borderSubtle: { color: "#fff", alpha: 0.1 }`. The build checks text contrast after compositing | [Porting a palette by role](/docs/getting-started/theming/#porting-a-palette-by-role) |
| A parallel `--app-text-*` scale or `.app-label` classes of `font-size`, `line-height` and `letter-spacing` | The type roles: `<Text role="label">`, `.ca-text-label`, `font: var(--ca-text-label)`; restyle them once with `type` in the theme | [Type roles](/docs/foundation/typography/#type-roles) |
| `font-variant-numeric: tabular-nums` and a weight on every counter or clock | `<Text role="num">` or `.ca-text-num` | [Type roles](/docs/foundation/typography/#type-roles) |
| A parallel `--app-text-*` scale or `.app-label` classes of `font-size`, `line-height` and `letter-spacing` | The type roles: `<Text role="label">`, `.ca-text-label`, `font: var(--ca-text-label)`; restyle them once with `type` in the theme | [Type roles](/docs/foundation/typography/#roles) |
| `font-variant-numeric: tabular-nums` and a weight on every counter or clock | `<Text role="num">` or `.ca-text-num` | [Type roles](/docs/foundation/typography/#roles) |

<Callout type="note" title="Spacing units differ">

Expand Down
2 changes: 1 addition & 1 deletion site/src/content/docs/layout/app-shell.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ Map-first products keep one live map behind every screen: the camera, the WebGL

`<html>` paints the page preset's surface, so a public screen wrapped in its own preset would leave the overscroll area and the mobile toolbar in the wrong color. Use `<ThemeIsland preset scheme document>` (`ThemeIsland`, from the theme work in #352): it mirrors the island's preset and scheme onto `<html>` while it is mounted, so the root background follows with no copied color.

## Panel width
## Resizing the panel

The panel is 200px by default. For lists with long names pass `panelWidth` (px, held between 160 and 480): the content offset and the open and close animation follow it, so the page never misaligns. Add `resizablePanel` to let people choose: a handle sits on the panel's trailing edge and is a keyboard-operable separator (`role="separator"` with `aria-valuenow`, `aria-valuemin` and `aria-valuemax`). Arrow keys move it by 8px, Shift by 32px, Home and End jump to the bounds. While the pointer holds the edge the panel follows it with no spring in between. With `persistPanel` the width is stored with the open state and restored before the first paint; `panelWidth` is the width used until the user picks one. A stored width is ignored when `resizablePanel` is off, so the prop stays the contract.

Expand Down
2 changes: 1 addition & 1 deletion site/src/content/docs/navigation/command-palette.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ The palette searches the same `NavModule[]` you pass to [AppShellLayout](/docs/l
<Demo title="Open the palette" description="Click the button or press Cmd+K / Ctrl+K while this page is focused. Picking an item shows its path instead of navigating." src={PaletteSrc}><Palette client:visible /></Demo>
<Demo title="Keyboard" description="Type to filter across every module. Queries shorter than minQueryLength keep the full grouped list." src={KeysSrc}><Keys /></Demo>

## Async results
## Server-side search

Pass `search` to add server-side results under the local matches. It runs once the user stops typing for `searchDebounce` ms, receives the trimmed query and an `AbortSignal`, and returns `CommandResult[]` (`{ id, label, group?, path }`). A newer query, closing the palette or unmounting aborts the pending call, so forward the signal to `fetch` and never apply a stale answer. While it runs the list is `aria-busy` and a "Searching…" row shows; a rejection shows "Search failed" without hiding local matches; the empty state appears only after the search settles. Progress, the result count and failures are announced through `announce()`.

Expand Down
6 changes: 3 additions & 3 deletions site/src/content/docs/navigation/menu.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -41,19 +41,19 @@ import SelectionSrc from "~/demos/navigation/menu/Selection.svelte?raw";

Keyboard support follows the WAI-ARIA menu pattern: Enter, Space or ArrowDown opens the menu on the first item, ArrowUp opens it on the last, arrows then move, Enter or Space selects, Escape closes and focus returns to the trigger. Tab closes the menu. Selecting an item closes it. The menu is a popover layer in the browser top layer, positioned with CSS anchor positioning where supported, so it stays on top of a dialog it is opened from. Pass an interactive trigger (`Button`, `IconButton`) through `triggerChild` and spread the received `props` on it. Use `trigger` only for non-interactive content such as text or an icon. Use `DropdownMenuLabel` for a non-interactive group heading and `DropdownMenuSeparator` for a divider.

### Checkbox and radio items
### Checkable items

`DropdownMenuCheckboxItem` renders `role="menuitemcheckbox"` with `aria-checked` and a check indicator; bind `checked` or listen to `onCheckedChange`. `DropdownMenuRadioGroup` wraps `DropdownMenuRadioItem`s (`role="menuitemradio"`): bind the group's `value`, each item sets it with its own `value`. Enter, Space or a click toggles the item and the menu stays open so several options can be set in one visit (the WAI-ARIA menu pattern); pass `closeOnSelect` to close after one pick. Arrow keys, Home, End and typeahead reach these items like any other, and disabled ones are skipped. Give the group an `aria-label` or put a `DropdownMenuLabel` first and the group is named for screen readers.

### Controlled and context menus

`bind:open` and `onOpenChange` control the menu from outside: set `open = true` from any handler and it opens; `onOpenChange` fires for user-driven changes (a trigger press, Escape, an outside press, picking an item), like `Dialog`. For a right-click menu use [ContextMenu](/docs/navigation/context-menu/), which wires right-click, long-press and Shift+F10 for you. To wire it yourself, pass `anchor={{ x, y }}` (viewport coordinates) together with `open = true` and leave out `trigger`: the menu opens at that point on the first item and focus returns to the element that had it when the menu closes. Setting `anchor` again while the menu is open moves it instead of toggling it, so a second right-click relocates the menu. Handle Shift+F10 and the ContextMenu key in your own `onkeydown` the same way (the demo shows both); on the built-in trigger they open the menu on its first item.

### Submenus
### Nested menus

Put a `DropdownMenuSub` among the items. Its `trigger` snippet holds a `DropdownMenuSubTrigger` (an item with a chevron, `aria-haspopup="menu"` and `aria-expanded`) and its children are the submenu's items, labels and further `DropdownMenuSub`s, nested to any depth. ArrowRight, Enter or Space on the trigger opens the submenu with focus on its first enabled item; ArrowLeft or Escape closes only that level and returns focus to the trigger; arrows, Home, End and typeahead stay inside the open level. Hovering the trigger opens it after `openDelay` (120 ms) and it stays open for `closeDelay` (200 ms) after the pointer leaves, so the pointer can cross the gap. Selecting any item closes every level and returns focus to the root trigger. A disabled sub-trigger neither opens nor takes ArrowRight. In a right-to-left layout use `side="left"` on the sub, and the chevron mirrors.

### Leading icons and brand logos
### Item icons

Pass a leading icon with the `icon` snippet instead of placing it in `children`: it gets a fixed 14px box, the standard gap and `aria-hidden` (the label stays the accessible name), so icon size and alignment never drift between menus. Any `svg` or `img` inside the box fills it, including one wrapped in `SchemeAsset`, whatever `width` or `height` the asset declares. A plain icon (Lucide, any `currentColor` glyph) follows the item's text color, including the highlighted, disabled and destructive states.

Expand Down
59 changes: 59 additions & 0 deletions site/src/lib/heading-ids.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/**
* Heading ids of one docs page. Three things emit headings: the markdown body (`## Foo`, slugged by
* Astro, and the table of contents links to those), `<Demo title>` and `<Props title>`. Each slugged on
* its own, so two headings called "Props" or "Autoplay" shared an id and the in-page link and the
* table of contents hit the first of them (#548).
*
* The docs route calls `startPage` with the body's own slugs before `<Content />` renders; the
* components then take their id from `headingId`, which never returns one already taken on that page.
* Body ids are never rewritten (the TOC points at them). A `<Demo>` keeps the plain catalog slug (it
* is published as `…/#slug` in `catalog.json`), so a body heading that clashes with a demo title has
* its component twin numbered, never the other way round: the demo slugs are claimed up front.
*/
export const slugOf = (title: string): string =>
title
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "");

/** `title="…"` literals of the `<Demo>` elements of an MDX body. */
export const demoTitles = (body: string): string[] =>
[...body.matchAll(/<Demo\s[^>]*?\btitle="([^"]+)"/g)].map((m) => m[1]);

interface Page {
taken: Set<string>;
/** Demo slugs not rendered yet: reserved for their `<Demo>`. */
reserved: Set<string>;
}
const pages = new Map<string, Page>();

/**
* @param bodyIds ids Astro gave the markdown headings
* @param body raw MDX source, to reserve the demo slugs
*/
export function startPage(path: string, bodyIds: string[], body: string): void {
pages.set(path, { taken: new Set(bodyIds), reserved: new Set(demoTitles(body).map(slugOf)) });
}

const unique = (page: Page, base: string, own: boolean): string => {
const free = (id: string) => !page.taken.has(id) && (own || !page.reserved.has(id));
let id = base;
for (let n = 1; !free(id); n++) id = `${base}-${n}`;
page.taken.add(id);
return id;
};

/**
* Id for the heading a component renders on `path`. `demo` is true for `<Demo>`: it may take its
* own reserved slug (the first time) and, like any heading, takes the next free number otherwise.
*/
export function headingId(path: string, title: string, demo = false): string {
const base = slugOf(title);
const page = pages.get(path);
if (!page) return base;
if (demo && page.reserved.has(base) && !page.taken.has(base)) {
page.taken.add(base);
return base;
}
return unique(page, base, false);
}
Loading