@colixsystems/widget-sdk 0.207.0 → 0.210.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.
@@ -2,20 +2,22 @@
2
2
  //
3
3
  // A link needs a TARGET, and no selection can supply one: the author has to
4
4
  // name either a page in this app or an external address. That is a form, and a
5
- // form belongs in an `<Overlay>` — the SDK primitive that escapes the widget's
6
- // own clipping box on both hosts — not in a bespoke absolutely-positioned panel
7
- // the layout container would crop.
5
+ // form belongs in a `<Modal>` — the SDK's standard dialog, which escapes the
6
+ // widget's own clipping box on both hosts — not in a bespoke absolutely-
7
+ // positioned panel the layout container would crop.
8
8
  //
9
9
  // Split out of markdown-input-view.js so each file stays one component's worth
10
10
  // of markup. Like every view module here it is bound per host by the caller
11
11
  // (see markdown-input.js / .native.js), so the two hosts cannot drift.
12
12
 
13
13
  import React from "react";
14
+ import { useHostTheme } from "./hooks.js";
15
+ import { resolveRichTextTokens } from "./richtext-tokens.js";
14
16
 
15
17
  // The page list scrolls rather than growing an app's 40 pages past the panel.
16
18
  const PAGE_LIST_MAX_HEIGHT = 180;
17
19
 
18
- export function makeMarkdownLinkDialog(rn, Overlay) {
20
+ export function makeMarkdownLinkDialog(rn, Modal, Button) {
19
21
  const { Text, View, Pressable, TextInput, ScrollView } = rn;
20
22
 
21
23
  function Field({ label, tokens, children }) {
@@ -38,50 +40,12 @@ export function makeMarkdownLinkDialog(rn, Overlay) {
38
40
  );
39
41
  }
40
42
 
41
- /**
42
- * @param {object} props
43
- * @param {boolean} props.visible
44
- * @param {() => void} props.onClose
45
- * @param {(result: {label: string, target: string}) => void} props.onSubmit
46
- * @param {object} props.tokens — resolveRichTextTokens output.
47
- * @param {Array<{id: string, name: string}>} props.pages — pickable pages;
48
- * empty means this host offered none, so only the URL mode is shown.
49
- * @param {string} props.initialLabel — the author's current selection.
50
- * @param {string} [props.testID]
51
- */
52
- function MarkdownLinkDialog({
53
- visible,
54
- onClose,
55
- onSubmit,
56
- tokens,
57
- pages,
58
- initialLabel,
59
- testID,
60
- }) {
61
- const pageList = Array.isArray(pages) ? pages.filter((p) => p && p.id) : [];
62
- const canPickPage = pageList.length > 0;
63
-
64
- const [label, setLabel] = React.useState("");
65
- const [url, setUrl] = React.useState("");
66
- const [pageId, setPageId] = React.useState("");
67
- const [mode, setMode] = React.useState("url");
68
-
69
- // Re-seed on each open: the author's selection and the mode they should
70
- // land in are both properties of THIS opening, not of the component.
71
- React.useEffect(() => {
72
- if (!visible) return;
73
- setLabel(typeof initialLabel === "string" ? initialLabel : "");
74
- setUrl("");
75
- setPageId("");
76
- setMode(canPickPage ? "page" : "url");
77
- }, [visible, initialLabel, canPickPage]);
78
-
79
- const target = mode === "page" ? pageId : url.trim();
80
-
81
- const submit = () => {
82
- if (!target) return;
83
- if (typeof onSubmit === "function") onSubmit({ label, target });
84
- };
43
+ // The form is its own component so its tokens resolve INSIDE the dialog: a
44
+ // dialog with its own background re-inks the theme its body reads.
45
+ function LinkForm({ form, pageList, canPickPage, testID }) {
46
+ const { label, setLabel, url, setUrl, pageId, setPageId, mode, setMode } = form;
47
+ const theme = useHostTheme();
48
+ const tokens = React.useMemo(() => resolveRichTextTokens(theme), [theme]);
85
49
 
86
50
  const inputStyle = {
87
51
  fontFamily: tokens.bodyFont,
@@ -130,49 +94,9 @@ export function makeMarkdownLinkDialog(rn, Overlay) {
130
94
  ),
131
95
  );
132
96
 
133
- const action = (key, text, { primary, disabled, onPress }) =>
134
- React.createElement(
135
- Pressable,
136
- {
137
- key,
138
- onPress,
139
- disabled,
140
- accessibilityRole: "button",
141
- testID: testID ? `${testID}-${key}` : undefined,
142
- style: {
143
- paddingVertical: tokens.markerGap,
144
- paddingHorizontal: tokens.padding,
145
- marginLeft: tokens.markerGap,
146
- borderWidth: 1,
147
- borderColor: primary ? tokens.accent : tokens.border,
148
- borderRadius: tokens.radius,
149
- opacity: disabled ? 0.5 : 1,
150
- ...(primary ? { backgroundColor: tokens.accent } : null),
151
- },
152
- },
153
- React.createElement(
154
- Text,
155
- {
156
- style: {
157
- fontFamily: tokens.bodyFont,
158
- fontSize: tokens.codeSize,
159
- fontWeight: "500",
160
- color: primary ? tokens.onAccent : tokens.color,
161
- },
162
- },
163
- text,
164
- ),
165
- );
166
-
167
97
  return React.createElement(
168
- Overlay,
169
- {
170
- visible: !!visible,
171
- onRequestClose: onClose,
172
- size: "sm",
173
- accessibilityLabel: "Insert link",
174
- testID: testID ? `${testID}-dialog` : undefined,
175
- },
98
+ React.Fragment,
99
+ null,
176
100
  React.createElement(
177
101
  Field,
178
102
  { label: "Link text", tokens },
@@ -209,6 +133,8 @@ export function makeMarkdownLinkDialog(rn, Overlay) {
209
133
  ScrollView,
210
134
  {
211
135
  accessibilityRole: "radiogroup",
136
+ // Inside the dialog's scrolling body; Android needs the opt-in.
137
+ nestedScrollEnabled: true,
212
138
  style: {
213
139
  maxHeight: PAGE_LIST_MAX_HEIGHT,
214
140
  borderWidth: 1,
@@ -264,25 +190,85 @@ export function makeMarkdownLinkDialog(rn, Overlay) {
264
190
  testID: testID ? `${testID}-url` : undefined,
265
191
  }),
266
192
  ),
193
+ );
194
+ }
267
195
 
268
- React.createElement(
269
- View,
270
- {
271
- style: {
272
- flexDirection: "row",
273
- justifyContent: "flex-end",
274
- marginTop: tokens.markerGap,
275
- },
276
- },
277
- action("cancel", "Cancel", { onPress: onClose }),
278
- // Disabled until there is somewhere to go — an empty target would
279
- // write `[text]()`, which reads as a link and goes nowhere.
280
- action("insert", "Insert link", {
281
- primary: true,
282
- disabled: !target,
283
- onPress: submit,
284
- }),
285
- ),
196
+ /**
197
+ * @param {object} props
198
+ * @param {boolean} props.visible
199
+ * @param {() => void} props.onClose
200
+ * @param {(result: {label: string, target: string}) => void} props.onSubmit
201
+ * @param {Array<{id: string, name: string}>} props.pages — pickable pages;
202
+ * empty means this host offered none, so only the URL mode is shown.
203
+ * @param {string} props.initialLabel — the author's current selection.
204
+ * @param {string} [props.testID]
205
+ */
206
+ function MarkdownLinkDialog({
207
+ visible,
208
+ onClose,
209
+ onSubmit,
210
+ pages,
211
+ initialLabel,
212
+ testID,
213
+ }) {
214
+ const pageList = Array.isArray(pages) ? pages.filter((p) => p && p.id) : [];
215
+ const canPickPage = pageList.length > 0;
216
+
217
+ const [label, setLabel] = React.useState("");
218
+ const [url, setUrl] = React.useState("");
219
+ const [pageId, setPageId] = React.useState("");
220
+ const [mode, setMode] = React.useState("url");
221
+
222
+ // Re-seed on each open: the author's selection and the mode they should
223
+ // land in are both properties of THIS opening, not of the component.
224
+ React.useEffect(() => {
225
+ if (!visible) return;
226
+ setLabel(typeof initialLabel === "string" ? initialLabel : "");
227
+ setUrl("");
228
+ setPageId("");
229
+ setMode(canPickPage ? "page" : "url");
230
+ }, [visible, initialLabel, canPickPage]);
231
+
232
+ const target = mode === "page" ? pageId : url.trim();
233
+
234
+ const submit = () => {
235
+ if (!target) return;
236
+ if (typeof onSubmit === "function") onSubmit({ label, target });
237
+ };
238
+
239
+ const action = (key, title, props) =>
240
+ React.createElement(Button, {
241
+ key,
242
+ title,
243
+ testID: testID ? `${testID}-${key}` : undefined,
244
+ ...props,
245
+ });
246
+
247
+ const actions = React.createElement(
248
+ React.Fragment,
249
+ null,
250
+ action("cancel", "Cancel", { variant: "secondary", onPress: onClose }),
251
+ // Disabled until there is somewhere to go — an empty target would
252
+ // write `[text]()`, which reads as a link and goes nowhere.
253
+ action("insert", "Insert link", { disabled: !target, onPress: submit }),
254
+ );
255
+
256
+ return React.createElement(
257
+ Modal,
258
+ {
259
+ visible: !!visible,
260
+ onRequestClose: onClose,
261
+ size: "sm",
262
+ title: "Insert link",
263
+ footer: actions,
264
+ testID: testID ? `${testID}-dialog` : undefined,
265
+ },
266
+ React.createElement(LinkForm, {
267
+ form: { label, setLabel, url, setUrl, pageId, setPageId, mode, setMode },
268
+ pageList,
269
+ canPickPage,
270
+ testID,
271
+ }),
286
272
  );
287
273
  }
288
274
 
@@ -0,0 +1,107 @@
1
+ // The values `<Modal>` paints with, resolved from the theme's `modal` component
2
+ // scope on top of the overlay's own. Platform-free, like overlay-tokens.js, so
3
+ // both hosts draw one dialog from the same numbers.
4
+
5
+ import { DEFAULT_THEME_TOKENS } from "./_theme-tokens.js";
6
+ import { INK_CONTRAST, inkForBackdrop } from "./contract.js";
7
+ import {
8
+ OVERLAY_DEFAULTS,
9
+ modalThemeScope,
10
+ resolveOverlayTokens,
11
+ } from "./overlay-tokens.js";
12
+ import { compositeOver, readableInk } from "./readable-ink.js";
13
+
14
+ // The close button's touch target; its glyph is drawn at CLOSE_ICON_SIZE.
15
+ export const MODAL_CLOSE_TARGET = 40;
16
+ export const MODAL_CLOSE_ICON_SIZE = 20;
17
+
18
+ const NO_INSETS = Object.freeze({ top: 0, bottom: 0 });
19
+
20
+ /**
21
+ * The device safe-area insets a dialog pads its panel by: none for
22
+ * a centred dialog, the bottom for a sheet, top and bottom edge to edge.
23
+ *
24
+ * @param {{ size: string, presentation: string }} tokens — resolved tokens.
25
+ * @param {{ top?: number, bottom?: number }} [insets] — the host's insets.
26
+ */
27
+ export function modalSafeInsets(tokens, insets) {
28
+ if (!insets) return NO_INSETS;
29
+ const top = tokens.size === "full" ? insets.top || 0 : 0;
30
+ const reachesBottom = tokens.size === "full" || tokens.presentation === "sheet";
31
+ const bottom = reachesBottom ? insets.bottom || 0 : 0;
32
+ return top || bottom ? { top, bottom } : NO_INSETS;
33
+ }
34
+
35
+ // A panel style replaces the overlay's shadow, so "none" must zero each key.
36
+ const NO_SHADOW = Object.freeze({
37
+ shadowOpacity: 0,
38
+ shadowRadius: 0,
39
+ elevation: 0,
40
+ });
41
+
42
+ // The theme's own ink while it reads on the panel, else the readable fixed one.
43
+ function inkOn(backdrop, themeInk, minimum) {
44
+ return inkForBackdrop(themeInk, readableInk(backdrop), backdrop, minimum);
45
+ }
46
+
47
+ /**
48
+ * What the scope's unset tokens show in Theme Settings where no `uiDefault`
49
+ * path exists: the scrim as the hex a colour field can display, and the title
50
+ * colour a dialog paints on the background currently set.
51
+ *
52
+ * @param {object} theme — the resolved workspace theme (`useTheme()` shape).
53
+ */
54
+ export function modalScopeDefaults(theme) {
55
+ const base = theme && typeof theme === "object" ? theme : {};
56
+ const { titleColor: _own, ...others } = modalThemeScope(base);
57
+ const untitled = { ...base, components: { ...base.components, modal: others } };
58
+ return {
59
+ scrim: "#0f172a8c",
60
+ titleColor: resolveModalTokens(untitled).titleColor,
61
+ };
62
+ }
63
+
64
+ /**
65
+ * Resolve one dialog: the theme's `modal` scope, then the overlay's own values,
66
+ * so an unthemed `<Modal>` sits on exactly the panel `<Overlay>` draws.
67
+ *
68
+ * @param {object} theme — the resolved workspace theme (`useTheme()` shape).
69
+ * @param {{ size?: string, presentation?: string }} [options] — the overlay's
70
+ * `sm` | `md` | `lg` | `full` and `dialog` | `sheet`.
71
+ */
72
+ export function resolveModalTokens(theme, { size, presentation } = {}) {
73
+ const overlay = resolveOverlayTokens(theme, size, presentation);
74
+ const base = theme && typeof theme === "object" ? theme : DEFAULT_THEME_TOKENS;
75
+ const fallback = DEFAULT_THEME_TOKENS;
76
+ const colors = base.colors || fallback.colors;
77
+ const spacing = base.spacing || fallback.spacing;
78
+ const levels = base.elevation || fallback.elevation;
79
+ const scope = modalThemeScope(base);
80
+ const background = scope.background || overlay.surface;
81
+ // A translucent fill is judged as it lands on the panel surface under it.
82
+ const backdrop = compositeOver(background, overlay.surface);
83
+ const onSurface = colors.onSurface || fallback.colors.onSurface;
84
+ const onSurfaceMuted = colors.onSurfaceMuted || fallback.colors.onSurfaceMuted;
85
+
86
+ return {
87
+ size: overlay.size,
88
+ presentation: overlay.presentation,
89
+ scrim: overlay.scrim,
90
+ background,
91
+ // What the panel paints: `background` as it lands on the overlay surface,
92
+ // so a see-through one never shows the scrim and the ink maths holds.
93
+ panelColor: backdrop,
94
+ // What the body sits on; null while the theme's own surface stands.
95
+ bodyBackdrop: scope.background ? backdrop : null,
96
+ // Edge to edge has no corner to round, whatever the theme says.
97
+ radius: overlay.size === "full" ? 0 : scope.radius ?? overlay.radius,
98
+ padding: scope.padding ?? overlay.padding,
99
+ // Null keeps the overlay's own elevation: the look with no token set.
100
+ shadow: scope.shadow ? { ...NO_SHADOW, ...levels[scope.shadow] } : null,
101
+ shadowLevel: scope.shadow || OVERLAY_DEFAULTS.elevation,
102
+ titleColor: scope.titleColor || inkOn(backdrop, onSurface, INK_CONTRAST.text),
103
+ closeColor: inkOn(backdrop, onSurfaceMuted, INK_CONTRAST.boundary),
104
+ sectionGap: spacing.md ?? fallback.spacing.md,
105
+ actionGap: spacing.sm ?? fallback.spacing.sm,
106
+ };
107
+ }
@@ -0,0 +1,255 @@
1
+ // The ONE implementation behind the `<Modal>` SDK component: the standard
2
+ // dialog, drawn inside `<Overlay>` so it leaves the widget's box on both hosts.
3
+ //
4
+ // Bound per host by modal.js / modal.native.js, which differ only in where the
5
+ // primitives come from, so the web Player and the Expo export cannot drift.
6
+
7
+ import React from "react";
8
+ import { ThemeScope, useHostTheme } from "./hooks.js";
9
+ import { DEFAULT_THEME_TOKENS } from "./_theme-tokens.js";
10
+ import { Icon } from "./icon.js";
11
+ import { overlayCornerStyle } from "./overlay-tokens.js";
12
+ import {
13
+ MODAL_CLOSE_ICON_SIZE,
14
+ MODAL_CLOSE_TARGET,
15
+ modalSafeInsets,
16
+ resolveModalTokens,
17
+ } from "./modal-tokens.js";
18
+ import { surfaceAwareTheme } from "./surface-theme.js";
19
+ import { textRoleStyle } from "./text-roles.js";
20
+
21
+ // Pulls the glyph, not its touch target, onto the panel's padding edge.
22
+ const CLOSE_EDGE_PULL = -(MODAL_CLOSE_TARGET - MODAL_CLOSE_ICON_SIZE) / 2;
23
+
24
+ const useNoInsets = () => null;
25
+
26
+ function headerStyle(t) {
27
+ // Never less than the pull, or the close target would leave the panel.
28
+ const edge = Math.max(t.padding, -CLOSE_EDGE_PULL);
29
+ return {
30
+ flexDirection: "row",
31
+ alignItems: "center",
32
+ gap: t.actionGap,
33
+ paddingTop: edge,
34
+ paddingLeft: t.padding,
35
+ paddingRight: edge,
36
+ paddingBottom: t.sectionGap,
37
+ };
38
+ }
39
+
40
+ function closeStyle(pressed) {
41
+ return {
42
+ width: MODAL_CLOSE_TARGET,
43
+ height: MODAL_CLOSE_TARGET,
44
+ alignItems: "center",
45
+ justifyContent: "center",
46
+ marginLeft: "auto",
47
+ marginRight: CLOSE_EDGE_PULL,
48
+ marginVertical: CLOSE_EDGE_PULL,
49
+ borderRadius: MODAL_CLOSE_TARGET / 2,
50
+ opacity: pressed ? 0.6 : 1,
51
+ };
52
+ }
53
+
54
+ function footerStyle(t) {
55
+ return {
56
+ flexDirection: "row",
57
+ flexWrap: "wrap",
58
+ justifyContent: "flex-end",
59
+ alignItems: "center",
60
+ gap: t.actionGap,
61
+ paddingTop: t.sectionGap,
62
+ paddingHorizontal: t.padding,
63
+ paddingBottom: t.padding,
64
+ };
65
+ }
66
+
67
+ /**
68
+ * Bind the shared dialog to one platform's primitives and its `<Overlay>`.
69
+ *
70
+ * `useSafeAreaInsets` and `KeyboardAvoider` are the native host's: a browser
71
+ * lays out inside its own safe area and resizes for its own keyboard.
72
+ *
73
+ * @param {{View: any, Text: any, Pressable: any, ScrollView: any,
74
+ * useSafeAreaInsets?: () => ({top: number, bottom: number}|null),
75
+ * KeyboardAvoider?: any}} rn
76
+ * @param {any} Overlay — the host's bound `<Overlay>`.
77
+ */
78
+ export function makeModal(rn, Overlay) {
79
+ const { View, Text, Pressable, ScrollView } = rn;
80
+ const useSafeAreaInsets = rn.useSafeAreaInsets || useNoInsets;
81
+
82
+ /**
83
+ * The standard dialog: a title, a close button, a body that scrolls when it
84
+ * is long, and a footer action row laid out below it. Its look follows the
85
+ * theme's `modal` scope (Design > Components > Dialogs). Components rendered
86
+ * inside it read a theme re-inked for the dialog's background.
87
+ *
88
+ * @param {object} props
89
+ * @param {boolean} [props.visible] — nothing is mounted while false.
90
+ * @param {() => void} [props.onRequestClose] — the close button, backdrop
91
+ * press, Escape (web) and the Android back button all fire it. Omitted,
92
+ * the dialog draws no close button and only your own controls close it.
93
+ * @param {boolean} [props.dismissible] — default true. False while the
94
+ * dialog must stay open (a save in flight): no close button, and the
95
+ * backdrop, Escape and Android back do not fire `onRequestClose`. Only
96
+ * while that work runs, and always restore it: with no footer or in-body
97
+ * control, such a dialog cannot be closed by anyone.
98
+ * @param {string} [props.title] — with no title and no close button, the
99
+ * dialog has no header row.
100
+ * @param {any} [props.footer] — the action buttons; laid out as one
101
+ * right-aligned row that wraps. Omitted, the dialog has no footer.
102
+ * @param {"dialog"|"sheet"} [props.presentation] — centred (default), or
103
+ * anchored to the bottom edge.
104
+ * @param {"sm"|"md"|"lg"|"full"} [props.size] — the overlay's width tier.
105
+ * @param {boolean} [props.dismissOnBackdropPress] — default true.
106
+ * @param {"dialog"|"alertdialog"} [props.role] — default "dialog"; web only,
107
+ * native has no equivalent.
108
+ * @param {{current: any}} [props.initialFocusRef] — the element focused on
109
+ * open instead of the dialog itself; web only, a no-op on native.
110
+ * @param {string} [props.closeAccessibilityLabel] — default "Close".
111
+ * @param {string} [props.accessibilityLabel] — names a dialog with no title.
112
+ * @param {string} [props.testID] — on the panel; the close button, body and
113
+ * footer take `-close`, `-body` and `-footer`.
114
+ */
115
+ function Modal({
116
+ visible = false,
117
+ onRequestClose,
118
+ dismissible = true,
119
+ title,
120
+ footer,
121
+ presentation = "dialog",
122
+ size = "md",
123
+ dismissOnBackdropPress = true,
124
+ role = "dialog",
125
+ initialFocusRef,
126
+ closeAccessibilityLabel = "Close",
127
+ accessibilityLabel,
128
+ testID,
129
+ children,
130
+ }) {
131
+ const theme = useHostTheme() || DEFAULT_THEME_TOKENS;
132
+ const deviceInsets = useSafeAreaInsets();
133
+ const titleId = React.useId();
134
+ const t = React.useMemo(
135
+ () => resolveModalTokens(theme, { size, presentation }),
136
+ [theme, size, presentation],
137
+ );
138
+ // Null while the theme's own surface stands, so the context is untouched.
139
+ const bodyTheme = React.useMemo(
140
+ () => (t.bodyBackdrop ? surfaceAwareTheme(theme, t.bodyBackdrop) : null),
141
+ [theme, t.bodyBackdrop],
142
+ );
143
+
144
+ const close =
145
+ dismissible && typeof onRequestClose === "function" ? onRequestClose : undefined;
146
+ const hasTitle = typeof title === "string" && title.length > 0;
147
+ const hasHeader = hasTitle || Boolean(close);
148
+ const hasFooter = footer !== undefined && footer !== null && footer !== false;
149
+ const fills = t.size === "full";
150
+ const insets = modalSafeInsets(t, deviceInsets);
151
+ const part = (name) => (testID ? `${testID}-${name}` : undefined);
152
+
153
+ const panel = {
154
+ padding: 0,
155
+ backgroundColor: t.panelColor,
156
+ ...overlayCornerStyle(t.radius, t.presentation),
157
+ ...t.shadow,
158
+ ...(insets.top ? { paddingTop: insets.top } : null),
159
+ ...(insets.bottom ? { paddingBottom: insets.bottom } : null),
160
+ };
161
+
162
+ const content = React.createElement(
163
+ React.Fragment,
164
+ null,
165
+ hasHeader
166
+ ? React.createElement(
167
+ View,
168
+ { style: headerStyle(t) },
169
+ hasTitle
170
+ ? React.createElement(
171
+ Text,
172
+ {
173
+ nativeID: titleId,
174
+ accessibilityRole: "header",
175
+ style: {
176
+ ...textRoleStyle("subtitle", theme.typography, theme.colors),
177
+ flex: 1,
178
+ color: t.titleColor,
179
+ },
180
+ },
181
+ title,
182
+ )
183
+ : null,
184
+ close
185
+ ? React.createElement(
186
+ Pressable,
187
+ {
188
+ onPress: close,
189
+ accessibilityRole: "button",
190
+ accessibilityLabel: closeAccessibilityLabel,
191
+ testID: part("close"),
192
+ style: ({ pressed }) => closeStyle(pressed),
193
+ },
194
+ React.createElement(Icon, {
195
+ name: "x",
196
+ size: MODAL_CLOSE_ICON_SIZE,
197
+ color: t.closeColor,
198
+ }),
199
+ )
200
+ : null,
201
+ )
202
+ : null,
203
+ React.createElement(
204
+ ScrollView,
205
+ {
206
+ // Shrinks inside the panel's height cap, so a long body scrolls here
207
+ // and the footer below it stays on screen.
208
+ style: fills ? { flex: 1 } : { flexGrow: 0, flexShrink: 1 },
209
+ contentContainerStyle: {
210
+ paddingTop: hasHeader ? 0 : t.padding,
211
+ paddingHorizontal: t.padding,
212
+ paddingBottom: hasFooter ? 0 : t.padding,
213
+ },
214
+ alwaysBounceVertical: false,
215
+ keyboardShouldPersistTaps: "handled",
216
+ testID: part("body"),
217
+ },
218
+ children,
219
+ ),
220
+ hasFooter
221
+ ? React.createElement(
222
+ View,
223
+ { style: footerStyle(t), testID: part("footer") },
224
+ footer,
225
+ )
226
+ : null,
227
+ );
228
+
229
+ return React.createElement(
230
+ Overlay,
231
+ {
232
+ visible,
233
+ onRequestClose: close,
234
+ size,
235
+ presentation,
236
+ dismissOnBackdropPress: dismissOnBackdropPress && Boolean(close),
237
+ // The dialog's own close button is the one a screen reader meets.
238
+ backdropAccessible: false,
239
+ role,
240
+ accessibilityLabelledBy: hasTitle ? titleId : undefined,
241
+ // Where the host cannot point at the title, the name is the title.
242
+ accessibilityLabel: accessibilityLabel || (hasTitle ? title : undefined),
243
+ initialFocusRef,
244
+ panelFrame: rn.KeyboardAvoider,
245
+ style: panel,
246
+ testID,
247
+ },
248
+ // The body reads a theme whose inks suit the dialog's background.
249
+ React.createElement(ThemeScope, { theme: bodyTheme }, content),
250
+ );
251
+ }
252
+
253
+ Modal.displayName = "Modal";
254
+ return Modal;
255
+ }
package/dist/modal.js ADDED
@@ -0,0 +1,8 @@
1
+ // `<Modal>` (web). Binds ./modal-view.js to react-native-web and the web
2
+ // `<Overlay>`; see ./primitives.js for why not the bare specifier.
3
+
4
+ import * as ReactNative from "react-native-web";
5
+ import { makeModal } from "./modal-view.js";
6
+ import { Overlay } from "./overlay.js";
7
+
8
+ export const Modal = makeModal(ReactNative, Overlay);
@@ -0,0 +1,45 @@
1
+ // `<Modal>` (native). Binds ./modal-view.js to React Native's own primitives
2
+ // and the native `<Overlay>`; only the imports differ from ./modal.js, plus
3
+ // the two things a browser does for itself: safe-area insets and keyboard
4
+ // avoidance. Both are the dialog's own; a bare `<Overlay>` gets neither.
5
+
6
+ import React from "react";
7
+ import {
8
+ View,
9
+ Text,
10
+ Pressable,
11
+ ScrollView,
12
+ KeyboardAvoidingView,
13
+ Platform,
14
+ } from "react-native";
15
+ // Host-provided: the exported app pins it and mounts its provider at the root.
16
+ import { SafeAreaInsetsContext } from "react-native-safe-area-context";
17
+ import { makeModal } from "./modal-view.js";
18
+ import { Overlay } from "./overlay.native.js";
19
+
20
+ // Null with no provider above, which the view reads as "no insets".
21
+ const useSafeAreaInsets = () => React.useContext(SafeAreaInsetsContext);
22
+
23
+ // Wraps the panel only, so the scrim still covers the strip under a raised one.
24
+ function KeyboardAvoider({ verticalAlign, children }) {
25
+ return React.createElement(
26
+ KeyboardAvoidingView,
27
+ {
28
+ behavior: Platform.OS === "ios" ? "padding" : "height",
29
+ // Lets a press beside the panel reach the backdrop behind this frame.
30
+ pointerEvents: "box-none",
31
+ style: {
32
+ flex: 1,
33
+ alignSelf: "stretch",
34
+ alignItems: "center",
35
+ justifyContent: verticalAlign,
36
+ },
37
+ },
38
+ children,
39
+ );
40
+ }
41
+
42
+ export const Modal = makeModal(
43
+ { View, Text, Pressable, ScrollView, useSafeAreaInsets, KeyboardAvoider },
44
+ Overlay,
45
+ );