@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,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 };
@@ -0,0 +1,7 @@
1
+ /*
2
+ * Preview Card needs no CSS to open or close: the native popover does it. What it needs is its
3
+ * placement next to the link, shared with Popover and Tooltip (placement.css).
4
+ */
5
+ @layer lu.core, lu.theme;
6
+
7
+ @import "../placement/placement.css";