@passui/react 0.1.45 → 0.1.47

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 (141) hide show
  1. package/README.md +57 -46
  2. package/dist/types/src/components/accordion/accordion.d.ts +1 -1
  3. package/dist/types/src/components/breadcrumb/breadcrumb.d.ts +1 -1
  4. package/dist/types/src/components/inline-disclosure/inline-disclosure.d.ts +1 -1
  5. package/dist/types/src/components/popover/anchored-surface.d.ts +5 -1
  6. package/dist/types/src/components/popover/index.d.ts +41 -0
  7. package/dist/types/src/components/popover/popover-content.d.ts +38 -0
  8. package/dist/types/src/components/popover/popover-root.d.ts +22 -0
  9. package/dist/types/src/components/popover/popover-trigger.d.ts +23 -0
  10. package/dist/types/src/components/stepper/rolling-digit.d.ts +1 -1
  11. package/dist/types/src/components/table/table.d.ts +1 -1
  12. package/dist/types/src/components/view-controller/view-controller.d.ts +2 -2
  13. package/dist/types/src/index.d.ts +1 -1
  14. package/dist/types/src/machines/popover.machine.d.ts +30 -0
  15. package/package.json +307 -307
  16. package/src/appearance/index.ts +2 -2
  17. package/src/appearance/platform.tsx +76 -60
  18. package/src/appearance/theme.tsx +409 -377
  19. package/src/components/accordion/accordion.tsx +58 -49
  20. package/src/components/ambient/ambient.tsx +25 -25
  21. package/src/components/ambient/index.ts +1 -1
  22. package/src/components/aspect-ratio/aspect-ratio.tsx +21 -16
  23. package/src/components/breadcrumb/breadcrumb.tsx +63 -57
  24. package/src/components/button/button-styles.ts +39 -32
  25. package/src/components/button/button.tsx +91 -84
  26. package/src/components/button/icon-button.tsx +27 -25
  27. package/src/components/button/pending-spinner.tsx +8 -8
  28. package/src/components/button/use-pending-indicator.ts +11 -11
  29. package/src/components/chip/chip.tsx +35 -30
  30. package/src/components/code-block/code-block.tsx +40 -39
  31. package/src/components/combo-box/combo-box.tsx +5 -5
  32. package/src/components/command/command.tsx +124 -94
  33. package/src/components/compact-date-picker/calendar-month.ts +75 -53
  34. package/src/components/compact-date-picker/compact-date-picker.tsx +308 -289
  35. package/src/components/compact-time-picker/compact-time-picker.tsx +288 -281
  36. package/src/components/compact-time-picker/time-value.ts +32 -29
  37. package/src/components/content-unavailable-view/content-unavailable-view.tsx +64 -48
  38. package/src/components/content-view/content-view.tsx +168 -159
  39. package/src/components/content-view/index.ts +1 -1
  40. package/src/components/context-menu/conceal-menu-source.ts +25 -25
  41. package/src/components/context-menu/context-menu-host.tsx +439 -402
  42. package/src/components/context-menu/context-menu-store.ts +125 -123
  43. package/src/components/context-menu/menu-navigation.ts +25 -18
  44. package/src/components/context-menu/menu-position.ts +33 -29
  45. package/src/components/context-menu/menu-reveal.ts +32 -32
  46. package/src/components/context-menu/use-context-menu.ts +63 -56
  47. package/src/components/conversation/conversation.tsx +265 -263
  48. package/src/components/conversation/index.ts +1 -1
  49. package/src/components/date-picker/date-picker.tsx +95 -94
  50. package/src/components/date-picker/date-value.ts +20 -10
  51. package/src/components/dialog/dialog-host.tsx +105 -103
  52. package/src/components/dialog/dialog-store.ts +37 -36
  53. package/src/components/dialog/dialog.tsx +192 -179
  54. package/src/components/dialog/index.ts +1 -1
  55. package/src/components/disclosure-group/disclosure-group.tsx +93 -86
  56. package/src/components/divider/divider.tsx +24 -22
  57. package/src/components/inline-disclosure/inline-disclosure.tsx +40 -36
  58. package/src/components/keyboard-shortcut/keyboard-shortcut.tsx +23 -19
  59. package/src/components/labeled-content/labeled-content.tsx +68 -68
  60. package/src/components/locale-switcher/index.ts +1 -1
  61. package/src/components/locale-switcher/locale-switcher.tsx +278 -251
  62. package/src/components/menu/menu.tsx +75 -58
  63. package/src/components/morph/index.ts +2 -2
  64. package/src/components/morph/morph.tsx +227 -201
  65. package/src/components/morph/use-morph-disclosure.ts +19 -19
  66. package/src/components/one-time-code/one-time-code.tsx +216 -200
  67. package/src/components/picker/picker-trigger.ts +16 -13
  68. package/src/components/picker/picker.tsx +371 -345
  69. package/src/components/popover/anchored-surface.tsx +253 -229
  70. package/src/components/popover/index.ts +50 -0
  71. package/src/components/popover/popover-content.tsx +374 -0
  72. package/src/components/popover/popover-root.tsx +97 -0
  73. package/src/components/popover/popover-trigger.tsx +71 -0
  74. package/src/components/profile-image/profile-image.tsx +80 -64
  75. package/src/components/progress-view/progress-view.tsx +61 -59
  76. package/src/components/progress-view/use-liquid-progress.ts +38 -38
  77. package/src/components/radio-group/radio-group.tsx +71 -68
  78. package/src/components/search-field/expanding-search-field.tsx +191 -177
  79. package/src/components/search-field/search-field.tsx +59 -50
  80. package/src/components/search-list/search-list.tsx +246 -236
  81. package/src/components/section/section.tsx +58 -58
  82. package/src/components/secure-field/secure-field.tsx +3 -3
  83. package/src/components/segmented-control/segmented-control.tsx +136 -136
  84. package/src/components/sheet/modal-environment.ts +71 -64
  85. package/src/components/sheet/sheet-portal.tsx +58 -53
  86. package/src/components/sheet/sheet.tsx +146 -141
  87. package/src/components/sheet/use-sheet-focus.ts +97 -82
  88. package/src/components/sidebar/sidebar.tsx +78 -78
  89. package/src/components/skeleton/skeleton.tsx +30 -21
  90. package/src/components/slider/slider.tsx +113 -104
  91. package/src/components/slosh-slider/liquid-step.ts +32 -31
  92. package/src/components/slosh-slider/slosh-slider.tsx +265 -228
  93. package/src/components/stat-tile/index.ts +1 -1
  94. package/src/components/stat-tile/stat-tile.tsx +79 -68
  95. package/src/components/status-indicator/status-indicator.tsx +40 -37
  96. package/src/components/stepper/rolling-digit.tsx +82 -68
  97. package/src/components/stepper/stepper.tsx +122 -113
  98. package/src/components/swatch-picker/swatch-picker.tsx +120 -120
  99. package/src/components/tab-bar/tab-bar.tsx +149 -148
  100. package/src/components/tab-view/tab-view.tsx +140 -137
  101. package/src/components/table/table.tsx +117 -98
  102. package/src/components/text-editor/text-editor.tsx +34 -29
  103. package/src/components/text-field/text-field.tsx +182 -180
  104. package/src/components/toast/toast-store.ts +127 -115
  105. package/src/components/toast/toaster.tsx +361 -347
  106. package/src/components/toggle/toggle.tsx +297 -282
  107. package/src/components/tooltip/tooltip-host.tsx +157 -150
  108. package/src/components/view-controller/index.ts +1 -1
  109. package/src/components/view-controller/view-controller.tsx +64 -53
  110. package/src/components/wake-slider/wake-slider.tsx +220 -205
  111. package/src/hooks/scroll/minimize-decision.ts +11 -7
  112. package/src/hooks/use-anchored-disclosure.ts +19 -19
  113. package/src/hooks/use-anchored-placement.ts +29 -25
  114. package/src/hooks/use-dismiss.ts +33 -29
  115. package/src/hooks/use-floating-dismiss.ts +24 -22
  116. package/src/hooks/use-media-query.ts +67 -60
  117. package/src/hooks/use-minimize-on-scroll.ts +69 -60
  118. package/src/hooks/use-visual-viewport.ts +63 -63
  119. package/src/hooks/viewport/types.ts +16 -16
  120. package/src/hooks/viewport/viewport-metrics.ts +16 -10
  121. package/src/icons/icons.tsx +440 -328
  122. package/src/index.ts +91 -91
  123. package/src/lib/anchored-placement.ts +70 -65
  124. package/src/lib/class-names.ts +14 -13
  125. package/src/lib/clipboard.ts +8 -7
  126. package/src/lib/date.ts +61 -60
  127. package/src/lib/floating-layers.ts +39 -24
  128. package/src/lib/haptics.ts +10 -10
  129. package/src/lib/language.ts +9 -9
  130. package/src/lib/range-value.ts +27 -21
  131. package/src/lib/screen-edges.ts +33 -30
  132. package/src/lib/shapes.ts +38 -34
  133. package/src/machines/popover.machine.ts +51 -0
  134. package/src/motion/transitions.ts +46 -43
  135. package/src/styles/code-block.css +69 -43
  136. package/src/styles/motion-controls.css +393 -191
  137. package/src/tokens.ts +1 -1
  138. package/dist/types/src/components/popover/popover-fit.d.ts +0 -21
  139. package/dist/types/src/components/popover/popover.d.ts +0 -21
  140. package/src/components/popover/popover-fit.ts +0 -69
  141. package/src/components/popover/popover.tsx +0 -334
@@ -0,0 +1,374 @@
1
+ import { AnimatePresence, motion, useReducedMotion } from "motion/react";
2
+ import { type ReactNode, useLayoutEffect, useRef, useState } from "react";
3
+ import { concealMenuSource } from "../context-menu/conceal-menu-source";
4
+ import {
5
+ menuSurfaceExit,
6
+ menuSurfaceReveal,
7
+ type MenuRect,
8
+ } from "../context-menu/menu-reveal";
9
+ import { useSheetFocus } from "../sheet/use-sheet-focus";
10
+ import { SheetPortal } from "../sheet/sheet-portal";
11
+ import { cn } from "../../lib/class-names";
12
+ import { screenEdges } from "../../lib/screen-edges";
13
+ import { fluidCollapse, fluidOvershoot } from "../../motion/transitions";
14
+ import { usePopover } from "./popover-root";
15
+
16
+ export type PopoverOrigin = "top-left" | "top-right";
17
+
18
+ export interface PopoverContentProps {
19
+ children: ReactNode;
20
+ /** Extra classes for the panel (sizing, padding, max-height). */
21
+ className?: string;
22
+ /** Accessible name (used when there is no labelledBy). */
23
+ label?: string;
24
+ /** ID of a visible heading inside the content. */
25
+ labelledBy?: string;
26
+ /** Allow tapping outside to close. Escape always closes. */
27
+ dismissOnOutsidePress?: boolean;
28
+ /**
29
+ * Where the content rests once expanded:
30
+ * - "anchor" positions relative to the trigger (default)
31
+ * - "center" floats in the middle of the viewport (modal)
32
+ */
33
+ placement?: "anchor" | "center";
34
+ /**
35
+ * Which corner the popover grows from when placement="anchor".
36
+ * top-left: panel's left edge aligns to the trigger's left.
37
+ * top-right: panel's right edge aligns to the trigger's right.
38
+ * @default "top-left"
39
+ */
40
+ origin?: PopoverOrigin;
41
+ /**
42
+ * Gap between the trigger and the panel, in pixels.
43
+ * @default 8
44
+ */
45
+ gap?: number;
46
+ /**
47
+ * ARIA role for the panel. Use "menu" for action menus, "dialog"
48
+ * (the default) for free-form content, "tooltip" for tips.
49
+ * @default "dialog"
50
+ */
51
+ role?: "dialog" | "menu" | "tooltip";
52
+ }
53
+
54
+ interface Placement {
55
+ /** Fixed left in viewport px. */
56
+ left: number;
57
+ /** Fixed top in viewport px. */
58
+ top: number;
59
+ /** Width cap so the panel never crosses the right safe edge. */
60
+ maxWidth: number;
61
+ /** Height cap; when set, the panel scrolls inside it. */
62
+ maxHeight?: number;
63
+ /** True when the panel opens above the trigger. */
64
+ above: boolean;
65
+ }
66
+
67
+ const NO_PLACEMENT: Placement = {
68
+ left: 0,
69
+ top: 0,
70
+ maxWidth: 0,
71
+ above: false,
72
+ };
73
+
74
+ /**
75
+ * Place the panel below (or above) the trigger, aligned to the chosen
76
+ * corner, kept inside the screen's safe edges. Pure: no DOM, just math.
77
+ */
78
+ function placePanel(
79
+ trigger: DOMRect,
80
+ panel: { width: number; height: number },
81
+ origin: PopoverOrigin,
82
+ gap: number,
83
+ ): Placement {
84
+ const edges = screenEdges();
85
+ const vv = window.visualViewport;
86
+ const vw = (vv?.width ?? window.innerWidth) + (vv?.offsetLeft ?? 0);
87
+ const vh = (vv?.height ?? window.innerHeight) + (vv?.offsetTop ?? 0);
88
+ const safeLeft = edges.left + (vv?.offsetLeft ?? 0);
89
+ const safeRight = vw - edges.right;
90
+ const safeTop = edges.top + (vv?.offsetTop ?? 0);
91
+ const safeBottom = vh - edges.bottom;
92
+
93
+ // Horizontal: align the chosen edge to the trigger, then clamp into view.
94
+ const maxWidth = Math.max(0, safeRight - safeLeft);
95
+ const width = Math.min(panel.width, maxWidth);
96
+ const left =
97
+ origin === "top-right"
98
+ ? Math.max(safeLeft, Math.min(trigger.right - width, safeRight - width))
99
+ : Math.max(safeLeft, Math.min(trigger.left, safeRight - width));
100
+
101
+ // Vertical: prefer below; flip above when there isn't room below but
102
+ // there is above (matching the legacy popover's behaviour).
103
+ const roomBelow = safeBottom - trigger.bottom - gap;
104
+ const roomAbove = trigger.top - gap - safeTop;
105
+ const above = roomBelow < panel.height && roomAbove > roomBelow;
106
+ const room = above ? roomAbove : roomBelow;
107
+ const top = above
108
+ ? trigger.top - gap - Math.min(panel.height, room)
109
+ : trigger.bottom + gap;
110
+ const maxHeight = panel.height > room ? room : undefined;
111
+
112
+ return { left, top, maxWidth, maxHeight, above };
113
+ }
114
+
115
+ export function PopoverContent({
116
+ children,
117
+ className,
118
+ label,
119
+ labelledBy,
120
+ dismissOnOutsidePress = true,
121
+ placement = "anchor",
122
+ origin = "top-left",
123
+ gap = 8,
124
+ role = "dialog",
125
+ }: PopoverContentProps) {
126
+ const { state, send, triggerRef, contentId } = usePopover();
127
+ const panel = useRef<HTMLDivElement>(null);
128
+ const reducedMotion = useReducedMotion();
129
+ const [settled, setSettled] = useState(false);
130
+ const [material, setMaterial] = useState({ width: 0, height: 0, radius: 0 });
131
+ const [placementState, setPlacement] = useState<Placement>(NO_PLACEMENT);
132
+ const [reveal, setReveal] = useState<ReturnType<
133
+ typeof menuSurfaceReveal
134
+ > | null>(null);
135
+
136
+ useSheetFocus(panel, () => send("CLOSE"));
137
+
138
+ // Re-measure on every layout pass: trigger position, panel size, scroll,
139
+ // viewport resize. Recompute the fixed placement + the morph source rect.
140
+ useLayoutEffect(() => {
141
+ const surface = panel.current;
142
+ const source = triggerRef.current;
143
+ if (!surface) return;
144
+
145
+ const refit = () => {
146
+ const trigger = source?.getBoundingClientRect();
147
+ // Measure the authored size (padding/width from className) so the
148
+ // morph target is the natural panel the author styled.
149
+ const natural = surface.getBoundingClientRect();
150
+ const radius =
151
+ Number.parseFloat(getComputedStyle(surface).borderTopLeftRadius) || 0;
152
+ setMaterial((prev) =>
153
+ prev.width === natural.width &&
154
+ prev.height === natural.height &&
155
+ prev.radius === radius
156
+ ? prev
157
+ : { width: natural.width, height: natural.height, radius },
158
+ );
159
+
160
+ if (trigger && trigger.width > 0 && trigger.height > 0) {
161
+ const next = placePanel(
162
+ trigger,
163
+ { width: natural.width, height: natural.height },
164
+ origin,
165
+ gap,
166
+ );
167
+ setPlacement((current) =>
168
+ current.left === next.left &&
169
+ current.top === next.top &&
170
+ current.maxWidth === next.maxWidth &&
171
+ current.maxHeight === next.maxHeight &&
172
+ current.above === next.above
173
+ ? current
174
+ : next,
175
+ );
176
+ const sourceArg: MenuRect = {
177
+ left: trigger.left,
178
+ top: trigger.top,
179
+ width: trigger.width,
180
+ height: trigger.height,
181
+ };
182
+ setReveal(
183
+ menuSurfaceReveal(sourceArg, {
184
+ left: next.left,
185
+ top: next.top,
186
+ width: Math.min(natural.width, next.maxWidth),
187
+ height: next.maxHeight
188
+ ? Math.min(natural.height, next.maxHeight)
189
+ : natural.height,
190
+ radius,
191
+ }),
192
+ );
193
+ }
194
+ };
195
+
196
+ refit();
197
+ let frame = 0;
198
+ const schedule = () => {
199
+ cancelAnimationFrame(frame);
200
+ frame = requestAnimationFrame(refit);
201
+ };
202
+ const observer = new ResizeObserver(schedule);
203
+ observer.observe(surface);
204
+ if (source) observer.observe(source);
205
+ const viewport = window.visualViewport;
206
+ viewport?.addEventListener("resize", schedule);
207
+ viewport?.addEventListener("scroll", schedule);
208
+ window.addEventListener("resize", schedule);
209
+ window.addEventListener("scroll", schedule, true);
210
+ return () => {
211
+ observer.disconnect();
212
+ cancelAnimationFrame(frame);
213
+ viewport?.removeEventListener("resize", schedule);
214
+ viewport?.removeEventListener("scroll", schedule);
215
+ window.removeEventListener("resize", schedule);
216
+ window.removeEventListener("scroll", schedule, true);
217
+ };
218
+ }, [triggerRef, origin, gap]);
219
+
220
+ // Conceal the trigger while the surface is morphing out of it.
221
+ useLayoutEffect(() => {
222
+ const source = triggerRef.current;
223
+ if (!source) return;
224
+ const restore = concealMenuSource(source, false);
225
+ return () => {
226
+ restore();
227
+ };
228
+ }, [triggerRef]);
229
+
230
+ const anchored = placement === "anchor";
231
+ const scrollable = settled && placementState.maxHeight !== undefined;
232
+
233
+ return (
234
+ <AnimatePresence>
235
+ {state !== "closed" && (
236
+ <SheetPortal>
237
+ {placement === "center" && (
238
+ <motion.div
239
+ aria-hidden="true"
240
+ className="fixed inset-0 z-[75] bg-black/30 backdrop-blur-md"
241
+ initial={{ opacity: 0 }}
242
+ animate={{ opacity: 1 }}
243
+ exit={{ opacity: 0 }}
244
+ transition={reducedMotion ? { duration: 0 } : { duration: 0.18 }}
245
+ onClick={dismissOnOutsidePress ? () => send("CLOSE") : undefined}
246
+ />
247
+ )}
248
+ <motion.div
249
+ ref={panel}
250
+ id={contentId}
251
+ role={role}
252
+ aria-modal={placement === "center" || undefined}
253
+ aria-label={labelledBy ? undefined : label}
254
+ aria-labelledby={labelledBy}
255
+ tabIndex={-1}
256
+ data-state={state}
257
+ data-placement={
258
+ anchored ? (placementState.above ? "top" : "bottom") : "center"
259
+ }
260
+ className={cn(
261
+ "isolate flex flex-col outline-none",
262
+ anchored
263
+ ? "rounded-5xl absolute z-40 [&>:not([data-popover-surface])]:opacity-[var(--popover-content-opacity)]"
264
+ : "rounded-5xl fixed z-[76] max-h-[92%] w-full max-w-[560px] p-6",
265
+ scrollable ? "overflow-hidden" : undefined,
266
+ className,
267
+ )}
268
+ style={
269
+ anchored
270
+ ? {
271
+ position: "fixed",
272
+ left: placementState.left,
273
+ top: placementState.top,
274
+ maxWidth: placementState.maxWidth || undefined,
275
+ maxHeight: placementState.maxHeight,
276
+ overflowY: scrollable ? "auto" : undefined,
277
+ transformOrigin:
278
+ origin === "top-left"
279
+ ? `0% ${placementState.above ? "100%" : "0%"}`
280
+ : `100% ${placementState.above ? "100%" : "0%"}`,
281
+ }
282
+ : undefined
283
+ }
284
+ initial={
285
+ anchored
286
+ ? { "--popover-content-opacity": reducedMotion ? 1 : 0 }
287
+ : { opacity: 0 }
288
+ }
289
+ animate={
290
+ anchored ? { "--popover-content-opacity": 1 } : { opacity: 1 }
291
+ }
292
+ exit={
293
+ anchored
294
+ ? {
295
+ "--popover-content-opacity": 0,
296
+ transition: { duration: reducedMotion ? 0 : 0.06 },
297
+ }
298
+ : { opacity: 0 }
299
+ }
300
+ transition={
301
+ anchored
302
+ ? reducedMotion
303
+ ? { duration: 0 }
304
+ : { duration: 0.16, delay: 0.06 }
305
+ : reducedMotion
306
+ ? { duration: 0 }
307
+ : { duration: 0.16, delay: 0.06 }
308
+ }
309
+ >
310
+ {/* The morphing glass surface — starts at the trigger's rect
311
+ and grows to fill the panel, with the fluid overshoot bounce. */}
312
+ <motion.div
313
+ key={reveal ? "surface" : "measure"}
314
+ aria-hidden="true"
315
+ data-popover-surface=""
316
+ onAnimationComplete={() => {
317
+ if (state === "opening") send("OPENED");
318
+ if (state === "closing") send("CLOSED");
319
+ if (reveal) setSettled(true);
320
+ }}
321
+ className="glass-menu rounded-5xl pointer-events-none absolute top-0 left-0 -z-10"
322
+ style={{ width: "100%", height: "100%" }}
323
+ initial={reducedMotion || !reveal ? { opacity: 0 } : reveal}
324
+ animate={
325
+ material.width > 0
326
+ ? {
327
+ x: 0,
328
+ y: 0,
329
+ width: material.width,
330
+ height: placementState.maxHeight
331
+ ? Math.min(material.height, placementState.maxHeight)
332
+ : material.height,
333
+ opacity: 1,
334
+ borderRadius: material.radius,
335
+ }
336
+ : undefined
337
+ }
338
+ exit={
339
+ reducedMotion || !reveal
340
+ ? { opacity: 0 }
341
+ : {
342
+ ...menuSurfaceExit(reveal),
343
+ transition: fluidCollapse,
344
+ }
345
+ }
346
+ transition={
347
+ reducedMotion
348
+ ? { duration: 0 }
349
+ : { ...fluidOvershoot, opacity: { duration: 0.16 } }
350
+ }
351
+ />
352
+ {/* Content fades in after the surface arrives. */}
353
+ <motion.div
354
+ initial={reducedMotion ? false : { opacity: 0 }}
355
+ animate={reducedMotion ? undefined : { opacity: 1 }}
356
+ exit={
357
+ reducedMotion
358
+ ? undefined
359
+ : { opacity: 0, transition: { duration: 0.12 } }
360
+ }
361
+ transition={
362
+ reducedMotion
363
+ ? { duration: 0 }
364
+ : { duration: 0.16, delay: 0.06 }
365
+ }
366
+ >
367
+ {state !== "closing" && children}
368
+ </motion.div>
369
+ </motion.div>
370
+ </SheetPortal>
371
+ )}
372
+ </AnimatePresence>
373
+ );
374
+ }
@@ -0,0 +1,97 @@
1
+ import {
2
+ type ReactNode,
3
+ useEffect,
4
+ useId,
5
+ useReducer,
6
+ useRef,
7
+ type RefObject,
8
+ } from "react";
9
+ import { createContext, useContext } from "react";
10
+ import {
11
+ popoverMachine,
12
+ type PopoverContextValue,
13
+ type PopoverEvent,
14
+ type PopoverState,
15
+ } from "../../machines/popover.machine";
16
+
17
+ const PopoverContext = createContext<PopoverContextValue | null>(null);
18
+
19
+ export function usePopover(): PopoverContextValue {
20
+ const ctx = useContext(PopoverContext);
21
+ if (!ctx) throw new Error("Popover parts need a <Popover.Root> parent");
22
+ return ctx;
23
+ }
24
+
25
+ export interface PopoverRootProps {
26
+ children: ReactNode;
27
+ /**
28
+ * Start open (uncontrolled) or set the initial state.
29
+ * Ignored when `open` is provided.
30
+ */
31
+ defaultOpen?: boolean;
32
+ /**
33
+ * Controlled open state. When provided, the popover is driven by this
34
+ * prop: setting it to true opens, false closes. The internal machine
35
+ * still runs the open/close transitions so the morph can play.
36
+ */
37
+ open?: boolean;
38
+ /** Called when the popover finishes opening or closing. */
39
+ onOpenChange?: (open: boolean) => void;
40
+ }
41
+
42
+ export function PopoverRoot({
43
+ children,
44
+ defaultOpen = false,
45
+ open,
46
+ onOpenChange,
47
+ }: PopoverRootProps) {
48
+ const [state, dispatch] = useReducer(
49
+ popoverMachine,
50
+ defaultOpen ? "opening" : ("closed" satisfies PopoverState),
51
+ );
52
+ const triggerRef = useRef<HTMLElement | null>(null);
53
+ const contentId = useId();
54
+ const surfaceId = useId();
55
+ const onOpenChangeRef = useRef(onOpenChange);
56
+
57
+ // Keep the latest callback without re-rendering the tree.
58
+ // eslint-disable-next-line react-hooks/exhaustive-deps
59
+ onOpenChangeRef.current = onOpenChange;
60
+
61
+ const send = (event: PopoverEvent) => {
62
+ const next = popoverMachine(state, event);
63
+ if (next !== state) {
64
+ dispatch(event);
65
+ // Fire onOpenChange only when fully open or fully closed — not
66
+ // during transitions. This lets the consumer close the popover
67
+ // (set open=false) and have it animate out, rather than unmounting.
68
+ if (next === "open") onOpenChangeRef.current?.(true);
69
+ if (next === "closed") onOpenChangeRef.current?.(false);
70
+ }
71
+ };
72
+
73
+ // Controlled mode: drive OPEN/CLOSE from the `open` prop. The machine
74
+ // still runs the morph transitions; we only nudge it when the desired
75
+ // presence differs from the current presence.
76
+ const present = state !== "closed";
77
+ useEffect(() => {
78
+ if (open === undefined) return;
79
+ if (open && !present) send("OPEN");
80
+ if (!open && present) send("CLOSE");
81
+ // eslint-disable-next-line react-hooks/exhaustive-deps
82
+ }, [open]);
83
+
84
+ const isPresent = present;
85
+
86
+ return (
87
+ <PopoverContext
88
+ value={{ state, send, triggerRef, contentId, surfaceId, isPresent }}
89
+ >
90
+ {children}
91
+ </PopoverContext>
92
+ );
93
+ }
94
+
95
+ // Re-export the context type for consumers that need it.
96
+ export type { PopoverContextValue };
97
+ export type { RefObject };
@@ -0,0 +1,71 @@
1
+ import { type ReactNode, cloneElement, isValidElement } from "react";
2
+ import { usePopover } from "./popover-root";
3
+
4
+ export type PopoverTriggerRenderProps = {
5
+ ref: (node: HTMLElement | null) => void;
6
+ "aria-haspopup": "dialog";
7
+ "aria-expanded": boolean;
8
+ "aria-controls": string | undefined;
9
+ onClick: () => void;
10
+ onKeyDown: (event: React.KeyboardEvent) => void;
11
+ };
12
+
13
+ export interface PopoverTriggerProps {
14
+ /** The trigger content when using render prop. */
15
+ children?: ReactNode;
16
+ /**
17
+ * Render prop: receives the trigger props (ref, aria, onClick) and
18
+ * returns the trigger element. This prevents ref-passing conflicts
19
+ * when multiple triggers exist — borrowed from Base UI.
20
+ *
21
+ * If you pass a single element as children instead, it will be
22
+ * cloned with the trigger props merged in.
23
+ */
24
+ render?: (props: PopoverTriggerRenderProps) => ReactNode;
25
+ }
26
+
27
+ export function PopoverTrigger({ children, render }: PopoverTriggerProps) {
28
+ const { state, send, triggerRef, contentId } = usePopover();
29
+
30
+ const triggerProps: PopoverTriggerRenderProps = {
31
+ ref: (node) => {
32
+ triggerRef.current = node;
33
+ },
34
+ "aria-haspopup": "dialog",
35
+ "aria-expanded": state === "open" || state === "opening",
36
+ "aria-controls": state !== "closed" ? contentId : undefined,
37
+ onClick: () => send("TOGGLE"),
38
+ onKeyDown: (event) => {
39
+ if (event.key === "Enter" || event.key === " ") {
40
+ event.preventDefault();
41
+ send("TOGGLE");
42
+ }
43
+ },
44
+ };
45
+
46
+ if (render) {
47
+ return render(triggerProps);
48
+ }
49
+
50
+ // Clone a single child element with the trigger props merged.
51
+ if (isValidElement(children)) {
52
+ const child = children as React.ReactElement<Record<string, unknown>>;
53
+ return cloneElement(child, {
54
+ ref: (node: HTMLElement | null) => {
55
+ triggerRef.current = node;
56
+ },
57
+ "aria-haspopup": "dialog",
58
+ "aria-expanded": state === "open" || state === "opening",
59
+ "aria-controls": state !== "closed" ? contentId : undefined,
60
+ onClick: () => send("TOGGLE"),
61
+ onKeyDown: (event: React.KeyboardEvent) => {
62
+ if (event.key === "Enter" || event.key === " ") {
63
+ event.preventDefault();
64
+ send("TOGGLE");
65
+ }
66
+ },
67
+ });
68
+ }
69
+
70
+ return null;
71
+ }