@webjsdev/ui 0.3.10 → 0.3.12

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 (38) hide show
  1. package/README.md +26 -15
  2. package/package.json +3 -3
  3. package/packages/registry/README.md +16 -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 +208 -14
  27. package/packages/registry/package.json +1 -1
  28. package/packages/registry/registry.json +1 -1
  29. package/packages/registry/themes/base-colors.js +2 -2
  30. package/src/commands/add.js +52 -2
  31. package/src/commands/diff.js +2 -2
  32. package/src/commands/info.js +2 -5
  33. package/src/commands/init.js +127 -25
  34. package/src/registry/example.js +7 -4
  35. package/src/registry/fetcher.js +10 -2
  36. package/src/registry/local.js +2 -2
  37. package/src/utils/index.js +0 -1
  38. package/src/utils/detect-project.js +0 -63
@@ -22,6 +22,7 @@
22
22
  * `duration`: ms, default 4000 (loading toasts default to 0, no auto-dismiss).
23
23
  * `action`: { label, onClick } | undefined. Renders an action button.
24
24
  * `cancel`: { label, onClick } | undefined. Renders a cancel button.
25
+ * Both buttons dismiss the toast after running their onClick.
25
26
  *
26
27
  * Events: none dispatched (consumers act on the id returned by `toast()`).
27
28
  *
@@ -29,6 +30,17 @@
29
30
  * per-instance dispatch (bypasses the singleton router that `toast()`
30
31
  * uses); typically only needed when mounting multiple viewports.
31
32
  *
33
+ * A11y (owned by the element, nothing to supply):
34
+ * The viewport is a persistent `aria-live="polite"` region labelled
35
+ * "Notifications", so a toast is announced as it is inserted; an `error`
36
+ * toast carries `role="alert"` so it is announced assertively. Every toast
37
+ * also ships a labelled close button, so a toast can always be dismissed by
38
+ * hand rather than only by its timer or a programmatic `toast.dismiss(id)`.
39
+ * That matters most for a toast that never auto-dismisses, which
40
+ * `toast.loading()` is by default (`duration: 0`) and which was otherwise
41
+ * impossible to get rid of from the UI. Each toast's icon is decorative and
42
+ * aria-hidden; the meaning is in the text and the role.
43
+ *
32
44
  * Design tokens used: --popover, --popover-foreground, --border, --radius.
33
45
  *
34
46
  * @example
@@ -44,6 +56,12 @@
44
56
  * toast.error('Failed to save', { description: 'Try again' });
45
57
  * toast.promise(savePost(), { loading: 'Saving', success: 'Saved', error: 'Failed' });
46
58
  * toast.dismiss(id);
59
+ *
60
+ * // action and cancel buttons; each dismisses the toast after its onClick.
61
+ * toast('Post deleted', {
62
+ * action: { label: 'Undo', onClick: () => restorePost() },
63
+ * cancel: { label: 'Dismiss', onClick: () => {} },
64
+ * });
47
65
  * </script>
48
66
  * ```
49
67
  */
@@ -57,6 +75,7 @@ interface ToastOptions {
57
75
  description?: string;
58
76
  duration?: number;
59
77
  action?: { label: string; onClick: () => void };
78
+ cancel?: { label: string; onClick: () => void };
60
79
  }
61
80
 
62
81
  interface ToastItem extends ToastOptions {
@@ -99,6 +118,7 @@ function makeToast(message: string, opts: ToastOptions = {}, type: ToastType = '
99
118
  description: opts.description,
100
119
  duration: opts.duration ?? (type === 'loading' ? 0 : 4000),
101
120
  action: opts.action,
121
+ cancel: opts.cancel,
102
122
  });
103
123
  }
104
124
 
@@ -207,6 +227,7 @@ export class UiSonner extends WebComponent({
207
227
  description: opts.description,
208
228
  duration: opts.duration ?? (type === 'loading' ? 0 : 4000),
209
229
  action: opts.action,
230
+ cancel: opts.cancel,
210
231
  });
211
232
  return id;
212
233
  }
@@ -242,6 +263,7 @@ export class UiSonner extends WebComponent({
242
263
  this.items.get(),
243
264
  (item) => item.id,
244
265
  (item) => html`<div
266
+ data-slot="sonner-toast"
245
267
  class=${TOAST_ITEM_BASE}
246
268
  data-type=${item.type}
247
269
  role=${item.type === 'error' ? 'alert' : 'status'}
@@ -253,9 +275,21 @@ export class UiSonner extends WebComponent({
253
275
  ? html`<div class="mt-1 text-xs text-muted-foreground">${item.description}</div>`
254
276
  : ''}
255
277
  </div>
278
+ ${item.cancel
279
+ ? html`<button
280
+ type="button"
281
+ data-slot="sonner-cancel"
282
+ class="rounded-md px-2 py-1 text-xs font-medium text-muted-foreground hover:bg-accent"
283
+ @click=${() => {
284
+ item.cancel!.onClick();
285
+ this._remove(item.id);
286
+ }}
287
+ >${item.cancel.label}</button>`
288
+ : ''}
256
289
  ${item.action
257
290
  ? html`<button
258
291
  type="button"
292
+ data-slot="sonner-action"
259
293
  class="rounded-md px-2 py-1 text-xs font-medium hover:bg-accent"
260
294
  @click=${() => {
261
295
  item.action!.onClick();
@@ -263,6 +297,13 @@ export class UiSonner extends WebComponent({
263
297
  }}
264
298
  >${item.action.label}</button>`
265
299
  : ''}
300
+ <button
301
+ type="button"
302
+ data-slot="sonner-close"
303
+ aria-label="Close notification"
304
+ class="shrink-0 rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
305
+ @click=${() => this._remove(item.id)}
306
+ >${unsafeHTML(CLOSE_SVG)}</button>
266
307
  </div>`,
267
308
  )}
268
309
  </div>`;
@@ -270,15 +311,22 @@ export class UiSonner extends WebComponent({
270
311
  }
271
312
  UiSonner.register('ui-sonner');
272
313
 
314
+ // The close affordance's own glyph. Decorative: the button's accessible name
315
+ // comes from its aria-label, so the icon must not be walked for a name.
316
+ const CLOSE_SVG =
317
+ '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
318
+
319
+ // Type glyphs, all decorative: the toast's text carries the meaning, and the
320
+ // type is conveyed by role="alert" vs role="status", not by the picture.
273
321
  const ICONS: Record<ToastType, string> = {
274
322
  default: '',
275
323
  success:
276
- '<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20 6 9 17 4 12"/></svg>',
324
+ '<svg aria-hidden="true" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20 6 9 17 4 12"/></svg>',
277
325
  error:
278
- '<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>',
279
- info: '<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>',
326
+ '<svg aria-hidden="true" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>',
327
+ info: '<svg aria-hidden="true" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>',
280
328
  warning:
281
- '<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>',
329
+ '<svg aria-hidden="true" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>',
282
330
  loading:
283
- '<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" class="animate-spin"><line x1="12" y1="2" x2="12" y2="6"/><line x1="12" y1="18" x2="12" y2="22"/><line x1="4.93" y1="4.93" x2="7.76" y2="7.76"/><line x1="16.24" y1="16.24" x2="19.07" y2="19.07"/><line x1="2" y1="12" x2="6" y2="12"/><line x1="18" y1="12" x2="22" y2="12"/></svg>',
331
+ '<svg aria-hidden="true" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" class="animate-spin"><line x1="12" y1="2" x2="12" y2="6"/><line x1="12" y1="18" x2="12" y2="22"/><line x1="4.93" y1="4.93" x2="7.76" y2="7.76"/><line x1="16.24" y1="16.24" x2="19.07" y2="19.07"/><line x1="2" y1="12" x2="6" y2="12"/><line x1="18" y1="12" x2="22" y2="12"/></svg>',
284
332
  };
@@ -11,6 +11,22 @@
11
11
  * Design tokens used: --primary, --input, --background, --foreground, --ring,
12
12
  * --primary-foreground.
13
13
  *
14
+ * A11y (required for accessible output):
15
+ * Keep `role="switch"` on the native checkbox. That is what makes a screen
16
+ * reader announce "on / off" rather than "checked / unchecked", and the
17
+ * browser maps the input's checked state to it for free.
18
+ * NAME IT. The visible track is a `<span>` and the real control is `sr-only`,
19
+ * so there is no text on the input at all. Wrapping the whole thing in a
20
+ * `<label>` (the first example) is the simplest way, since the label's text
21
+ * then names the input. A STANDALONE switch, one with no wrapping `<label>`
22
+ * and no `<label for>` pointing at it, has NO accessible name and needs an
23
+ * explicit `aria-label`, as the second example shows.
24
+ * The visual track and thumb are presentation only. They carry no text and no
25
+ * role, so nothing about the state reaches assistive tech through them: it all
26
+ * comes from the input. Do not move the name onto the track.
27
+ * Use `disabled` on the input, not on the track: the track styles itself from
28
+ * the `peer-disabled:` variant, and only the real control can refuse input.
29
+ *
14
30
  * @example
15
31
  * ```html
16
32
  * <label class="inline-flex items-center gap-2">
@@ -19,8 +35,14 @@
19
35
  * <span class=${labelClass()}>Notifications</span>
20
36
  * </label>
21
37
  *
38
+ * <!-- Standalone (no wrapping label), so the name has to be explicit. -->
39
+ * <input type="checkbox" role="switch" name="wifi" aria-label="Wi-Fi"
40
+ * class=${switchInputClass()}>
41
+ * <span class=${switchTrackClass()}></span>
42
+ *
22
43
  * <!-- Small size. -->
23
- * <input type="checkbox" role="switch" name="x" class=${cn(switchInputClass(), 'peer/sm')}>
44
+ * <input type="checkbox" role="switch" name="x" aria-label="Compact mode"
45
+ * class=${cn(switchInputClass(), 'peer/sm')}>
24
46
  * <span class=${switchTrackClass({ size: 'sm' })}></span>
25
47
  * ```
26
48
  */
@@ -30,6 +30,21 @@
30
30
  * Home / End first / last
31
31
  * Enter / Space activate (native button activation)
32
32
  *
33
+ * A11y (owned by the element, nothing to supply beyond trigger names):
34
+ * The element wires the whole APG Tabs pattern: the list is a
35
+ * `role="tablist"` reporting `aria-orientation`, each trigger is a
36
+ * `role="tab"` cross-linked to its panel with `aria-controls` while the panel
37
+ * points back with `aria-labelledby`, ids stay unique across groups on one
38
+ * page, and an inactive panel is both `hidden` and `inert`. Arrow / Home / End
39
+ * move focus AND selection (APG automatic activation) via a roving tabindex,
40
+ * so the tablist is a single Tab stop and nav stays inside its own group when
41
+ * tabs are nested. Do not hand-add any of these; the element already has.
42
+ * What you DO supply: a name for each trigger. A trigger's name comes from its
43
+ * own content, so an icon-only tab needs an `aria-label` on the
44
+ * `<ui-tabs-trigger>` and an `aria-hidden="true"` icon inside it.
45
+ * Keep the panel's content after the tablist in the DOM, which the example
46
+ * shape already does, so reading order follows the tab that was activated.
47
+ *
33
48
  * Design tokens used: --muted, --muted-foreground, --foreground, --background,
34
49
  * --input, --ring.
35
50
  *
@@ -12,10 +12,29 @@
12
12
  * Design tokens used: --input, --background, --muted-foreground, --ring,
13
13
  * --destructive.
14
14
  *
15
+ * A11y (required for accessible output):
16
+ * LABEL IT. A `<label class=${labelClass()} for="<the textarea's id">` linked
17
+ * by the `for` / `id` pair is the whole requirement. The `placeholder` in the
18
+ * old example was doing that job and cannot: it vanishes as soon as the user
19
+ * types, and it is not reliably exposed as a name.
20
+ * Point `aria-describedby` at a hint or a character counter that EXISTS on the
21
+ * page, and on a validation failure set `aria-invalid="true"` plus an
22
+ * `aria-describedby` error element so the reason is announced with the field.
23
+ * If you show a live character count, put it in an `aria-live="polite"`
24
+ * region, otherwise the number updates silently for a screen reader user.
25
+ * Leave the textarea RESIZABLE. The class autosizes via `field-sizing:
26
+ * content`, which is fine, but do not add `resize-none` on top: a user who
27
+ * needs more room to read their own text has no other way to get it (WCAG
28
+ * 1.4.4-adjacent).
29
+ *
15
30
  * @example
16
31
  * ```html
17
- * <textarea class=${textareaClass()} name="message" rows="4" placeholder="Your message">
18
- * </textarea>
32
+ * <div class=${fieldClass()}>
33
+ * <label class=${labelClass()} for="message">Message</label>
34
+ * <textarea class=${textareaClass()} name="message" id="message" rows="4"
35
+ * aria-describedby="message-hint"></textarea>
36
+ * <p class=${hintClass()} id="message-hint">Markdown is supported.</p>
37
+ * </div>
19
38
  * ```
20
39
  */
21
40
  import { cn } from '../lib/utils.ts';
@@ -22,15 +22,30 @@
22
22
  * `orientation`: "horizontal" (default) | "vertical".
23
23
  *
24
24
  * Attributes on <ui-toggle-group-item>:
25
- * `value`: string. Identifier this item contributes when selected.
26
- * `pressed`: boolean (reflected). Mirrors the group's selection for this item.
25
+ * `value`: string. Identifier this item contributes when selected.
26
+ * `pressed`: boolean (reflected). Mirrors the group's selection for this item.
27
+ * `disabled`: boolean (reflected). Refuses click + Enter / Space, is skipped by
28
+ * Arrow / Home / End, and never holds the group's tab stop.
27
29
  *
28
30
  * Events:
29
31
  * `ui-value-change` on <ui-toggle-group>: `{ detail: { value } }` after selection changes.
30
32
  *
31
33
  * Keyboard: Arrow keys move focus between items (roving tabindex, so the
32
34
  * group is a single Tab stop), Home / End jump to the first / last item, and
33
- * Enter / Space toggles the focused item.
35
+ * Enter / Space toggles the focused item. A `disabled` item is skipped by all
36
+ * of these.
37
+ *
38
+ * A11y (owned by the element, nothing to supply beyond item names):
39
+ * The group is a `role="group"` whose items carry `aria-pressed`, navigated by
40
+ * a roving tabindex so the whole group is one Tab stop. A `disabled` item
41
+ * reports `aria-disabled` and is skipped by navigation: since the group is a
42
+ * single tab stop, focus landing on a disabled item could not be tabbed past,
43
+ * so skipping it is what keeps the group usable rather than a nicety.
44
+ * Note `disabled` on the item is deliberately exposed as `aria-disabled`, not
45
+ * a `disabled` attribute. The item's host IS the button and it is a custom
46
+ * element, so a `disabled` attribute there is inert: no `:disabled` CSS, no
47
+ * click suppression, no removal from the tab order.
48
+ * What you DO supply: an `aria-label` on any icon-only item, as below.
34
49
  *
35
50
  * Design tokens used: inherited from toggleClass (--muted, --accent, --ring,
36
51
  * --input, --destructive).
@@ -48,6 +63,13 @@
48
63
  * <ui-toggle-group-item value="bold">B</ui-toggle-group-item>
49
64
  * <ui-toggle-group-item value="italic">I</ui-toggle-group-item>
50
65
  * </ui-toggle-group>
66
+ *
67
+ * <!-- A disabled item: unclickable, and skipped by Arrow / Home / End. -->
68
+ * <ui-toggle-group type="single" value="left">
69
+ * <ui-toggle-group-item value="left" aria-label="Align left">L</ui-toggle-group-item>
70
+ * <ui-toggle-group-item value="justify" aria-label="Justify" disabled>J</ui-toggle-group-item>
71
+ * <ui-toggle-group-item value="right" aria-label="Align right">R</ui-toggle-group-item>
72
+ * </ui-toggle-group>
51
73
  * ```
52
74
  */
53
75
  import { WebComponent, html, prop } from '@webjsdev/core';
@@ -57,8 +79,13 @@ import { toggleClass, type ToggleVariant, type ToggleSize } from './toggle.ts';
57
79
  const ROOT_BASE =
58
80
  'group/toggle-group flex w-fit items-center rounded-md data-[spacing=default]:data-[variant=outline]:shadow-xs data-[orientation=vertical]:flex-col data-[orientation=vertical]:items-stretch';
59
81
 
82
+ // The `disabled:` variants inside toggleClass() key on the :disabled pseudo,
83
+ // which only ever matches a real form control. An item's host IS the button
84
+ // here (a custom element), so `disabled` on it is inert as far as CSS and the
85
+ // browser are concerned, and the disabled look has to come from the
86
+ // aria-disabled variant instead.
60
87
  const ITEM_EXTRA =
61
- 'w-auto min-w-0 shrink-0 px-3 focus:z-10 focus-visible:z-10 data-[spacing=0]:rounded-none data-[spacing=0]:shadow-none data-[spacing=0]:first:rounded-l-md data-[spacing=0]:last:rounded-r-md data-[spacing=0]:data-[variant=outline]:border-l-0 data-[spacing=0]:data-[variant=outline]:first:border-l';
88
+ 'w-auto min-w-0 shrink-0 px-3 focus:z-10 focus-visible:z-10 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[spacing=0]:rounded-none data-[spacing=0]:shadow-none data-[spacing=0]:first:rounded-l-md data-[spacing=0]:last:rounded-r-md data-[spacing=0]:data-[variant=outline]:border-l-0 data-[spacing=0]:data-[variant=outline]:first:border-l';
62
89
 
63
90
  // --------------------------------------------------------------------------
64
91
  // <ui-toggle-group>
@@ -117,6 +144,14 @@ export class UiToggleGroup extends WebComponent({
117
144
  return Array.from(this.querySelectorAll<UiToggleGroupItem>('ui-toggle-group-item'));
118
145
  }
119
146
 
147
+ // Arrow / Home / End navigate among ENABLED items only. Landing focus on a
148
+ // disabled item would strand the user on a control that refuses every
149
+ // interaction, and since this group is a single tab stop they cannot simply
150
+ // Tab past it.
151
+ _enabledItems(): UiToggleGroupItem[] {
152
+ return this._items().filter((i) => !i.disabled);
153
+ }
154
+
120
155
  _reflectItems(): void {
121
156
  const values = this._values;
122
157
  const items = this._items();
@@ -138,18 +173,29 @@ export class UiToggleGroup extends WebComponent({
138
173
  // single tabbable slot via `focusItem`.
139
174
  _roving(items: UiToggleGroupItem[], current?: UiToggleGroupItem): void {
140
175
  if (!items.length) return;
176
+ // The tab stop must land on an ENABLED item: promoting a disabled one makes
177
+ // the group's single Tab stop a dead end.
178
+ const enabled = items.filter((i) => !i.disabled);
179
+ if (!enabled.length) {
180
+ items.forEach((item) => {
181
+ item.tabIndex = -1;
182
+ });
183
+ return;
184
+ }
141
185
  const values = this._values;
142
186
  const active =
143
187
  typeof document !== 'undefined' ? (document.activeElement as Element | null) : null;
144
- const focused = active ? items.find((i) => i === active) : undefined;
145
- const selected = items.find((i) => !!i.value && values.has(i.value));
146
- const tabbable = focused ?? current ?? selected ?? items[0];
188
+ const focused = active ? enabled.find((i) => i === active) : undefined;
189
+ const selected = enabled.find((i) => !!i.value && values.has(i.value));
190
+ const held = current && !current.disabled ? current : undefined;
191
+ const tabbable = focused ?? held ?? selected ?? enabled[0];
147
192
  items.forEach((item) => {
148
193
  item.tabIndex = item === tabbable ? 0 : -1;
149
194
  });
150
195
  }
151
196
 
152
197
  focusItem(item: UiToggleGroupItem): void {
198
+ if (item.disabled) return;
153
199
  this._items().forEach((el) => {
154
200
  el.tabIndex = el === item ? 0 : -1;
155
201
  });
@@ -189,11 +235,13 @@ UiToggleGroup.register('ui-toggle-group');
189
235
  export class UiToggleGroupItem extends WebComponent({
190
236
  value: prop(String, { reflect: true }),
191
237
  pressed: prop(Boolean, { reflect: true }),
238
+ disabled: prop(Boolean, { reflect: true }),
192
239
  }) {
193
240
  constructor() {
194
241
  super();
195
242
  this.value = '';
196
243
  this.pressed = false;
244
+ this.disabled = false;
197
245
  }
198
246
 
199
247
  // render() runs server-side too. webjs resolves closest() at SSR against
@@ -240,12 +288,36 @@ export class UiToggleGroupItem extends WebComponent({
240
288
  this.dataset.size = size;
241
289
  this.dataset.spacing = spacing;
242
290
  this.dataset.state = this.pressed ? 'on' : 'off';
291
+ // role and data-slot are set HERE as well as in connectedCallback, because
292
+ // SSR runs render() but NOT connectedCallback. Without this the served item
293
+ // carried aria-pressed / aria-disabled with no role at all, and neither is a
294
+ // global ARIA attribute, so the first paint shipped attributes that are not
295
+ // allowed on a generic-role element. (`this.role` is shimmed server-side too,
296
+ // like the ariaPressed / dataset / className writes around it; setAttribute
297
+ // is simply the more explicit spelling.)
298
+ this.setAttribute('role', 'button');
299
+ this.setAttribute('data-slot', 'toggle-group-item');
243
300
  this.ariaPressed = String(this.pressed);
301
+ // aria-disabled rather than a `disabled` attribute: the host is a custom
302
+ // element, so `disabled` would be inert (no CSS pseudo, no click blocking,
303
+ // no removal from the tab order). aria-disabled is what actually reaches
304
+ // assistive tech, and the group skips these in its roving tabindex.
305
+ this.ariaDisabled = String(this.disabled);
244
306
  this.className = cn(toggleClass({ variant, size }), ITEM_EXTRA);
245
307
  return html`<slot></slot>`;
246
308
  }
247
309
 
310
+ // Disabling the item that happens to hold the group's only tab stop would
311
+ // leave the group unreachable by keyboard, and the group cannot see an item's
312
+ // prop change on its own, so ask it to recompute which item is tabbable.
313
+ updated(changedProperties: Map<string, unknown>): void {
314
+ if (!changedProperties.has('disabled')) return;
315
+ if (changedProperties.get('disabled') === undefined) return;
316
+ queueMicrotask(() => this._group?._reflectItems());
317
+ }
318
+
248
319
  _onClick = (): void => {
320
+ if (this.disabled) return;
249
321
  if (!this.value) return;
250
322
  this.dispatchEvent(
251
323
  new CustomEvent('ui-toggle-item-click', { detail: { value: this.value }, bubbles: true }),
@@ -253,6 +325,7 @@ export class UiToggleGroupItem extends WebComponent({
253
325
  };
254
326
 
255
327
  _onKeyDown = (e: KeyboardEvent): void => {
328
+ if (this.disabled) return;
256
329
  if (e.key === ' ' || e.key === 'Enter') {
257
330
  e.preventDefault();
258
331
  this._onClick();
@@ -263,7 +336,7 @@ export class UiToggleGroupItem extends WebComponent({
263
336
  const horizontal = group.orientation !== 'vertical';
264
337
  const nextKey = horizontal ? 'ArrowRight' : 'ArrowDown';
265
338
  const prevKey = horizontal ? 'ArrowLeft' : 'ArrowUp';
266
- const items = group._items();
339
+ const items = group._enabledItems();
267
340
  const idx = items.indexOf(this);
268
341
  if (idx === -1) return;
269
342
  let target: UiToggleGroupItem | null = null;
@@ -21,6 +21,32 @@
21
21
  *
22
22
  * Keyboard: native button. Enter / Space activates (via the inner <button>).
23
23
  *
24
+ * A11y (required for accessible output):
25
+ * `<ui-toggle>` renders a native `<button>` inside itself, and that button is
26
+ * the focusable control whose accessible name a screen reader announces. An
27
+ * `aria-label` on the host would not reach it (a name on a generic-role
28
+ * element does not contribute to a descendant's name), so the element
29
+ * FORWARDS the host's `aria-label` / `aria-labelledby` onto the inner button.
30
+ * Put the name on the host, as the icon-only example below does, and the
31
+ * element wires the rest. An icon-only toggle needs one; a text toggle takes
32
+ * its name from the slotted text and needs nothing.
33
+ * Set the name as an ATTRIBUTE on the host in your markup
34
+ * (`aria-label="Toggle bold"`), not as a property.
35
+ * LIMITATION, and it is a real one: `aria-label` / `aria-labelledby` are not
36
+ * reactive properties of this element, so they are not observed attributes.
37
+ * The name is read during render(), which means markup present at first paint
38
+ * works, but a LATER `el.setAttribute('aria-label', ...)` does not re-render
39
+ * and so does not reach the inner button until something else happens to
40
+ * trigger a render. If your name is computed after mount, set it on the inner
41
+ * button yourself (`el.querySelector('button').setAttribute('aria-label',
42
+ * name)`). Do NOT reach for "flip a reactive prop to force a re-render":
43
+ * adding or removing the name switches which of the three templates below is
44
+ * used, and a template swap REBUILDS the button, so doing it on a click would
45
+ * destroy the control the user is focused on and drop focus to <body>.
46
+ * Using the Tier-1 `toggleClass()` helper on your own `<button>` instead? Then
47
+ * the name is entirely yours: give an icon-only button an `aria-label`, and
48
+ * carry the pressed state on `aria-pressed` + `data-state` as the example does.
49
+ *
24
50
  * Design tokens used: --muted, --muted-foreground, --accent, --accent-foreground,
25
51
  * --input, --background, --ring, --destructive.
26
52
  *
@@ -93,13 +119,66 @@ export class UiToggle extends WebComponent({
93
119
  this.disabled = false;
94
120
  }
95
121
 
122
+ // Read a host attribute defensively. render() runs server-side too, where
123
+ // webjs shims the attribute methods; the typeof guard stays for any other
124
+ // renderer that does not.
125
+ _hostAttr(name: string): string | null {
126
+ if (typeof this.getAttribute !== 'function') return null;
127
+ return this.getAttribute(name);
128
+ }
129
+
96
130
  render() {
131
+ // The focusable control is the inner <button>, so its accessible name is
132
+ // what a screen reader announces. `aria-label` on the host cannot supply
133
+ // that (the host has a generic role, and a name there does not contribute
134
+ // to a descendant's name), and the documented icon-only shape slots an
135
+ // aria-hidden SVG, which contributes nothing either. Forward the host's
136
+ // name source onto the button so the documented shape ships named.
137
+ //
138
+ // The name is BRANCHED into three templates rather than carried in one
139
+ // hole, because a null hole does NOT omit an attribute on the server: the
140
+ // server renderer stringifies the value, so `aria-label=${null}` ships
141
+ // `aria-label=""` while the client renderer removes it. On an unlabelled
142
+ // toggle that would put an empty name on the very control whose name is
143
+ // supposed to come from its slotted text, and disagree with the hydrated
144
+ // DOM on the exact attribute this forwarding exists to get right.
145
+ const labelledBy = this._hostAttr('aria-labelledby');
146
+ const label = this._hostAttr('aria-label');
147
+ const cls = toggleClass({ variant: this.variant, size: this.size });
148
+ const pressed = String(this.pressed);
149
+ const state = this.pressed ? 'on' : 'off';
150
+ // aria-labelledby beats aria-label per accname, so it is checked first and
151
+ // only ONE of the two is ever emitted.
152
+ if (labelledBy) {
153
+ return html`<button
154
+ type="button"
155
+ data-slot="toggle"
156
+ class=${cls}
157
+ aria-pressed=${pressed}
158
+ aria-labelledby=${labelledBy}
159
+ data-state=${state}
160
+ ?disabled=${this.disabled}
161
+ @click=${this._onClick}
162
+ ><slot></slot></button>`;
163
+ }
164
+ if (label) {
165
+ return html`<button
166
+ type="button"
167
+ data-slot="toggle"
168
+ class=${cls}
169
+ aria-pressed=${pressed}
170
+ aria-label=${label}
171
+ data-state=${state}
172
+ ?disabled=${this.disabled}
173
+ @click=${this._onClick}
174
+ ><slot></slot></button>`;
175
+ }
97
176
  return html`<button
98
177
  type="button"
99
178
  data-slot="toggle"
100
- class=${toggleClass({ variant: this.variant, size: this.size })}
101
- aria-pressed=${String(this.pressed)}
102
- data-state=${this.pressed ? 'on' : 'off'}
179
+ class=${cls}
180
+ aria-pressed=${pressed}
181
+ data-state=${state}
103
182
  ?disabled=${this.disabled}
104
183
  @click=${this._onClick}
105
184
  ><slot></slot></button>`;
@@ -30,6 +30,20 @@
30
30
  *
31
31
  * Programmatic API on <ui-tooltip>: `.show()` · `.hide()`.
32
32
  *
33
+ * Keyboard:
34
+ * Escape dismiss a showing tip, leaving focus on the trigger
35
+ *
36
+ * A11y (owned by the element, nothing to supply):
37
+ * The focusable trigger references the tip via `aria-describedby`, so a
38
+ * screen reader appends the tip to the trigger's own name, and the tip opens
39
+ * on focus as well as hover. Escape dismisses a showing tip without moving
40
+ * focus (APG requires the escape hatch, since a tip can cover the content
41
+ * underneath it). A CLOSED tooltip never consumes Escape, so it cannot
42
+ * swallow the key from a dialog it happens to sit inside.
43
+ * What you DO supply: an accessible name on the trigger control itself. The
44
+ * tip is a DESCRIPTION, not a name, so an icon-only trigger still needs its
45
+ * own `aria-label` as the example below shows.
46
+ *
33
47
  * Design tokens used: --foreground, --background.
34
48
  *
35
49
  * @example
@@ -75,6 +89,8 @@ export class UiTooltip extends WebComponent({
75
89
  _showTimer: number | undefined;
76
90
  _hideTimer: number | undefined;
77
91
 
92
+ _keyHandler = (e: KeyboardEvent): void => this._onKeyDown(e);
93
+
78
94
  constructor() {
79
95
  super();
80
96
  this.open = false;
@@ -98,10 +114,43 @@ export class UiTooltip extends WebComponent({
98
114
  }
99
115
 
100
116
  disconnectedCallback(): void {
117
+ this._unbindEscape();
101
118
  this._disposeBeforeCache?.();
102
119
  super.disconnectedCallback?.();
103
120
  }
104
121
 
122
+ // APG Tooltip requires an Escape escape-hatch: a tip can cover the content
123
+ // underneath it, and a keyboard user who cannot dismiss it is stuck. Focus
124
+ // is deliberately untouched, since the tip is not focusable and the trigger
125
+ // should keep focus (moving it would lose the user's place).
126
+ //
127
+ // The listener is bound only WHILE OPEN, which is how a closed tooltip is
128
+ // kept from consuming Escape that a dialog (or anything else) wants.
129
+ _onKeyDown(e: KeyboardEvent): void {
130
+ if (e.key !== 'Escape' || !this.open) return;
131
+ e.preventDefault();
132
+ this._dismiss();
133
+ }
134
+
135
+ // Immediate, unlike hide()'s 100ms hover-out grace: a dismissal is a
136
+ // deliberate act, so it should not sit around waiting to be cancelled.
137
+ _dismiss(): void {
138
+ clearTimeout(this._showTimer);
139
+ clearTimeout(this._hideTimer);
140
+ this.open = false;
141
+ lastTooltipHideAt = Date.now();
142
+ }
143
+
144
+ _bindEscape(): void {
145
+ if (typeof document === 'undefined') return;
146
+ document.addEventListener('keydown', this._keyHandler);
147
+ }
148
+
149
+ _unbindEscape(): void {
150
+ if (typeof document === 'undefined') return;
151
+ document.removeEventListener('keydown', this._keyHandler);
152
+ }
153
+
105
154
  // APG tooltip wiring: the focusable trigger references the tip via
106
155
  // aria-describedby, so a screen reader appends the tip text to the
107
156
  // trigger's own name ("Help, button, Helpful tip"). The tip is JS-driven,
@@ -140,6 +189,10 @@ export class UiTooltip extends WebComponent({
140
189
 
141
190
  hide(): void {
142
191
  clearTimeout(this._showTimer);
192
+ // Clear the PENDING hide before scheduling the next one: overwriting the
193
+ // handle alone orphans the old timer, which then fires on its own and
194
+ // closes a tip a later show() had already reopened.
195
+ clearTimeout(this._hideTimer);
143
196
  this._hideTimer = window.setTimeout(() => {
144
197
  this.open = false;
145
198
  lastTooltipHideAt = Date.now();
@@ -157,6 +210,10 @@ export class UiTooltip extends WebComponent({
157
210
  if (!changedProperties.has('open')) return;
158
211
  // Skip the constructor's initial open=false set.
159
212
  if (changedProperties.get('open') === undefined) return;
213
+ // Bind synchronously rather than inside _syncContent, which bails early on
214
+ // engines with no Popover API: Escape has to work regardless of that.
215
+ if (this.open) this._bindEscape();
216
+ else this._unbindEscape();
160
217
  // Defer one microtask so the content child's [popover] inner element
161
218
  // has committed; we drive its showPopover() / hidePopover() from here.
162
219
  queueMicrotask(() => this._syncContent());