@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 +20 -2
- package/package.json +1 -1
- package/packages/registry/components/alert-dialog.ts +41 -31
- package/packages/registry/components/alert.ts +5 -0
- package/packages/registry/components/avatar.ts +5 -0
- package/packages/registry/components/badge.ts +4 -0
- package/packages/registry/components/breadcrumb.ts +5 -0
- package/packages/registry/components/button.ts +6 -0
- package/packages/registry/components/dialog.ts +36 -21
- package/packages/registry/components/dropdown-menu.ts +72 -29
- package/packages/registry/components/hover-card.ts +44 -13
- package/packages/registry/components/pagination.ts +5 -0
- package/packages/registry/components/progress.ts +5 -0
- package/packages/registry/components/separator.ts +5 -0
- package/packages/registry/components/skeleton.ts +5 -0
- package/packages/registry/components/sonner.ts +14 -6
- package/packages/registry/components/table.ts +7 -2
- package/packages/registry/components/tabs.ts +49 -30
- package/packages/registry/components/toggle-group.ts +70 -28
- package/packages/registry/components/toggle.ts +7 -13
- package/packages/registry/components/tooltip.ts +37 -11
- package/packages/registry/lib/utils.ts +27 -0
- package/packages/registry/registry.json +14 -1
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
|
|
34
|
-
Lit-shaped base class
|
|
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.
|
|
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
|
-
|
|
136
|
-
|
|
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
|
-
|
|
228
|
-
|
|
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
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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
|
-
|
|
162
|
-
|
|
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
|
-
|
|
262
|
-
|
|
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
|
-
|
|
364
|
-
|
|
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
|
-
|
|
121
|
-
|
|
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
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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
|
-
|
|
473
|
-
|
|
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
|
-
|
|
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
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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
|
-
|
|
139
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
145
|
-
|
|
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
|
-
|
|
171
|
-
|
|
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
|
-
|
|
240
|
-
|
|
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:
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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.
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
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
|
-
|
|
74
|
-
|
|
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
|
],
|