@lumiose/ui 0.0.0-stage → 0.1.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 (149) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +53 -2
  3. package/dist/accordion/accordion.css +20 -0
  4. package/dist/accordion/accordion.script.ts +66 -0
  5. package/dist/accordion/accordion.tsx +288 -0
  6. package/dist/animation/animation.ts +34 -0
  7. package/dist/autocomplete/autocomplete.tsx +239 -0
  8. package/dist/avatar/avatar.css +54 -0
  9. package/dist/avatar/avatar.script.ts +96 -0
  10. package/dist/avatar/avatar.tsx +110 -0
  11. package/dist/button/button.script.ts +35 -0
  12. package/dist/button/button.tsx +40 -0
  13. package/dist/checkbox/checkbox.script.ts +225 -0
  14. package/dist/checkbox/checkbox.tsx +156 -0
  15. package/dist/checkbox/context.ts +17 -0
  16. package/dist/checkbox-group/checkbox-group.tsx +111 -0
  17. package/dist/collapsible/collapsible.css +12 -0
  18. package/dist/collapsible/collapsible.script.ts +7 -0
  19. package/dist/collapsible/collapsible.tsx +153 -0
  20. package/dist/collapsible/panel.ts +115 -0
  21. package/dist/combobox/combobox.css +37 -0
  22. package/dist/combobox/combobox.script.ts +974 -0
  23. package/dist/combobox/combobox.tsx +556 -0
  24. package/dist/combobox/context.ts +46 -0
  25. package/dist/context-menu/context-menu.css +16 -0
  26. package/dist/context-menu/context-menu.script.ts +172 -0
  27. package/dist/context-menu/context-menu.tsx +178 -0
  28. package/dist/dialog/dialog.css +35 -0
  29. package/dist/dialog/dialog.script.ts +269 -0
  30. package/dist/dialog/dialog.tsx +214 -0
  31. package/dist/drawer/drawer.css +38 -0
  32. package/dist/drawer/drawer.script.ts +207 -0
  33. package/dist/drawer/drawer.tsx +113 -0
  34. package/dist/field/context.ts +156 -0
  35. package/dist/field/field.css +16 -0
  36. package/dist/field/field.script.ts +422 -0
  37. package/dist/field/field.tsx +465 -0
  38. package/dist/fieldset/fieldset.tsx +41 -0
  39. package/dist/form/form.tsx +50 -0
  40. package/dist/form/native.ts +21 -0
  41. package/dist/form/reset.ts +53 -0
  42. package/dist/index.ts +261 -0
  43. package/dist/input/input.tsx +67 -0
  44. package/dist/layer/layer.ts +191 -0
  45. package/dist/locale/digits.ts +21 -0
  46. package/dist/menu/menu.css +22 -0
  47. package/dist/menu/menu.script.ts +591 -0
  48. package/dist/menu/menu.tsx +520 -0
  49. package/dist/menubar/menubar.css +16 -0
  50. package/dist/menubar/menubar.script.ts +195 -0
  51. package/dist/menubar/menubar.tsx +67 -0
  52. package/dist/meter/meter.tsx +134 -0
  53. package/dist/navigation-menu/navigation-menu.css +14 -0
  54. package/dist/navigation-menu/navigation-menu.script.ts +466 -0
  55. package/dist/navigation-menu/navigation-menu.tsx +266 -0
  56. package/dist/number-field/format.ts +70 -0
  57. package/dist/number-field/number-field.css +38 -0
  58. package/dist/number-field/number-field.script.ts +490 -0
  59. package/dist/number-field/number-field.tsx +403 -0
  60. package/dist/otp-field/otp-field.css +45 -0
  61. package/dist/otp-field/otp-field.script.ts +330 -0
  62. package/dist/otp-field/otp-field.tsx +320 -0
  63. package/dist/placement/place.ts +148 -0
  64. package/dist/placement/placement.css +155 -0
  65. package/dist/placement/placement.ts +60 -0
  66. package/dist/popover/hover.ts +178 -0
  67. package/dist/popover/popover.css +20 -0
  68. package/dist/popover/popover.script.ts +256 -0
  69. package/dist/popover/popover.tsx +317 -0
  70. package/dist/preview-card/preview-card.css +7 -0
  71. package/dist/preview-card/preview-card.script.ts +136 -0
  72. package/dist/preview-card/preview-card.tsx +128 -0
  73. package/dist/progress/progress.tsx +155 -0
  74. package/dist/progress/value.ts +23 -0
  75. package/dist/radio-group/radio-group.tsx +156 -0
  76. package/dist/scroll-area/scroll-area.css +72 -0
  77. package/dist/scroll-area/scroll-area.script.ts +281 -0
  78. package/dist/scroll-area/scroll-area.tsx +118 -0
  79. package/dist/select/select.css +25 -0
  80. package/dist/select/select.tsx +171 -0
  81. package/dist/separator/separator.css +12 -0
  82. package/dist/separator/separator.tsx +38 -0
  83. package/dist/slider/format.ts +33 -0
  84. package/dist/slider/slider.css +93 -0
  85. package/dist/slider/slider.script.ts +355 -0
  86. package/dist/slider/slider.tsx +374 -0
  87. package/dist/styles.css +30 -0
  88. package/dist/switch/switch.tsx +92 -0
  89. package/dist/tabs/tabs.css +54 -0
  90. package/dist/tabs/tabs.script.ts +225 -0
  91. package/dist/tabs/tabs.tsx +272 -0
  92. package/dist/themes/default.css +2111 -0
  93. package/dist/toast/command.ts +3 -0
  94. package/dist/toast/toast.css +68 -0
  95. package/dist/toast/toast.script.ts +516 -0
  96. package/dist/toast/toast.tsx +432 -0
  97. package/dist/toggle/context.ts +16 -0
  98. package/dist/toggle/toggle.tsx +99 -0
  99. package/dist/toggle-group/toggle-group.script.ts +166 -0
  100. package/dist/toggle-group/toggle-group.tsx +117 -0
  101. package/dist/toolbar/context.ts +18 -0
  102. package/dist/toolbar/toolbar.css +12 -0
  103. package/dist/toolbar/toolbar.script.ts +123 -0
  104. package/dist/toolbar/toolbar.tsx +180 -0
  105. package/dist/tooltip/tooltip.css +8 -0
  106. package/dist/tooltip/tooltip.script.ts +222 -0
  107. package/dist/tooltip/tooltip.tsx +149 -0
  108. package/docs/README.md +174 -0
  109. package/docs/accordion.md +208 -0
  110. package/docs/autocomplete.md +161 -0
  111. package/docs/avatar.md +198 -0
  112. package/docs/button.md +98 -0
  113. package/docs/checkbox-group.md +112 -0
  114. package/docs/checkbox.md +133 -0
  115. package/docs/collapsible.md +165 -0
  116. package/docs/combobox.md +369 -0
  117. package/docs/context-menu.md +197 -0
  118. package/docs/dialog.md +256 -0
  119. package/docs/drawer.md +204 -0
  120. package/docs/field.md +295 -0
  121. package/docs/fieldset.md +92 -0
  122. package/docs/form.md +131 -0
  123. package/docs/input.md +87 -0
  124. package/docs/installation.md +65 -0
  125. package/docs/llms-full.txt +6923 -0
  126. package/docs/llms.txt +53 -0
  127. package/docs/menu.md +365 -0
  128. package/docs/menubar.md +186 -0
  129. package/docs/meter.md +103 -0
  130. package/docs/navigation-menu.md +257 -0
  131. package/docs/number-field.md +181 -0
  132. package/docs/otp-field.md +160 -0
  133. package/docs/popover.md +271 -0
  134. package/docs/preview-card.md +130 -0
  135. package/docs/progress.md +115 -0
  136. package/docs/radio-group.md +127 -0
  137. package/docs/scroll-area.md +147 -0
  138. package/docs/select.md +236 -0
  139. package/docs/separator.md +116 -0
  140. package/docs/slider.md +184 -0
  141. package/docs/switch.md +98 -0
  142. package/docs/tabs.md +254 -0
  143. package/docs/theming.md +132 -0
  144. package/docs/toast.md +311 -0
  145. package/docs/toggle-group.md +119 -0
  146. package/docs/toggle.md +94 -0
  147. package/docs/toolbar.md +138 -0
  148. package/docs/tooltip.md +128 -0
  149. package/package.json +102 -3
@@ -0,0 +1,591 @@
1
+ // Menu's client script (decision-62, shipped through the client-script import of decision-63): it
2
+ // defines `lu-menu`, the light-DOM element that adds what a popover menu lacks. The trigger's
3
+ // `popovertarget` already opens and closes the popup, puts it in the top layer and light-dismisses
4
+ // it, in server HTML and with no JavaScript. The element adds the APG Menu Button contract and Base
5
+ // UI's behaviour (doc-9, sections 1 and 5):
6
+ //
7
+ // - Dismissal: the popup becomes a manual popover when the element opens it, dismissed by the
8
+ // layer model Menu shares with Popover and Combobox (../layer/layer.ts): a main-button press and
9
+ // release outside (native light dismiss also closes on a right-click and cannot be canceled), a
10
+ // close request, and another layer opening outside it, such as a sibling submenu. Submenus close
11
+ // with their parent.
12
+ // - Placement: where CSS anchor positioning is missing, the shared ../placement/place.ts places
13
+ // an open popup next to its trigger, and again on scroll and resize.
14
+ // - Focus: a keyboard opening focuses the first item that can run (Arrow Up: the last one), a
15
+ // pointer opening the popup; closing returns focus to the trigger, without scrolling, unless an
16
+ // outside press landed on something focusable. Once defined, the element toggles the popup on
17
+ // the trigger's click itself, so focus moves in before the next key.
18
+ // - Keys: arrows (looping), Home, End, typeahead, Escape (one level), Tab and Shift+Tab (close the
19
+ // menu), and Arrow Right and Left to open and close submenus (swapped right to left).
20
+ // - Items: running one closes the whole menu and returns focus before its handlers run; checkbox
21
+ // and radio items set `aria-checked`, fire `checkedchange` and stay open; `aria-disabled` items
22
+ // can be highlighted but do not run. `data-highlighted` follows focus.
23
+ // - Pointer: hovering highlights; a mouse press on the trigger opens the menu and releasing on an
24
+ // item runs it; submenus open on hover and stay open while the pointer heads for them.
25
+ //
26
+ // Unlike Timeless it keeps no per-item state and observes no mutations: listeners are delegated to
27
+ // the element and items are queried when an event needs them, so Lumiose may add, remove or
28
+ // rerender items at any time. It must not import `lumiose`: server-rendered pages load it without
29
+ // the runtime.
30
+
31
+ import { moved, openLayer } from "../layer/layer.ts";
32
+ import { anchoring, place } from "../placement/place.ts";
33
+
34
+ const ITEM = "[role^='menuitem']";
35
+ /** Typed characters add up into one search until this pause (ms), as in Base UI. */
36
+ const TYPEAHEAD_RESET = 750;
37
+ /** Hovering a submenu trigger opens its submenu after this delay (ms), as in Base UI. */
38
+ const HOVER_DELAY = 100;
39
+ /** A release on an item this soon (ms) after a press opened the menu does not run it. */
40
+ const RELEASE_DELAY = 200;
41
+ /** How long (ms) the pointer may rest on its way to an open submenu before a sibling takes over. */
42
+ const INTENT_TIMEOUT = 300;
43
+
44
+ /** Where focus goes when the menu opens. */
45
+ type Edge = "first" | "last" | "popup" | "none";
46
+
47
+ export interface MenuOpenChangeDetail {
48
+ open: boolean;
49
+ }
50
+
51
+ export interface MenuCheckedChangeDetail {
52
+ checked: boolean;
53
+ }
54
+
55
+ const enabled = (item: Element) => item.getAttribute("aria-disabled") !== "true";
56
+ const label = (item: HTMLElement) =>
57
+ (item.dataset.label ?? item.textContent ?? "").trim().toLowerCase();
58
+ /** The events the element listens to on itself, besides `click` and `toggle` (capturing). */
59
+ const EVENTS = ["keydown", "pointerdown", "pointermove", "pointerout", "focusin", "focusout"];
60
+ const FOCUSABLE = "button, a[href], input, select, textarea, [tabindex], [contenteditable]";
61
+
62
+ /** Whether `p` is inside the triangle `a`, `b`, `c`. */
63
+ function inside(p: Point, a: Point, b: Point, c: Point): boolean {
64
+ const side = (u: Point, v: Point) => (p.x - v.x) * (u.y - v.y) - (u.x - v.x) * (p.y - v.y);
65
+ const [d1, d2, d3] = [side(a, b), side(b, c), side(c, a)];
66
+ return !((d1 < 0 || d2 < 0 || d3 < 0) && (d1 > 0 || d2 > 0 || d3 > 0));
67
+ }
68
+
69
+ interface Point {
70
+ x: number;
71
+ y: number;
72
+ }
73
+
74
+ export class MenuElement extends HTMLElement {
75
+ /** Where focus goes when the popup opens; set by whatever opened it. */
76
+ #edge: Edge | undefined;
77
+ #typed = "";
78
+ #typedAt = -Infinity;
79
+ /** A pending hover opening of this submenu. */
80
+ #hoverTimer: ReturnType<typeof setTimeout> | undefined;
81
+ /** A pending move to a sibling item while the pointer heads for an open submenu. */
82
+ #intentTimer: ReturnType<typeof setTimeout> | undefined;
83
+ /** The last pointer position over the trigger of an open submenu. */
84
+ #exit: Point | undefined;
85
+ /** When a mouse press on the trigger opened the menu, until the press ends. */
86
+ #pressedAt: number | undefined;
87
+ /** Where the outside press that dismissed the menu landed. */
88
+ #outside: Element | undefined;
89
+ /** Stops dismissing the menu (../layer/layer.ts). */
90
+ #release: (() => void) | undefined;
91
+ /** While open: keeps the focused item in view when the popup changes size. */
92
+ #resize: ResizeObserver | undefined;
93
+
94
+ /** The menu's popup: its first `[role=menu][popover]`. */
95
+ get popup(): HTMLElement | null {
96
+ return this.querySelector<HTMLElement>("[role=menu][popover]");
97
+ }
98
+
99
+ /** The button whose `popovertarget` is the popup. */
100
+ get trigger(): HTMLElement | null {
101
+ const id = this.popup?.id;
102
+ return id ? this.querySelector<HTMLElement>(`[popovertarget="${CSS.escape(id)}"]`) : null;
103
+ }
104
+
105
+ /** Whether the menu is open. Setting it opens or closes the menu; anything but a boolean is ignored. */
106
+ get open(): boolean {
107
+ return this.popup?.matches(":popover-open") ?? false;
108
+ }
109
+
110
+ set open(value: boolean | undefined) {
111
+ const popup = this.popup;
112
+ if (typeof value !== "boolean" || value === this.open || !popup?.isConnected) return;
113
+ if (value) this.#show("first");
114
+ else this.#hide(popup.contains(document.activeElement));
115
+ }
116
+
117
+ /**
118
+ * Opens the menu as a keyboard (focus on the first item that can run) or a pointer opening
119
+ * (focus on the popup) would: ContextMenu opens it this way, having no trigger.
120
+ */
121
+ show(keyboard: boolean): void {
122
+ if (this.popup?.isConnected && !this.open) this.#show(keyboard ? "first" : "popup");
123
+ }
124
+
125
+ connectedCallback(): void {
126
+ for (const type of EVENTS) {
127
+ this.addEventListener(type, this);
128
+ }
129
+ // Before the items' own handlers, which Lumiose delegates to the document.
130
+ this.addEventListener("click", this, true);
131
+ // `toggle` does not bubble: a capturing listener on the element still sees it.
132
+ this.addEventListener("toggle", this, true);
133
+ }
134
+
135
+ disconnectedCallback(): void {
136
+ for (const type of EVENTS) {
137
+ this.removeEventListener(type, this);
138
+ }
139
+ this.removeEventListener("click", this, true);
140
+ this.removeEventListener("toggle", this, true);
141
+ this.#listen(false);
142
+ this.#release?.();
143
+ this.#release = undefined;
144
+ clearTimeout(this.#hoverTimer);
145
+ clearTimeout(this.#intentTimer);
146
+ }
147
+
148
+ handleEvent(event: Event): void {
149
+ switch (event.type) {
150
+ case "keydown":
151
+ return this.#onKeyDown(event as KeyboardEvent);
152
+ case "click":
153
+ return this.#onClick(event as MouseEvent);
154
+ case "pointerdown":
155
+ return this.#onPress(event as PointerEvent);
156
+ case "pointerup":
157
+ return this.#onRelease(event as PointerEvent);
158
+ case "pointermove":
159
+ return this.#onPointerMove(event as PointerEvent);
160
+ case "pointerout":
161
+ return this.#onPointerOut(event as PointerEvent);
162
+ case "toggle":
163
+ return this.#onToggle(event as ToggleEvent);
164
+ case "scroll":
165
+ case "resize":
166
+ return this.#place();
167
+ default: {
168
+ // focusin, focusout: the highlight follows focus.
169
+ const item = event.target as HTMLElement;
170
+ if (item.matches(ITEM) && this.#owns(item)) {
171
+ item.toggleAttribute("data-highlighted", event.type === "focusin");
172
+ }
173
+ }
174
+ }
175
+ }
176
+
177
+ /** The menu whose popup holds this menu's trigger, for a submenu. */
178
+ #parent(): MenuElement | null {
179
+ return this.parentElement?.closest<MenuElement>("lu-menu") ?? null;
180
+ }
181
+
182
+ #root(): MenuElement {
183
+ const parent = this.#parent();
184
+ return parent ? parent.#root() : this;
185
+ }
186
+
187
+ /** Whether `node` belongs to this menu's popup rather than to a submenu inside it. */
188
+ #owns(node: Element): boolean {
189
+ const popup = this.popup;
190
+ return popup !== null && node.closest("[role=menu]") === popup;
191
+ }
192
+
193
+ /** The items of the popup, without hidden, natively disabled or submenu items. */
194
+ #items(): HTMLElement[] {
195
+ const popup = this.popup;
196
+ if (!popup) return [];
197
+ return [...popup.querySelectorAll<HTMLElement>(ITEM)].filter(
198
+ (item) =>
199
+ item.closest("[role=menu]") === popup &&
200
+ item.getClientRects().length > 0 &&
201
+ !item.matches(":disabled"),
202
+ );
203
+ }
204
+
205
+ /** The open submenu whose trigger is one of this menu's items. */
206
+ #openChild(): MenuElement | undefined {
207
+ for (const item of this.#items()) {
208
+ const child = item.closest<MenuElement>("lu-menu");
209
+ if (child !== this && child?.open) return child;
210
+ }
211
+ return undefined;
212
+ }
213
+
214
+ #show(edge: Edge): void {
215
+ const popup = this.popup!;
216
+ // The server HTML's `popover="auto"` light-dismisses on any button and has no filters: where
217
+ // the element runs, the popup is a manual popover dismissed by the shared layer model, which
218
+ // also closes an open sibling submenu (a layer this one is not inside of).
219
+ popup.setAttribute("popover", "manual");
220
+ this.#edge = edge;
221
+ // `source` makes the trigger the invoker, as a click on it would. A context menu has none.
222
+ (popup.showPopover as (options?: { source?: HTMLElement }) => void)({
223
+ source: this.trigger ?? undefined,
224
+ });
225
+ this.#place();
226
+ this.#listen(true);
227
+ this.#release?.();
228
+ this.#release = openLayer({
229
+ popup,
230
+ contains: (node) => this.contains(node),
231
+ dismiss: (press) => {
232
+ // An outside press keeps focus where it landed; a close request returns it from inside.
233
+ this.#outside = press;
234
+ this.#hide(!press && popup.contains(document.activeElement));
235
+ },
236
+ });
237
+ this.#focus(edge);
238
+ this.#edge = "none";
239
+ }
240
+
241
+ /** Closes this menu and its submenus, focusing its trigger first when `returnFocus`. */
242
+ #hide(returnFocus: boolean): void {
243
+ if (!this.open) return;
244
+ const child = this.#openChild();
245
+ if (child) child.#hide(false);
246
+ // Moving focus out first keeps the popover from restoring it to whatever had it on opening.
247
+ if (returnFocus) this.trigger?.focus({ preventScroll: true });
248
+ // Now, not on `toggle` a task later: the next close request belongs to the layer below.
249
+ this.#release?.();
250
+ this.#release = undefined;
251
+ this.popup!.hidePopover();
252
+ }
253
+
254
+ /**
255
+ * While the menu is open: the release of a press that opened it, wherever it lands, and the
256
+ * scrolling and resizing that move its trigger where the script places the popup.
257
+ */
258
+ #listen(on: boolean): void {
259
+ const method = on ? "addEventListener" : "removeEventListener";
260
+ document[method]("pointerup", this, true);
261
+ if (!anchoring) for (const type of ["scroll", "resize"]) window[method](type, this, true);
262
+ }
263
+
264
+ /** Places the popup next to its trigger where CSS anchor positioning is missing. */
265
+ #place(): void {
266
+ const trigger = this.trigger;
267
+ if (!anchoring && trigger && this.open) place(this.popup!, trigger);
268
+ }
269
+
270
+ #focus(edge: Edge): void {
271
+ if (edge === "none") return;
272
+ const items = this.#items();
273
+ const runnable = items.filter(enabled);
274
+ const item =
275
+ edge === "first"
276
+ ? (runnable[0] ?? items[0])
277
+ : edge === "last"
278
+ ? (runnable.at(-1) ?? items.at(-1))
279
+ : undefined;
280
+ if (item) item.focus();
281
+ else this.popup?.focus({ preventScroll: true });
282
+ }
283
+
284
+ #onToggle(event: ToggleEvent): void {
285
+ const popup = this.popup;
286
+ if (!popup || event.target !== popup) return;
287
+ const open = event.newState === "open";
288
+ this.trigger?.setAttribute("aria-expanded", String(open));
289
+ if (open) {
290
+ const edge = this.#edge ?? "first";
291
+ this.#edge = undefined;
292
+ this.#place();
293
+ this.#focus(edge);
294
+ // The popup may shrink to the room beside its trigger after an item took focus (its opening
295
+ // transition): the focused item stays in view (TASK-428).
296
+ this.#resize ??= new ResizeObserver(() => {
297
+ const active = document.activeElement;
298
+ if (active instanceof HTMLElement && active.closest("[role=menu]") === this.popup) {
299
+ active.scrollIntoView({ block: "nearest" });
300
+ }
301
+ });
302
+ this.#resize.observe(popup);
303
+ } else {
304
+ this.#resize?.disconnect();
305
+ this.#listen(false);
306
+ this.#release?.();
307
+ clearTimeout(this.#intentTimer);
308
+ this.#edge = this.#pressedAt = this.#release = undefined;
309
+ for (const item of popup.querySelectorAll("[data-highlighted]")) {
310
+ item.removeAttribute("data-highlighted");
311
+ }
312
+ // Light dismiss leaves focus on the page: back to the trigger, unless the press landed on
313
+ // something focusable (Safari and Firefox on macOS do not focus a pressed button).
314
+ const active = document.activeElement;
315
+ if ((!active || active === document.body) && !this.#outside?.closest(FOCUSABLE)) {
316
+ this.trigger?.focus({ preventScroll: true });
317
+ }
318
+ this.#outside = undefined;
319
+ }
320
+ this.dispatchEvent(new CustomEvent<MenuOpenChangeDetail>("openchange", { detail: { open } }));
321
+ }
322
+
323
+ #onKeyDown(event: KeyboardEvent): void {
324
+ if (event.isComposing || event.defaultPrevented) return;
325
+ const target = event.target as HTMLElement;
326
+ const popup = this.popup;
327
+ if (!popup) return;
328
+ const rtl = popup.matches(":dir(rtl)");
329
+ const forward = rtl ? "ArrowLeft" : "ArrowRight";
330
+ if (this.trigger?.contains(target)) {
331
+ // Enter and Space open the menu through `popovertarget`; the arrows choose the item that
332
+ // gets focus. A submenu, and a menu of a vertical Menubar or Toolbar, opens with the arrow
333
+ // that points at it; its parent (or the bar) handles the rest (TASK-437 M14).
334
+ const bar = this.trigger!.closest("[popover], dialog, lu-menubar, lu-toolbar");
335
+ const sideways =
336
+ this.#parent() !== null ||
337
+ (bar?.matches("lu-menubar, lu-toolbar") === true &&
338
+ bar.getAttribute("aria-orientation") === "vertical");
339
+ const opens = sideways ? event.key === forward : /^Arrow(Down|Up)$/.test(event.key);
340
+ if (!opens || !enabled(this.trigger!)) return;
341
+ event.preventDefault();
342
+ const edge = event.key === "ArrowUp" ? "last" : "first";
343
+ if (this.open) this.#focus(edge);
344
+ else this.#show(edge);
345
+ return;
346
+ }
347
+ if (!this.#owns(target)) return;
348
+ const items = this.#items();
349
+ const index = items.indexOf(target);
350
+ let next: HTMLElement | undefined;
351
+ switch (event.key) {
352
+ case "ArrowDown":
353
+ next = items[(index + 1) % items.length];
354
+ break;
355
+ case "ArrowUp":
356
+ next = items.at(index < 0 ? -1 : index - 1);
357
+ break;
358
+ case "Home":
359
+ next = items[0];
360
+ break;
361
+ case "End":
362
+ next = items.at(-1);
363
+ break;
364
+ case rtl ? "ArrowRight" : "ArrowLeft":
365
+ if (this.#parent()) {
366
+ event.preventDefault();
367
+ this.#hide(true);
368
+ }
369
+ return;
370
+ case "Escape": {
371
+ // One level at a time: a submenu opened by hover closes before its parent.
372
+ event.preventDefault();
373
+ const child = this.#openChild();
374
+ if (child) child.#hide(false);
375
+ else this.#hide(true);
376
+ return;
377
+ }
378
+ case "Tab":
379
+ // Leaves the whole menu. Tab moves on from the trigger, Shift+Tab stays on it.
380
+ if (event.shiftKey) event.preventDefault();
381
+ this.#root().#hide(true);
382
+ return;
383
+ default: {
384
+ if (event.key.length !== 1 || event.ctrlKey || event.metaKey || event.altKey) return;
385
+ const typing = event.timeStamp - this.#typedAt < TYPEAHEAD_RESET;
386
+ // Space runs the item, except while typing, where it is part of the text. A link has no
387
+ // Space of its own (TASK-437 M16).
388
+ if (event.key === " " && !typing) {
389
+ if (!target.matches(`a${ITEM}`)) return;
390
+ event.preventDefault();
391
+ target.click();
392
+ return;
393
+ }
394
+ event.preventDefault();
395
+ next = this.#typeahead(items, index, event.key.toLowerCase(), typing, event.timeStamp);
396
+ }
397
+ }
398
+ if (next) {
399
+ event.preventDefault();
400
+ next.focus();
401
+ }
402
+ }
403
+
404
+ #typeahead(
405
+ items: HTMLElement[],
406
+ index: number,
407
+ key: string,
408
+ typing: boolean,
409
+ now: number,
410
+ ): HTMLElement | undefined {
411
+ // Repeating a letter cycles through the items that start with it; more letters refine the
412
+ // match from the current item.
413
+ const typed = typing ? this.#typed + key : key;
414
+ const repeated = [...typed].every((char) => char === key);
415
+ this.#typed = repeated ? key : typed;
416
+ this.#typedAt = now;
417
+ const start = repeated ? index + 1 : Math.max(index, 0);
418
+ const ordered = [...items.slice(start), ...items.slice(0, start)];
419
+ return ordered.find((item) => label(item).startsWith(this.#typed));
420
+ }
421
+
422
+ #onClick(event: MouseEvent): void {
423
+ const target = event.target as Element;
424
+ const trigger = this.trigger;
425
+ if (trigger?.contains(target)) {
426
+ // The element opens and closes the menu itself rather than through `popovertarget`, so
427
+ // focus moves in at once: the popover's `toggle` event comes a task later, after keys the
428
+ // user may already have typed. A click with no detail comes from Enter or Space.
429
+ event.preventDefault();
430
+ const keyboard = event.detail === 0;
431
+ if (this.#parent()) {
432
+ // A click keeps a submenu opened by hover open; Enter and Space move into it.
433
+ if (!this.open) this.#show(keyboard ? "first" : "none");
434
+ else if (keyboard) this.#focus("first");
435
+ } else if (this.#pressedAt !== undefined && !keyboard) {
436
+ // The press already opened the menu.
437
+ this.#pressedAt = undefined;
438
+ } else if (this.open) {
439
+ this.#hide(false);
440
+ } else {
441
+ this.#show(keyboard ? "first" : "popup");
442
+ }
443
+ return;
444
+ }
445
+ const item = target.closest<HTMLElement>(ITEM);
446
+ if (!item || !this.#owns(item)) return;
447
+ if (!enabled(item)) {
448
+ // Keeps the item's own handlers, a link's navigation and a submenu's toggle from running.
449
+ event.preventDefault();
450
+ event.stopImmediatePropagation();
451
+ return;
452
+ }
453
+ // A submenu trigger: its own menu handles it.
454
+ if (item.hasAttribute("popovertarget")) return;
455
+ const role = item.getAttribute("role");
456
+ let close = item.dataset.closeOnClick
457
+ ? item.dataset.closeOnClick === "true"
458
+ : role === "menuitem";
459
+ if (role === "menuitemcheckbox") {
460
+ this.#check(item, item.getAttribute("aria-checked") !== "true");
461
+ } else if (role === "menuitemradio") {
462
+ if (item.getAttribute("aria-checked") !== "true") {
463
+ const group = item.closest("[role=group], [role=menu]")!;
464
+ for (const radio of group.querySelectorAll("[role=menuitemradio][aria-checked=true]")) {
465
+ if (radio.closest("[role=group], [role=menu]") === group) {
466
+ radio.setAttribute("aria-checked", "false");
467
+ }
468
+ }
469
+ this.#check(item, true);
470
+ }
471
+ } else if (
472
+ item.localName === "a" &&
473
+ (event.ctrlKey || event.metaKey || event.shiftKey || item.hasAttribute("download"))
474
+ ) {
475
+ // The link opens elsewhere: the menu stays, as in Zag.
476
+ close = false;
477
+ }
478
+ if (close) this.#root().#hide(true);
479
+ }
480
+
481
+ #check(item: HTMLElement, checked: boolean): void {
482
+ item.setAttribute("aria-checked", String(checked));
483
+ item.dispatchEvent(
484
+ new CustomEvent<MenuCheckedChangeDetail>("checkedchange", {
485
+ bubbles: true,
486
+ detail: { checked },
487
+ }),
488
+ );
489
+ }
490
+
491
+ /** A mouse press on the trigger opens the menu at once, so a drag can release on an item. */
492
+ #onPress(event: PointerEvent): void {
493
+ this.#pressedAt = undefined;
494
+ const trigger = this.trigger;
495
+ if (
496
+ event.pointerType !== "mouse" ||
497
+ event.button !== 0 ||
498
+ this.#parent() ||
499
+ this.open ||
500
+ !trigger?.contains(event.target as Node) ||
501
+ trigger.matches(":disabled")
502
+ ) {
503
+ return;
504
+ }
505
+ // No mousedown: the trigger would take focus back from the popup.
506
+ event.preventDefault();
507
+ this.#pressedAt = event.timeStamp;
508
+ this.#show("popup");
509
+ }
510
+
511
+ /** The release of a press that opened the menu: on an item it runs it, outside it closes. */
512
+ #onRelease(event: PointerEvent): void {
513
+ const pressedAt = this.#pressedAt;
514
+ const target = event.target as Element;
515
+ if (pressedAt === undefined) return;
516
+ // A release on the trigger is a click: the menu stays open.
517
+ if (this.trigger?.contains(target)) return;
518
+ this.#pressedAt = undefined;
519
+ const popup = this.popup;
520
+ const item = target.closest?.<HTMLElement>(ITEM);
521
+ if (item && popup?.contains(item)) {
522
+ if (event.timeStamp - pressedAt >= RELEASE_DELAY) item.click();
523
+ } else if (!popup?.contains(target)) {
524
+ this.#hide(true);
525
+ }
526
+ }
527
+
528
+ #onPointerMove(event: PointerEvent): void {
529
+ // Keyboard scrolling under a resting pointer moves nothing (../layer/layer.ts).
530
+ if (!moved(this, event)) return;
531
+ clearTimeout(this.#intentTimer);
532
+ const target = event.target as Element;
533
+ const trigger = this.trigger;
534
+ if (this.#parent() && trigger?.contains(target) && !this.open && enabled(trigger)) {
535
+ this.#hoverTimer ??= setTimeout(() => {
536
+ this.#hoverTimer = undefined;
537
+ if (!this.open && this.isConnected) this.#show("none");
538
+ }, HOVER_DELAY);
539
+ }
540
+ const item = target.closest?.<HTMLElement>(ITEM);
541
+ if (!item || !this.#owns(item)) return;
542
+ const child = this.#openChild();
543
+ if (child?.trigger === item) {
544
+ this.#exit = { x: event.clientX, y: event.clientY };
545
+ } else if (child) {
546
+ // On the way to the open submenu, the items it crosses do not close it.
547
+ if (this.#exit && this.#towards(child, event)) {
548
+ this.#intentTimer = setTimeout(() => this.#highlight(item, child), INTENT_TIMEOUT);
549
+ return;
550
+ }
551
+ this.#exit = undefined;
552
+ }
553
+ this.#highlight(item, child);
554
+ }
555
+
556
+ #highlight(item: HTMLElement, child: MenuElement | undefined): void {
557
+ if (child && child.trigger !== item) child.#hide(false);
558
+ if (document.activeElement !== item) item.focus({ preventScroll: true });
559
+ }
560
+
561
+ /** Whether the pointer is in the triangle between its exit from the trigger and the submenu. */
562
+ #towards(child: MenuElement, event: PointerEvent): boolean {
563
+ const box = child.popup!.getBoundingClientRect();
564
+ const exit = this.#exit!;
565
+ const x = exit.x <= box.left ? box.left : box.right;
566
+ return inside(
567
+ { x: event.clientX, y: event.clientY },
568
+ exit,
569
+ { x, y: box.top },
570
+ { x, y: box.bottom },
571
+ );
572
+ }
573
+
574
+ #onPointerOut(event: PointerEvent): void {
575
+ if (event.pointerType === "touch") return;
576
+ const target = event.target as Element;
577
+ const to = event.relatedTarget as Element | null;
578
+ const trigger = this.trigger;
579
+ if (this.#parent() && trigger?.contains(target) && !trigger.contains(to)) {
580
+ clearTimeout(this.#hoverTimer);
581
+ this.#hoverTimer = undefined;
582
+ }
583
+ const item = target.closest?.<HTMLElement>(ITEM);
584
+ if (!item || !this.#owns(item) || item.contains(to) || to?.closest(ITEM)) return;
585
+ // Leaving the items clears the highlight, except on the way into an open submenu.
586
+ if (this.#openChild()?.trigger === item) return;
587
+ if (document.activeElement === item) this.popup?.focus({ preventScroll: true });
588
+ }
589
+ }
590
+
591
+ if (!customElements.get("lu-menu")) customElements.define("lu-menu", MenuElement);