@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.
- package/package.json +5 -3
- package/src/components/menu/AGENTS.md +172 -0
- package/src/components/menu/index.ts +44 -0
- package/src/components/menu/menu-background.tsx +17 -0
- package/src/components/menu/menu-checkbox-item.tsx +57 -0
- package/src/components/menu/menu-content.tsx +286 -0
- package/src/components/menu/menu-item.tsx +162 -0
- package/src/components/menu/menu-label.tsx +14 -0
- package/src/components/menu/menu-radio-group.tsx +50 -0
- package/src/components/menu/menu-radio-item.tsx +57 -0
- package/src/components/menu/menu-separator.tsx +11 -0
- package/src/components/menu/menu-sub-content.tsx +72 -0
- package/src/components/menu/menu-sub-trigger.tsx +50 -0
- package/src/components/menu/menu-sub.tsx +75 -0
- package/src/components/menu/menu-trigger.tsx +57 -0
- package/src/components/menu/menu.context.tsx +214 -0
- package/src/components/menu/menu.tsx +88 -0
- package/src/components/menu/menu.types.ts +33 -0
- package/src/components/menu/menu.variants.test.ts +393 -0
- package/src/components/menu/menu.variants.ts +351 -0
- package/src/components/stack-card/AGENTS.md +169 -0
- package/src/components/stack-card/index.ts +37 -0
- package/src/components/stack-card/stack-card-action.tsx +66 -0
- package/src/components/stack-card/stack-card-actions.tsx +18 -0
- package/src/components/stack-card/stack-card-card.tsx +71 -0
- package/src/components/stack-card/stack-card-empty.tsx +30 -0
- package/src/components/stack-card/stack-card-slot.tsx +90 -0
- package/src/components/stack-card/stack-card-stamp.tsx +76 -0
- package/src/components/stack-card/stack-card.context.tsx +117 -0
- package/src/components/stack-card/stack-card.tsx +557 -0
- package/src/components/stack-card/stack-card.types.ts +26 -0
- package/src/components/stack-card/stack-card.variants.test.ts +355 -0
- 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.
|
|
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.
|
|
113
|
-
"@delacour/react-native-charts": "0.1.0-alpha.
|
|
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
|
+
}
|