@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.
- package/README.md +57 -46
- package/dist/types/src/components/accordion/accordion.d.ts +1 -1
- package/dist/types/src/components/breadcrumb/breadcrumb.d.ts +1 -1
- package/dist/types/src/components/inline-disclosure/inline-disclosure.d.ts +1 -1
- package/dist/types/src/components/popover/anchored-surface.d.ts +5 -1
- package/dist/types/src/components/popover/index.d.ts +41 -0
- package/dist/types/src/components/popover/popover-content.d.ts +38 -0
- package/dist/types/src/components/popover/popover-root.d.ts +22 -0
- package/dist/types/src/components/popover/popover-trigger.d.ts +23 -0
- package/dist/types/src/components/stepper/rolling-digit.d.ts +1 -1
- package/dist/types/src/components/table/table.d.ts +1 -1
- package/dist/types/src/components/view-controller/view-controller.d.ts +2 -2
- package/dist/types/src/index.d.ts +1 -1
- package/dist/types/src/machines/popover.machine.d.ts +30 -0
- package/package.json +307 -307
- package/src/appearance/index.ts +2 -2
- package/src/appearance/platform.tsx +76 -60
- package/src/appearance/theme.tsx +409 -377
- package/src/components/accordion/accordion.tsx +58 -49
- package/src/components/ambient/ambient.tsx +25 -25
- package/src/components/ambient/index.ts +1 -1
- package/src/components/aspect-ratio/aspect-ratio.tsx +21 -16
- package/src/components/breadcrumb/breadcrumb.tsx +63 -57
- package/src/components/button/button-styles.ts +39 -32
- package/src/components/button/button.tsx +91 -84
- package/src/components/button/icon-button.tsx +27 -25
- package/src/components/button/pending-spinner.tsx +8 -8
- package/src/components/button/use-pending-indicator.ts +11 -11
- package/src/components/chip/chip.tsx +35 -30
- package/src/components/code-block/code-block.tsx +40 -39
- package/src/components/combo-box/combo-box.tsx +5 -5
- package/src/components/command/command.tsx +124 -94
- package/src/components/compact-date-picker/calendar-month.ts +75 -53
- package/src/components/compact-date-picker/compact-date-picker.tsx +308 -289
- package/src/components/compact-time-picker/compact-time-picker.tsx +288 -281
- package/src/components/compact-time-picker/time-value.ts +32 -29
- package/src/components/content-unavailable-view/content-unavailable-view.tsx +64 -48
- package/src/components/content-view/content-view.tsx +168 -159
- package/src/components/content-view/index.ts +1 -1
- package/src/components/context-menu/conceal-menu-source.ts +25 -25
- package/src/components/context-menu/context-menu-host.tsx +439 -402
- package/src/components/context-menu/context-menu-store.ts +125 -123
- package/src/components/context-menu/menu-navigation.ts +25 -18
- package/src/components/context-menu/menu-position.ts +33 -29
- package/src/components/context-menu/menu-reveal.ts +32 -32
- package/src/components/context-menu/use-context-menu.ts +63 -56
- package/src/components/conversation/conversation.tsx +265 -263
- package/src/components/conversation/index.ts +1 -1
- package/src/components/date-picker/date-picker.tsx +95 -94
- package/src/components/date-picker/date-value.ts +20 -10
- package/src/components/dialog/dialog-host.tsx +105 -103
- package/src/components/dialog/dialog-store.ts +37 -36
- package/src/components/dialog/dialog.tsx +192 -179
- package/src/components/dialog/index.ts +1 -1
- package/src/components/disclosure-group/disclosure-group.tsx +93 -86
- package/src/components/divider/divider.tsx +24 -22
- package/src/components/inline-disclosure/inline-disclosure.tsx +40 -36
- package/src/components/keyboard-shortcut/keyboard-shortcut.tsx +23 -19
- package/src/components/labeled-content/labeled-content.tsx +68 -68
- package/src/components/locale-switcher/index.ts +1 -1
- package/src/components/locale-switcher/locale-switcher.tsx +278 -251
- package/src/components/menu/menu.tsx +75 -58
- package/src/components/morph/index.ts +2 -2
- package/src/components/morph/morph.tsx +227 -201
- package/src/components/morph/use-morph-disclosure.ts +19 -19
- package/src/components/one-time-code/one-time-code.tsx +216 -200
- package/src/components/picker/picker-trigger.ts +16 -13
- package/src/components/picker/picker.tsx +371 -345
- package/src/components/popover/anchored-surface.tsx +253 -229
- package/src/components/popover/index.ts +50 -0
- package/src/components/popover/popover-content.tsx +374 -0
- package/src/components/popover/popover-root.tsx +97 -0
- package/src/components/popover/popover-trigger.tsx +71 -0
- package/src/components/profile-image/profile-image.tsx +80 -64
- package/src/components/progress-view/progress-view.tsx +61 -59
- package/src/components/progress-view/use-liquid-progress.ts +38 -38
- package/src/components/radio-group/radio-group.tsx +71 -68
- package/src/components/search-field/expanding-search-field.tsx +191 -177
- package/src/components/search-field/search-field.tsx +59 -50
- package/src/components/search-list/search-list.tsx +246 -236
- package/src/components/section/section.tsx +58 -58
- package/src/components/secure-field/secure-field.tsx +3 -3
- package/src/components/segmented-control/segmented-control.tsx +136 -136
- package/src/components/sheet/modal-environment.ts +71 -64
- package/src/components/sheet/sheet-portal.tsx +58 -53
- package/src/components/sheet/sheet.tsx +146 -141
- package/src/components/sheet/use-sheet-focus.ts +97 -82
- package/src/components/sidebar/sidebar.tsx +78 -78
- package/src/components/skeleton/skeleton.tsx +30 -21
- package/src/components/slider/slider.tsx +113 -104
- package/src/components/slosh-slider/liquid-step.ts +32 -31
- package/src/components/slosh-slider/slosh-slider.tsx +265 -228
- package/src/components/stat-tile/index.ts +1 -1
- package/src/components/stat-tile/stat-tile.tsx +79 -68
- package/src/components/status-indicator/status-indicator.tsx +40 -37
- package/src/components/stepper/rolling-digit.tsx +82 -68
- package/src/components/stepper/stepper.tsx +122 -113
- package/src/components/swatch-picker/swatch-picker.tsx +120 -120
- package/src/components/tab-bar/tab-bar.tsx +149 -148
- package/src/components/tab-view/tab-view.tsx +140 -137
- package/src/components/table/table.tsx +117 -98
- package/src/components/text-editor/text-editor.tsx +34 -29
- package/src/components/text-field/text-field.tsx +182 -180
- package/src/components/toast/toast-store.ts +127 -115
- package/src/components/toast/toaster.tsx +361 -347
- package/src/components/toggle/toggle.tsx +297 -282
- package/src/components/tooltip/tooltip-host.tsx +157 -150
- package/src/components/view-controller/index.ts +1 -1
- package/src/components/view-controller/view-controller.tsx +64 -53
- package/src/components/wake-slider/wake-slider.tsx +220 -205
- package/src/hooks/scroll/minimize-decision.ts +11 -7
- package/src/hooks/use-anchored-disclosure.ts +19 -19
- package/src/hooks/use-anchored-placement.ts +29 -25
- package/src/hooks/use-dismiss.ts +33 -29
- package/src/hooks/use-floating-dismiss.ts +24 -22
- package/src/hooks/use-media-query.ts +67 -60
- package/src/hooks/use-minimize-on-scroll.ts +69 -60
- package/src/hooks/use-visual-viewport.ts +63 -63
- package/src/hooks/viewport/types.ts +16 -16
- package/src/hooks/viewport/viewport-metrics.ts +16 -10
- package/src/icons/icons.tsx +440 -328
- package/src/index.ts +91 -91
- package/src/lib/anchored-placement.ts +70 -65
- package/src/lib/class-names.ts +14 -13
- package/src/lib/clipboard.ts +8 -7
- package/src/lib/date.ts +61 -60
- package/src/lib/floating-layers.ts +39 -24
- package/src/lib/haptics.ts +10 -10
- package/src/lib/language.ts +9 -9
- package/src/lib/range-value.ts +27 -21
- package/src/lib/screen-edges.ts +33 -30
- package/src/lib/shapes.ts +38 -34
- package/src/machines/popover.machine.ts +51 -0
- package/src/motion/transitions.ts +46 -43
- package/src/styles/code-block.css +69 -43
- package/src/styles/motion-controls.css +393 -191
- package/src/tokens.ts +1 -1
- package/dist/types/src/components/popover/popover-fit.d.ts +0 -21
- package/dist/types/src/components/popover/popover.d.ts +0 -21
- package/src/components/popover/popover-fit.ts +0 -69
- 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
|
+
}
|