@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 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.4",
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
- static properties = {
135
- open: { type: Boolean, reflect: true },
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
- static properties = {
227
- size: { type: String, reflect: true },
228
- };
229
- declare size: 'default' | 'sm';
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
- const native = this.querySelector<HTMLDialogElement>('dialog[data-slot="alert-dialog-native"]');
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.querySelector<HTMLDialogElement>('dialog[data-slot="alert-dialog-native"]');
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
- static properties = {
309
- variant: { type: String, reflect: true },
310
- size: { type: String, reflect: true },
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
- static properties = {
336
- variant: { type: String, reflect: true },
337
- size: { type: String, reflect: true },
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
- static properties = {
161
- open: { type: Boolean, reflect: true },
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
- static properties = {
261
- showCloseButton: { type: String, reflect: true, attribute: 'show-close-button' },
262
- };
263
- declare showCloseButton: string;
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
- const native = this.querySelector<HTMLDialogElement>('dialog[data-slot="dialog-native"]');
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.querySelector<HTMLDialogElement>('dialog[data-slot="dialog-native"]');
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
- static properties = {
357
- showCloseButton: { type: String, attribute: 'show-close-button' },
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
- static properties = {
121
- open: { type: Boolean, reflect: true },
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
- static properties = {
345
- variant: { type: String, reflect: true },
346
- inset: { type: Boolean },
347
- };
348
- declare variant: 'default' | 'destructive';
349
- declare inset: boolean;
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 = (e: Event): void => {
385
- (e.currentTarget as HTMLElement).setAttribute('data-highlighted', '');
439
+ _onFocus = (): void => {
440
+ this.#highlighted.set(true);
386
441
  };
387
442
 
388
- _onBlur = (e: Event): void => {
389
- (e.currentTarget as HTMLElement).removeAttribute('data-highlighted');
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
- static properties = {
466
- open: { type: Boolean, reflect: true },
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>`;