@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,256 @@
|
|
|
1
|
+
// Popover's client script (decision-62's "native gaps are closed by small scripts", shipped through
|
|
2
|
+
// the client-script import of decision-63). Popover works without it; on a page that renders one,
|
|
3
|
+
// this module runs once and closes what a native `popover="auto"` misses next to Base UI and Zag
|
|
4
|
+
// (doc-9), for every Popover popup on the page (`[popover][data-side][role=dialog]`):
|
|
5
|
+
//
|
|
6
|
+
// - Focus on open: the first focusable element, or the popup itself when there is none or when it
|
|
7
|
+
// was opened by touch (no virtual keyboard). The browser only focuses an `autofocus` element.
|
|
8
|
+
// - Focus return: when closing leaves focus nowhere (a click outside on the page, WebKit not
|
|
9
|
+
// focusing a clicked trigger, a trigger that is gone), focus goes to the opener, else a trigger,
|
|
10
|
+
// without scrolling. A click outside on another control keeps focus there.
|
|
11
|
+
// - Dismissal: the dismissal model Popover shares with Menu and Combobox (layer/layer.ts). A
|
|
12
|
+
// trigger's click makes the popup a manual popover before `popovertarget` opens it, so a
|
|
13
|
+
// right-click or a second finger outside does not close it (native light dismiss would, and
|
|
14
|
+
// cannot be cancelled); the layer model closes it on a main-button press outside, a close
|
|
15
|
+
// request, or another layer opening outside it. Server HTML keeps `popover="auto"`, so the
|
|
16
|
+
// browser light-dismisses it until the script runs.
|
|
17
|
+
// - `aria-expanded` on the triggers, following the popover.
|
|
18
|
+
// - Placement where CSS anchor positioning is missing (placement/place.ts).
|
|
19
|
+
// - Open on hover (TASK-394): a trigger with `data-open-on-hover` opens its popover after
|
|
20
|
+
// `data-delay` (300 ms) of hover by a mouse or a pen, never by touch or focus, without moving
|
|
21
|
+
// focus. It stays open while the pointer is on the trigger or the popup (WCAG 1.4.13: hoverable)
|
|
22
|
+
// and closes `data-close-delay` (0 ms, at least a 100 ms grace to cross the gap) after it left
|
|
23
|
+
// both. A click on the trigger keeps it open until it is dismissed (an outside press, Escape).
|
|
24
|
+
// - A modal popover is a modal `<dialog data-side>` opened by `command="show-modal"`: placement,
|
|
25
|
+
// focus and `aria-expanded` as above; Dialog's script and the native dialog do the rest.
|
|
26
|
+
// - `data-trap-focus` (`modal="trap-focus"`, TASK-410): Tab and Shift+Tab go around the open
|
|
27
|
+
// popup's tab stops (not a roving item, nor an unchecked radio of a group with a checked one),
|
|
28
|
+
// and the popup stays non-modal otherwise (the page scrolls and takes presses). Focus on open
|
|
29
|
+
// goes to the first tab stop too (TASK-436 M4).
|
|
30
|
+
// - Focus leaving a non-modal popover for elsewhere on the page (Tab past it) closes it, so it
|
|
31
|
+
// covers no focused control (TASK-436 M2). Focus going nowhere is left to the outside press, and
|
|
32
|
+
// focus into a toast leaves it open, as a press there does.
|
|
33
|
+
// - A popover a hover opened gives focus back on close only if it held focus (TASK-436 M3).
|
|
34
|
+
//
|
|
35
|
+
// It must not import `lumiose`: server-rendered pages load it without the runtime.
|
|
36
|
+
import { openLayer } from "../layer/layer.ts";
|
|
37
|
+
import { hoverIntent } from "./hover.ts";
|
|
38
|
+
import { anchoring, place } from "../placement/place.ts";
|
|
39
|
+
|
|
40
|
+
/** The last press, until a key is pressed: what an opening came from. */
|
|
41
|
+
let press: PointerEvent | undefined;
|
|
42
|
+
const openers = new WeakMap<HTMLElement, HTMLElement>();
|
|
43
|
+
/** The popovers that held focus as they started to close. */
|
|
44
|
+
const holding = new WeakSet<HTMLElement>();
|
|
45
|
+
/** The open popovers that the fallback placement follows on scroll and resize. */
|
|
46
|
+
const placed = new Set<HTMLElement>();
|
|
47
|
+
|
|
48
|
+
const FOCUSABLE =
|
|
49
|
+
"a[href], button:not([disabled]), input:not([disabled]):not([type=hidden]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex='-1']), [contenteditable]";
|
|
50
|
+
|
|
51
|
+
function on<K extends keyof DocumentEventMap>(
|
|
52
|
+
type: K,
|
|
53
|
+
listener: (event: DocumentEventMap[K]) => void,
|
|
54
|
+
) {
|
|
55
|
+
document.addEventListener(type, listener, true);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The popup's tab stops, in order: focusable and shown, not taken out of the order (`tabindex="-1"`,
|
|
60
|
+
* a roving toolbar's or toggle group's other items), and one radio per group (the checked one, or
|
|
61
|
+
* the first enabled one).
|
|
62
|
+
*/
|
|
63
|
+
function tabStops(popup: HTMLElement): HTMLElement[] {
|
|
64
|
+
return [...popup.querySelectorAll<HTMLElement>(FOCUSABLE)].filter((element) => {
|
|
65
|
+
if (element.tabIndex < 0 || !element.checkVisibility()) return false;
|
|
66
|
+
if (!(element instanceof HTMLInputElement) || element.type !== "radio" || !element.name) {
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
const group = [
|
|
70
|
+
...popup.querySelectorAll<HTMLInputElement>(
|
|
71
|
+
`input[type=radio][name="${CSS.escape(element.name)}"]`,
|
|
72
|
+
),
|
|
73
|
+
].filter((radio) => !radio.disabled);
|
|
74
|
+
return element === (group.find((radio) => radio.checked) ?? group[0]);
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const isPopover = (node: unknown): node is HTMLElement =>
|
|
79
|
+
node instanceof HTMLElement &&
|
|
80
|
+
node.matches("[popover][data-side][role=dialog], dialog[data-side]");
|
|
81
|
+
|
|
82
|
+
/** The buttons that open or toggle the popover. */
|
|
83
|
+
const triggers = (popup: HTMLElement) => {
|
|
84
|
+
const id = CSS.escape(popup.id);
|
|
85
|
+
return document.querySelectorAll<HTMLElement>(
|
|
86
|
+
`[popovertarget="${id}"]:not([popovertargetaction=hide]), [commandfor="${id}"][command=show-modal]`,
|
|
87
|
+
);
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
function anchor(popup: HTMLElement): HTMLElement | undefined {
|
|
91
|
+
const opener = openers.get(popup);
|
|
92
|
+
return opener?.isConnected ? opener : (triggers(popup)[0] ?? undefined);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function follow() {
|
|
96
|
+
for (const popup of placed) {
|
|
97
|
+
const trigger = anchor(popup);
|
|
98
|
+
if (trigger) place(popup, trigger);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
on("pointerdown", (event) => (press = event));
|
|
103
|
+
on("keydown", () => (press = undefined));
|
|
104
|
+
|
|
105
|
+
on("keydown", (event) => {
|
|
106
|
+
if (event.key !== "Tab" || event.defaultPrevented || event.altKey || event.ctrlKey) return;
|
|
107
|
+
const popup = (event.target as Element).closest?.<HTMLElement>("[data-trap-focus]");
|
|
108
|
+
if (!isPopover(popup) || !popup.matches(":popover-open")) return;
|
|
109
|
+
const stops = tabStops(popup);
|
|
110
|
+
const first = stops[0];
|
|
111
|
+
const last = stops.at(-1);
|
|
112
|
+
const at = event.target as HTMLElement;
|
|
113
|
+
const wrap = !first
|
|
114
|
+
? popup
|
|
115
|
+
: event.shiftKey
|
|
116
|
+
? at === first || at === popup
|
|
117
|
+
? last
|
|
118
|
+
: undefined
|
|
119
|
+
: at === last
|
|
120
|
+
? first
|
|
121
|
+
: undefined;
|
|
122
|
+
if (!wrap) return;
|
|
123
|
+
event.preventDefault();
|
|
124
|
+
wrap.focus();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
/** Stops dismissing each open popover (layer/layer.ts). */
|
|
128
|
+
const layers = new Map<HTMLElement, () => void>();
|
|
129
|
+
|
|
130
|
+
// Before `popovertarget` acts: a closed popup becomes a manual popover, which the layer model
|
|
131
|
+
// dismisses. A key press on the trigger clicks it too. A click on the trigger of a popover its
|
|
132
|
+
// hover opened keeps it open instead of closing it.
|
|
133
|
+
on("click", (event) => {
|
|
134
|
+
const trigger = (event.target as Element).closest?.<HTMLElement>("[popovertarget]");
|
|
135
|
+
const popup = trigger && document.getElementById(trigger.getAttribute("popovertarget")!);
|
|
136
|
+
if (!isPopover(popup) || trigger!.matches("[popovertargetaction=hide]")) return;
|
|
137
|
+
if (!popup.matches(":popover-open")) popup.popover = "manual";
|
|
138
|
+
else if (hovered.has(popup)) {
|
|
139
|
+
event.preventDefault();
|
|
140
|
+
hover.forget(popup);
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// Focus leaving an open non-modal popover (and those around it) for elsewhere on the page closes
|
|
145
|
+
// it; a trap-focus popover keeps focus in itself.
|
|
146
|
+
on("focusout", (event) => {
|
|
147
|
+
const from = event.target as Node;
|
|
148
|
+
const to = event.relatedTarget as Node | null;
|
|
149
|
+
// Focus going nowhere is the outside press's to judge; into a toast, it leaves layers alone.
|
|
150
|
+
if (!to || (to instanceof Element && to.closest("lu-toast-viewport"))) return;
|
|
151
|
+
const open = document.querySelectorAll<HTMLElement>(
|
|
152
|
+
"[popover][data-side][role=dialog]:popover-open:not([data-trap-focus])",
|
|
153
|
+
);
|
|
154
|
+
for (const popup of [...open].toReversed()) {
|
|
155
|
+
if (!popup.contains(from) || popup.contains(to)) continue;
|
|
156
|
+
if ([...triggers(popup)].some((trigger) => trigger.contains(to))) continue;
|
|
157
|
+
popup.hidePopover();
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// Open on hover (hover.ts) -------------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
const hover = hoverIntent({
|
|
164
|
+
triggerAt: (node) =>
|
|
165
|
+
node.closest?.<HTMLElement>("button[data-open-on-hover][popovertarget]") ?? undefined,
|
|
166
|
+
popupOf: (trigger) => {
|
|
167
|
+
const popup = document.getElementById(trigger.getAttribute("popovertarget")!);
|
|
168
|
+
return isPopover(popup) ? popup : undefined;
|
|
169
|
+
},
|
|
170
|
+
show: (trigger, popup) => {
|
|
171
|
+
// A hover-opened popover leaves focus where it is (see `toggle`).
|
|
172
|
+
popup.popover = "manual";
|
|
173
|
+
(popup.showPopover as (options?: { source?: HTMLElement }) => void)({ source: trigger });
|
|
174
|
+
},
|
|
175
|
+
hide: (popup) => popup.hidePopover(),
|
|
176
|
+
delay: 300,
|
|
177
|
+
closeDelay: 0,
|
|
178
|
+
});
|
|
179
|
+
const hovered = hover.hovered;
|
|
180
|
+
|
|
181
|
+
/** Stops dismissing `popup`: as it closes, so the next close request goes to the layer below. */
|
|
182
|
+
function release(popup: HTMLElement) {
|
|
183
|
+
layers.get(popup)?.();
|
|
184
|
+
layers.delete(popup);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
on("beforetoggle", (event) => {
|
|
188
|
+
const popup = event.target;
|
|
189
|
+
if (!isPopover(popup)) return;
|
|
190
|
+
release(popup);
|
|
191
|
+
if (event.newState === "closed") {
|
|
192
|
+
if (popup.contains(document.activeElement)) holding.add(popup);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
// Now, not on `toggle` a task later: a press right after the opening already counts.
|
|
196
|
+
if (popup.popover === "manual") {
|
|
197
|
+
layers.set(
|
|
198
|
+
popup,
|
|
199
|
+
openLayer({
|
|
200
|
+
popup,
|
|
201
|
+
contains: (node) =>
|
|
202
|
+
popup.contains(node) || [...triggers(popup)].some((trigger) => trigger.contains(node)),
|
|
203
|
+
dismiss: () => popup.hidePopover(),
|
|
204
|
+
}),
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
const active = document.activeElement;
|
|
208
|
+
const pressed = press && (press.target as Element).closest?.<HTMLElement>("[popovertarget]");
|
|
209
|
+
const opener = active instanceof HTMLElement && active !== document.body ? active : pressed;
|
|
210
|
+
if (opener) openers.set(popup, opener);
|
|
211
|
+
// Placed in the frame it first paints in, once it can be measured (`toggle` may come later).
|
|
212
|
+
if (!anchoring) {
|
|
213
|
+
requestAnimationFrame(() => {
|
|
214
|
+
if (!popup.matches(":popover-open")) return;
|
|
215
|
+
placed.add(popup);
|
|
216
|
+
follow();
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
on("toggle", (event) => {
|
|
222
|
+
const popup = event.target;
|
|
223
|
+
if (!isPopover(popup)) return;
|
|
224
|
+
const open = event.newState === "open";
|
|
225
|
+
for (const trigger of triggers(popup)) trigger.setAttribute("aria-expanded", String(open));
|
|
226
|
+
|
|
227
|
+
if (open) {
|
|
228
|
+
// A hover opens it without taking focus from where it is.
|
|
229
|
+
if (hovered.has(popup) || popup.contains(document.activeElement)) return;
|
|
230
|
+
const touch = press?.pointerType === "touch" && event.timeStamp - press.timeStamp < 1000;
|
|
231
|
+
const first = touch ? undefined : tabStops(popup)[0];
|
|
232
|
+
if (first) first.focus();
|
|
233
|
+
else {
|
|
234
|
+
if (!popup.hasAttribute("tabindex")) popup.tabIndex = -1;
|
|
235
|
+
popup.focus({ preventScroll: true });
|
|
236
|
+
}
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
placed.delete(popup);
|
|
241
|
+
const wasHovered = hovered.has(popup);
|
|
242
|
+
const held = holding.has(popup);
|
|
243
|
+
holding.delete(popup);
|
|
244
|
+
hover.forget(popup);
|
|
245
|
+
// A hover opened it and focus was elsewhere: closing it moves no focus.
|
|
246
|
+
if (wasHovered && !held) return;
|
|
247
|
+
const active = document.activeElement;
|
|
248
|
+
if (active && active !== document.body && !popup.contains(active)) return;
|
|
249
|
+
anchor(popup)?.focus({ preventScroll: true });
|
|
250
|
+
openers.delete(popup);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
if (!anchoring) {
|
|
254
|
+
addEventListener("scroll", follow, { capture: true, passive: true });
|
|
255
|
+
addEventListener("resize", follow, { passive: true });
|
|
256
|
+
}
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
// Popover (decision-62, TASK-356): a native `popover="auto"` opened and closed by its trigger's
|
|
2
|
+
// `popovertarget`, so it works in server HTML with no JavaScript, placed next to the trigger with
|
|
3
|
+
// CSS anchor positioning (placement.css). The popup is a non-modal dialog (`role="dialog"`), as in
|
|
4
|
+
// Base UI and Zag. The popup element is the only state: the trigger, a close button, Escape, a
|
|
5
|
+
// click outside and the `open` prop all end in its `toggle` event, the single source of
|
|
6
|
+
// `onOpenChange`. Ported from Timeless's approach (https://timeless.build).
|
|
7
|
+
//
|
|
8
|
+
// TASK-394: a trigger may open the popover on hover (`openOnHover`, after `delay`, closing
|
|
9
|
+
// `closeDelay` after the pointer leaves both), which popover.script.ts runs. A `modal` popover is a
|
|
10
|
+
// modal `<dialog>` instead, opened by the trigger's `command="show-modal"`: the page behind it is
|
|
11
|
+
// inert and does not scroll, focus stays inside, and a press outside or Escape closes it.
|
|
12
|
+
import {
|
|
13
|
+
createContext,
|
|
14
|
+
effect,
|
|
15
|
+
isServer,
|
|
16
|
+
onCleanup,
|
|
17
|
+
ref,
|
|
18
|
+
signal,
|
|
19
|
+
untrack,
|
|
20
|
+
useContext,
|
|
21
|
+
useId,
|
|
22
|
+
type Signal,
|
|
23
|
+
} from "lumiose";
|
|
24
|
+
import dialogScript from "../dialog/dialog.script.ts?client";
|
|
25
|
+
import { anchorName, placement, withStyle, type PlacementProps } from "../placement/placement.ts";
|
|
26
|
+
import popoverScript from "./popover.script.ts?client";
|
|
27
|
+
import { OUTSIDE_TOOLBAR, ToolbarContext } from "../toolbar/context.ts";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* `Popover.Root` renders no element: it holds the ids its parts share. `bind:open={signal}`
|
|
31
|
+
* stands in for `open` and `onOpenChange`.
|
|
32
|
+
*/
|
|
33
|
+
export interface PopoverRootProps {
|
|
34
|
+
/**
|
|
35
|
+
* Opens (`true`) or closes (`false`) the popover when it changes, in the browser. Pair it with
|
|
36
|
+
* `onOpenChange` to keep a signal in sync; leave both out and the popover keeps its own state.
|
|
37
|
+
* Server HTML always starts closed: a popover only opens from script or its trigger.
|
|
38
|
+
*/
|
|
39
|
+
open?: boolean;
|
|
40
|
+
/** Called after the popover opens or closes, whatever opened or closed it. */
|
|
41
|
+
onOpenChange?: (open: boolean) => void;
|
|
42
|
+
/**
|
|
43
|
+
* A modal popover: the rest of the page is inert and does not scroll, focus stays in the popup,
|
|
44
|
+
* and a press outside or Escape closes it. The popup is then a modal `<dialog>`. With
|
|
45
|
+
* `"trap-focus"`, only Tab and Shift+Tab stay in the popup: the page still scrolls and takes
|
|
46
|
+
* presses (one outside closes it).
|
|
47
|
+
*/
|
|
48
|
+
modal?: boolean | "trap-focus";
|
|
49
|
+
/** The other parts: a `Popover.Trigger` and the `Popover.Popup`, anywhere inside. */
|
|
50
|
+
children?: JSX.Element;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* A `<button>` that opens and closes the popover (`popovertarget`, or `command="show-modal"` when
|
|
55
|
+
* modal), and its anchor.
|
|
56
|
+
*/
|
|
57
|
+
export type PopoverTriggerProps = JSX.IntrinsicElements["button"] & {
|
|
58
|
+
/**
|
|
59
|
+
* Hovering the trigger with a mouse or a pen opens the popover too, and it closes once the
|
|
60
|
+
* pointer has left the trigger and the popup. A click keeps it open; touch and the keyboard
|
|
61
|
+
* open it with a click, as without. Not for a modal popover.
|
|
62
|
+
*/
|
|
63
|
+
openOnHover?: boolean;
|
|
64
|
+
/** With `openOnHover`: how long the pointer rests on the trigger before it opens, in ms. Default `300`. */
|
|
65
|
+
delay?: number;
|
|
66
|
+
/** With `openOnHover`: how long it stays open after the pointer leaves, in ms. Default `0`. */
|
|
67
|
+
closeDelay?: number;
|
|
68
|
+
};
|
|
69
|
+
/** The popup: a `<div popover role="dialog">` (a modal `<dialog>`), placed next to the trigger. */
|
|
70
|
+
export type PopoverPopupProps = JSX.IntrinsicElements["div"] & PlacementProps;
|
|
71
|
+
/** The `<h2>` that names the popover. */
|
|
72
|
+
export type PopoverTitleProps = JSX.IntrinsicElements["h2"];
|
|
73
|
+
/** The `<p>` that describes the popover. */
|
|
74
|
+
export type PopoverDescriptionProps = JSX.IntrinsicElements["p"];
|
|
75
|
+
/** A `<button>` that closes the popover (`popovertargetaction="hide"`, or `command="close"`). */
|
|
76
|
+
export type PopoverCloseProps = JSX.IntrinsicElements["button"];
|
|
77
|
+
|
|
78
|
+
interface PopoverContextValue {
|
|
79
|
+
id: string;
|
|
80
|
+
modal: boolean;
|
|
81
|
+
trapFocus: boolean;
|
|
82
|
+
trigger: JSX.Ref<HTMLButtonElement>;
|
|
83
|
+
popup: JSX.Ref<HTMLElement>;
|
|
84
|
+
onToggle: (open: boolean) => void;
|
|
85
|
+
popupId: string;
|
|
86
|
+
titleId: string;
|
|
87
|
+
descriptionId: string;
|
|
88
|
+
/** How many Title and Description parts are rendered: the popup references only those. */
|
|
89
|
+
titles: Signal<number>;
|
|
90
|
+
descriptions: Signal<number>;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const PopoverContext = createContext<PopoverContextValue>();
|
|
94
|
+
|
|
95
|
+
function usePopover(): PopoverContextValue {
|
|
96
|
+
const context = useContext(PopoverContext);
|
|
97
|
+
if (!context) throw new Error("Popover parts must be inside <Popover.Root>.");
|
|
98
|
+
return context;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function Root(props: PopoverRootProps) {
|
|
102
|
+
// Focus on open and its return, `aria-expanded`, and placement without anchor positioning; the
|
|
103
|
+
// page loads it once (decision-63).
|
|
104
|
+
popoverScript();
|
|
105
|
+
const modal = untrack(() => props.modal === true);
|
|
106
|
+
// A modal popover is a modal <dialog>: Dialog's script closes its gaps (invoker commands where
|
|
107
|
+
// missing, light dismiss, focus return, scroll lock).
|
|
108
|
+
if (modal) dialogScript();
|
|
109
|
+
const id = useId();
|
|
110
|
+
const trigger = ref<HTMLButtonElement>();
|
|
111
|
+
const popup = ref<HTMLElement>();
|
|
112
|
+
const context: PopoverContextValue = {
|
|
113
|
+
id,
|
|
114
|
+
modal,
|
|
115
|
+
trapFocus: untrack(() => props.modal === "trap-focus"),
|
|
116
|
+
trigger,
|
|
117
|
+
popup,
|
|
118
|
+
onToggle: (next) => props.onOpenChange?.(next),
|
|
119
|
+
popupId: `${id}-popup`,
|
|
120
|
+
titleId: `${id}-title`,
|
|
121
|
+
descriptionId: `${id}-description`,
|
|
122
|
+
titles: signal(0),
|
|
123
|
+
descriptions: signal(0),
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
// The `open` prop drives the native state; the popup's `toggle` event reports back, so a signal
|
|
127
|
+
// written by `onOpenChange` sees the same value again and nothing loops.
|
|
128
|
+
effect(() => {
|
|
129
|
+
const element = popup();
|
|
130
|
+
const wanted = props.open;
|
|
131
|
+
if (!element?.isConnected || wanted === undefined) return;
|
|
132
|
+
if (element instanceof HTMLDialogElement) {
|
|
133
|
+
if (wanted && !element.open) element.showModal();
|
|
134
|
+
else if (!wanted && element.open) element.close();
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
const shown = element.matches(":popover-open");
|
|
138
|
+
// A manual popover, as the client script makes it on a trigger's click: the script's shared
|
|
139
|
+
// dismissal closes it. `source` makes the trigger the popover's invoker, as a click on it
|
|
140
|
+
// does: focus order and nesting follow it.
|
|
141
|
+
if (wanted && !shown) {
|
|
142
|
+
element.popover = "manual";
|
|
143
|
+
(element.showPopover as (options?: { source?: HTMLElement }) => void)({
|
|
144
|
+
source: trigger(),
|
|
145
|
+
});
|
|
146
|
+
} else if (!wanted && shown) element.hidePopover();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
return <PopoverContext value={context}>{props.children}</PopoverContext>;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Each part spreads the caller's props first, so they override the defaults (`type`, the label
|
|
153
|
+
// references), and sets the wiring last, so `id` and `popovertarget` stay consistent.
|
|
154
|
+
|
|
155
|
+
function Trigger({
|
|
156
|
+
children,
|
|
157
|
+
ref: forwarded,
|
|
158
|
+
openOnHover,
|
|
159
|
+
delay,
|
|
160
|
+
closeDelay,
|
|
161
|
+
...rest
|
|
162
|
+
}: PopoverTriggerProps) {
|
|
163
|
+
const popover = usePopover();
|
|
164
|
+
// A disabled Toolbar disables it: focusable, it opens nothing (TASK-437 M13).
|
|
165
|
+
const toolbar = useContext(ToolbarContext);
|
|
166
|
+
const off = untrack(() => toolbar?.disabled() === true);
|
|
167
|
+
// A modal popover opens with an invoker command; the others with `popovertarget`.
|
|
168
|
+
const opens = off
|
|
169
|
+
? { "aria-disabled": "true" as const }
|
|
170
|
+
: popover.modal
|
|
171
|
+
? { command: "show-modal", commandfor: popover.popupId, "aria-expanded": "false" as const }
|
|
172
|
+
: { popovertarget: popover.popupId };
|
|
173
|
+
// A function, so hover opening and its delays follow the props: the script reads them from
|
|
174
|
+
// the trigger at each hover (TASK-477).
|
|
175
|
+
const hover = () =>
|
|
176
|
+
openOnHover && !popover.modal && !off
|
|
177
|
+
? { "data-open-on-hover": "", "data-delay": delay, "data-close-delay": closeDelay }
|
|
178
|
+
: {};
|
|
179
|
+
return (
|
|
180
|
+
<button
|
|
181
|
+
type="button"
|
|
182
|
+
aria-haspopup="dialog"
|
|
183
|
+
{...rest}
|
|
184
|
+
{...hover()}
|
|
185
|
+
ref={(element: HTMLButtonElement) => {
|
|
186
|
+
popover.trigger(element);
|
|
187
|
+
forwarded?.(element);
|
|
188
|
+
}}
|
|
189
|
+
style={withStyle(rest.style, { "anchor-name": anchorName(popover.id) })}
|
|
190
|
+
{...opens}
|
|
191
|
+
>
|
|
192
|
+
{children}
|
|
193
|
+
</button>
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function Popup({
|
|
198
|
+
children,
|
|
199
|
+
ref: forwarded,
|
|
200
|
+
side,
|
|
201
|
+
align,
|
|
202
|
+
sideOffset,
|
|
203
|
+
alignOffset,
|
|
204
|
+
...rest
|
|
205
|
+
}: PopoverPopupProps) {
|
|
206
|
+
const popover = usePopover();
|
|
207
|
+
// The server writes the attributes before it renders the children, so it renders them first to
|
|
208
|
+
// know which Title and Description exist (as Dialog.Popup does), once.
|
|
209
|
+
// Its content is not a surrounding toolbar's (TASK-413).
|
|
210
|
+
const inside = () => <ToolbarContext value={OUTSIDE_TOOLBAR}>{children}</ToolbarContext>;
|
|
211
|
+
const content = isServer ? untrack(inside) : undefined;
|
|
212
|
+
const attach = (element: HTMLElement) => {
|
|
213
|
+
popover.popup(element);
|
|
214
|
+
forwarded?.(element as HTMLDivElement);
|
|
215
|
+
};
|
|
216
|
+
const toggle = (event: ToggleEvent) => popover.onToggle(event.newState === "open");
|
|
217
|
+
// Modal: a <dialog> that a press outside closes (`closedby="any"`), as Base UI's does.
|
|
218
|
+
return popover.modal ? (
|
|
219
|
+
<dialog
|
|
220
|
+
aria-labelledby={popover.titles() > 0 ? popover.titleId : undefined}
|
|
221
|
+
aria-describedby={popover.descriptions() > 0 ? popover.descriptionId : undefined}
|
|
222
|
+
{...(rest as JSX.IntrinsicElements["dialog"])}
|
|
223
|
+
{...placement(
|
|
224
|
+
{ side, align, sideOffset, alignOffset, style: rest.style },
|
|
225
|
+
popover.id,
|
|
226
|
+
"bottom",
|
|
227
|
+
)}
|
|
228
|
+
ref={attach}
|
|
229
|
+
id={popover.popupId}
|
|
230
|
+
closedby="any"
|
|
231
|
+
on:toggle={toggle}
|
|
232
|
+
>
|
|
233
|
+
{isServer ? content : inside()}
|
|
234
|
+
</dialog>
|
|
235
|
+
) : (
|
|
236
|
+
<div
|
|
237
|
+
role="dialog"
|
|
238
|
+
aria-labelledby={popover.titles() > 0 ? popover.titleId : undefined}
|
|
239
|
+
aria-describedby={popover.descriptions() > 0 ? popover.descriptionId : undefined}
|
|
240
|
+
{...rest}
|
|
241
|
+
{...placement(
|
|
242
|
+
{ side, align, sideOffset, alignOffset, style: rest.style },
|
|
243
|
+
popover.id,
|
|
244
|
+
"bottom",
|
|
245
|
+
)}
|
|
246
|
+
ref={attach}
|
|
247
|
+
id={popover.popupId}
|
|
248
|
+
popover="auto"
|
|
249
|
+
data-trap-focus={popover.trapFocus ? "" : undefined}
|
|
250
|
+
on:toggle={toggle}
|
|
251
|
+
>
|
|
252
|
+
{isServer ? content : inside()}
|
|
253
|
+
</div>
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Counts a rendered part in `count` while it is mounted. */
|
|
258
|
+
function register(count: Signal<number>) {
|
|
259
|
+
count.update((n) => n + 1);
|
|
260
|
+
onCleanup(() => count.update((n) => n - 1));
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function Title({ children, ...rest }: PopoverTitleProps) {
|
|
264
|
+
const popover = usePopover();
|
|
265
|
+
// A caller's `id` names it: the Popup renders its parts before its own attributes on the server,
|
|
266
|
+
// and follows them in the browser (TASK-441 M39).
|
|
267
|
+
if (rest.id) popover.titleId = rest.id;
|
|
268
|
+
register(popover.titles);
|
|
269
|
+
return (
|
|
270
|
+
<h2 {...rest} id={popover.titleId}>
|
|
271
|
+
{children}
|
|
272
|
+
</h2>
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function Description({ children, ...rest }: PopoverDescriptionProps) {
|
|
277
|
+
const popover = usePopover();
|
|
278
|
+
// As Title's.
|
|
279
|
+
if (rest.id) popover.descriptionId = rest.id;
|
|
280
|
+
register(popover.descriptions);
|
|
281
|
+
return (
|
|
282
|
+
<p {...rest} id={popover.descriptionId}>
|
|
283
|
+
{children}
|
|
284
|
+
</p>
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function Close({ children, ...rest }: PopoverCloseProps) {
|
|
289
|
+
const popover = usePopover();
|
|
290
|
+
const closes = popover.modal
|
|
291
|
+
? { command: "close", commandfor: popover.popupId }
|
|
292
|
+
: { popovertarget: popover.popupId, popovertargetaction: "hide" as const };
|
|
293
|
+
return (
|
|
294
|
+
<button type="button" {...rest} {...closes}>
|
|
295
|
+
{children}
|
|
296
|
+
</button>
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* A popup anchored to its trigger: a non-modal dialog that a click outside, Escape or a close
|
|
302
|
+
* button dismisses; or opened on hover (`openOnHover`); or modal (`modal`). No JavaScript needed
|
|
303
|
+
* to open and close it: the native popover and `popovertarget` (a modal `<dialog>` and invoker
|
|
304
|
+
* commands) do the work, in server HTML too. Unstyled: style the parts through `class` and native
|
|
305
|
+
* state (`:popover-open`, `[open]`, `[aria-expanded]`, `[data-side]`).
|
|
306
|
+
*
|
|
307
|
+
* @example
|
|
308
|
+
* <Popover.Root>
|
|
309
|
+
* <Popover.Trigger>Notifications</Popover.Trigger>
|
|
310
|
+
* <Popover.Popup side="bottom" align="start" sideOffset={8}>
|
|
311
|
+
* <Popover.Title>Notifications</Popover.Title>
|
|
312
|
+
* <Popover.Description>You are all caught up.</Popover.Description>
|
|
313
|
+
* <Popover.Close>Close</Popover.Close>
|
|
314
|
+
* </Popover.Popup>
|
|
315
|
+
* </Popover.Root>
|
|
316
|
+
*/
|
|
317
|
+
export const Popover = { Root, Trigger, Popup, Title, Description, Close };
|