@webjsdev/ui 0.3.4 → 0.3.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -2
- package/package.json +1 -1
- package/packages/registry/components/alert-dialog.ts +49 -32
- 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 +44 -22
- package/packages/registry/components/dropdown-menu.ts +82 -32
- 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 +52 -33
- package/packages/registry/components/toggle-group.ts +73 -31
- 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,7 +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
|
+
import { ref, createRef } from '@webjsdev/core/directives';
|
|
68
|
+
import { ensureId } from '../lib/utils.ts';
|
|
67
69
|
import { buttonClass, type ButtonVariant, type ButtonSize } from './button.ts';
|
|
68
70
|
|
|
69
71
|
export const alertDialogContentClass = (): string =>
|
|
@@ -130,12 +132,9 @@ function unlockScroll(): void {
|
|
|
130
132
|
// content child via prop transitions.
|
|
131
133
|
// --------------------------------------------------------------------------
|
|
132
134
|
|
|
133
|
-
export class UiAlertDialog extends WebComponent
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
};
|
|
137
|
-
declare open: boolean;
|
|
138
|
-
|
|
135
|
+
export class UiAlertDialog extends WebComponent({
|
|
136
|
+
open: prop(Boolean, { reflect: true }),
|
|
137
|
+
}) {
|
|
139
138
|
constructor() {
|
|
140
139
|
super();
|
|
141
140
|
this.open = false;
|
|
@@ -222,11 +221,13 @@ UiAlertDialogTrigger.register('ui-alert-dialog-trigger');
|
|
|
222
221
|
// explicit choice via Cancel / Action).
|
|
223
222
|
// --------------------------------------------------------------------------
|
|
224
223
|
|
|
225
|
-
export class UiAlertDialogContent extends WebComponent
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
224
|
+
export class UiAlertDialogContent extends WebComponent({
|
|
225
|
+
size: prop<'default' | 'sm'>(String, { reflect: true }),
|
|
226
|
+
}) {
|
|
227
|
+
// ref to the own-rendered native <dialog>. render() creates it, so a
|
|
228
|
+
// ref() binding is the lit-idiomatic handle (no querySelector against
|
|
229
|
+
// a string selector into the component's own output).
|
|
230
|
+
#dialog = createRef<HTMLDialogElement>();
|
|
230
231
|
|
|
231
232
|
constructor() {
|
|
232
233
|
super();
|
|
@@ -234,12 +235,35 @@ export class UiAlertDialogContent extends WebComponent {
|
|
|
234
235
|
}
|
|
235
236
|
|
|
236
237
|
showModal(): void {
|
|
237
|
-
|
|
238
|
+
this._wireLabels();
|
|
239
|
+
const native = this.#dialog.value;
|
|
238
240
|
if (native && !native.open) native.showModal();
|
|
239
241
|
}
|
|
240
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
|
+
|
|
241
265
|
close(): void {
|
|
242
|
-
const native = this.
|
|
266
|
+
const native = this.#dialog.value;
|
|
243
267
|
if (native?.open) native.close();
|
|
244
268
|
}
|
|
245
269
|
|
|
@@ -248,6 +272,7 @@ export class UiAlertDialogContent extends WebComponent {
|
|
|
248
272
|
return html`<dialog
|
|
249
273
|
data-slot="alert-dialog-native"
|
|
250
274
|
class=${NATIVE_DIALOG_CLASS}
|
|
275
|
+
${ref(this.#dialog)}
|
|
251
276
|
@cancel=${this._onNativeCancel}
|
|
252
277
|
@close=${this._onNativeClose}
|
|
253
278
|
><div
|
|
@@ -304,14 +329,10 @@ UiAlertDialogContent.register('ui-alert-dialog-content');
|
|
|
304
329
|
|
|
305
330
|
const ALERT_DIALOG_ACTION_GRID_STRETCH = 'group-data-[size=sm]/alert-dialog-content:w-full';
|
|
306
331
|
|
|
307
|
-
export class UiAlertDialogCancel extends WebComponent
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
};
|
|
312
|
-
declare variant: ButtonVariant;
|
|
313
|
-
declare size: ButtonSize;
|
|
314
|
-
|
|
332
|
+
export class UiAlertDialogCancel extends WebComponent({
|
|
333
|
+
variant: prop<ButtonVariant>(String, { reflect: true }),
|
|
334
|
+
size: prop<ButtonSize>(String, { reflect: true }),
|
|
335
|
+
}) {
|
|
315
336
|
constructor() {
|
|
316
337
|
super();
|
|
317
338
|
this.variant = 'outline';
|
|
@@ -331,14 +352,10 @@ export class UiAlertDialogCancel extends WebComponent {
|
|
|
331
352
|
}
|
|
332
353
|
UiAlertDialogCancel.register('ui-alert-dialog-cancel');
|
|
333
354
|
|
|
334
|
-
export class UiAlertDialogAction extends WebComponent
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
};
|
|
339
|
-
declare variant: ButtonVariant;
|
|
340
|
-
declare size: ButtonSize;
|
|
341
|
-
|
|
355
|
+
export class UiAlertDialogAction extends WebComponent({
|
|
356
|
+
variant: prop<ButtonVariant>(String, { reflect: true }),
|
|
357
|
+
size: prop<ButtonSize>(String, { reflect: true }),
|
|
358
|
+
}) {
|
|
342
359
|
constructor() {
|
|
343
360
|
super();
|
|
344
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,9 +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
|
+
import { ref, createRef } from '@webjsdev/core/directives';
|
|
68
|
+
import { ensureId } from '../lib/utils.ts';
|
|
67
69
|
import { buttonClass } from './button.ts';
|
|
68
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
|
+
|
|
69
93
|
// --------------------------------------------------------------------------
|
|
70
94
|
// Class helpers for subparts.
|
|
71
95
|
// --------------------------------------------------------------------------
|
|
@@ -156,12 +180,9 @@ function unlockScroll(): void {
|
|
|
156
180
|
// to showModal() / close() its inner <dialog>.
|
|
157
181
|
// --------------------------------------------------------------------------
|
|
158
182
|
|
|
159
|
-
export class UiDialog extends WebComponent
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
};
|
|
163
|
-
declare open: boolean;
|
|
164
|
-
|
|
183
|
+
export class UiDialog extends WebComponent({
|
|
184
|
+
open: prop(Boolean, { reflect: true }),
|
|
185
|
+
}) {
|
|
165
186
|
constructor() {
|
|
166
187
|
super();
|
|
167
188
|
this.open = false;
|
|
@@ -256,11 +277,13 @@ UiDialogTrigger.register('ui-dialog-trigger');
|
|
|
256
277
|
// close button. Exposes showModal() / close() so the parent <ui-dialog>
|
|
257
278
|
// can drive the open state imperatively without a named slot.
|
|
258
279
|
|
|
259
|
-
export class UiDialogContent extends WebComponent
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
280
|
+
export class UiDialogContent extends WebComponent({
|
|
281
|
+
showCloseButton: prop(String, { reflect: true, attribute: 'show-close-button' }),
|
|
282
|
+
}) {
|
|
283
|
+
// ref to the own-rendered native <dialog>. render() creates it, so a
|
|
284
|
+
// ref() binding is the lit-idiomatic handle (no querySelector against
|
|
285
|
+
// a string selector into the component's own output).
|
|
286
|
+
#dialog = createRef<HTMLDialogElement>();
|
|
264
287
|
|
|
265
288
|
constructor() {
|
|
266
289
|
super();
|
|
@@ -268,12 +291,13 @@ export class UiDialogContent extends WebComponent {
|
|
|
268
291
|
}
|
|
269
292
|
|
|
270
293
|
showModal(): void {
|
|
271
|
-
|
|
294
|
+
wireDialogLabels(this, '[data-slot="dialog-content"]');
|
|
295
|
+
const native = this.#dialog.value;
|
|
272
296
|
if (native && !native.open) native.showModal();
|
|
273
297
|
}
|
|
274
298
|
|
|
275
299
|
close(): void {
|
|
276
|
-
const native = this.
|
|
300
|
+
const native = this.#dialog.value;
|
|
277
301
|
if (native?.open) native.close();
|
|
278
302
|
}
|
|
279
303
|
|
|
@@ -283,6 +307,7 @@ export class UiDialogContent extends WebComponent {
|
|
|
283
307
|
return html`<dialog
|
|
284
308
|
data-slot="dialog-native"
|
|
285
309
|
class=${NATIVE_DIALOG_CLASS}
|
|
310
|
+
${ref(this.#dialog)}
|
|
286
311
|
@close=${this._onNativeClose}
|
|
287
312
|
@click=${this._onNativeBackdropClick}
|
|
288
313
|
><div
|
|
@@ -352,12 +377,9 @@ UiDialogClose.register('ui-dialog-close');
|
|
|
352
377
|
// <ui-dialog-footer>
|
|
353
378
|
// --------------------------------------------------------------------------
|
|
354
379
|
|
|
355
|
-
export class UiDialogFooter extends WebComponent
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
};
|
|
359
|
-
declare showCloseButton: string | null;
|
|
360
|
-
|
|
380
|
+
export class UiDialogFooter extends WebComponent({
|
|
381
|
+
showCloseButton: prop<string | null>(String, { attribute: 'show-close-button' }),
|
|
382
|
+
}) {
|
|
361
383
|
constructor() {
|
|
362
384
|
super();
|
|
363
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 } 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,13 +385,15 @@ 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
|
-
|
|
349
|
-
|
|
388
|
+
export class UiDropdownMenuItem extends WebComponent({
|
|
389
|
+
variant: prop<'default' | 'destructive'>(String, { reflect: true }),
|
|
390
|
+
inset: Boolean,
|
|
391
|
+
}) {
|
|
392
|
+
// Keyboard / pointer highlight state for the own-rendered menuitem. A
|
|
393
|
+
// local signal bound with ?data-highlighted keeps the highlight in the
|
|
394
|
+
// declarative template instead of an imperative setAttribute on
|
|
395
|
+
// e.currentTarget (the lit-idiomatic form).
|
|
396
|
+
#highlighted = signal(false);
|
|
350
397
|
|
|
351
398
|
constructor() {
|
|
352
399
|
super();
|
|
@@ -355,12 +402,20 @@ export class UiDropdownMenuItem extends WebComponent {
|
|
|
355
402
|
}
|
|
356
403
|
|
|
357
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');
|
|
358
410
|
return html`<div
|
|
359
411
|
data-slot="dropdown-menu-item"
|
|
360
412
|
role="menuitem"
|
|
361
413
|
tabindex="-1"
|
|
362
414
|
data-variant=${this.variant}
|
|
363
415
|
?data-inset=${this.inset}
|
|
416
|
+
?data-disabled=${disabled}
|
|
417
|
+
aria-disabled=${disabled ? 'true' : 'false'}
|
|
418
|
+
?data-highlighted=${this.#highlighted.get()}
|
|
364
419
|
class=${dropdownMenuItemClass()}
|
|
365
420
|
@click=${this._onClick}
|
|
366
421
|
@pointerenter=${this._onPointerEnter}
|
|
@@ -381,12 +436,12 @@ export class UiDropdownMenuItem extends WebComponent {
|
|
|
381
436
|
el.focus();
|
|
382
437
|
};
|
|
383
438
|
|
|
384
|
-
_onFocus = (
|
|
385
|
-
|
|
439
|
+
_onFocus = (): void => {
|
|
440
|
+
this.#highlighted.set(true);
|
|
386
441
|
};
|
|
387
442
|
|
|
388
|
-
_onBlur = (
|
|
389
|
-
|
|
443
|
+
_onBlur = (): void => {
|
|
444
|
+
this.#highlighted.set(false);
|
|
390
445
|
};
|
|
391
446
|
}
|
|
392
447
|
UiDropdownMenuItem.register('ui-dropdown-menu-item');
|
|
@@ -395,10 +450,7 @@ UiDropdownMenuItem.register('ui-dropdown-menu-item');
|
|
|
395
450
|
// <ui-dropdown-menu-label>
|
|
396
451
|
// --------------------------------------------------------------------------
|
|
397
452
|
|
|
398
|
-
export class UiDropdownMenuLabel extends WebComponent {
|
|
399
|
-
static properties = { inset: { type: Boolean } };
|
|
400
|
-
declare inset: boolean;
|
|
401
|
-
|
|
453
|
+
export class UiDropdownMenuLabel extends WebComponent({ inset: Boolean }) {
|
|
402
454
|
constructor() {
|
|
403
455
|
super();
|
|
404
456
|
this.inset = false;
|
|
@@ -461,12 +513,9 @@ UiDropdownMenuGroup.register('ui-dropdown-menu-group');
|
|
|
461
513
|
// Submenu: Sub / SubTrigger / SubContent
|
|
462
514
|
// --------------------------------------------------------------------------
|
|
463
515
|
|
|
464
|
-
export class UiDropdownMenuSub extends WebComponent
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
};
|
|
468
|
-
declare open: boolean;
|
|
469
|
-
|
|
516
|
+
export class UiDropdownMenuSub extends WebComponent({
|
|
517
|
+
open: prop(Boolean, { reflect: true }),
|
|
518
|
+
}) {
|
|
470
519
|
_closeTimer: number | undefined;
|
|
471
520
|
|
|
472
521
|
constructor() {
|
|
@@ -548,10 +597,7 @@ export class UiDropdownMenuSub extends WebComponent {
|
|
|
548
597
|
}
|
|
549
598
|
UiDropdownMenuSub.register('ui-dropdown-menu-sub');
|
|
550
599
|
|
|
551
|
-
export class UiDropdownMenuSubTrigger extends WebComponent {
|
|
552
|
-
static properties = { inset: { type: Boolean } };
|
|
553
|
-
declare inset: boolean;
|
|
554
|
-
|
|
600
|
+
export class UiDropdownMenuSubTrigger extends WebComponent({ inset: Boolean }) {
|
|
555
601
|
constructor() {
|
|
556
602
|
super();
|
|
557
603
|
this.inset = false;
|
|
@@ -565,14 +611,17 @@ export class UiDropdownMenuSubTrigger extends WebComponent {
|
|
|
565
611
|
|
|
566
612
|
render() {
|
|
567
613
|
const open = !!this._sub()?.open;
|
|
614
|
+
const disabled = typeof this.hasAttribute === 'function' && this.hasAttribute('data-disabled');
|
|
568
615
|
return html`<div
|
|
569
616
|
data-slot="dropdown-menu-sub-trigger"
|
|
570
617
|
role="menuitem"
|
|
571
618
|
tabindex="-1"
|
|
572
619
|
aria-haspopup="menu"
|
|
573
620
|
aria-expanded=${String(open)}
|
|
621
|
+
aria-disabled=${disabled ? 'true' : 'false'}
|
|
574
622
|
data-state=${open ? 'open' : 'closed'}
|
|
575
623
|
?data-inset=${this.inset}
|
|
624
|
+
?data-disabled=${disabled}
|
|
576
625
|
class=${dropdownMenuSubTriggerClass()}
|
|
577
626
|
@click=${this._onClick}
|
|
578
627
|
@pointerenter=${this._onPointerEnter}
|
|
@@ -599,6 +648,7 @@ export class UiDropdownMenuSubContent extends WebComponent {
|
|
|
599
648
|
return html`<div
|
|
600
649
|
data-slot="dropdown-menu-sub-content"
|
|
601
650
|
role="menu"
|
|
651
|
+
aria-orientation="vertical"
|
|
602
652
|
popover="manual"
|
|
603
653
|
class=${dropdownMenuSubContentClass()}
|
|
604
654
|
><slot></slot></div>`;
|