@delacour/react-native-ui 0.1.0-alpha.20261006141648 → 0.1.0-alpha.20261007115740

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@delacour/react-native-ui",
3
- "version": "0.1.0-alpha.20261006141648",
3
+ "version": "0.1.0-alpha.20261007115740",
4
4
  "description": "React Native UI components — Uniwind, Reanimated, Gesture Handler",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -56,6 +56,7 @@
56
56
  "./list-group": "./src/components/list-group/index.ts",
57
57
  "./meter": "./src/components/meter/index.ts",
58
58
  "./overlay": "./src/components/overlay/index.ts",
59
+ "./popover": "./src/components/popover/index.ts",
59
60
  "./pressable": "./src/components/pressable/index.ts",
60
61
  "./progress": "./src/components/progress/index.ts",
61
62
  "./provider": "./src/components/provider/index.ts",
@@ -105,8 +106,8 @@
105
106
  },
106
107
  "peerDependencies": {
107
108
  "@central-icons-react-native/round-outlined-radius-1-stroke-1.5": "^1.1",
108
- "@delacour/react-native-bottom-sheet": "0.1.0-alpha.20261006141648",
109
- "@delacour/react-native-charts": "0.1.0-alpha.20261006141648",
109
+ "@delacour/react-native-bottom-sheet": "0.1.0-alpha.20261007115740",
110
+ "@delacour/react-native-charts": "0.1.0-alpha.20261007115740",
110
111
  "@legendapp/list": ">=3.3",
111
112
  "expo-linear-gradient": ">=15",
112
113
  "expo-router": ">=57",
@@ -0,0 +1,138 @@
1
+ # Popover
2
+
3
+ A small panel anchored to the control that opened it, with the screen around it still visible —
4
+ a rename field beside a title, a note on a badge, a short list of options.
5
+
6
+ `import { Popover, usePopover } from "@delacour/react-native-ui/popover";`
7
+
8
+ It draws through the overlay foundation, so it needs `OverlayProvider` at the app root and
9
+ `react-native-teleport` installed — see [Overlay](../overlay/AGENTS.md). Without the provider it
10
+ renders inline and may be clipped.
11
+
12
+ ## Anatomy
13
+
14
+ ```tsx
15
+ <Popover isOpen? defaultOpen? onOpenChange? isDismissible?>
16
+ <Popover.Trigger asChild><Button /></Popover.Trigger>
17
+ <Popover.Anchor /> // optional — anchor to this instead of the trigger
18
+ <Popover.Content placement? align? offset? alignOffset? width? minWidth? maxHeight?
19
+ isScrollable? isUnstyled? background? hasScrim? scrimClassName?>
20
+ <Popover.Arrow />
21
+ <Popover.Close />
22
+ <Popover.Title /> <Popover.Description />
23
+ …
24
+ </Popover.Content>
25
+ </Popover>
26
+ ```
27
+
28
+ `usePopover()` returns `{ isOpen, setOpen, close, placement }` — `placement` is the side the panel
29
+ actually landed on, after any flip.
30
+
31
+ ## Files
32
+
33
+ | File | What it holds |
34
+ | --- | --- |
35
+ | `index.ts` | → `@delacour/react-native-ui/popover` |
36
+ | `popover.tsx` | `Popover` — open state, the two anchor measures, the context; the `Object.assign` names every part |
37
+ | `popover.context.tsx` | **Leaf.** `PopoverContext`, `usePopover`, and the content context the arrow and title read |
38
+ | `popover.position.ts` | **Leaf, pure, imports nothing.** `resolveAnchoredPosition`, `resolvePopoverWidth`, `resolveEnterTranslate`, `resolveTransformOrigin`, `resolveArrowFrame` and their types |
39
+ | `popover.position.test.ts` | The placement matrix — every placement and align, flip and no-flip, shift at each edge, `maxHeight`, the arrow, RTL, `alignOffset`, widths, motion geometry |
40
+ | `popover.variants.ts` | The slotted `tv()` — `scrim`, `dismissLayer`, `content`, `arrow`, `title`, `description`, `close` — and the numeric constants |
41
+ | `popover.variants.test.ts` | Slots, `isUnstyled`, tokens in both themes, the arrow inset against `--radius` |
42
+ | `use-anchor-measure.ts` | **Leaf.** `useAnchorMeasure` — `measureInWindow` on enable and on window-size change |
43
+ | `use-anchored-content.ts` | **Leaf.** `useAnchoredContent` — the off-screen measure frame, the resolve, presence, the animated style |
44
+ | `popover-arrow.tsx` | **Leaf.** `AnchoredArrow` (placement passed in) and `Popover.Arrow` (placement read from the panel) |
45
+ | `popover-trigger.tsx` | `Popover.Trigger` — `Pressable`, or `asChild` to donate the press; the default anchor |
46
+ | `popover-anchor.tsx` | `Popover.Anchor` — a non-collapsing `View`, or `asChild` |
47
+ | `popover-content.tsx` | `Popover.Content` — the portal, the dismiss layer or scrim, the panel |
48
+ | `popover-title.tsx` | `Popover.Title` — `Text.Label` as a header, the panel's label |
49
+ | `popover-description.tsx` | `Popover.Description` — `Text.Caption` |
50
+ | `popover-close.tsx` | `Popover.Close` — the corner ✕, or `asChild` around a button |
51
+
52
+ ## The anchored leaves — Tooltip imports them
53
+
54
+ Tooltip, and later Menu, Context Menu and Select, are anchored panels too. They import
55
+ `popover.position.ts`, `use-anchor-measure.ts`, `use-anchored-content.ts` and `popover-arrow.tsx`
56
+ **directly**, never `./popover` or `./index` — package rule 3's leaf exception. **None of the four
57
+ may import `./popover`, `./index` or any part file.** `popover-arrow.tsx` reads
58
+ `popover.context.tsx`, itself a leaf; the other three import only `popover.position.ts`, the
59
+ variants and the overlay foundation. Break that and a Tooltip import closes a cycle Metro serves
60
+ half-initialised.
61
+
62
+ ## Design
63
+
64
+ - **Placement is a preference, and the rules are a test each.** `resolveAnchoredPosition` keeps
65
+ the preferred side, and flips to the opposite **only** when the preferred side cannot hold the
66
+ panel *and* the opposite has more room — a panel that fits nowhere does not ping-pong to a side
67
+ that is just as bad. Then it aligns along the cross axis, shifts to stay inside
68
+ `window − safe area − 8pt`, and clamps both axes, so it is never off-screen. `maxHeight` is
69
+ `min(maxHeight, room on the resolved side)`, which is what keeps a tall panel on screen.
70
+ - **`align` is logical on top and bottom.** Under RTL `start` is the right edge; on a side
71
+ placement it is always the top. `alignOffset` nudges inward from whichever edge is aligned.
72
+ - **The arrow points at the anchor's centre, not the panel's.** After a shift the panel is no
73
+ longer centred on its trigger; the arrow still is, clamped `POPOVER_ARROW_INSET` clear of each
74
+ corner so it never hangs off the curve. The inset is the card radius plus the arrow's
75
+ half-diagonal, pinned against `tokens.css`'s `--radius` by a test.
76
+ - **The arrow is a square turned 45°, bordered on two edges.** `resolveArrowFrame` turns its
77
+ bordered corner toward the anchor in each placement, so the two bordered edges are the ones
78
+ outside the panel and the bare inner half covers the panel's own border where they meet.
79
+ - **Measured in window coordinates, placed by translate.** `measureInWindow` is what makes a
80
+ trigger inside a `ScrollView`, a bottom sheet or under the header land the panel in the right
81
+ place — the panel is teleported to a host that fills the window. The panel sits at the window's
82
+ origin and moves by `translateX/Y`, never `left`/`top`: a translate is not layout, so moving it
83
+ never re-wraps a content-fit panel and never fires another `onLayout`.
84
+ - **The panel sits inside a positioner, and the safe-span cap is on the positioner.** The
85
+ positioner is measured, translated and animated, and carries `maxWidth` = the safe span; the
86
+ panel inside it carries the classes and the resolved width and `maxHeight`. An inline
87
+ `maxWidth` on the panel itself beat every class, so a caller's `max-w-64` was silently ignored
88
+ — found on a simulator with the `arrow` demo.
89
+ - **One invisible frame before the entrance.** The panel mounts at opacity 0, reports its size,
90
+ is resolved, and only then is presence told to open — so the entrance always starts from the
91
+ right side and the right place. Presence follows `isOpen && position !== null`; the exit does
92
+ not wait for anything.
93
+ - **The entrance comes from the anchor.** A 6pt slide back toward the anchor and a `0.96 → 1`
94
+ scale about the arrow (`transformOrigin`), driven by presence's `progress`. Under reduce motion
95
+ both collapse and only the fade runs. A window-size change — a rotation — re-measures the anchor
96
+ a frame later and moves the panel there directly, with no animation.
97
+ - **The keyboard is the bottom of the screen.** `useAnchoredContent` tracks keyboard-controller's
98
+ show/hide events and takes `max(safe bottom, keyboard height)` as the bottom inset, so a field
99
+ inside the panel flips it above its trigger instead of typing under the keyboard.
100
+ - **Outside taps close, and are swallowed.** Under the panel sits an invisible absolute-fill
101
+ React Native `Pressable` — or `Overlay.Scrim` with `hasScrim` — that closes on a tap when
102
+ dismissible. It never passes the tap through: the mobile convention, and the reason a tap
103
+ outside never presses the button under it. With `isDismissible={false}` it still takes the
104
+ touch and does nothing, for the scrim's reason.
105
+ - **`width`.** `"trigger"` is the anchor's measured width, `"full"` the safe span, a number as
106
+ given, each raised to `minWidth` and capped at the safe span; `"content-fit"` (the default)
107
+ lets the content size itself, with `minWidth` as a floor and the safe span as `maxWidth`.
108
+ - **`isScrollable` wraps the body in a `ScrollView`, and lifts `Popover.Arrow` out of it** —
109
+ detected by element type — so the arrow is never scrolled or clipped. Without it a body taller
110
+ than `maxHeight` is cut off.
111
+ - **`isUnstyled` strips the surface, the border, the corner and the padding** of the panel and the
112
+ arrow's paint, keeping the gap. `background` is drawn absolutely behind the content, for a
113
+ caller painting their own surface.
114
+ - **Triggers donate the press.** `Popover.Trigger asChild` hands the toggle to the child's
115
+ `onPress` and composes the measuring ref onto the child's, for `BottomSheet.Trigger`'s reason —
116
+ so the child must be built on `Pressable`. It reports `accessibilityState.expanded`.
117
+ - **`Popover.Anchor` takes the measuring from the trigger while it is mounted.** The root keeps one
118
+ `useAnchorMeasure` per candidate and enables only the one in use; the trigger still opens the
119
+ panel and still takes focus back.
120
+ - **Modal for assistive technology.** The panel is `accessibilityViewIsModal`, `role="dialog"`,
121
+ labelled by the title's `nativeID`; on entry focus goes to the title (or the panel), on exit back
122
+ to the trigger, and `onAccessibilityEscape` closes it. Android back closes it while it is the top
123
+ overlay (`useOverlayBackHandler`).
124
+ - **Draws in the `anchored` band.** Over every sheet and over the dialog it was opened from — the
125
+ foundation's z-order.
126
+
127
+ ## Out of scope
128
+
129
+ - A sheet presentation on small screens — compose `BottomSheet` instead.
130
+ - A frosted backdrop — needs `expo-blur`, not a peer.
131
+ - Following an anchor that scrolls while the panel is open — the panel stays where it opened.
132
+
133
+ ## Testing
134
+
135
+ `bun test` reaches the position resolver and the variants. Everything else — the measure frame,
136
+ the motion, the keyboard, dismissal, focus — is verified on a simulator through
137
+ `apps/playground`'s `/popover` gallery, whose `edge-collision` demo puts triggers in the four
138
+ corners to prove flip and shift.
@@ -0,0 +1,48 @@
1
+ export { Popover, type PopoverProps } from "./popover";
2
+ export {
3
+ PopoverContext,
4
+ type PopoverContextValue,
5
+ usePopover,
6
+ } from "./popover.context";
7
+ export {
8
+ type AnchoredBounds,
9
+ type AnchoredInput,
10
+ type AnchoredInsets,
11
+ type AnchoredPosition,
12
+ type AnchoredSize,
13
+ type AnchorRect,
14
+ type PopoverAlign,
15
+ type PopoverPlacement,
16
+ type PopoverWidth,
17
+ type ResolvedPopoverWidth,
18
+ resolveAnchoredPosition,
19
+ resolveArrowFrame,
20
+ resolveEnterTranslate,
21
+ resolvePopoverWidth,
22
+ resolveTransformOrigin,
23
+ } from "./popover.position";
24
+ export {
25
+ POPOVER_ARROW_INSET,
26
+ POPOVER_ARROW_SIZE,
27
+ POPOVER_CLOSE_HIT_SLOP,
28
+ POPOVER_COLLISION_PADDING,
29
+ POPOVER_DEFAULTS,
30
+ POPOVER_ENTER_DISTANCE,
31
+ POPOVER_ENTER_SCALE,
32
+ type PopoverVariantProps,
33
+ popoverVariants,
34
+ } from "./popover.variants";
35
+ export type { PopoverAnchorProps } from "./popover-anchor";
36
+ export type { AnchoredArrowProps, PopoverArrowProps } from "./popover-arrow";
37
+ export type { PopoverCloseProps } from "./popover-close";
38
+ export type { PopoverContentProps } from "./popover-content";
39
+ export type { PopoverDescriptionProps } from "./popover-description";
40
+ export type { PopoverTitleProps } from "./popover-title";
41
+ export type { PopoverTriggerProps } from "./popover-trigger";
42
+ export {
43
+ type AnchorMeasure,
44
+ type MeasurableNode,
45
+ type UseAnchorMeasureOptions,
46
+ useAnchorMeasure,
47
+ } from "./use-anchor-measure";
48
+ export { type AnchoredContent, type UseAnchoredContentOptions, useAnchoredContent } from "./use-anchored-content";
@@ -0,0 +1,49 @@
1
+ import { type ReactElement, useEffect } from "react";
2
+ import { View, type ViewProps } from "react-native";
3
+ import { Slot } from "../../lib/slot";
4
+ import { usePopoverContext } from "./popover.context";
5
+
6
+ export type PopoverAnchorProps = ViewProps & {
7
+ className?: string;
8
+ /** Anchor to the single child itself rather than a wrapping view. */
9
+ asChild?: boolean;
10
+ };
11
+
12
+ /**
13
+ * Anchors the panel to a different view than the trigger — a whole row whose
14
+ * trailing button opens it, a field whose icon does.
15
+ *
16
+ * While it is mounted the panel measures this instead of the trigger; the
17
+ * trigger still opens it and still takes focus back on close. Without
18
+ * `asChild` it is a plain `View` that never collapses, so it always has a
19
+ * native frame to measure.
20
+ *
21
+ * @example
22
+ * <Popover>
23
+ * <Popover.Anchor className="flex-row items-center gap-2">
24
+ * <Input value={tag} />
25
+ * <Popover.Trigger asChild><Button size="icon-md" variant="ghost">…</Button></Popover.Trigger>
26
+ * </Popover.Anchor>
27
+ * <Popover.Content width="trigger">…</Popover.Content>
28
+ * </Popover>
29
+ */
30
+ export function PopoverAnchor({ asChild = false, children, ...props }: PopoverAnchorProps): ReactElement {
31
+ const { anchorRef, registerAnchor } = usePopoverContext();
32
+
33
+ useEffect(() => registerAnchor(), [registerAnchor]);
34
+
35
+ if (asChild) {
36
+ return (
37
+ <Slot {...props} ref={anchorRef}>
38
+ {children}
39
+ </Slot>
40
+ );
41
+ }
42
+
43
+ return (
44
+ <View collapsable={false} {...props} ref={anchorRef}>
45
+ {children}
46
+ </View>
47
+ );
48
+ }
49
+ PopoverAnchor.displayName = "DelacourUI.Popover.Anchor";
@@ -0,0 +1,89 @@
1
+ import type { ReactElement } from "react";
2
+ import { View } from "react-native";
3
+ import { useOptionalPopoverContent } from "./popover.context";
4
+ import { type AnchoredSize, type PopoverPlacement, resolveArrowFrame } from "./popover.position";
5
+ import { POPOVER_ARROW_SIZE, popoverVariants } from "./popover.variants";
6
+
7
+ export type AnchoredArrowProps = {
8
+ /** The side the panel was placed on — the arrow sits on the edge facing the anchor. */
9
+ placement: PopoverPlacement;
10
+ /** From `resolveAnchoredPosition`: along that edge, toward the anchor's centre. */
11
+ arrowOffset: number;
12
+ /** The panel's measured size. */
13
+ size: AnchoredSize;
14
+ /** Strips the arrow's fill and border, for a panel drawn with its own `background`. */
15
+ isUnstyled?: boolean;
16
+ className?: string;
17
+ };
18
+
19
+ /**
20
+ * A square turned 45°, half of it past the panel's edge, pointing at the anchor.
21
+ *
22
+ * It wears the panel's fill and the panel's border on its two outer edges, and
23
+ * its inner half lies over the panel — covering the panel's own border where
24
+ * the two meet, so the arrow reads as part of the panel rather than a tab stuck
25
+ * to it. `resolveArrowFrame` turns it per placement.
26
+ *
27
+ * Hidden from assistive technology: it is decoration.
28
+ *
29
+ * **A leaf.** Tooltip imports it with its own placement; it imports nothing
30
+ * from `./popover` or `./index`.
31
+ */
32
+ export function AnchoredArrow({
33
+ placement,
34
+ arrowOffset,
35
+ size,
36
+ isUnstyled = false,
37
+ className,
38
+ }: AnchoredArrowProps): ReactElement {
39
+ const frame = resolveArrowFrame(placement, arrowOffset, size, POPOVER_ARROW_SIZE);
40
+
41
+ return (
42
+ <View
43
+ accessibilityElementsHidden
44
+ accessible={false}
45
+ className={popoverVariants({ isUnstyled }).arrow({ className })}
46
+ importantForAccessibility="no-hide-descendants"
47
+ pointerEvents="none"
48
+ style={{
49
+ left: frame.left,
50
+ top: frame.top,
51
+ width: POPOVER_ARROW_SIZE,
52
+ height: POPOVER_ARROW_SIZE,
53
+ transform: [{ rotate: `${frame.rotate}deg` }],
54
+ }}
55
+ />
56
+ );
57
+ }
58
+ AnchoredArrow.displayName = "DelacourUI.Popover.AnchoredArrow";
59
+
60
+ export type PopoverArrowProps = {
61
+ className?: string;
62
+ };
63
+
64
+ /**
65
+ * The panel's arrow. Write it anywhere inside `Popover.Content` — it reads the
66
+ * resolved placement and offset from the panel, follows a flip, and is lifted
67
+ * out of a scrollable body so it is never scrolled or clipped.
68
+ *
69
+ * @example
70
+ * <Popover.Content>
71
+ * <Popover.Arrow />
72
+ * <Popover.Title>Rename</Popover.Title>
73
+ * </Popover.Content>
74
+ */
75
+ export function PopoverArrow({ className }: PopoverArrowProps): ReactElement | null {
76
+ const content = useOptionalPopoverContent();
77
+ if (content === null) return null;
78
+
79
+ return (
80
+ <AnchoredArrow
81
+ arrowOffset={content.arrowOffset}
82
+ className={className}
83
+ isUnstyled={content.isUnstyled}
84
+ placement={content.placement}
85
+ size={content.size}
86
+ />
87
+ );
88
+ }
89
+ PopoverArrow.displayName = "DelacourUI.Popover.Arrow";
@@ -0,0 +1,73 @@
1
+ import type { ReactElement } from "react";
2
+ import { IconCrossSmall } from "../../icons/central";
3
+ import { Slot } from "../../lib/slot";
4
+ import { Icon } from "../icon";
5
+ import { Pressable, type PressableProps } from "../pressable";
6
+ import { usePopoverContext } from "./popover.context";
7
+ import { POPOVER_CLOSE_HIT_SLOP, popoverVariants } from "./popover.variants";
8
+
9
+ export type PopoverCloseProps =
10
+ | ({ asChild: true; children: ReactElement } & Omit<PressableProps, "asChild" | "children">)
11
+ | ({ asChild?: false; accessibilityLabel?: string } & Omit<PressableProps, "asChild" | "children">);
12
+
13
+ /**
14
+ * Closes the popover.
15
+ *
16
+ * Without `asChild` it is the ✕ in the panel's top-right corner: out of the
17
+ * flow, `fade` feedback and an 8pt slop for `BottomSheet.Close`'s reasons, and
18
+ * `Popover.Title` reserves its clearance. With `asChild` it donates the close to
19
+ * its child's `onPress` — a "Done" button in the panel's footer.
20
+ *
21
+ * @example
22
+ * <Popover.Close />
23
+ *
24
+ * @example
25
+ * <Popover.Close asChild>
26
+ * <Button size="sm">Save</Button>
27
+ * </Popover.Close>
28
+ */
29
+ export function PopoverClose(props: PopoverCloseProps): ReactElement {
30
+ const { close } = usePopoverContext();
31
+
32
+ if (props.asChild) {
33
+ const { asChild: _asChild, children, onPress, ...rest } = props;
34
+ const press = () => {
35
+ close();
36
+ onPress?.();
37
+ };
38
+ return (
39
+ <Slot {...rest} onPress={press}>
40
+ {children}
41
+ </Slot>
42
+ );
43
+ }
44
+
45
+ const {
46
+ asChild: _asChild,
47
+ accessibilityLabel = "Close",
48
+ className,
49
+ feedback = "fade",
50
+ hitSlop = POPOVER_CLOSE_HIT_SLOP,
51
+ onPress,
52
+ ...rest
53
+ } = props;
54
+ const press = () => {
55
+ close();
56
+ onPress?.();
57
+ };
58
+
59
+ return (
60
+ <Pressable
61
+ accessibilityLabel={accessibilityLabel}
62
+ accessibilityRole="button"
63
+ className={popoverVariants().close({ className })}
64
+ feedback={feedback}
65
+ hitSlop={hitSlop}
66
+ onPress={press}
67
+ {...rest}
68
+ >
69
+ <Icon color="muted-foreground" icon={IconCrossSmall} />
70
+ </Pressable>
71
+ );
72
+ }
73
+ PopoverClose.displayName = "DelacourUI.Popover.Close";
@@ -0,0 +1,235 @@
1
+ import {
2
+ Children,
3
+ isValidElement,
4
+ type ReactElement,
5
+ type ReactNode,
6
+ useCallback,
7
+ useEffect,
8
+ useId,
9
+ useMemo,
10
+ useRef,
11
+ } from "react";
12
+ import { AccessibilityInfo, Pressable as NativePressable, ScrollView, View, type ViewProps } from "react-native";
13
+ import Animated from "react-native-reanimated";
14
+ import { Overlay, useOverlayBackHandler } from "../overlay";
15
+ import { PopoverContentContext, type PopoverContentContextValue, usePopoverContext } from "./popover.context";
16
+ import type { PopoverAlign, PopoverPlacement, PopoverWidth } from "./popover.position";
17
+ import {
18
+ POPOVER_ARROW_INSET,
19
+ POPOVER_COLLISION_PADDING,
20
+ POPOVER_DEFAULTS,
21
+ POPOVER_ENTER_DISTANCE,
22
+ POPOVER_ENTER_SCALE,
23
+ popoverVariants,
24
+ } from "./popover.variants";
25
+ import { PopoverArrow } from "./popover-arrow";
26
+ import type { MeasurableNode } from "./use-anchor-measure";
27
+ import { useAnchoredContent } from "./use-anchored-content";
28
+
29
+ export type PopoverContentProps = ViewProps & {
30
+ className?: string;
31
+ /** The side of the anchor the panel prefers. It flips when that side lacks room. Default `"bottom"`. */
32
+ placement?: PopoverPlacement;
33
+ /** Which edges line up along the cross axis; logical under RTL. Default `"center"`. */
34
+ align?: PopoverAlign;
35
+ /** The gap between anchor and panel, in points. Default 8. */
36
+ offset?: number;
37
+ /** A nudge along the cross axis, inward from the aligned edge. Default 0. */
38
+ alignOffset?: number;
39
+ /** A number, the anchor's width, the content's own, or the safe span. Default `"content-fit"`. */
40
+ width?: PopoverWidth;
41
+ minWidth?: number;
42
+ /** Clamped to the room on the resolved side. */
43
+ maxHeight?: number;
44
+ /** Scroll the body when it is taller than `maxHeight`. Default false. */
45
+ isScrollable?: boolean;
46
+ /** No surface, border, corner or padding — draw your own with `background`. */
47
+ isUnstyled?: boolean;
48
+ /** Drawn behind the content, filling the panel. */
49
+ background?: ReactNode;
50
+ /** Dim the app behind the panel. Default false — an outside tap still closes it. */
51
+ hasScrim?: boolean;
52
+ scrimClassName?: string;
53
+ };
54
+
55
+ /** Pulls `Popover.Arrow` out of the body, so a scrolling body never scrolls or clips it. */
56
+ function partitionArrow(children: ReactNode): { arrows: ReactNode[]; body: ReactNode[] } {
57
+ const arrows: ReactNode[] = [];
58
+ const body: ReactNode[] = [];
59
+ Children.forEach(children, (child) => {
60
+ if (isValidElement(child) && child.type === PopoverArrow) arrows.push(child);
61
+ else body.push(child);
62
+ });
63
+ return { arrows, body };
64
+ }
65
+
66
+ /**
67
+ * The panel, drawn over the app and anchored to the trigger.
68
+ *
69
+ * Renders nothing while closed. On open it mounts in the `anchored` band of
70
+ * the overlay z-order — above every sheet and dialog — measures itself
71
+ * invisibly, resolves where it fits, and enters from its resolved side. The
72
+ * placement is a preference: the panel flips across the anchor when that side
73
+ * lacks room and slides along it to stay inside the safe area and above the
74
+ * keyboard.
75
+ *
76
+ * Under the panel, an invisible layer the size of the screen — or the scrim,
77
+ * with `hasScrim` — closes the popover on a tap when it is dismissible. It
78
+ * swallows the tap rather than passing it through to what is under it. Android
79
+ * back closes it while it is the top overlay.
80
+ *
81
+ * The panel is a modal view labelled by its title: VoiceOver focus is held
82
+ * inside it, moves to the title (or the panel) on entry and back to the
83
+ * trigger on exit, and the escape gesture closes it.
84
+ *
85
+ * @example
86
+ * <Popover.Content align="start" width="trigger" minWidth={260}>
87
+ * <Popover.Arrow />
88
+ * <Popover.Title>Rename</Popover.Title>
89
+ * <Input value={name} onChangeText={setName} />
90
+ * </Popover.Content>
91
+ */
92
+ export function PopoverContent({
93
+ children,
94
+ className,
95
+ placement = POPOVER_DEFAULTS.placement,
96
+ align = POPOVER_DEFAULTS.align,
97
+ offset = POPOVER_DEFAULTS.offset,
98
+ alignOffset = POPOVER_DEFAULTS.alignOffset,
99
+ width = POPOVER_DEFAULTS.width,
100
+ minWidth,
101
+ maxHeight,
102
+ isScrollable = false,
103
+ isUnstyled = false,
104
+ background,
105
+ hasScrim = false,
106
+ scrimClassName,
107
+ style,
108
+ ...props
109
+ }: PopoverContentProps): ReactElement | null {
110
+ const { isOpen, close, isDismissible, anchorRect, triggerNode, onPlaced } = usePopoverContext();
111
+ const overlayId = useId();
112
+ const titleId = useId();
113
+ const panelNode = useRef<MeasurableNode | null>(null);
114
+ const titleNode = useRef<MeasurableNode | null>(null);
115
+
116
+ const focusIn = useCallback(() => {
117
+ const target = titleNode.current ?? panelNode.current;
118
+ if (target !== null) AccessibilityInfo.sendAccessibilityEvent(target, "focus");
119
+ }, []);
120
+
121
+ const focusBack = useCallback(() => {
122
+ if (triggerNode.current !== null) AccessibilityInfo.sendAccessibilityEvent(triggerNode.current, "focus");
123
+ }, [triggerNode]);
124
+
125
+ const anchored = useAnchoredContent({
126
+ isOpen,
127
+ anchor: anchorRect,
128
+ placement,
129
+ align,
130
+ offset,
131
+ alignOffset,
132
+ collisionPadding: POPOVER_COLLISION_PADDING,
133
+ arrowInset: POPOVER_ARROW_INSET,
134
+ width,
135
+ minWidth,
136
+ maxHeight,
137
+ enterDistance: POPOVER_ENTER_DISTANCE,
138
+ enterScale: POPOVER_ENTER_SCALE,
139
+ onEntered: focusIn,
140
+ onExited: focusBack,
141
+ });
142
+
143
+ const resolvedPlacement = anchored.position?.placement ?? placement;
144
+ useEffect(() => onPlaced(resolvedPlacement), [onPlaced, resolvedPlacement]);
145
+
146
+ const dismiss = useCallback(() => {
147
+ if (isDismissible) close();
148
+ }, [isDismissible, close]);
149
+
150
+ useOverlayBackHandler({ id: overlayId, isEnabled: anchored.isMounted && isDismissible, onBack: close });
151
+
152
+ const titleRef = useCallback((node: MeasurableNode | null) => {
153
+ titleNode.current = node;
154
+ }, []);
155
+ const panelRef = useCallback((node: MeasurableNode | null) => {
156
+ panelNode.current = node;
157
+ }, []);
158
+
159
+ const contentContext = useMemo<PopoverContentContextValue>(
160
+ () => ({
161
+ placement: resolvedPlacement,
162
+ arrowOffset: anchored.position?.arrowOffset ?? 0,
163
+ size: anchored.size ?? { width: 0, height: 0 },
164
+ isUnstyled,
165
+ titleId,
166
+ titleRef,
167
+ }),
168
+ [resolvedPlacement, anchored.position?.arrowOffset, anchored.size, isUnstyled, titleId, titleRef]
169
+ );
170
+
171
+ if (!anchored.isMounted) return null;
172
+
173
+ const slots = popoverVariants({ isUnstyled });
174
+ const { arrows, body } = partitionArrow(children);
175
+
176
+ // The catcher is written before the panel, so view order gives a touch on
177
+ // the panel to the panel; it is React Native's own Pressable for the scrim's
178
+ // reason — no feedback, no haptic, no gesture to race.
179
+ return (
180
+ <Overlay.Portal id={overlayId} layer="anchored">
181
+ {hasScrim ? (
182
+ <Overlay.Scrim
183
+ className={slots.scrim({ className: scrimClassName })}
184
+ onDismiss={dismiss}
185
+ progress={anchored.presence.progress}
186
+ />
187
+ ) : (
188
+ <NativePressable
189
+ accessibilityElementsHidden
190
+ accessible={false}
191
+ className={slots.dismissLayer()}
192
+ importantForAccessibility="no-hide-descendants"
193
+ onPress={dismiss}
194
+ />
195
+ )}
196
+ <Animated.View
197
+ onLayout={anchored.onLayout}
198
+ pointerEvents="box-none"
199
+ style={[anchored.positionerStyle, anchored.animatedStyle]}
200
+ >
201
+ <View
202
+ accessibilityLabelledBy={titleId}
203
+ accessibilityViewIsModal
204
+ className={slots.content({ className })}
205
+ onAccessibilityEscape={isDismissible ? close : undefined}
206
+ ref={panelRef}
207
+ role="dialog"
208
+ style={[anchored.frameStyle, style]}
209
+ {...props}
210
+ >
211
+ <PopoverContentContext.Provider value={contentContext}>
212
+ {background === undefined ? null : (
213
+ <View className="absolute inset-0" pointerEvents="none">
214
+ {background}
215
+ </View>
216
+ )}
217
+ {isScrollable ? (
218
+ <ScrollView
219
+ className="shrink grow-0"
220
+ contentContainerClassName="gap-2"
221
+ keyboardShouldPersistTaps="handled"
222
+ >
223
+ {body}
224
+ </ScrollView>
225
+ ) : (
226
+ body
227
+ )}
228
+ {arrows}
229
+ </PopoverContentContext.Provider>
230
+ </View>
231
+ </Animated.View>
232
+ </Overlay.Portal>
233
+ );
234
+ }
235
+ PopoverContent.displayName = "DelacourUI.Popover.Content";
@@ -0,0 +1,18 @@
1
+ import type { ReactElement } from "react";
2
+ import { Text, type TextPresetProps } from "../text";
3
+ import { popoverVariants } from "./popover.variants";
4
+
5
+ export type PopoverDescriptionProps = TextPresetProps;
6
+
7
+ /**
8
+ * Supporting copy under the title — a `Text.Caption`, so it sits on the muted
9
+ * token at the label's size and reads as the title's explanation rather than a
10
+ * second heading.
11
+ *
12
+ * @example
13
+ * <Popover.Description>Shown to everyone in the workspace.</Popover.Description>
14
+ */
15
+ export function PopoverDescription({ className, ...props }: PopoverDescriptionProps): ReactElement {
16
+ return <Text.Caption className={popoverVariants().description({ className })} {...props} />;
17
+ }
18
+ PopoverDescription.displayName = "DelacourUI.Popover.Description";