@webjsdev/ui 0.3.5 → 0.3.7

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.
@@ -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,39 @@ 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
+ // Assign the scope id BEFORE render so it is serialized onto this host's
109
+ // opening tag at SSR (willUpdate now runs server-side). Without this, the id
110
+ // was minted lazily during a CHILD trigger's render, AFTER this host's tag
111
+ // was already serialized without an id, so the host shipped no id; on
112
+ // hydration the client re-minted a DIFFERENT counter id and the trigger /
113
+ // panel ids mismatched SSR vs client, breaking hydration (#730).
114
+ willUpdate(): void {
115
+ ensureId(this, 'ui-tabs');
116
+ }
117
+
118
+ // Stable per-instance scope so a trigger and its panel agree on the
119
+ // shared id stem (`<scope>-trigger-<value>` / `<scope>-panel-<value>`)
120
+ // even when several tab sets reuse the same `value` names on one page.
121
+ get scopeId(): string {
122
+ return ensureId(this, 'ui-tabs');
123
+ }
124
+
125
+ // Builds the trigger / panel id pair a child derives from its `value`.
126
+ idsFor(value: string): { trigger: string; panel: string } {
127
+ const v = idSafe(value);
128
+ return { trigger: `${this.scopeId}-trigger-${v}`, panel: `${this.scopeId}-panel-${v}` };
129
+ }
130
+
109
131
  render() {
110
132
  return html`<div
111
133
  data-slot="tabs"
@@ -130,7 +152,7 @@ export class UiTabs extends WebComponent {
130
152
 
131
153
  _broadcast(): void {
132
154
  this.querySelectorAll<WebComponent>(
133
- 'ui-tabs-trigger, ui-tabs-content',
155
+ 'ui-tabs-list, ui-tabs-trigger, ui-tabs-content',
134
156
  ).forEach((el) => el.requestUpdate?.());
135
157
  }
136
158
  }
@@ -140,21 +162,25 @@ UiTabs.register('ui-tabs');
140
162
  // <ui-tabs-list>
141
163
  // --------------------------------------------------------------------------
142
164
 
143
- export class UiTabsList extends WebComponent {
144
- static properties = {
145
- variant: { type: String, reflect: true },
146
- };
147
- declare variant: TabsListVariant;
148
-
165
+ export class UiTabsList extends WebComponent({
166
+ variant: prop<TabsListVariant>(String, { reflect: true }),
167
+ }) {
149
168
  constructor() {
150
169
  super();
151
170
  this.variant = 'default';
152
171
  }
153
172
 
173
+ get _tabs(): UiTabs | null {
174
+ if (typeof this.closest !== 'function') return null;
175
+ return this.closest('ui-tabs') as UiTabs | null;
176
+ }
177
+
154
178
  render() {
179
+ const orientation = this._tabs?.orientation ?? 'horizontal';
155
180
  return html`<div
156
181
  data-slot="tabs-list"
157
182
  role="tablist"
183
+ aria-orientation=${orientation}
158
184
  data-variant=${this.variant}
159
185
  class=${tabsListClass({ variant: this.variant })}
160
186
  ><slot></slot></div>`;
@@ -166,12 +192,9 @@ UiTabsList.register('ui-tabs-list');
166
192
  // <ui-tabs-trigger value="...">
167
193
  // --------------------------------------------------------------------------
168
194
 
169
- export class UiTabsTrigger extends WebComponent {
170
- static properties = {
171
- value: { type: String, reflect: true },
172
- };
173
- declare value: string;
174
-
195
+ export class UiTabsTrigger extends WebComponent({
196
+ value: prop(String, { reflect: true }),
197
+ }) {
175
198
  constructor() {
176
199
  super();
177
200
  this.value = '';
@@ -188,10 +211,13 @@ export class UiTabsTrigger extends WebComponent {
188
211
  render() {
189
212
  const tabs = this._tabs;
190
213
  const active = !!tabs && tabs.value === this.value && this.value !== '';
214
+ const ids = tabs && this.value ? tabs.idsFor(this.value) : null;
191
215
  return html`<button
192
216
  type="button"
193
217
  role="tab"
194
218
  data-slot="tabs-trigger"
219
+ id=${ids ? ids.trigger : ''}
220
+ aria-controls=${ids ? ids.panel : ''}
195
221
  data-state=${active ? 'active' : 'inactive'}
196
222
  aria-selected=${String(active)}
197
223
  tabindex=${active ? '0' : '-1'}
@@ -235,12 +261,9 @@ UiTabsTrigger.register('ui-tabs-trigger');
235
261
  // <ui-tabs-content value="...">
236
262
  // --------------------------------------------------------------------------
237
263
 
238
- export class UiTabsContent extends WebComponent {
239
- static properties = {
240
- value: { type: String, reflect: true },
241
- };
242
- declare value: string;
243
-
264
+ export class UiTabsContent extends WebComponent({
265
+ value: prop(String, { reflect: true }),
266
+ }) {
244
267
  constructor() {
245
268
  super();
246
269
  this.value = '';
@@ -254,14 +277,20 @@ export class UiTabsContent extends WebComponent {
254
277
  render() {
255
278
  const tabs = this._tabs;
256
279
  const active = !!tabs && tabs.value === this.value && this.value !== '';
280
+ const ids = tabs && this.value ? tabs.idsFor(this.value) : null;
257
281
  // The host needs to be hidden when inactive so its rendered <section>
258
282
  // is removed from layout (light DOM has no :host CSS to scope this).
259
283
  // Use the native `hidden` IDL property rather than imperative
260
- // setAttribute, so it reads as a property assignment.
284
+ // setAttribute, so it reads as a property assignment. `inert` on the
285
+ // host additionally pulls an inactive panel (and anything focusable
286
+ // inside it) out of the tab order and the accessibility tree.
261
287
  this.hidden = !active;
288
+ this.inert = !active;
262
289
  return html`<section
263
290
  data-slot="tabs-content"
264
291
  role="tabpanel"
292
+ id=${ids ? ids.panel : ''}
293
+ aria-labelledby=${ids ? ids.trigger : ''}
265
294
  tabindex="0"
266
295
  data-state=${active ? 'active' : 'inactive'}
267
296
  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, onBeforeCache } 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,45 @@ export class UiTooltip extends WebComponent {
83
79
  this.skipDelayDuration = 300;
84
80
  }
85
81
 
82
+ _disposeBeforeCache?: () => void;
83
+
84
+ connectedCallback(): void {
85
+ super.connectedCallback?.();
86
+ // webjs projects slotted light-DOM children in a pass after the first
87
+ // render, so the trigger's focusable control is not in place yet at
88
+ // connect / firstUpdated. Defer to the next frame, by which point the
89
+ // projection has run and both the control and the tip content exist.
90
+ if (typeof requestAnimationFrame === 'function') {
91
+ requestAnimationFrame(() => this._wireAria());
92
+ }
93
+ // Close before a back/forward snapshot so it does not restore open (#766).
94
+ this._disposeBeforeCache = onBeforeCache(() => { this.open = false; });
95
+ }
96
+
97
+ disconnectedCallback(): void {
98
+ this._disposeBeforeCache?.();
99
+ super.disconnectedCallback?.();
100
+ }
101
+
102
+ // APG tooltip wiring: the focusable trigger references the tip via
103
+ // aria-describedby, so a screen reader appends the tip text to the
104
+ // trigger's own name ("Help, button, Helpful tip"). The tip is JS-driven,
105
+ // so doing this at runtime is correct.
106
+ _wireAria(): void {
107
+ const triggerHost = this.querySelector('ui-tooltip-trigger');
108
+ const content = this.querySelector<HTMLElement>('ui-tooltip-content [role="tooltip"]');
109
+ if (!triggerHost || !content) return;
110
+ const control =
111
+ triggerHost.querySelector<HTMLElement>('button, a[href], [tabindex], [role="button"]') ??
112
+ (triggerHost as HTMLElement);
113
+ const id = ensureId(content, 'ui-tooltip');
114
+ const existing = control.getAttribute('aria-describedby');
115
+ if (!existing) control.setAttribute('aria-describedby', id);
116
+ else if (!existing.split(/\s+/).includes(id)) {
117
+ control.setAttribute('aria-describedby', `${existing} ${id}`);
118
+ }
119
+ }
120
+
86
121
  // Back-compat getter for tests + external code that read `el.isOpen`
87
122
  // alongside the reactive `open` prop.
88
123
  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
@@ -239,3 +266,19 @@ export const helpClass = () => 'text-xs text-muted-foreground';
239
266
 
240
267
  /** Validation error text: replaces hint when the field is invalid. */
241
268
  export const errorClass = () => 'text-sm font-medium text-destructive';
269
+
270
+ /**
271
+ * Run `reset` just before the webjs client router snapshots the page into its
272
+ * back/forward cache (the `webjs:before-cache` event). Transient overlays use
273
+ * this to close themselves so a restored snapshot is clean rather than, say, a
274
+ * hover-card frozen open after Back then Forward (#766). Wire it in
275
+ * `connectedCallback` and call the returned disposer in `disconnectedCallback`
276
+ * so the listener never leaks across soft navigations.
277
+ *
278
+ * SSR-safe: a no-op when there is no `document` (server / no client router).
279
+ */
280
+ export function onBeforeCache(reset: () => void): () => void {
281
+ if (typeof document === 'undefined') return () => {};
282
+ document.addEventListener('webjs:before-cache', reset);
283
+ return () => document.removeEventListener('webjs:before-cache', reset);
284
+ }
@@ -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
  ],