@webjsdev/ui 0.3.5 → 0.3.6

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.
package/README.md CHANGED
@@ -30,14 +30,32 @@ is configured, the components render correctly. Variant names, sizes, and
30
30
  data-attribute conventions mirror shadcn's so an AI agent's existing
31
31
  knowledge of shadcn maps directly.
32
32
 
33
- Tier-2 elements extend `WebComponent` from `@webjsdev/core`, a tiny
34
- Lit-shaped base class with `static properties` for reactive attributes,
33
+ Tier-2 elements extend the `WebComponent({ ... })` factory from
34
+ `@webjsdev/core`, a tiny Lit-shaped base class whose factory shape declares
35
+ reactive attributes,
35
36
  `render()` returning an `` html`...` `` template, and declarative
36
37
  bindings (`@click`, `?attr`, `attr=`). Light DOM throughout, so Tailwind
37
38
  utility classes on authored children apply directly. The `webjsui add`
38
39
  CLI installs `@webjsdev/core` automatically when you add a Tier-2
39
40
  component.
40
41
 
42
+ ## Accessibility
43
+
44
+ Tier-2 elements are accessible out of the box: they wire their own WAI-ARIA
45
+ pattern, so you do not hand-add ARIA. Tabs cross-links triggers and panels and
46
+ reports orientation, toggle-group uses roving tabindex with Arrow / Home / End,
47
+ dropdown-menu declares orientation and reflects `aria-disabled`, dialog and
48
+ alert-dialog name themselves from their title and description, tooltip wires
49
+ `aria-describedby`, hover-card exposes `aria-haspopup` / `aria-expanded`, and
50
+ sonner is a live region.
51
+
52
+ Tier-1 class helpers return only classes, so the semantic element and ARIA are
53
+ yours to supply. Each one's JSDoc carries an `A11y (required for accessible
54
+ output)` block stating exactly what to add: a name on an icon-only button, a
55
+ role on an alert, `scope` on table headers, `alt` on an avatar image, a
56
+ labelled `<nav>` with `aria-current="page"` on pagination and breadcrumb, and
57
+ so on. Follow that block and the markup is fully accessible.
58
+
41
59
  ## Install
42
60
 
43
61
  ### Option A : Webjs users (already have `@webjsdev/cli`)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/ui",
3
- "version": "0.3.5",
3
+ "version": "0.3.6",
4
4
  "type": "module",
5
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.",
6
6
  "bin": {
@@ -32,8 +32,8 @@
32
32
  * </ui-alert-dialog-trigger>
33
33
  * <ui-alert-dialog-content>
34
34
  * <div class=${alertDialogHeaderClass()}>
35
- * <h2 class=${alertDialogTitleClass()}>Delete account?</h2>
36
- * <p class=${alertDialogDescriptionClass()}>This cannot be undone.</p>
35
+ * <h2 data-slot="alert-dialog-title" class=${alertDialogTitleClass()}>Delete account?</h2>
36
+ * <p data-slot="alert-dialog-description" class=${alertDialogDescriptionClass()}>This cannot be undone.</p>
37
37
  * </div>
38
38
  * <div class=${alertDialogFooterClass()}>
39
39
  * <ui-alert-dialog-cancel>Cancel</ui-alert-dialog-cancel>
@@ -63,8 +63,9 @@
63
63
  *
64
64
  * Design tokens used: --background, --border, --muted-foreground.
65
65
  */
66
- import { WebComponent, html } from '@webjsdev/core';
66
+ import { WebComponent, html, prop } from '@webjsdev/core';
67
67
  import { ref, createRef } from '@webjsdev/core/directives';
68
+ import { ensureId } from '../lib/utils.ts';
68
69
  import { buttonClass, type ButtonVariant, type ButtonSize } from './button.ts';
69
70
 
70
71
  export const alertDialogContentClass = (): string =>
@@ -131,12 +132,9 @@ function unlockScroll(): void {
131
132
  // content child via prop transitions.
132
133
  // --------------------------------------------------------------------------
133
134
 
134
- export class UiAlertDialog extends WebComponent {
135
- static properties = {
136
- open: { type: Boolean, reflect: true },
137
- };
138
- declare open: boolean;
139
-
135
+ export class UiAlertDialog extends WebComponent({
136
+ open: prop(Boolean, { reflect: true }),
137
+ }) {
140
138
  constructor() {
141
139
  super();
142
140
  this.open = false;
@@ -223,12 +221,9 @@ UiAlertDialogTrigger.register('ui-alert-dialog-trigger');
223
221
  // explicit choice via Cancel / Action).
224
222
  // --------------------------------------------------------------------------
225
223
 
226
- export class UiAlertDialogContent extends WebComponent {
227
- static properties = {
228
- size: { type: String, reflect: true },
229
- };
230
- declare size: 'default' | 'sm';
231
-
224
+ export class UiAlertDialogContent extends WebComponent({
225
+ size: prop<'default' | 'sm'>(String, { reflect: true }),
226
+ }) {
232
227
  // ref to the own-rendered native <dialog>. render() creates it, so a
233
228
  // ref() binding is the lit-idiomatic handle (no querySelector against
234
229
  // a string selector into the component's own output).
@@ -240,10 +235,33 @@ export class UiAlertDialogContent extends WebComponent {
240
235
  }
241
236
 
242
237
  showModal(): void {
238
+ this._wireLabels();
243
239
  const native = this.#dialog.value;
244
240
  if (native && !native.open) native.showModal();
245
241
  }
246
242
 
243
+ // Wire the alertdialog's accessible name + description to its title /
244
+ // description nodes at open time (an alert dialog only ever appears via
245
+ // showModal(), so there is no SSR id-stability concern). The title is
246
+ // data-slot="alert-dialog-title" (falling back to the first heading); the
247
+ // description is data-slot="alert-dialog-description" (falling back to the
248
+ // first paragraph). Author-set ARIA always wins. Inlined rather than shared
249
+ // with dialog.ts so `webjs ui add alert-dialog` stays self-contained.
250
+ _wireLabels(): void {
251
+ const panel = this.querySelector('[data-slot="alert-dialog-content"]');
252
+ if (!panel) return;
253
+ const title =
254
+ this.querySelector('[data-slot="alert-dialog-title"]') ?? this.querySelector('h1, h2, h3');
255
+ const desc =
256
+ this.querySelector('[data-slot="alert-dialog-description"]') ?? this.querySelector('p');
257
+ if (title && !panel.hasAttribute('aria-labelledby')) {
258
+ panel.setAttribute('aria-labelledby', ensureId(title as HTMLElement, 'ui-alert-title'));
259
+ }
260
+ if (desc && !panel.hasAttribute('aria-describedby')) {
261
+ panel.setAttribute('aria-describedby', ensureId(desc as HTMLElement, 'ui-alert-desc'));
262
+ }
263
+ }
264
+
247
265
  close(): void {
248
266
  const native = this.#dialog.value;
249
267
  if (native?.open) native.close();
@@ -311,14 +329,10 @@ UiAlertDialogContent.register('ui-alert-dialog-content');
311
329
 
312
330
  const ALERT_DIALOG_ACTION_GRID_STRETCH = 'group-data-[size=sm]/alert-dialog-content:w-full';
313
331
 
314
- export class UiAlertDialogCancel extends WebComponent {
315
- static properties = {
316
- variant: { type: String, reflect: true },
317
- size: { type: String, reflect: true },
318
- };
319
- declare variant: ButtonVariant;
320
- declare size: ButtonSize;
321
-
332
+ export class UiAlertDialogCancel extends WebComponent({
333
+ variant: prop<ButtonVariant>(String, { reflect: true }),
334
+ size: prop<ButtonSize>(String, { reflect: true }),
335
+ }) {
322
336
  constructor() {
323
337
  super();
324
338
  this.variant = 'outline';
@@ -338,14 +352,10 @@ export class UiAlertDialogCancel extends WebComponent {
338
352
  }
339
353
  UiAlertDialogCancel.register('ui-alert-dialog-cancel');
340
354
 
341
- export class UiAlertDialogAction extends WebComponent {
342
- static properties = {
343
- variant: { type: String, reflect: true },
344
- size: { type: String, reflect: true },
345
- };
346
- declare variant: ButtonVariant;
347
- declare size: ButtonSize;
348
-
355
+ export class UiAlertDialogAction extends WebComponent({
356
+ variant: prop<ButtonVariant>(String, { reflect: true }),
357
+ size: prop<ButtonSize>(String, { reflect: true }),
358
+ }) {
349
359
  constructor() {
350
360
  super();
351
361
  this.variant = 'default';
@@ -25,6 +25,11 @@
25
25
  * </div>
26
26
  * </div>
27
27
  *
28
+ * A11y (required for accessible output): put role="alert" on the container
29
+ * for an urgent, interrupting message, or role="status" for a polite,
30
+ * non-urgent update. The class helper sets no role, so without one the
31
+ * banner is silent to assistive tech.
32
+ *
28
33
  * Design tokens used: --card, --card-foreground, --destructive, --muted-foreground.
29
34
  */
30
35
  import { cn } from '../lib/utils.ts';
@@ -23,6 +23,11 @@
23
23
  * <div class=${avatarGroupCountClass()}>+3</div>
24
24
  * </div>
25
25
  *
26
+ * A11y (required for accessible output): the <img> MUST have an alt that
27
+ * names the person (alt="Vivek Khandelwal"), or alt="" when a visible text
28
+ * fallback already names them. Always provide the fallback <span> so the
29
+ * avatar is still named if the image fails to load.
30
+ *
26
31
  * Design tokens used: --muted, --muted-foreground, --primary, --background.
27
32
  */
28
33
  import { cn } from '../lib/utils.ts';
@@ -14,6 +14,10 @@
14
14
  * The `[a&]:hover:...` hover styles only apply when the element is an `<a>`,
15
15
  * so a static `<span>` doesn't pick up an unwanted hover.
16
16
  *
17
+ * A11y (required for accessible output): render a static badge as a plain
18
+ * <span> (not focusable, no tabindex). Only an interactive badge (an <a>
19
+ * or <button>) is focusable, and an icon-only one needs an aria-label.
20
+ *
17
21
  * Design tokens used: --primary, --secondary, --destructive, --foreground,
18
22
  * --accent, --border, --ring.
19
23
  */
@@ -26,6 +26,11 @@
26
26
  * </ol>
27
27
  * </nav>
28
28
  *
29
+ * A11y (required for accessible output): wrap the list in <nav
30
+ * aria-label="breadcrumb">, set aria-current="page" on the current-page
31
+ * element, and mark each separator role="presentation" aria-hidden="true".
32
+ * The class helpers emit none of these.
33
+ *
29
34
  * Design tokens used: --muted-foreground, --foreground.
30
35
  */
31
36
 
@@ -17,6 +17,12 @@
17
17
  * shadcn React's `asChild` (Slot) prop has no equivalent here: just call
18
18
  * `buttonClass(...)` and spread the classes onto whatever element you want.
19
19
  *
20
+ * A11y (required for accessible output): an icon-only button (the `icon`,
21
+ * `icon-xs`, `icon-sm`, `icon-lg` sizes) has no visible text, so it MUST
22
+ * carry an accessible name via aria-label (or aria-labelledby). A button
23
+ * that opens an overlay should also set aria-haspopup and aria-expanded.
24
+ * Native <button> focus and keyboard activation are already correct.
25
+ *
20
26
  * Design tokens used: --primary, --primary-foreground, --destructive,
21
27
  * --secondary, --secondary-foreground, --accent, --accent-foreground,
22
28
  * --background, --input, --ring.
@@ -29,8 +29,8 @@
29
29
  * </ui-dialog-trigger>
30
30
  * <ui-dialog-content>
31
31
  * <div class=${dialogHeaderClass()}>
32
- * <h2 class=${dialogTitleClass()}>Edit profile</h2>
33
- * <p class=${dialogDescriptionClass()}>Make changes and click save.</p>
32
+ * <h2 data-slot="dialog-title" class=${dialogTitleClass()}>Edit profile</h2>
33
+ * <p data-slot="dialog-description" class=${dialogDescriptionClass()}>Make changes and click save.</p>
34
34
  * </div>
35
35
  * <div class="grid gap-3">
36
36
  * <label class=${labelClass()} for="dlg-name">Name</label>
@@ -63,10 +63,33 @@
63
63
  *
64
64
  * Design tokens used: --background, --border, --muted-foreground.
65
65
  */
66
- import { WebComponent, html, unsafeHTML } from '@webjsdev/core';
66
+ import { WebComponent, html, unsafeHTML, prop } from '@webjsdev/core';
67
67
  import { ref, createRef } from '@webjsdev/core/directives';
68
+ import { ensureId } from '../lib/utils.ts';
68
69
  import { buttonClass } from './button.ts';
69
70
 
71
+ // Wires a dialog panel's accessible name + description to its title /
72
+ // description nodes. A dialog only ever appears via showModal() (JS), so
73
+ // resolving the relationship at open time is correct and avoids any
74
+ // SSR id-stability concern. The title is the element marked
75
+ // data-slot="dialog-title" (falling back to the first heading); the
76
+ // description is data-slot="dialog-description" (falling back to the first
77
+ // paragraph). Author-set aria-labelledby / aria-describedby always win.
78
+ export function wireDialogLabels(host: Element, panelSelector: string): void {
79
+ const panel = host.querySelector(panelSelector);
80
+ if (!panel) return;
81
+ const title =
82
+ host.querySelector('[data-slot="dialog-title"]') ?? host.querySelector('h1, h2, h3');
83
+ const desc =
84
+ host.querySelector('[data-slot="dialog-description"]') ?? host.querySelector('p');
85
+ if (title && !panel.hasAttribute('aria-labelledby')) {
86
+ panel.setAttribute('aria-labelledby', ensureId(title as HTMLElement, 'ui-dialog-title'));
87
+ }
88
+ if (desc && !panel.hasAttribute('aria-describedby')) {
89
+ panel.setAttribute('aria-describedby', ensureId(desc as HTMLElement, 'ui-dialog-desc'));
90
+ }
91
+ }
92
+
70
93
  // --------------------------------------------------------------------------
71
94
  // Class helpers for subparts.
72
95
  // --------------------------------------------------------------------------
@@ -157,12 +180,9 @@ function unlockScroll(): void {
157
180
  // to showModal() / close() its inner <dialog>.
158
181
  // --------------------------------------------------------------------------
159
182
 
160
- export class UiDialog extends WebComponent {
161
- static properties = {
162
- open: { type: Boolean, reflect: true },
163
- };
164
- declare open: boolean;
165
-
183
+ export class UiDialog extends WebComponent({
184
+ open: prop(Boolean, { reflect: true }),
185
+ }) {
166
186
  constructor() {
167
187
  super();
168
188
  this.open = false;
@@ -257,12 +277,9 @@ UiDialogTrigger.register('ui-dialog-trigger');
257
277
  // close button. Exposes showModal() / close() so the parent <ui-dialog>
258
278
  // can drive the open state imperatively without a named slot.
259
279
 
260
- export class UiDialogContent extends WebComponent {
261
- static properties = {
262
- showCloseButton: { type: String, reflect: true, attribute: 'show-close-button' },
263
- };
264
- declare showCloseButton: string;
265
-
280
+ export class UiDialogContent extends WebComponent({
281
+ showCloseButton: prop(String, { reflect: true, attribute: 'show-close-button' }),
282
+ }) {
266
283
  // ref to the own-rendered native <dialog>. render() creates it, so a
267
284
  // ref() binding is the lit-idiomatic handle (no querySelector against
268
285
  // a string selector into the component's own output).
@@ -274,6 +291,7 @@ export class UiDialogContent extends WebComponent {
274
291
  }
275
292
 
276
293
  showModal(): void {
294
+ wireDialogLabels(this, '[data-slot="dialog-content"]');
277
295
  const native = this.#dialog.value;
278
296
  if (native && !native.open) native.showModal();
279
297
  }
@@ -359,12 +377,9 @@ UiDialogClose.register('ui-dialog-close');
359
377
  // <ui-dialog-footer>
360
378
  // --------------------------------------------------------------------------
361
379
 
362
- export class UiDialogFooter extends WebComponent {
363
- static properties = {
364
- showCloseButton: { type: String, attribute: 'show-close-button' },
365
- };
366
- declare showCloseButton: string | null;
367
-
380
+ export class UiDialogFooter extends WebComponent({
381
+ showCloseButton: prop<string | null>(String, { attribute: 'show-close-button' }),
382
+ }) {
368
383
  constructor() {
369
384
  super();
370
385
  this.showCloseButton = null;
@@ -55,6 +55,9 @@
55
55
  * `type`: omit (default) | "checkbox" | "radio".
56
56
  * `checked`: boolean. Applies to checkbox / radio items.
57
57
  * `value`: string. Identifier for radio items.
58
+ * `data-disabled`: boolean. Skips keyboard focus and activation, dims the
59
+ * item, and sets aria-disabled. Same attribute on a
60
+ * <ui-dropdown-menu-sub-trigger> disables the submenu.
58
61
  *
59
62
  * Events:
60
63
  * `ui-open-change` on <ui-dropdown-menu>: `{ detail: { open } }` after a transition.
@@ -74,7 +77,8 @@
74
77
  * Design tokens used: --popover, --popover-foreground, --accent,
75
78
  * --accent-foreground, --destructive, --muted-foreground, --border.
76
79
  */
77
- import { WebComponent, html, unsafeHTML, signal } from '@webjsdev/core';
80
+ import { WebComponent, html, unsafeHTML, signal, prop } from '@webjsdev/core';
81
+ import { ensureId } from '../lib/utils.ts';
78
82
  import { positionFloating, type PopoverSide, type PopoverAlign } from './popover.ts';
79
83
 
80
84
  // --------------------------------------------------------------------------
@@ -116,12 +120,9 @@ const SUB_CLOSE_DELAY = 200;
116
120
  // <ui-dropdown-menu>
117
121
  // --------------------------------------------------------------------------
118
122
 
119
- export class UiDropdownMenu extends WebComponent {
120
- static properties = {
121
- open: { type: Boolean, reflect: true },
122
- };
123
- declare open: boolean;
124
-
123
+ export class UiDropdownMenu extends WebComponent({
124
+ open: prop(Boolean, { reflect: true }),
125
+ }) {
125
126
  _typeBuffer = '';
126
127
  _typeBufferTimer: number | undefined;
127
128
 
@@ -156,15 +157,58 @@ export class UiDropdownMenu extends WebComponent {
156
157
  queueMicrotask(() => this._afterRender());
157
158
  }
158
159
 
160
+ connectedCallback(): void {
161
+ super.connectedCallback?.();
162
+ // webjs projects slotted light-DOM children in a pass after the first
163
+ // render, so the trigger button and the menu are not in place at
164
+ // connect. Defer to the next frame, when the projection has run.
165
+ if (typeof requestAnimationFrame === 'function') {
166
+ requestAnimationFrame(() => this._wireAria());
167
+ }
168
+ }
169
+
159
170
  _afterRender(): void {
160
171
  const content = this._content();
161
172
  if (content) {
162
173
  this._syncContentPopover(content);
163
174
  }
175
+ this._wireAria();
164
176
  if (this.open) this._setup();
165
177
  else this._teardown();
166
178
  }
167
179
 
180
+ // The trigger wraps an author-supplied control (usually a <button>). Expose
181
+ // the menu relationship on that focusable control: aria-haspopup announces
182
+ // it opens a menu, aria-expanded tracks open state, aria-controls points at
183
+ // the menu, and the menu is labelled back by the trigger. Done at runtime
184
+ // because the menu is JS-driven (never shown without script).
185
+ _triggerControl(): HTMLElement | null {
186
+ const trigger = this.querySelector('ui-dropdown-menu-trigger');
187
+ if (!trigger) return null;
188
+ return (
189
+ trigger.querySelector<HTMLElement>('button, [role="button"], a[href], [tabindex]') ??
190
+ (trigger as HTMLElement)
191
+ );
192
+ }
193
+
194
+ _menuEl(): HTMLElement | null {
195
+ return this.querySelector('ui-dropdown-menu-content [role="menu"]');
196
+ }
197
+
198
+ _wireAria(): void {
199
+ const control = this._triggerControl();
200
+ if (!control) return;
201
+ control.setAttribute('aria-haspopup', 'menu');
202
+ control.setAttribute('aria-expanded', String(this.open));
203
+ const menu = this._menuEl();
204
+ if (!menu) return;
205
+ const menuId = ensureId(menu, 'ui-menu');
206
+ control.setAttribute('aria-controls', menuId);
207
+ if (!menu.hasAttribute('aria-label') && !menu.hasAttribute('aria-labelledby')) {
208
+ menu.setAttribute('aria-labelledby', ensureId(control, 'ui-menu-trigger'));
209
+ }
210
+ }
211
+
168
212
  _content(): HTMLElement | null {
169
213
  return this.querySelector('ui-dropdown-menu-content [popover]');
170
214
  }
@@ -329,6 +373,7 @@ export class UiDropdownMenuContent extends WebComponent {
329
373
  return html`<div
330
374
  data-slot="dropdown-menu-content"
331
375
  role="menu"
376
+ aria-orientation="vertical"
332
377
  popover="manual"
333
378
  class=${dropdownMenuContentClass()}
334
379
  ><slot></slot></div>`;
@@ -340,14 +385,10 @@ UiDropdownMenuContent.register('ui-dropdown-menu-content');
340
385
  // <ui-dropdown-menu-item>
341
386
  // --------------------------------------------------------------------------
342
387
 
343
- export class UiDropdownMenuItem extends WebComponent {
344
- static properties = {
345
- variant: { type: String, reflect: true },
346
- inset: { type: Boolean },
347
- };
348
- declare variant: 'default' | 'destructive';
349
- declare inset: boolean;
350
-
388
+ export class UiDropdownMenuItem extends WebComponent({
389
+ variant: prop<'default' | 'destructive'>(String, { reflect: true }),
390
+ inset: Boolean,
391
+ }) {
351
392
  // Keyboard / pointer highlight state for the own-rendered menuitem. A
352
393
  // local signal bound with ?data-highlighted keeps the highlight in the
353
394
  // declarative template instead of an imperative setAttribute on
@@ -361,12 +402,19 @@ export class UiDropdownMenuItem extends WebComponent {
361
402
  }
362
403
 
363
404
  render() {
405
+ // `data-disabled` on the host is the historical disabled marker (focus
406
+ // skips it, the click / pointer handlers bail on it). Mirror it onto the
407
+ // inner menuitem as both data-disabled (CSS) and aria-disabled, so the
408
+ // state also reaches assistive tech.
409
+ const disabled = typeof this.hasAttribute === 'function' && this.hasAttribute('data-disabled');
364
410
  return html`<div
365
411
  data-slot="dropdown-menu-item"
366
412
  role="menuitem"
367
413
  tabindex="-1"
368
414
  data-variant=${this.variant}
369
415
  ?data-inset=${this.inset}
416
+ ?data-disabled=${disabled}
417
+ aria-disabled=${disabled ? 'true' : 'false'}
370
418
  ?data-highlighted=${this.#highlighted.get()}
371
419
  class=${dropdownMenuItemClass()}
372
420
  @click=${this._onClick}
@@ -402,10 +450,7 @@ UiDropdownMenuItem.register('ui-dropdown-menu-item');
402
450
  // <ui-dropdown-menu-label>
403
451
  // --------------------------------------------------------------------------
404
452
 
405
- export class UiDropdownMenuLabel extends WebComponent {
406
- static properties = { inset: { type: Boolean } };
407
- declare inset: boolean;
408
-
453
+ export class UiDropdownMenuLabel extends WebComponent({ inset: Boolean }) {
409
454
  constructor() {
410
455
  super();
411
456
  this.inset = false;
@@ -468,12 +513,9 @@ UiDropdownMenuGroup.register('ui-dropdown-menu-group');
468
513
  // Submenu: Sub / SubTrigger / SubContent
469
514
  // --------------------------------------------------------------------------
470
515
 
471
- export class UiDropdownMenuSub extends WebComponent {
472
- static properties = {
473
- open: { type: Boolean, reflect: true },
474
- };
475
- declare open: boolean;
476
-
516
+ export class UiDropdownMenuSub extends WebComponent({
517
+ open: prop(Boolean, { reflect: true }),
518
+ }) {
477
519
  _closeTimer: number | undefined;
478
520
 
479
521
  constructor() {
@@ -555,10 +597,7 @@ export class UiDropdownMenuSub extends WebComponent {
555
597
  }
556
598
  UiDropdownMenuSub.register('ui-dropdown-menu-sub');
557
599
 
558
- export class UiDropdownMenuSubTrigger extends WebComponent {
559
- static properties = { inset: { type: Boolean } };
560
- declare inset: boolean;
561
-
600
+ export class UiDropdownMenuSubTrigger extends WebComponent({ inset: Boolean }) {
562
601
  constructor() {
563
602
  super();
564
603
  this.inset = false;
@@ -572,14 +611,17 @@ export class UiDropdownMenuSubTrigger extends WebComponent {
572
611
 
573
612
  render() {
574
613
  const open = !!this._sub()?.open;
614
+ const disabled = typeof this.hasAttribute === 'function' && this.hasAttribute('data-disabled');
575
615
  return html`<div
576
616
  data-slot="dropdown-menu-sub-trigger"
577
617
  role="menuitem"
578
618
  tabindex="-1"
579
619
  aria-haspopup="menu"
580
620
  aria-expanded=${String(open)}
621
+ aria-disabled=${disabled ? 'true' : 'false'}
581
622
  data-state=${open ? 'open' : 'closed'}
582
623
  ?data-inset=${this.inset}
624
+ ?data-disabled=${disabled}
583
625
  class=${dropdownMenuSubTriggerClass()}
584
626
  @click=${this._onClick}
585
627
  @pointerenter=${this._onPointerEnter}
@@ -606,6 +648,7 @@ export class UiDropdownMenuSubContent extends WebComponent {
606
648
  return html`<div
607
649
  data-slot="dropdown-menu-sub-content"
608
650
  role="menu"
651
+ aria-orientation="vertical"
609
652
  popover="manual"
610
653
  class=${dropdownMenuSubContentClass()}
611
654
  ><slot></slot></div>`;
@@ -38,7 +38,8 @@
38
38
  *
39
39
  * Design tokens used: --popover, --popover-foreground, --border.
40
40
  */
41
- import { WebComponent, html } from '@webjsdev/core';
41
+ import { WebComponent, html, prop } from '@webjsdev/core';
42
+ import { ensureId } from '../lib/utils.ts';
42
43
  import { positionFloating, type PopoverSide, type PopoverAlign } from './popover.ts';
43
44
 
44
45
  // `fixed m-0` opts out of the UA `[popover]` auto-centering margin so
@@ -52,18 +53,13 @@ export const hoverCardContentClass = (): string =>
52
53
  // <ui-hover-card>
53
54
  // --------------------------------------------------------------------------
54
55
 
55
- export class UiHoverCard extends WebComponent {
56
- static properties = {
57
- open: { type: Boolean, reflect: true },
58
- openDelay: { type: Number },
59
- closeDelay: { type: Number },
60
- };
61
- declare open: boolean;
56
+ export class UiHoverCard extends WebComponent({
57
+ open: prop(Boolean, { reflect: true }),
62
58
  // `openDelay` / `closeDelay` ride the `open-delay` / `close-delay`
63
59
  // attributes (shadcn parity), read as typed props.
64
- declare openDelay: number;
65
- declare closeDelay: number;
66
-
60
+ openDelay: Number,
61
+ closeDelay: Number,
62
+ }) {
67
63
  _showTimer: number | undefined;
68
64
  _hideTimer: number | undefined;
69
65
 
@@ -74,6 +70,37 @@ export class UiHoverCard extends WebComponent {
74
70
  this.closeDelay = 300;
75
71
  }
76
72
 
73
+ connectedCallback(): void {
74
+ super.connectedCallback?.();
75
+ // webjs projects slotted light-DOM children after the first render, so
76
+ // the trigger control is not in place at connect. Defer to the next
77
+ // frame, when the projection has run.
78
+ if (typeof requestAnimationFrame === 'function') {
79
+ requestAnimationFrame(() => this._wireAria());
80
+ }
81
+ }
82
+
83
+ // The trigger also opens on focus (see the @focusin handler), so it is
84
+ // keyboard-reachable: expose the popup relationship on the focusable
85
+ // control. aria-expanded is refreshed on every open transition.
86
+ _control(): HTMLElement | null {
87
+ const t = this.querySelector('ui-hover-card-trigger');
88
+ if (!t) return null;
89
+ return (
90
+ t.querySelector<HTMLElement>('a[href], button, [tabindex], [role="button"]') ??
91
+ (t as HTMLElement)
92
+ );
93
+ }
94
+
95
+ _wireAria(): void {
96
+ const control = this._control();
97
+ if (!control) return;
98
+ control.setAttribute('aria-haspopup', 'dialog');
99
+ control.setAttribute('aria-expanded', String(this.open));
100
+ const content = this.querySelector<HTMLElement>('ui-hover-card-content [role="dialog"]');
101
+ if (content) control.setAttribute('aria-controls', ensureId(content, 'ui-hovercard'));
102
+ }
103
+
77
104
  // Back-compat getter.
78
105
  get isOpen(): boolean { return this.open; }
79
106
 
@@ -98,8 +125,12 @@ export class UiHoverCard extends WebComponent {
98
125
  if (!changedProperties.has('open')) return;
99
126
  if (changedProperties.get('open') === undefined) return;
100
127
  // Wait one microtask for <ui-hover-card-content>'s inner [popover]
101
- // element to commit; we drive its showPopover() / hidePopover().
102
- queueMicrotask(() => this._syncContent());
128
+ // element to commit; we drive its showPopover() / hidePopover() and
129
+ // refresh the trigger's aria-expanded.
130
+ queueMicrotask(() => {
131
+ this._wireAria();
132
+ this._syncContent();
133
+ });
103
134
  }
104
135
 
105
136
  _syncContent(): void {
@@ -23,6 +23,11 @@
23
23
  * </ul>
24
24
  * </nav>
25
25
  *
26
+ * A11y (required for accessible output): wrap the list in <nav
27
+ * aria-label="pagination">, set aria-current="page" on the active page
28
+ * link, give an icon-only Previous / Next control an aria-label, and mark
29
+ * the ellipsis aria-hidden="true". The class helpers emit none of these.
30
+ *
26
31
  * Design tokens used: inherited from buttonClass.
27
32
  */
28
33
  import { cn } from '../lib/utils.ts';
@@ -16,6 +16,11 @@
16
16
  * <!-- Indeterminate (no `value` attribute): -->
17
17
  * <progress class=${progressClass()}></progress>
18
18
  *
19
+ * A11y (required for accessible output): give the <progress> an accessible
20
+ * name with aria-label (or a <label for>), e.g. aria-label="Upload
21
+ * progress". The native element supplies the role and value; only the name
22
+ * is the author's responsibility.
23
+ *
19
24
  * Design tokens used: --primary.
20
25
  */
21
26
 
@@ -13,6 +13,11 @@
13
13
  * class=${separatorClass({ orientation: 'vertical' })}
14
14
  * data-orientation="vertical"></div>
15
15
  *
16
+ * A11y (required for accessible output): a meaningful divider needs
17
+ * role="separator" plus aria-orientation. A purely decorative one needs
18
+ * role="none" (or aria-hidden="true") so assistive tech does not announce
19
+ * an empty separator.
20
+ *
16
21
  * Design tokens used: --border.
17
22
  */
18
23
  import { cn } from '../lib/utils.ts';
@@ -10,6 +10,11 @@
10
10
  * <div class=${cn(skeletonClass(), 'h-4 w-32')}></div>
11
11
  * <div class=${cn(skeletonClass(), 'h-12 w-12 rounded-full')}></div>
12
12
  *
13
+ * A11y (required for accessible output): a skeleton is a decorative
14
+ * placeholder, so hide it from assistive tech with aria-hidden="true" (or
15
+ * mark the loading region aria-busy="true"). Announce the real content
16
+ * once it replaces the skeleton.
17
+ *
13
18
  * Design tokens used: --accent.
14
19
  */
15
20
 
@@ -43,7 +43,7 @@
43
43
  *
44
44
  * Design tokens used: --popover, --popover-foreground, --border, --radius.
45
45
  */
46
- import { WebComponent, html, repeat, unsafeHTML, signal } from '@webjsdev/core';
46
+ import { WebComponent, html, repeat, unsafeHTML, signal, prop } from '@webjsdev/core';
47
47
 
48
48
  type ToastType = 'default' | 'success' | 'error' | 'info' | 'warning' | 'loading';
49
49
 
@@ -134,11 +134,9 @@ const TYPE_ICON_COLOR: Record<ToastType, string> = {
134
134
  loading: 'text-muted-foreground',
135
135
  };
136
136
 
137
- export class UiSonner extends WebComponent {
138
- static properties = {
139
- position: { type: String, reflect: true },
140
- };
141
- declare position: SonnerPosition;
137
+ export class UiSonner extends WebComponent({
138
+ position: prop<SonnerPosition>(String, { reflect: true }),
139
+ }) {
142
140
  items = signal<ToastItem[]>([]);
143
141
 
144
142
  constructor() {
@@ -190,8 +188,18 @@ export class UiSonner extends WebComponent {
190
188
 
191
189
  render() {
192
190
  const pos = POSITIONS[this.position] ?? POSITIONS['bottom-right'];
191
+ // The container is a persistent live region: it is in the DOM from the
192
+ // first render (even with zero toasts), so a screen reader announces
193
+ // each toast as it is inserted. It defaults to polite; an `error` toast
194
+ // carries its own role="alert" (assertive) and that innermost live
195
+ // region wins for that item.
193
196
  return html`<div
194
197
  data-slot="sonner"
198
+ role="region"
199
+ aria-label="Notifications"
200
+ aria-live="polite"
201
+ aria-relevant="additions text"
202
+ aria-atomic="false"
195
203
  class=${`pointer-events-none fixed z-[100] flex flex-col gap-2 ${pos}`}
196
204
  >
197
205
  ${repeat(
@@ -18,8 +18,8 @@
18
18
  * <table class=${tableClass()}>
19
19
  * <thead class=${tableHeaderClass()}>
20
20
  * <tr class=${tableRowClass()}>
21
- * <th class=${tableHeadClass()}>Name</th>
22
- * <th class=${tableHeadClass()}>Status</th>
21
+ * <th scope="col" class=${tableHeadClass()}>Name</th>
22
+ * <th scope="col" class=${tableHeadClass()}>Status</th>
23
23
  * </tr>
24
24
  * </thead>
25
25
  * <tbody class=${tableBodyClass()}>
@@ -32,6 +32,11 @@
32
32
  * </table>
33
33
  * </div>
34
34
  *
35
+ * A11y (required for accessible output): every header cell needs a scope
36
+ * (scope="col" on a column header, scope="row" on a row header) so screen
37
+ * readers map cells to their headers. Add a <caption> naming the table's
38
+ * purpose (it can be visually hidden if a heading already names it).
39
+ *
35
40
  * Design tokens used: --muted, --muted-foreground, --foreground.
36
41
  */
37
42
 
@@ -43,8 +43,11 @@
43
43
  * Design tokens used: --muted, --muted-foreground, --foreground, --background,
44
44
  * --input, --ring.
45
45
  */
46
- import { WebComponent, html } from '@webjsdev/core';
47
- import { cn } from '../lib/utils.ts';
46
+ import { WebComponent, html, prop } from '@webjsdev/core';
47
+ import { cn, ensureId } from '../lib/utils.ts';
48
+
49
+ // A tab `value` becomes part of an id, so reduce it to id-safe characters.
50
+ const idSafe = (s: string): string => s.replace(/[^A-Za-z0-9_-]/g, '-');
48
51
 
49
52
  // --------------------------------------------------------------------------
50
53
  // Class helpers
@@ -92,20 +95,29 @@ const TABS_CONTENT_CLASS = 'flex-1 outline-none';
92
95
  // requestUpdate() to re-render against the new parent value.
93
96
  // --------------------------------------------------------------------------
94
97
 
95
- export class UiTabs extends WebComponent {
96
- static properties = {
97
- value: { type: String, reflect: true },
98
- orientation: { type: String, reflect: true },
99
- };
100
- declare value: string;
101
- declare orientation: 'horizontal' | 'vertical';
102
-
98
+ export class UiTabs extends WebComponent({
99
+ value: prop(String, { reflect: true }),
100
+ orientation: prop<'horizontal' | 'vertical'>(String, { reflect: true }),
101
+ }) {
103
102
  constructor() {
104
103
  super();
105
104
  this.value = '';
106
105
  this.orientation = 'horizontal';
107
106
  }
108
107
 
108
+ // Stable per-instance scope so a trigger and its panel agree on the
109
+ // shared id stem (`<scope>-trigger-<value>` / `<scope>-panel-<value>`)
110
+ // even when several tab sets reuse the same `value` names on one page.
111
+ get scopeId(): string {
112
+ return ensureId(this, 'ui-tabs');
113
+ }
114
+
115
+ // Builds the trigger / panel id pair a child derives from its `value`.
116
+ idsFor(value: string): { trigger: string; panel: string } {
117
+ const v = idSafe(value);
118
+ return { trigger: `${this.scopeId}-trigger-${v}`, panel: `${this.scopeId}-panel-${v}` };
119
+ }
120
+
109
121
  render() {
110
122
  return html`<div
111
123
  data-slot="tabs"
@@ -130,7 +142,7 @@ export class UiTabs extends WebComponent {
130
142
 
131
143
  _broadcast(): void {
132
144
  this.querySelectorAll<WebComponent>(
133
- 'ui-tabs-trigger, ui-tabs-content',
145
+ 'ui-tabs-list, ui-tabs-trigger, ui-tabs-content',
134
146
  ).forEach((el) => el.requestUpdate?.());
135
147
  }
136
148
  }
@@ -140,21 +152,25 @@ UiTabs.register('ui-tabs');
140
152
  // <ui-tabs-list>
141
153
  // --------------------------------------------------------------------------
142
154
 
143
- export class UiTabsList extends WebComponent {
144
- static properties = {
145
- variant: { type: String, reflect: true },
146
- };
147
- declare variant: TabsListVariant;
148
-
155
+ export class UiTabsList extends WebComponent({
156
+ variant: prop<TabsListVariant>(String, { reflect: true }),
157
+ }) {
149
158
  constructor() {
150
159
  super();
151
160
  this.variant = 'default';
152
161
  }
153
162
 
163
+ get _tabs(): UiTabs | null {
164
+ if (typeof this.closest !== 'function') return null;
165
+ return this.closest('ui-tabs') as UiTabs | null;
166
+ }
167
+
154
168
  render() {
169
+ const orientation = this._tabs?.orientation ?? 'horizontal';
155
170
  return html`<div
156
171
  data-slot="tabs-list"
157
172
  role="tablist"
173
+ aria-orientation=${orientation}
158
174
  data-variant=${this.variant}
159
175
  class=${tabsListClass({ variant: this.variant })}
160
176
  ><slot></slot></div>`;
@@ -166,12 +182,9 @@ UiTabsList.register('ui-tabs-list');
166
182
  // <ui-tabs-trigger value="...">
167
183
  // --------------------------------------------------------------------------
168
184
 
169
- export class UiTabsTrigger extends WebComponent {
170
- static properties = {
171
- value: { type: String, reflect: true },
172
- };
173
- declare value: string;
174
-
185
+ export class UiTabsTrigger extends WebComponent({
186
+ value: prop(String, { reflect: true }),
187
+ }) {
175
188
  constructor() {
176
189
  super();
177
190
  this.value = '';
@@ -188,10 +201,13 @@ export class UiTabsTrigger extends WebComponent {
188
201
  render() {
189
202
  const tabs = this._tabs;
190
203
  const active = !!tabs && tabs.value === this.value && this.value !== '';
204
+ const ids = tabs && this.value ? tabs.idsFor(this.value) : null;
191
205
  return html`<button
192
206
  type="button"
193
207
  role="tab"
194
208
  data-slot="tabs-trigger"
209
+ id=${ids ? ids.trigger : ''}
210
+ aria-controls=${ids ? ids.panel : ''}
195
211
  data-state=${active ? 'active' : 'inactive'}
196
212
  aria-selected=${String(active)}
197
213
  tabindex=${active ? '0' : '-1'}
@@ -235,12 +251,9 @@ UiTabsTrigger.register('ui-tabs-trigger');
235
251
  // <ui-tabs-content value="...">
236
252
  // --------------------------------------------------------------------------
237
253
 
238
- export class UiTabsContent extends WebComponent {
239
- static properties = {
240
- value: { type: String, reflect: true },
241
- };
242
- declare value: string;
243
-
254
+ export class UiTabsContent extends WebComponent({
255
+ value: prop(String, { reflect: true }),
256
+ }) {
244
257
  constructor() {
245
258
  super();
246
259
  this.value = '';
@@ -254,14 +267,20 @@ export class UiTabsContent extends WebComponent {
254
267
  render() {
255
268
  const tabs = this._tabs;
256
269
  const active = !!tabs && tabs.value === this.value && this.value !== '';
270
+ const ids = tabs && this.value ? tabs.idsFor(this.value) : null;
257
271
  // The host needs to be hidden when inactive so its rendered <section>
258
272
  // is removed from layout (light DOM has no :host CSS to scope this).
259
273
  // Use the native `hidden` IDL property rather than imperative
260
- // setAttribute, so it reads as a property assignment.
274
+ // setAttribute, so it reads as a property assignment. `inert` on the
275
+ // host additionally pulls an inactive panel (and anything focusable
276
+ // inside it) out of the tab order and the accessibility tree.
261
277
  this.hidden = !active;
278
+ this.inert = !active;
262
279
  return html`<section
263
280
  data-slot="tabs-content"
264
281
  role="tabpanel"
282
+ id=${ids ? ids.panel : ''}
283
+ aria-labelledby=${ids ? ids.trigger : ''}
265
284
  tabindex="0"
266
285
  data-state=${active ? 'active' : 'inactive'}
267
286
  class=${TABS_CONTENT_CLASS}
@@ -41,12 +41,14 @@
41
41
  * Events:
42
42
  * `ui-value-change` on <ui-toggle-group>: `{ detail: { value } }` after selection changes.
43
43
  *
44
- * Keyboard: Enter / Space toggles the focused item (native button activation).
44
+ * Keyboard: Arrow keys move focus between items (roving tabindex, so the
45
+ * group is a single Tab stop), Home / End jump to the first / last item, and
46
+ * Enter / Space toggles the focused item.
45
47
  *
46
48
  * Design tokens used: inherited from toggleClass (--muted, --accent, --ring,
47
49
  * --input, --destructive).
48
50
  */
49
- import { WebComponent, html } from '@webjsdev/core';
51
+ import { WebComponent, html, prop } from '@webjsdev/core';
50
52
  import { cn } from '../lib/utils.ts';
51
53
  import { toggleClass, type ToggleVariant, type ToggleSize } from './toggle.ts';
52
54
 
@@ -66,22 +68,14 @@ const ITEM_EXTRA =
66
68
  // their own renders before we read / write their state.
67
69
  // --------------------------------------------------------------------------
68
70
 
69
- export class UiToggleGroup extends WebComponent {
70
- static properties = {
71
- value: { type: String, reflect: true },
72
- type: { type: String, reflect: true },
73
- variant: { type: String, reflect: true },
74
- size: { type: String, reflect: true },
75
- spacing: { type: String, reflect: true },
76
- orientation: { type: String, reflect: true },
77
- };
78
- declare value: string;
79
- declare type: 'single' | 'multiple';
80
- declare variant: ToggleVariant;
81
- declare size: ToggleSize;
82
- declare spacing: string;
83
- declare orientation: 'horizontal' | 'vertical';
84
-
71
+ export class UiToggleGroup extends WebComponent({
72
+ value: prop(String, { reflect: true }),
73
+ type: prop<'single' | 'multiple'>(String, { reflect: true }),
74
+ variant: prop<ToggleVariant>(String, { reflect: true }),
75
+ size: prop<ToggleSize>(String, { reflect: true }),
76
+ spacing: prop(String, { reflect: true }),
77
+ orientation: prop<'horizontal' | 'vertical'>(String, { reflect: true }),
78
+ }) {
85
79
  constructor() {
86
80
  super();
87
81
  this.value = '';
@@ -117,9 +111,15 @@ export class UiToggleGroup extends WebComponent {
117
111
  queueMicrotask(() => this._reflectItems());
118
112
  }
119
113
 
114
+ _items(): UiToggleGroupItem[] {
115
+ return Array.from(this.querySelectorAll<UiToggleGroupItem>('ui-toggle-group-item'));
116
+ }
117
+
120
118
  _reflectItems(): void {
121
119
  const values = this._values;
122
- this.querySelectorAll<UiToggleGroupItem>('ui-toggle-group-item').forEach((item) => {
120
+ const items = this._items();
121
+ const current = items.find((el) => el.tabIndex === 0);
122
+ items.forEach((item) => {
123
123
  const on = !!item.value && values.has(item.value);
124
124
  // Reflect both on the host (for CSS sibling selectors like
125
125
  // data-[spacing=0]:first:rounded-l-md that need to target the host
@@ -127,6 +127,31 @@ export class UiToggleGroup extends WebComponent {
127
127
  // item's render() refreshes its inner styling.
128
128
  item.pressed = on;
129
129
  });
130
+ this._roving(items, current);
131
+ }
132
+
133
+ // Roving tabindex (APG): exactly one item is in the tab order. Prefer the
134
+ // currently-focused item, then whichever was tabbable, then the first
135
+ // selected item, then the first item. Arrow keys move focus and shift the
136
+ // single tabbable slot via `focusItem`.
137
+ _roving(items: UiToggleGroupItem[], current?: UiToggleGroupItem): void {
138
+ if (!items.length) return;
139
+ const values = this._values;
140
+ const active =
141
+ typeof document !== 'undefined' ? (document.activeElement as Element | null) : null;
142
+ const focused = active ? items.find((i) => i === active) : undefined;
143
+ const selected = items.find((i) => !!i.value && values.has(i.value));
144
+ const tabbable = focused ?? current ?? selected ?? items[0];
145
+ items.forEach((item) => {
146
+ item.tabIndex = item === tabbable ? 0 : -1;
147
+ });
148
+ }
149
+
150
+ focusItem(item: UiToggleGroupItem): void {
151
+ this._items().forEach((el) => {
152
+ el.tabIndex = el === item ? 0 : -1;
153
+ });
154
+ item.focus();
130
155
  }
131
156
 
132
157
  _onItemClick = (e: Event): void => {
@@ -159,14 +184,10 @@ UiToggleGroup.register('ui-toggle-group');
159
184
  // Tailwind variant selectors on the joined-spacing rounded corners).
160
185
  // --------------------------------------------------------------------------
161
186
 
162
- export class UiToggleGroupItem extends WebComponent {
163
- static properties = {
164
- value: { type: String, reflect: true },
165
- pressed: { type: Boolean, reflect: true },
166
- };
167
- declare value: string;
168
- declare pressed: boolean;
169
-
187
+ export class UiToggleGroupItem extends WebComponent({
188
+ value: prop(String, { reflect: true }),
189
+ pressed: prop(Boolean, { reflect: true }),
190
+ }) {
170
191
  constructor() {
171
192
  super();
172
193
  this.value = '';
@@ -193,7 +214,10 @@ export class UiToggleGroupItem extends WebComponent {
193
214
  connectedCallback(): void {
194
215
  this.dataset.slot = 'toggle-group-item';
195
216
  this.role = 'button';
196
- this.tabIndex = 0;
217
+ // Roving tabindex: start outside the tab order. The group promotes
218
+ // exactly one item to tabindex 0 in _reflectItems (runs after first
219
+ // render); Arrow keys move focus and the tabbable slot from there.
220
+ this.tabIndex = -1;
197
221
  this.addEventListener('click', this._onClick);
198
222
  this.addEventListener('keydown', this._onKeyDown);
199
223
  super.connectedCallback?.();
@@ -230,6 +254,24 @@ export class UiToggleGroupItem extends WebComponent {
230
254
  if (e.key === ' ' || e.key === 'Enter') {
231
255
  e.preventDefault();
232
256
  this._onClick();
257
+ return;
258
+ }
259
+ const group = this._group;
260
+ if (!group) return;
261
+ const horizontal = group.orientation !== 'vertical';
262
+ const nextKey = horizontal ? 'ArrowRight' : 'ArrowDown';
263
+ const prevKey = horizontal ? 'ArrowLeft' : 'ArrowUp';
264
+ const items = group._items();
265
+ const idx = items.indexOf(this);
266
+ if (idx === -1) return;
267
+ let target: UiToggleGroupItem | null = null;
268
+ if (e.key === nextKey) target = items[(idx + 1) % items.length] ?? null;
269
+ else if (e.key === prevKey) target = items[(idx - 1 + items.length) % items.length] ?? null;
270
+ else if (e.key === 'Home') target = items[0] ?? null;
271
+ else if (e.key === 'End') target = items[items.length - 1] ?? null;
272
+ if (target) {
273
+ e.preventDefault();
274
+ group.focusItem(target);
233
275
  }
234
276
  };
235
277
  }
@@ -38,7 +38,7 @@
38
38
  * Design tokens used: --muted, --muted-foreground, --accent, --accent-foreground,
39
39
  * --input, --background, --ring, --destructive.
40
40
  */
41
- import { WebComponent, html } from '@webjsdev/core';
41
+ import { WebComponent, html, prop } from '@webjsdev/core';
42
42
  import { cn } from '../lib/utils.ts';
43
43
 
44
44
  // cursor-pointer + select-none on BASE for both call sites: the
@@ -76,18 +76,12 @@ export function toggleClass(opts: { variant?: ToggleVariant; size?: ToggleSize }
76
76
  // project through the default slot inside the inner button.
77
77
  // --------------------------------------------------------------------------
78
78
 
79
- export class UiToggle extends WebComponent {
80
- static properties = {
81
- pressed: { type: Boolean, reflect: true },
82
- variant: { type: String, reflect: true },
83
- size: { type: String, reflect: true },
84
- disabled: { type: Boolean, reflect: true },
85
- };
86
- declare pressed: boolean;
87
- declare variant: ToggleVariant;
88
- declare size: ToggleSize;
89
- declare disabled: boolean;
90
-
79
+ export class UiToggle extends WebComponent({
80
+ pressed: prop(Boolean, { reflect: true }),
81
+ variant: prop<ToggleVariant>(String, { reflect: true }),
82
+ size: prop<ToggleSize>(String, { reflect: true }),
83
+ disabled: prop(Boolean, { reflect: true }),
84
+ }) {
91
85
  constructor() {
92
86
  super();
93
87
  this.pressed = false;
@@ -40,7 +40,8 @@
40
40
  *
41
41
  * Design tokens used: --foreground, --background.
42
42
  */
43
- import { WebComponent, html } from '@webjsdev/core';
43
+ import { WebComponent, html, prop } from '@webjsdev/core';
44
+ import { ensureId } from '../lib/utils.ts';
44
45
  import { positionFloating, type PopoverSide, type PopoverAlign } from './popover.ts';
45
46
 
46
47
  // UA `[popover]` defaults paint a bordered, padded panel centered with
@@ -61,18 +62,13 @@ let lastTooltipHideAt = 0;
61
62
  // <ui-tooltip>
62
63
  // --------------------------------------------------------------------------
63
64
 
64
- export class UiTooltip extends WebComponent {
65
- static properties = {
66
- open: { type: Boolean, reflect: true },
67
- delayDuration: { type: Number },
68
- skipDelayDuration: { type: Number },
69
- };
70
- declare open: boolean;
65
+ export class UiTooltip extends WebComponent({
66
+ open: prop(Boolean, { reflect: true }),
71
67
  // `delayDuration` / `skipDelayDuration` ride the `delay-duration` /
72
68
  // `skip-delay-duration` attributes (shadcn parity), read as typed props.
73
- declare delayDuration: number;
74
- declare skipDelayDuration: number;
75
-
69
+ delayDuration: Number,
70
+ skipDelayDuration: Number,
71
+ }) {
76
72
  _showTimer: number | undefined;
77
73
  _hideTimer: number | undefined;
78
74
 
@@ -83,6 +79,36 @@ export class UiTooltip extends WebComponent {
83
79
  this.skipDelayDuration = 300;
84
80
  }
85
81
 
82
+ connectedCallback(): void {
83
+ super.connectedCallback?.();
84
+ // webjs projects slotted light-DOM children in a pass after the first
85
+ // render, so the trigger's focusable control is not in place yet at
86
+ // connect / firstUpdated. Defer to the next frame, by which point the
87
+ // projection has run and both the control and the tip content exist.
88
+ if (typeof requestAnimationFrame === 'function') {
89
+ requestAnimationFrame(() => this._wireAria());
90
+ }
91
+ }
92
+
93
+ // APG tooltip wiring: the focusable trigger references the tip via
94
+ // aria-describedby, so a screen reader appends the tip text to the
95
+ // trigger's own name ("Help, button, Helpful tip"). The tip is JS-driven,
96
+ // so doing this at runtime is correct.
97
+ _wireAria(): void {
98
+ const triggerHost = this.querySelector('ui-tooltip-trigger');
99
+ const content = this.querySelector<HTMLElement>('ui-tooltip-content [role="tooltip"]');
100
+ if (!triggerHost || !content) return;
101
+ const control =
102
+ triggerHost.querySelector<HTMLElement>('button, a[href], [tabindex], [role="button"]') ??
103
+ (triggerHost as HTMLElement);
104
+ const id = ensureId(content, 'ui-tooltip');
105
+ const existing = control.getAttribute('aria-describedby');
106
+ if (!existing) control.setAttribute('aria-describedby', id);
107
+ else if (!existing.split(/\s+/).includes(id)) {
108
+ control.setAttribute('aria-describedby', `${existing} ${id}`);
109
+ }
110
+ }
111
+
86
112
  // Back-compat getter for tests + external code that read `el.isOpen`
87
113
  // alongside the reactive `open` prop.
88
114
  get isOpen(): boolean { return this.open; }
@@ -100,6 +100,33 @@ function variantPrefix(token: string): string {
100
100
  return i === -1 ? '' : token.slice(0, i + 1);
101
101
  }
102
102
 
103
+ // ---------------------------------------------------------------------------
104
+ // Stable DOM ids for wiring ARIA relationships (aria-controls /
105
+ // aria-labelledby / aria-describedby) between sibling light-DOM nodes.
106
+ // A monotonic counter is fine for uniqueness within a document: the id is
107
+ // only consumed as an attribute value, never persisted. When SSR emits an
108
+ // id on a host element, the upgraded element reuses it (ensureId is a no-op
109
+ // when an id is already present), so the server and client agree.
110
+ // ---------------------------------------------------------------------------
111
+
112
+ let _idSeq = 0;
113
+
114
+ /** A fresh, document-unique id string with a readable prefix. */
115
+ export function domId(prefix = 'ui'): string {
116
+ _idSeq += 1;
117
+ return `${prefix}-${_idSeq}`;
118
+ }
119
+
120
+ /**
121
+ * Returns `el.id`, assigning a generated one (prefix-based) when absent.
122
+ * Idempotent, so an id already present (author-set, or carried over from
123
+ * SSR) is reused unchanged.
124
+ */
125
+ export function ensureId(el: { id: string }, prefix = 'ui'): string {
126
+ if (!el.id) el.id = domId(prefix);
127
+ return el.id;
128
+ }
129
+
103
130
  // ---------------------------------------------------------------------------
104
131
  // Custom-element base: SSR-safe. In the browser `Base = HTMLElement`. On
105
132
  // the server (Node, during SSR) `HTMLElement` is undefined; we substitute
@@ -188,7 +188,8 @@
188
188
  "name": "dialog",
189
189
  "type": "registry:ui",
190
190
  "registryDependencies": [
191
- "lib-utils"
191
+ "lib-utils",
192
+ "button"
192
193
  ],
193
194
  "dependencies": [
194
195
  "@webjsdev/core"
@@ -203,6 +204,9 @@
203
204
  {
204
205
  "name": "dropdown-menu",
205
206
  "type": "registry:ui",
207
+ "registryDependencies": [
208
+ "popover"
209
+ ],
206
210
  "dependencies": [
207
211
  "@webjsdev/core"
208
212
  ],
@@ -216,6 +220,9 @@
216
220
  {
217
221
  "name": "hover-card",
218
222
  "type": "registry:ui",
223
+ "registryDependencies": [
224
+ "popover"
225
+ ],
219
226
  "dependencies": [
220
227
  "@webjsdev/core"
221
228
  ],
@@ -452,6 +459,9 @@
452
459
  {
453
460
  "name": "toggle-group",
454
461
  "type": "registry:ui",
462
+ "registryDependencies": [
463
+ "toggle"
464
+ ],
455
465
  "dependencies": [
456
466
  "@webjsdev/core"
457
467
  ],
@@ -465,6 +475,9 @@
465
475
  {
466
476
  "name": "tooltip",
467
477
  "type": "registry:ui",
478
+ "registryDependencies": [
479
+ "popover"
480
+ ],
468
481
  "dependencies": [
469
482
  "@webjsdev/core"
470
483
  ],