@webjsdev/ui 0.3.10 → 0.3.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/README.md +21 -13
  2. package/package.json +3 -3
  3. package/packages/registry/README.md +14 -10
  4. package/packages/registry/components/accordion.ts +18 -0
  5. package/packages/registry/components/alert-dialog.ts +203 -22
  6. package/packages/registry/components/aspect-ratio.ts +12 -0
  7. package/packages/registry/components/card.ts +20 -1
  8. package/packages/registry/components/checkbox.ts +45 -3
  9. package/packages/registry/components/collapsible.ts +15 -0
  10. package/packages/registry/components/dialog.ts +205 -22
  11. package/packages/registry/components/dropdown-menu.ts +390 -22
  12. package/packages/registry/components/hover-card.ts +162 -4
  13. package/packages/registry/components/input.ts +34 -2
  14. package/packages/registry/components/kbd.ts +35 -2
  15. package/packages/registry/components/label.ts +21 -0
  16. package/packages/registry/components/native-select.ts +28 -7
  17. package/packages/registry/components/popover.ts +43 -7
  18. package/packages/registry/components/radio-group.ts +37 -9
  19. package/packages/registry/components/sonner.ts +53 -5
  20. package/packages/registry/components/switch.ts +23 -1
  21. package/packages/registry/components/tabs.ts +15 -0
  22. package/packages/registry/components/textarea.ts +21 -2
  23. package/packages/registry/components/toggle-group.ts +81 -8
  24. package/packages/registry/components/toggle.ts +82 -3
  25. package/packages/registry/components/tooltip.ts +57 -0
  26. package/packages/registry/lib/utils.ts +147 -8
  27. package/packages/registry/registry.json +1 -1
  28. package/packages/registry/themes/base-colors.js +1 -1
  29. package/src/commands/add.js +50 -0
  30. package/src/commands/info.js +1 -4
  31. package/src/commands/init.js +126 -24
  32. package/src/registry/fetcher.js +9 -1
  33. package/src/registry/local.js +1 -1
  34. package/src/utils/index.js +0 -1
  35. package/src/utils/detect-project.js +0 -63
package/README.md CHANGED
@@ -24,10 +24,12 @@ function calls, not for a layered React abstraction over every primitive:
24
24
  Escape, and backdrop overlay all come from the platform. Light DOM
25
25
  throughout (no shadow DOM); authored children project through `<slot>`.
26
26
 
27
- Works with any project that uses Tailwind CSS v4 and supports custom elements:
28
- webjs, Next, Astro, Vite, SvelteKit, Lit, vanilla HTML, as long as Tailwind
29
- is configured, the components render correctly. Variant names, sizes, and
30
- data-attribute conventions mirror shadcn's so an AI agent's existing
27
+ **This is the component library for WebJs apps.** It is what `webjs ui init`
28
+ and `webjs ui add` install, and WebJs is the only host it is tested and
29
+ supported on. The output is plain Tailwind CSS v4 classes and standard custom
30
+ elements, so nothing stops it rendering elsewhere, but no other framework is a
31
+ supported target and none is detected or defaulted for. Variant names, sizes,
32
+ and data-attribute conventions mirror shadcn's so an AI agent's existing
31
33
  knowledge of shadcn maps directly.
32
34
 
33
35
  Tier-2 elements extend the `WebComponent({ ... })` factory from
@@ -58,7 +60,7 @@ so on. Follow that block and the markup is fully accessible.
58
60
 
59
61
  ## Install
60
62
 
61
- ### Option A : Webjs users (already have `@webjsdev/cli`)
63
+ ### Option A : through `@webjsdev/cli` (the normal path)
62
64
 
63
65
  Nothing to install. `@webjsdev/ui` is a hard dependency of `@webjsdev/cli`,
64
66
  so a global webjs install already includes it. Apps scaffolded with
@@ -69,9 +71,11 @@ webjs ui init
69
71
  webjs ui add button card dialog
70
72
  ```
71
73
 
72
- ### Option B : Everyone else (Next, Astro, Vite, SvelteKit, Lit, vanilla, …)
74
+ ### Option B : the standalone binary
73
75
 
74
- Two npm installs, the CLI and the runtime base class, then run the CLI:
76
+ The `webjsui` binary does not require `@webjsdev/cli`, so a WebJs app that
77
+ skipped the global install can reach the kit with two npm installs, the CLI
78
+ and the runtime base class:
75
79
 
76
80
  ```sh
77
81
  npm install -D @webjsdev/ui
@@ -80,15 +84,19 @@ npx webjsui init
80
84
  npx webjsui add button card dialog
81
85
  ```
82
86
 
83
- The `webjsui` binary is standalone, it doesn't require `@webjsdev/cli`.
84
- `init` auto-detects your project type (Next / Astro / Vite / Lit / plain)
85
- and picks sensible defaults.
87
+ Both paths write the same `components.json`. `init` takes no reading of the
88
+ host project: the defaults below are fixed, and `--css <path>` overrides the
89
+ stylesheet it appends the tokens to. Re-running it preserves what an existing
90
+ project already declared (its aliases, stylesheet path, and base color) and
91
+ leaves an existing helper file as you edited it, so it is safe as a repair
92
+ step. Pass `--overwrite` to reset those instead.
86
93
 
87
94
  ## What `init` writes
88
95
 
89
96
  - `components.json`, your project's UI config (aliases, base color, Tailwind path)
90
- - `lib/utils.ts`, the `cn()` class-merge helper
91
- - Tailwind tokens + CSS variables appended to your global stylesheet
97
+ - `lib/utils/cn.ts`, the `cn()` class-merge helper, and `lib/utils/dom.ts`
98
+ beside it for the client-only `onBeforeCache()` helper
99
+ - Tailwind tokens + CSS variables appended to `styles/globals.css`
92
100
 
93
101
  ## What `add` does
94
102
 
@@ -109,7 +117,7 @@ always compares against the live upstream.
109
117
 
110
118
  | Command | Effect |
111
119
  |---|---|
112
- | `webjsui init` | Initialize a project (writes `components.json`, `lib/utils.ts`, the theme tokens). Exits non-zero if the tokens cannot be written. |
120
+ | `webjsui init` | Initialize a project (writes `components.json`, `lib/utils/cn.ts`, the theme tokens). Preserves an existing project's settings and helper files; `-o, --overwrite` resets them. Exits non-zero if the tokens cannot be written. |
113
121
  | `webjsui add <names...>` | Add components (copies helpers + a pointer for Tier-1, self-heals theme tokens) |
114
122
  | `webjsui list` | List all available components |
115
123
  | `webjsui view <name>` | Print a component's projected view (helpers + paste-ready example) and full source |
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@webjsdev/ui",
3
- "version": "0.3.10",
3
+ "version": "0.3.11",
4
4
  "type": "module",
5
- "description": "An AI-first component library - class-helper functions for visuals, custom elements only where state matters. Source-copied into your repo, you own it. Works with any Tailwind v4 project.",
5
+ "description": "The AI-first component library for WebJs, a full-stack JavaScript framework built on web components with server-side rendering and no build step. Class-helper functions for visuals, custom elements only where state matters, source-copied into your repo so you own it.",
6
6
  "bin": {
7
7
  "webjsui": "bin/webjsui.js"
8
8
  },
@@ -37,7 +37,7 @@
37
37
  "url": "git+https://github.com/webjsdev/webjs.git",
38
38
  "directory": "packages/ui"
39
39
  },
40
- "homepage": "https://ui.webjs.dev",
40
+ "homepage": "https://webjs.dev/ui",
41
41
  "bugs": "https://github.com/webjsdev/webjs/issues",
42
42
  "license": "MIT",
43
43
  "keywords": [
@@ -3,11 +3,15 @@
3
3
  Internal: sources for the `@webjsdev/ui` component registry.
4
4
 
5
5
  Not published. `registry.json` is the manifest, and the files it points at
6
- are the source of truth. The website
7
- ([`@webjsdev/ui-website`](../ui-website)) composes shadcn-compatible JSON on
8
- demand and serves it at `https://ui.webjs.dev/registry/<name>.json`, which the
9
- `@webjsdev/ui` CLI fetches. There is no build step, no generated output, no
10
- `prestart` hook.
6
+ are the source of truth. The marketing site composes shadcn-compatible JSON on
7
+ demand from these sources and serves it at
8
+ `https://webjs.dev/ui/registry/<name>.json`, which the `@webjsdev/ui` CLI
9
+ fetches. There is no build step, no generated output, no `prestart` hook.
10
+
11
+ The composer and the routes moved out of this package with #1099, when the
12
+ gallery merged into the marketing site. The old `ui.webjs.dev/registry/**`
13
+ URLs still resolve, permanently redirecting to the new ones, because
14
+ already-published CLI versions fetch from them.
11
15
 
12
16
  ## Layout
13
17
 
@@ -26,10 +30,10 @@ overrides → merged CSS, same `files[]` shape.
26
30
 
27
31
  ## Wire endpoints
28
32
 
29
- Served by `@webjsdev/ui-website` (`app/_lib/registry.server.ts` +
30
- `app/registry/**`):
33
+ Served by the marketing site (`website/modules/ui/queries/registry.server.ts`
34
+ composes, `website/app/ui/registry/**` routes):
31
35
 
32
- - `GET /registry/<name>.json`: single registry item (`type: registry:ui` /
36
+ - `GET /ui/registry/<name>.json`: single registry item (`type: registry:ui` /
33
37
  `registry:theme` / `registry:lib`), with file contents inlined.
34
- - `GET /registry/index.json`: flat metadata-only list.
35
- - `GET /registry`: full manifest with every item's content inlined.
38
+ - `GET /ui/registry/index.json`: flat metadata-only list.
39
+ - `GET /ui/registry`: full manifest with every item's content inlined.
@@ -24,6 +24,24 @@
24
24
  *
25
25
  * Design tokens used: --border, --ring, --foreground.
26
26
  *
27
+ * A11y (mostly handled by the native primitives):
28
+ * Build it on `<details>` + `<summary>`, which is where nearly all of this
29
+ * comes from: the browser supplies the button semantics, the expanded state,
30
+ * Enter / Space activation, and focus, with no ARIA to hand-write. Do not
31
+ * reimplement it with a `<div>` and `aria-expanded`.
32
+ * Put the trigger classes on the `<summary>` ITSELF, not on a `<button>` or
33
+ * `<div>` nested inside it. A nested interactive element inside a `<summary>`
34
+ * is the #1078-class bug: the focusable thing and the labelled thing come
35
+ * apart, and the summary's own name goes empty.
36
+ * The chevron is decorative, so keep it `aria-hidden="true"`.
37
+ * `name` on `<details>` is what gives exclusive-open behaviour natively, so
38
+ * prefer it over JS that closes siblings.
39
+ * Use a real heading inside the `<summary>` when the section title belongs in
40
+ * the document outline, and pick its level from the surrounding page.
41
+ * Never rely on the collapsed state to HIDE something that must stay
42
+ * available: collapsed content is not reachable by find-in-page on every
43
+ * engine, so nothing essential should live only there.
44
+ *
27
45
  * @example
28
46
  * ```html
29
47
  * <div class=${accordionClass()}>
@@ -44,8 +44,29 @@
44
44
  * Keyboard: Escape is blocked (alert dialogs require explicit choice);
45
45
  * Tab cycles trapped within the dialog (native focus trap).
46
46
  *
47
+ * A11y (owned by the element, but SUPPLY A TITLE):
48
+ * On open the element names and describes the panel from the
49
+ * `data-slot="alert-dialog-title"` / `alert-dialog-description` nodes,
50
+ * falling back to the first heading / paragraph. An `aria-label` or
51
+ * `aria-labelledby` you put on `<ui-alert-dialog-content>` is forwarded onto
52
+ * the panel and wins over both.
53
+ * A modal MUST have an accessible name, so with no title node and no name of
54
+ * your own the panel falls back to a generic `aria-label="Alert dialog"`.
55
+ * Treat that as a bug in your markup rather than a feature: this dialog
56
+ * interrupts the user to demand an explicit choice and blocks Escape, so
57
+ * naming it "Alert dialog" tells them nothing about what they are deciding.
58
+ *
47
59
  * Design tokens used: --background, --border, --muted-foreground.
48
60
  *
61
+ * Scroll lock (#1144): opening the dialog locks body scroll, which hides the
62
+ * page scrollbar and would widen the viewport. The lock reserves the scrollbar
63
+ * gutter so the width never changes and a `position: fixed` header stays put.
64
+ * Where the engine ignores `scrollbar-gutter` (WebKit today) it publishes the
65
+ * leftover width on <html> as `--wj-scrollbar-compensation`, so a fixed header
66
+ * opts in with `border-right: var(--wj-scrollbar-compensation, 0px) solid transparent`
67
+ * (a transparent border composes with existing padding, and a background paints
68
+ * across it, so a header keeps its own chrome full bleed).
69
+ *
49
70
  * @example
50
71
  * ```html
51
72
  * <ui-alert-dialog>
@@ -104,29 +125,130 @@ function installStyles(): void {
104
125
  document.head.appendChild(style);
105
126
  }
106
127
 
107
- // Body scroll lock. Refcounted so nested dialogs unlock in order. Kept in
108
- // lockstep with dialog.ts (not imported) so `webjs ui add alert-dialog`
128
+ // Page scroll lock, refcounted so nested dialogs release safely in ANY order.
129
+ // Kept in lockstep with dialog.ts (not imported) so `webjs ui add alert-dialog`
109
130
  // doesn't pull in the full dialog component.
110
- let scrollLockCount = 0;
111
- let savedOverflow = '';
112
- let savedPaddingRight = '';
131
+ //
132
+ // Hiding the page scrollbar widens the viewport by the scrollbar's width, so
133
+ // anything laid out against the viewport moves. Padding the page holds in-flow
134
+ // content still, but a `position: fixed` header lays out against the initial
135
+ // containing block, never against any padding box, so padding alone leaves it
136
+ // sliding right by half the scrollbar width. Two mechanisms fix it:
137
+ //
138
+ // 1. Reserve the scrollbar gutter for the duration of the lock, so the
139
+ // viewport width never changes and NOTHING moves, in flow or fixed. This
140
+ // needs no cooperation from the page. Measured honoured on Chromium.
141
+ // 2. Where the engine ignores it (measured on WebKit), fall back to
142
+ // padding <html> and publish the leftover width as
143
+ // `--wj-scrollbar-compensation` on it, so a fixed element can opt in with
144
+ // `border-right: var(--wj-scrollbar-compensation, 0px) solid transparent`.
145
+ //
146
+ // Everything below is MEASURED rather than assumed, because engines disagree
147
+ // about both scrollbar geometry and gutter support. When mechanism 1 works the
148
+ // measured residual is zero, so no padding is applied and the custom property is
149
+ // never set: the two mechanisms cannot double-compensate.
150
+ //
151
+ // The lock's state is DOCUMENT level, so it is keyed on globalThis rather than
152
+ // module scope. dialog.ts and alert-dialog.ts ship as separate copies (so
153
+ // `webjs ui add alert-dialog` stays self contained), and two independent
154
+ // counters mutating the same <html> can only be released safely in LIFO order.
155
+ // They are not: `disconnectedCallback` fires in tree order and the #766
156
+ // before-cache close runs in registration order, so a confirm inside a dialog
157
+ // releases OUTER first, and the inner unlock would then re-apply the values it
158
+ // captured with nothing left to clear them, leaving <html> padded for good. One
159
+ // shared counter makes release order irrelevant.
160
+ interface ScrollLockState {
161
+ count: number;
162
+ overflow: string;
163
+ rootPaddingRight: string;
164
+ scrollbarGutter: string;
165
+ compensation: string;
166
+ }
167
+
168
+ const SCROLLBAR_COMPENSATION = '--wj-scrollbar-compensation';
169
+
170
+ function scrollLockState(): ScrollLockState {
171
+ // The key follows the framework's global-key prefix (see `__webjsSonnerBus` in
172
+ // sonner.ts, the same pattern for the same reason), and the cast is typed
173
+ // rather than a string-keyed bag, so a change to the shape is a type error in
174
+ // both copies instead of a silent undefined at runtime.
175
+ const store = globalThis as unknown as { __webjsScrollLock?: ScrollLockState };
176
+ let state = store.__webjsScrollLock;
177
+ if (!state) {
178
+ state = { count: 0, overflow: '', rootPaddingRight: '', scrollbarGutter: '', compensation: '' };
179
+ store.__webjsScrollLock = state;
180
+ }
181
+ return state;
182
+ }
113
183
 
114
184
  function lockScroll(): void {
115
- if (scrollLockCount === 0) {
116
- const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
117
- savedOverflow = document.body.style.overflow;
118
- savedPaddingRight = document.body.style.paddingRight;
119
- document.body.style.overflow = 'hidden';
120
- if (scrollbarWidth > 0) document.body.style.paddingRight = `${scrollbarWidth}px`;
185
+ const state = scrollLockState();
186
+ if (state.count === 0) {
187
+ const root = document.documentElement;
188
+ const body = document.body;
189
+ const rootStyle = getComputedStyle(root);
190
+
191
+ state.overflow = body.style.overflow;
192
+ state.rootPaddingRight = root.style.paddingRight;
193
+ state.scrollbarGutter = root.style.scrollbarGutter;
194
+ state.compensation = root.style.getPropertyValue(SCROLLBAR_COMPENSATION);
195
+
196
+ // <html> is an in-flow block filling the initial containing block, so its
197
+ // border box IS the viewport width, and it is re-laid-out synchronously.
198
+ // A `position: fixed` probe is NOT a substitute: WebKit keeps reporting its
199
+ // old box until the next rendering update, so it reads a zero delta and the
200
+ // compensation below never fires.
201
+ const widthBefore = root.getBoundingClientRect().width;
202
+ const padBefore = parseFloat(rootStyle.paddingRight) || 0;
203
+ // An engine with no `scrollbar-gutter` at all reads back undefined, which
204
+ // must be treated as "the page has not chosen" so the gutter is still
205
+ // attempted (setting an unsupported property is a harmless no-op, and the
206
+ // measured residual below is what actually decides the fallback).
207
+ const chosenGutter = rootStyle.scrollbarGutter || 'auto';
208
+
209
+ // Reserve the gutter, but only when the page has not already made its own
210
+ // choice. An app running `stable both-edges` keeps both gutters through the
211
+ // lock, and overwriting that with the single-edge value would drop one and
212
+ // introduce the very shift this exists to prevent.
213
+ if (chosenGutter === 'auto' && window.innerWidth > root.clientWidth) {
214
+ root.style.scrollbarGutter = 'stable';
215
+ }
216
+ body.style.overflow = 'hidden';
217
+
218
+ const grew = root.getBoundingClientRect().width - widthBefore;
219
+ if (grew > 0) {
220
+ // Padding the ROOT holds in-flow content still whatever the body's own
221
+ // width is (a `max-width` body does not widen with the viewport, so
222
+ // padding the body would miss it), and it leaves the page's own body
223
+ // padding untouched.
224
+ root.style.paddingRight = `${padBefore + grew}px`;
225
+ // A fixed box lays out against the viewport, so no padding here can reach
226
+ // it. Publish what it moved by and let it opt in.
227
+ root.style.setProperty(SCROLLBAR_COMPENSATION, `${grew}px`);
228
+ }
121
229
  }
122
- scrollLockCount++;
230
+ state.count++;
123
231
  }
124
232
 
125
233
  function unlockScroll(): void {
126
- scrollLockCount = Math.max(0, scrollLockCount - 1);
127
- if (scrollLockCount === 0) {
128
- document.body.style.overflow = savedOverflow;
129
- document.body.style.paddingRight = savedPaddingRight;
234
+ const state = scrollLockState();
235
+ // An unlock with no matching lock is a no-op, NOT the last release: clamping to
236
+ // zero and restoring would replay a stale snapshot onto whatever the page owns
237
+ // now. Belt-and-braces as things stand, because _teardown() only unlocks what
238
+ // its own element locked, so no path through the components can reach here with
239
+ // a zero count. It is kept because the cost is one comparison and the failure
240
+ // it prevents is silent.
241
+ if (state.count === 0) return;
242
+ state.count--;
243
+ if (state.count === 0) {
244
+ const root = document.documentElement;
245
+ document.body.style.overflow = state.overflow;
246
+ root.style.paddingRight = state.rootPaddingRight;
247
+ root.style.scrollbarGutter = state.scrollbarGutter;
248
+ // Restored rather than removed, so a value the PAGE set before any dialog
249
+ // opened survives the lock.
250
+ if (state.compensation) root.style.setProperty(SCROLLBAR_COMPENSATION, state.compensation);
251
+ else root.style.removeProperty(SCROLLBAR_COMPENSATION);
130
252
  }
131
253
  }
132
254
 
@@ -147,6 +269,7 @@ export class UiAlertDialog extends WebComponent({
147
269
  }
148
270
 
149
271
  _disposeBeforeCache?: () => void;
272
+ _scrollLocked?: boolean;
150
273
 
151
274
  connectedCallback(): void {
152
275
  installStyles();
@@ -195,14 +318,33 @@ export class UiAlertDialog extends WebComponent({
195
318
  }
196
319
 
197
320
  _setup(): void {
321
+ // A detached element must not lock: `updated()` defers this to a microtask,
322
+ // and a removal in between already ran _teardown() as a no-op, so locking
323
+ // here would never be released (nothing disconnects twice).
324
+ if (!this.isConnected) return;
198
325
  const content = this._content;
199
326
  if (!content) return;
200
- lockScroll();
327
+ // Idempotent, because the count is an integer and the flag is a boolean. A
328
+ // coalesced hide() then show() in one task produces ONE update whose
329
+ // changedProperties still reports open, so _setup() can run twice with no
330
+ // _teardown() between; double-counting there would leave the page locked for
331
+ // the rest of the session.
332
+ if (!this._scrollLocked) {
333
+ lockScroll();
334
+ this._scrollLocked = true;
335
+ }
201
336
  content.showModal();
202
337
  }
203
338
 
204
339
  _teardown(): void {
205
- unlockScroll();
340
+ // Release only what THIS element locked. _setup() returns before locking
341
+ // when there is no content child, so an unconditional unlock here would
342
+ // consume ANOTHER open dialog's count and restore the page out from under
343
+ // it, dropping its compensation while it is still open.
344
+ if (this._scrollLocked) {
345
+ this._scrollLocked = false;
346
+ unlockScroll();
347
+ }
206
348
  this._content?.close();
207
349
  }
208
350
  }
@@ -267,18 +409,57 @@ export class UiAlertDialogContent extends WebComponent({
267
409
  // description is data-slot="alert-dialog-description" (falling back to the
268
410
  // first paragraph). Author-set ARIA always wins. Inlined rather than shared
269
411
  // with dialog.ts so `webjs ui add alert-dialog` stays self-contained.
412
+ // The description is independent of the NAME, so it is wired on every path,
413
+ // including the two authored-name early returns.
414
+ _wireDescription(panel: Element): void {
415
+ const authored = this.getAttribute('aria-describedby');
416
+ if (authored) {
417
+ panel.setAttribute('aria-describedby', authored);
418
+ return;
419
+ }
420
+ const desc =
421
+ this.querySelector('[data-slot="alert-dialog-description"]') ?? this.querySelector('p');
422
+ if (desc && !panel.hasAttribute('aria-describedby')) {
423
+ panel.setAttribute('aria-describedby', ensureId(desc as HTMLElement, 'ui-alert-desc'));
424
+ }
425
+ }
426
+
270
427
  _wireLabels(): void {
271
428
  const panel = this.querySelector('[data-slot="alert-dialog-content"]');
272
429
  if (!panel) return;
430
+ // A name the author put on <ui-alert-dialog-content> is where they
431
+ // naturally write it, but role="alertdialog" lives on the inner panel.
432
+ //
433
+ // Each authored-name branch RETURNS rather than falling through to the
434
+ // title wiring. Falling through would set aria-labelledby from the title
435
+ // alongside the forwarded aria-label, and aria-labelledby beats aria-label
436
+ // per accname, so the title would silently win over the author's name.
437
+ const authoredLabelledBy = this.getAttribute('aria-labelledby');
438
+ if (authoredLabelledBy) {
439
+ panel.setAttribute('aria-labelledby', authoredLabelledBy);
440
+ this._wireDescription(panel);
441
+ return;
442
+ }
443
+ const authoredLabel = this.getAttribute('aria-label');
444
+ if (authoredLabel) {
445
+ panel.setAttribute('aria-label', authoredLabel);
446
+ // An authored name replaces the title as the NAME, so a stale
447
+ // aria-labelledby from an earlier open must go or it would win over it.
448
+ panel.removeAttribute('aria-labelledby');
449
+ this._wireDescription(panel);
450
+ return;
451
+ }
273
452
  const title =
274
453
  this.querySelector('[data-slot="alert-dialog-title"]') ?? this.querySelector('h1, h2, h3');
275
- const desc =
276
- this.querySelector('[data-slot="alert-dialog-description"]') ?? this.querySelector('p');
277
454
  if (title && !panel.hasAttribute('aria-labelledby')) {
278
455
  panel.setAttribute('aria-labelledby', ensureId(title as HTMLElement, 'ui-alert-title'));
279
456
  }
280
- if (desc && !panel.hasAttribute('aria-describedby')) {
281
- panel.setAttribute('aria-describedby', ensureId(desc as HTMLElement, 'ui-alert-desc'));
457
+ this._wireDescription(panel);
458
+ // APG: a modal MUST have an accessible name, and this one interrupts the
459
+ // user to demand a decision, so an unnamed one is worse here than
460
+ // anywhere. A floor, not a substitute for a real title; a title wins.
461
+ if (!panel.hasAttribute('aria-labelledby') && !panel.hasAttribute('aria-label')) {
462
+ panel.setAttribute('aria-label', 'Alert dialog');
282
463
  }
283
464
  }
284
465
 
@@ -8,6 +8,18 @@
8
8
  *
9
9
  * Design tokens used: none (layout only).
10
10
  *
11
+ * A11y (required for accessible output):
12
+ * Layout only, so it adds no semantics and takes none away. What matters is
13
+ * the CONTENT you put inside it: an `<img>` still needs an `alt` (empty
14
+ * `alt=""` when it is purely decorative), an `<iframe>` still needs a `title`,
15
+ * and a `<video>` still needs its captions.
16
+ * Reserving the ratio IS the accessibility win: the box keeps its space before
17
+ * the media loads, so the page does not shift under someone who is already
18
+ * reading or aiming at a target (WCAG 2.4.x-adjacent).
19
+ * Do not clip meaningful content to make a ratio fit. If a crop would hide
20
+ * part of an image that carries information, put that information in the
21
+ * surrounding text too.
22
+ *
11
23
  * @example
12
24
  * ```html
13
25
  * <div style="aspect-ratio: 16/9;" class="${aspectRatioClass()}">
@@ -13,11 +13,30 @@
13
13
  *
14
14
  * Design tokens used: --card, --card-foreground, --muted-foreground, --border.
15
15
  *
16
+ * A11y (required for accessible output):
17
+ * Use a REAL HEADING for `cardTitleClass()` whenever the card has a
18
+ * meaningful title. The helper only styles text, so on a `<div>` the title is
19
+ * invisible to the heading outline a screen reader user navigates by, and on a
20
+ * page of cards they get an unstructured wall. Pick the level that fits the
21
+ * surrounding document (`<h2>` under the page `<h1>`, `<h3>` inside a
22
+ * sectioned region); do not pick it for the font size, which the class sets.
23
+ * The card root is a plain `<div>` with no role, which is correct: it is a
24
+ * visual container. Give it a role only when it really is one thing, and then
25
+ * name it, e.g. `<section aria-labelledby="<the title's id">` for a landmark,
26
+ * or `role="listitem"` inside a `role="list"` for a card grid.
27
+ * A WHOLE-CARD LINK is the trap. Do not wrap the card in an `<a>` around a
28
+ * header, body, and buttons: it swallows the nested controls and gives a
29
+ * screen reader user one enormous link whose name is the entire card text.
30
+ * Put the link on the title instead and let the card be a container.
31
+ * Keep the reading order of the DOM matching the visual order.
32
+ * `cardActionClass()` places an action at the top right, but it stays after
33
+ * the title and description in the markup, which is what you want announced.
34
+ *
16
35
  * @example
17
36
  * ```html
18
37
  * <div class=${cardClass()}>
19
38
  * <div class=${cardHeaderClass()}>
20
- * <div class=${cardTitleClass()}>Notifications</div>
39
+ * <h3 class=${cardTitleClass()}>Notifications</h3>
21
40
  * <div class=${cardDescriptionClass()}>You have 3 unread messages.</div>
22
41
  * <div class=${cardActionClass()}>
23
42
  * <button class=${buttonClass({ variant: 'ghost', size: 'sm' })}>Mark all read</button>
@@ -12,10 +12,45 @@
12
12
  * Design tokens used: --input, --primary, --primary-foreground, --background,
13
13
  * --ring, --destructive.
14
14
  *
15
+ * A11y (required for accessible output):
16
+ * `data-slot="checkbox"` is REQUIRED, not decoration. The injected stylesheet
17
+ * keys the checkmark on it, so without it a checked box renders as a filled
18
+ * square with no tick: the checked and unchecked states then differ by colour
19
+ * alone, which is exactly what WCAG 1.4.1 rules out. Every example here
20
+ * carries it; keep it when you copy one.
21
+ * Associate a label with the input, either `<label for>` pointing at the
22
+ * input's `id` (as below) or by nesting the input inside the `<label>`. A
23
+ * checkbox with no label has no accessible name.
24
+ * Group related checkboxes in a `<fieldset>` with a `<legend>` naming the
25
+ * group, so the set is announced as one thing rather than N loose controls.
26
+ * Set `aria-invalid="true"` on a failed checkbox (the class styles it) and
27
+ * point `aria-describedby` at the element holding the error text.
28
+ * The indeterminate state is a PROPERTY, not an attribute
29
+ * (`el.indeterminate = true`). Do NOT add `aria-checked="mixed"` alongside it:
30
+ * the browser already maps a native checkbox's `indeterminate` to a mixed
31
+ * checked state, and hand-writing the ARIA duplicates a state the host
32
+ * language owns, which is discouraged precisely because the two can then
33
+ * disagree. Set the property and leave the ARIA alone.
34
+ *
15
35
  * @example
16
36
  * ```html
17
- * <input type="checkbox" name="terms" id="terms" class=${checkboxClass()}>
18
- * <label class=${labelClass()} for="terms">I accept the terms</label>
37
+ * <div class="flex items-center gap-2">
38
+ * <input type="checkbox" data-slot="checkbox" name="terms" id="terms" class=${checkboxClass()}>
39
+ * <label class=${labelClass()} for="terms">I accept the terms</label>
40
+ * </div>
41
+ *
42
+ * <!-- A group of related checkboxes is named by its legend. -->
43
+ * <fieldset class=${stackClass({ gap: 'sm' })}>
44
+ * <legend class=${fieldLabelClass()}>Email preferences</legend>
45
+ * <div class="flex items-center gap-2">
46
+ * <input type="checkbox" data-slot="checkbox" name="digest" id="pref-digest" class=${checkboxClass()}>
47
+ * <label class=${labelClass()} for="pref-digest">Weekly digest</label>
48
+ * </div>
49
+ * <div class="flex items-center gap-2">
50
+ * <input type="checkbox" data-slot="checkbox" name="security" id="pref-security" class=${checkboxClass()}>
51
+ * <label class=${labelClass()} for="pref-security">Security alerts</label>
52
+ * </div>
53
+ * </fieldset>
19
54
  * ```
20
55
  */
21
56
  import { cn } from '../lib/utils.ts';
@@ -93,7 +128,14 @@ export function installCheckboxStyles(): void {
93
128
 
94
129
  if (typeof document !== 'undefined') installCheckboxStyles();
95
130
 
96
- /** Tailwind classes for a styled native `<input type="checkbox">`. Add `data-slot="checkbox"` for the checkmark style to apply. */
131
+ /**
132
+ * Tailwind classes for a styled native `<input type="checkbox">`.
133
+ *
134
+ * PAIR THIS WITH `data-slot="checkbox"` ON THE SAME INPUT. The injected
135
+ * stylesheet keys the checkmark and the indeterminate dash on that attribute,
136
+ * so the class alone gives you a box that fills with colour when checked but
137
+ * never draws a tick, leaving the two states distinguishable by colour only.
138
+ */
97
139
  export function checkboxClass(): string {
98
140
  return cn(CHECKBOX_CLASS);
99
141
  }
@@ -17,6 +17,21 @@
17
17
  *
18
18
  * Design tokens used: --border, --ring, --foreground.
19
19
  *
20
+ * A11y (mostly handled by the native primitives):
21
+ * Build it on `<details>` + `<summary>`. The browser gives you the button
22
+ * semantics, the expanded state, Enter / Space activation, and focus for free,
23
+ * with no ARIA to hand-write. Do not rebuild it from a `<div>` plus
24
+ * `aria-expanded`.
25
+ * Put the trigger classes on the `<summary>` ITSELF, never on a `<button>` or
26
+ * `<div>` nested inside it: nesting an interactive element in a `<summary>`
27
+ * splits the focusable element from the labelled one and empties the summary's
28
+ * accessible name.
29
+ * Give the `<summary>` real text. A summary whose only content is an icon has
30
+ * no accessible name, so an icon-only trigger needs an `aria-label` and an
31
+ * `aria-hidden="true"` icon.
32
+ * Do not put anything essential ONLY inside collapsed content: it is not
33
+ * reachable by find-in-page on every engine.
34
+ *
20
35
  * @example
21
36
  * ```html
22
37
  * <details class=${collapsibleClass()}>