|
| 1 | +# PopoverMenu (v2) |
| 2 | + |
| 3 | +Compound popover-menu primitives. Inspired by Radix DropdownMenu, adapted for React Native. |
| 4 | + |
| 5 | +## Contract |
| 6 | + |
| 7 | +- **Uncontrolled.** Visibility lives inside `<Root>`; seed the initial state with `<Root defaultOpen?>`. Triggers open it; item selection (unless its handler calls `event.preventDefault()`), screen blur, or modal-stack cover close it. Observe via `useIsPopoverVisible()`. |
| 8 | +- **Hierarchy enforced at render.** Misuse throws synchronously in dev *and* staging — e.g. `<PopoverMenu.Item> must be rendered inside <PopoverMenu.Content>`. |
| 9 | +- **Triggers publish via context, not `cloneElement`.** `<Trigger>` and `<SecondaryInteractionTrigger>` publish their handler plus shared accessibility metadata into `PressResponderContext` (a project-level primitive at `src/components/Pressable/PressResponder/`) — see the [pressable consumption table](#pressables-that-consume-pressrespondercontext). |
| 10 | +- **Sub-level: wrapper + hook.** `<Sub.Trigger>` / `useSubTrigger` and `<Sub.BackButton>` / `useSubBackButton` ship both layers — opinionated `MenuItem` for the canonical row, hook for non-`MenuItem` shapes. |
| 11 | + |
| 12 | +## Usage |
| 13 | + |
| 14 | +```tsx |
| 15 | +import * as PopoverMenu from '@components/PopoverMenu/v2'; |
| 16 | + |
| 17 | +<PopoverMenu.Root> |
| 18 | + <PopoverMenu.Trigger> |
| 19 | + <PressableWithFeedback |
| 20 | + onPress={() => {}} |
| 21 | + accessibilityLabel="Open menu" |
| 22 | + sentryLabel="MyTrigger" |
| 23 | + > |
| 24 | + <Icon /> |
| 25 | + </PressableWithFeedback> |
| 26 | + </PopoverMenu.Trigger> |
| 27 | + |
| 28 | + <PopoverMenu.Content> |
| 29 | + <PopoverMenu.Header>Menu title</PopoverMenu.Header> |
| 30 | + <PopoverMenu.Item text="Edit" onSelect={...} /> |
| 31 | + <PopoverMenu.Separator /> |
| 32 | + |
| 33 | + <PopoverMenu.Sub id="more"> |
| 34 | + <PopoverMenu.Sub.Trigger text="More" /> |
| 35 | + <PopoverMenu.Sub.Content> |
| 36 | + <PopoverMenu.Sub.BackButton /> |
| 37 | + <PopoverMenu.Item text="Archive" onSelect={...} /> |
| 38 | + </PopoverMenu.Sub.Content> |
| 39 | + </PopoverMenu.Sub> |
| 40 | + </PopoverMenu.Content> |
| 41 | +</PopoverMenu.Root> |
| 42 | +``` |
| 43 | + |
| 44 | +### Root — `<Root defaultOpen?: boolean>` |
| 45 | + |
| 46 | +Owns visibility state. `defaultOpen` seeds the initial open state; there is no `open` / `onOpenChange` (uncontrolled-only). |
| 47 | + |
| 48 | +### Triggers |
| 49 | + |
| 50 | +- **`<Trigger>`** — primary trigger. Render any subtree containing a `<PressableWithFeedback>`. The pressable's `onPress` (if supplied) runs *before* the popover opens; call `event.preventDefault()` synchronously inside `onPress` to gate the open. |
| 51 | +- **`<SecondaryInteractionTrigger>`** — long-press (native) / right-click (web). Always opens; the framework reserves `event.preventDefault()` for OS-level long-press / context-menu suppression so it cannot double as a consumer gate. Conditional opening means conditionally rendering the trigger or doing the work inside the consumer's `onSecondaryInteraction`. Web anchors at a 1×1 rect at the cursor; native at the pressable's bounding rect. |
| 52 | +- **`<Sub.Trigger>` / `useSubTrigger({disabled?, text?})`** — drill-down row inside `<Sub>`. The hook returns `{ref, onPress, onFocus, focused, isAtActiveLevel}` for non-`MenuItem` shapes. |
| 53 | +- **`<Sub.BackButton text?>` / `useSubBackButton()`** — back row inside `<Sub.Content>`. The wrapper's `text` prop defaults to a localized "Go back" and drives the rendered row label. Self-gates to the active sub-level. The hook takes no params (back buttons stay out of typeahead so a "g" keypress doesn't focus the back button on every drilled-in level) and returns the same shape as `useSubTrigger`. |
| 54 | + |
| 55 | +#### Pressables that consume `PressResponderContext` |
| 56 | + |
| 57 | +| Pressable | Consumes | |
| 58 | +|---|---| |
| 59 | +| `<PressableWithFeedback>` | `onPress`, `ref`, `accessibilityState`, `accessibilityHasPopup`, `nativeID`, `accessibilityControls` | |
| 60 | +| `<PressableWithSecondaryInteraction>` | `onSecondaryInteraction` (its inner `<PressableWithFeedback>` independently consumes the remaining props from the same context) | |
| 61 | + |
| 62 | +Raw RN `<Pressable>` / `<TouchableOpacity>` do **not** consume the responder — wrap them in one of the above or call `usePressResponderProps` / `useResponderRef` directly in a custom pressable. Without a consumer, the trigger renders but pressing does nothing (the published `onPress` reaches no descendant). |
| 63 | + |
| 64 | +### Rows |
| 65 | + |
| 66 | +All rows except `<Group>` auto-hide outside the active sub-level. |
| 67 | + |
| 68 | +- **`<Item text onSelect? disabled?>`** — selectable row. Closes the menu after `onSelect`; consumer's `event.preventDefault()` synchronously inside `onSelect` keeps it open. `onSelect` receives an `ItemSelectEvent` (custom event exposing `preventDefault()` / `isDefaultPrevented()` — synchronous-only). |
| 69 | +- **`<RadioItem text isSelected? onSelect? disabled? rightIcon?>`** — single-select row with a radio indicator. Pass `rightIcon` to replace the indicator. |
| 70 | +- **`<Header>children</Header>`** — heading-role title (`accessibilityRole="heading"`, level 3). To title a sub, render inside `<Sub.Content>`. |
| 71 | +- **`<Label text>`** — non-interactive labelled row. |
| 72 | +- **`<Separator />`** — horizontal divider. |
| 73 | +- **`<Group>children</Group>`** — ARIA `role="group"` wrapper (web-only — react-native-web maps the `role` prop to the DOM attribute; iOS / Android RN don't expose ARIA grouping). Stays mounted across sub-navigation so nested `<Sub>` descendants don't unmount when the user drills in or out. |
| 74 | + |
| 75 | +### Sub-menus |
| 76 | + |
| 77 | +`<Sub>` declares one nested sub-menu level and provides its identity context. Each `<Sub>` accepts: |
| 78 | + |
| 79 | +- **`<Sub.Trigger>`** — the row that drills into the sub. |
| 80 | +- **`<Sub.Content>`** — wraps the sub's content. Stays mounted at ancestor levels so deeper subs survive when a shallower sub is the currently-active level (back-button pops one level instead of collapsing to root). Render `<Sub.BackButton>` as the first child here — the visual idiom places back rows at the top of the active sub panel. |
| 81 | +- **`<Sub.BackButton>`** — back row that pops one level. |
| 82 | + |
| 83 | +Subs nest arbitrarily — render a `<Sub>` inside another `<Sub.Content>` to declare a deeper level. `<Sub.BackButton>` always pops one level (to the parent sub, or to root from the outermost). |
| 84 | + |
| 85 | +**`<Sub id>`** — required stable identifier. Pass a stable string (literal or memoized) — sub-navigation state is keyed by it, and `useId()` would rotate on each fresh mount. |
| 86 | + |
| 87 | +### Row composition — `useSelectableRow({onSelect?, disabled?, text?})` |
| 88 | + |
| 89 | +Returns `{ref, onPress, onFocus, focused, isAtActiveLevel}` for composing any pressable as a selectable row inside `<Content>`. The menu closes after `onSelect` fires; call `event.preventDefault()` synchronously inside `onSelect` to keep it open. `text` is stored on the registered focusable item (for the focus registry's label). `<Item>` and `<RadioItem>` ship the canonical `MenuItem` shapes. |
| 90 | + |
| 91 | +### Custom non-row content — `useIsAtActiveLevel()` |
| 92 | + |
| 93 | +Hook for self-gating custom (non-row) content rendered directly inside `<Content>` or `<Sub.Content>`. Returns `true` when the enclosing `<Sub>`'s id is the current sub-level (or when the consumer is at the top level and no sub is active). Custom content should render `null` when this is `false` so siblings at other levels don't show through. Requires `<Content>`; `<Sub>` is optional (top-level use returns `true` when no sub is active). |
| 94 | + |
| 95 | +### Visibility observation — `useIsPopoverVisible()` |
| 96 | + |
| 97 | +Reads `Root`'s `isVisible`. Throws outside `<Root>`. Use to drive trigger UI that depends on menu state (active-state icon color, controls staying visible while open). |
| 98 | + |
| 99 | +### Programmatic close — `useClosePopover()` |
| 100 | + |
| 101 | +Returns `() => void` for descendants that need to close from custom logic (async work completion, deep-link change). Throws outside `<Content>`. Item selection already routes through close; only reach for this hook when no item triggered the close. |
| 102 | + |
| 103 | +### Keyboard (web) |
| 104 | + |
| 105 | +When `<Content>` is open, ArrowUp / ArrowDown move focus among registered rows in DOM order, skipping disabled rows; Enter activates the focused row. Native platforms rely on OS focus traversal — these key handlers are no-ops. |
| 106 | + |
| 107 | +### Lifecycle closes |
| 108 | + |
| 109 | +- **Screen blur** — via `navigation.addListener('blur', …)`. |
| 110 | +- **Modal-stack cover** — when a non-popover alert modal becomes visible over the popover (`willAlertModalBecomeVisible && !isPopover`). |
| 111 | + |
| 112 | +Both fire inside `<Content>` (the layer where the atomic `close()` is in scope), so adding a `<Content>` automatically opts into them. |
| 113 | + |
| 114 | +### Content variants |
| 115 | + |
| 116 | +- **`<Content>`** — for menus that fit the viewport. |
| 117 | +- **`<ScrollableContent>`** — wraps children in a `<ScrollView>` capped at window height; use when the row count is bounded but may exceed the viewport. |
| 118 | + |
| 119 | +### Anchor |
| 120 | + |
| 121 | +The pressable consuming the `<Trigger>` / `<SecondaryInteractionTrigger>` context is the anchor — no separate `<Anchor>` slot, no `anchorRef` prop. The trigger captures `getBoundingClientRect()` on press; `<SecondaryInteractionTrigger>` on web captures a 1×1 rect at `MouseEvent.pageX/pageY` instead. Multiple triggers in one `<Root>` are supported; the popover anchors to whichever was pressed last. |
| 122 | + |
| 123 | +> **Runtime requirement.** Uses `View.getBoundingClientRect()` and `View.compareDocumentPosition()` — exposed by `ReadOnlyElement` on Fabric. Old Architecture is not supported. |
| 124 | +
|
| 125 | +## Folder layout |
| 126 | + |
| 127 | +Grouped by feature. Each subfolder re-exports its public surface through a barrel (`index.ts`); the top-level [`index.tsx`](./index.tsx) re-exports each barrel. |
| 128 | + |
| 129 | +- **`root/`** — `<Root>`, the trigger wrappers (`<Trigger>`, `<SecondaryInteractionTrigger>`), `useIsPopoverVisible`, and `useAnchorOpener` (internal). |
| 130 | +- **`content/`** — `<Content>`, `<ScrollableContent>`, `useClosePopover`, `<BaseContent>` (internal), `useContentController` (internal), and the close-lifecycle hooks (internal). |
| 131 | +- **`rows/`** — leaf rows (`<Item>`, `<RadioItem>`, `<Label>`, `<Header>`, `<Separator>`, `<Group>`), `useSelectableRow`, and `useFocusableRow` (internal). |
| 132 | +- **`sub/`** — `<Sub>` and its compound members `<Sub.Trigger>` / `<Sub.Content>` / `<Sub.BackButton>`, plus `useSubTrigger` / `useSubBackButton` and `useIsAtActiveLevel`. |
| 133 | + |
| 134 | +Each file carries a header comment describing its role — treat that as the source of truth, not this README. |
| 135 | + |
| 136 | +## Architectural rules |
| 137 | + |
| 138 | +| Component / Hook | Must be rendered / called inside | |
| 139 | +|---|---| |
| 140 | +| `Trigger`, `SecondaryInteractionTrigger`, `useIsPopoverVisible` | `Root` | |
| 141 | +| `Content`, `ScrollableContent` | `Root` | |
| 142 | +| `Item`, `RadioItem`, `Label`, `Header`, `Separator`, `Group`, `Sub`, `useSelectableRow`, `useClosePopover`, `useIsAtActiveLevel` | `Content` (anywhere inside it, including within `<Sub.Content>`) | |
| 143 | +| `Sub.Trigger`, `Sub.Content`, `Sub.BackButton`, `useSubTrigger`, `useSubBackButton` | `Sub` (which itself must be inside `Content`) | |
| 144 | + |
| 145 | +Violations throw synchronously during render — not `__DEV__`-gated. |
0 commit comments