@webjsdev/ui 0.3.4 → 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.
@@ -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,20 +182,17 @@ 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 = '';
178
191
  }
179
192
 
180
- // render() runs server-side too; linkedom doesn't implement closest()
181
- // on custom elements. Return null during SSR; the client re-renders
182
- // with the parent reference after hydration.
193
+ // render() runs server-side too. webjs resolves closest() at SSR against
194
+ // the enclosing-element ancestor chain, so the active tab is marked in the
195
+ // first paint (no hydration flash). The typeof guard stays defensive.
183
196
  get _tabs(): UiTabs | null {
184
197
  if (typeof this.closest !== 'function') return null;
185
198
  return this.closest('ui-tabs') as UiTabs | null;
@@ -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,23 +184,19 @@ 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 = '';
173
194
  this.pressed = false;
174
195
  }
175
196
 
176
- // render() runs server-side too. linkedom doesn't implement closest()
177
- // on custom elements, so guard it; the client re-renders with the
178
- // real parent reference after hydration.
197
+ // render() runs server-side too. webjs resolves closest() at SSR against
198
+ // the enclosing-element ancestor chain, so the pressed item is marked in
199
+ // the first paint (no hydration flash). The typeof guard stays defensive.
179
200
  get _group(): UiToggleGroup | null {
180
201
  if (typeof this.closest !== 'function') return null;
181
202
  return this.closest('ui-toggle-group') as UiToggleGroup | null;
@@ -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; }