@colixsystems/widget-sdk 0.245.0 → 0.246.0

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.
@@ -16,7 +16,7 @@
16
16
 
17
17
  import React from "react";
18
18
  import { useHostTheme } from "./hooks.js";
19
- import { overlayCornerStyle, resolveOverlayTokens } from "./overlay-tokens.js";
19
+ import { overlayFrameStyles, resolveOverlayTokens } from "./overlay-tokens.js";
20
20
 
21
21
  // Kept clickable, but out of the tab order and the accessibility tree.
22
22
  const SILENT_BACKDROP = {
@@ -32,13 +32,58 @@ const SILENT_BACKDROP = {
32
32
  *
33
33
  * `namesPanel` is for a host whose `Modal` cannot carry the accessible name
34
34
  * (React Native's): the panel carries it there, the dialog node on the web.
35
+ * `awaitsDismiss` is for a host whose `Modal` leaves the screen after it is
36
+ * told to (iOS): it stays mounted, hidden, until its `onDismiss`.
35
37
  *
36
38
  * @param {{Modal: any, View: any, Pressable: any, StyleSheet: any,
37
- * namesPanel?: boolean}} rn
39
+ * namesPanel?: boolean, awaitsDismiss?: boolean}} rn
38
40
  */
39
41
  export function makeOverlay(rn) {
40
42
  const { Modal, View, Pressable, StyleSheet } = rn;
41
43
  const namesPanel = Boolean(rn.namesPanel);
44
+ const awaitsDismiss = Boolean(rn.awaitsDismiss);
45
+ // Compiled, so the web host can express "box-none" as real CSS rules.
46
+ let passiveBackdrop = null;
47
+ const passiveBackdropStyle = () => {
48
+ passiveBackdrop =
49
+ passiveBackdrop || StyleSheet.create({ backdrop: { pointerEvents: "box-none" } }).backdrop;
50
+ return passiveBackdrop;
51
+ };
52
+
53
+ /**
54
+ * Runs `onDismiss` once a shown overlay has left the screen: after the
55
+ * commit that removed it (focus already returned on the web), or, where
56
+ * the host takes it away itself, from that host's `onDismiss`. Returns
57
+ * the handler to hand the host while it is still dismissing, else null.
58
+ */
59
+ function useDismissal(visible, onDismiss) {
60
+ const onDismissRef = React.useRef(onDismiss);
61
+ onDismissRef.current = onDismiss;
62
+ // Decided in render, so the host's Modal is never unmounted mid-dismissal.
63
+ const [prevVisible, setPrevVisible] = React.useState(visible);
64
+ const [hostDismissing, setHostDismissing] = React.useState(false);
65
+ if (visible !== prevVisible) {
66
+ setPrevVisible(visible);
67
+ setHostDismissing(!visible && awaitsDismiss && typeof onDismiss === "function");
68
+ }
69
+ const hostDismissed = React.useCallback(() => {
70
+ setHostDismissing(false);
71
+ if (typeof onDismissRef.current === "function") onDismissRef.current();
72
+ }, []);
73
+
74
+ const shown = React.useRef(false);
75
+ React.useEffect(() => {
76
+ if (visible) {
77
+ shown.current = true;
78
+ return;
79
+ }
80
+ if (!shown.current) return;
81
+ shown.current = false;
82
+ if (!awaitsDismiss && typeof onDismissRef.current === "function") onDismissRef.current();
83
+ }, [visible]);
84
+
85
+ return hostDismissing ? hostDismissed : null;
86
+ }
42
87
 
43
88
  function Overlay({
44
89
  visible = false,
@@ -53,6 +98,9 @@ export function makeOverlay(rn) {
53
98
  closeAccessibilityLabel = "Close",
54
99
  initialFocusRef,
55
100
  panelFrame: PanelFrame,
101
+ anchoredAt,
102
+ passive = false,
103
+ onDismiss,
56
104
  style,
57
105
  testID,
58
106
  children,
@@ -65,36 +113,28 @@ export function makeOverlay(rn) {
65
113
  const requestClose = React.useCallback(() => {
66
114
  if (typeof onRequestClose === "function") onRequestClose();
67
115
  }, [onRequestClose]);
116
+ const dismissing = useDismissal(visible, onDismiss);
68
117
 
118
+ if (dismissing) {
119
+ return React.createElement(Modal, {
120
+ visible: false,
121
+ transparent: true,
122
+ animationType: "none",
123
+ onDismiss: dismissing,
124
+ });
125
+ }
69
126
  // Unmounted while closed: `Modal` appends a host container the moment it
70
127
  // renders, so every widget on the page would otherwise leave one behind.
71
128
  if (!visible) return null;
72
129
 
73
- const backdrop = {
74
- flex: 1,
75
- alignItems: "center",
76
- justifyContent: tokens.verticalAlign,
77
- padding: tokens.screenPadding,
78
- ...(tokens.topGap ? { paddingTop: tokens.topGap } : null),
79
- backgroundColor: tokens.scrim,
80
- };
81
- const panel = {
82
- width: "100%",
83
- // Capped to the padded screen box so a tall panel scrolls inside its own
84
- // ScrollView instead of overflowing past the viewport unreachably.
85
- maxHeight: "100%",
86
- flexShrink: 1,
87
- ...(tokens.maxWidth ? { maxWidth: tokens.maxWidth } : { flex: 1 }),
88
- backgroundColor: tokens.surface,
89
- ...overlayCornerStyle(tokens.radius, tokens.presentation),
90
- padding: tokens.padding,
91
- ...tokens.elevation,
92
- };
130
+ const frame = overlayFrameStyles(tokens, anchoredAt);
131
+ // A passive layer lets the pointer through everywhere but its panel.
132
+ const backdrop = passive ? [frame.backdrop, passiveBackdropStyle()] : frame.backdrop;
93
133
 
94
134
  const panelNode = React.createElement(
95
135
  View,
96
136
  {
97
- style: [panel, style],
137
+ style: [frame.panel, style],
98
138
  accessibilityLabel: namesPanel ? accessibilityLabel : undefined,
99
139
  testID,
100
140
  },
@@ -113,11 +153,13 @@ export function makeOverlay(rn) {
113
153
  "aria-label": accessibilityLabelledBy ? undefined : accessibilityLabel,
114
154
  "aria-labelledby": accessibilityLabelledBy,
115
155
  initialFocusRef,
156
+ ...(passive ? { passive } : null),
157
+ ...(anchoredAt ? { closesOnOutsideScroll: true } : null),
116
158
  },
117
159
  React.createElement(
118
160
  View,
119
161
  { style: backdrop },
120
- dismissOnBackdropPress
162
+ dismissOnBackdropPress && !passive
121
163
  ? React.createElement(Pressable, {
122
164
  style: StyleSheet.absoluteFill,
123
165
  onPress: requestClose,
@@ -130,6 +172,7 @@ export function makeOverlay(rn) {
130
172
  })
131
173
  : null,
132
174
  // @internal `panelFrame`: `<Modal>` wraps its panel, never the scrim.
175
+ // @internal `anchoredAt` / `passive` / `onDismiss`: `<Popover>`, `<Menu>`, `<Tooltip>`.
133
176
  PanelFrame
134
177
  ? React.createElement(PanelFrame, { verticalAlign: tokens.verticalAlign }, panelNode)
135
178
  : panelNode,
@@ -5,14 +5,16 @@
5
5
  // hardware back button to `onRequestClose`, matching what react-native-web's
6
6
  // portal does on the web (./overlay.js). Only the import differs.
7
7
 
8
- import { Modal, View, Pressable, StyleSheet } from "react-native";
8
+ import { Modal, View, Pressable, StyleSheet, Platform } from "react-native";
9
9
  import { makeOverlay } from "./overlay-view.js";
10
10
 
11
11
  // RN's `Modal` forwards no ARIA props, so the panel carries the name here.
12
+ // iOS cannot present a modal while another is still being dismissed.
12
13
  export const Overlay = makeOverlay({
13
14
  Modal,
14
15
  View,
15
16
  Pressable,
16
17
  StyleSheet,
17
18
  namesPanel: true,
19
+ awaitsDismiss: Platform.OS === "ios",
18
20
  });
@@ -0,0 +1,145 @@
1
+ // The values `<Menu>`, `<Popover>` and `<Tooltip>` paint and place with, and
2
+ // the `popover` scope look `<Select>`'s option sheet shares. No renderer and
3
+ // no React here, so both hosts share it and the node suite tests it.
4
+
5
+ import { DEFAULT_THEME_TOKENS } from "./_theme-tokens.js";
6
+ import { isNarrowWidth } from "./container-width.js";
7
+ import { overlayCornerStyle } from "./overlay-tokens.js";
8
+ import { normaliseThemeComponents } from "./theme-components.js";
9
+
10
+ export const POPOVER_PLACEMENTS = Object.freeze(["bottom", "top"]);
11
+ export const POPOVER_BORDER_WIDTH = 1;
12
+
13
+ export const POPOVER_DEFAULTS = Object.freeze({
14
+ // Between the trigger and the panel.
15
+ gap: 4,
16
+ // The closest a panel comes to a screen edge.
17
+ screenMargin: 8,
18
+ menuMinWidth: 180,
19
+ menuMaxWidth: 320,
20
+ popoverMaxWidth: 360,
21
+ tooltipMaxWidth: 240,
22
+ // A pointer passing over must not flash a tooltip; leaving waits a beat so
23
+ // the pointer can cross the gap onto the bubble.
24
+ tooltipShowDelayMs: 400,
25
+ tooltipHideDelayMs: 150,
26
+ });
27
+
28
+ // A themed shadow replaces the overlay's, so a lower level must zero each key.
29
+ const NO_SHADOW = Object.freeze({ shadowOpacity: 0, shadowRadius: 0, elevation: 0 });
30
+
31
+ export function normalisePopoverPlacement(placement) {
32
+ return POPOVER_PLACEMENTS.includes(placement) ? placement : "bottom";
33
+ }
34
+
35
+ /** The theme's validated `popover` scope (Design > Components > Dropdown menus). */
36
+ export function popoverThemeScope(theme) {
37
+ const components = theme && typeof theme === "object" ? theme.components : null;
38
+ return normaliseThemeComponents({ popover: components && components.popover }).popover || {};
39
+ }
40
+
41
+ /**
42
+ * Resolve the `popover` scope. `radius` and `shadow` stay null while unset,
43
+ * so the panel keeps the `<Overlay>` look every option sheet had before.
44
+ *
45
+ * @param {object} theme — the resolved workspace theme (`useTheme()` shape).
46
+ */
47
+ export function resolvePopoverTokens(theme) {
48
+ const base = theme && typeof theme === "object" ? theme : DEFAULT_THEME_TOKENS;
49
+ const colors = base.colors || DEFAULT_THEME_TOKENS.colors;
50
+ const levels = base.elevation || DEFAULT_THEME_TOKENS.elevation;
51
+ const scope = popoverThemeScope(base);
52
+ return {
53
+ background: scope.background || colors.surface,
54
+ textColor: scope.textColor || colors.onSurface,
55
+ borderColor: scope.borderColor || colors.border,
56
+ radius: scope.radius ?? null,
57
+ shadow: scope.shadow ? { ...NO_SHADOW, ...levels[scope.shadow] } : null,
58
+ dangerColor: colors.danger || DEFAULT_THEME_TOKENS.colors.danger,
59
+ mutedColor: colors.onSurfaceMuted || DEFAULT_THEME_TOKENS.colors.onSurfaceMuted,
60
+ };
61
+ }
62
+
63
+ /**
64
+ * The style a `popover`-scope panel lays over the `<Overlay>` panel.
65
+ *
66
+ * @param {object} popover — `resolvePopoverTokens()`'s result.
67
+ * @param {"dialog"|"sheet"} presentation — a sheet rounds its top corners only.
68
+ */
69
+ export function popoverPanelStyle(popover, presentation) {
70
+ return {
71
+ backgroundColor: popover.background,
72
+ borderWidth: POPOVER_BORDER_WIDTH,
73
+ borderColor: popover.borderColor,
74
+ ...(popover.radius === null ? null : overlayCornerStyle(popover.radius, presentation)),
75
+ ...popover.shadow,
76
+ };
77
+ }
78
+
79
+ /**
80
+ * Whether an anchored surface opens as a bottom sheet instead: always on a
81
+ * device, where a sheet is the platform's own menu, and in a narrow window.
82
+ *
83
+ * @param {string} os — `Platform.OS`.
84
+ * @param {number} windowWidth
85
+ */
86
+ export function presentsAsSheet(os, windowWidth) {
87
+ return os !== "web" || isNarrowWidth(windowWidth);
88
+ }
89
+
90
+ function clamp(value, min, max) {
91
+ return Math.max(min, Math.min(value, max));
92
+ }
93
+
94
+ /**
95
+ * Where a panel goes beside its trigger, in window coordinates. It opens on
96
+ * the preferred side, flips to the other when that side is short of room and
97
+ * the other has more, lines up with the trigger's start edge (its end edge
98
+ * when that would run off screen), and never leaves the screen margin.
99
+ *
100
+ * @param {{x: number, y: number, width: number, height: number}} anchor
101
+ * @param {{width: number, height: number}} panel
102
+ * @param {{width: number, height: number}} viewport
103
+ * @param {{placement?: string, gap?: number, margin?: number}} [options]
104
+ * @returns {{top: number, left: number, placement: "bottom"|"top", maxHeight: number}}
105
+ */
106
+ export function placePopover(anchor, panel, viewport, options = {}) {
107
+ const gap = options.gap ?? POPOVER_DEFAULTS.gap;
108
+ const margin = options.margin ?? POPOVER_DEFAULTS.screenMargin;
109
+ const preferred = normalisePopoverPlacement(options.placement);
110
+ const below = viewport.height - margin - (anchor.y + anchor.height + gap);
111
+ const above = anchor.y - gap - margin;
112
+ const fitsPreferred = (preferred === "bottom" ? below : above) >= panel.height;
113
+ const flips = !fitsPreferred && (preferred === "bottom" ? above > below : below > above);
114
+ const placement = flips ? (preferred === "bottom" ? "top" : "bottom") : preferred;
115
+ const room = Math.max(0, placement === "bottom" ? below : above);
116
+ const height = Math.min(panel.height, room);
117
+ const top =
118
+ placement === "bottom" ? anchor.y + anchor.height + gap : anchor.y - gap - height;
119
+
120
+ const endAligned = anchor.x + anchor.width - panel.width;
121
+ const startFits = anchor.x + panel.width <= viewport.width - margin;
122
+ const left = clamp(
123
+ startFits ? anchor.x : endAligned,
124
+ margin,
125
+ Math.max(margin, viewport.width - margin - panel.width),
126
+ );
127
+ return { top: Math.max(margin, top), left, placement, maxHeight: room };
128
+ }
129
+
130
+ /**
131
+ * A `<Menu>`'s items as it draws them: falsy entries and entries with no
132
+ * label dropped, `disabled` a boolean, and a stable `key` on each.
133
+ *
134
+ * @param {Array<object|null|false|undefined>} items
135
+ */
136
+ export function menuItemList(items) {
137
+ const hasLabel = (item) => typeof item.label === "string" ? item.label !== "" : typeof item.label === "number";
138
+ return (Array.isArray(items) ? items : [])
139
+ .filter((item) => item && typeof item === "object" && hasLabel(item))
140
+ .map((item, index) => ({
141
+ ...item,
142
+ disabled: Boolean(item.disabled),
143
+ key: item.key !== undefined && item.key !== null ? String(item.key) : `${index}:${item.label}`,
144
+ }));
145
+ }
@@ -0,0 +1,252 @@
1
+ // The ONE implementation behind `<Popover>`, and the anchored surface that
2
+ // `<Menu>` and `<Tooltip>` open with. It is drawn in `<Overlay>`, so no card
3
+ // or scroll container clips it on either host. It sits beside its trigger,
4
+ // measured with `measureInWindow`, and flips at the screen edge. On a device
5
+ // and in a narrow window it is a bottom sheet instead.
6
+ //
7
+ // Bound per host by popover.js / popover.native.js, which differ only in
8
+ // where the primitives come from.
9
+
10
+ import React from "react";
11
+ import { ThemeScope, useHostTheme } from "./hooks.js";
12
+ import { DEFAULT_THEME_TOKENS } from "./_theme-tokens.js";
13
+ import {
14
+ POPOVER_BORDER_WIDTH,
15
+ POPOVER_DEFAULTS,
16
+ placePopover,
17
+ popoverPanelStyle,
18
+ popoverThemeScope,
19
+ presentsAsSheet,
20
+ resolvePopoverTokens,
21
+ } from "./popover-tokens.js";
22
+ import { compositeOver } from "./readable-ink.js";
23
+ import { surfaceAwareTheme } from "./surface-theme.js";
24
+
25
+ const h = React.createElement;
26
+ const UNMEASURABLE = "unmeasurable";
27
+ const HIDDEN = Object.freeze({ opacity: 0 });
28
+ const useNoInsets = () => null;
29
+ const sameSize = (a, b) => Math.abs(a.width - b.width) <= 1 && Math.abs(a.height - b.height) <= 1;
30
+
31
+ /** The ref an element was created with, on React 18 and on React 19. */
32
+ export function elementRef(element) {
33
+ const major = Number(String(React.version).split(".")[0]);
34
+ return major >= 19 ? element.props.ref : element.ref;
35
+ }
36
+
37
+ /** One callback ref that feeds every ref given, function or object. */
38
+ export function mergeRefs(...refs) {
39
+ return (node) => {
40
+ for (const ref of refs) {
41
+ if (typeof ref === "function") ref(node);
42
+ else if (ref && typeof ref === "object") ref.current = node;
43
+ }
44
+ };
45
+ }
46
+
47
+ /**
48
+ * The trigger element, cloned so a press opens the surface and the surface
49
+ * can measure it. Its own `onPress` still runs first.
50
+ */
51
+ export function triggerWith(trigger, { anchorRef, onOpen, extraProps }) {
52
+ if (!React.isValidElement(trigger)) return null;
53
+ const own = trigger.props.onPress;
54
+ return React.cloneElement(trigger, {
55
+ ref: mergeRefs(elementRef(trigger), anchorRef),
56
+ onPress: (...args) => {
57
+ if (typeof own === "function") own(...args);
58
+ onOpen();
59
+ },
60
+ ...extraProps,
61
+ });
62
+ }
63
+
64
+ // The trigger's window rectangle, measured each time the surface opens.
65
+ function useAnchorRect(anchorRef, open, windowWidth, windowHeight) {
66
+ const [rect, setRect] = React.useState(null);
67
+ React.useEffect(() => {
68
+ if (!open) {
69
+ setRect(null);
70
+ return undefined;
71
+ }
72
+ const node = anchorRef.current;
73
+ if (!node || typeof node.measureInWindow !== "function") {
74
+ setRect(UNMEASURABLE);
75
+ return undefined;
76
+ }
77
+ let live = true;
78
+ node.measureInWindow((x, y, width, height) => {
79
+ if (live) setRect({ x, y, width, height });
80
+ });
81
+ return () => {
82
+ live = false;
83
+ };
84
+ }, [anchorRef, open, windowWidth, windowHeight]);
85
+ return rect;
86
+ }
87
+
88
+ // A popover scope background re-inks what is drawn on it, as `<Modal>` does.
89
+ function usePanelTheme(theme) {
90
+ return React.useMemo(() => {
91
+ const { background } = popoverThemeScope(theme);
92
+ if (!background) return null;
93
+ const surface = (theme.colors || DEFAULT_THEME_TOKENS.colors).surface;
94
+ return surfaceAwareTheme(theme, compositeOver(background, surface));
95
+ }, [theme]);
96
+ }
97
+
98
+ /**
99
+ * Bind the shared surface and `<Popover>` to one platform's primitives.
100
+ *
101
+ * @param {{View: any, Platform: any, useWindowDimensions: () => {width: number,
102
+ * height: number}, useSafeAreaInsets?: () => ({bottom: number}|null)}} rn
103
+ * @param {any} Overlay — the host's bound `<Overlay>`.
104
+ */
105
+ export function makePopover(rn, Overlay) {
106
+ const { View, Platform, useWindowDimensions } = rn;
107
+ const useSafeAreaInsets = rn.useSafeAreaInsets || useNoInsets;
108
+
109
+ /**
110
+ * @internal The panel `<Popover>`, `<Menu>` and `<Tooltip>` open. `passive`
111
+ * (a hovered tooltip) never takes focus or blocks the pointer. `onDismiss`
112
+ * runs once a closed panel has left the screen (see `<Overlay>`).
113
+ */
114
+ function AnchoredSurface({
115
+ visible,
116
+ onRequestClose,
117
+ onDismiss,
118
+ anchorRef,
119
+ placement,
120
+ passive = false,
121
+ padding = 0,
122
+ minWidth,
123
+ maxWidth,
124
+ accessibilityLabel,
125
+ closeAccessibilityLabel,
126
+ testID,
127
+ children,
128
+ }) {
129
+ const theme = useHostTheme() || DEFAULT_THEME_TOKENS;
130
+ const { width, height } = useWindowDimensions();
131
+ const insets = useSafeAreaInsets();
132
+ const popover = React.useMemo(() => resolvePopoverTokens(theme), [theme]);
133
+ const panelTheme = usePanelTheme(theme);
134
+ const rect = useAnchorRect(anchorRef, visible, width, height);
135
+ const [size, setSize] = React.useState(null);
136
+ React.useEffect(() => setSize(null), [visible]);
137
+
138
+ // Still rendered while closed, so the overlay can report its dismissal.
139
+ if (!visible) return h(Overlay, { visible: false, onDismiss });
140
+ const body = h(ThemeScope, { theme: panelTheme }, children);
141
+ const shared = { visible: true, onRequestClose, onDismiss, accessibilityLabel, testID };
142
+ if (closeAccessibilityLabel) shared.closeAccessibilityLabel = closeAccessibilityLabel;
143
+
144
+ if (passive && rect === UNMEASURABLE) return null;
145
+ if (!passive && (presentsAsSheet(Platform.OS, width) || rect === UNMEASURABLE)) {
146
+ const bottom = (insets && insets.bottom) || 0;
147
+ return h(
148
+ Overlay,
149
+ {
150
+ ...shared,
151
+ presentation: "sheet",
152
+ style: { ...popoverPanelStyle(popover, "sheet"), padding, paddingBottom: padding + bottom },
153
+ },
154
+ body,
155
+ );
156
+ }
157
+
158
+ // Placed once the panel's natural size is known, and again when it
159
+ // changes (content that grows must not cover the trigger); hidden until then.
160
+ const place =
161
+ rect && rect !== UNMEASURABLE && size
162
+ ? placePopover(rect, size, { width, height }, { placement })
163
+ : null;
164
+ const onLayout = (event) => {
165
+ const { layout } = event.nativeEvent;
166
+ const border = 2 * POPOVER_BORDER_WIDTH;
167
+ const next = { width: layout.width + border, height: layout.height + border };
168
+ setSize((own) => (own && sameSize(own, next) ? own : next));
169
+ };
170
+ return h(
171
+ Overlay,
172
+ {
173
+ ...shared,
174
+ passive,
175
+ dismissOnBackdropPress: !passive,
176
+ backdropAccessible: false,
177
+ anchoredAt: place ? { top: place.top, left: place.left } : { top: 0, left: 0 },
178
+ style: [
179
+ { ...popoverPanelStyle(popover, "dialog"), padding: 0, minWidth, maxWidth },
180
+ place ? { maxHeight: place.maxHeight } : HIDDEN,
181
+ ],
182
+ },
183
+ h(View, { onLayout, style: { padding, flexShrink: 1 } }, body),
184
+ );
185
+ }
186
+
187
+ /**
188
+ * Free content beside a trigger: a filter form, a colour swatch picker, a
189
+ * detail card. A press on `trigger` opens it; the backdrop, Escape (web)
190
+ * and the Android back button close it.
191
+ *
192
+ * @param {object} props
193
+ * @param {any} props.trigger — the pressable element that opens it.
194
+ * @param {any|((api: {close: () => void}) => any)} props.children
195
+ * @param {boolean} [props.visible] — set it to control the popover.
196
+ * @param {(visible: boolean) => void} [props.onVisibleChange]
197
+ * @param {"bottom"|"top"} [props.placement] — default "bottom"; flips.
198
+ * @param {string} [props.accessibilityLabel] — names the panel.
199
+ * @param {string} [props.closeAccessibilityLabel] — the sheet backdrop's name.
200
+ * @param {string} [props.testID] — on the panel.
201
+ */
202
+ function Popover({
203
+ trigger,
204
+ children,
205
+ visible,
206
+ onVisibleChange,
207
+ placement = "bottom",
208
+ accessibilityLabel,
209
+ closeAccessibilityLabel,
210
+ testID,
211
+ }) {
212
+ const theme = useHostTheme() || DEFAULT_THEME_TOKENS;
213
+ const anchorRef = React.useRef(null);
214
+ const [ownOpen, setOwnOpen] = React.useState(false);
215
+ const controlled = typeof visible === "boolean";
216
+ const open = controlled ? visible : ownOpen;
217
+ const setOpen = (next) => {
218
+ if (!controlled) setOwnOpen(next);
219
+ if (typeof onVisibleChange === "function") onVisibleChange(next);
220
+ };
221
+ const close = () => setOpen(false);
222
+ const spacing = theme.spacing || DEFAULT_THEME_TOKENS.spacing;
223
+
224
+ return h(
225
+ React.Fragment,
226
+ null,
227
+ triggerWith(trigger, {
228
+ anchorRef,
229
+ onOpen: () => setOpen(true),
230
+ extraProps: { "aria-haspopup": "dialog", "aria-expanded": open },
231
+ }),
232
+ h(
233
+ AnchoredSurface,
234
+ {
235
+ visible: open,
236
+ onRequestClose: close,
237
+ anchorRef,
238
+ placement,
239
+ padding: spacing.md,
240
+ maxWidth: POPOVER_DEFAULTS.popoverMaxWidth,
241
+ accessibilityLabel,
242
+ closeAccessibilityLabel,
243
+ testID,
244
+ },
245
+ typeof children === "function" ? children({ close }) : children,
246
+ ),
247
+ );
248
+ }
249
+
250
+ Popover.displayName = "Popover";
251
+ return { Popover, AnchoredSurface };
252
+ }
@@ -0,0 +1,23 @@
1
+ // `<Popover>`, `<Menu>` and `<Tooltip>` (web). Bind ./popover-view.js,
2
+ // ./menu-view.js and ./tooltip-view.js to react-native-web and the web
3
+ // `<Overlay>`; see ./primitives.js for why not the bare specifier.
4
+
5
+ import * as ReactNative from "react-native-web";
6
+ import { makePopover } from "./popover-view.js";
7
+ import { makeMenu } from "./menu-view.js";
8
+ import { makeTooltip } from "./tooltip-view.js";
9
+ import { Overlay } from "./overlay.js";
10
+
11
+ const primitives = {
12
+ View: ReactNative.View,
13
+ Text: ReactNative.Text,
14
+ Pressable: ReactNative.Pressable,
15
+ ScrollView: ReactNative.ScrollView,
16
+ Platform: ReactNative.Platform,
17
+ useWindowDimensions: ReactNative.useWindowDimensions,
18
+ };
19
+
20
+ const { Popover, AnchoredSurface } = makePopover(primitives, Overlay);
21
+ export { Popover };
22
+ export const Menu = makeMenu(primitives, AnchoredSurface);
23
+ export const Tooltip = makeTooltip(primitives, AnchoredSurface);
@@ -0,0 +1,22 @@
1
+ // `<Popover>`, `<Menu>` and `<Tooltip>` (native). Bind the shared views to
2
+ // React Native's own primitives and the native `<Overlay>`; only the imports
3
+ // differ from ./popover.js, plus the safe-area inset a bottom sheet pads by.
4
+
5
+ import React from "react";
6
+ import { View, Text, Pressable, ScrollView, Platform, useWindowDimensions } from "react-native";
7
+ // Host-provided: the exported app pins it and mounts its provider at the root.
8
+ import { SafeAreaInsetsContext } from "react-native-safe-area-context";
9
+ import { makePopover } from "./popover-view.js";
10
+ import { makeMenu } from "./menu-view.js";
11
+ import { makeTooltip } from "./tooltip-view.js";
12
+ import { Overlay } from "./overlay.native.js";
13
+
14
+ // Null with no provider above, which the view reads as "no insets".
15
+ const useSafeAreaInsets = () => React.useContext(SafeAreaInsetsContext);
16
+
17
+ const primitives = { View, Text, Pressable, ScrollView, Platform, useWindowDimensions, useSafeAreaInsets };
18
+
19
+ const { Popover, AnchoredSurface } = makePopover(primitives, Overlay);
20
+ export { Popover };
21
+ export const Menu = makeMenu(primitives, AnchoredSurface);
22
+ export const Tooltip = makeTooltip(primitives, AnchoredSurface);
@@ -0,0 +1,61 @@
1
+ // The `<RadioGroup>` rules that need no renderer: which option is picked,
2
+ // which one holds the group's single tab stop, and where an arrow key moves
3
+ // the choice. Both hosts share them, and the node suite tests them.
4
+
5
+ export const RADIO_GROUP_VARIANTS = Object.freeze(["list", "card", "segmented"]);
6
+ export const DEFAULT_RADIO_GROUP_VARIANT = "list";
7
+
8
+ const ARROW_STEP = Object.freeze({ ArrowDown: 1, ArrowRight: 1, ArrowUp: -1, ArrowLeft: -1 });
9
+
10
+ /** A requested variant, or the list when it names none we draw. */
11
+ export function radioGroupVariant(variant) {
12
+ return RADIO_GROUP_VARIANTS.includes(variant) ? variant : DEFAULT_RADIO_GROUP_VARIANT;
13
+ }
14
+
15
+ function optionList(options) {
16
+ return Array.isArray(options) ? options.filter((option) => option && typeof option === "object") : [];
17
+ }
18
+
19
+ function pickable(option, groupDisabled) {
20
+ return !groupDisabled && !option.disabled;
21
+ }
22
+
23
+ /** The index of the option whose `value` is `value` (strict), or -1. */
24
+ export function checkedIndex(options, value) {
25
+ return optionList(options).findIndex((option) => option.value === value);
26
+ }
27
+
28
+ /**
29
+ * The option a Tab key lands on: the picked one when it can be picked, else
30
+ * the first that can, else -1 (nothing to focus).
31
+ *
32
+ * @param {Array<{value: any, disabled?: boolean}>} options
33
+ * @param {any} value
34
+ * @param {boolean} [groupDisabled]
35
+ */
36
+ export function tabStopIndex(options, value, groupDisabled = false) {
37
+ const list = optionList(options);
38
+ const picked = checkedIndex(list, value);
39
+ if (picked !== -1 && pickable(list[picked], groupDisabled)) return picked;
40
+ return list.findIndex((option) => pickable(option, groupDisabled));
41
+ }
42
+
43
+ /**
44
+ * The option an arrow key moves the choice to: the next pickable one in that
45
+ * direction, wrapping at either end. Returns -1 for any other key, or when no
46
+ * other option can be picked.
47
+ *
48
+ * @param {Array<{disabled?: boolean}>} options
49
+ * @param {number} from — the focused option's index.
50
+ * @param {string} key — a KeyboardEvent `key`.
51
+ */
52
+ export function arrowTargetIndex(options, from, key) {
53
+ const step = ARROW_STEP[key];
54
+ const list = optionList(options);
55
+ if (!step || list.length < 2) return -1;
56
+ for (let hop = 1; hop < list.length; hop += 1) {
57
+ const at = (((from + step * hop) % list.length) + list.length) % list.length;
58
+ if (pickable(list[at], false)) return at;
59
+ }
60
+ return -1;
61
+ }