@colixsystems/widget-sdk 0.245.0 → 0.247.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.
Files changed (56) hide show
  1. package/README.md +60 -2
  2. package/dist/button-tokens.js +154 -0
  3. package/dist/button-view.js +251 -93
  4. package/dist/button.js +5 -3
  5. package/dist/button.native.js +6 -4
  6. package/dist/chart-logic.js +575 -0
  7. package/dist/chart-tokens.js +131 -0
  8. package/dist/chart-view.js +262 -0
  9. package/dist/chart.js +21 -0
  10. package/dist/chart.native.js +10 -0
  11. package/dist/contract.cjs +132 -35
  12. package/dist/contract.js +132 -35
  13. package/dist/detail-parts-view.js +31 -2
  14. package/dist/file-image-logic.js +162 -0
  15. package/dist/file-image-source.js +71 -0
  16. package/dist/file-image-view.js +119 -0
  17. package/dist/file-image.js +8 -0
  18. package/dist/file-image.native.js +8 -0
  19. package/dist/form-control-logic.js +23 -0
  20. package/dist/form-control-tokens.js +25 -10
  21. package/dist/grid-logic.js +222 -0
  22. package/dist/grid-view.js +109 -0
  23. package/dist/grid.js +7 -0
  24. package/dist/grid.native.js +7 -0
  25. package/dist/host.d.ts +6 -0
  26. package/dist/host.js +2 -0
  27. package/dist/index.d.ts +481 -20
  28. package/dist/index.js +14 -1
  29. package/dist/index.native.js +14 -1
  30. package/dist/menu-view.js +168 -0
  31. package/dist/overlay-tokens.js +50 -0
  32. package/dist/overlay-view.js +67 -24
  33. package/dist/overlay.native.js +3 -1
  34. package/dist/people-picker-logic.js +203 -0
  35. package/dist/people-picker-view.js +247 -0
  36. package/dist/people-picker.js +11 -0
  37. package/dist/people-picker.native.js +9 -0
  38. package/dist/popover-tokens.js +145 -0
  39. package/dist/popover-view.js +252 -0
  40. package/dist/popover.js +23 -0
  41. package/dist/popover.native.js +22 -0
  42. package/dist/radio-group-logic.js +61 -0
  43. package/dist/radio-group-tokens.js +62 -0
  44. package/dist/radio-group-view.js +262 -0
  45. package/dist/radio-group.js +12 -0
  46. package/dist/radio-group.native.js +7 -0
  47. package/dist/select-search-logic.js +150 -0
  48. package/dist/select-view.js +255 -43
  49. package/dist/select.js +6 -2
  50. package/dist/select.native.js +5 -3
  51. package/dist/tabs-tokens.js +59 -0
  52. package/dist/tabs-view.js +9 -43
  53. package/dist/tooltip-view.js +137 -0
  54. package/dist/web-modal.js +95 -23
  55. package/package.json +1 -1
  56. package/src/contract.js +132 -35
@@ -0,0 +1,247 @@
1
+ // The ONE implementation behind the `<PeoplePicker>` SDK primitive: a
2
+ // searchable `<Select>` over the workspace's users, its groups, or one
3
+ // table's records, read through the SDK's own directory and datastore hooks.
4
+ // The platform bindings (`people-picker.js` / `people-picker.native.js`)
5
+ // differ only in which host's `<Select>` and `<Avatar>` they pass.
6
+ //
7
+ // Nothing is fetched until the sheet first opens; each pause in typing asks
8
+ // the server again. A saved choice the page does not hold is looked up once
9
+ // by id, so the trigger names it and never shows the raw id.
10
+
11
+ import React from "react";
12
+ import {
13
+ useDatastoreQuery,
14
+ useDatastoreRecord,
15
+ useDatastoreSchema,
16
+ useDirectory,
17
+ useGroups,
18
+ } from "./hooks.js";
19
+ import {
20
+ answersQuery,
21
+ lookupableIds,
22
+ offeredRows,
23
+ pageQuery,
24
+ pickedIds,
25
+ pickedLabels,
26
+ pickerSource,
27
+ queueLookups,
28
+ recordLabelColumn,
29
+ rememberRows,
30
+ rowLabel,
31
+ settleLookup,
32
+ unresolvedIds,
33
+ } from "./people-picker-logic.js";
34
+
35
+ const h = React.createElement;
36
+ const NO_ROWS = Object.freeze([]);
37
+ const NO_LOOKUPS = Object.freeze({ pending: [], next: 0 });
38
+
39
+ function serialize(query) {
40
+ try {
41
+ return JSON.stringify(query);
42
+ } catch (_e) {
43
+ return null;
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Bind the shared people picker to one host's `<Select>` and `<Avatar>`.
49
+ *
50
+ * @param {{View: any, Select: any, Avatar: any}} rn
51
+ */
52
+ export function makePeoplePicker(rn) {
53
+ const { View, Select, Avatar } = rn;
54
+
55
+ // Hands one hook result up once it answers the current query.
56
+ function useReport(onReport, query, rows, loading, error, sent) {
57
+ const queryKey = serialize(query);
58
+ const last = React.useRef(null);
59
+ React.useEffect(() => {
60
+ const now = { queryKey, rows, error, loading };
61
+ if (!answersQuery(last.current, now)) return;
62
+ last.current = now;
63
+ onReport({ rows: Array.isArray(rows) ? rows : NO_ROWS, loading, error: error || null, query: sent });
64
+ // eslint-disable-next-line react-hooks/exhaustive-deps
65
+ }, [queryKey, rows, loading, error, sent]);
66
+ }
67
+
68
+ function UsersPage({ query, sent, onReport }) {
69
+ const { users, loading, error } = useDirectory(query);
70
+ useReport(onReport, query, users, loading, error, sent);
71
+ return null;
72
+ }
73
+
74
+ function GroupsPage({ query, sent, onReport }) {
75
+ const { groups, loading, error } = useGroups(query);
76
+ useReport(onReport, query, groups, loading, error, sent);
77
+ return null;
78
+ }
79
+
80
+ function RecordsPage({ table, query, sent, onReport }) {
81
+ const { data, loading, fetching, error } = useDatastoreQuery(table, query);
82
+ useReport(onReport, query, data, loading || fetching, error, sent);
83
+ return null;
84
+ }
85
+
86
+ // A lookup reports once it settles; a failed one names nothing.
87
+ function useResolved(onResolved, lookup, settled, rows) {
88
+ React.useEffect(() => {
89
+ if (settled) onResolved(lookup, rows || NO_ROWS);
90
+ // eslint-disable-next-line react-hooks/exhaustive-deps
91
+ }, [settled]);
92
+ }
93
+
94
+ function UsersLookup({ lookup, onResolved }) {
95
+ const { users, loading, error } = useDirectory({ ids: lookup.ids, limit: lookup.ids.length });
96
+ useResolved(onResolved, lookup, !loading, error ? null : users);
97
+ return null;
98
+ }
99
+
100
+ function GroupsLookup({ lookup, onResolved }) {
101
+ const { groups, loading, error } = useGroups({ ids: lookup.ids, limit: lookup.ids.length });
102
+ useResolved(onResolved, lookup, !loading, error ? null : groups);
103
+ return null;
104
+ }
105
+
106
+ // The datastore list filters no system `id`, so each saved record is read alone.
107
+ function RecordLookup({ table, lookup, onResolved }) {
108
+ const { data, loading, error } = useDatastoreRecord(table, lookup.ids[0]);
109
+ useResolved(onResolved, lookup, !loading, error || !data ? null : [data]);
110
+ return null;
111
+ }
112
+
113
+ const LOOKUPS = { users: UsersLookup, groups: GroupsLookup, records: RecordLookup };
114
+
115
+ function avatarFor(source, row, label, avatarColumn) {
116
+ if (source === "records") {
117
+ if (!avatarColumn) return undefined;
118
+ const fileId = typeof row[avatarColumn] === "string" ? row[avatarColumn] : null;
119
+ return h(Avatar, { name: label, fileId, seed: String(row.id), size: "sm" });
120
+ }
121
+ return h(Avatar, {
122
+ name: label,
123
+ seed: String(row.id),
124
+ size: "sm",
125
+ shape: source === "groups" ? "rounded" : "circle",
126
+ });
127
+ }
128
+
129
+ // The row's name is its text; the picture only decorates it.
130
+ function decoration(node) {
131
+ if (!node) return undefined;
132
+ return h(
133
+ View,
134
+ {
135
+ "aria-hidden": true,
136
+ importantForAccessibility: "no-hide-descendants",
137
+ accessibilityElementsHidden: true,
138
+ },
139
+ node,
140
+ );
141
+ }
142
+
143
+ function SourcePicker(props) {
144
+ const {
145
+ source,
146
+ table,
147
+ labelColumn,
148
+ avatarColumn,
149
+ groupId,
150
+ filter,
151
+ excludeIds,
152
+ pageSize,
153
+ showAvatars = true,
154
+ unknownText = "Unknown",
155
+ loadingText = "Loading…",
156
+ multiple = false,
157
+ value,
158
+ values,
159
+ forwardedRef,
160
+ ...rest
161
+ } = props;
162
+ const [sent, setSent] = React.useState(null);
163
+ const [page, setPage] = React.useState({ rows: NO_ROWS, loading: false, error: null, query: null });
164
+ const [knownRows, setKnownRows] = React.useState(() => new Map());
165
+ const [attempted, setAttempted] = React.useState(() => new Set());
166
+ const [lookups, setLookups] = React.useState(NO_LOOKUPS);
167
+
168
+ const how = { source, labelColumn, unknownText };
169
+ const nameOf = (row) => rowLabel(row, how);
170
+ const toOption = (row) => {
171
+ const label = nameOf(row);
172
+ return {
173
+ value: row.id,
174
+ label,
175
+ ...(showAvatars ? { leading: decoration(avatarFor(source, row, label, avatarColumn)) } : null),
176
+ };
177
+ };
178
+
179
+ const onReport = React.useCallback((result) => {
180
+ setPage(result);
181
+ if (result.rows.length) setKnownRows((own) => rememberRows(own, result.rows));
182
+ }, []);
183
+ const onResolved = React.useCallback((lookup, rows) => {
184
+ setKnownRows((own) => rememberRows(own, rows));
185
+ setAttempted((own) => new Set([...own, ...lookup.ids.map(String)]));
186
+ setLookups((own) => settleLookup(own, lookup.key));
187
+ }, []);
188
+
189
+ // A record source with no table bound yet has nothing to read.
190
+ const readable = source !== "records" || (typeof table === "string" && table !== "");
191
+ const ids = pickedIds({ multiple, value, values });
192
+ const missing = readable ? unresolvedIds(ids, knownRows, attempted) : [];
193
+ const { lookup, unknown } = lookupableIds(missing, source);
194
+ const named = unknown.length ? new Set([...attempted, ...unknown.map(String)]) : attempted;
195
+ const query = sent === null || !readable ? null : pageQuery(source, { query: sent, pageSize, groupId, filter });
196
+ const options = offeredRows(page.rows, excludeIds).map(toOption);
197
+ const selectedOptions = pickedLabels(ids, { knownRows, attempted: named, nameOf, loadingText, unknownText }).map(
198
+ (own) => (own.row ? toOption(own.row) : own),
199
+ );
200
+
201
+ const lookupKey = lookup.join(",");
202
+ React.useEffect(() => {
203
+ if (lookup.length) setLookups((own) => queueLookups(own, lookup, source === "records" ? 1 : undefined));
204
+ // eslint-disable-next-line react-hooks/exhaustive-deps
205
+ }, [lookupKey]);
206
+
207
+ const pages = { users: UsersPage, groups: GroupsPage, records: RecordsPage };
208
+ const Lookup = LOOKUPS[source];
209
+
210
+ return h(
211
+ React.Fragment,
212
+ null,
213
+ h(Select, {
214
+ ...rest,
215
+ ref: forwardedRef,
216
+ multiple,
217
+ value,
218
+ values,
219
+ options,
220
+ selectedOptions,
221
+ onSearch: setSent,
222
+ loading: sent !== null && (page.query !== sent || page.loading),
223
+ optionsError: !!page.error,
224
+ loadingText,
225
+ }),
226
+ query ? h(pages[source], { table, query, sent, onReport }) : null,
227
+ ...lookups.pending.map((own) => h(Lookup, { key: own.key, table, lookup: own, onResolved })),
228
+ );
229
+ }
230
+
231
+ // A record source names rows by a column, so it waits for the table's schema.
232
+ function RecordsPicker(props) {
233
+ const { schema } = useDatastoreSchema(props.table || null);
234
+ return h(SourcePicker, { ...props, labelColumn: recordLabelColumn(schema, props.labelColumn) });
235
+ }
236
+
237
+ const PeoplePicker = React.forwardRef(function PeoplePicker(props, ref) {
238
+ const source = pickerSource(props.source);
239
+ const own = { ...props, source, forwardedRef: ref };
240
+ // Changing the source starts a fresh picker rather than mixing rows.
241
+ if (source === "records") return h(RecordsPicker, { ...own, key: `records:${props.table}` });
242
+ return h(SourcePicker, { ...own, key: source });
243
+ });
244
+
245
+ PeoplePicker.displayName = "PeoplePicker";
246
+ return PeoplePicker;
247
+ }
@@ -0,0 +1,11 @@
1
+ // `<PeoplePicker>` (web). Binds ./people-picker-view.js to the web `<Select>`
2
+ // and `<Avatar>`; see ./primitives.js for why not the bare specifier.
3
+
4
+ import * as ReactNative from "react-native-web";
5
+ import { makePeoplePicker } from "./people-picker-view.js";
6
+ import { Select } from "./select.js";
7
+ import { Avatar } from "./detail-parts.js";
8
+
9
+ const { View } = ReactNative;
10
+
11
+ export const PeoplePicker = makePeoplePicker({ View, Select, Avatar });
@@ -0,0 +1,9 @@
1
+ // `<PeoplePicker>` (native). Binds ./people-picker-view.js to the native
2
+ // `<Select>` and `<Avatar>`; only the imports differ from ./people-picker.js.
3
+
4
+ import { View } from "react-native";
5
+ import { makePeoplePicker } from "./people-picker-view.js";
6
+ import { Select } from "./select.native.js";
7
+ import { Avatar } from "./detail-parts.native.js";
8
+
9
+ export const PeoplePicker = makePeoplePicker({ View, Select, Avatar });
@@ -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);