@delacour/react-native-ui 0.1.0-alpha.20261007130133 → 0.1.0-alpha.20261009010458

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/package.json +5 -3
  2. package/src/components/menu/AGENTS.md +172 -0
  3. package/src/components/menu/index.ts +44 -0
  4. package/src/components/menu/menu-background.tsx +17 -0
  5. package/src/components/menu/menu-checkbox-item.tsx +57 -0
  6. package/src/components/menu/menu-content.tsx +286 -0
  7. package/src/components/menu/menu-item.tsx +162 -0
  8. package/src/components/menu/menu-label.tsx +14 -0
  9. package/src/components/menu/menu-radio-group.tsx +50 -0
  10. package/src/components/menu/menu-radio-item.tsx +57 -0
  11. package/src/components/menu/menu-separator.tsx +11 -0
  12. package/src/components/menu/menu-sub-content.tsx +72 -0
  13. package/src/components/menu/menu-sub-trigger.tsx +50 -0
  14. package/src/components/menu/menu-sub.tsx +75 -0
  15. package/src/components/menu/menu-trigger.tsx +57 -0
  16. package/src/components/menu/menu.context.tsx +214 -0
  17. package/src/components/menu/menu.tsx +88 -0
  18. package/src/components/menu/menu.types.ts +33 -0
  19. package/src/components/menu/menu.variants.test.ts +393 -0
  20. package/src/components/menu/menu.variants.ts +351 -0
  21. package/src/components/stack-card/AGENTS.md +169 -0
  22. package/src/components/stack-card/index.ts +37 -0
  23. package/src/components/stack-card/stack-card-action.tsx +66 -0
  24. package/src/components/stack-card/stack-card-actions.tsx +18 -0
  25. package/src/components/stack-card/stack-card-card.tsx +71 -0
  26. package/src/components/stack-card/stack-card-empty.tsx +30 -0
  27. package/src/components/stack-card/stack-card-slot.tsx +90 -0
  28. package/src/components/stack-card/stack-card-stamp.tsx +76 -0
  29. package/src/components/stack-card/stack-card.context.tsx +117 -0
  30. package/src/components/stack-card/stack-card.tsx +557 -0
  31. package/src/components/stack-card/stack-card.types.ts +26 -0
  32. package/src/components/stack-card/stack-card.variants.test.ts +355 -0
  33. package/src/components/stack-card/stack-card.variants.ts +360 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@delacour/react-native-ui",
3
- "version": "0.1.0-alpha.20261007130133",
3
+ "version": "0.1.0-alpha.20261009010458",
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
  "./kpi": "./src/components/kpi/index.ts",
57
57
  "./label": "./src/components/label/index.ts",
58
58
  "./list-group": "./src/components/list-group/index.ts",
59
+ "./menu": "./src/components/menu/index.ts",
59
60
  "./meter": "./src/components/meter/index.ts",
60
61
  "./overlay": "./src/components/overlay/index.ts",
61
62
  "./popover": "./src/components/popover/index.ts",
@@ -69,6 +70,7 @@
69
70
  "./skeleton": "./src/components/skeleton/index.ts",
70
71
  "./slider": "./src/components/slider/index.ts",
71
72
  "./spinner": "./src/components/spinner/index.ts",
73
+ "./stack-card": "./src/components/stack-card/index.ts",
72
74
  "./steps": "./src/components/steps/index.ts",
73
75
  "./surface": "./src/components/surface/index.ts",
74
76
  "./swipe": "./src/components/swipe/index.ts",
@@ -109,8 +111,8 @@
109
111
  },
110
112
  "peerDependencies": {
111
113
  "@central-icons-react-native/round-outlined-radius-1-stroke-1.5": "^1.1",
112
- "@delacour/react-native-bottom-sheet": "0.1.0-alpha.20261007130133",
113
- "@delacour/react-native-charts": "0.1.0-alpha.20261007130133",
114
+ "@delacour/react-native-bottom-sheet": "0.1.0-alpha.20261009010458",
115
+ "@delacour/react-native-charts": "0.1.0-alpha.20261009010458",
114
116
  "@legendapp/list": ">=3.3",
115
117
  "expo-linear-gradient": ">=15",
116
118
  "expo-router": ">=57",
@@ -0,0 +1,172 @@
1
+ # Menu
2
+
3
+ A list of **verbs** dropped from a control that exists to open it — a ⋯ button,
4
+ a toolbar item. Rename, duplicate, share, delete. A menu is not a select: its
5
+ rows run actions, they never become the trigger's value. Compound root plus
6
+ `Menu.Trigger`, `Menu.Content`, `Menu.Background`, `Menu.Label`, `Menu.Item`,
7
+ `Menu.CheckboxItem`, `Menu.RadioGroup`, `Menu.RadioItem`, `Menu.Separator`,
8
+ `Menu.Sub`, `Menu.SubTrigger` and `Menu.SubContent`.
9
+
10
+ `import { Menu } from "@delacour/react-native-ui/menu";`
11
+
12
+ ## Files
13
+
14
+ | File | What it holds |
15
+ | --- | --- |
16
+ | `index.ts` | → `@delacour/react-native-ui/menu` |
17
+ | `menu.tsx` | Root — `useMenuRootValue` into `MenuRootProvider`; the `Object.assign` |
18
+ | `menu-trigger.tsx` | `Menu.Trigger` — measures itself and toggles; `asChild` donates the press |
19
+ | `menu-content.tsx` | `Menu.Content` — the transparent `Modal`, the outside-tap layer, the placed and animated panel, the scroller |
20
+ | `menu-background.tsx` | `Menu.Background` — the `bg-popover` surface behind the scroller |
21
+ | `menu-label.tsx` | `Menu.Label` — a section heading |
22
+ | `menu-item.tsx` | `MenuRow`, the row every row is built on, and `Menu.Item` |
23
+ | `menu-checkbox-item.tsx` | `Menu.CheckboxItem` |
24
+ | `menu-radio-group.tsx` | `Menu.RadioGroup` — holds the value |
25
+ | `menu-radio-item.tsx` | `Menu.RadioItem` |
26
+ | `menu-separator.tsx` | `Menu.Separator` — `Separator` with `my-1` |
27
+ | `menu-sub.tsx` | `Menu.Sub` — the submenu's open state, `progress` and measured height |
28
+ | `menu-sub-trigger.tsx` | `Menu.SubTrigger` — the row with the turning chevron |
29
+ | `menu-sub-content.tsx` | `Menu.SubContent` — the measured clip |
30
+ | `menu.context.tsx` | The menu, submenu and radio-group contexts; `useMenu`, `useMenuRootValue`, `MenuRootProvider` |
31
+ | `menu.types.ts` | `MenuItemProps`, shared by every row |
32
+ | `menu.variants.ts` | The slotted `tv()`, the constants and the pure resolvers — no RN imports |
33
+ | `menu.variants.test.ts` | Placement (flip, cap, clamp, RTL, the zero-size anchor), width, close rules, accessibility, tokens |
34
+
35
+ ## The panel
36
+
37
+ - **It is a minimal anchored panel built here, not a Popover.** There is no
38
+ Popover in the package yet. `Menu.Content` renders inside a transparent RN
39
+ `Modal` — top-most layer, Android back through `onRequestClose`, screen-reader
40
+ containment — so it needs no new peer. **When Popover lands, Menu.Content moves
41
+ onto it and `resolveMenuPlacement` moves with it.** The resolver is exported
42
+ for exactly that.
43
+ - **Placement takes a rect, never a ref.** `resolveMenuPlacement({ anchor, content,
44
+ window, insets, placement, align, offset, isRTL })` places against
45
+ `{ x, y, width, height }`, so a zero-size point — ContextMenu's long-press —
46
+ goes through the same arithmetic with no special case, and a test proves it.
47
+ - **Flip, then cap.** The requested side wins when the content fits; otherwise
48
+ the other side if it fits there; otherwise the larger side, and `maxHeight`
49
+ caps it so the rows scroll. Room is measured inside the safe area plus an 8pt
50
+ margin, and the panel is clamped horizontally inside the same bounds. `align`
51
+ start and end swap under RTL.
52
+ - **Measure, place, show.** The panel's first frame is at opacity 0; the
53
+ scroller's `onContentSizeChange` reports the rows' height, placement runs with
54
+ it, and only then does the enter start — there is no jump.
55
+ - **The side is locked for the visit.** A submenu expanding mid-visit must not
56
+ flip the panel under the finger. A panel above the anchor is positioned by
57
+ its **bottom** edge (`MenuPlacementResult.bottom`), so it grows upward, away
58
+ from the anchor, with nothing re-placed; past the cap the rows scroll.
59
+ - **Its surface is `bg-popover`, never `bg-overlay`.** `overlay` is the
60
+ translucent scrim colour (`oklch(0 0 0 / 45%)`); a panel in it is a
61
+ see-through card. `hasScrim` (ContextMenu sets it) draws `bg-overlay` behind
62
+ the panel, fading with it.
63
+ - **The shadow is on an outer layer with no clip.** iOS clips a shadow to a
64
+ clipping view's bounds, so `panel` casts `shadow-lg` and the inner `content`
65
+ slot carries the corner, the hairline and `overflow-hidden`.
66
+ - **A caller's `Menu.Background` is lifted out by type** and drawn behind the
67
+ scroller, so it stays put while the rows scroll. The default is drawn when
68
+ there is none.
69
+ - **Enter 160ms, exit 120ms**, opacity with a 0.96 scale from the anchor's side.
70
+ React Native has no animatable transform origin on both platforms, so
71
+ `resolveMenuOrigin` approximates it with a translate that falls to zero as the
72
+ panel arrives. Under reduced motion it is opacity alone — the panel still
73
+ appears, so the information is not lost. The `Modal` unmounts when the exit's
74
+ timing finishes; a reopen mid-exit cancels it and runs the enter instead.
75
+ - **Gesture Handler needs its own root inside a `Modal`.** On Android a `Modal`
76
+ is a separate window the app's root view cannot see into, so every tap in the
77
+ panel would be dead. `Menu.Content` wraps its tree in a `GestureHandlerRootView`.
78
+
79
+ ## Rows
80
+
81
+ - **One row, `MenuRow`, under every row kind.** `Menu.Item`, `CheckboxItem`,
82
+ `RadioItem` and `SubTrigger` are `MenuRow` with a `kind`, a leading override
83
+ and an `accessibilityState`. Four hand-rolled rows would be four press fills
84
+ that drift.
85
+ - **The press fill is the row's background.** An absolute `bg-accent` layer
86
+ (`bg-destructive-soft` on a destructive row) fades in over 90ms and out over
87
+ 160ms while the row dips to 0.98 — one shared value for both. There is no
88
+ wash or ripple on top. A row is built on its own Gesture Handler tap rather
89
+ than `Pressable`, because `Pressable` owns its `pressed` value privately and
90
+ the fill has to read it.
91
+ - **Under reduced motion the fill snaps and the scale does not run.** The
92
+ scale is movement and goes; the fill is colour, and it is what says the tap
93
+ landed, so it stays — instantly.
94
+ - **A tap that travels is a scroll.** The tap fails past 10pt
95
+ (`MENU_TAP_MAX_DISTANCE`), so a long menu scrolls without choosing the row the
96
+ finger started on.
97
+ - **Rows take `ViewProps`, not `PressableProps`.** The spec sketched
98
+ `Omit<PressableProps, …>`, but a row ignores `feedback`, `asChild`, `busy` and
99
+ the rest; a type that accepts props it drops is a quiet bug. `isDisabled` is
100
+ the row's own.
101
+ - **Close rules live in one resolver.** `resolveCloseOnSelect(kind, explicit?)`:
102
+ item and radio close, checkbox and sub-trigger stay open; an explicit
103
+ `isClosedOnSelect` wins. `resolveRadioNext` pins that re-choosing a selected
104
+ radio keeps it — a radio never clears.
105
+ - **A checkbox's tick and a radio's mark take the leading icon column**, so the
106
+ labels of every row line up; an `icon` on those rows moves to the trailing
107
+ edge. `isInset` reserves the same column on a plain row, and on a `Menu.Label`.
108
+ - **The icon column is 18pt, `size-icon-md`.** The spec named `size-icon-sm`,
109
+ which is 16pt in `tokens.css`; the 18pt it also named wins, since that is the
110
+ footprint `isInset` and the label's `ps-[42px]` (12 + 18 + 12) are built on.
111
+ - **Haptics are the root's `haptic`, played on select**, from the tap's worklet,
112
+ and off by default.
113
+
114
+ ## Submenus
115
+
116
+ - **They expand in place, never fly out.** A phone has no room beside the
117
+ panel, and a second layer over the first hides where the user came from.
118
+ `Menu.SubContent` is `Collapsible.Content`'s measured clip — restated, not
119
+ imported, so `delacour add menu` copies one folder — and `MENU_SUB_SPRING` is
120
+ `Collapsible`'s spring.
121
+ - **The chevron turns 90° off the submenu's own `progress`**, so it and the
122
+ height cannot drift. Under RTL it is mirrored with `scaleX: -1` on a wrapper
123
+ view, so it points to the trailing edge and still turns downward; the
124
+ rotation lives on the inner view.
125
+ - **Nothing on the JS thread reads `contentHeight.value`** — the accordion's
126
+ Release-build bug. "Measured" is React state.
127
+
128
+ ## State
129
+
130
+ - **`isOpen` / `defaultOpen` / `onOpenChange` through `useControllableState`**,
131
+ in `useMenuRootValue`. `open(anchor?)` opens at a rect, or measures the
132
+ trigger first; `close()`; `toggle()`. Callbacks are ref-backed so the context
133
+ keeps its identity.
134
+ - **A controlled open is measured too.** A parent flipping `isOpen` to `true`
135
+ without a trigger press still gets the trigger measured, by an effect that
136
+ tells such an open from one `open()` started.
137
+ - **`Menu.Trigger asChild` donates the press**, `BottomSheet.Trigger`'s rule: a
138
+ `Button` wrapped in a pressable trigger would win the touch and the menu
139
+ would never open. The child gets `onPress`, the measured ref and the
140
+ accessibility props through `Slot`.
141
+
142
+ ## Accessibility
143
+
144
+ - Content: `accessibilityRole="menu"`, `accessibilityViewIsModal`.
145
+ - Rows: `accessibilityRole="menuitem"`; checkbox → `checked`, radio →
146
+ `selected`, sub-trigger → `expanded`, all → `disabled`. One source:
147
+ `resolveMenuItemAccessibility`, a discriminated union on `kind`.
148
+ - Trigger: `accessibilityState={{ expanded }}`, hint "Opens a menu".
149
+ - A closed submenu is out of the accessibility tree, not just clipped.
150
+
151
+ ## Reuse contract for ContextMenu
152
+
153
+ ContextMenu renders this component's rows and panel rather than a copy:
154
+
155
+ - **Import leaves, never `../menu`.** `../menu/menu-item` (`MenuRow`,
156
+ `MenuItem`), `../menu/menu-content`, `../menu/menu.context` and
157
+ `../menu/menu.variants` are each importable on their own. None imports
158
+ `./menu`, so no cycle can close (rule 3).
159
+ - **Drive the state with your own anchor.** Build a value with
160
+ `useMenuRootValue(...)`, wrap your tree in `MenuRootProvider`, and call
161
+ `open({ x, y, width: 0, height: 0 })` from the long-press. Or pass
162
+ `anchor` straight to `Menu.Content`.
163
+ - **`hasScrim`** tints the screen behind the panel; **`iconPlacement="trailing"`**
164
+ puts a row's icon at its end.
165
+
166
+ ## Out of scope
167
+
168
+ - **`presentation="bottom-sheet"`.** Importing `../bottom-sheet` would force the
169
+ optional sheet engine and `react-native-teleport` on every Menu consumer. A
170
+ follow-up.
171
+ - **Keyboard navigation and focus return on web.**
172
+ - **Typeahead, and rows that open a second panel.**
@@ -0,0 +1,44 @@
1
+ export { Menu, type MenuProps } from "./menu";
2
+ export {
3
+ type MenuContextValue,
4
+ type MenuMeasurable,
5
+ MenuRootProvider,
6
+ type UseMenuRootValueOptions,
7
+ useMenu,
8
+ useMenuRootValue,
9
+ } from "./menu.context";
10
+ export type { MenuItemProps } from "./menu.types";
11
+ export {
12
+ MENU_ALIGNS,
13
+ MENU_DEFAULT_MIN_WIDTH,
14
+ MENU_DEFAULT_OFFSET,
15
+ MENU_EDGE_MARGIN,
16
+ MENU_ITEM_VARIANTS,
17
+ MENU_PLACEMENTS,
18
+ MENU_RADIO_INDICATORS,
19
+ type MenuAlign,
20
+ type MenuAnchorRect,
21
+ type MenuInsets,
22
+ type MenuItemVariant,
23
+ type MenuPlacement,
24
+ type MenuPlacementInput,
25
+ type MenuPlacementResult,
26
+ type MenuRadioIndicator,
27
+ type MenuVariantProps,
28
+ menuVariants,
29
+ resolveCloseOnSelect,
30
+ resolveMenuPlacement,
31
+ resolveMenuWidth,
32
+ resolveRadioNext,
33
+ } from "./menu.variants";
34
+ export type { MenuBackgroundProps } from "./menu-background";
35
+ export type { MenuCheckboxItemProps } from "./menu-checkbox-item";
36
+ export type { MenuContentProps } from "./menu-content";
37
+ export type { MenuLabelProps } from "./menu-label";
38
+ export type { MenuRadioGroupProps } from "./menu-radio-group";
39
+ export type { MenuRadioItemProps } from "./menu-radio-item";
40
+ export type { MenuSeparatorProps } from "./menu-separator";
41
+ export type { MenuSubProps } from "./menu-sub";
42
+ export type { MenuSubContentProps } from "./menu-sub-content";
43
+ export type { MenuSubTriggerProps } from "./menu-sub-trigger";
44
+ export type { MenuTriggerProps } from "./menu-trigger";
@@ -0,0 +1,17 @@
1
+ import type { ReactElement } from "react";
2
+ import { View, type ViewProps } from "react-native";
3
+ import { menuVariants } from "./menu.variants";
4
+
5
+ export type MenuBackgroundProps = ViewProps & { className?: string };
6
+
7
+ /**
8
+ * The panel's surface: an absolute-fill layer in `bg-popover`, behind the rows.
9
+ *
10
+ * `Menu.Content` draws one by default. Pass your own as a child of
11
+ * `Menu.Content` to repaint it — the content lifts it out of the children and
12
+ * renders it outside the scroller, where it stays put while the rows scroll.
13
+ */
14
+ export function MenuBackground({ className, ...props }: MenuBackgroundProps): ReactElement {
15
+ return <View className={menuVariants().background({ className })} pointerEvents="none" {...props} />;
16
+ }
17
+ MenuBackground.displayName = "DelacourUI.Menu.Background";
@@ -0,0 +1,57 @@
1
+ import type { ReactElement } from "react";
2
+ import { View } from "react-native";
3
+ import { useControllableState } from "../../hooks/use-controllable-state";
4
+ import { IconCheckmark1 } from "../../icons/central";
5
+ import { Icon } from "../icon";
6
+ import type { MenuItemProps } from "./menu.types";
7
+ import { MENU_INDICATOR_TOKEN, menuVariants, resolveMenuItemAccessibility } from "./menu.variants";
8
+ import { MenuRow } from "./menu-item";
9
+
10
+ export type MenuCheckboxItemProps = Omit<MenuItemProps, "onSelect" | "isClosedOnSelect"> & {
11
+ isChecked?: boolean;
12
+ defaultChecked?: boolean;
13
+ onCheckedChange?: (isChecked: boolean) => void;
14
+ /** Whether choosing the row closes the menu. Default `false` — several are usually toggled in one visit. */
15
+ isClosedOnSelect?: boolean;
16
+ };
17
+
18
+ /**
19
+ * A row that toggles a setting, with a tick in the icon column while it is on.
20
+ *
21
+ * Stays open on select by default. The tick takes the leading column, so an
22
+ * `icon` moves to the row's end. Announced as `checked`.
23
+ *
24
+ * @example
25
+ * <Menu.CheckboxItem isChecked={showDone} onCheckedChange={setShowDone}>Completed</Menu.CheckboxItem>
26
+ */
27
+ export function MenuCheckboxItem({
28
+ isChecked: isCheckedProp,
29
+ defaultChecked = false,
30
+ onCheckedChange,
31
+ isDisabled = false,
32
+ ...props
33
+ }: MenuCheckboxItemProps): ReactElement {
34
+ const [isChecked, setChecked] = useControllableState<boolean>({
35
+ defaultValue: defaultChecked,
36
+ onChange: onCheckedChange,
37
+ value: isCheckedProp,
38
+ });
39
+ const slots = menuVariants();
40
+
41
+ return (
42
+ <MenuRow
43
+ accessibilityState={resolveMenuItemAccessibility({ isChecked, isDisabled, kind: "checkbox" })}
44
+ isDisabled={isDisabled}
45
+ kind="checkbox"
46
+ leading={
47
+ <View className={slots.indicator()}>
48
+ {isChecked ? <Icon className={slots.itemIcon()} color={MENU_INDICATOR_TOKEN} icon={IconCheckmark1} /> : null}
49
+ </View>
50
+ }
51
+ onSelect={() => setChecked(!isChecked)}
52
+ {...props}
53
+ iconPlacement="trailing"
54
+ />
55
+ );
56
+ }
57
+ MenuCheckboxItem.displayName = "DelacourUI.Menu.CheckboxItem";
@@ -0,0 +1,286 @@
1
+ import {
2
+ Children,
3
+ isValidElement,
4
+ type ReactElement,
5
+ type ReactNode,
6
+ useCallback,
7
+ useEffect,
8
+ useMemo,
9
+ useState,
10
+ } from "react";
11
+ import { I18nManager, Modal, ScrollView, useWindowDimensions, View, type ViewProps } from "react-native";
12
+ import { GestureHandlerRootView } from "react-native-gesture-handler";
13
+ import Animated, { useAnimatedStyle, useReducedMotion, useSharedValue, withTiming } from "react-native-reanimated";
14
+ import { useSafeAreaInsets } from "react-native-safe-area-context";
15
+ import { scheduleOnRN } from "react-native-worklets";
16
+ import { Pressable } from "../pressable";
17
+ import { useMenuPart } from "./menu.context";
18
+ import {
19
+ MENU_DEFAULT_OFFSET,
20
+ MENU_EDGE_MARGIN,
21
+ MENU_ENTER_SCALE,
22
+ MENU_MOTION,
23
+ type MenuAlign,
24
+ type MenuAnchorRect,
25
+ type MenuPlacement,
26
+ type MenuPlacementResult,
27
+ menuVariants,
28
+ resolveMenuOrigin,
29
+ resolveMenuPhysicalAlign,
30
+ resolveMenuPlacement,
31
+ resolveMenuWidth,
32
+ } from "./menu.variants";
33
+ import { MenuBackground } from "./menu-background";
34
+
35
+ export type MenuContentProps = Omit<ViewProps, "children"> & {
36
+ children: ReactNode;
37
+ className?: string;
38
+ /** The side it prefers. Default `bottom`; flips when there is no room. */
39
+ placement?: MenuPlacement;
40
+ /** Which edge lines up with the anchor's. Default `start`; swaps under RTL. */
41
+ align?: MenuAlign;
42
+ /** Gap from the anchor, in points. Default 6. */
43
+ offset?: number;
44
+ /** Panel width. Default: the trigger's width, at least 200. */
45
+ width?: number;
46
+ minWidth?: number;
47
+ /** Cap on the panel's height. Default: the room inside the safe area. */
48
+ maxHeight?: number;
49
+ /** Scroll rows past the cap. Default `true`; `false` clips instead. */
50
+ isScrollable?: boolean;
51
+ /** Tint the screen behind the panel. Default `false`. */
52
+ hasScrim?: boolean;
53
+ /** Anchor to this rect instead of the trigger. A zero-size rect is a point. */
54
+ anchor?: MenuAnchorRect;
55
+ };
56
+
57
+ /**
58
+ * The panel: a transparent `Modal` holding an anchored, scrolling list of rows.
59
+ *
60
+ * **The `Modal` is the layer.** It sits above everything, routes Android's back
61
+ * button to `onRequestClose`, and contains a screen reader — with no new
62
+ * dependency. A transparent full-screen pressable behind the panel closes it on
63
+ * an outside tap.
64
+ *
65
+ * **It measures, then places, then shows.** The first frame renders at opacity
66
+ * 0; once the rows report their height, `resolveMenuPlacement` picks the side,
67
+ * and the enter runs from there — so the panel never jumps. The side is then
68
+ * locked for the visit: a submenu expanding later grows the panel away from the
69
+ * anchor (a panel above is positioned by its bottom edge) and scrolls past the
70
+ * cap rather than flipping under the finger.
71
+ *
72
+ * **Enter is 160ms, exit 120ms**: opacity with a 0.96 scale from the anchor's
73
+ * side, approximated with a translate. Under reduced motion, opacity alone. The
74
+ * `Modal` unmounts when the exit finishes.
75
+ *
76
+ * A caller's `Menu.Background` child is lifted out and drawn behind the
77
+ * scroller in place of the default one.
78
+ */
79
+ export function MenuContent({
80
+ children,
81
+ className,
82
+ placement = "bottom",
83
+ align = "start",
84
+ offset = MENU_DEFAULT_OFFSET,
85
+ width,
86
+ minWidth,
87
+ maxHeight: maxHeightProp,
88
+ isScrollable = true,
89
+ hasScrim = false,
90
+ anchor: anchorProp,
91
+ ...props
92
+ }: MenuContentProps): ReactElement | null {
93
+ const { anchor: contextAnchor, close, isOpen } = useMenuPart("Menu.Content");
94
+ const anchor = anchorProp ?? contextAnchor;
95
+ const window = useWindowDimensions();
96
+ const insets = useSafeAreaInsets();
97
+ const isReducedMotion = useReducedMotion();
98
+ const slots = menuVariants();
99
+
100
+ // Adjusted during render, so the Modal mounts in the commit that opened it.
101
+ const [isPresent, setPresent] = useState(isOpen);
102
+ if (isOpen && !isPresent) setPresent(true);
103
+
104
+ const [measuredHeight, setMeasuredHeight] = useState<number | null>(null);
105
+ const [locked, setLocked] = useState<MenuPlacementResult | null>(null);
106
+
107
+ const { background, rows } = useMemo(() => splitBackground(children), [children]);
108
+
109
+ const resolvedWidth = resolveMenuWidth({
110
+ maxWidth: window.width - insets.left - insets.right - MENU_EDGE_MARGIN * 2,
111
+ minWidth,
112
+ triggerWidth: anchor?.width ?? 0,
113
+ width,
114
+ });
115
+
116
+ const resolve = useCallback(
117
+ (height: number) =>
118
+ anchor
119
+ ? resolveMenuPlacement({
120
+ align,
121
+ anchor,
122
+ content: { height, width: resolvedWidth },
123
+ insets,
124
+ isRTL: I18nManager.isRTL,
125
+ offset,
126
+ placement,
127
+ window: { height: window.height, width: window.width },
128
+ })
129
+ : null,
130
+ [align, anchor, insets, offset, placement, resolvedWidth, window.height, window.width]
131
+ );
132
+
133
+ // Placed once per visit, from the first measurement taken with an anchor.
134
+ useEffect(() => {
135
+ if (!isOpen || locked || measuredHeight === null) return;
136
+ const result = resolve(measuredHeight);
137
+ if (result) setLocked(result);
138
+ }, [isOpen, locked, measuredHeight, resolve]);
139
+
140
+ const handleContentSize = useCallback((_width: number, height: number) => setMeasuredHeight(height), []);
141
+
142
+ const handleExited = useCallback(() => {
143
+ setPresent(false);
144
+ setLocked(null);
145
+ setMeasuredHeight(null);
146
+ }, []);
147
+
148
+ const progress = useSharedValue(0);
149
+
150
+ useEffect(() => {
151
+ if (isOpen) {
152
+ if (!locked) return;
153
+ progress.value = withTiming(1, { duration: MENU_MOTION.enterMs });
154
+ return;
155
+ }
156
+ if (!isPresent) return;
157
+ progress.value = withTiming(0, { duration: MENU_MOTION.exitMs }, (isFinished) => {
158
+ "worklet";
159
+ if (isFinished) scheduleOnRN(handleExited);
160
+ });
161
+ }, [handleExited, isOpen, isPresent, locked, progress]);
162
+
163
+ const { cap, origin, position } = resolvePanelFrame({
164
+ align,
165
+ maxHeight: maxHeightProp,
166
+ measuredHeight,
167
+ placed: locked ?? resolve(measuredHeight ?? 0),
168
+ placement,
169
+ width: resolvedWidth,
170
+ windowHeight: window.height,
171
+ });
172
+ const originX = origin.x;
173
+ const originY = origin.y;
174
+ const enterScale = MENU_ENTER_SCALE;
175
+
176
+ const panelStyle = useAnimatedStyle(() => {
177
+ const p = progress.value;
178
+ if (isReducedMotion) return { opacity: p, transform: [{ translateX: 0 }, { translateY: 0 }, { scale: 1 }] };
179
+ return {
180
+ opacity: p,
181
+ transform: [
182
+ { translateX: originX * (1 - p) },
183
+ { translateY: originY * (1 - p) },
184
+ { scale: enterScale + (1 - enterScale) * p },
185
+ ],
186
+ };
187
+ });
188
+ const scrimStyle = useAnimatedStyle(() => ({ opacity: progress.value }));
189
+
190
+ if (!isPresent) return null;
191
+
192
+ return (
193
+ <Modal animationType="none" onRequestClose={close} statusBarTranslucent transparent visible>
194
+ <GestureHandlerRootView style={fill}>
195
+ <Pressable
196
+ accessibilityLabel="Close menu"
197
+ className="absolute inset-0"
198
+ feedback="none"
199
+ importantForAccessibility="no"
200
+ onPress={close}
201
+ >
202
+ {hasScrim ? <Animated.View className={slots.scrim()} style={scrimStyle} /> : null}
203
+ </Pressable>
204
+ <Animated.View
205
+ accessibilityRole="menu"
206
+ accessibilityViewIsModal
207
+ className={slots.panel()}
208
+ pointerEvents={locked ? "auto" : "none"}
209
+ style={[position, panelStyle]}
210
+ {...props}
211
+ >
212
+ <View className={slots.content({ className })}>
213
+ {background ?? <MenuBackground />}
214
+ <ScrollView
215
+ bounces={false}
216
+ contentContainerClassName={slots.scroll()}
217
+ onContentSizeChange={handleContentSize}
218
+ scrollEnabled={isScrollable}
219
+ showsVerticalScrollIndicator
220
+ style={{ flexGrow: 0, maxHeight: cap }}
221
+ >
222
+ {rows}
223
+ </ScrollView>
224
+ </View>
225
+ </Animated.View>
226
+ </GestureHandlerRootView>
227
+ </Modal>
228
+ );
229
+ }
230
+ MenuContent.displayName = "DelacourUI.Menu.Content";
231
+
232
+ const fill = { flex: 1 };
233
+
234
+ type PanelFrameInput = {
235
+ align: MenuAlign;
236
+ maxHeight: number | undefined;
237
+ measuredHeight: number | null;
238
+ placed: MenuPlacementResult | null;
239
+ placement: MenuPlacement;
240
+ width: number;
241
+ windowHeight: number;
242
+ };
243
+
244
+ /** The panel's height cap, its absolute position, and the side its enter grows from. */
245
+ function resolvePanelFrame({
246
+ align,
247
+ maxHeight,
248
+ measuredHeight,
249
+ placed,
250
+ placement,
251
+ width,
252
+ windowHeight,
253
+ }: PanelFrameInput): {
254
+ cap: number;
255
+ origin: { x: number; y: number };
256
+ position: { top?: number; bottom?: number; left: number; width: number };
257
+ } {
258
+ const cap = Math.min(maxHeight ?? Number.POSITIVE_INFINITY, placed?.maxHeight ?? windowHeight);
259
+ const origin = resolveMenuOrigin({
260
+ align: resolveMenuPhysicalAlign(align, I18nManager.isRTL),
261
+ height: Math.min(measuredHeight ?? 0, cap),
262
+ placement: placed?.placement ?? placement,
263
+ width,
264
+ });
265
+ const position =
266
+ placed?.placement === "top"
267
+ ? { bottom: placed.bottom, left: placed.left, width }
268
+ : { left: placed?.left ?? 0, top: placed?.top ?? 0, width };
269
+ return { cap, origin, position };
270
+ }
271
+
272
+ /** Lifts a caller's `Menu.Background` out of the rows, so it is drawn behind the scroller. */
273
+ function splitBackground(children: ReactNode): { background: ReactElement | null; rows: ReactNode[] } {
274
+ let background: ReactElement | null = null;
275
+ const rows: ReactNode[] = [];
276
+
277
+ for (const child of Children.toArray(children)) {
278
+ if (isValidElement(child) && child.type === MenuBackground) {
279
+ background = child;
280
+ continue;
281
+ }
282
+ rows.push(child);
283
+ }
284
+
285
+ return { background, rows };
286
+ }