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