@passui/react 0.1.45 → 0.1.46

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 (140) 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 +32 -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 +279 -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 +334 -0
  72. package/src/components/popover/popover-fit.ts +58 -53
  73. package/src/components/popover/popover-root.tsx +97 -0
  74. package/src/components/popover/popover-trigger.tsx +71 -0
  75. package/src/components/profile-image/profile-image.tsx +80 -64
  76. package/src/components/progress-view/progress-view.tsx +61 -59
  77. package/src/components/progress-view/use-liquid-progress.ts +38 -38
  78. package/src/components/radio-group/radio-group.tsx +71 -68
  79. package/src/components/search-field/expanding-search-field.tsx +191 -177
  80. package/src/components/search-field/search-field.tsx +59 -50
  81. package/src/components/search-list/search-list.tsx +246 -236
  82. package/src/components/section/section.tsx +58 -58
  83. package/src/components/secure-field/secure-field.tsx +3 -3
  84. package/src/components/segmented-control/segmented-control.tsx +136 -136
  85. package/src/components/sheet/modal-environment.ts +71 -64
  86. package/src/components/sheet/sheet-portal.tsx +58 -53
  87. package/src/components/sheet/sheet.tsx +146 -141
  88. package/src/components/sheet/use-sheet-focus.ts +97 -82
  89. package/src/components/sidebar/sidebar.tsx +78 -78
  90. package/src/components/skeleton/skeleton.tsx +30 -21
  91. package/src/components/slider/slider.tsx +113 -104
  92. package/src/components/slosh-slider/liquid-step.ts +32 -31
  93. package/src/components/slosh-slider/slosh-slider.tsx +265 -228
  94. package/src/components/stat-tile/index.ts +1 -1
  95. package/src/components/stat-tile/stat-tile.tsx +79 -68
  96. package/src/components/status-indicator/status-indicator.tsx +40 -37
  97. package/src/components/stepper/rolling-digit.tsx +82 -68
  98. package/src/components/stepper/stepper.tsx +122 -113
  99. package/src/components/swatch-picker/swatch-picker.tsx +120 -120
  100. package/src/components/tab-bar/tab-bar.tsx +149 -148
  101. package/src/components/tab-view/tab-view.tsx +140 -137
  102. package/src/components/table/table.tsx +117 -98
  103. package/src/components/text-editor/text-editor.tsx +34 -29
  104. package/src/components/text-field/text-field.tsx +182 -180
  105. package/src/components/toast/toast-store.ts +127 -115
  106. package/src/components/toast/toaster.tsx +361 -347
  107. package/src/components/toggle/toggle.tsx +297 -282
  108. package/src/components/tooltip/tooltip-host.tsx +157 -150
  109. package/src/components/view-controller/index.ts +1 -1
  110. package/src/components/view-controller/view-controller.tsx +64 -53
  111. package/src/components/wake-slider/wake-slider.tsx +220 -205
  112. package/src/hooks/scroll/minimize-decision.ts +11 -7
  113. package/src/hooks/use-anchored-disclosure.ts +19 -19
  114. package/src/hooks/use-anchored-placement.ts +29 -25
  115. package/src/hooks/use-dismiss.ts +33 -29
  116. package/src/hooks/use-floating-dismiss.ts +24 -22
  117. package/src/hooks/use-media-query.ts +67 -60
  118. package/src/hooks/use-minimize-on-scroll.ts +69 -60
  119. package/src/hooks/use-visual-viewport.ts +63 -63
  120. package/src/hooks/viewport/types.ts +16 -16
  121. package/src/hooks/viewport/viewport-metrics.ts +16 -10
  122. package/src/icons/icons.tsx +440 -328
  123. package/src/index.ts +91 -91
  124. package/src/lib/anchored-placement.ts +70 -65
  125. package/src/lib/class-names.ts +14 -13
  126. package/src/lib/clipboard.ts +8 -7
  127. package/src/lib/date.ts +61 -60
  128. package/src/lib/floating-layers.ts +39 -24
  129. package/src/lib/haptics.ts +10 -10
  130. package/src/lib/language.ts +9 -9
  131. package/src/lib/range-value.ts +27 -21
  132. package/src/lib/screen-edges.ts +33 -30
  133. package/src/lib/shapes.ts +38 -34
  134. package/src/machines/popover.machine.ts +51 -0
  135. package/src/motion/transitions.ts +46 -43
  136. package/src/styles/code-block.css +69 -43
  137. package/src/styles/motion-controls.css +393 -191
  138. package/src/tokens.ts +1 -1
  139. package/dist/types/src/components/popover/popover.d.ts +0 -21
  140. package/src/components/popover/popover.tsx +0 -334
@@ -0,0 +1,334 @@
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 { fitPopover, type PopoverFit } from "./popover-fit";
15
+ import { usePopover } from "./popover-root";
16
+
17
+ export type PopoverOrigin = "top-left" | "top-right";
18
+
19
+ export interface PopoverContentProps {
20
+ children: ReactNode;
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
+ * ARIA role for the panel. Use "menu" for action menus, "dialog"
43
+ * (the default) for free-form content, "tooltip" for tips.
44
+ * @default "dialog"
45
+ */
46
+ role?: "dialog" | "menu" | "tooltip";
47
+ }
48
+
49
+ export function PopoverContent({
50
+ children,
51
+ className,
52
+ label,
53
+ labelledBy,
54
+ dismissOnOutsidePress = true,
55
+ placement = "anchor",
56
+ origin = "top-left",
57
+ role = "dialog",
58
+ }: PopoverContentProps) {
59
+ const { state, send, triggerRef, contentId } = usePopover();
60
+ const panel = useRef<HTMLDivElement>(null);
61
+ const reducedMotion = useReducedMotion();
62
+ const [settled, setSettled] = useState(false);
63
+ const [material, setMaterial] = useState({ width: 0, height: 0, radius: 0 });
64
+ const [fit, setFit] = useState<PopoverFit>({
65
+ shiftX: 0,
66
+ shiftY: 0,
67
+ maxWidth: 0,
68
+ above: false,
69
+ });
70
+ const [reveal, setReveal] = useState<ReturnType<
71
+ typeof menuSurfaceReveal
72
+ > | null>(null);
73
+
74
+ useSheetFocus(panel, () => send("CLOSE"));
75
+
76
+ // Measure panel + trigger rects, compute the reveal (initial morph state)
77
+ // and the viewport fit (so the panel stays on screen as it resizes).
78
+ useLayoutEffect(() => {
79
+ const surface = panel.current;
80
+ const source = triggerRef.current;
81
+ if (!surface) return;
82
+
83
+ const refit = () => {
84
+ const panelRect = surface.getBoundingClientRect();
85
+ const radius =
86
+ Number.parseFloat(getComputedStyle(surface).borderTopLeftRadius) || 0;
87
+ setMaterial((prev) =>
88
+ prev.width === panelRect.width &&
89
+ prev.height === panelRect.height &&
90
+ prev.radius === radius
91
+ ? prev
92
+ : { width: panelRect.width, height: panelRect.height, radius },
93
+ );
94
+
95
+ const visualViewport = window.visualViewport;
96
+ const offsetLeft = visualViewport?.offsetLeft ?? 0;
97
+ const offsetTop = visualViewport?.offsetTop ?? 0;
98
+ const edges = screenEdges();
99
+ const trigger = source?.getBoundingClientRect();
100
+ const next = fitPopover(
101
+ {
102
+ left: panelRect.left,
103
+ top: panelRect.top,
104
+ width: panelRect.width,
105
+ height: panelRect.height,
106
+ },
107
+ trigger ? { top: trigger.top, bottom: trigger.bottom } : undefined,
108
+ {
109
+ viewport: {
110
+ width: offsetLeft + (visualViewport?.width ?? window.innerWidth),
111
+ height: offsetTop + (visualViewport?.height ?? window.innerHeight),
112
+ },
113
+ edges: {
114
+ ...edges,
115
+ left: offsetLeft + edges.left,
116
+ top: offsetTop + edges.top,
117
+ },
118
+ },
119
+ trigger ? -trigger.height : 6,
120
+ origin,
121
+ );
122
+ setFit((current) => {
123
+ if (
124
+ current.shiftX === next.shiftX &&
125
+ current.shiftY === next.shiftY &&
126
+ current.maxWidth === next.maxWidth &&
127
+ current.maxHeight === next.maxHeight &&
128
+ current.above === next.above
129
+ )
130
+ return current;
131
+ return next;
132
+ });
133
+
134
+ const sourceArg: MenuRect | undefined =
135
+ trigger && trigger.width > 0 && trigger.height > 0
136
+ ? {
137
+ left: trigger.left,
138
+ top: trigger.top,
139
+ width: trigger.width,
140
+ height: trigger.height,
141
+ }
142
+ : undefined;
143
+ setReveal(
144
+ menuSurfaceReveal(sourceArg, {
145
+ left: panelRect.left,
146
+ top: panelRect.top,
147
+ width: next.maxWidth || panelRect.width,
148
+ height: next.maxHeight
149
+ ? Math.min(next.maxHeight, panelRect.height)
150
+ : panelRect.height,
151
+ radius,
152
+ }),
153
+ );
154
+ };
155
+
156
+ refit();
157
+ let frame = 0;
158
+ const schedule = () => {
159
+ cancelAnimationFrame(frame);
160
+ frame = requestAnimationFrame(refit);
161
+ };
162
+ const observer = new ResizeObserver(schedule);
163
+ observer.observe(surface);
164
+ if (source) observer.observe(source);
165
+ const viewport = window.visualViewport;
166
+ viewport?.addEventListener("resize", schedule);
167
+ viewport?.addEventListener("scroll", schedule);
168
+ window.addEventListener("resize", schedule);
169
+ window.addEventListener("scroll", schedule, true);
170
+ return () => {
171
+ observer.disconnect();
172
+ cancelAnimationFrame(frame);
173
+ viewport?.removeEventListener("resize", schedule);
174
+ viewport?.removeEventListener("scroll", schedule);
175
+ window.removeEventListener("resize", schedule);
176
+ window.removeEventListener("scroll", schedule, true);
177
+ };
178
+ }, [triggerRef, origin]);
179
+
180
+ // Conceal the trigger while the surface is morphing.
181
+ useLayoutEffect(() => {
182
+ const source = triggerRef.current;
183
+ if (!source) return;
184
+ const restore = concealMenuSource(source, false);
185
+ return () => {
186
+ restore();
187
+ };
188
+ }, [triggerRef]);
189
+
190
+ const anchored = placement === "anchor";
191
+
192
+ return (
193
+ <AnimatePresence>
194
+ {state !== "closed" && (
195
+ <SheetPortal>
196
+ {placement === "center" && (
197
+ <motion.div
198
+ aria-hidden="true"
199
+ className="fixed inset-0 z-[75] bg-black/30 backdrop-blur-md"
200
+ initial={{ opacity: 0 }}
201
+ animate={{ opacity: 1 }}
202
+ exit={{ opacity: 0 }}
203
+ transition={reducedMotion ? { duration: 0 } : { duration: 0.18 }}
204
+ onClick={dismissOnOutsidePress ? () => send("CLOSE") : undefined}
205
+ />
206
+ )}
207
+ <motion.div
208
+ ref={panel}
209
+ id={contentId}
210
+ role={role}
211
+ aria-modal={placement === "center" || undefined}
212
+ aria-label={labelledBy ? undefined : label}
213
+ aria-labelledby={labelledBy}
214
+ tabIndex={-1}
215
+ data-state={state}
216
+ className={cn(
217
+ "relative isolate flex flex-col outline-none",
218
+ anchored
219
+ ? cn(
220
+ "rounded-5xl absolute z-40",
221
+ "[&>:not([data-popover-surface])]:opacity-[var(--popover-content-opacity)]",
222
+ )
223
+ : "rounded-5xl fixed z-[76] max-h-[92%] w-full max-w-[560px] p-6",
224
+ settled && anchored ? "overflow-hidden" : undefined,
225
+ className,
226
+ )}
227
+ style={
228
+ anchored
229
+ ? {
230
+ transformOrigin:
231
+ origin === "top-left"
232
+ ? `0% ${fit.above ? "100%" : "0%"}`
233
+ : `100% ${fit.above ? "100%" : "0%"}`,
234
+ translate: `${fit.shiftX}px ${fit.shiftY}px`,
235
+ maxWidth: fit.maxWidth || undefined,
236
+ maxHeight: fit.maxHeight,
237
+ overflowY:
238
+ fit.maxHeight !== undefined && settled
239
+ ? "auto"
240
+ : undefined,
241
+ }
242
+ : undefined
243
+ }
244
+ initial={
245
+ anchored
246
+ ? { "--popover-content-opacity": reducedMotion ? 1 : 0 }
247
+ : { opacity: 0 }
248
+ }
249
+ animate={
250
+ anchored ? { "--popover-content-opacity": 1 } : { opacity: 1 }
251
+ }
252
+ exit={
253
+ anchored
254
+ ? {
255
+ "--popover-content-opacity": 0,
256
+ transition: { duration: reducedMotion ? 0 : 0.06 },
257
+ }
258
+ : { opacity: 0 }
259
+ }
260
+ transition={
261
+ anchored
262
+ ? reducedMotion
263
+ ? { duration: 0 }
264
+ : { duration: 0.16, delay: 0.06 }
265
+ : reducedMotion
266
+ ? { duration: 0 }
267
+ : { duration: 0.16, delay: 0.06 }
268
+ }
269
+ >
270
+ {/* The morphing glass surface — starts at the trigger's rect
271
+ and grows to fill the panel, with the fluid overshoot bounce. */}
272
+ <motion.div
273
+ key={reveal ? "surface" : "measure"}
274
+ aria-hidden="true"
275
+ data-popover-surface=""
276
+ onAnimationComplete={() => {
277
+ if (state === "opening") send("OPENED");
278
+ if (state === "closing") send("CLOSED");
279
+ if (reveal) setSettled(true);
280
+ }}
281
+ className="glass-menu rounded-5xl pointer-events-none absolute top-0 left-0 -z-10"
282
+ style={{ width: "100%", height: "100%" }}
283
+ initial={reducedMotion || !reveal ? { opacity: 0 } : reveal}
284
+ animate={
285
+ material.width > 0
286
+ ? {
287
+ x: 0,
288
+ y: 0,
289
+ width: material.width,
290
+ height: fit.maxHeight
291
+ ? Math.min(material.height, fit.maxHeight)
292
+ : material.height,
293
+ opacity: 1,
294
+ borderRadius: material.radius,
295
+ }
296
+ : undefined
297
+ }
298
+ exit={
299
+ reducedMotion || !reveal
300
+ ? { opacity: 0 }
301
+ : {
302
+ ...menuSurfaceExit(reveal),
303
+ transition: fluidCollapse,
304
+ }
305
+ }
306
+ transition={
307
+ reducedMotion
308
+ ? { duration: 0 }
309
+ : { ...fluidOvershoot, opacity: { duration: 0.16 } }
310
+ }
311
+ />
312
+ {/* Content fades in after the surface arrives. */}
313
+ <motion.div
314
+ initial={reducedMotion ? false : { opacity: 0 }}
315
+ animate={reducedMotion ? undefined : { opacity: 1 }}
316
+ exit={
317
+ reducedMotion
318
+ ? undefined
319
+ : { opacity: 0, transition: { duration: 0.12 } }
320
+ }
321
+ transition={
322
+ reducedMotion
323
+ ? { duration: 0 }
324
+ : { duration: 0.16, delay: 0.06 }
325
+ }
326
+ >
327
+ {state !== "closing" && children}
328
+ </motion.div>
329
+ </motion.div>
330
+ </SheetPortal>
331
+ )}
332
+ </AnimatePresence>
333
+ );
334
+ }
@@ -1,69 +1,74 @@
1
- import { type PlacementFrame, placeInViewport } from "../../lib/anchored-placement"
1
+ import {
2
+ type PlacementFrame,
3
+ placeInViewport,
4
+ } from "../../lib/anchored-placement";
2
5
 
3
6
  interface PanelRect {
4
- left: number
5
- top: number
6
- width: number
7
- height: number
7
+ left: number;
8
+ top: number;
9
+ width: number;
10
+ height: number;
8
11
  }
9
12
 
10
13
  interface AnchorRect {
11
- top: number
12
- bottom: number
14
+ top: number;
15
+ bottom: number;
13
16
  }
14
17
 
15
18
  export interface PopoverFit {
16
- shiftX: number
17
- shiftY: number
18
- maxWidth: number
19
- maxHeight?: number
20
- above: boolean
19
+ shiftX: number;
20
+ shiftY: number;
21
+ maxWidth: number;
22
+ maxHeight?: number;
23
+ above: boolean;
21
24
  }
22
25
 
23
26
  /** Keep an absolutely positioned popover visible without scaling its contents. */
24
27
  export function fitPopover(
25
- panel: PanelRect,
26
- anchor: AnchorRect | undefined,
27
- frame: Pick<PlacementFrame, "viewport" | "edges">,
28
- gap = 6,
29
- origin: "top-left" | "top-right" = "top-left"
28
+ panel: PanelRect,
29
+ anchor: AnchorRect | undefined,
30
+ frame: Pick<PlacementFrame, "viewport" | "edges">,
31
+ gap = 6,
32
+ origin: "top-left" | "top-right" = "top-left",
30
33
  ): PopoverFit {
31
- const { viewport, edges } = frame
32
- const topEdge = edges.top
33
- const bottomEdge = viewport.height - edges.bottom
34
- const availableHeight = Math.max(0, bottomEdge - topEdge)
35
- const placement = placeInViewport(
36
- {
37
- ...frame,
38
- anchor: {
39
- left: panel.left,
40
- top: anchor?.top ?? panel.top,
41
- bottom: anchor?.bottom ?? panel.top,
42
- },
43
- },
44
- panel.width,
45
- panel.height,
46
- gap
47
- )
34
+ const { viewport, edges } = frame;
35
+ const topEdge = edges.top;
36
+ const bottomEdge = viewport.height - edges.bottom;
37
+ const availableHeight = Math.max(0, bottomEdge - topEdge);
38
+ const placement = placeInViewport(
39
+ {
40
+ ...frame,
41
+ anchor: {
42
+ left: panel.left,
43
+ top: anchor?.top ?? panel.top,
44
+ bottom: anchor?.bottom ?? panel.top,
45
+ },
46
+ },
47
+ panel.width,
48
+ panel.height,
49
+ gap,
50
+ );
48
51
 
49
- const above = Boolean(anchor && panel.top + panel.height > bottomEdge && placement.above)
50
- const aboveHeight = anchor ? Math.max(0, anchor.top - topEdge - gap) : 0
51
- const visibleHeight = above
52
- ? Math.min(panel.height, aboveHeight)
53
- : Math.min(panel.height, availableHeight)
54
- const desiredTop = above
55
- ? (anchor?.top ?? panel.top) - gap - visibleHeight
56
- : Math.max(topEdge, Math.min(panel.top, bottomEdge - visibleHeight))
57
- const heightRoom = above ? aboveHeight : Math.max(0, bottomEdge - desiredTop)
52
+ const above = Boolean(
53
+ anchor && panel.top + panel.height > bottomEdge && placement.above,
54
+ );
55
+ const aboveHeight = anchor ? Math.max(0, anchor.top - topEdge - gap) : 0;
56
+ const visibleHeight = above
57
+ ? Math.min(panel.height, aboveHeight)
58
+ : Math.min(panel.height, availableHeight);
59
+ const desiredTop = above
60
+ ? (anchor?.top ?? panel.top) - gap - visibleHeight
61
+ : Math.max(topEdge, Math.min(panel.top, bottomEdge - visibleHeight));
62
+ const heightRoom = above ? aboveHeight : Math.max(0, bottomEdge - desiredTop);
58
63
 
59
- return {
60
- shiftX:
61
- placement.left -
62
- panel.left -
63
- (origin === "top-right" ? panel.width - placement.width : 0),
64
- shiftY: desiredTop - panel.top,
65
- maxWidth: Math.max(0, viewport.width - edges.left - edges.right),
66
- maxHeight: panel.height > heightRoom ? heightRoom : undefined,
67
- above,
68
- }
64
+ return {
65
+ shiftX:
66
+ placement.left -
67
+ panel.left -
68
+ (origin === "top-right" ? panel.width - placement.width : 0),
69
+ shiftY: desiredTop - panel.top,
70
+ maxWidth: Math.max(0, viewport.width - edges.left - edges.right),
71
+ maxHeight: panel.height > heightRoom ? heightRoom : undefined,
72
+ above,
73
+ };
69
74
  }
@@ -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
+ }