@passui/react 0.1.46 → 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.
@@ -2,6 +2,7 @@ import { type ReactNode } from "react";
2
2
  export type PopoverOrigin = "top-left" | "top-right";
3
3
  export interface PopoverContentProps {
4
4
  children: ReactNode;
5
+ /** Extra classes for the panel (sizing, padding, max-height). */
5
6
  className?: string;
6
7
  /** Accessible name (used when there is no labelledBy). */
7
8
  label?: string;
@@ -22,6 +23,11 @@ export interface PopoverContentProps {
22
23
  * @default "top-left"
23
24
  */
24
25
  origin?: PopoverOrigin;
26
+ /**
27
+ * Gap between the trigger and the panel, in pixels.
28
+ * @default 8
29
+ */
30
+ gap?: number;
25
31
  /**
26
32
  * ARIA role for the panel. Use "menu" for action menus, "dialog"
27
33
  * (the default) for free-form content, "tooltip" for tips.
@@ -29,4 +35,4 @@ export interface PopoverContentProps {
29
35
  */
30
36
  role?: "dialog" | "menu" | "tooltip";
31
37
  }
32
- export declare function PopoverContent({ children, className, label, labelledBy, dismissOnOutsidePress, placement, origin, role, }: PopoverContentProps): import("react").JSX.Element;
38
+ export declare function PopoverContent({ children, className, label, labelledBy, dismissOnOutsidePress, placement, origin, gap, role, }: PopoverContentProps): import("react").JSX.Element;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@passui/react",
3
- "version": "0.1.46",
3
+ "version": "0.1.47",
4
4
  "description": "Passui: native React controls, design tokens, presentation and appearance.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -252,9 +252,8 @@ export function LocaleSwitcher<L extends string>({
252
252
  role="menu"
253
253
  label={label}
254
254
  className={cn(
255
- "top-0 flex max-w-[calc(100vw-2.5rem)] gap-2 p-2",
255
+ "flex max-w-[calc(100vw-2.5rem)] gap-2 p-2",
256
256
  popoverWidths[size],
257
- anchor.side === "right" ? "right-0" : "left-0",
258
257
  growsUp ? "flex-col-reverse" : "flex-col",
259
258
  )}
260
259
  >
@@ -11,13 +11,13 @@ import { SheetPortal } from "../sheet/sheet-portal";
11
11
  import { cn } from "../../lib/class-names";
12
12
  import { screenEdges } from "../../lib/screen-edges";
13
13
  import { fluidCollapse, fluidOvershoot } from "../../motion/transitions";
14
- import { fitPopover, type PopoverFit } from "./popover-fit";
15
14
  import { usePopover } from "./popover-root";
16
15
 
17
16
  export type PopoverOrigin = "top-left" | "top-right";
18
17
 
19
18
  export interface PopoverContentProps {
20
19
  children: ReactNode;
20
+ /** Extra classes for the panel (sizing, padding, max-height). */
21
21
  className?: string;
22
22
  /** Accessible name (used when there is no labelledBy). */
23
23
  label?: string;
@@ -38,6 +38,11 @@ export interface PopoverContentProps {
38
38
  * @default "top-left"
39
39
  */
40
40
  origin?: PopoverOrigin;
41
+ /**
42
+ * Gap between the trigger and the panel, in pixels.
43
+ * @default 8
44
+ */
45
+ gap?: number;
41
46
  /**
42
47
  * ARIA role for the panel. Use "menu" for action menus, "dialog"
43
48
  * (the default) for free-form content, "tooltip" for tips.
@@ -46,6 +51,67 @@ export interface PopoverContentProps {
46
51
  role?: "dialog" | "menu" | "tooltip";
47
52
  }
48
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
+
49
115
  export function PopoverContent({
50
116
  children,
51
117
  className,
@@ -54,6 +120,7 @@ export function PopoverContent({
54
120
  dismissOnOutsidePress = true,
55
121
  placement = "anchor",
56
122
  origin = "top-left",
123
+ gap = 8,
57
124
  role = "dialog",
58
125
  }: PopoverContentProps) {
59
126
  const { state, send, triggerRef, contentId } = usePopover();
@@ -61,96 +128,69 @@ export function PopoverContent({
61
128
  const reducedMotion = useReducedMotion();
62
129
  const [settled, setSettled] = useState(false);
63
130
  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
- });
131
+ const [placementState, setPlacement] = useState<Placement>(NO_PLACEMENT);
70
132
  const [reveal, setReveal] = useState<ReturnType<
71
133
  typeof menuSurfaceReveal
72
134
  > | null>(null);
73
135
 
74
136
  useSheetFocus(panel, () => send("CLOSE"));
75
137
 
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).
138
+ // Re-measure on every layout pass: trigger position, panel size, scroll,
139
+ // viewport resize. Recompute the fixed placement + the morph source rect.
78
140
  useLayoutEffect(() => {
79
141
  const surface = panel.current;
80
142
  const source = triggerRef.current;
81
143
  if (!surface) return;
82
144
 
83
145
  const refit = () => {
84
- const panelRect = surface.getBoundingClientRect();
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();
85
150
  const radius =
86
151
  Number.parseFloat(getComputedStyle(surface).borderTopLeftRadius) || 0;
87
152
  setMaterial((prev) =>
88
- prev.width === panelRect.width &&
89
- prev.height === panelRect.height &&
153
+ prev.width === natural.width &&
154
+ prev.height === natural.height &&
90
155
  prev.radius === radius
91
156
  ? prev
92
- : { width: panelRect.width, height: panelRect.height, radius },
157
+ : { width: natural.width, height: natural.height, radius },
93
158
  );
94
159
 
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 &&
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 &&
126
170
  current.maxWidth === next.maxWidth &&
127
171
  current.maxHeight === next.maxHeight &&
128
172
  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
- );
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
+ }
154
194
  };
155
195
 
156
196
  refit();
@@ -175,9 +215,9 @@ export function PopoverContent({
175
215
  window.removeEventListener("resize", schedule);
176
216
  window.removeEventListener("scroll", schedule, true);
177
217
  };
178
- }, [triggerRef, origin]);
218
+ }, [triggerRef, origin, gap]);
179
219
 
180
- // Conceal the trigger while the surface is morphing.
220
+ // Conceal the trigger while the surface is morphing out of it.
181
221
  useLayoutEffect(() => {
182
222
  const source = triggerRef.current;
183
223
  if (!source) return;
@@ -188,6 +228,7 @@ export function PopoverContent({
188
228
  }, [triggerRef]);
189
229
 
190
230
  const anchored = placement === "anchor";
231
+ const scrollable = settled && placementState.maxHeight !== undefined;
191
232
 
192
233
  return (
193
234
  <AnimatePresence>
@@ -213,31 +254,30 @@ export function PopoverContent({
213
254
  aria-labelledby={labelledBy}
214
255
  tabIndex={-1}
215
256
  data-state={state}
257
+ data-placement={
258
+ anchored ? (placementState.above ? "top" : "bottom") : "center"
259
+ }
216
260
  className={cn(
217
- "relative isolate flex flex-col outline-none",
261
+ "isolate flex flex-col outline-none",
218
262
  anchored
219
- ? cn(
220
- "rounded-5xl absolute z-40",
221
- "[&>:not([data-popover-surface])]:opacity-[var(--popover-content-opacity)]",
222
- )
263
+ ? "rounded-5xl absolute z-40 [&>:not([data-popover-surface])]:opacity-[var(--popover-content-opacity)]"
223
264
  : "rounded-5xl fixed z-[76] max-h-[92%] w-full max-w-[560px] p-6",
224
- settled && anchored ? "overflow-hidden" : undefined,
265
+ scrollable ? "overflow-hidden" : undefined,
225
266
  className,
226
267
  )}
227
268
  style={
228
269
  anchored
229
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,
230
277
  transformOrigin:
231
278
  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,
279
+ ? `0% ${placementState.above ? "100%" : "0%"}`
280
+ : `100% ${placementState.above ? "100%" : "0%"}`,
241
281
  }
242
282
  : undefined
243
283
  }
@@ -287,8 +327,8 @@ export function PopoverContent({
287
327
  x: 0,
288
328
  y: 0,
289
329
  width: material.width,
290
- height: fit.maxHeight
291
- ? Math.min(material.height, fit.maxHeight)
330
+ height: placementState.maxHeight
331
+ ? Math.min(material.height, placementState.maxHeight)
292
332
  : material.height,
293
333
  opacity: 1,
294
334
  borderRadius: material.radius,
@@ -1,21 +0,0 @@
1
- import { type PlacementFrame } from "../../lib/anchored-placement";
2
- interface PanelRect {
3
- left: number;
4
- top: number;
5
- width: number;
6
- height: number;
7
- }
8
- interface AnchorRect {
9
- top: number;
10
- bottom: number;
11
- }
12
- export interface PopoverFit {
13
- shiftX: number;
14
- shiftY: number;
15
- maxWidth: number;
16
- maxHeight?: number;
17
- above: boolean;
18
- }
19
- /** Keep an absolutely positioned popover visible without scaling its contents. */
20
- export declare function fitPopover(panel: PanelRect, anchor: AnchorRect | undefined, frame: Pick<PlacementFrame, "viewport" | "edges">, gap?: number, origin?: "top-left" | "top-right"): PopoverFit;
21
- export {};
@@ -1,74 +0,0 @@
1
- import {
2
- type PlacementFrame,
3
- placeInViewport,
4
- } from "../../lib/anchored-placement";
5
-
6
- interface PanelRect {
7
- left: number;
8
- top: number;
9
- width: number;
10
- height: number;
11
- }
12
-
13
- interface AnchorRect {
14
- top: number;
15
- bottom: number;
16
- }
17
-
18
- export interface PopoverFit {
19
- shiftX: number;
20
- shiftY: number;
21
- maxWidth: number;
22
- maxHeight?: number;
23
- above: boolean;
24
- }
25
-
26
- /** Keep an absolutely positioned popover visible without scaling its contents. */
27
- export function fitPopover(
28
- panel: PanelRect,
29
- anchor: AnchorRect | undefined,
30
- frame: Pick<PlacementFrame, "viewport" | "edges">,
31
- gap = 6,
32
- origin: "top-left" | "top-right" = "top-left",
33
- ): PopoverFit {
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
- );
51
-
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);
63
-
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
- };
74
- }