@lumiose/ui 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/README.md +53 -2
- package/dist/accordion/accordion.css +20 -0
- package/dist/accordion/accordion.script.ts +66 -0
- package/dist/accordion/accordion.tsx +288 -0
- package/dist/animation/animation.ts +34 -0
- package/dist/autocomplete/autocomplete.tsx +239 -0
- package/dist/avatar/avatar.css +54 -0
- package/dist/avatar/avatar.script.ts +96 -0
- package/dist/avatar/avatar.tsx +110 -0
- package/dist/button/button.script.ts +35 -0
- package/dist/button/button.tsx +40 -0
- package/dist/checkbox/checkbox.script.ts +225 -0
- package/dist/checkbox/checkbox.tsx +156 -0
- package/dist/checkbox/context.ts +17 -0
- package/dist/checkbox-group/checkbox-group.tsx +111 -0
- package/dist/collapsible/collapsible.css +12 -0
- package/dist/collapsible/collapsible.script.ts +7 -0
- package/dist/collapsible/collapsible.tsx +153 -0
- package/dist/collapsible/panel.ts +115 -0
- package/dist/combobox/combobox.css +37 -0
- package/dist/combobox/combobox.script.ts +974 -0
- package/dist/combobox/combobox.tsx +556 -0
- package/dist/combobox/context.ts +46 -0
- package/dist/context-menu/context-menu.css +16 -0
- package/dist/context-menu/context-menu.script.ts +172 -0
- package/dist/context-menu/context-menu.tsx +178 -0
- package/dist/dialog/dialog.css +35 -0
- package/dist/dialog/dialog.script.ts +269 -0
- package/dist/dialog/dialog.tsx +214 -0
- package/dist/drawer/drawer.css +38 -0
- package/dist/drawer/drawer.script.ts +207 -0
- package/dist/drawer/drawer.tsx +113 -0
- package/dist/field/context.ts +156 -0
- package/dist/field/field.css +16 -0
- package/dist/field/field.script.ts +422 -0
- package/dist/field/field.tsx +465 -0
- package/dist/fieldset/fieldset.tsx +41 -0
- package/dist/form/form.tsx +50 -0
- package/dist/form/native.ts +21 -0
- package/dist/form/reset.ts +53 -0
- package/dist/index.ts +261 -0
- package/dist/input/input.tsx +67 -0
- package/dist/layer/layer.ts +191 -0
- package/dist/locale/digits.ts +21 -0
- package/dist/menu/menu.css +22 -0
- package/dist/menu/menu.script.ts +591 -0
- package/dist/menu/menu.tsx +520 -0
- package/dist/menubar/menubar.css +16 -0
- package/dist/menubar/menubar.script.ts +195 -0
- package/dist/menubar/menubar.tsx +67 -0
- package/dist/meter/meter.tsx +134 -0
- package/dist/navigation-menu/navigation-menu.css +14 -0
- package/dist/navigation-menu/navigation-menu.script.ts +466 -0
- package/dist/navigation-menu/navigation-menu.tsx +266 -0
- package/dist/number-field/format.ts +70 -0
- package/dist/number-field/number-field.css +38 -0
- package/dist/number-field/number-field.script.ts +490 -0
- package/dist/number-field/number-field.tsx +403 -0
- package/dist/otp-field/otp-field.css +45 -0
- package/dist/otp-field/otp-field.script.ts +330 -0
- package/dist/otp-field/otp-field.tsx +320 -0
- package/dist/placement/place.ts +148 -0
- package/dist/placement/placement.css +155 -0
- package/dist/placement/placement.ts +60 -0
- package/dist/popover/hover.ts +178 -0
- package/dist/popover/popover.css +20 -0
- package/dist/popover/popover.script.ts +256 -0
- package/dist/popover/popover.tsx +317 -0
- package/dist/preview-card/preview-card.css +7 -0
- package/dist/preview-card/preview-card.script.ts +136 -0
- package/dist/preview-card/preview-card.tsx +128 -0
- package/dist/progress/progress.tsx +155 -0
- package/dist/progress/value.ts +23 -0
- package/dist/radio-group/radio-group.tsx +156 -0
- package/dist/scroll-area/scroll-area.css +72 -0
- package/dist/scroll-area/scroll-area.script.ts +281 -0
- package/dist/scroll-area/scroll-area.tsx +118 -0
- package/dist/select/select.css +25 -0
- package/dist/select/select.tsx +171 -0
- package/dist/separator/separator.css +12 -0
- package/dist/separator/separator.tsx +38 -0
- package/dist/slider/format.ts +33 -0
- package/dist/slider/slider.css +93 -0
- package/dist/slider/slider.script.ts +355 -0
- package/dist/slider/slider.tsx +374 -0
- package/dist/styles.css +30 -0
- package/dist/switch/switch.tsx +92 -0
- package/dist/tabs/tabs.css +54 -0
- package/dist/tabs/tabs.script.ts +225 -0
- package/dist/tabs/tabs.tsx +272 -0
- package/dist/themes/default.css +2111 -0
- package/dist/toast/command.ts +3 -0
- package/dist/toast/toast.css +68 -0
- package/dist/toast/toast.script.ts +516 -0
- package/dist/toast/toast.tsx +432 -0
- package/dist/toggle/context.ts +16 -0
- package/dist/toggle/toggle.tsx +99 -0
- package/dist/toggle-group/toggle-group.script.ts +166 -0
- package/dist/toggle-group/toggle-group.tsx +117 -0
- package/dist/toolbar/context.ts +18 -0
- package/dist/toolbar/toolbar.css +12 -0
- package/dist/toolbar/toolbar.script.ts +123 -0
- package/dist/toolbar/toolbar.tsx +180 -0
- package/dist/tooltip/tooltip.css +8 -0
- package/dist/tooltip/tooltip.script.ts +222 -0
- package/dist/tooltip/tooltip.tsx +149 -0
- package/docs/README.md +174 -0
- package/docs/accordion.md +208 -0
- package/docs/autocomplete.md +161 -0
- package/docs/avatar.md +198 -0
- package/docs/button.md +98 -0
- package/docs/checkbox-group.md +112 -0
- package/docs/checkbox.md +133 -0
- package/docs/collapsible.md +165 -0
- package/docs/combobox.md +369 -0
- package/docs/context-menu.md +197 -0
- package/docs/dialog.md +256 -0
- package/docs/drawer.md +204 -0
- package/docs/field.md +295 -0
- package/docs/fieldset.md +92 -0
- package/docs/form.md +131 -0
- package/docs/input.md +87 -0
- package/docs/installation.md +65 -0
- package/docs/llms-full.txt +6923 -0
- package/docs/llms.txt +53 -0
- package/docs/menu.md +365 -0
- package/docs/menubar.md +186 -0
- package/docs/meter.md +103 -0
- package/docs/navigation-menu.md +257 -0
- package/docs/number-field.md +181 -0
- package/docs/otp-field.md +160 -0
- package/docs/popover.md +271 -0
- package/docs/preview-card.md +130 -0
- package/docs/progress.md +115 -0
- package/docs/radio-group.md +127 -0
- package/docs/scroll-area.md +147 -0
- package/docs/select.md +236 -0
- package/docs/separator.md +116 -0
- package/docs/slider.md +184 -0
- package/docs/switch.md +98 -0
- package/docs/tabs.md +254 -0
- package/docs/theming.md +132 -0
- package/docs/toast.md +311 -0
- package/docs/toggle-group.md +119 -0
- package/docs/toggle.md +94 -0
- package/docs/toolbar.md +138 -0
- package/docs/tooltip.md +128 -0
- package/package.json +102 -3
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
// Navigation Menu's client script (decision-62, decision-63). Each item's trigger already opens its
|
|
2
|
+
// content through `popovertarget`, a `popover="auto"`: one at a time, closed by Escape and a click
|
|
3
|
+
// outside, in server HTML and without JavaScript. `<lu-navigation-menu>` adds Base UI's behaviour
|
|
4
|
+
// and the WAI-ARIA disclosure navigation pattern's optional keys (doc-9, sections 1 and 3):
|
|
5
|
+
//
|
|
6
|
+
// - Hover intent (mouse and pen): resting on a trigger for `data-delay` (50ms) opens its content,
|
|
7
|
+
// at once while another is open; leaving the item closes it `data-close-delay` (50ms) later,
|
|
8
|
+
// 300ms when the pointer leaves toward the content. A click keeps a content opened by hover open
|
|
9
|
+
// and makes it stay when the pointer leaves; another click closes it. On its way from a trigger
|
|
10
|
+
// to its open content, the pointer may cross another trigger: inside the triangle between where
|
|
11
|
+
// it left and the content's near edge, that trigger waits 300ms before it takes over, so a path
|
|
12
|
+
// that reaches the content keeps it (TASK-451 L25).
|
|
13
|
+
// - Dismissal: where the element runs, a content is a manual popover that the layer model Menu and
|
|
14
|
+
// Popover share closes (../layer/layer.ts): a main-button press outside (not a right-click, and
|
|
15
|
+
// not a press in a toast), an Escape nothing else took, another layer opening outside it
|
|
16
|
+
// (TASK-451 L24). Server HTML keeps `popover="auto"`, which needs no script.
|
|
17
|
+
// - Keys: Arrow Right and Left (Down and Up when vertical, swapped right to left) move between the
|
|
18
|
+
// top-level triggers and links, with Home and End; Arrow Down (Right when vertical) on a trigger
|
|
19
|
+
// opens its content on its first link; in a content, Arrow Down and Up move between its links;
|
|
20
|
+
// Escape closes it and returns to its trigger. Every trigger and link stays a tab stop.
|
|
21
|
+
// - Focus leaving an item for elsewhere on the page closes its content. "Inside" is the item's DOM,
|
|
22
|
+
// a Navigation Menu nested in its content included (TASK-433); a `closeOnClick` link closes
|
|
23
|
+
// every level it is in.
|
|
24
|
+
// - `aria-expanded` on the triggers; `data-activation-direction` (`left`, `right`, `up`, `down`) on a
|
|
25
|
+
// content opened while another was, toward where its trigger is; `value` and `valuechange`.
|
|
26
|
+
// - Placement: where CSS anchor positioning is missing, the shared ../placement/place.ts places an
|
|
27
|
+
// open content next to its trigger, and again on scroll and resize.
|
|
28
|
+
// It must not import `lumiose`: server-rendered pages load it without the runtime.
|
|
29
|
+
|
|
30
|
+
import { openLayer } from "../layer/layer.ts";
|
|
31
|
+
import { anchoring, place } from "../placement/place.ts";
|
|
32
|
+
|
|
33
|
+
/** How long (ms) the content stays while the pointer travels from its trigger toward it. */
|
|
34
|
+
const TRAVEL_DELAY = 300;
|
|
35
|
+
const FOCUSABLE = "a[href], button:not(:disabled), input, select, textarea, [tabindex]";
|
|
36
|
+
|
|
37
|
+
export interface NavigationMenuValueChangeDetail {
|
|
38
|
+
value: string | null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export class NavigationMenuElement extends HTMLElement {
|
|
42
|
+
#openTimer: ReturnType<typeof setTimeout> | undefined;
|
|
43
|
+
#closeTimer: ReturnType<typeof setTimeout> | undefined;
|
|
44
|
+
/** The item whose content the pointer opened and no click has kept open yet. */
|
|
45
|
+
#hovered: HTMLElement | undefined;
|
|
46
|
+
/** The value last reported in `valuechange`. */
|
|
47
|
+
#reported: string | null = null;
|
|
48
|
+
/** The item whose content is the open layer, and the release of that layer. */
|
|
49
|
+
#layer: { item: HTMLElement; release: () => void } | undefined;
|
|
50
|
+
/** The pointer's last position over a trigger. */
|
|
51
|
+
#last: Point | undefined;
|
|
52
|
+
/** Where (its last position over it) and when the pointer left the open item's trigger. */
|
|
53
|
+
#exit: { x: number; y: number; at: number } | undefined;
|
|
54
|
+
|
|
55
|
+
/** The `value` of the item whose content is open, or `null`. Setting it opens or closes them. */
|
|
56
|
+
get value(): string | null {
|
|
57
|
+
return this.#openItem()?.dataset.value ?? null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
set value(value: string | null | undefined) {
|
|
61
|
+
if (value === undefined || value === this.value) return;
|
|
62
|
+
const item = value === null ? undefined : this.#items().find((i) => i.dataset.value === value);
|
|
63
|
+
if (item) this.#open(item, false);
|
|
64
|
+
else this.#close();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
connectedCallback(): void {
|
|
68
|
+
for (const type of ["pointerover", "pointerout", "pointermove", "keydown", "focusout"]) {
|
|
69
|
+
this.addEventListener(type, this);
|
|
70
|
+
}
|
|
71
|
+
// Before the trigger's `popovertarget` acts, and before Lumiose's delegated handlers.
|
|
72
|
+
this.addEventListener("click", this, true);
|
|
73
|
+
// `toggle` does not bubble: the capture phase sees each content's.
|
|
74
|
+
this.addEventListener("toggle", this, true);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
disconnectedCallback(): void {
|
|
78
|
+
for (const type of ["pointerover", "pointerout", "pointermove", "keydown", "focusout"]) {
|
|
79
|
+
this.removeEventListener(type, this);
|
|
80
|
+
}
|
|
81
|
+
this.removeEventListener("click", this, true);
|
|
82
|
+
this.removeEventListener("toggle", this, true);
|
|
83
|
+
clearTimeout(this.#openTimer);
|
|
84
|
+
clearTimeout(this.#closeTimer);
|
|
85
|
+
this.#listen(false);
|
|
86
|
+
this.#release();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
handleEvent(event: Event): void {
|
|
90
|
+
switch (event.type) {
|
|
91
|
+
case "scroll":
|
|
92
|
+
case "resize":
|
|
93
|
+
return this.#place();
|
|
94
|
+
case "pointerover":
|
|
95
|
+
return this.#onPointerOver(event as PointerEvent);
|
|
96
|
+
case "pointerout":
|
|
97
|
+
return this.#onPointerOut(event as PointerEvent);
|
|
98
|
+
case "pointermove": {
|
|
99
|
+
const item = this.#itemOf(event.target as Node);
|
|
100
|
+
if (item && this.#trigger(item)?.contains(event.target as Node)) {
|
|
101
|
+
this.#last = { x: (event as PointerEvent).clientX, y: (event as PointerEvent).clientY };
|
|
102
|
+
}
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
case "click":
|
|
106
|
+
return this.#onClick(event as MouseEvent);
|
|
107
|
+
case "keydown":
|
|
108
|
+
return this.#onKeyDown(event as KeyboardEvent);
|
|
109
|
+
case "focusout":
|
|
110
|
+
return this.#onFocusOut(event as FocusEvent);
|
|
111
|
+
default:
|
|
112
|
+
return this.#onToggle(event as ToggleEvent);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** This menu's items (not those of a menu nested in a content). */
|
|
117
|
+
#items(): HTMLElement[] {
|
|
118
|
+
return [...this.querySelectorAll<HTMLElement>("li[data-value]")].filter(
|
|
119
|
+
(item) => item.closest("lu-navigation-menu") === this,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
#itemOf(node: Node | null): HTMLElement | undefined {
|
|
124
|
+
const item = node instanceof Element ? node.closest<HTMLElement>("li[data-value]") : null;
|
|
125
|
+
return item && item.closest("lu-navigation-menu") === this ? item : undefined;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** This menu's item around `node`, a node in a menu nested in its content included. */
|
|
129
|
+
#itemAround(node: Node | null): HTMLElement | undefined {
|
|
130
|
+
return node ? this.#items().find((item) => item.contains(node)) : undefined;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
#trigger(item: HTMLElement): HTMLElement | null {
|
|
134
|
+
const trigger = item.querySelector<HTMLElement>("[popovertarget]");
|
|
135
|
+
return trigger && this.#itemOf(trigger) === item ? trigger : null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
#content(item: HTMLElement): HTMLElement | null {
|
|
139
|
+
const id = this.#trigger(item)?.getAttribute("popovertarget");
|
|
140
|
+
return id ? document.getElementById(id) : null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
#openItem(): HTMLElement | undefined {
|
|
144
|
+
return this.#items().find((item) => this.#content(item)?.matches(":popover-open"));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Opens `item`'s content, closing the open one; `hover` when the pointer opened it. */
|
|
148
|
+
#open(item: HTMLElement, hover: boolean): void {
|
|
149
|
+
clearTimeout(this.#openTimer);
|
|
150
|
+
clearTimeout(this.#closeTimer);
|
|
151
|
+
const trigger = this.#trigger(item);
|
|
152
|
+
const content = this.#content(item);
|
|
153
|
+
if (!trigger || !content || trigger.matches(":disabled")) return;
|
|
154
|
+
this.#hovered = hover ? item : undefined;
|
|
155
|
+
if (content.matches(":popover-open")) return;
|
|
156
|
+
const previous = this.#openItem();
|
|
157
|
+
if (previous) {
|
|
158
|
+
content.dataset.activationDirection = direction(this.#trigger(previous)!, trigger);
|
|
159
|
+
} else {
|
|
160
|
+
delete content.dataset.activationDirection;
|
|
161
|
+
}
|
|
162
|
+
// Manual: the layer model dismisses it, not native light dismiss, which also closes on a
|
|
163
|
+
// right-click; and a manual popover closes no other, so the open one closes here.
|
|
164
|
+
content.setAttribute("popover", "manual");
|
|
165
|
+
if (previous) this.#hide(previous);
|
|
166
|
+
(content.showPopover as (options?: { source?: HTMLElement }) => void)({ source: trigger });
|
|
167
|
+
this.#layer = {
|
|
168
|
+
item,
|
|
169
|
+
release: openLayer({
|
|
170
|
+
popup: content,
|
|
171
|
+
contains: (node) => item.contains(node),
|
|
172
|
+
dismiss: (press) => {
|
|
173
|
+
const focused = content.contains(document.activeElement);
|
|
174
|
+
if (this.#hovered === item) this.#hovered = undefined;
|
|
175
|
+
this.#hide(item);
|
|
176
|
+
// A close request from inside returns focus to the trigger; a press keeps it there.
|
|
177
|
+
if (!press && focused) trigger.focus({ preventScroll: true });
|
|
178
|
+
},
|
|
179
|
+
}),
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Stops dismissing the open content (it closed). */
|
|
184
|
+
#release(item?: HTMLElement): void {
|
|
185
|
+
if (!this.#layer || (item && this.#layer.item !== item)) return;
|
|
186
|
+
const { release } = this.#layer;
|
|
187
|
+
this.#layer = undefined;
|
|
188
|
+
release();
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Closes `item`'s content. */
|
|
192
|
+
#hide(item: HTMLElement): void {
|
|
193
|
+
this.#release(item);
|
|
194
|
+
const content = this.#content(item);
|
|
195
|
+
if (content?.matches(":popover-open")) content.hidePopover();
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
#close(): void {
|
|
199
|
+
clearTimeout(this.#openTimer);
|
|
200
|
+
clearTimeout(this.#closeTimer);
|
|
201
|
+
this.#hovered = undefined;
|
|
202
|
+
const item = this.#openItem();
|
|
203
|
+
if (item) this.#hide(item);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
#onToggle(event: ToggleEvent): void {
|
|
207
|
+
const item = this.#items().find((each) => this.#content(each) === event.target);
|
|
208
|
+
if (!item) return;
|
|
209
|
+
this.#trigger(item)?.setAttribute("aria-expanded", String(event.newState === "open"));
|
|
210
|
+
this.#place();
|
|
211
|
+
this.#listen(this.#openItem() !== undefined);
|
|
212
|
+
if (event.newState === "closed") {
|
|
213
|
+
if (this.#hovered === item) this.#hovered = undefined;
|
|
214
|
+
// Whatever closed it (a parent closing the content it is in, say).
|
|
215
|
+
this.#release(item);
|
|
216
|
+
}
|
|
217
|
+
// Switching items closes one and opens another: report the open one once.
|
|
218
|
+
const value = this.value;
|
|
219
|
+
if (value === this.#reported) return;
|
|
220
|
+
this.#reported = value;
|
|
221
|
+
this.dispatchEvent(
|
|
222
|
+
new CustomEvent<NavigationMenuValueChangeDetail>("valuechange", { detail: { value } }),
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Where CSS anchor positioning is missing, scrolling and resizing move the open content too. */
|
|
227
|
+
#listen(on: boolean): void {
|
|
228
|
+
if (anchoring) return;
|
|
229
|
+
for (const type of ["scroll", "resize"]) {
|
|
230
|
+
window[on ? "addEventListener" : "removeEventListener"](type, this, true);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Places the open content next to its trigger where CSS anchor positioning is missing. */
|
|
235
|
+
#place(): void {
|
|
236
|
+
const item = this.#openItem();
|
|
237
|
+
if (!anchoring && item) place(this.#content(item)!, this.#trigger(item)!);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
#delay(name: "delay" | "closeDelay"): number {
|
|
241
|
+
const value = Number(this.dataset[name]);
|
|
242
|
+
return Number.isFinite(value) && this.dataset[name] !== undefined ? value : 50;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
#onPointerOver(event: PointerEvent): void {
|
|
246
|
+
if (event.pointerType === "touch") return;
|
|
247
|
+
const item = this.#itemOf(event.target as Node);
|
|
248
|
+
if (!item) return;
|
|
249
|
+
const open = this.#openItem();
|
|
250
|
+
if (open === item) {
|
|
251
|
+
// Back in the open item, its content reached: a trigger crossed on the way does not open.
|
|
252
|
+
clearTimeout(this.#closeTimer);
|
|
253
|
+
clearTimeout(this.#openTimer);
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
const trigger = this.#trigger(item);
|
|
257
|
+
if (!trigger?.contains(event.target as Node)) return;
|
|
258
|
+
clearTimeout(this.#openTimer);
|
|
259
|
+
if (open && this.#travelling(open, event)) {
|
|
260
|
+
this.#openTimer = setTimeout(() => this.#open(item, true), TRAVEL_DELAY);
|
|
261
|
+
} else if (open) this.#open(item, true);
|
|
262
|
+
else this.#openTimer = setTimeout(() => this.#open(item, true), this.#delay("delay"));
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Whether the pointer, over another trigger, is on its way to `open`'s content (L25). */
|
|
266
|
+
#travelling(open: HTMLElement, event: PointerEvent): boolean {
|
|
267
|
+
const exit = this.#exit;
|
|
268
|
+
if (!exit || event.timeStamp - exit.at > TRAVEL_DELAY) return false;
|
|
269
|
+
return this.#safe(open, exit, event);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Whether `event` is in the triangle between `from` and `item`'s content's near edge. */
|
|
273
|
+
#safe(item: HTMLElement, from: Point, event: PointerEvent): boolean {
|
|
274
|
+
const content = this.#content(item);
|
|
275
|
+
if (!content?.matches(":popover-open")) return false;
|
|
276
|
+
const [a, b] = nearEdge(this.#trigger(item)!, content);
|
|
277
|
+
return inside({ x: event.clientX, y: event.clientY }, from, a, b);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
#onPointerOut(event: PointerEvent): void {
|
|
281
|
+
if (event.pointerType === "touch") return;
|
|
282
|
+
const item = this.#itemAround(event.target as Node);
|
|
283
|
+
if (!item) return;
|
|
284
|
+
const to = event.relatedTarget as Node | null;
|
|
285
|
+
const trigger = this.#trigger(item);
|
|
286
|
+
if (trigger?.contains(event.target as Node) && !trigger.contains(to)) {
|
|
287
|
+
clearTimeout(this.#openTimer);
|
|
288
|
+
if (this.#openItem() === item) {
|
|
289
|
+
const from = this.#last ?? { x: event.clientX, y: event.clientY };
|
|
290
|
+
this.#exit = { ...from, at: event.timeStamp };
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
// Still inside the item (its trigger, its content, a menu nested in it), or a content kept
|
|
294
|
+
// open by a click.
|
|
295
|
+
if ((to && item.contains(to)) || this.#hovered !== item) return;
|
|
296
|
+
const content = this.#content(item)!;
|
|
297
|
+
const leaving = trigger?.contains(event.target as Node) === true;
|
|
298
|
+
const toward =
|
|
299
|
+
leaving &&
|
|
300
|
+
(heading(trigger!, content, event) ||
|
|
301
|
+
(this.#exit !== undefined && this.#safe(item, this.#exit, event)));
|
|
302
|
+
clearTimeout(this.#closeTimer);
|
|
303
|
+
this.#closeTimer = setTimeout(
|
|
304
|
+
() => {
|
|
305
|
+
// Not `#close()`: a trigger crossed on the way may still be about to open its content.
|
|
306
|
+
if (this.#hovered !== item) return;
|
|
307
|
+
this.#hovered = undefined;
|
|
308
|
+
this.#hide(item);
|
|
309
|
+
},
|
|
310
|
+
toward ? TRAVEL_DELAY : this.#delay("closeDelay"),
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
#onClick(event: MouseEvent): void {
|
|
315
|
+
const target = event.target as Element;
|
|
316
|
+
// A `closeOnClick` link closes the content it is in, at every level of nested menus.
|
|
317
|
+
const link = target.closest<HTMLElement>("a[data-close-on-click=true]");
|
|
318
|
+
if (link && this.contains(link) && this.#openItem()?.contains(link)) {
|
|
319
|
+
this.#close();
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
const item = this.#itemOf(target);
|
|
323
|
+
if (!item) return;
|
|
324
|
+
const trigger = this.#trigger(item);
|
|
325
|
+
if (trigger?.contains(target)) {
|
|
326
|
+
// The element toggles the content itself: a click keeps a content opened by hover open.
|
|
327
|
+
event.preventDefault();
|
|
328
|
+
if (this.#hovered === item) this.#hovered = undefined;
|
|
329
|
+
else if (this.#openItem() === item) this.#close();
|
|
330
|
+
else this.#open(item, false);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
#onKeyDown(event: KeyboardEvent): void {
|
|
335
|
+
if (event.isComposing || event.defaultPrevented || event.altKey || event.ctrlKey) return;
|
|
336
|
+
if (event.metaKey) return;
|
|
337
|
+
const target = event.target as HTMLElement;
|
|
338
|
+
const item = this.#itemOf(target);
|
|
339
|
+
if (!item) return;
|
|
340
|
+
const vertical = this.dataset.orientation === "vertical";
|
|
341
|
+
const rtl = this.matches(":dir(rtl)");
|
|
342
|
+
const [next, previous] = vertical
|
|
343
|
+
? ["ArrowDown", "ArrowUp"]
|
|
344
|
+
: rtl
|
|
345
|
+
? ["ArrowLeft", "ArrowRight"]
|
|
346
|
+
: ["ArrowRight", "ArrowLeft"];
|
|
347
|
+
const opens = vertical ? (rtl ? "ArrowLeft" : "ArrowRight") : "ArrowDown";
|
|
348
|
+
const content = this.#content(item);
|
|
349
|
+
const inContent = content?.contains(target) ?? false;
|
|
350
|
+
|
|
351
|
+
if (event.key === "Escape" && this.#openItem() === item) {
|
|
352
|
+
event.preventDefault();
|
|
353
|
+
this.#close();
|
|
354
|
+
this.#trigger(item)?.focus();
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
if (inContent) {
|
|
358
|
+
// Down and Up between the content's links (not those of a menu nested in it).
|
|
359
|
+
const links = [...content!.querySelectorAll<HTMLElement>(FOCUSABLE)].filter(
|
|
360
|
+
(link) => link.closest("[popover]") === content && link.getClientRects().length > 0,
|
|
361
|
+
);
|
|
362
|
+
const index = links.indexOf(target);
|
|
363
|
+
const to =
|
|
364
|
+
event.key === "ArrowDown"
|
|
365
|
+
? links[(index + 1) % links.length]
|
|
366
|
+
: event.key === "ArrowUp"
|
|
367
|
+
? links.at(index - 1)
|
|
368
|
+
: undefined;
|
|
369
|
+
if (index < 0 || !to) return;
|
|
370
|
+
event.preventDefault();
|
|
371
|
+
to.focus();
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
// On a top-level trigger or link.
|
|
375
|
+
const tops = this.#items()
|
|
376
|
+
.map((each) => {
|
|
377
|
+
const own = [...each.querySelectorAll<HTMLElement>(FOCUSABLE)];
|
|
378
|
+
return own.find((el) => !this.#content(each)?.contains(el) && this.#itemOf(el) === each);
|
|
379
|
+
})
|
|
380
|
+
.filter((el): el is HTMLElement => el !== undefined && !el.matches(":disabled"));
|
|
381
|
+
const index = tops.indexOf(target);
|
|
382
|
+
if (index < 0) return;
|
|
383
|
+
let to: HTMLElement | undefined;
|
|
384
|
+
if (event.key === next) to = tops[(index + 1) % tops.length];
|
|
385
|
+
else if (event.key === previous) to = tops.at(index - 1);
|
|
386
|
+
else if (event.key === "Home") to = tops[0];
|
|
387
|
+
else if (event.key === "End") to = tops.at(-1);
|
|
388
|
+
else if (event.key === opens && this.#trigger(item) === target && content) {
|
|
389
|
+
event.preventDefault();
|
|
390
|
+
this.#open(item, false);
|
|
391
|
+
content.querySelector<HTMLElement>(FOCUSABLE)?.focus();
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
if (!to) return;
|
|
395
|
+
event.preventDefault();
|
|
396
|
+
to.focus();
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
#onFocusOut(event: FocusEvent): void {
|
|
400
|
+
const item = this.#itemAround(event.target as Node);
|
|
401
|
+
const to = event.relatedTarget as Node | null;
|
|
402
|
+
// Focus leaving the open item for another element of the page closes its content.
|
|
403
|
+
if (!item || to === null || this.#openItem() !== item || item.contains(to)) return;
|
|
404
|
+
this.#close();
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** Where `to` is from `from`: the direction a content opened from another one comes in from. */
|
|
409
|
+
function direction(from: HTMLElement, to: HTMLElement): string {
|
|
410
|
+
const a = from.getBoundingClientRect();
|
|
411
|
+
const b = to.getBoundingClientRect();
|
|
412
|
+
if (Math.abs(b.left - a.left) >= Math.abs(b.top - a.top))
|
|
413
|
+
return b.left > a.left ? "right" : "left";
|
|
414
|
+
return b.top > a.top ? "down" : "up";
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
interface Point {
|
|
418
|
+
x: number;
|
|
419
|
+
y: number;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** The two ends of `content`'s edge that faces `trigger`. */
|
|
423
|
+
function nearEdge(trigger: HTMLElement, content: HTMLElement): [Point, Point] {
|
|
424
|
+
const t = trigger.getBoundingClientRect();
|
|
425
|
+
const c = content.getBoundingClientRect();
|
|
426
|
+
if (c.top >= t.bottom - 1)
|
|
427
|
+
return [
|
|
428
|
+
{ x: c.left, y: c.top },
|
|
429
|
+
{ x: c.right, y: c.top },
|
|
430
|
+
];
|
|
431
|
+
if (c.bottom <= t.top + 1)
|
|
432
|
+
return [
|
|
433
|
+
{ x: c.left, y: c.bottom },
|
|
434
|
+
{ x: c.right, y: c.bottom },
|
|
435
|
+
];
|
|
436
|
+
if (c.left >= t.right - 1)
|
|
437
|
+
return [
|
|
438
|
+
{ x: c.left, y: c.top },
|
|
439
|
+
{ x: c.left, y: c.bottom },
|
|
440
|
+
];
|
|
441
|
+
return [
|
|
442
|
+
{ x: c.right, y: c.top },
|
|
443
|
+
{ x: c.right, y: c.bottom },
|
|
444
|
+
];
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/** Whether `p` is inside the triangle `a`, `b`, `c`. */
|
|
448
|
+
function inside(p: Point, a: Point, b: Point, c: Point): boolean {
|
|
449
|
+
const side = (u: Point, v: Point) => (p.x - v.x) * (u.y - v.y) - (u.x - v.x) * (p.y - v.y);
|
|
450
|
+
const [d1, d2, d3] = [side(a, b), side(b, c), side(c, a)];
|
|
451
|
+
return !((d1 < 0 || d2 < 0 || d3 < 0) && (d1 > 0 || d2 > 0 || d3 > 0));
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/** Whether the pointer left `trigger` through the edge that faces `content`. */
|
|
455
|
+
function heading(trigger: HTMLElement, content: HTMLElement, event: PointerEvent): boolean {
|
|
456
|
+
const t = trigger.getBoundingClientRect();
|
|
457
|
+
const c = content.getBoundingClientRect();
|
|
458
|
+
if (c.top >= t.bottom - 1) return event.clientY >= t.bottom - 1;
|
|
459
|
+
if (c.bottom <= t.top + 1) return event.clientY <= t.top + 1;
|
|
460
|
+
if (c.left >= t.right - 1) return event.clientX >= t.right - 1;
|
|
461
|
+
return event.clientX <= t.left + 1;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
if (!customElements.get("lu-navigation-menu")) {
|
|
465
|
+
customElements.define("lu-navigation-menu", NavigationMenuElement);
|
|
466
|
+
}
|