@antadesign/anta 0.2.2 → 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.
- package/README.md +14 -0
- package/dist/anta_helpers.d.ts +39 -1
- package/dist/anta_helpers.js +30 -2
- package/dist/components/Button.d.ts +7 -4
- package/dist/components/Button.js +6 -11
- package/dist/components/Checkbox.d.ts +97 -0
- package/dist/components/Checkbox.js +77 -0
- package/dist/components/Expander.d.ts +74 -0
- package/dist/components/Expander.js +53 -0
- package/dist/components/Input.d.ts +159 -0
- package/dist/components/Input.js +150 -0
- package/dist/components/Menu.d.ts +70 -0
- package/dist/components/Menu.js +42 -0
- package/dist/components/MenuGroup.d.ts +24 -0
- package/dist/components/MenuGroup.js +19 -0
- package/dist/components/MenuItem.d.ts +50 -0
- package/dist/components/MenuItem.js +41 -0
- package/dist/components/MenuSeparator.d.ts +14 -0
- package/dist/components/MenuSeparator.js +7 -0
- package/dist/components/Progress.d.ts +12 -6
- package/dist/components/Progress.js +7 -4
- package/dist/components/Radio.d.ts +37 -0
- package/dist/components/Radio.js +33 -0
- package/dist/components/RadioGroup.d.ts +119 -0
- package/dist/components/RadioGroup.js +108 -0
- package/dist/components/Tag.d.ts +38 -5
- package/dist/components/Tag.js +9 -5
- package/dist/components/Text.d.ts +27 -12
- package/dist/components/Text.js +6 -3
- package/dist/components/Title.d.ts +10 -1
- package/dist/elements/a-button.css +1 -1
- package/dist/elements/a-button.d.ts +56 -0
- package/dist/elements/a-button.js +13 -11
- package/dist/elements/a-checkbox.css +1 -0
- package/dist/elements/a-checkbox.d.ts +52 -0
- package/dist/elements/a-checkbox.js +130 -0
- package/dist/elements/a-expander.css +1 -0
- package/dist/elements/a-expander.d.ts +28 -0
- package/dist/elements/a-expander.js +237 -0
- package/dist/elements/a-icon.d.ts +14 -0
- package/dist/elements/a-icon.shapes.css +1 -1
- package/dist/elements/a-icon.shapes.d.ts +9 -1
- package/dist/elements/a-icon.shapes.js +10 -1
- package/dist/elements/a-input.css +1 -0
- package/dist/elements/a-input.d.ts +68 -0
- package/dist/elements/a-input.js +511 -0
- package/dist/elements/a-menu-group.css +1 -0
- package/dist/elements/a-menu-group.d.ts +13 -0
- package/dist/elements/a-menu-group.js +15 -0
- package/dist/elements/a-menu-item.css +1 -0
- package/dist/elements/a-menu-item.d.ts +47 -0
- package/dist/elements/a-menu-item.js +30 -0
- package/dist/elements/a-menu-separator.css +1 -0
- package/dist/elements/a-menu-separator.d.ts +13 -0
- package/dist/elements/a-menu-separator.js +15 -0
- package/dist/elements/a-menu.css +1 -0
- package/dist/elements/a-menu.d.ts +171 -0
- package/dist/elements/a-menu.js +714 -0
- package/dist/elements/a-progress.css +1 -1
- package/dist/elements/a-progress.d.ts +12 -0
- package/dist/elements/a-progress.js +1 -0
- package/dist/elements/a-radio-group.css +1 -0
- package/dist/elements/a-radio-group.d.ts +33 -0
- package/dist/elements/a-radio-group.js +160 -0
- package/dist/elements/a-radio.css +1 -0
- package/dist/elements/a-radio.d.ts +14 -0
- package/dist/elements/a-radio.js +46 -0
- package/dist/elements/a-tag.css +1 -1
- package/dist/elements/a-text.css +1 -1
- package/dist/elements/a-text.d.ts +42 -3
- package/dist/elements/a-text.js +73 -33
- package/dist/elements/a-tooltip.d.ts +43 -11
- package/dist/elements/a-tooltip.js +46 -51
- package/dist/elements/index.d.ts +9 -0
- package/dist/elements/index.js +27 -0
- package/dist/general_types.d.ts +467 -15
- package/dist/index.d.ts +16 -0
- package/dist/index.js +16 -0
- package/dist/jsx-runtime.d.ts +42 -7
- package/dist/jsx-runtime.js +14 -2
- package/dist/tokens.css +1 -1
- 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
|
+
};
|