@passui/react 0.1.44 → 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.
- 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 +32 -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 -398
- 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 +279 -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 +334 -0
- package/src/components/popover/popover-fit.ts +58 -53
- 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.d.ts +0 -21
- package/src/components/popover/popover.tsx +0 -322
|
@@ -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 {
|
|
1
|
+
import {
|
|
2
|
+
type PlacementFrame,
|
|
3
|
+
placeInViewport,
|
|
4
|
+
} from "../../lib/anchored-placement";
|
|
2
5
|
|
|
3
6
|
interface PanelRect {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
7
|
+
left: number;
|
|
8
|
+
top: number;
|
|
9
|
+
width: number;
|
|
10
|
+
height: number;
|
|
8
11
|
}
|
|
9
12
|
|
|
10
13
|
interface AnchorRect {
|
|
11
|
-
|
|
12
|
-
|
|
14
|
+
top: number;
|
|
15
|
+
bottom: number;
|
|
13
16
|
}
|
|
14
17
|
|
|
15
18
|
export interface PopoverFit {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
+
}
|