@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.
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.7",
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": {
@@ -19,7 +19,8 @@
19
19
  "README.md"
20
20
  ],
21
21
  "scripts": {
22
- "test": "node --test test/*.test.js"
22
+ "test": "node --test test/*.test.js",
23
+ "test:e2e:touch": "node test/e2e/touch.e2e.mjs"
23
24
  },
24
25
  "dependencies": {
25
26
  "commander": "^14.0.0",
@@ -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, onBeforeCache } from '../lib/utils.ts';
68
69
  import { buttonClass, type ButtonVariant, type ButtonSize } from './button.ts';
69
70
 
70
71
  export const alertDialogContentClass = (): string =>
@@ -86,7 +87,10 @@ ui-alert-dialog:not([open]) ui-alert-dialog-content { display: none !important;
86
87
  ui-alert-dialog-content { display: grid; }
87
88
  `;
88
89
 
89
- const NATIVE_DIALOG_CLASS = 'border-0 bg-transparent p-0 m-0 w-0 h-0 max-w-none max-h-none overflow-visible text-inherit backdrop:bg-black/50';
90
+ // Fills the viewport (fixed inset-0), NOT 0x0 (#730): WebKit makes a
91
+ // top-layer <dialog> the containing block for its position:fixed descendants,
92
+ // so a 0x0 host collapsed the fixed content panel to 0x0 (invisible on iOS).
93
+ const NATIVE_DIALOG_CLASS = 'fixed inset-0 border-0 bg-transparent p-0 m-0 max-w-none max-h-none overflow-visible text-inherit backdrop:bg-black/50';
90
94
 
91
95
  function installStyles(): void {
92
96
  if (typeof document === 'undefined') return;
@@ -131,27 +135,30 @@ function unlockScroll(): void {
131
135
  // content child via prop transitions.
132
136
  // --------------------------------------------------------------------------
133
137
 
134
- export class UiAlertDialog extends WebComponent {
135
- static properties = {
136
- open: { type: Boolean, reflect: true },
137
- };
138
- declare open: boolean;
139
-
138
+ export class UiAlertDialog extends WebComponent({
139
+ open: prop(Boolean, { reflect: true }),
140
+ }) {
140
141
  constructor() {
141
142
  super();
142
143
  this.open = false;
143
144
  }
144
145
 
146
+ _disposeBeforeCache?: () => void;
147
+
145
148
  connectedCallback(): void {
146
149
  installStyles();
147
150
  // Legacy <ui-alert-dialog-overlay> isn't supported anymore; the native
148
151
  // ::backdrop pseudo replaces it. Strip it if a stale doc uses it.
149
152
  this.querySelector<HTMLElement>(':scope > ui-alert-dialog-overlay')?.remove();
150
153
  super.connectedCallback?.();
154
+ // Close before a back/forward snapshot: a modal restored half-open (the
155
+ // `open` attr without the native showModal top-layer state) is broken (#766).
156
+ this._disposeBeforeCache = onBeforeCache(() => { this.open = false; });
151
157
  }
152
158
 
153
159
  disconnectedCallback(): void {
154
160
  if (this.open) this._teardown();
161
+ this._disposeBeforeCache?.();
155
162
  super.disconnectedCallback?.();
156
163
  }
157
164
 
@@ -223,12 +230,9 @@ UiAlertDialogTrigger.register('ui-alert-dialog-trigger');
223
230
  // explicit choice via Cancel / Action).
224
231
  // --------------------------------------------------------------------------
225
232
 
226
- export class UiAlertDialogContent extends WebComponent {
227
- static properties = {
228
- size: { type: String, reflect: true },
229
- };
230
- declare size: 'default' | 'sm';
231
-
233
+ export class UiAlertDialogContent extends WebComponent({
234
+ size: prop<'default' | 'sm'>(String, { reflect: true }),
235
+ }) {
232
236
  // ref to the own-rendered native <dialog>. render() creates it, so a
233
237
  // ref() binding is the lit-idiomatic handle (no querySelector against
234
238
  // a string selector into the component's own output).
@@ -239,13 +243,44 @@ export class UiAlertDialogContent extends WebComponent {
239
243
  this.size = 'default';
240
244
  }
241
245
 
246
+ // Query the native <dialog> rather than the #dialog ref: the ref's `.value`
247
+ // came back null on iOS WebKit (the binding did not populate through SSR
248
+ // hydration), so showModal() never ran (#730). querySelector is robust on
249
+ // every engine.
250
+ _native(): HTMLDialogElement | null {
251
+ return this.querySelector<HTMLDialogElement>('dialog[data-slot="alert-dialog-native"]');
252
+ }
253
+
242
254
  showModal(): void {
243
- const native = this.#dialog.value;
255
+ this._wireLabels();
256
+ const native = this._native();
244
257
  if (native && !native.open) native.showModal();
245
258
  }
246
259
 
260
+ // Wire the alertdialog's accessible name + description to its title /
261
+ // description nodes at open time (an alert dialog only ever appears via
262
+ // showModal(), so there is no SSR id-stability concern). The title is
263
+ // data-slot="alert-dialog-title" (falling back to the first heading); the
264
+ // description is data-slot="alert-dialog-description" (falling back to the
265
+ // first paragraph). Author-set ARIA always wins. Inlined rather than shared
266
+ // with dialog.ts so `webjs ui add alert-dialog` stays self-contained.
267
+ _wireLabels(): void {
268
+ const panel = this.querySelector('[data-slot="alert-dialog-content"]');
269
+ if (!panel) return;
270
+ const title =
271
+ this.querySelector('[data-slot="alert-dialog-title"]') ?? this.querySelector('h1, h2, h3');
272
+ const desc =
273
+ this.querySelector('[data-slot="alert-dialog-description"]') ?? this.querySelector('p');
274
+ if (title && !panel.hasAttribute('aria-labelledby')) {
275
+ panel.setAttribute('aria-labelledby', ensureId(title as HTMLElement, 'ui-alert-title'));
276
+ }
277
+ if (desc && !panel.hasAttribute('aria-describedby')) {
278
+ panel.setAttribute('aria-describedby', ensureId(desc as HTMLElement, 'ui-alert-desc'));
279
+ }
280
+ }
281
+
247
282
  close(): void {
248
- const native = this.#dialog.value;
283
+ const native = this._native();
249
284
  if (native?.open) native.close();
250
285
  }
251
286
 
@@ -311,14 +346,10 @@ UiAlertDialogContent.register('ui-alert-dialog-content');
311
346
 
312
347
  const ALERT_DIALOG_ACTION_GRID_STRETCH = 'group-data-[size=sm]/alert-dialog-content:w-full';
313
348
 
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
-
349
+ export class UiAlertDialogCancel extends WebComponent({
350
+ variant: prop<ButtonVariant>(String, { reflect: true }),
351
+ size: prop<ButtonSize>(String, { reflect: true }),
352
+ }) {
322
353
  constructor() {
323
354
  super();
324
355
  this.variant = 'outline';
@@ -338,14 +369,10 @@ export class UiAlertDialogCancel extends WebComponent {
338
369
  }
339
370
  UiAlertDialogCancel.register('ui-alert-dialog-cancel');
340
371
 
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
-
372
+ export class UiAlertDialogAction extends WebComponent({
373
+ variant: prop<ButtonVariant>(String, { reflect: true }),
374
+ size: prop<ButtonSize>(String, { reflect: true }),
375
+ }) {
349
376
  constructor() {
350
377
  super();
351
378
  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, onBeforeCache } 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
  // --------------------------------------------------------------------------
@@ -110,7 +133,16 @@ ui-dialog-content { display: grid; }
110
133
  // Clears the UA defaults on <dialog> so it becomes an invisible top-layer
111
134
  // host. The visible box is rendered by <ui-dialog-content>. The
112
135
  // backdrop: variant styles the ::backdrop pseudo-element.
113
- const NATIVE_DIALOG_CLASS = 'border-0 bg-transparent p-0 m-0 w-0 h-0 max-w-none max-h-none overflow-visible text-inherit backdrop:bg-black/50';
136
+ //
137
+ // The host FILLS THE VIEWPORT (fixed inset-0) rather than collapsing to
138
+ // 0x0 (#730): WebKit makes a top-layer <dialog> the containing block for
139
+ // its position:fixed descendants, so a 0x0 host collapsed the fixed content
140
+ // panel (w-full) to 0x0 and the dialog was invisible on iOS (Blink resolves
141
+ // the same fixed panel against the viewport, so it worked on Android). A
142
+ // viewport-sized transparent host gives the panel a correct containing block
143
+ // on both engines; the panel still centers itself and the ::backdrop covers
144
+ // the screen.
145
+ const NATIVE_DIALOG_CLASS = 'fixed inset-0 border-0 bg-transparent p-0 m-0 max-w-none max-h-none overflow-visible text-inherit backdrop:bg-black/50';
114
146
 
115
147
  function installStyles(): void {
116
148
  if (typeof document === 'undefined') return;
@@ -157,27 +189,30 @@ function unlockScroll(): void {
157
189
  // to showModal() / close() its inner <dialog>.
158
190
  // --------------------------------------------------------------------------
159
191
 
160
- export class UiDialog extends WebComponent {
161
- static properties = {
162
- open: { type: Boolean, reflect: true },
163
- };
164
- declare open: boolean;
165
-
192
+ export class UiDialog extends WebComponent({
193
+ open: prop(Boolean, { reflect: true }),
194
+ }) {
166
195
  constructor() {
167
196
  super();
168
197
  this.open = false;
169
198
  }
170
199
 
200
+ _disposeBeforeCache?: () => void;
201
+
171
202
  connectedCallback(): void {
172
203
  installStyles();
173
204
  // Legacy <ui-dialog-overlay> isn't supported anymore; the native
174
205
  // ::backdrop pseudo replaces it. Strip it if a stale doc uses it.
175
206
  this.querySelector<HTMLElement>(':scope > ui-dialog-overlay')?.remove();
176
207
  super.connectedCallback?.();
208
+ // Close before a back/forward snapshot: a modal restored half-open (the
209
+ // `open` attr without the native showModal top-layer state) is broken (#766).
210
+ this._disposeBeforeCache = onBeforeCache(() => { this.open = false; });
177
211
  }
178
212
 
179
213
  disconnectedCallback(): void {
180
214
  if (this.open) this._teardown();
215
+ this._disposeBeforeCache?.();
181
216
  super.disconnectedCallback?.();
182
217
  }
183
218
 
@@ -257,12 +292,9 @@ UiDialogTrigger.register('ui-dialog-trigger');
257
292
  // close button. Exposes showModal() / close() so the parent <ui-dialog>
258
293
  // can drive the open state imperatively without a named slot.
259
294
 
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
-
295
+ export class UiDialogContent extends WebComponent({
296
+ showCloseButton: prop(String, { reflect: true, attribute: 'show-close-button' }),
297
+ }) {
266
298
  // ref to the own-rendered native <dialog>. render() creates it, so a
267
299
  // ref() binding is the lit-idiomatic handle (no querySelector against
268
300
  // a string selector into the component's own output).
@@ -273,13 +305,23 @@ export class UiDialogContent extends WebComponent {
273
305
  this.showCloseButton = 'true';
274
306
  }
275
307
 
308
+ // Resolve the native <dialog> by query, NOT the #dialog ref: on iOS WebKit
309
+ // the ref's `.value` came back null (the ref binding did not populate through
310
+ // SSR hydration), so showModal() was never called and the dialog never opened
311
+ // (#730). A direct querySelector is robust on every engine, and matches what
312
+ // the tooltip / popover components already do.
313
+ _native(): HTMLDialogElement | null {
314
+ return this.querySelector<HTMLDialogElement>('dialog[data-slot="dialog-native"]');
315
+ }
316
+
276
317
  showModal(): void {
277
- const native = this.#dialog.value;
318
+ wireDialogLabels(this, '[data-slot="dialog-content"]');
319
+ const native = this._native();
278
320
  if (native && !native.open) native.showModal();
279
321
  }
280
322
 
281
323
  close(): void {
282
- const native = this.#dialog.value;
324
+ const native = this._native();
283
325
  if (native?.open) native.close();
284
326
  }
285
327
 
@@ -359,12 +401,9 @@ UiDialogClose.register('ui-dialog-close');
359
401
  // <ui-dialog-footer>
360
402
  // --------------------------------------------------------------------------
361
403
 
362
- export class UiDialogFooter extends WebComponent {
363
- static properties = {
364
- showCloseButton: { type: String, attribute: 'show-close-button' },
365
- };
366
- declare showCloseButton: string | null;
367
-
404
+ export class UiDialogFooter extends WebComponent({
405
+ showCloseButton: prop<string | null>(String, { attribute: 'show-close-button' }),
406
+ }) {
368
407
  constructor() {
369
408
  super();
370
409
  this.showCloseButton = null;