@antadesign/anta 0.3.2 → 0.3.4

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 (96) hide show
  1. package/dist/anta_helpers.d.ts +97 -0
  2. package/dist/anta_helpers.js +70 -0
  3. package/dist/calendar-core.d.ts +126 -0
  4. package/dist/calendar-core.js +289 -0
  5. package/dist/components/Button.d.ts +9 -1
  6. package/dist/components/Button.js +5 -2
  7. package/dist/components/Calendar.d.ts +85 -0
  8. package/dist/components/Calendar.js +226 -0
  9. package/dist/components/Checkbox.d.ts +16 -11
  10. package/dist/components/Checkbox.js +13 -7
  11. package/dist/components/Expander.d.ts +7 -1
  12. package/dist/components/Expander.js +4 -2
  13. package/dist/components/Input.d.ts +13 -5
  14. package/dist/components/Input.js +14 -6
  15. package/dist/components/InputDate.d.ts +89 -0
  16. package/dist/components/InputDate.js +273 -0
  17. package/dist/components/InputDate.module.css +1 -0
  18. package/dist/components/Menu.d.ts +14 -1
  19. package/dist/components/Menu.js +6 -2
  20. package/dist/components/MenuItem.d.ts +48 -7
  21. package/dist/components/MenuItem.js +72 -14
  22. package/dist/components/MenuSeparator.d.ts +13 -2
  23. package/dist/components/MenuSeparator.js +12 -2
  24. package/dist/components/Progress.d.ts +4 -1
  25. package/dist/components/Progress.js +4 -3
  26. package/dist/components/RadioGroup.d.ts +12 -10
  27. package/dist/components/RadioGroup.js +5 -5
  28. package/dist/components/Select.d.ts +319 -0
  29. package/dist/components/Select.js +341 -0
  30. package/dist/components/Select.module.css +1 -0
  31. package/dist/components/Tab.d.ts +3 -0
  32. package/dist/components/Tab.js +2 -1
  33. package/dist/components/TabPanel.d.ts +21 -13
  34. package/dist/components/TabPanel.js +13 -1
  35. package/dist/components/Tabs.d.ts +75 -40
  36. package/dist/components/Tabs.js +20 -88
  37. package/dist/components/Tag.d.ts +10 -9
  38. package/dist/components/Tag.js +2 -2
  39. package/dist/components/Text.d.ts +6 -5
  40. package/dist/components/Title.d.ts +4 -2
  41. package/dist/components/Tooltip.d.ts +4 -1
  42. package/dist/components/Tooltip.js +5 -0
  43. package/dist/elements/a-button.css +1 -1
  44. package/dist/elements/a-calendar.css +1 -0
  45. package/dist/elements/a-calendar.d.ts +76 -0
  46. package/dist/elements/a-calendar.js +190 -0
  47. package/dist/elements/a-checkbox.css +1 -1
  48. package/dist/elements/a-checkbox.d.ts +1 -2
  49. package/dist/elements/a-checkbox.js +5 -5
  50. package/dist/elements/a-expander.css +1 -1
  51. package/dist/elements/a-expander.d.ts +16 -5
  52. package/dist/elements/a-expander.js +59 -8
  53. package/dist/elements/a-icon.shapes.css +1 -1
  54. package/dist/elements/a-icon.shapes.d.ts +6 -1
  55. package/dist/elements/a-icon.shapes.js +9 -0
  56. package/dist/elements/a-input.css +1 -1
  57. package/dist/elements/a-input.d.ts +6 -0
  58. package/dist/elements/a-input.js +28 -8
  59. package/dist/elements/a-menu-group.css +1 -1
  60. package/dist/elements/a-menu-item.css +1 -1
  61. package/dist/elements/a-menu-item.d.ts +22 -10
  62. package/dist/elements/a-menu-item.js +17 -0
  63. package/dist/elements/a-menu-separator.css +1 -1
  64. package/dist/elements/a-menu.css +1 -1
  65. package/dist/elements/a-menu.d.ts +61 -20
  66. package/dist/elements/a-menu.js +337 -58
  67. package/dist/elements/a-progress.css +1 -1
  68. package/dist/elements/a-radio-group.d.ts +1 -3
  69. package/dist/elements/a-radio-group.js +13 -13
  70. package/dist/elements/a-radio.css +1 -1
  71. package/dist/elements/a-radio.d.ts +3 -11
  72. package/dist/elements/a-radio.js +3 -34
  73. package/dist/elements/a-tab.css +1 -1
  74. package/dist/elements/a-tab.d.ts +3 -11
  75. package/dist/elements/a-tab.js +3 -34
  76. package/dist/elements/a-tabpanel.css +1 -1
  77. package/dist/elements/a-tabpanel.d.ts +17 -0
  78. package/dist/elements/a-tabpanel.js +66 -0
  79. package/dist/elements/a-tabs.css +1 -1
  80. package/dist/elements/a-tabs.d.ts +1 -4
  81. package/dist/elements/a-tabs.js +14 -14
  82. package/dist/elements/a-tag.css +1 -1
  83. package/dist/elements/a-text.d.ts +14 -13
  84. package/dist/elements/a-text.js +53 -23
  85. package/dist/elements/a-tooltip.css +1 -1
  86. package/dist/elements/a-tooltip.d.ts +1 -11
  87. package/dist/elements/a-tooltip.js +20 -19
  88. package/dist/elements/index.d.ts +2 -1
  89. package/dist/elements/index.js +6 -1
  90. package/dist/general_types.d.ts +158 -42
  91. package/dist/index.d.ts +10 -4
  92. package/dist/index.js +38 -3
  93. package/dist/jsx-runtime.d.ts +16 -7
  94. package/dist/jsx-runtime.js +6 -0
  95. package/dist/reset.css +1 -1
  96. package/package.json +4 -2
@@ -1,4 +1,4 @@
1
- import { HTMLElementBase } from "../anta_helpers";
1
+ import { HTMLElementBase, anchorRect, setMenuPresence } from "../anta_helpers";
2
2
  import { AMenuItemElement } from "./a-menu-item";
3
3
  import "./a-menu.css";
4
4
  const MARGIN = 4;
@@ -42,11 +42,13 @@ function trackPosition(el, onEscape) {
42
42
  const vw = doc.documentElement.clientWidth;
43
43
  const vh = doc.documentElement.clientHeight;
44
44
  const rootMargin = `${-rect.top}px ${-(vw - rect.right)}px ${-(vh - rect.bottom)}px ${-rect.left}px`;
45
+ const win = doc.defaultView;
46
+ const root = win && win !== win.top ? doc.documentElement : null;
45
47
  const io = new IntersectionObserver(
46
48
  ([entry]) => {
47
49
  if (!entry.isIntersecting) onEscape();
48
50
  },
49
- { root: null, rootMargin, threshold: ANCHOR_VISIBLE_RATIO }
51
+ { root, rootMargin, threshold: ANCHOR_VISIBLE_RATIO }
50
52
  );
51
53
  io.observe(el);
52
54
  return () => io.disconnect();
@@ -62,6 +64,18 @@ function pathHitsMenus(e, primaryClick = false) {
62
64
  }
63
65
  return false;
64
66
  }
67
+ function nodeHitsMenus(node) {
68
+ for (const m of openStack) {
69
+ if (m.contains(node) || m.surface.contains(node)) return true;
70
+ const anchor = m.triggerAnchor;
71
+ if (anchor && anchor.contains(node)) return true;
72
+ }
73
+ return false;
74
+ }
75
+ setMenuPresence({
76
+ isOpen: () => openStack.length > 0,
77
+ contains: nodeHitsMenus
78
+ });
65
79
  function onDocPointerDown(e) {
66
80
  if (!openStack.length) return;
67
81
  if (!pathHitsMenus(e, e.button === 0)) dismiss(e);
@@ -111,6 +125,8 @@ class AMenuElement extends HTMLElementBase {
111
125
  static observedAttributes = ["placement", "context", "coord", "offset", "nohover", "state"];
112
126
  /** Shadow-internal popover surface — the only thing we ever mutate. */
113
127
  surface;
128
+ /** The scrolling body inside the surface (holds the items). */
129
+ scrollEl;
114
130
  listening = false;
115
131
  _shown = false;
116
132
  teardown;
@@ -118,14 +134,32 @@ class AMenuElement extends HTMLElementBase {
118
134
  * but stays visible until the consumer flips `state`. The flag lets the
119
135
  * `closeAll` backstop skip a duplicate emit. Cleared on every show. */
120
136
  _dismissNotified = false;
137
+ // Custom-state carrier — exposes the menu's own open state as `:state(open)`
138
+ // (off-DOM, like `a-menu-item`'s `:state(active)`). Never used to mutate light
139
+ // DOM; see `reflectOpen`.
140
+ internals;
121
141
  // Submenu hover-intent timers.
122
142
  openTimer;
123
143
  closeTimer;
124
144
  // Typeahead state (root navigation).
125
145
  typeBuffer = "";
126
146
  typeTimer;
147
+ // Combobox (filter) state — engaged when a `[data-menu-search]` field is slotted
148
+ // in (e.g. `Select` with `filter`). Focus stays in that field; ArrowUp/Down move
149
+ // `activeItem` (a cursor, not DOM focus) and REPORT it via the `activedescendant`
150
+ // event, which the reactive layer reflects onto the field's `aria-activedescendant`.
151
+ activeItem = null;
152
+ comboObserver;
153
+ // The vertical side chosen at open (true = flipped above the anchor). A re-anchor
154
+ // (filtering changes height) keeps this side rather than re-deciding — a shrunk
155
+ // menu shouldn't hop back under the trigger.
156
+ _flippedTop = null;
127
157
  constructor() {
128
158
  super();
159
+ try {
160
+ this.internals = this.attachInternals?.();
161
+ } catch {
162
+ }
129
163
  const shadow = this.attachShadow({ mode: "open" });
130
164
  const style = document.createElement("style");
131
165
  style.textContent = `
@@ -136,16 +170,13 @@ class AMenuElement extends HTMLElementBase {
136
170
  left: 0;
137
171
  top: 0;
138
172
  margin: 0;
173
+ padding: 0;
139
174
  box-sizing: border-box;
140
175
  flex-direction: column;
141
- gap: 1px;
142
- min-width: var(--menu-min-width, 88px);
176
+ min-width: max(var(--menu-min-width, 88px), var(--_anchor-width, 0px));
143
177
  max-width: calc(100vw - ${2 * MARGIN}px);
144
178
  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);
179
+ overflow: hidden;
149
180
  background: var(--menu-bg, Canvas);
150
181
  color: var(--text-2, CanvasText);
151
182
  border: var(--menu-border, 1px solid);
@@ -184,19 +215,58 @@ class AMenuElement extends HTMLElementBase {
184
215
  @starting-style {
185
216
  .container:popover-open { opacity: 0; translate: 0 -4px; }
186
217
  }
218
+ /* The scrolling body \u2014 only the items scroll; the header / footer slots stay
219
+ fixed above and below it. min-height:0 lets it shrink so its own scrollbar
220
+ engages instead of the flex item overflowing. The 1px inter-item gap that
221
+ used to live on the container lives here now. */
222
+ .scroll {
223
+ flex: 1 1 auto;
224
+ min-height: 0;
225
+ display: flex;
226
+ flex-direction: column;
227
+ gap: 1px;
228
+ overflow-y: auto;
229
+ overscroll-behavior: contain;
230
+ scrollbar-width: thin;
231
+ /* The menu's padding lives here (not on the clipping container) so an item's
232
+ focus outline has room inside the scroll box instead of being clipped at
233
+ the edge; scroll-padding keeps a focused row off the edge when scrolled. */
234
+ padding: var(--menu-padding, 4px);
235
+ scroll-padding-block: var(--menu-padding, 4px);
236
+ /* Soft scroll edges: fade the content into the top / bottom only on the side
237
+ with more to scroll (the --fade-* vars, set from JS, are 0 otherwise). The
238
+ top also clears a hard --gap-top band (fully transparent, ahead of the ramp)
239
+ so a row scrolling up disappears cleanly under a pinned header / filter field
240
+ before the fade begins, instead of bleeding right against it. */
241
+ --fade-top: 0px;
242
+ --fade-bottom: 0px;
243
+ --gap-top: 0px;
244
+ -webkit-mask-image: linear-gradient(to bottom, transparent 0, transparent var(--gap-top), #000 var(--fade-top), #000 calc(100% - var(--fade-bottom)), transparent 100%);
245
+ mask-image: linear-gradient(to bottom, transparent 0, transparent var(--gap-top), #000 var(--fade-top), #000 calc(100% - var(--fade-bottom)), transparent 100%);
246
+ }
187
247
  `;
188
248
  this.surface = document.createElement("div");
189
249
  this.surface.className = "container";
190
250
  this.surface.setAttribute("part", "menu");
191
251
  this.surface.setAttribute("popover", "manual");
192
- this.surface.append(document.createElement("slot"));
252
+ const header = document.createElement("slot");
253
+ header.setAttribute("name", "header");
254
+ const scroll = document.createElement("div");
255
+ scroll.className = "scroll";
256
+ scroll.setAttribute("part", "scroll");
257
+ scroll.append(document.createElement("slot"));
258
+ scroll.addEventListener("scroll", () => this.updateScrollFade(), { passive: true });
259
+ this.scrollEl = scroll;
260
+ const footer = document.createElement("slot");
261
+ footer.setAttribute("name", "footer");
262
+ this.surface.append(header, scroll, footer);
193
263
  this.surface.addEventListener("pointerenter", (e) => {
194
264
  if (e.pointerType !== "mouse") return;
195
265
  if (this.isSubmenu) this.cancelCloseTimer();
196
266
  });
197
267
  this.surface.addEventListener("pointerleave", (e) => {
198
268
  if (e.pointerType !== "mouse") return;
199
- if (this.isSubmenu && this.isHover) this.scheduleClose();
269
+ if (this.isSubmenu && this.#isHover) this.scheduleClose();
200
270
  });
201
271
  this.surface.addEventListener("click", this.onSurfaceClick);
202
272
  shadow.append(style, this.surface);
@@ -248,22 +318,22 @@ class AMenuElement extends HTMLElementBase {
248
318
  get isSubmenu() {
249
319
  return !!this.closest("a-menu-item");
250
320
  }
251
- get isContext() {
321
+ get #isContext() {
252
322
  return this.hasAttribute("context");
253
323
  }
254
- get isCoord() {
324
+ get #isCoord() {
255
325
  return this.hasAttribute("coord");
256
326
  }
257
327
  // Submenus open on hover by default; `nohover` opts out (click-only). Root
258
328
  // menus never consult this — it's read only on the submenu paths.
259
- get isHover() {
329
+ get #isHover() {
260
330
  return !this.hasAttribute("nohover");
261
331
  }
262
- get offset() {
332
+ get #offset() {
263
333
  const n = parseInt(this.getAttribute("offset") ?? "", 10);
264
334
  return Number.isFinite(n) ? n : MARGIN;
265
335
  }
266
- get placement() {
336
+ get #placement() {
267
337
  const p = this.getAttribute("placement");
268
338
  if (p === "bottom-end" || p === "top-start" || p === "top-end" || p === "bottom" || p === "top")
269
339
  return p;
@@ -274,8 +344,32 @@ class AMenuElement extends HTMLElementBase {
274
344
  get triggerAnchor() {
275
345
  return this.closest("a-menu-item") ?? this.previousElementSibling;
276
346
  }
347
+ /** The focusable element to hand focus back to — the anchor itself if it's
348
+ * focusable, else the first focusable within it. Positioning still uses the
349
+ * anchor's box; this only picks the focus target. A custom `renderTrigger`
350
+ * that returns a single focusable element (the documented contract) resolves
351
+ * to that element; if it wrongly returns a non-focusable node with none
352
+ * inside, this is null and `focusTrigger` warns. */
353
+ get #triggerFocusable() {
354
+ const a = this.triggerAnchor;
355
+ if (!a) return null;
356
+ const sel = 'a-input, a-button, button, [href], input:not([type="hidden"]), select, textarea, [tabindex]:not([tabindex="-1"])';
357
+ return a.matches(sel) ? a : a.querySelector(sel);
358
+ }
359
+ /** Return focus to the trigger. Focuses the first focusable in the anchor; if
360
+ * there is none (a mis-authored trigger — e.g. `renderTrigger` returning a
361
+ * fragment or a non-focusable node), warns instead of silently focusing air. */
362
+ focusTrigger() {
363
+ const f = this.#triggerFocusable;
364
+ if (f) f.focus();
365
+ else if (this.triggerAnchor) {
366
+ console.warn(
367
+ "[a-menu] trigger has no focusable element \u2014 a menu trigger (or `Select` renderTrigger) must be a single focusable element."
368
+ );
369
+ }
370
+ }
277
371
  /** For a submenu: the menu that contains its anchor item. */
278
- get ownerMenu() {
372
+ get #ownerMenu() {
279
373
  if (!this.isSubmenu) return null;
280
374
  return this.triggerAnchor?.closest("a-menu") ?? null;
281
375
  }
@@ -308,13 +402,119 @@ class AMenuElement extends HTMLElementBase {
308
402
  * used to trap Tab within the open menu. Submenu contents are excluded
309
403
  * (their nearest `a-menu` is the submenu). */
310
404
  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"])';
405
+ const sel = 'a-menu-item, a[href], button:not([disabled]), input:not([disabled]):not([type="hidden"]), a-input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"]), [data-menu-search]';
312
406
  return Array.from(this.querySelectorAll(sel)).filter(
313
407
  (el) => el.closest("a-menu") === this && !el.hasAttribute("disabled") && this.isVisible(el)
314
408
  );
315
409
  }
316
- focusFirstItem() {
317
- this.focusableItems()[0]?.focus({ preventScroll: true });
410
+ /** On open, seat initial focus like a native `<select>` / macOS menu: a menu
411
+ * carrying a current value opens *at* that value, not at the top. Brings the
412
+ * first selected row into view and, when opened via keyboard, focuses it;
413
+ * with no selected row it focuses the first item (keyboard) and does nothing
414
+ * otherwise. "Selected" = a `selected` row (tint) or `aria-checked` /
415
+ * `aria-selected="true"` (checkable rows) — the first such visible item of
416
+ * THIS menu, so a leaf buried in a closed submenu (not visible) is skipped and
417
+ * a multi-select lands on its topmost checked row. */
418
+ seatInitialFocus(viaKeyboard) {
419
+ const items = this.focusableItems();
420
+ const selected = items.find(
421
+ (el) => el.hasAttribute("selected") || el.getAttribute("aria-checked") === "true" || el.getAttribute("aria-selected") === "true"
422
+ );
423
+ if (selected) this.scrollItemIntoView(selected);
424
+ if (viaKeyboard) (selected ?? items[0])?.focus({ preventScroll: true });
425
+ }
426
+ /** Scroll THIS menu's body so `item` sits inside the visible scroll viewport,
427
+ * touching only the internal `.scroll` container — never the document, whose
428
+ * scroll would trip the anchor-scrolled-out dismiss. No-op for a menu short
429
+ * enough not to scroll. */
430
+ scrollItemIntoView(item) {
431
+ const c = this.scrollEl;
432
+ if (!c) return;
433
+ const pad = 4;
434
+ const cr = c.getBoundingClientRect();
435
+ const ir = item.getBoundingClientRect();
436
+ if (ir.top < cr.top + pad) c.scrollTop -= cr.top + pad - ir.top;
437
+ else if (ir.bottom > cr.bottom - pad) c.scrollTop += ir.bottom - cr.bottom + pad;
438
+ }
439
+ /** Fade the scrolling body's content into the top / bottom edges — but only on the
440
+ * side that actually has more to scroll, so a short (non-scrolling) menu and the
441
+ * true top / bottom stay crisp. Drives the `--fade-*` / `--gap-top` vars the
442
+ * `.scroll` mask reads; runs on scroll and after every (re)position.
443
+ * Shadow-internal only. */
444
+ updateScrollFade() {
445
+ const el = this.scrollEl;
446
+ if (!el) return;
447
+ const top = el.scrollTop > 1;
448
+ const bottom = el.scrollTop + el.clientHeight < el.scrollHeight - 1;
449
+ el.style.setProperty("--fade-top", top ? "16px" : "0px");
450
+ el.style.setProperty("--gap-top", top ? "3px" : "0px");
451
+ el.style.setProperty("--fade-bottom", bottom ? "14px" : "0px");
452
+ }
453
+ /* ------------------------------ combobox mode ------------------------------ */
454
+ /** The filter field slotted into THIS menu (never a submenu's), or null. Its
455
+ * presence switches the menu to the combobox keyboard. */
456
+ get #searchField() {
457
+ const el = this.querySelector("[data-menu-search]");
458
+ return el && el.closest("a-menu") === this ? el : null;
459
+ }
460
+ /** Move the combobox cursor. Sets the item's `active` **property** (off-DOM
461
+ * `:state(active)`, no attribute churn) for the highlight, and REPORTS the
462
+ * active id via the `activedescendant` event so the reactive layer can set
463
+ * `aria-activedescendant` on the light-DOM field. `null` clears the cursor. */
464
+ setActive(item) {
465
+ if (this.activeItem && this.activeItem !== item) this.activeItem.active = false;
466
+ this.activeItem = item;
467
+ if (item) {
468
+ item.active = true;
469
+ item.scrollIntoView?.({ block: "nearest" });
470
+ }
471
+ this.dispatchEvent(
472
+ new CustomEvent("activedescendant", { detail: { id: item?.id ?? null } })
473
+ );
474
+ }
475
+ /** Re-seat the cursor on the first option — but only once the filter has input.
476
+ * An empty filter (e.g. right after opening) shows NO active row, so the first
477
+ * ArrowDown is what steps onto the list; typing then keeps the top match active.
478
+ * Rows marked `data-menu-skip-active` (e.g. a Select-all action) are skipped as
479
+ * the seat target — the cursor lands on the first real option — but they stay
480
+ * arrow-reachable. */
481
+ resetActive() {
482
+ const q = this.#searchField?.value;
483
+ if (!q || !q.trim()) return this.setActive(null);
484
+ const items = this.focusableItems();
485
+ this.setActive(items.find((it) => !it.hasAttribute("data-menu-skip-active")) ?? items[0] ?? null);
486
+ }
487
+ /** Combobox arrow / Home / End / Enter handling; returns true if it consumed the
488
+ * key (so `handleKey` stops). Any other key falls through to the input (typing). */
489
+ handleComboKey(e) {
490
+ const items = this.focusableItems();
491
+ const idx = this.activeItem ? items.indexOf(this.activeItem) : -1;
492
+ switch (e.key) {
493
+ case "ArrowDown":
494
+ e.preventDefault();
495
+ this.setActive(items[idx < 0 ? 0 : (idx + 1) % items.length] ?? null);
496
+ return true;
497
+ case "ArrowUp":
498
+ e.preventDefault();
499
+ this.setActive(items[idx <= 0 ? items.length - 1 : idx - 1] ?? null);
500
+ return true;
501
+ case "Home":
502
+ e.preventDefault();
503
+ this.setActive(items[0] ?? null);
504
+ return true;
505
+ case "End":
506
+ e.preventDefault();
507
+ this.setActive(items[items.length - 1] ?? null);
508
+ return true;
509
+ case "Enter":
510
+ if (this.activeItem) {
511
+ e.preventDefault();
512
+ this.activeItem.click();
513
+ }
514
+ return true;
515
+ default:
516
+ return false;
517
+ }
318
518
  }
319
519
  /* ============================ open / close ============================ */
320
520
  /** Public imperative API. Routes through the same intent path as the
@@ -372,7 +572,7 @@ class AMenuElement extends HTMLElementBase {
372
572
  * controlled `state` sync. */
373
573
  show(coord, viaKeyboard = false, _originEvent) {
374
574
  if (this.isSubmenu) {
375
- const parent = this.ownerMenu;
575
+ const parent = this.#ownerMenu;
376
576
  if (parent) {
377
577
  const pidx = openStack.indexOf(parent);
378
578
  if (pidx !== -1) {
@@ -381,7 +581,19 @@ class AMenuElement extends HTMLElementBase {
381
581
  }
382
582
  }
383
583
  } else if (!openStack.includes(this)) {
384
- closeAll();
584
+ const anchor = this.triggerAnchor;
585
+ const container = anchor ? openStack.find((m) => m !== this && m.contains(anchor)) : void 0;
586
+ if (container) {
587
+ const idx = openStack.indexOf(container);
588
+ for (let i = openStack.length - 1; i > idx; i--) {
589
+ const m = openStack[i];
590
+ if (m.isOpen && !m._dismissNotified) m.emitChange("closed");
591
+ m._doHide();
592
+ }
593
+ openStack.length = idx + 1;
594
+ } else {
595
+ closeAll();
596
+ }
385
597
  }
386
598
  if (openStack.includes(this)) {
387
599
  this.position(coord);
@@ -394,7 +606,29 @@ class AMenuElement extends HTMLElementBase {
394
606
  bindDocListeners(this.doc, this.view);
395
607
  this.armPositionTracker();
396
608
  }
397
- if (viaKeyboard) this.focusFirstItem();
609
+ const search = this.#searchField;
610
+ if (search) {
611
+ ;
612
+ search.focus({ preventScroll: true });
613
+ this.resetActive();
614
+ this.startComboObserver();
615
+ } else {
616
+ this.seatInitialFocus(viaKeyboard);
617
+ }
618
+ }
619
+ /** While a filter field is open, the visible option list changes as the user
620
+ * types (the consumer re-renders the matches); re-seat the cursor on the first
621
+ * match. `childList` only — an item toggling its own `selected` (multi-select)
622
+ * mutates deep in its subtree, not this menu's direct children, so it won't
623
+ * spuriously reset the cursor. */
624
+ startComboObserver() {
625
+ this.comboObserver?.disconnect();
626
+ this.comboObserver = new this.view.MutationObserver(() => {
627
+ if (!this._shown || !this.#searchField) return;
628
+ this.resetActive();
629
+ this.position(void 0, false, true);
630
+ });
631
+ this.comboObserver.observe(this, { childList: true });
398
632
  }
399
633
  /** Watch the root trigger and dismiss the system once it scrolls out of the
400
634
  * spot it held at open (see trackPosition). Deferred a frame so the trigger's
@@ -413,6 +647,10 @@ class AMenuElement extends HTMLElementBase {
413
647
  /** Apply CLOSE to the DOM (no event). Closes this menu and everything stacked
414
648
  * above it (its submenus). */
415
649
  hide() {
650
+ this.comboObserver?.disconnect();
651
+ this.comboObserver = void 0;
652
+ this.setActive(null);
653
+ this._flippedTop = null;
416
654
  const idx = openStack.indexOf(this);
417
655
  if (idx === -1) {
418
656
  if (this._shown) this._doHide();
@@ -431,7 +669,7 @@ class AMenuElement extends HTMLElementBase {
431
669
  if (this.surface.isConnected && !this._shown) this.surface.showPopover();
432
670
  this._shown = true;
433
671
  this._dismissNotified = false;
434
- this.reflectExpanded(true);
672
+ this.reflectOpen(true);
435
673
  this.hideAnchorTooltip();
436
674
  this.position(coord, instant);
437
675
  }
@@ -447,27 +685,27 @@ class AMenuElement extends HTMLElementBase {
447
685
  _doHide() {
448
686
  if (this.surface.isConnected && this._shown) this.surface.hidePopover();
449
687
  this._shown = false;
450
- this.reflectExpanded(false);
688
+ this.reflectOpen(false);
451
689
  this.cancelOpenTimer();
452
690
  this.cancelCloseTimer();
453
691
  }
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 forwriting 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");
692
+ /** Expose the menu's OWN open state as an off-DOM custom state (`:state(open)`),
693
+ * never a light-DOM attribute. A web component must not mutate light DOM — it
694
+ * desyncs the worker-thread reactive model, which owns the light tree. A
695
+ * submenu parent lights its open branch purely in CSS via
696
+ * `a-menu-item:has(> a-menu:state(open))`; the state is element-owned (like
697
+ * `a-menu-item`'s `:state(active)`) and, being off-DOM, survives a reactive
698
+ * re-render without the element ever writing an attribute. Set on every menu
699
+ * (harmless on rootsno `a-menu-item` parent matches the selector). */
700
+ reflectOpen(open) {
701
+ try {
702
+ if (open) this.internals?.states.add("open");
703
+ else this.internals?.states.delete("open");
704
+ } catch {
705
+ }
468
706
  }
469
707
  /* ============================ positioning ============================ */
470
- position(coord, sync = false) {
708
+ position(coord, sync = false, reanchor = false) {
471
709
  const run = () => {
472
710
  if (!this._shown) return;
473
711
  const view = this.view;
@@ -486,30 +724,38 @@ class AMenuElement extends HTMLElementBase {
486
724
  if (top + box.height > vh - MARGIN) top = top - box.height;
487
725
  top = Math.max(MARGIN, top);
488
726
  } else if (this.isSubmenu) {
489
- const it = this.triggerAnchor?.getBoundingClientRect();
727
+ const it = this.triggerAnchor ? anchorRect(this.triggerAnchor) : null;
490
728
  if (!it) return;
491
729
  surface.style.maxHeight = `${Math.max(MIN_HEIGHT, vh - 2 * MARGIN)}px`;
492
730
  const box = surface.getBoundingClientRect();
493
- left = it.right + this.offset;
731
+ left = it.right + this.#offset;
494
732
  if (left + box.width > vw - MARGIN) {
495
- left = it.left - box.width - this.offset;
733
+ left = it.left - box.width - this.#offset;
496
734
  }
497
735
  left = Math.max(MARGIN, left);
498
736
  const cs = view.getComputedStyle(surface);
499
- const insetTop = parseFloat(cs.borderTopWidth) + parseFloat(cs.paddingTop);
737
+ const scs = view.getComputedStyle(this.scrollEl);
738
+ const insetTop = parseFloat(cs.borderTopWidth) + parseFloat(cs.paddingTop) + parseFloat(scs.paddingTop);
500
739
  top = it.top - insetTop;
501
740
  if (top + box.height > vh - MARGIN) top = vh - box.height - MARGIN;
502
741
  top = Math.max(MARGIN, top);
503
742
  } else {
504
- const a = this.triggerAnchor?.getBoundingClientRect();
743
+ const a = this.triggerAnchor ? anchorRect(this.triggerAnchor) : null;
505
744
  if (!a) return;
506
- const p = this.placement;
745
+ surface.style.setProperty("--_anchor-width", `${Math.ceil(a.width)}px`);
746
+ const p = this.#placement;
507
747
  const spaceBelow = vh - a.bottom - 2 * MARGIN;
508
748
  const spaceAbove = a.top - 2 * MARGIN;
509
749
  let onTop = p.startsWith("top");
750
+ surface.style.maxHeight = "";
510
751
  const natural = surface.scrollHeight;
511
- if (onTop && spaceAbove < natural && spaceBelow > spaceAbove) onTop = false;
512
- else if (!onTop && spaceBelow < natural && spaceAbove > spaceBelow) onTop = true;
752
+ if (reanchor && this._flippedTop !== null) {
753
+ onTop = this._flippedTop;
754
+ } else {
755
+ if (onTop && spaceAbove < natural && spaceBelow > spaceAbove) onTop = false;
756
+ else if (!onTop && spaceBelow < natural && spaceAbove > spaceBelow) onTop = true;
757
+ }
758
+ this._flippedTop = onTop;
513
759
  const space = onTop ? spaceAbove : spaceBelow;
514
760
  surface.style.maxHeight = `${Math.max(MIN_HEIGHT, Math.floor(space))}px`;
515
761
  const box = surface.getBoundingClientRect();
@@ -517,10 +763,11 @@ class AMenuElement extends HTMLElementBase {
517
763
  left = align === "center" ? a.left + a.width / 2 - box.width / 2 : align === "end" ? a.right - box.width : a.left;
518
764
  if (left + box.width > vw - MARGIN) left = vw - box.width - MARGIN;
519
765
  left = Math.max(MARGIN, left);
520
- top = onTop ? a.top - box.height - this.offset : a.bottom + this.offset;
766
+ top = onTop ? a.top - box.height - this.#offset : a.bottom + this.#offset;
521
767
  top = Math.max(MARGIN, top);
522
768
  }
523
769
  surface.style.transform = `translate(${Math.round(left)}px, ${Math.round(top)}px)`;
770
+ this.updateScrollFade();
524
771
  };
525
772
  if (sync) run();
526
773
  else requestAnimationFrame(run);
@@ -540,6 +787,23 @@ class AMenuElement extends HTMLElementBase {
540
787
  * - nothing → keep open (plain custom content doesn't dismiss).
541
788
  */
542
789
  onSurfaceClick = (e) => {
790
+ for (const node of e.composedPath()) {
791
+ if (node === this.surface) break;
792
+ if (node instanceof AMenuItemElement) {
793
+ if (!node.hasAttribute("disabled") && !node.querySelector("a-menu") && node.closest("a-menu") === this) {
794
+ node.dispatchEvent(
795
+ new MouseEvent("menuselect", {
796
+ bubbles: false,
797
+ altKey: e.altKey,
798
+ ctrlKey: e.ctrlKey,
799
+ metaKey: e.metaKey,
800
+ shiftKey: e.shiftKey
801
+ })
802
+ );
803
+ }
804
+ break;
805
+ }
806
+ }
543
807
  for (const node of e.composedPath()) {
544
808
  if (node === this.surface) break;
545
809
  if (!(node instanceof Element)) continue;
@@ -569,9 +833,8 @@ class AMenuElement extends HTMLElementBase {
569
833
  if (e.key === "Escape") {
570
834
  e.preventDefault();
571
835
  e.stopPropagation();
572
- const anchor = this.triggerAnchor;
573
836
  this.requestClose(e);
574
- anchor?.focus();
837
+ this.focusTrigger();
575
838
  return;
576
839
  }
577
840
  if (e.key === "Tab") {
@@ -587,6 +850,11 @@ class AMenuElement extends HTMLElementBase {
587
850
  f[next]?.focus();
588
851
  return;
589
852
  }
853
+ const search = this.#searchField;
854
+ if (search && (active === search || search.contains(active))) {
855
+ this.handleComboKey(e);
856
+ return;
857
+ }
590
858
  const within = active?.closest("a-menu") === this;
591
859
  if (within && !(active instanceof AMenuItemElement)) return;
592
860
  const items = this.focusableItems();
@@ -594,19 +862,19 @@ class AMenuElement extends HTMLElementBase {
594
862
  switch (e.key) {
595
863
  case "ArrowDown":
596
864
  e.preventDefault();
597
- items[idx < 0 ? 0 : (idx + 1) % items.length]?.focus();
865
+ items[idx < 0 ? 0 : (idx + 1) % items.length]?.focus({ preventScroll: true });
598
866
  break;
599
867
  case "ArrowUp":
600
868
  e.preventDefault();
601
- items[idx <= 0 ? items.length - 1 : idx - 1]?.focus();
869
+ items[idx <= 0 ? items.length - 1 : idx - 1]?.focus({ preventScroll: true });
602
870
  break;
603
871
  case "Home":
604
872
  e.preventDefault();
605
- items[0]?.focus();
873
+ items[0]?.focus({ preventScroll: true });
606
874
  break;
607
875
  case "End":
608
876
  e.preventDefault();
609
- items[items.length - 1]?.focus();
877
+ items[items.length - 1]?.focus({ preventScroll: true });
610
878
  break;
611
879
  case "ArrowRight": {
612
880
  const sub = this.submenuOf(active);
@@ -665,7 +933,7 @@ class AMenuElement extends HTMLElementBase {
665
933
  anchor.addEventListener("click", onClick);
666
934
  let onEnter;
667
935
  let onLeave;
668
- if (this.isHover) {
936
+ if (this.#isHover) {
669
937
  onEnter = (e) => {
670
938
  if (e.pointerType === "mouse") this.scheduleOpen();
671
939
  };
@@ -681,7 +949,7 @@ class AMenuElement extends HTMLElementBase {
681
949
  if (onLeave) anchor.removeEventListener("pointerleave", onLeave);
682
950
  this.listening = false;
683
951
  };
684
- } else if (this.isContext) {
952
+ } else if (this.#isContext) {
685
953
  const onContext = (e) => {
686
954
  e.preventDefault();
687
955
  this.requestOpen({ coord: [e.clientX, e.clientY], originEvent: e });
@@ -695,9 +963,9 @@ class AMenuElement extends HTMLElementBase {
695
963
  const onClick = (e) => {
696
964
  const viaKeyboard = e.detail === 0;
697
965
  let coord;
698
- if (this.isCoord) {
966
+ if (this.#isCoord) {
699
967
  if (viaKeyboard) {
700
- const r = anchor.getBoundingClientRect();
968
+ const r = anchorRect(anchor);
701
969
  coord = [r.left, r.bottom];
702
970
  } else {
703
971
  coord = [e.clientX, e.clientY];
@@ -731,12 +999,23 @@ class AMenuElement extends HTMLElementBase {
731
999
  scheduleClose() {
732
1000
  this.cancelOpenTimer();
733
1001
  if (!this._shown) return;
1002
+ if (this.#hasKeyboardFocusInside) return;
734
1003
  this.cancelCloseTimer();
735
1004
  this.closeTimer = setTimeout(() => {
736
1005
  this.closeTimer = void 0;
737
1006
  this.requestClose();
738
1007
  }, SUBMENU_CLOSE_DELAY);
739
1008
  }
1009
+ /** True when the document's focused element is inside this menu AND is
1010
+ * keyboard-focused (`:focus-visible`). Distinguishes "arrowed into the flyout"
1011
+ * (keep it open on hover-out) from a mouse-click focus (close as usual).
1012
+ * `activeElement` retargets to the shadow host at the document level, so a
1013
+ * focused menu item (or its delegated inner control) reads as a light-DOM
1014
+ * descendant here. */
1015
+ get #hasKeyboardFocusInside() {
1016
+ const active = this.doc.activeElement;
1017
+ return !!active && this.contains(active) && active.matches(":focus-visible");
1018
+ }
740
1019
  cancelOpenTimer() {
741
1020
  if (this.openTimer !== void 0) {
742
1021
  clearTimeout(this.openTimer);
@@ -1 +1 @@
1
- @layer anta{a-progress{--progress-bg: var(--bg-4);--progress-border-color: var(--border-4);--progress-indicator-bg: var(--bg-5);--progress-indicator-edge: linear-gradient( 90deg, var(--progress-indicator-bg) 0%, var(--progress-border-color) 100% );--progress-text-color: var(--text-2);--progress-hint-color: var(--text-3);display:block;container-type:inline-size;padding:4px 8px;min-height:8px;background:var(--progress-bg);border:0px solid var(--progress-border-color);border-radius:0}a-progress[tone=brand]{--progress-bg: var(--bg-4-brand);--progress-border-color: var(--border-4-brand);--progress-indicator-bg: var(--bg-5-brand);--progress-text-color: var(--text-2-brand);--progress-hint-color: var(--text-3-brand)}a-progress[tone=info]{--progress-bg: var(--bg-4-info);--progress-border-color: var(--border-4-info);--progress-indicator-bg: var(--bg-5-info);--progress-text-color: var(--text-2-info);--progress-hint-color: var(--text-3-info)}a-progress[tone=success]{--progress-bg: var(--bg-4-success);--progress-border-color: var(--border-4-success);--progress-indicator-bg: var(--bg-5-success);--progress-text-color: var(--text-2-success);--progress-hint-color: var(--text-3-success)}a-progress[tone=warning]{--progress-bg: var(--bg-4-warning);--progress-border-color: var(--border-4-warning);--progress-indicator-bg: var(--bg-5-warning);--progress-text-color: var(--text-2-warning);--progress-hint-color: var(--text-3-warning)}a-progress[tone=critical]{--progress-bg: var(--bg-4-critical);--progress-border-color: var(--border-4-critical);--progress-indicator-bg: var(--bg-5-critical);--progress-text-color: var(--text-2-critical);--progress-hint-color: var(--text-3-critical)}a-progress[tone]:not([tone=""],[tone=neutral],[tone=brand],[tone=info],[tone=success],[tone=warning],[tone=critical]){--progress-tone-source: attr(tone type(<color>), transparent);--progress-bg: oklch(from var(--progress-tone-source) .95 calc(c*.4) h);--progress-border-color: oklch(from var(--progress-tone-source) .85 calc(c*.6) h);--progress-indicator-bg: oklch(from var(--progress-tone-source) .6 c h);--progress-text-color: oklch(from var(--progress-tone-source) .45 c h);--progress-hint-color: oklch(from var(--progress-tone-source) .55 c h);.dark &{--progress-bg: oklch(from var(--progress-tone-source) .28 calc(c/2) h);--progress-border-color: oklch(from var(--progress-tone-source) .4 calc(c*.6) h);--progress-indicator-bg: oklch(from var(--progress-tone-source) .6 c h);--progress-text-color: oklch(from var(--progress-tone-source) .85 c h);--progress-hint-color: oklch(from var(--progress-tone-source) .75 c h)}}a-progress-label{display:flex;align-items:flex-start;gap:.5ch;font-variant-numeric:tabular-nums;font-feature-settings:"ss02","ss05";letter-spacing:.03em;font-size:13px;line-height:16px}a-progress-number{flex-shrink:0;color:var(--progress-text-color)}a-progress-text{flex:1 1 auto;min-width:0;color:var(--progress-text-color)}a-progress-hint{flex-shrink:0;margin-left:auto;color:var(--progress-hint-color)}}
1
+ @layer anta{a-progress{--progress-bg: var(--bg-4);--progress-border-color: var(--border-4);--progress-indicator-bg: var(--bg-5);--progress-indicator-edge: linear-gradient( 90deg, var(--progress-indicator-bg) 0%, var(--progress-border-color) 100% );--progress-text-color: var(--text-2);--progress-hint-color: var(--text-3);display:block;container-type:inline-size;padding:4px 8px;min-height:8px;background:var(--progress-bg);border:0px solid var(--progress-border-color);border-radius:0}a-progress[round]{--progress-round: attr(round type(<length>), 999px);border-radius:var(--progress-round, 999px)}a-progress[tone=brand]{--progress-bg: var(--bg-4-brand);--progress-border-color: var(--border-4-brand);--progress-indicator-bg: var(--bg-5-brand);--progress-text-color: var(--text-2-brand);--progress-hint-color: var(--text-3-brand)}a-progress[tone=info]{--progress-bg: var(--bg-4-info);--progress-border-color: var(--border-4-info);--progress-indicator-bg: var(--bg-5-info);--progress-text-color: var(--text-2-info);--progress-hint-color: var(--text-3-info)}a-progress[tone=success]{--progress-bg: var(--bg-4-success);--progress-border-color: var(--border-4-success);--progress-indicator-bg: var(--bg-5-success);--progress-text-color: var(--text-2-success);--progress-hint-color: var(--text-3-success)}a-progress[tone=warning]{--progress-bg: var(--bg-4-warning);--progress-border-color: var(--border-4-warning);--progress-indicator-bg: var(--bg-5-warning);--progress-text-color: var(--text-2-warning);--progress-hint-color: var(--text-3-warning)}a-progress[tone=critical]{--progress-bg: var(--bg-4-critical);--progress-border-color: var(--border-4-critical);--progress-indicator-bg: var(--bg-5-critical);--progress-text-color: var(--text-2-critical);--progress-hint-color: var(--text-3-critical)}a-progress[tone]:not([tone=""],[tone=neutral],[tone=brand],[tone=info],[tone=success],[tone=warning],[tone=critical]){--progress-tone-source: attr(tone type(<color>), transparent);--progress-bg: oklch(from var(--progress-tone-source) .95 calc(c*.4) h);--progress-border-color: oklch(from var(--progress-tone-source) .85 calc(c*.6) h);--progress-indicator-bg: oklch(from var(--progress-tone-source) .6 c h);--progress-text-color: oklch(from var(--progress-tone-source) .45 c h);--progress-hint-color: oklch(from var(--progress-tone-source) .55 c h);.dark &{--progress-bg: oklch(from var(--progress-tone-source) .28 calc(c/2) h);--progress-border-color: oklch(from var(--progress-tone-source) .4 calc(c*.6) h);--progress-indicator-bg: oklch(from var(--progress-tone-source) .6 c h);--progress-text-color: oklch(from var(--progress-tone-source) .85 c h);--progress-hint-color: oklch(from var(--progress-tone-source) .75 c h)}}a-progress-label{display:flex;align-items:flex-start;gap:.5ch;font-variant-numeric:lining-nums tabular-nums;font-feature-settings:"ss02","ss05";letter-spacing:.03em;font-size:13px;line-height:16px}a-progress-number{flex-shrink:0;color:var(--progress-text-color)}a-progress-text{flex:1 1 auto;min-width:0;color:var(--progress-text-color)}a-progress-hint{flex-shrink:0;margin-left:auto;color:var(--progress-hint-color)}}