@antadesign/anta 0.2.1 → 0.3.0

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