@webjsdev/ui 0.3.10 → 0.3.12
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 +26 -15
- package/package.json +3 -3
- package/packages/registry/README.md +16 -10
- package/packages/registry/components/accordion.ts +18 -0
- package/packages/registry/components/alert-dialog.ts +203 -22
- package/packages/registry/components/aspect-ratio.ts +12 -0
- package/packages/registry/components/card.ts +20 -1
- package/packages/registry/components/checkbox.ts +45 -3
- package/packages/registry/components/collapsible.ts +15 -0
- package/packages/registry/components/dialog.ts +205 -22
- package/packages/registry/components/dropdown-menu.ts +390 -22
- package/packages/registry/components/hover-card.ts +162 -4
- package/packages/registry/components/input.ts +34 -2
- package/packages/registry/components/kbd.ts +35 -2
- package/packages/registry/components/label.ts +21 -0
- package/packages/registry/components/native-select.ts +28 -7
- package/packages/registry/components/popover.ts +43 -7
- package/packages/registry/components/radio-group.ts +37 -9
- package/packages/registry/components/sonner.ts +53 -5
- package/packages/registry/components/switch.ts +23 -1
- package/packages/registry/components/tabs.ts +15 -0
- package/packages/registry/components/textarea.ts +21 -2
- package/packages/registry/components/toggle-group.ts +81 -8
- package/packages/registry/components/toggle.ts +82 -3
- package/packages/registry/components/tooltip.ts +57 -0
- package/packages/registry/lib/utils.ts +208 -14
- package/packages/registry/package.json +1 -1
- package/packages/registry/registry.json +1 -1
- package/packages/registry/themes/base-colors.js +2 -2
- package/src/commands/add.js +52 -2
- package/src/commands/diff.js +2 -2
- package/src/commands/info.js +2 -5
- package/src/commands/init.js +127 -25
- package/src/registry/example.js +7 -4
- package/src/registry/fetcher.js +10 -2
- package/src/registry/local.js +2 -2
- package/src/utils/index.js +0 -1
- 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
|
|
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
|
|
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
|
-
|
|
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>(
|
|
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
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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();
|