@antadesign/anta 0.2.2 → 0.3.1

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 (82) hide show
  1. package/README.md +14 -0
  2. package/dist/anta_helpers.d.ts +39 -1
  3. package/dist/anta_helpers.js +30 -2
  4. package/dist/components/Button.d.ts +7 -4
  5. package/dist/components/Button.js +6 -11
  6. package/dist/components/Checkbox.d.ts +97 -0
  7. package/dist/components/Checkbox.js +77 -0
  8. package/dist/components/Expander.d.ts +74 -0
  9. package/dist/components/Expander.js +53 -0
  10. package/dist/components/Input.d.ts +159 -0
  11. package/dist/components/Input.js +150 -0
  12. package/dist/components/Menu.d.ts +73 -0
  13. package/dist/components/Menu.js +42 -0
  14. package/dist/components/MenuGroup.d.ts +24 -0
  15. package/dist/components/MenuGroup.js +19 -0
  16. package/dist/components/MenuItem.d.ts +61 -0
  17. package/dist/components/MenuItem.js +50 -0
  18. package/dist/components/MenuSeparator.d.ts +14 -0
  19. package/dist/components/MenuSeparator.js +7 -0
  20. package/dist/components/Progress.d.ts +12 -6
  21. package/dist/components/Progress.js +7 -4
  22. package/dist/components/Radio.d.ts +37 -0
  23. package/dist/components/Radio.js +33 -0
  24. package/dist/components/RadioGroup.d.ts +119 -0
  25. package/dist/components/RadioGroup.js +108 -0
  26. package/dist/components/Tag.d.ts +38 -5
  27. package/dist/components/Tag.js +9 -5
  28. package/dist/components/Text.d.ts +27 -12
  29. package/dist/components/Text.js +6 -3
  30. package/dist/components/Title.d.ts +10 -1
  31. package/dist/elements/a-button.css +1 -1
  32. package/dist/elements/a-button.d.ts +56 -0
  33. package/dist/elements/a-button.js +13 -11
  34. package/dist/elements/a-checkbox.css +1 -0
  35. package/dist/elements/a-checkbox.d.ts +52 -0
  36. package/dist/elements/a-checkbox.js +130 -0
  37. package/dist/elements/a-expander.css +1 -0
  38. package/dist/elements/a-expander.d.ts +28 -0
  39. package/dist/elements/a-expander.js +237 -0
  40. package/dist/elements/a-icon.d.ts +14 -0
  41. package/dist/elements/a-icon.shapes.css +1 -1
  42. package/dist/elements/a-icon.shapes.d.ts +10 -1
  43. package/dist/elements/a-icon.shapes.js +11 -1
  44. package/dist/elements/a-input.css +1 -0
  45. package/dist/elements/a-input.d.ts +68 -0
  46. package/dist/elements/a-input.js +511 -0
  47. package/dist/elements/a-menu-group.css +1 -0
  48. package/dist/elements/a-menu-group.d.ts +13 -0
  49. package/dist/elements/a-menu-group.js +15 -0
  50. package/dist/elements/a-menu-item.css +1 -0
  51. package/dist/elements/a-menu-item.d.ts +47 -0
  52. package/dist/elements/a-menu-item.js +30 -0
  53. package/dist/elements/a-menu-separator.css +1 -0
  54. package/dist/elements/a-menu-separator.d.ts +13 -0
  55. package/dist/elements/a-menu-separator.js +15 -0
  56. package/dist/elements/a-menu.css +1 -0
  57. package/dist/elements/a-menu.d.ts +183 -0
  58. package/dist/elements/a-menu.js +763 -0
  59. package/dist/elements/a-progress.css +1 -1
  60. package/dist/elements/a-progress.d.ts +12 -0
  61. package/dist/elements/a-progress.js +1 -0
  62. package/dist/elements/a-radio-group.css +1 -0
  63. package/dist/elements/a-radio-group.d.ts +33 -0
  64. package/dist/elements/a-radio-group.js +160 -0
  65. package/dist/elements/a-radio.css +1 -0
  66. package/dist/elements/a-radio.d.ts +14 -0
  67. package/dist/elements/a-radio.js +46 -0
  68. package/dist/elements/a-tag.css +1 -1
  69. package/dist/elements/a-text.css +1 -1
  70. package/dist/elements/a-text.d.ts +42 -3
  71. package/dist/elements/a-text.js +73 -33
  72. package/dist/elements/a-tooltip.d.ts +43 -11
  73. package/dist/elements/a-tooltip.js +46 -51
  74. package/dist/elements/index.d.ts +9 -0
  75. package/dist/elements/index.js +27 -0
  76. package/dist/general_types.d.ts +468 -15
  77. package/dist/index.d.ts +16 -0
  78. package/dist/index.js +16 -0
  79. package/dist/jsx-runtime.d.ts +42 -7
  80. package/dist/jsx-runtime.js +14 -2
  81. package/dist/tokens.css +1 -1
  82. package/package.json +1 -1
@@ -0,0 +1,763 @@
1
+ import { HTMLElementBase } from "../anta_helpers";
2
+ import { AMenuItemElement } from "./a-menu-item";
3
+ import "./a-menu.css";
4
+ const MARGIN = 4;
5
+ const MIN_HEIGHT = 96;
6
+ const SUBMENU_OPEN_DELAY = 130;
7
+ const SUBMENU_CLOSE_DELAY = 130;
8
+ const TYPEAHEAD_RESET = 500;
9
+ const ANCHOR_VISIBLE_RATIO = 0.5;
10
+ const openStack = [];
11
+ let docBound = false;
12
+ let boundDoc = null;
13
+ let boundView = null;
14
+ let removePosTracker = null;
15
+ function bindDocListeners(doc, view) {
16
+ if (docBound) return;
17
+ doc.addEventListener("pointerdown", onDocPointerDown, true);
18
+ doc.addEventListener("keydown", onDocKeyDown, true);
19
+ doc.addEventListener("contextmenu", onDocContextMenu, true);
20
+ view.addEventListener("resize", onResize);
21
+ boundDoc = doc;
22
+ boundView = view;
23
+ docBound = true;
24
+ }
25
+ function unbindDocListeners() {
26
+ if (!docBound) return;
27
+ boundDoc?.removeEventListener("pointerdown", onDocPointerDown, true);
28
+ boundDoc?.removeEventListener("keydown", onDocKeyDown, true);
29
+ boundDoc?.removeEventListener("contextmenu", onDocContextMenu, true);
30
+ boundView?.removeEventListener("resize", onResize);
31
+ removePosTracker?.();
32
+ removePosTracker = null;
33
+ boundDoc = null;
34
+ boundView = null;
35
+ docBound = false;
36
+ }
37
+ function trackPosition(el, onEscape) {
38
+ if (typeof IntersectionObserver === "undefined") return () => {
39
+ };
40
+ const doc = el.ownerDocument;
41
+ const rect = el.getBoundingClientRect();
42
+ const vw = doc.documentElement.clientWidth;
43
+ const vh = doc.documentElement.clientHeight;
44
+ const rootMargin = `${-rect.top}px ${-(vw - rect.right)}px ${-(vh - rect.bottom)}px ${-rect.left}px`;
45
+ const io = new IntersectionObserver(
46
+ ([entry]) => {
47
+ if (!entry.isIntersecting) onEscape();
48
+ },
49
+ { root: null, rootMargin, threshold: ANCHOR_VISIBLE_RATIO }
50
+ );
51
+ io.observe(el);
52
+ return () => io.disconnect();
53
+ }
54
+ function pathHitsMenus(e, primaryClick = false) {
55
+ const path = e.composedPath();
56
+ for (const m of openStack) {
57
+ if (path.includes(m.surface)) return true;
58
+ const anchor = m.triggerAnchor;
59
+ if (!anchor) continue;
60
+ if (primaryClick && m.hasAttribute("context")) continue;
61
+ if (path.includes(anchor)) return true;
62
+ }
63
+ return false;
64
+ }
65
+ function onDocPointerDown(e) {
66
+ if (!openStack.length) return;
67
+ if (!pathHitsMenus(e, e.button === 0)) dismiss(e);
68
+ }
69
+ function onDocContextMenu(e) {
70
+ if (!openStack.length) return;
71
+ if (!pathHitsMenus(e)) dismiss(e);
72
+ }
73
+ function onResize() {
74
+ if (!openStack.length) return;
75
+ dismiss();
76
+ }
77
+ function onDocKeyDown(e) {
78
+ const menu = openStack[openStack.length - 1];
79
+ if (!menu) return;
80
+ menu.handleKey(e);
81
+ }
82
+ function dismiss(originEvent) {
83
+ openStack[0]?.requestClose(originEvent);
84
+ }
85
+ function closeAll() {
86
+ for (let i = openStack.length - 1; i >= 0; i--) {
87
+ const m = openStack[i];
88
+ if (m.isOpen && !m._dismissNotified) m.emitChange("closed");
89
+ m._doHide();
90
+ }
91
+ openStack.length = 0;
92
+ unbindDocListeners();
93
+ }
94
+ const anchorToMenu = /* @__PURE__ */ new WeakMap();
95
+ function handleIntersection(entries) {
96
+ for (const entry of entries) {
97
+ const menu = anchorToMenu.get(entry.target);
98
+ if (!menu) continue;
99
+ if (!menu.listening && entry.isIntersecting) {
100
+ requestAnimationFrame(() => menu.setupListeners());
101
+ } else if (menu.listening && !entry.isIntersecting) {
102
+ requestAnimationFrame(() => {
103
+ menu.teardownListeners();
104
+ if (menu.isOpen) menu.close();
105
+ });
106
+ }
107
+ }
108
+ }
109
+ const lazyObserver = typeof IntersectionObserver !== "undefined" ? new IntersectionObserver(handleIntersection, { root: null, rootMargin: "0px", threshold: 0 }) : null;
110
+ class AMenuElement extends HTMLElementBase {
111
+ static observedAttributes = ["placement", "context", "coord", "offset", "nohover", "state"];
112
+ /** Shadow-internal popover surface — the only thing we ever mutate. */
113
+ surface;
114
+ listening = false;
115
+ _shown = false;
116
+ teardown;
117
+ /** A controlled menu was told to dismiss (it emitted `statechange→'closed'`)
118
+ * but stays visible until the consumer flips `state`. The flag lets the
119
+ * `closeAll` backstop skip a duplicate emit. Cleared on every show. */
120
+ _dismissNotified = false;
121
+ // Submenu hover-intent timers.
122
+ openTimer;
123
+ closeTimer;
124
+ // Typeahead state (root navigation).
125
+ typeBuffer = "";
126
+ typeTimer;
127
+ constructor() {
128
+ super();
129
+ const shadow = this.attachShadow({ mode: "open" });
130
+ const style = document.createElement("style");
131
+ style.textContent = `
132
+ :host { display: contents; }
133
+
134
+ .container {
135
+ position: fixed;
136
+ left: 0;
137
+ top: 0;
138
+ margin: 0;
139
+ box-sizing: border-box;
140
+ flex-direction: column;
141
+ gap: 1px;
142
+ min-width: var(--menu-min-width, 88px);
143
+ max-width: calc(100vw - ${2 * MARGIN}px);
144
+ max-height: calc(100dvh - ${2 * MARGIN}px);
145
+ overflow-y: auto;
146
+ overscroll-behavior: contain;
147
+ scrollbar-width: thin;
148
+ padding: var(--menu-padding, 4px);
149
+ background: var(--menu-bg, Canvas);
150
+ color: var(--text-2, CanvasText);
151
+ border: var(--menu-border, 1px solid);
152
+ border-radius: var(--menu-radius, 8px);
153
+ box-shadow: var(--menu-shadow, 0 8px 24px rgba(0,0,0,0.2));
154
+ -webkit-backdrop-filter: var(--menu-backdrop-filter, blur(20px));
155
+ backdrop-filter: var(--menu-backdrop-filter, blur(20px));
156
+ outline: none;
157
+
158
+ /* Closed / exit state \u2014 fade + a tiny vertical settle (no horizontal
159
+ shift). This 140ms governs the EXIT (open \u2192 closed); the enter is a
160
+ touch quicker (see :popover-open below) so the menu feels snappy to
161
+ open but unhurried to dismiss. 'display' and 'overlay' transition with
162
+ 'allow-discrete' so the menu stays in the top layer and visible while
163
+ it animates OUT, then hides. */
164
+ opacity: 0;
165
+ translate: 0 -4px;
166
+ transition:
167
+ opacity 140ms ease-out,
168
+ translate 140ms ease-out,
169
+ display 140ms allow-discrete,
170
+ overlay 140ms allow-discrete;
171
+ }
172
+ .container:popover-open {
173
+ display: flex;
174
+ opacity: 1;
175
+ translate: 0 0;
176
+ /* Enter (closed \u2192 open) \u2014 quicker than the exit above. */
177
+ transition:
178
+ opacity 100ms ease-out,
179
+ translate 100ms ease-out,
180
+ display 100ms allow-discrete,
181
+ overlay 100ms allow-discrete;
182
+ }
183
+ /* Enter: start from the closed state and transition in. */
184
+ @starting-style {
185
+ .container:popover-open { opacity: 0; translate: 0 -4px; }
186
+ }
187
+ `;
188
+ this.surface = document.createElement("div");
189
+ this.surface.className = "container";
190
+ this.surface.setAttribute("part", "menu");
191
+ this.surface.setAttribute("popover", "manual");
192
+ this.surface.append(document.createElement("slot"));
193
+ this.surface.addEventListener("pointerenter", (e) => {
194
+ if (e.pointerType !== "mouse") return;
195
+ if (this.isSubmenu) this.cancelCloseTimer();
196
+ });
197
+ this.surface.addEventListener("pointerleave", (e) => {
198
+ if (e.pointerType !== "mouse") return;
199
+ if (this.isSubmenu && this.isHover) this.scheduleClose();
200
+ });
201
+ this.surface.addEventListener("click", this.onSurfaceClick);
202
+ shadow.append(style, this.surface);
203
+ }
204
+ connectedCallback() {
205
+ const anchor = this.triggerAnchor;
206
+ if (anchor) {
207
+ anchorToMenu.set(anchor, this);
208
+ lazyObserver?.observe(anchor);
209
+ }
210
+ if (this.hasAttribute("state")) requestAnimationFrame(() => this.syncState());
211
+ }
212
+ disconnectedCallback() {
213
+ this.hide();
214
+ this.teardownListeners();
215
+ this.cancelOpenTimer();
216
+ this.cancelCloseTimer();
217
+ const anchor = this.triggerAnchor;
218
+ if (anchor && anchorToMenu.get(anchor) === this) {
219
+ anchorToMenu.delete(anchor);
220
+ lazyObserver?.unobserve(anchor);
221
+ }
222
+ }
223
+ attributeChangedCallback(name) {
224
+ if (name === "state") {
225
+ this.syncState();
226
+ return;
227
+ }
228
+ if (this.listening) {
229
+ this.teardownListeners();
230
+ this.setupListeners();
231
+ }
232
+ }
233
+ /** Apply the controlled `state` attribute to actual visibility, silently.
234
+ * Absent → uncontrolled (no-op here; triggers manage it). */
235
+ syncState() {
236
+ if (!this.isConnected) return;
237
+ const v = this.getAttribute("state");
238
+ if (v === "open" && !this._shown) this.show();
239
+ else if (v === "closed" && this._shown) this.hide();
240
+ }
241
+ /** Controlled iff the consumer is managing the `state` attribute. */
242
+ get isControlled() {
243
+ return this.hasAttribute("state");
244
+ }
245
+ /* --- config getters --- */
246
+ /** A submenu is an `<a-menu>` nested inside an `<a-menu-item>` — derived from
247
+ * structure, no `submenu` attribute needed (the parent item is the anchor). */
248
+ get isSubmenu() {
249
+ return !!this.closest("a-menu-item");
250
+ }
251
+ get isContext() {
252
+ return this.hasAttribute("context");
253
+ }
254
+ get isCoord() {
255
+ return this.hasAttribute("coord");
256
+ }
257
+ // Submenus open on hover by default; `nohover` opts out (click-only). Root
258
+ // menus never consult this — it's read only on the submenu paths.
259
+ get isHover() {
260
+ return !this.hasAttribute("nohover");
261
+ }
262
+ get offset() {
263
+ const n = parseInt(this.getAttribute("offset") ?? "", 10);
264
+ return Number.isFinite(n) ? n : MARGIN;
265
+ }
266
+ get placement() {
267
+ const p = this.getAttribute("placement");
268
+ if (p === "bottom-end" || p === "top-start" || p === "top-end" || p === "bottom" || p === "top")
269
+ return p;
270
+ return "bottom-start";
271
+ }
272
+ /** Root menu: the previous element sibling is the trigger. Submenu: the
273
+ * enclosing menu item. One deterministic rule per case — no ambiguity. */
274
+ get triggerAnchor() {
275
+ return this.closest("a-menu-item") ?? this.previousElementSibling;
276
+ }
277
+ /** For a submenu: the menu that contains its anchor item. */
278
+ get ownerMenu() {
279
+ if (!this.isSubmenu) return null;
280
+ return this.triggerAnchor?.closest("a-menu") ?? null;
281
+ }
282
+ get isOpen() {
283
+ return this._shown;
284
+ }
285
+ /* --- focusable items belonging to THIS menu (not nested submenus) --- */
286
+ /** Skip elements that can't actually take focus — `display:none` (incl. a
287
+ * closed submenu's contents), `visibility:hidden`, `content-visibility`
288
+ * skipped — so navigation never lands on a hidden node (programmatic
289
+ * `.focus()` on one silently fails). `getClientRects` is the fallback where
290
+ * `checkVisibility` isn't available. */
291
+ isVisible(el) {
292
+ const check = el.checkVisibility;
293
+ if (typeof check === "function") {
294
+ return check.call(el, { visibilityProperty: true, contentVisibilityAuto: true });
295
+ }
296
+ return el.getClientRects().length > 0;
297
+ }
298
+ /** The subset of `focusables()` that are menu items (drives arrow / Home /
299
+ * End / type-ahead navigation). Same visibility / disabled / ownership
300
+ * filter — just narrowed to `a-menu-item`. */
301
+ focusableItems() {
302
+ return this.focusables().filter(
303
+ (el) => el instanceof AMenuItemElement
304
+ );
305
+ }
306
+ /** Every tabbable element belonging to THIS menu (items + nested controls
307
+ * like inputs / sliders / buttons), in DOM order, visible and enabled —
308
+ * used to trap Tab within the open menu. Submenu contents are excluded
309
+ * (their nearest `a-menu` is the submenu). */
310
+ focusables() {
311
+ const sel = 'a-menu-item, a[href], button:not([disabled]), input:not([disabled]):not([type="hidden"]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
312
+ return Array.from(this.querySelectorAll(sel)).filter(
313
+ (el) => el.closest("a-menu") === this && !el.hasAttribute("disabled") && this.isVisible(el)
314
+ );
315
+ }
316
+ focusFirstItem() {
317
+ this.focusableItems()[0]?.focus({ preventScroll: true });
318
+ }
319
+ /* ============================ open / close ============================ */
320
+ /** Public imperative API. Routes through the same intent path as the
321
+ * triggers, so it emits `statechange` and respects controlled mode. */
322
+ open(opts) {
323
+ this.requestOpen(opts);
324
+ }
325
+ close(originEvent) {
326
+ this.requestClose(originEvent);
327
+ }
328
+ toggle(opts) {
329
+ if (this._shown) this.requestClose(opts?.originEvent);
330
+ else this.requestOpen(opts);
331
+ }
332
+ /** Dispatch the single `statechange` event (requested + previous state),
333
+ * `cancelable` and *before* applying. Returns `false` if a handler vetoed it
334
+ * via `preventDefault()` — the uncontrolled veto (see requestOpen/Close). */
335
+ emitChange(next, opts) {
336
+ return this.dispatchEvent(
337
+ new CustomEvent("statechange", {
338
+ cancelable: true,
339
+ detail: {
340
+ next,
341
+ prev: this._shown ? "open" : "closed",
342
+ coord: opts?.coord,
343
+ originEvent: opts?.originEvent
344
+ }
345
+ })
346
+ );
347
+ }
348
+ /** Intent to open (trigger / method / keyboard). Emits the cancelable
349
+ * `statechange`; applies the visibility itself ONLY when uncontrolled and
350
+ * not vetoed — a controlled menu waits for the consumer to flip `state`. */
351
+ requestOpen(opts) {
352
+ if (this._shown) {
353
+ this.show(opts?.coord, opts?.viaKeyboard, opts?.originEvent);
354
+ return;
355
+ }
356
+ const ok = this.emitChange("open", opts);
357
+ if (this.isControlled) return;
358
+ if (ok) this.show(opts?.coord, opts?.viaKeyboard, opts?.originEvent);
359
+ }
360
+ /** Intent to close. Emits the cancelable `statechange`; hides itself only when
361
+ * uncontrolled and not vetoed. */
362
+ requestClose(originEvent) {
363
+ if (!this._shown) return;
364
+ const ok = this.emitChange("closed", { originEvent });
365
+ if (this.isControlled) {
366
+ this._dismissNotified = true;
367
+ return;
368
+ }
369
+ if (ok) this.hide();
370
+ }
371
+ /** Apply OPEN to the DOM (no event) — used by uncontrolled intent and by the
372
+ * controlled `state` sync. */
373
+ show(coord, viaKeyboard = false, _originEvent) {
374
+ if (this.isSubmenu) {
375
+ const parent = this.ownerMenu;
376
+ if (parent) {
377
+ const pidx = openStack.indexOf(parent);
378
+ if (pidx !== -1) {
379
+ for (let i = openStack.length - 1; i > pidx; i--) openStack[i]._doHide();
380
+ openStack.length = pidx + 1;
381
+ }
382
+ }
383
+ } else if (!openStack.includes(this)) {
384
+ closeAll();
385
+ }
386
+ if (openStack.includes(this)) {
387
+ this.position(coord);
388
+ return;
389
+ }
390
+ const wasEmpty = openStack.length === 0;
391
+ this._doShow(coord, !wasEmpty);
392
+ openStack.push(this);
393
+ if (wasEmpty) {
394
+ bindDocListeners(this.doc, this.view);
395
+ this.armPositionTracker();
396
+ }
397
+ if (viaKeyboard) this.focusFirstItem();
398
+ }
399
+ /** Watch the root trigger and dismiss the system once it scrolls out of the
400
+ * spot it held at open (see trackPosition). Deferred a frame so the trigger's
401
+ * post-open layout has settled before the rect is snapshotted; guarded in case
402
+ * the menu closed in between. Tracks the root anchor only — submenus ride
403
+ * inside it, so if the root anchor goes, the whole system should go. */
404
+ armPositionTracker() {
405
+ const anchor = this.triggerAnchor;
406
+ if (!anchor) return;
407
+ this.view.requestAnimationFrame(() => {
408
+ if (!this._shown || openStack[0] !== this) return;
409
+ removePosTracker?.();
410
+ removePosTracker = trackPosition(anchor, () => dismiss());
411
+ });
412
+ }
413
+ /** Apply CLOSE to the DOM (no event). Closes this menu and everything stacked
414
+ * above it (its submenus). */
415
+ hide() {
416
+ const idx = openStack.indexOf(this);
417
+ if (idx === -1) {
418
+ if (this._shown) this._doHide();
419
+ return;
420
+ }
421
+ for (let i = openStack.length - 1; i >= idx; i--) openStack[i]._doHide();
422
+ openStack.length = idx;
423
+ if (openStack.length === 0) unbindDocListeners();
424
+ }
425
+ /** Shadow-only show: open the popover and position it. `instant` positions
426
+ * synchronously (no rAF), so a menu opening over an already-visible one is
427
+ * placed before its first paint — it still fades in via the CSS transition.
428
+ * Relies on the Popover API without feature detection — see "Browser
429
+ * support" in README.md. */
430
+ _doShow(coord, instant = false) {
431
+ if (this.surface.isConnected && !this._shown) this.surface.showPopover();
432
+ this._shown = true;
433
+ this._dismissNotified = false;
434
+ this.reflectExpanded(true);
435
+ this.hideAnchorTooltip();
436
+ this.position(coord, instant);
437
+ }
438
+ /** Dismiss any tooltip on the trigger as the menu opens, so the trigger's
439
+ * hover tooltip doesn't linger over the just-opened menu. `a-tooltip.hide()`
440
+ * mutates only its own shadow internals (like `el.focus()`), so this is
441
+ * allowed under the no-light-DOM-mutation rule. No-op when the trigger has no
442
+ * tooltip (or it hasn't upgraded). */
443
+ hideAnchorTooltip() {
444
+ this.triggerAnchor?.querySelectorAll("a-tooltip").forEach((t) => t.hide?.());
445
+ }
446
+ /** Shadow-only hide. */
447
+ _doHide() {
448
+ if (this.surface.isConnected && this._shown) this.surface.hidePopover();
449
+ this._shown = false;
450
+ this.reflectExpanded(false);
451
+ this.cancelOpenTimer();
452
+ this.cancelCloseTimer();
453
+ }
454
+ /** Mirror the open state onto a SUBMENU parent's `aria-expanded`. This is the
455
+ * one sanctioned light-DOM ARIA mutation (like `el.focus()`): the anchor is
456
+ * an `<a-menu-item>` the `MenuItem` wrapper renders WITH a resting
457
+ * `aria-expanded="false"` baseline, so a reactive re-render resets it to a
458
+ * valid value and the next open/close re-syncs.
459
+ *
460
+ * A ROOT menu's trigger is a consumer-owned sibling we don't render and have
461
+ * no baseline for — writing to it would mutate foreign DOM (and couldn't
462
+ * self-heal), so we leave its `aria-expanded` to the consumer. The menu is
463
+ * still announced and Esc-dismissable; consumers add `aria-haspopup="menu"`
464
+ * to their trigger themselves. (`context` menus aren't triggers either.) */
465
+ reflectExpanded(open) {
466
+ if (!this.isSubmenu) return;
467
+ this.triggerAnchor?.setAttribute("aria-expanded", open ? "true" : "false");
468
+ }
469
+ /* ============================ positioning ============================ */
470
+ position(coord, sync = false) {
471
+ const run = () => {
472
+ if (!this._shown) return;
473
+ const view = this.view;
474
+ const vw = view.innerWidth;
475
+ const vh = view.innerHeight;
476
+ const surface = this.surface;
477
+ let left = MARGIN;
478
+ let top = MARGIN;
479
+ if (coord) {
480
+ surface.style.maxHeight = `${Math.max(MIN_HEIGHT, vh - 2 * MARGIN)}px`;
481
+ const box = surface.getBoundingClientRect();
482
+ left = coord[0];
483
+ if (left + box.width > vw - MARGIN) left = vw - box.width - MARGIN;
484
+ left = Math.max(MARGIN, left);
485
+ top = coord[1];
486
+ if (top + box.height > vh - MARGIN) top = top - box.height;
487
+ top = Math.max(MARGIN, top);
488
+ } else if (this.isSubmenu) {
489
+ const it = this.triggerAnchor?.getBoundingClientRect();
490
+ if (!it) return;
491
+ surface.style.maxHeight = `${Math.max(MIN_HEIGHT, vh - 2 * MARGIN)}px`;
492
+ const box = surface.getBoundingClientRect();
493
+ left = it.right + this.offset;
494
+ if (left + box.width > vw - MARGIN) {
495
+ left = it.left - box.width - this.offset;
496
+ }
497
+ left = Math.max(MARGIN, left);
498
+ const cs = view.getComputedStyle(surface);
499
+ const insetTop = parseFloat(cs.borderTopWidth) + parseFloat(cs.paddingTop);
500
+ top = it.top - insetTop;
501
+ if (top + box.height > vh - MARGIN) top = vh - box.height - MARGIN;
502
+ top = Math.max(MARGIN, top);
503
+ } else {
504
+ const a = this.triggerAnchor?.getBoundingClientRect();
505
+ if (!a) return;
506
+ const p = this.placement;
507
+ const spaceBelow = vh - a.bottom - 2 * MARGIN;
508
+ const spaceAbove = a.top - 2 * MARGIN;
509
+ let onTop = p.startsWith("top");
510
+ const natural = surface.scrollHeight;
511
+ if (onTop && spaceAbove < natural && spaceBelow > spaceAbove) onTop = false;
512
+ else if (!onTop && spaceBelow < natural && spaceAbove > spaceBelow) onTop = true;
513
+ const space = onTop ? spaceAbove : spaceBelow;
514
+ surface.style.maxHeight = `${Math.max(MIN_HEIGHT, Math.floor(space))}px`;
515
+ const box = surface.getBoundingClientRect();
516
+ const align = p.endsWith("end") ? "end" : p.endsWith("start") ? "start" : "center";
517
+ left = align === "center" ? a.left + a.width / 2 - box.width / 2 : align === "end" ? a.right - box.width : a.left;
518
+ if (left + box.width > vw - MARGIN) left = vw - box.width - MARGIN;
519
+ left = Math.max(MARGIN, left);
520
+ top = onTop ? a.top - box.height - this.offset : a.bottom + this.offset;
521
+ top = Math.max(MARGIN, top);
522
+ }
523
+ surface.style.transform = `translate(${Math.round(left)}px, ${Math.round(top)}px)`;
524
+ };
525
+ if (sync) run();
526
+ else requestAnimationFrame(run);
527
+ }
528
+ /* ====================== click / close contract ====================== */
529
+ /**
530
+ * Fully declarative close contract — decided synchronously from the DOM, so
531
+ * it never depends on the consumer's click handler (which in a worker-thread
532
+ * runtime can't `preventDefault` on the UI thread). The menu never
533
+ * stops/prevents the click, so the consumer's selection handler always runs.
534
+ *
535
+ * Walk the click's composedPath outward to the surface; the NEAREST marker
536
+ * wins:
537
+ * - `data-menu-open` → keep the menu open (a Done button can still close
538
+ * from inside such a region — it's hit first).
539
+ * - `a-menu-item` (a choice) or `data-menu-close` → close the menu.
540
+ * - nothing → keep open (plain custom content doesn't dismiss).
541
+ */
542
+ onSurfaceClick = (e) => {
543
+ for (const node of e.composedPath()) {
544
+ if (node === this.surface) break;
545
+ if (!(node instanceof Element)) continue;
546
+ if (node.hasAttribute("data-menu-open")) return;
547
+ if (node instanceof AMenuItemElement) {
548
+ if (node.hasAttribute("disabled")) {
549
+ e.preventDefault();
550
+ return;
551
+ }
552
+ if (node.querySelector("a-menu")) return;
553
+ return this.closeSystem(e);
554
+ }
555
+ if (node.hasAttribute("data-menu-close")) return this.closeSystem(e);
556
+ }
557
+ };
558
+ /** Close the whole open menu system from the root down. */
559
+ closeSystem(e) {
560
+ const root = openStack[0] ?? this;
561
+ root.requestClose(e);
562
+ }
563
+ /* ============================ keyboard ============================ */
564
+ /** Called by the coordinator on the topmost open menu. Handles navigation;
565
+ * Enter / Space activation is handled by a-menu-item's own global keydown
566
+ * (which synthesizes a click → routed through onSurfaceClick). */
567
+ handleKey(e) {
568
+ const active = this.doc.activeElement;
569
+ if (e.key === "Escape") {
570
+ e.preventDefault();
571
+ e.stopPropagation();
572
+ const anchor = this.triggerAnchor;
573
+ this.requestClose(e);
574
+ anchor?.focus();
575
+ return;
576
+ }
577
+ if (e.key === "Tab") {
578
+ const f = this.focusables();
579
+ if (!f.length) return;
580
+ e.preventDefault();
581
+ const i = active ? f.indexOf(active) : -1;
582
+ if (i === -1) {
583
+ f[0]?.focus();
584
+ return;
585
+ }
586
+ const next = e.shiftKey ? i === 0 ? f.length - 1 : i - 1 : i === f.length - 1 ? 0 : i + 1;
587
+ f[next]?.focus();
588
+ return;
589
+ }
590
+ const within = active?.closest("a-menu") === this;
591
+ if (within && !(active instanceof AMenuItemElement)) return;
592
+ const items = this.focusableItems();
593
+ const idx = active ? items.indexOf(active) : -1;
594
+ switch (e.key) {
595
+ case "ArrowDown":
596
+ e.preventDefault();
597
+ items[idx < 0 ? 0 : (idx + 1) % items.length]?.focus();
598
+ break;
599
+ case "ArrowUp":
600
+ e.preventDefault();
601
+ items[idx <= 0 ? items.length - 1 : idx - 1]?.focus();
602
+ break;
603
+ case "Home":
604
+ e.preventDefault();
605
+ items[0]?.focus();
606
+ break;
607
+ case "End":
608
+ e.preventDefault();
609
+ items[items.length - 1]?.focus();
610
+ break;
611
+ case "ArrowRight": {
612
+ const sub = this.submenuOf(active);
613
+ if (sub) {
614
+ e.preventDefault();
615
+ sub.requestOpen({ viaKeyboard: true });
616
+ }
617
+ break;
618
+ }
619
+ case "ArrowLeft":
620
+ if (this.isSubmenu) {
621
+ e.preventDefault();
622
+ const anchorItem = this.triggerAnchor;
623
+ this.requestClose(e);
624
+ anchorItem?.focus();
625
+ }
626
+ break;
627
+ default:
628
+ if (e.key.length === 1 && !e.metaKey && !e.ctrlKey && !e.altKey) {
629
+ this.typeahead(e.key, items);
630
+ }
631
+ }
632
+ }
633
+ submenuOf(item) {
634
+ if (!item || !(item instanceof AMenuItemElement)) return null;
635
+ return item.querySelector("a-menu");
636
+ }
637
+ typeahead(ch, items) {
638
+ this.typeBuffer += ch.toLowerCase();
639
+ clearTimeout(this.typeTimer);
640
+ this.typeTimer = setTimeout(() => this.typeBuffer = "", TYPEAHEAD_RESET);
641
+ const match = items.find((it) => this.itemLabel(it).startsWith(this.typeBuffer));
642
+ match?.focus();
643
+ }
644
+ /** The item's own visible label for type-ahead. Prefers the
645
+ * `<a-menu-item-label>` text so it excludes a trailing `kbd` hint AND — for
646
+ * a submenu parent — the entire nested `<a-menu>` flyout's text (which is a
647
+ * light-DOM descendant, so it'd otherwise be folded into `textContent`). */
648
+ itemLabel(it) {
649
+ const label = it.querySelector("a-menu-item-label");
650
+ return ((label ?? it).textContent ?? "").trim().toLowerCase();
651
+ }
652
+ /* ====================== trigger listener wiring ====================== */
653
+ setupListeners() {
654
+ if (this.listening) return;
655
+ const anchor = this.triggerAnchor;
656
+ if (!anchor) {
657
+ this.listening = true;
658
+ return;
659
+ }
660
+ if (this.isSubmenu) {
661
+ const onClick = (e) => {
662
+ if (e.composedPath().includes(this.surface)) return;
663
+ this.requestOpen({ originEvent: e });
664
+ };
665
+ anchor.addEventListener("click", onClick);
666
+ let onEnter;
667
+ let onLeave;
668
+ if (this.isHover) {
669
+ onEnter = (e) => {
670
+ if (e.pointerType === "mouse") this.scheduleOpen();
671
+ };
672
+ onLeave = (e) => {
673
+ if (e.pointerType === "mouse") this.scheduleClose();
674
+ };
675
+ anchor.addEventListener("pointerenter", onEnter);
676
+ anchor.addEventListener("pointerleave", onLeave);
677
+ }
678
+ this.teardown = () => {
679
+ anchor.removeEventListener("click", onClick);
680
+ if (onEnter) anchor.removeEventListener("pointerenter", onEnter);
681
+ if (onLeave) anchor.removeEventListener("pointerleave", onLeave);
682
+ this.listening = false;
683
+ };
684
+ } else if (this.isContext) {
685
+ const onContext = (e) => {
686
+ e.preventDefault();
687
+ this.requestOpen({ coord: [e.clientX, e.clientY], originEvent: e });
688
+ };
689
+ anchor.addEventListener("contextmenu", onContext);
690
+ this.teardown = () => {
691
+ anchor.removeEventListener("contextmenu", onContext);
692
+ this.listening = false;
693
+ };
694
+ } else {
695
+ const onClick = (e) => {
696
+ const viaKeyboard = e.detail === 0;
697
+ let coord;
698
+ if (this.isCoord) {
699
+ if (viaKeyboard) {
700
+ const r = anchor.getBoundingClientRect();
701
+ coord = [r.left, r.bottom];
702
+ } else {
703
+ coord = [e.clientX, e.clientY];
704
+ }
705
+ }
706
+ if (this._shown) this.requestClose(e);
707
+ else this.requestOpen({ coord, viaKeyboard, originEvent: e });
708
+ };
709
+ anchor.addEventListener("click", onClick);
710
+ this.teardown = () => {
711
+ anchor.removeEventListener("click", onClick);
712
+ this.listening = false;
713
+ };
714
+ }
715
+ this.listening = true;
716
+ }
717
+ teardownListeners() {
718
+ this.teardown?.();
719
+ this.teardown = void 0;
720
+ }
721
+ /* --- submenu hover-intent timers --- */
722
+ scheduleOpen() {
723
+ this.cancelCloseTimer();
724
+ if (this._shown) return;
725
+ this.cancelOpenTimer();
726
+ this.openTimer = setTimeout(() => {
727
+ this.openTimer = void 0;
728
+ this.requestOpen();
729
+ }, SUBMENU_OPEN_DELAY);
730
+ }
731
+ scheduleClose() {
732
+ this.cancelOpenTimer();
733
+ if (!this._shown) return;
734
+ this.cancelCloseTimer();
735
+ this.closeTimer = setTimeout(() => {
736
+ this.closeTimer = void 0;
737
+ this.requestClose();
738
+ }, SUBMENU_CLOSE_DELAY);
739
+ }
740
+ cancelOpenTimer() {
741
+ if (this.openTimer !== void 0) {
742
+ clearTimeout(this.openTimer);
743
+ this.openTimer = void 0;
744
+ }
745
+ }
746
+ cancelCloseTimer() {
747
+ if (this.closeTimer !== void 0) {
748
+ clearTimeout(this.closeTimer);
749
+ this.closeTimer = void 0;
750
+ }
751
+ }
752
+ }
753
+ function register_a_menu() {
754
+ if (typeof customElements === "undefined") return;
755
+ if (!customElements.get("a-menu")) {
756
+ customElements.define("a-menu", AMenuElement);
757
+ }
758
+ }
759
+ register_a_menu();
760
+ export {
761
+ AMenuElement,
762
+ register_a_menu
763
+ };