@webjsdev/ui 0.3.9 → 0.3.11

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.
Files changed (37) hide show
  1. package/README.md +21 -13
  2. package/package.json +3 -3
  3. package/packages/registry/README.md +14 -10
  4. package/packages/registry/components/accordion.ts +18 -0
  5. package/packages/registry/components/alert-dialog.ts +203 -22
  6. package/packages/registry/components/aspect-ratio.ts +12 -0
  7. package/packages/registry/components/card.ts +20 -1
  8. package/packages/registry/components/checkbox.ts +45 -3
  9. package/packages/registry/components/collapsible.ts +15 -0
  10. package/packages/registry/components/dialog.ts +205 -22
  11. package/packages/registry/components/dropdown-menu.ts +390 -22
  12. package/packages/registry/components/hover-card.ts +162 -4
  13. package/packages/registry/components/input.ts +34 -2
  14. package/packages/registry/components/kbd.ts +35 -2
  15. package/packages/registry/components/label.ts +21 -0
  16. package/packages/registry/components/native-select.ts +28 -7
  17. package/packages/registry/components/popover.ts +43 -7
  18. package/packages/registry/components/radio-group.ts +37 -9
  19. package/packages/registry/components/sonner.ts +53 -5
  20. package/packages/registry/components/switch.ts +23 -1
  21. package/packages/registry/components/tabs.ts +29 -2
  22. package/packages/registry/components/textarea.ts +21 -2
  23. package/packages/registry/components/toggle-group.ts +81 -8
  24. package/packages/registry/components/toggle.ts +82 -3
  25. package/packages/registry/components/tooltip.ts +57 -0
  26. package/packages/registry/lib/utils.ts +178 -13
  27. package/packages/registry/registry.json +1 -1
  28. package/packages/registry/themes/base-colors.js +1 -1
  29. package/packages/registry/themes/index.css +11 -0
  30. package/src/commands/add.js +50 -0
  31. package/src/commands/info.js +1 -4
  32. package/src/commands/init.js +126 -24
  33. package/src/registry/extract.js +1 -7
  34. package/src/registry/fetcher.js +9 -1
  35. package/src/registry/local.js +1 -1
  36. package/src/utils/index.js +0 -1
  37. package/src/utils/detect-project.js +0 -63
@@ -41,7 +41,12 @@
41
41
  *
42
42
  * Events:
43
43
  * `ui-open-change` on <ui-dropdown-menu>: `{ detail: { open } }` after a transition.
44
- * `ui-item-select` bubbled by an item: `{ detail: { value, item } }` on activation.
44
+ * `ui-item-select` bubbled by an item on activation, CANCELABLE:
45
+ * `{ detail: { value, item, type, checked } }`. `checked` is the state the
46
+ * item has settled on, so a checkbox / radio listener reads it directly.
47
+ * Calling `preventDefault()` keeps the menu OPEN (the parity shape for
48
+ * shadcn's `onSelect={e => e.preventDefault()}`), which is what a
49
+ * multi-select checkbox menu wants.
45
50
  *
46
51
  * Programmatic API on <ui-dropdown-menu>: `.show()` · `.hide()` · `.toggle()`.
47
52
  *
@@ -51,9 +56,27 @@
51
56
  * ArrowLeft inside a submenu: close it, refocus the sub-trigger
52
57
  * Home / End first / last item
53
58
  * Enter / Space activate focused item
54
- * Escape close menu (or close current submenu first)
59
+ * Escape close the menu that holds focus (a submenu first,
60
+ * refocusing its sub-trigger) and refocus the trigger
55
61
  * Tab close menu and proceed with normal tab order
56
62
  *
63
+ * A11y (owned by the element, nothing to supply):
64
+ * The trigger gets `aria-haspopup` / `aria-expanded` / `aria-controls`, the
65
+ * panel is a `role="menu"` labelled back by the trigger, and a disabled item
66
+ * reflects `aria-disabled`. EVERY close path leaves focus somewhere sensible
67
+ * rather than dropping it to `<body>`: Escape, Tab, and item activation
68
+ * return focus to the trigger, and so does an outside click that did not put
69
+ * focus anywhere itself. An outside click ON another control leaves focus on
70
+ * that control, since the user chose it.
71
+ * A `type="checkbox"` / `type="radio"` item carries
72
+ * `role="menuitemcheckbox"` / `role="menuitemradio"` plus `aria-checked`.
73
+ * What you DO supply: a name for a radio set, as `aria-label` on the
74
+ * enclosing `<ui-dropdown-menu-group>` (APG asks a menuitemradio set to sit
75
+ * in a labelled group; the group forwards the name onto its `role="group"`).
76
+ * Put it in the MARKUP. The group declares no reactive props, so it renders
77
+ * once and its `aria-label` / `aria-labelledby` are not observed attributes: a
78
+ * name set on the host after mount is never picked up.
79
+ *
57
80
  * Design tokens used: --popover, --popover-foreground, --accent,
58
81
  * --accent-foreground, --destructive, --muted-foreground, --border.
59
82
  *
@@ -77,6 +100,38 @@
77
100
  * <ui-dropdown-menu-item variant="destructive">Sign out</ui-dropdown-menu-item>
78
101
  * </ui-dropdown-menu-content>
79
102
  * </ui-dropdown-menu>
103
+ *
104
+ * <!-- Checkbox items. Read the new state off the event's detail.checked. -->
105
+ * <ui-dropdown-menu @ui-item-select=${onSelect}>
106
+ * <ui-dropdown-menu-trigger>
107
+ * <button class=${buttonClass({ variant: 'outline' })}>View</button>
108
+ * </ui-dropdown-menu-trigger>
109
+ * <ui-dropdown-menu-content>
110
+ * <ui-dropdown-menu-item type="checkbox" value="status" checked>
111
+ * Status bar
112
+ * </ui-dropdown-menu-item>
113
+ * <ui-dropdown-menu-item type="checkbox" value="activity">
114
+ * Activity bar
115
+ * </ui-dropdown-menu-item>
116
+ * </ui-dropdown-menu-content>
117
+ * </ui-dropdown-menu>
118
+ *
119
+ * <!-- A multi-select menu cancels the event so the menu stays open:
120
+ * const onSelect = (e) => { e.preventDefault(); save(e.detail); }; -->
121
+ *
122
+ * <!-- Radio items: one checked per group. Name the set on the group. -->
123
+ * <ui-dropdown-menu>
124
+ * <ui-dropdown-menu-trigger>
125
+ * <button class=${buttonClass({ variant: 'outline' })}>Panel</button>
126
+ * </ui-dropdown-menu-trigger>
127
+ * <ui-dropdown-menu-content>
128
+ * <ui-dropdown-menu-group aria-label="Panel position">
129
+ * <ui-dropdown-menu-item type="radio" value="top" checked>Top</ui-dropdown-menu-item>
130
+ * <ui-dropdown-menu-item type="radio" value="bottom">Bottom</ui-dropdown-menu-item>
131
+ * <ui-dropdown-menu-item type="radio" value="right">Right</ui-dropdown-menu-item>
132
+ * </ui-dropdown-menu-group>
133
+ * </ui-dropdown-menu-content>
134
+ * </ui-dropdown-menu>
80
135
  * ```
81
136
  */
82
137
  import { WebComponent, html, unsafeHTML, signal, prop } from '@webjsdev/core';
@@ -117,8 +172,22 @@ export const dropdownMenuSubContentClass = (): string =>
117
172
  const CHEVRON_RIGHT_SVG =
118
173
  '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="ml-auto size-4" aria-hidden="true"><polyline points="9 18 15 12 9 6"></polyline></svg>';
119
174
 
175
+ // Checked-state indicators for a checkbox / radio item. Decorative: the state
176
+ // they draw is announced from aria-checked, so both are aria-hidden.
177
+ const CHECK_SVG =
178
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4" aria-hidden="true"><polyline points="20 6 9 17 4 12"></polyline></svg>';
179
+
180
+ const RADIO_DOT_SVG =
181
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-2" aria-hidden="true"><circle cx="12" cy="12" r="12"></circle></svg>';
182
+
120
183
  const SUB_CLOSE_DELAY = 200;
121
184
 
185
+ // Every role a focusable menu item can carry. A checkbox / radio item is NOT
186
+ // role="menuitem", so a bare [role="menuitem"] query silently skips it and the
187
+ // item drops out of arrow nav, typeahead, and the focus-first-item-on-open.
188
+ const MENU_ITEM = ':is([role="menuitem"],[role="menuitemcheckbox"],[role="menuitemradio"])';
189
+ const ENABLED_MENU_ITEM = `${MENU_ITEM}:not([data-disabled])`;
190
+
122
191
  // --------------------------------------------------------------------------
123
192
  // <ui-dropdown-menu>
124
193
  // --------------------------------------------------------------------------
@@ -130,6 +199,7 @@ export class UiDropdownMenu extends WebComponent({
130
199
  _typeBufferTimer: number | undefined;
131
200
 
132
201
  _docClickHandler = (e: MouseEvent): void => this._onDocClick(e);
202
+ _docPointerDownHandler = (e: Event): void => this._onDocPointerDown(e);
133
203
  _keyHandler = (e: KeyboardEvent): void => this._onKeyDown(e);
134
204
  _resizeHandler = (): void => this._reposition();
135
205
 
@@ -150,6 +220,30 @@ export class UiDropdownMenu extends WebComponent({
150
220
  show(): void { this.open = true; }
151
221
  hide(): void { this.open = false; }
152
222
 
223
+ // APG Menu Button requires Escape, Tab, item activation, AND an outside-click
224
+ // dismiss to close the menu without stranding focus. Without this, focus is
225
+ // sitting on an item inside a popover="manual" panel, so hiding the panel
226
+ // drops focus to <body> and a keyboard user loses their place entirely.
227
+ //
228
+ // The restore is GUARDED, because an outside click that deliberately moved
229
+ // focus to another control must not have focus yanked back to the trigger.
230
+ //
231
+ // Focus moves BEFORE the panel hides. Hiding a popover whose descendant
232
+ // holds focus makes the engine run its own focus fixup, and moving out
233
+ // first means the trigger is the final focus rather than a target the
234
+ // fixup then overrides.
235
+ _closeAndRestoreFocus(): void {
236
+ if (this._focusIsInside()) this._triggerControl()?.focus();
237
+ this.hide();
238
+ }
239
+
240
+ _focusIsInside(): boolean {
241
+ if (typeof document === 'undefined') return false;
242
+ const active = document.activeElement;
243
+ if (!active || active === document.body) return false;
244
+ return this.contains(active);
245
+ }
246
+
153
247
  render() {
154
248
  return html`<div data-slot="dropdown-menu" data-state=${this.open ? 'open' : 'closed'}>
155
249
  <slot></slot>
@@ -216,6 +310,32 @@ export class UiDropdownMenu extends WebComponent({
216
310
  if (!menu.hasAttribute('aria-label') && !menu.hasAttribute('aria-labelledby')) {
217
311
  menu.setAttribute('aria-labelledby', ensureId(control, 'ui-menu-trigger'));
218
312
  }
313
+ this._wireSubmenuAria();
314
+ }
315
+
316
+ // A SUBMENU is a role="menu" too, and APG asks for it to be labelled by the
317
+ // menuitem that opens it, with that menuitem pointing back via aria-controls.
318
+ // Only the root panel was wired, so every submenu shipped as an unnamed menu.
319
+ _wireSubmenuAria(): void {
320
+ this.querySelectorAll<HTMLElement>('ui-dropdown-menu-sub').forEach((sub) => {
321
+ // NOT `:scope >`: the sub renders its own wrapper div with a <slot>, so the
322
+ // trigger and content are grandchildren, not direct children. Match on the
323
+ // nearest enclosing sub instead, which also keeps a NESTED submenu's nodes
324
+ // from being wired to its outer sub.
325
+ const owns = (el: Element | null): boolean =>
326
+ !!el && el.closest('ui-dropdown-menu-sub') === sub;
327
+ const trigger = Array.from(
328
+ sub.querySelectorAll<HTMLElement>(`ui-dropdown-menu-sub-trigger ${MENU_ITEM}`),
329
+ ).find((el) => owns(el.closest('ui-dropdown-menu-sub-trigger')));
330
+ const panel = Array.from(
331
+ sub.querySelectorAll<HTMLElement>('ui-dropdown-menu-sub-content [role="menu"]'),
332
+ ).find((el) => owns(el.closest('ui-dropdown-menu-sub-content')));
333
+ if (!trigger || !panel) return;
334
+ trigger.setAttribute('aria-controls', ensureId(panel, 'ui-submenu'));
335
+ if (!panel.hasAttribute('aria-label') && !panel.hasAttribute('aria-labelledby')) {
336
+ panel.setAttribute('aria-labelledby', ensureId(trigger, 'ui-submenu-trigger'));
337
+ }
338
+ });
219
339
  }
220
340
 
221
341
  _content(): HTMLElement | null {
@@ -248,21 +368,24 @@ export class UiDropdownMenu extends WebComponent({
248
368
 
249
369
  _setup(): void {
250
370
  this._reposition();
371
+ document.addEventListener('pointerdown', this._docPointerDownHandler, true);
251
372
  document.addEventListener('click', this._docClickHandler);
252
373
  document.addEventListener('keydown', this._keyHandler);
253
374
  window.addEventListener('resize', this._resizeHandler);
254
375
  window.addEventListener('scroll', this._resizeHandler, true);
255
376
  queueMicrotask(() => {
256
377
  const first = this.querySelector<HTMLElement>(
257
- 'ui-dropdown-menu-item:not([data-disabled]) [role="menuitem"]',
378
+ `ui-dropdown-menu-item:not([data-disabled]) ${MENU_ITEM}`,
258
379
  );
259
380
  first?.focus();
260
381
  });
261
382
  }
262
383
 
263
384
  _teardown(): void {
385
+ document.removeEventListener('pointerdown', this._docPointerDownHandler, true);
264
386
  document.removeEventListener('click', this._docClickHandler);
265
387
  document.removeEventListener('keydown', this._keyHandler);
388
+ this._focusWasInsideAtPointerDown = false;
266
389
  window.removeEventListener('resize', this._resizeHandler);
267
390
  window.removeEventListener('scroll', this._resizeHandler, true);
268
391
  this.querySelectorAll<UiDropdownMenuSub>('ui-dropdown-menu-sub[open]').forEach(
@@ -270,28 +393,80 @@ export class UiDropdownMenu extends WebComponent({
270
393
  );
271
394
  }
272
395
 
396
+ // Sampled on POINTERDOWN, before the browser moves focus for the click. By
397
+ // the time the click handler runs, a click on a non-focusable area has often
398
+ // already blurred the focused menu item to <body>, so a check made there
399
+ // cannot tell "clicked away from everything" (focus should go back to the
400
+ // trigger) from "clicked another control" (leave focus where the user put it).
401
+ _focusWasInsideAtPointerDown = false;
402
+
403
+ _onDocPointerDown(e: Event): void {
404
+ if (!this.open) return;
405
+ if (e.composedPath().some((n) => n === this)) return;
406
+ this._focusWasInsideAtPointerDown = this._focusIsInside();
407
+ }
408
+
273
409
  _onDocClick(e: MouseEvent): void {
274
410
  if (!this.open) return;
275
411
  if (e.composedPath().some((n) => n === this)) return;
412
+ // An outside click dismisses the menu, and it is still a close of a
413
+ // popover="manual" panel, so it owes the same focus care as Escape: leaving
414
+ // focus on an item that is about to become display:none drops it to <body>.
415
+ // Restore only when the click did not land focus somewhere itself.
416
+ const active = typeof document !== 'undefined' ? document.activeElement : null;
417
+ const clickTookFocus = !!active && active !== document.body && !this.contains(active);
418
+ if (this._focusWasInsideAtPointerDown && !clickTookFocus) {
419
+ this._triggerControl()?.focus();
420
+ }
421
+ this._focusWasInsideAtPointerDown = false;
276
422
  this.hide();
277
423
  }
278
424
 
279
425
  _onKeyDown(e: KeyboardEvent): void {
280
426
  if (!this.open) return;
281
- if (e.key === 'Escape') {
282
- e.preventDefault();
283
- this.hide();
284
- return;
285
- }
286
427
 
287
428
  // Active context = nearest content / sub-content panel owning focus.
288
429
  // Scoping arrow nav avoids walking into siblings of a different submenu.
430
+ // Computed before Escape / Tab because both need to know which panel holds
431
+ // focus, and neither may bail when focus is outside the menu entirely.
289
432
  const active = document.activeElement as HTMLElement | null;
290
433
  const context = active?.closest('[role="menu"]') as HTMLElement | null;
434
+
435
+ if (e.key === 'Escape') {
436
+ e.preventDefault();
437
+ // APG: Escape closes the menu that CONTAINS focus and returns focus to
438
+ // whatever opened it. Inside a submenu panel that is the submenu and its
439
+ // sub-trigger, so the root menu stays open (the JSDoc's "close current
440
+ // submenu first"). Focus on the sub-trigger itself is focus in the ROOT
441
+ // panel, so that case closes the whole menu, which is why this reads the
442
+ // panel rather than walking up to any open <ui-dropdown-menu-sub>.
443
+ const subContent = context?.closest('ui-dropdown-menu-sub-content');
444
+ const sub = subContent?.closest('ui-dropdown-menu-sub') as UiDropdownMenuSub | null;
445
+ if (sub) {
446
+ sub.querySelector<HTMLElement>(`ui-dropdown-menu-sub-trigger ${MENU_ITEM}`)?.focus();
447
+ sub.hide();
448
+ return;
449
+ }
450
+ this._closeAndRestoreFocus();
451
+ return;
452
+ }
453
+
454
+ if (e.key === 'Tab') {
455
+ // JSDoc contract: Tab closes the menu and proceeds with the normal tab
456
+ // order, so the default is deliberately NOT prevented. Focus is restored
457
+ // to the trigger first because the items are tabindex="-1" inside a
458
+ // top-layer panel that is about to be display:none, so tabbing onward
459
+ // from there has no sensible next element. Moving to the trigger (which
460
+ // IS in the tab sequence) means the browser's own Tab then advances to
461
+ // the element after it, which is what APG asks for.
462
+ this._closeAndRestoreFocus();
463
+ return;
464
+ }
465
+
291
466
  if (!context) return;
292
467
 
293
468
  const items = Array.from(
294
- context.querySelectorAll<HTMLElement>('[role="menuitem"]:not([data-disabled])'),
469
+ context.querySelectorAll<HTMLElement>(ENABLED_MENU_ITEM),
295
470
  ).filter((it) => it.closest('[role="menu"]') === context);
296
471
  if (items.length === 0) return;
297
472
  const idx = active ? items.indexOf(active) : -1;
@@ -314,15 +489,10 @@ export class UiDropdownMenu extends WebComponent({
314
489
  if (subTrigger) {
315
490
  e.preventDefault();
316
491
  const sub = subTrigger.closest('ui-dropdown-menu-sub') as UiDropdownMenuSub | null;
317
- if (sub) {
318
- sub.show();
319
- queueMicrotask(() => {
320
- const firstSubItem = sub.querySelector<HTMLElement>(
321
- 'ui-dropdown-menu-sub-content [role="menuitem"]:not([data-disabled])',
322
- );
323
- firstSubItem?.focus();
324
- });
325
- }
492
+ // The sub owns the open-then-focus sequencing: its panel is
493
+ // popover="manual" and only becomes visible a microtask later, so
494
+ // focusing from here races the reveal and loses (see the method).
495
+ sub?.openAndFocusFirstItem();
326
496
  }
327
497
  } else if (e.key === 'ArrowLeft') {
328
498
  // Inside a sub-content: close the submenu and refocus its trigger.
@@ -330,11 +500,45 @@ export class UiDropdownMenu extends WebComponent({
330
500
  e.preventDefault();
331
501
  const sub = context.closest('ui-dropdown-menu-sub') as UiDropdownMenuSub | null;
332
502
  const trigger = sub?.querySelector<HTMLElement>(
333
- 'ui-dropdown-menu-sub-trigger [role="menuitem"]',
503
+ `ui-dropdown-menu-sub-trigger ${MENU_ITEM}`,
334
504
  );
335
- sub?.hide();
336
505
  trigger?.focus();
506
+ sub?.hide();
507
+ }
508
+ } else if (e.key === 'Enter' || e.key === ' ') {
509
+ // A menu item is a <div role="menuitem">, and a div gets NO native
510
+ // activation, so Enter / Space have to be synthesized here. Without this
511
+ // the keyboard cannot activate any item at all, and a checkable item,
512
+ // whose ONLY state transition is activation, cannot be toggled.
513
+ //
514
+ // Activation applies to a MENU ITEM only. A control the author slotted
515
+ // into the panel (a filter input, a plain button) is inside [role="menu"]
516
+ // too, so preventing Space unconditionally would swallow it there with
517
+ // nothing to handle it, and the user could not type a space into their own
518
+ // input. Bail before touching the event when the focus is not an item.
519
+ const subTrigger = active?.closest('ui-dropdown-menu-sub-trigger');
520
+ const activeItem = active?.closest('ui-dropdown-menu-item') as UiDropdownMenuItem | null;
521
+ if (!subTrigger && !activeItem) return;
522
+ // Space while a typeahead search is in flight belongs to the SEARCH, or a
523
+ // multi-word item is unreachable past its first word ("Status bar" could
524
+ // never be disambiguated from "Status line"). Radix draws the same line.
525
+ // Enter always activates.
526
+ if (e.key === ' ' && this._typeBuffer) {
527
+ this._typeahead(e, items);
528
+ return;
529
+ }
530
+ // Space otherwise MUST be prevented, or it falls through to typeahead,
531
+ // matches nothing, and scrolls the page underneath the open menu.
532
+ e.preventDefault();
533
+ if (subTrigger) {
534
+ // Same as ArrowRight on a sub-trigger: open it and move focus in.
535
+ if (!subTrigger.hasAttribute('data-disabled')) {
536
+ const sub = subTrigger.closest('ui-dropdown-menu-sub') as UiDropdownMenuSub | null;
537
+ sub?.openAndFocusFirstItem();
538
+ }
539
+ return;
337
540
  }
541
+ if (activeItem && !activeItem.hasAttribute('data-disabled')) activeItem._select();
338
542
  } else if (e.key.length === 1 && !e.metaKey && !e.ctrlKey && !e.altKey) {
339
543
  this._typeahead(e, items);
340
544
  }
@@ -397,6 +601,13 @@ UiDropdownMenuContent.register('ui-dropdown-menu-content');
397
601
  export class UiDropdownMenuItem extends WebComponent({
398
602
  variant: prop<'default' | 'destructive'>(String, { reflect: true }),
399
603
  inset: Boolean,
604
+ // `type` is read from the authored attribute and never written back: an
605
+ // unreflected default keeps a plain item's markup free of a type="" that
606
+ // means nothing. `checked` reflects so the state is readable and settable
607
+ // as an attribute, the same contract <ui-toggle>'s `pressed` has.
608
+ type: prop<'' | 'checkbox' | 'radio'>(String),
609
+ checked: prop(Boolean, { reflect: true }),
610
+ value: prop(String, { reflect: true }),
400
611
  }) {
401
612
  // Keyboard / pointer highlight state for the own-rendered menuitem. A
402
613
  // local signal bound with ?data-highlighted keeps the highlight in the
@@ -408,6 +619,9 @@ export class UiDropdownMenuItem extends WebComponent({
408
619
  super();
409
620
  this.variant = 'default';
410
621
  this.inset = false;
622
+ this.type = '';
623
+ this.checked = false;
624
+ this.value = '';
411
625
  }
412
626
 
413
627
  render() {
@@ -416,6 +630,40 @@ export class UiDropdownMenuItem extends WebComponent({
416
630
  // inner menuitem as both data-disabled (CSS) and aria-disabled, so the
417
631
  // state also reaches assistive tech.
418
632
  const disabled = typeof this.hasAttribute === 'function' && this.hasAttribute('data-disabled');
633
+ const checkbox = this.type === 'checkbox';
634
+ const radio = this.type === 'radio';
635
+ // The role is what tells a screen reader this is a checkable control at
636
+ // all, and aria-checked is what carries the state. Neither belongs on a
637
+ // plain item, where aria-checked is not an allowed attribute.
638
+ //
639
+ // Hence TWO templates rather than one with conditional holes: a null hole
640
+ // does NOT omit an attribute on the server (the server renderer
641
+ // stringifies the value), so a single template would ship every plain item
642
+ // as `role="menuitem" aria-checked="" data-state=""`, carrying the exact
643
+ // disallowed attribute this branch exists to keep off it, and disagreeing
644
+ // with the hydrated DOM where the client renderer removes it. The two
645
+ // shapes differ by the indicator anyway.
646
+ if (checkbox || radio) {
647
+ return html`<div
648
+ data-slot=${`dropdown-menu-${this.type}-item`}
649
+ role=${checkbox ? 'menuitemcheckbox' : 'menuitemradio'}
650
+ tabindex="-1"
651
+ data-variant=${this.variant}
652
+ ?data-inset=${this.inset}
653
+ ?data-disabled=${disabled}
654
+ aria-disabled=${disabled ? 'true' : 'false'}
655
+ aria-checked=${String(this.checked)}
656
+ data-state=${this.checked ? 'checked' : 'unchecked'}
657
+ ?data-highlighted=${this.#highlighted.get()}
658
+ class=${checkbox ? dropdownMenuCheckboxItemClass() : dropdownMenuRadioItemClass()}
659
+ @click=${this._onClick}
660
+ @pointerenter=${this._onPointerEnter}
661
+ @focus=${this._onFocus}
662
+ @blur=${this._onBlur}
663
+ ><span
664
+ class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center"
665
+ >${this.checked ? unsafeHTML(radio ? RADIO_DOT_SVG : CHECK_SVG) : ''}</span><slot></slot></div>`;
666
+ }
419
667
  return html`<div
420
668
  data-slot="dropdown-menu-item"
421
669
  role="menuitem"
@@ -436,9 +684,56 @@ export class UiDropdownMenuItem extends WebComponent({
436
684
  _onClick = (e: Event): void => {
437
685
  const el = e.currentTarget as HTMLElement;
438
686
  if (el.hasAttribute('data-disabled')) return;
439
- (this.closest('ui-dropdown-menu') as UiDropdownMenu | null)?.hide();
687
+ this._select();
440
688
  };
441
689
 
690
+ // Activation: flip checkbox / radio state, announce the selection, then
691
+ // close. The event is cancelable, so a listener calling preventDefault()
692
+ // keeps the menu open, which is the parity shape for Radix's
693
+ // onSelect(e => e.preventDefault()) that a multi-select menu relies on.
694
+ _select(): void {
695
+ if (this.type === 'checkbox') this.checked = !this.checked;
696
+ else if (this.type === 'radio') this._selectRadio();
697
+ const proceed = this.dispatchEvent(
698
+ new CustomEvent('ui-item-select', {
699
+ detail: { value: this.value, item: this, type: this.type, checked: this.checked },
700
+ bubbles: true,
701
+ cancelable: true,
702
+ }),
703
+ );
704
+ if (!proceed) return;
705
+ (this.closest('ui-dropdown-menu') as UiDropdownMenu | null)?._closeAndRestoreFocus();
706
+ }
707
+
708
+ // The radio set is the nearest <ui-dropdown-menu-group>, which is the APG
709
+ // grouping element for menuitemradio, falling back to the panel so an
710
+ // ungrouped set still behaves like one set rather than N independent items.
711
+ _radioScope(): Element | null {
712
+ if (typeof this.closest !== 'function') return null;
713
+ return (
714
+ this.closest('ui-dropdown-menu-group') ??
715
+ this.closest('ui-dropdown-menu-sub-content') ??
716
+ this.closest('ui-dropdown-menu-content')
717
+ );
718
+ }
719
+
720
+ _selectRadio(): void {
721
+ const scope = this._radioScope();
722
+ if (!scope) {
723
+ this.checked = true;
724
+ return;
725
+ }
726
+ // Filter on the PROPERTY, not a [type="radio"] attribute selector: `type`
727
+ // is unreflected, so an item configured through the property alone would
728
+ // be invisible to an attribute query and survive as a second checked item.
729
+ // The scope re-check keeps a nested group's items out of this set.
730
+ Array.from(scope.querySelectorAll<UiDropdownMenuItem>('ui-dropdown-menu-item'))
731
+ .filter((it) => it.type === 'radio' && it._radioScope() === scope)
732
+ .forEach((it) => {
733
+ it.checked = it === this;
734
+ });
735
+ }
736
+
442
737
  _onPointerEnter = (e: Event): void => {
443
738
  const el = e.currentTarget as HTMLElement;
444
739
  if (el.hasAttribute('data-disabled')) return;
@@ -509,7 +804,36 @@ UiDropdownMenuShortcut.register('ui-dropdown-menu-shortcut');
509
804
  // --------------------------------------------------------------------------
510
805
 
511
806
  export class UiDropdownMenuGroup extends WebComponent {
807
+ // role="group", NOT role="radiogroup". radiogroup is the grouping role for
808
+ // role="radio"; the grouping role ARIA specifies for menuitemradio is plain
809
+ // group, so a radio set inside this element is already correctly grouped.
512
810
  render() {
811
+ // The group carries the name of the set ("Sort by", "Panel position"), and
812
+ // the name has to be on the element that holds role="group" to be exposed.
813
+ // Forward it off the host so an author writes it where the tag is.
814
+ //
815
+ // Branched, not held in a null hole: a null hole does NOT omit an attribute
816
+ // on the server, so an unnamed group would ship
817
+ // `role="group" aria-label="" aria-labelledby=""`, an empty IDREF list and
818
+ // an empty name on an element that simply has no name.
819
+ const attr = (n: string): string | null =>
820
+ typeof this.getAttribute === 'function' ? this.getAttribute(n) : null;
821
+ const labelledBy = attr('aria-labelledby');
822
+ const label = attr('aria-label');
823
+ if (labelledBy) {
824
+ return html`<div
825
+ data-slot="dropdown-menu-group"
826
+ role="group"
827
+ aria-labelledby=${labelledBy}
828
+ ><slot></slot></div>`;
829
+ }
830
+ if (label) {
831
+ return html`<div
832
+ data-slot="dropdown-menu-group"
833
+ role="group"
834
+ aria-label=${label}
835
+ ><slot></slot></div>`;
836
+ }
513
837
  return html`<div
514
838
  data-slot="dropdown-menu-group"
515
839
  role="group"
@@ -541,6 +865,43 @@ export class UiDropdownMenuSub extends WebComponent({
541
865
  hide(): void { this._cancelClose(); this.open = false; }
542
866
  toggle(): void { if (this.open) this.hide(); else this.show(); }
543
867
 
868
+ // ArrowRight on a sub-trigger must open the submenu AND move focus to its
869
+ // first item (APG, and what this component's JSDoc promises). The focus
870
+ // cannot simply be queued next to show(): the panel is popover="manual" and
871
+ // only becomes visible in _afterRender one microtask later, and focus() on a
872
+ // still-display:none element is silently a no-op that nothing retries. So
873
+ // record the intent and consume it once the panel is genuinely up.
874
+ _focusFirstOnOpen = false;
875
+
876
+ openAndFocusFirstItem(): void {
877
+ this._focusFirstOnOpen = true;
878
+ this.show();
879
+ // Hover already opened it, so there is no open-change to ride on and
880
+ // _afterRender will not run. The panel is up, so consume the intent now.
881
+ if (this._panelIsOpen()) this._consumeFocusFirst();
882
+ }
883
+
884
+ _panelIsOpen(): boolean {
885
+ const panel = this.querySelector<HTMLElement & { showPopover?: () => void }>(
886
+ 'ui-dropdown-menu-sub-content [popover]',
887
+ );
888
+ // Gate on showPopover, not just on matches, like the two sibling call sites.
889
+ // `:popover-open` is an unknown pseudo-class where the Popover API is
890
+ // absent, so matches() THROWS SyntaxError there. This runs synchronously
891
+ // from the document keydown handler, so the throw would escape _onKeyDown
892
+ // and take out all submenu keyboard handling on such an engine.
893
+ if (!panel || typeof panel.showPopover !== 'function') return false;
894
+ return typeof panel.matches === 'function' && panel.matches(':popover-open');
895
+ }
896
+
897
+ _consumeFocusFirst(): void {
898
+ if (!this._focusFirstOnOpen) return;
899
+ this._focusFirstOnOpen = false;
900
+ this.querySelector<HTMLElement>(
901
+ `ui-dropdown-menu-sub-content ${ENABLED_MENU_ITEM}`,
902
+ )?.focus();
903
+ }
904
+
544
905
  render() {
545
906
  return html`<div
546
907
  data-slot="dropdown-menu-sub"
@@ -569,7 +930,14 @@ export class UiDropdownMenuSub extends WebComponent({
569
930
  else if (!this.open && p.matches(':popover-open')) p.hidePopover();
570
931
  }
571
932
  }
572
- if (this.open) this._position();
933
+ if (this.open) {
934
+ this._position();
935
+ this._consumeFocusFirst();
936
+ } else {
937
+ // Closed before the intent was consumed (a hover-close raced the key).
938
+ // Dropping it keeps a later hover-open from stealing focus into the panel.
939
+ this._focusFirstOnOpen = false;
940
+ }
573
941
  }
574
942
 
575
943
  _cancelCloseHandler = (): void => this._cancelClose();