@webjsdev/ui 0.3.9 → 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 (37) 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 +29 -2
  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 +178 -13
  27. package/packages/registry/registry.json +1 -1
  28. package/packages/registry/themes/base-colors.js +1 -1
  29. package/packages/registry/themes/index.css +11 -0
  30. package/src/commands/add.js +50 -0
  31. package/src/commands/info.js +1 -4
  32. package/src/commands/init.js +126 -24
  33. package/src/registry/extract.js +1 -7
  34. package/src/registry/fetcher.js +9 -1
  35. package/src/registry/local.js +1 -1
  36. package/src/utils/index.js +0 -1
  37. package/src/utils/detect-project.js +0 -63
@@ -26,6 +26,28 @@
26
26
  *
27
27
  * Programmatic API on <ui-hover-card>: `.show()` · `.hide()`.
28
28
  *
29
+ * Keyboard:
30
+ * Tab from the trigger into the card, whose content stays open while it
31
+ * holds focus, so links and buttons inside it are reachable
32
+ * Escape dismiss the card, returning focus to the trigger when the card
33
+ * held it
34
+ *
35
+ * A11y (owned by the element, mostly nothing to supply):
36
+ * The trigger opens on focus as well as hover and carries `aria-haspopup` /
37
+ * `aria-expanded` / `aria-controls`. The panel is a `role="dialog"`, which
38
+ * REQUIRES an accessible name, so the element always gives it one, from the
39
+ * first of: an `aria-label` / `aria-labelledby` you put on
40
+ * `<ui-hover-card-content>`, a `[data-slot="hover-card-title"]` or heading
41
+ * inside the card, or the trigger itself (the card is about whatever the
42
+ * trigger names). Escape dismisses it and hands focus back when the card had
43
+ * it, and the content keeps itself open while focus is inside.
44
+ * What you DO supply: a name for the trigger control if it is icon-only, and
45
+ * ideally a title node in the card so its name describes the card rather
46
+ * than falling back to the trigger's text.
47
+ * Touch: the hover and focus linger paths are gated to pointer devices, so on
48
+ * a no-hover device the card is tap-toggled and dismissed by an outside tap
49
+ * (#745). Do not remove that gate to "fix" keyboard behaviour.
50
+ *
29
51
  * Design tokens used: --popover, --popover-foreground, --border.
30
52
  *
31
53
  * @example
@@ -38,8 +60,9 @@
38
60
  * <div class="flex gap-3">
39
61
  * <img class="size-10 rounded-full" src="/avatars/vivek.jpg" alt="Vivek Khandelwal">
40
62
  * <div>
41
- * <div class="text-sm font-semibold">Vivek Khandelwal</div>
63
+ * <div class="text-sm font-semibold" data-slot="hover-card-title">Vivek Khandelwal</div>
42
64
  * <p class="text-sm text-muted-foreground">Builds the platform, not against it.</p>
65
+ * <a class="text-sm underline" href="/user/vivek/posts">Read the posts</a>
43
66
  * </div>
44
67
  * </div>
45
68
  * </ui-hover-card-content>
@@ -72,6 +95,8 @@ export class UiHoverCard extends WebComponent({
72
95
  _showTimer: number | undefined;
73
96
  _hideTimer: number | undefined;
74
97
 
98
+ _keyHandler = (e: KeyboardEvent): void => this._onKeyDown(e);
99
+
75
100
  constructor() {
76
101
  super();
77
102
  this.open = false;
@@ -95,10 +120,49 @@ export class UiHoverCard extends WebComponent({
95
120
  }
96
121
 
97
122
  disconnectedCallback(): void {
123
+ this._unbindEscape();
98
124
  this._disposeBeforeCache?.();
99
125
  super.disconnectedCallback?.();
100
126
  }
101
127
 
128
+ // A hover card can hold interactive content and cover what is under it, so a
129
+ // keyboard user needs a way out that is not "tab through the whole card".
130
+ // Bound only WHILE OPEN so a closed card cannot swallow Escape.
131
+ _onKeyDown(e: KeyboardEvent): void {
132
+ if (e.key !== 'Escape' || !this.open) return;
133
+ e.preventDefault();
134
+ this._dismiss();
135
+ }
136
+
137
+ // Immediate, unlike hide()'s close-delay linger, since a dismissal is
138
+ // deliberate. Focus moves out BEFORE the panel hides: the content is
139
+ // popover="manual", so hiding it while a descendant holds focus drops focus
140
+ // to <body> and the user loses their place on the page.
141
+ _suppressReopen = false;
142
+
143
+ _dismiss(): void {
144
+ clearTimeout(this._showTimer);
145
+ clearTimeout(this._hideTimer);
146
+ this._closeReleasingFocus();
147
+ }
148
+
149
+ _focusIsInContent(): boolean {
150
+ if (typeof document === 'undefined') return false;
151
+ const content = this.querySelector('ui-hover-card-content');
152
+ const active = document.activeElement;
153
+ return !!content && !!active && content.contains(active);
154
+ }
155
+
156
+ _bindEscape(): void {
157
+ if (typeof document === 'undefined') return;
158
+ document.addEventListener('keydown', this._keyHandler);
159
+ }
160
+
161
+ _unbindEscape(): void {
162
+ if (typeof document === 'undefined') return;
163
+ document.removeEventListener('keydown', this._keyHandler);
164
+ }
165
+
102
166
  // The trigger also opens on focus (see the @focusin handler), so it is
103
167
  // keyboard-reachable: expose the popup relationship on the focusable
104
168
  // control. aria-expanded is refreshed on every open transition.
@@ -117,20 +181,103 @@ export class UiHoverCard extends WebComponent({
117
181
  control.setAttribute('aria-haspopup', 'dialog');
118
182
  control.setAttribute('aria-expanded', String(this.open));
119
183
  const content = this.querySelector<HTMLElement>('ui-hover-card-content [role="dialog"]');
120
- if (content) control.setAttribute('aria-controls', ensureId(content, 'ui-hovercard'));
184
+ if (!content) return;
185
+ control.setAttribute('aria-controls', ensureId(content, 'ui-hovercard'));
186
+ this._nameContent(content, control);
187
+ }
188
+
189
+ // role="dialog" REQUIRES an accessible name. Without one a screen reader
190
+ // announces a bare "dialog" with no hint of what it holds, which is an ARIA
191
+ // defect rather than a missing nicety. Three sources, most specific first:
192
+ // 1. what the author put on <ui-hover-card-content>, which always wins
193
+ // 2. a title node inside the card (the dialog.ts idiom)
194
+ // 3. the trigger, since the card is ABOUT whatever the trigger names
195
+ // ("@vivek" names that user's card). Same fallback the dropdown menu
196
+ // already uses to label its panel back to its trigger.
197
+ // Source 3 is what makes the name unconditional: there is no shape of this
198
+ // component that can end up with an unnamed dialog role.
199
+ _nameContent(content: HTMLElement, control: HTMLElement): void {
200
+ const host = this.querySelector('ui-hover-card-content');
201
+ // aria-labelledby is checked FIRST because it beats aria-label per accname,
202
+ // matching dialog / alert-dialog / toggle. Checking aria-label first would
203
+ // name this panel by the attribute that loses everywhere else, so an author
204
+ // writing both would get a different name here than in a dialog.
205
+ const authoredLabelledBy = host?.getAttribute('aria-labelledby');
206
+ if (authoredLabelledBy) {
207
+ content.setAttribute('aria-labelledby', authoredLabelledBy);
208
+ return;
209
+ }
210
+ const authoredLabel = host?.getAttribute('aria-label');
211
+ if (authoredLabel) {
212
+ content.setAttribute('aria-label', authoredLabel);
213
+ // This method re-runs on every open change, and the fallback below writes
214
+ // aria-labelledby unconditionally. So a card named from its title on an
215
+ // earlier pass still carries that reference, and it would outrank the
216
+ // author's aria-label. Drop it, as dialog / alert-dialog do.
217
+ content.removeAttribute('aria-labelledby');
218
+ return;
219
+ }
220
+ // Already named by an earlier pass (this runs again on every open change).
221
+ if (content.hasAttribute('aria-label') || content.hasAttribute('aria-labelledby')) return;
222
+ const title = content.querySelector<HTMLElement>(
223
+ '[data-slot="hover-card-title"], h1, h2, h3, h4, h5, h6',
224
+ );
225
+ content.setAttribute(
226
+ 'aria-labelledby',
227
+ title
228
+ ? ensureId(title, 'ui-hovercard-title')
229
+ : ensureId(control, 'ui-hovercard-trigger'),
230
+ );
121
231
  }
122
232
 
123
233
  // Back-compat getter.
124
234
  get isOpen(): boolean { return this.open; }
125
235
 
126
236
  show(): void {
237
+ // A keyboard dismissal has to stick: _dismiss() moves focus to the trigger,
238
+ // and that focusin lands right back here, which would reopen the card and
239
+ // make Escape look broken. The guard is released once the transfer flushes.
240
+ if (this._suppressReopen) return;
127
241
  clearTimeout(this._hideTimer);
128
242
  this._showTimer = window.setTimeout(() => { this.open = true; }, this.openDelay);
129
243
  }
130
244
 
131
245
  hide(): void {
132
246
  clearTimeout(this._showTimer);
133
- this._hideTimer = window.setTimeout(() => { this.open = false; }, this.closeDelay);
247
+ // Clear the PENDING hide before scheduling the next one. Overwriting the
248
+ // handle alone orphans the old timer, which then fires on its own and
249
+ // closes the card after a later show() thought it had cancelled the close.
250
+ // Reachable whenever two leave paths fire without a show() between them,
251
+ // and the focus linger below doubles how many of those paths exist.
252
+ clearTimeout(this._hideTimer);
253
+ this._hideTimer = window.setTimeout(() => {
254
+ // The card KEEPS ITSELF OPEN while focus is inside it. That is what makes
255
+ // in-card content Tab-reachable and what both doc surfaces promise, and a
256
+ // mouseleave can schedule this close while a keyboard user still holds
257
+ // focus on an in-card link. Closing then would pull the card out from
258
+ // under them, so leave it open: the content's own focusout schedules the
259
+ // close once focus has actually left, and that pass takes this branch.
260
+ if (this._focusIsInContent()) return;
261
+ this.open = false;
262
+ }, this.closeDelay);
263
+ }
264
+
265
+ // Every close of this popover="manual" panel owes the same focus care, not
266
+ // just Escape. The focus linger added here makes in-card content Tab-
267
+ // reachable, so a user CAN be holding focus on a link inside the card when a
268
+ // mouseleave-scheduled close fires; hiding the panel then drops focus to
269
+ // <body>. Guarded, so a close while focus is elsewhere leaves it alone.
270
+ //
271
+ // The reopen suppression is what makes this terminate. Moving focus to the
272
+ // trigger fires the trigger's own focusin, which calls show() and would
273
+ // reopen the card the close just closed. It is released on the next
274
+ // macrotask, once that synchronous focus transfer has been refused, so a
275
+ // genuinely new hover or a fresh Tab back in still opens the card.
276
+ _closeReleasingFocus(): void {
277
+ this._suppressReopen = true;
278
+ if (this._focusIsInContent()) this._control()?.focus();
279
+ this.open = false;
280
+ setTimeout(() => { this._suppressReopen = false; }, 0);
134
281
  }
135
282
 
136
283
  // Touch open: there is no hover delay and no mouseleave to close it, so open
@@ -145,7 +292,8 @@ export class UiHoverCard extends WebComponent({
145
292
  // Close on an outside tap; also self-remove if the card was already
146
293
  // closed by other means (a re-tap toggle), so the listener never lingers.
147
294
  if (!this.open || !this.contains(ev.target as Node)) {
148
- this.open = false;
295
+ // Same focus care as every other close path: the card may hold focus.
296
+ this._closeReleasingFocus();
149
297
  document.removeEventListener('pointerdown', onOutside, true);
150
298
  }
151
299
  };
@@ -162,6 +310,10 @@ export class UiHoverCard extends WebComponent({
162
310
  updated(changedProperties: Map<string, unknown>): void {
163
311
  if (!changedProperties.has('open')) return;
164
312
  if (changedProperties.get('open') === undefined) return;
313
+ // Bind synchronously rather than in the microtask below, which bails on
314
+ // engines with no Popover API: Escape has to work regardless of that.
315
+ if (this.open) this._bindEscape();
316
+ else this._unbindEscape();
165
317
  // Wait one microtask for <ui-hover-card-content>'s inner [popover]
166
318
  // element to commit; we drive its showPopover() / hidePopover() and
167
319
  // refresh the trigger's aria-expanded.
@@ -260,6 +412,10 @@ UiHoverCardTrigger.register('ui-hover-card-trigger');
260
412
  // The mouseenter/mouseleave handlers keep the card open while the cursor
261
413
  // is over the content itself (so it does not close during a brief
262
414
  // mouseleave on the trigger if the user is moving toward the card).
415
+ // focusin/focusout mirror that linger for the KEYBOARD: the trigger closes
416
+ // the card on its own focusout, so without these, Tabbing from the trigger
417
+ // toward the card scheduled the close before focus could land inside, and
418
+ // the interactive content the JSDoc example shows was unreachable.
263
419
  // --------------------------------------------------------------------------
264
420
 
265
421
  export class UiHoverCardContent extends WebComponent {
@@ -271,6 +427,8 @@ export class UiHoverCardContent extends WebComponent {
271
427
  class=${hoverCardContentClass()}
272
428
  @mouseenter=${this._onEnter}
273
429
  @mouseleave=${this._onLeave}
430
+ @focusin=${this._onEnter}
431
+ @focusout=${this._onLeave}
274
432
  ><slot></slot></div>`;
275
433
  }
276
434
 
@@ -15,10 +15,42 @@
15
15
  * Design tokens used: --input, --background, --primary, --primary-foreground,
16
16
  * --muted-foreground, --foreground, --ring, --destructive.
17
17
  *
18
+ * A11y (required for accessible output):
19
+ * LABEL IT. A `<label class=${labelClass()} for="<the input's id">` is the
20
+ * whole requirement, and the `for` / `id` pair is what links them. Without it
21
+ * the input has no accessible name and a screen reader announces only "edit
22
+ * text". A `placeholder` is NOT a label: it disappears the moment the user
23
+ * types, and some engines never expose it as a name at all.
24
+ * Every `aria-describedby` must point at an element that EXISTS on the page.
25
+ * A dangling id silently contributes nothing, so the hint or error text is
26
+ * never announced; the example below therefore renders the `#email-hint` node
27
+ * it references rather than assuming it.
28
+ * On a validation failure set `aria-invalid="true"` (the class styles the
29
+ * error ring off it) and point `aria-describedby` at the error text, so the
30
+ * reason is announced along with the field rather than only shown in red.
31
+ * Set the right `type` and `autocomplete`. Both are accessibility features:
32
+ * `type` picks the mobile keyboard and the native validation, and
33
+ * `autocomplete` is what lets a password manager or an autofill user avoid
34
+ * retyping. `type="search"` / `type="email"` also announce as such.
35
+ * A required field wants the native `required` attribute, not just an
36
+ * asterisk in the label, so the state reaches assistive tech.
37
+ *
18
38
  * @example
19
39
  * ```html
20
- * <input class=${inputClass()} type="email" name="email" id="email" required
21
- * aria-describedby="email-hint">
40
+ * <div class=${fieldClass()}>
41
+ * <label class=${labelClass()} for="email">Email</label>
42
+ * <input class=${inputClass()} type="email" name="email" id="email" required
43
+ * autocomplete="email" aria-describedby="email-hint">
44
+ * <p class=${hintClass()} id="email-hint">We never share it.</p>
45
+ * </div>
46
+ *
47
+ * <!-- Failed validation: invalid state plus the reason, both announced. -->
48
+ * <div class=${fieldClass()}>
49
+ * <label class=${labelClass()} for="email-2">Email</label>
50
+ * <input class=${inputClass()} type="email" name="email" id="email-2"
51
+ * aria-invalid="true" aria-describedby="email-2-error" value="nope">
52
+ * <p class=${errorClass()} id="email-2-error">Enter a valid email address.</p>
53
+ * </div>
22
54
  * ```
23
55
  */
24
56
  import { cn } from '../lib/utils.ts';
@@ -8,12 +8,45 @@
8
8
  *
9
9
  * Design tokens used: --muted, --muted-foreground, --background.
10
10
  *
11
+ * A11y (required for accessible output):
12
+ * SYMBOL-ONLY KEYS NEED A SPOKEN NAME. A screen reader reads a bare glyph
13
+ * however its speech engine happens to handle that codepoint, so a key shown
14
+ * as a symbol can be announced as a meaningless character name or skipped
15
+ * entirely. Spell the name the way a person says it: `⌘` is "Command", `⇧`
16
+ * "Shift", `⌥` "Option", `⌃` "Control", `⏎` "Enter", `⌫` "Backspace", `␣`
17
+ * "Space", `⎋` "Escape", and the arrows are "Up arrow" and friends. A key
18
+ * already spelled in words ("Shift", "K") reads correctly and needs nothing.
19
+ * DO NOT put `aria-label` on the `<kbd>` itself. `<kbd>` maps to
20
+ * `role=generic`, where a name is prohibited and browsers ignore it, so it
21
+ * would silently do nothing. Supply the name as TEXT instead: hide the glyph
22
+ * with `aria-hidden="true"` and put the spoken form in a visually-hidden
23
+ * sibling (`sr-only`), which every screen reader reads.
24
+ * NAME THE CHORD, not just the keys, since three `<kbd>` elements are
25
+ * otherwise announced as three separate things with no hint they are pressed
26
+ * together. Wrap the group in an element with `role="img"` and an
27
+ * `aria-label` of the whole chord: `role="img"` both SUPPORTS a name (unlike
28
+ * generic) and makes its children presentational, so the chord is announced
29
+ * once as one thing. Do not use `role="group"` + `aria-hidden` children for
30
+ * this: that leaves the group with nothing to announce.
31
+ * Keep the native `<kbd>` element. The class is styling only; `<kbd>` is what
32
+ * carries the "this is keyboard input" semantic.
33
+ * `<kbd>` DOCUMENTS a shortcut, it does not create one. If the shortcut is
34
+ * real, it also needs a keyboard handler, must not clash with an assistive-tech
35
+ * or browser binding, and per WCAG 2.1.4 a single-character shortcut has to be
36
+ * remappable or disableable.
37
+ *
11
38
  * @example
12
39
  * ```html
13
- * <kbd class=${kbdClass()}>⌘</kbd>
40
+ * <!-- A symbol key: hide the glyph, supply the spoken form as text. An
41
+ * aria-label on the <kbd> itself would be ignored (role=generic). -->
42
+ * <kbd class=${kbdClass()} aria-hidden="true">⌘</kbd><span class="sr-only">Command</span>
43
+ *
44
+ * <!-- A spelled-out key needs nothing. -->
14
45
  * <kbd class=${kbdClass()}>K</kbd>
15
46
  *
16
- * <div class=${kbdGroupClass()}>
47
+ * <!-- A chord, announced once as one thing. role="img" supports a name AND
48
+ * makes its children presentational, so the glyphs are not read twice. -->
49
+ * <div class=${kbdGroupClass()} role="img" aria-label="Command Shift P">
17
50
  * <kbd class=${kbdClass()}>⌘</kbd>
18
51
  * <kbd class=${kbdClass()}>Shift</kbd>
19
52
  * <kbd class=${kbdClass()}>P</kbd>
@@ -12,10 +12,31 @@
12
12
  *
13
13
  * Design tokens used: none (typography only).
14
14
  *
15
+ * A11y (required for accessible output):
16
+ * Put this on a REAL `<label>`, and link it to its control, either with `for`
17
+ * pointing at the control's `id` (below) or by nesting the control inside the
18
+ * `<label>`. That link is the entire point of the element: it supplies the
19
+ * control's accessible name and makes clicking the text focus the control.
20
+ * The same classes on a `<span>` or `<div>` look identical and name nothing.
21
+ * One label, one control. `for` takes a single id, so a label cannot name a
22
+ * group; use `<fieldset>` + `<legend>`, or a heading referenced by
23
+ * `aria-labelledby`, for that.
24
+ * Do not use a label as a heading for a non-control region. A label with no
25
+ * control is inert; if it is really a section title, use a heading element.
26
+ * The dimmed disabled look is presentational only (it keys on a parent's
27
+ * `data-disabled` or a `peer-disabled:` sibling). Actually disable the
28
+ * CONTROL: styling the label alone leaves a control that still takes input.
29
+ *
15
30
  * @example
16
31
  * ```html
17
32
  * <label class=${labelClass()} for="email">Email</label>
18
33
  * <input class=${inputClass()} id="email" name="email" type="email">
34
+ *
35
+ * <!-- Nesting works too, and needs no id / for pair. -->
36
+ * <label class=${labelClass()}>
37
+ * <input type="checkbox" data-slot="checkbox" class=${checkboxClass()}>
38
+ * Email me a receipt
39
+ * </label>
19
40
  * ```
20
41
  */
21
42
 
@@ -21,15 +21,36 @@
21
21
  * Design tokens used: --input, --background, --primary, --primary-foreground,
22
22
  * --muted-foreground, --ring, --destructive.
23
23
  *
24
+ * A11y (required for accessible output):
25
+ * LABEL IT. A `<label class=${labelClass()} for="<the select's id">` linked by
26
+ * the `for` / `id` pair is the whole requirement. A `<select>` with no label
27
+ * announces only its current value, so the user hears "Basic" with no idea it
28
+ * is the billing plan.
29
+ * The chevron is DECORATIVE and must stay `aria-hidden="true"`. It duplicates
30
+ * information the select's own role already conveys, and it sits outside the
31
+ * control, so without the attribute it is announced as a stray graphic.
32
+ * There is no placeholder option in a `<select>`. If you need one, use a
33
+ * `<option value="" disabled selected>` prompt, and keep `required` on the
34
+ * select so an empty submit is caught natively.
35
+ * Group long option lists with `<optgroup label="...">`; the label is
36
+ * announced, so it is real structure rather than only a visual break.
37
+ * Being native is the accessibility win here: the platform picker, the
38
+ * keyboard behaviour, and the mobile UX come for free. Do not rebuild it as a
39
+ * div-based listbox to gain styling.
40
+ *
24
41
  * @example
25
42
  * ```html
26
- * <div class=${nativeSelectWrapperClass()}>
27
- * <select class=${nativeSelectClass()} name="plan">
28
- * <option>Basic</option>
29
- * <option>Pro</option>
30
- * </select>
31
- * <!-- chevron icon, decorative -->
32
- * <svg class="${nativeSelectIconClass()}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="m6 9 6 6 6-6" /></svg>
43
+ * <div class=${fieldClass()}>
44
+ * <label class=${labelClass()} for="plan">Billing plan</label>
45
+ * <div class=${nativeSelectWrapperClass()}>
46
+ * <select class=${nativeSelectClass()} name="plan" id="plan" required>
47
+ * <option value="" disabled selected>Choose a plan</option>
48
+ * <option value="basic">Basic</option>
49
+ * <option value="pro">Pro</option>
50
+ * </select>
51
+ * <!-- chevron icon, decorative -->
52
+ * <svg class="${nativeSelectIconClass()}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="m6 9 6 6 6-6" /></svg>
53
+ * </div>
33
54
  * </div>
34
55
  * ```
35
56
  */
@@ -25,25 +25,61 @@
25
25
  *
26
26
  * Design tokens used: --popover, --popover-foreground, --border.
27
27
  *
28
+ * A11y (required for accessible output):
29
+ * This is the Tier-1 component with the most for you to supply, because
30
+ * `popoverContentClass()` returns only classes: the panel it styles is a bare
31
+ * `<div popover>` with NO role and NO accessible name unless you add them.
32
+ * GIVE THE PANEL `role="dialog"`, then NAME it with `aria-labelledby`
33
+ * pointing at the `popoverTitleClass()` heading's `id`. Both halves matter: a
34
+ * role with no name announces as a bare "dialog", and a name with no role is
35
+ * never announced as a grouped thing at all. This is what shadcn's
36
+ * `PopoverContent` does for you in React, and here it is yours.
37
+ * Point `aria-describedby` at the `popoverDescriptionClass()` paragraph when
38
+ * there is one, so the supporting line is announced with the panel.
39
+ * TELL THE TRIGGER what it does: `aria-expanded` tracking the open state, and
40
+ * `aria-haspopup="dialog"`. The browser does not derive either from
41
+ * `popovertarget`, so without them the button announces as a plain button that
42
+ * gives no hint a panel will appear. Keep `aria-expanded` in sync from the
43
+ * panel's `toggle` event, which is the native signal for this.
44
+ * Use `popover` (auto), not `popover="manual"`, unless you have a reason.
45
+ * The auto mode is where the platform gives you light-dismiss, Escape-to-close,
46
+ * and FOCUS RESTORATION back to the invoker; manual mode gives up all three
47
+ * and makes them your problem.
48
+ * Make the title a REAL heading element (`<h2>` / `<h3>`) at the right level
49
+ * for its place in the page, not a styled `<div>`, so it appears in the
50
+ * heading outline a screen reader user navigates by.
51
+ *
28
52
  * @example
29
53
  * ```html
30
54
  * <!-- Single invoker, implicit anchor, zero inline style. -->
31
- * <button popovertarget="filter" class=${buttonClass({ variant: 'outline' })}>Filter</button>
32
- * <div id="filter" popover
55
+ * <button popovertarget="filter" aria-expanded="false" aria-haspopup="dialog"
56
+ * class=${buttonClass({ variant: 'outline' })}>Filter</button>
57
+ * <div id="filter" popover role="dialog" aria-labelledby="filter-title"
58
+ * aria-describedby="filter-desc"
33
59
  * class=${popoverContentClass({ side: 'bottom', align: 'start', sideOffset: 4 })}>
34
60
  * <div class=${popoverHeaderClass()}>
35
- * <h3 class=${popoverTitleClass()}>Filter posts</h3>
36
- * <p class=${popoverDescriptionClass()}>By tag and status.</p>
61
+ * <h3 class=${popoverTitleClass()} id="filter-title">Filter posts</h3>
62
+ * <p class=${popoverDescriptionClass()} id="filter-desc">By tag and status.</p>
37
63
  * </div>
38
64
  * </div>
39
65
  *
66
+ * <!-- Keep the trigger's aria-expanded in sync from the native toggle event. -->
67
+ * <script type="module">
68
+ * const panel = document.getElementById('filter');
69
+ * const trigger = document.querySelector('[popovertarget="filter"]');
70
+ * panel.addEventListener('toggle', (e) => {
71
+ * trigger.setAttribute('aria-expanded', String(e.newState === 'open'));
72
+ * });
73
+ * </script>
74
+ *
40
75
  * <!-- Explicit anchor, for multiple invokers or anchoring to a different element. -->
41
76
  * <span style="anchor-name: --picker">@vivek</span>
42
- * <button popovertarget="profile">Show</button>
43
- * <div id="profile" popover style="position-anchor: --picker"
77
+ * <button popovertarget="profile" aria-expanded="false" aria-haspopup="dialog">Show</button>
78
+ * <div id="profile" popover role="dialog" aria-labelledby="profile-title"
79
+ * style="position-anchor: --picker"
44
80
  * class=${popoverContentClass({ side: 'bottom' })}>
45
81
  * <div class=${popoverHeaderClass()}>
46
- * <h3 class=${popoverTitleClass()}>Vivek Khandelwal</h3>
82
+ * <h3 class=${popoverTitleClass()} id="profile-title">Vivek Khandelwal</h3>
47
83
  * <p class=${popoverDescriptionClass()}>@vivek</p>
48
84
  * </div>
49
85
  * </div>
@@ -11,16 +11,37 @@
11
11
  *
12
12
  * Design tokens used: --input, --primary, --ring, --destructive.
13
13
  *
14
+ * A11y (required for accessible output):
15
+ * `data-slot="radio"` is REQUIRED, not decoration. The injected stylesheet
16
+ * keys the indicator dot on it, and `radioClass()` carries no checked
17
+ * background fill, so without the attribute a SELECTED radio differs from an
18
+ * unselected one by border colour alone. That is both easy to miss and a
19
+ * WCAG 1.4.1 problem (state by colour only). Every example here carries it.
20
+ * NAME THE GROUP. A bare `<div role="radiogroup">` has no accessible name, so
21
+ * a screen reader announces "radio group" with no idea what is being chosen.
22
+ * Point `aria-labelledby` at the visible group label, as below, or use a
23
+ * `<fieldset>` + `<legend>` instead of the `role="radiogroup"` div.
24
+ * Give every input the SAME `name`, which is what makes the browser treat
25
+ * them as one set: it supplies single-selection, Arrow-key navigation, and a
26
+ * single tab stop for free. Different names produce N independent radios that
27
+ * can all be selected at once.
28
+ * Label each input with `<label for>` pointing at its `id`, and mark the
29
+ * group `aria-invalid="true"` plus an `aria-describedby` error element when
30
+ * the selection fails validation.
31
+ *
14
32
  * @example
15
33
  * ```html
16
- * <div role="radiogroup" class=${radioGroupClass()}>
17
- * <div class="flex items-center gap-2">
18
- * <input type="radio" name="plan" value="basic" id="plan-basic" class=${radioClass()}>
19
- * <label class=${labelClass()} for="plan-basic">Basic</label>
20
- * </div>
21
- * <div class="flex items-center gap-2">
22
- * <input type="radio" name="plan" value="pro" id="plan-pro" class=${radioClass()}>
23
- * <label class=${labelClass()} for="plan-pro">Pro</label>
34
+ * <div class=${fieldClass()}>
35
+ * <span class=${fieldLabelClass()} id="plan-label">Billing plan</span>
36
+ * <div role="radiogroup" aria-labelledby="plan-label" class=${radioGroupClass()}>
37
+ * <div class="flex items-center gap-2">
38
+ * <input type="radio" data-slot="radio" name="plan" value="basic" id="plan-basic" class=${radioClass()}>
39
+ * <label class=${labelClass()} for="plan-basic">Basic</label>
40
+ * </div>
41
+ * <div class="flex items-center gap-2">
42
+ * <input type="radio" data-slot="radio" name="plan" value="pro" id="plan-pro" class=${radioClass()}>
43
+ * <label class=${labelClass()} for="plan-pro">Pro</label>
44
+ * </div>
24
45
  * </div>
25
46
  * </div>
26
47
  * ```
@@ -109,7 +130,14 @@ export function radioGroupClass(opts: { orientation?: RadioGroupOrientation } =
109
130
  : 'grid gap-3';
110
131
  }
111
132
 
112
- /** Tailwind classes for a styled `<input type="radio">`. Add `data-slot="radio"` for the indicator dot. */
133
+ /**
134
+ * Tailwind classes for a styled `<input type="radio">`.
135
+ *
136
+ * PAIR THIS WITH `data-slot="radio"` ON THE SAME INPUT. The injected stylesheet
137
+ * keys the indicator dot on that attribute, and this class deliberately carries
138
+ * no checked background fill, so the class alone leaves a selected radio
139
+ * differing from an unselected one by border colour and nothing else.
140
+ */
113
141
  export function radioClass(): string {
114
142
  return cn(RADIO_CLASS);
115
143
  }