@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.
- package/README.md +44 -1
- package/dist/contract.cjs +37 -2
- package/dist/contract.js +37 -2
- package/dist/hooks.js +13 -0
- package/dist/host.d.ts +46 -0
- package/dist/host.js +4 -0
- package/dist/index.d.ts +94 -0
- package/dist/index.js +2 -0
- package/dist/index.native.js +2 -0
- package/dist/markdown-input-view.js +2 -3
- package/dist/markdown-input.js +4 -2
- package/dist/markdown-input.native.js +4 -2
- package/dist/markdown-link-dialog.js +94 -108
- package/dist/modal-tokens.js +107 -0
- package/dist/modal-view.js +255 -0
- package/dist/modal.js +8 -0
- package/dist/modal.native.js +45 -0
- package/dist/overlay-tokens.js +69 -5
- package/dist/overlay-view.js +52 -16
- package/dist/overlay.native.js +8 -1
- package/dist/surface-theme.js +108 -0
- package/dist/web-modal.js +24 -5
- package/package.json +1 -1
- package/src/contract.js +37 -2
|
@@ -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
|
|
6
|
-
// own clipping box on both hosts — not in a bespoke absolutely-
|
|
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,
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
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
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
+
);
|