Skip to content

Commit 57f365d

Browse files
authored
Merge pull request Expensify#89424 from TaduJR/refactor-decompose-popover-menu
refactor: decompose popover menu
2 parents 223e5f8 + c26d43b commit 57f365d

73 files changed

Lines changed: 5401 additions & 222 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

config/eslint/eslint.seatbelt.tsv

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -143,8 +143,8 @@
143143
"../../src/components/OptionRow.tsx" "@typescript-eslint/no-deprecated/InteractionManager.runAfterInteractions" 1
144144
"../../src/components/OptionRow.tsx" "react-hooks/set-state-in-effect" 1
145145
"../../src/components/PDFView/index.tsx" "react-hooks/set-state-in-effect" 1
146-
"../../src/components/PopoverMenu.tsx" "react-hooks/preserve-manual-memoization" 3
147-
"../../src/components/PopoverMenu.tsx" "react-hooks/set-state-in-effect" 1
146+
"../../src/components/PopoverMenu/index.tsx" "react-hooks/preserve-manual-memoization" 3
147+
"../../src/components/PopoverMenu/index.tsx" "react-hooks/set-state-in-effect" 1
148148
"../../src/components/Pressable/GenericPressable/implementation/BaseGenericPressable.tsx" "react-hooks/refs" 1
149149
"../../src/components/PressableWithSecondaryInteraction/index.native.tsx" "no-restricted-syntax" 1
150150
"../../src/components/PressableWithSecondaryInteraction/index.tsx" "no-restricted-syntax" 1

src/CONST/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6327,6 +6327,8 @@ const CONST = {
63276327
MENUITEM: 'menuitem',
63286328
/** Use for selectable options within a listbox. */
63296329
OPTION: 'option',
6330+
/** Use to group related elements together for assistive technology. */
6331+
GROUP: 'group',
63306332
/** Use when no specific role is needed. */
63316333
NONE: 'none',
63326334
/** Use for elements that don't require a specific role. */
Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,17 @@ import type {ReactNode, RefObject} from 'react';
33
import React, {useCallback, useEffect, useLayoutEffect, useMemo, useState} from 'react';
44
import {StyleSheet, View} from 'react-native';
55
import type {GestureResponderEvent, LayoutChangeEvent, StyleProp, TextStyle, ViewStyle} from 'react-native';
6+
import CompactMenuContext from '@components/CompactMenuContext';
7+
import FocusableMenuItem from '@components/FocusableMenuItem';
8+
import FocusTrapForModal from '@components/FocusTrap/FocusTrapForModal';
9+
import MenuItem from '@components/MenuItem';
10+
import type {MenuItemProps} from '@components/MenuItem';
11+
import type ReanimatedModalProps from '@components/Modal/ReanimatedModal/types';
12+
import type BaseModalProps from '@components/Modal/types';
13+
import OfflineWithFeedback from '@components/OfflineWithFeedback';
14+
import PopoverWithMeasuredContent from '@components/PopoverWithMeasuredContent';
15+
import ScrollView from '@components/ScrollView';
16+
import Text from '@components/Text';
617
import useArrowKeyFocusManager from '@hooks/useArrowKeyFocusManager';
718
import useKeyboardShortcut from '@hooks/useKeyboardShortcut';
819
import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
@@ -23,17 +34,6 @@ import type {AnchorPosition} from '@src/styles';
2334
import type {PendingAction} from '@src/types/onyx/OnyxCommon';
2435
import type AnchorAlignment from '@src/types/utils/AnchorAlignment';
2536
import type IconAsset from '@src/types/utils/IconAsset';
26-
import CompactMenuContext from './CompactMenuContext';
27-
import FocusableMenuItem from './FocusableMenuItem';
28-
import FocusTrapForModal from './FocusTrap/FocusTrapForModal';
29-
import MenuItem from './MenuItem';
30-
import type {MenuItemProps} from './MenuItem';
31-
import type ReanimatedModalProps from './Modal/ReanimatedModal/types';
32-
import type BaseModalProps from './Modal/types';
33-
import OfflineWithFeedback from './OfflineWithFeedback';
34-
import PopoverWithMeasuredContent from './PopoverWithMeasuredContent';
35-
import ScrollView from './ScrollView';
36-
import Text from './Text';
3737

3838
type PopoverMenuItem = MenuItemProps & {
3939
/** Text label */
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
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.
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import React from 'react';
2+
import type {ReactNode} from 'react';
3+
import {View} from 'react-native';
4+
import type {LayoutChangeEvent, StyleProp, ViewStyle} from 'react-native';
5+
import CompactMenuContext from '@components/CompactMenuContext';
6+
import FocusTrapForModal from '@components/FocusTrap/FocusTrapForModal';
7+
import type BaseModalProps from '@components/Modal/types';
8+
import {useRootMeta, useRootVisibility} from '@components/PopoverMenu/v2/root/RootContext';
9+
import type {ActiveAnchor} from '@components/PopoverMenu/v2/root/RootContext';
10+
import PopoverWithMeasuredContent from '@components/PopoverWithMeasuredContent';
11+
import {computeAnchorPosition} from '@hooks/usePopoverPosition';
12+
import useResponsiveLayout from '@hooks/useResponsiveLayout';
13+
import useThemeStyles from '@hooks/useThemeStyles';
14+
import variables from '@styles/variables';
15+
import CONST from '@src/CONST';
16+
import type AnchorAlignment from '@src/types/utils/AnchorAlignment';
17+
import {ContentCloseContext, ContentFocusContext, ContentItemActionsContext, ContentNavigationContext, ContentSubActionsContext} from './ContentContext';
18+
import DismissButton from './DismissButton';
19+
import useContentController from './useContentController';
20+
21+
type BasePopoverProps = {
22+
children: ReactNode;
23+
anchorAlignment?: AnchorAlignment;
24+
containerStyles?: StyleProp<ViewStyle>;
25+
/** Replaces the default `paddingVertical: 0` — include it in your override to keep it. */
26+
innerContainerStyle?: ViewStyle;
27+
onLayout?: (e: LayoutChangeEvent) => void;
28+
onModalShow?: () => void;
29+
onModalHide?: () => void;
30+
restoreFocusType?: BaseModalProps['restoreFocusType'];
31+
testID?: string;
32+
};
33+
34+
type BaseContentProps = BasePopoverProps & {
35+
componentName: string;
36+
maxHeightStyle?: ViewStyle;
37+
/** Set to `false` by `<ScrollableContent>` since it wraps children in a `<ScrollView>` itself. */
38+
shouldWrapModalChildrenInScrollViewIfBottomDockedInLandscapeMode?: boolean;
39+
};
40+
41+
const DEFAULT_ANCHOR_ALIGNMENT: AnchorAlignment = {
42+
horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT,
43+
vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM,
44+
};
45+
46+
/** Outer guard: skips the controller's subscriptions until the trigger has published an anchor. */
47+
function BaseContent(props: BaseContentProps): React.ReactElement | null {
48+
const {activeAnchor} = useRootMeta(props.componentName);
49+
if (!activeAnchor) {
50+
return null;
51+
}
52+
return (
53+
<BaseContentInner
54+
{...props}
55+
activeAnchor={activeAnchor}
56+
/>
57+
);
58+
}
59+
60+
function BaseContentInner({
61+
children,
62+
componentName,
63+
anchorAlignment = DEFAULT_ANCHOR_ALIGNMENT,
64+
containerStyles,
65+
innerContainerStyle,
66+
onLayout,
67+
onModalShow,
68+
onModalHide,
69+
restoreFocusType,
70+
testID,
71+
maxHeightStyle,
72+
shouldWrapModalChildrenInScrollViewIfBottomDockedInLandscapeMode = true,
73+
activeAnchor,
74+
}: BaseContentProps & {activeAnchor: ActiveAnchor}): React.ReactElement {
75+
const styles = useThemeStyles();
76+
const {isVisible} = useRootVisibility(componentName);
77+
const {triggerID, contentID} = useRootMeta(componentName);
78+
// eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth -- popovers float even in RHP on desktop, so true device width drives sizing
79+
const {isSmallScreenWidth} = useResponsiveLayout();
80+
81+
const {navigation, focus, subActions, itemActions, close} = useContentController(componentName);
82+
83+
const anchorPosition = computeAnchorPosition(activeAnchor.rect, anchorAlignment);
84+
85+
return (
86+
<PopoverWithMeasuredContent
87+
anchorPosition={anchorPosition}
88+
anchorRef={activeAnchor.ref}
89+
anchorAlignment={anchorAlignment}
90+
onClose={close}
91+
isVisible={isVisible}
92+
onModalShow={onModalShow}
93+
onModalHide={onModalHide}
94+
disableAnimation
95+
restoreFocusType={restoreFocusType}
96+
innerContainerStyle={innerContainerStyle ?? styles.pv0}
97+
shouldWrapModalChildrenInScrollViewIfBottomDockedInLandscapeMode={shouldWrapModalChildrenInScrollViewIfBottomDockedInLandscapeMode}
98+
testID={testID}
99+
>
100+
<FocusTrapForModal active={isVisible}>
101+
<CompactMenuContext.Provider value>
102+
<ContentNavigationContext.Provider value={navigation}>
103+
<ContentFocusContext.Provider value={focus}>
104+
<ContentSubActionsContext.Provider value={subActions}>
105+
<ContentItemActionsContext.Provider value={itemActions}>
106+
<ContentCloseContext.Provider value={close}>
107+
<View
108+
role={CONST.ROLE.MENU}
109+
aria-orientation="vertical"
110+
nativeID={contentID}
111+
accessibilityLabelledBy={triggerID}
112+
onLayout={onLayout}
113+
style={[isSmallScreenWidth ? undefined : {width: variables.compactPopoverMenuWidth}, maxHeightStyle, containerStyles]}
114+
>
115+
<DismissButton onPress={close} />
116+
{children}
117+
</View>
118+
</ContentCloseContext.Provider>
119+
</ContentItemActionsContext.Provider>
120+
</ContentSubActionsContext.Provider>
121+
</ContentFocusContext.Provider>
122+
</ContentNavigationContext.Provider>
123+
</CompactMenuContext.Provider>
124+
</FocusTrapForModal>
125+
</PopoverWithMeasuredContent>
126+
);
127+
}
128+
129+
export default BaseContent;
130+
export type {BasePopoverProps};

0 commit comments

Comments
 (0)