@colixsystems/widget-sdk 0.206.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.
@@ -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
+ );
@@ -7,16 +7,28 @@
7
7
  // thing tests can pin rather than a literal buried in JSX.
8
8
 
9
9
  import { DEFAULT_THEME_TOKENS } from "./_theme-tokens.js";
10
+ import { hexAlphaPercent, withHexAlpha } from "./contract.js";
11
+ import { normaliseThemeComponents } from "./theme-components.js";
10
12
 
11
13
  export const OVERLAY_SIZES = Object.freeze(["sm", "md", "lg", "full"]);
12
14
 
15
+ // `dialog` is centred; `sheet` rises from the bottom edge of the screen.
16
+ export const OVERLAY_PRESENTATIONS = Object.freeze(["dialog", "sheet"]);
17
+
13
18
  export const OVERLAY_DEFAULTS = Object.freeze({
14
19
  size: "md",
20
+ presentation: "dialog",
21
+ elevation: "lg",
15
22
  // Slate-black at 55%: dark enough to mute a busy page behind the panel,
16
23
  // light enough that the page still reads as "still there, just behind".
17
24
  scrim: "rgba(15, 23, 42, 0.55)",
18
25
  // Breathing room between the panel and the screen edge on a phone.
19
26
  screenPadding: 16,
27
+ // Scrim kept visible above a sheet, so it reads as a layer over the page.
28
+ sheetTopGap: 48,
29
+ // A themed scrim with no alpha takes the default's; none may hide the page.
30
+ scrimAlphaPercent: 55,
31
+ scrimAlphaMaxPercent: 85,
20
32
  });
21
33
 
22
34
  // `full` deliberately has no ceiling — a media/PDF preview wants the screen.
@@ -31,6 +43,48 @@ export function normaliseOverlaySize(size) {
31
43
  return OVERLAY_SIZES.indexOf(size) === -1 ? OVERLAY_DEFAULTS.size : size;
32
44
  }
33
45
 
46
+ export function normaliseOverlayPresentation(presentation) {
47
+ return OVERLAY_PRESENTATIONS.indexOf(presentation) === -1
48
+ ? OVERLAY_DEFAULTS.presentation
49
+ : presentation;
50
+ }
51
+
52
+ /**
53
+ * The theme's validated `modal` component scope (Design > Components > Dialogs):
54
+ * `<Modal>` paints from all of it, `<Overlay>` from its scrim alone.
55
+ *
56
+ * @param {object} theme — the resolved workspace theme (`useTheme()` shape).
57
+ * @returns {Record<string, string|number>} the set tokens; `{}` when none.
58
+ */
59
+ export function modalThemeScope(theme) {
60
+ const components = theme && typeof theme === "object" ? theme.components : null;
61
+ return normaliseThemeComponents({ modal: components && components.modal }).modal || {};
62
+ }
63
+
64
+ /**
65
+ * The scrim a themed colour really paints: always see-through, so a theme (or
66
+ * a planner) that names an opaque colour dims the page instead of hiding it.
67
+ */
68
+ export function themedScrim(color) {
69
+ if (!color) return OVERLAY_DEFAULTS.scrim;
70
+ const alpha = hexAlphaPercent(color);
71
+ const { scrimAlphaPercent, scrimAlphaMaxPercent } = OVERLAY_DEFAULTS;
72
+ return withHexAlpha(
73
+ color,
74
+ alpha >= 100 ? scrimAlphaPercent : Math.min(alpha, scrimAlphaMaxPercent),
75
+ );
76
+ }
77
+
78
+ /**
79
+ * The panel's corners: all four for a dialog, the top two for a sheet, which
80
+ * meets the screen edge with square ones.
81
+ */
82
+ export function overlayCornerStyle(radius, presentation) {
83
+ return presentation === "sheet"
84
+ ? { borderTopLeftRadius: radius, borderTopRightRadius: radius }
85
+ : { borderRadius: radius };
86
+ }
87
+
34
88
  /**
35
89
  * Resolve the values one overlay is painted with, from the workspace theme.
36
90
  *
@@ -39,25 +93,35 @@ export function normaliseOverlaySize(size) {
39
93
  *
40
94
  * @param {object} theme — the resolved workspace theme (`useTheme()` shape).
41
95
  * @param {string} size — `sm` | `md` | `lg` | `full`.
96
+ * @param {string} [presentation] — `dialog` | `sheet`; `full` is always a dialog.
42
97
  */
43
- export function resolveOverlayTokens(theme, size) {
98
+ export function resolveOverlayTokens(theme, size, presentation) {
44
99
  const safeSize = normaliseOverlaySize(size);
100
+ const isFull = safeSize === "full";
101
+ const safePresentation = isFull
102
+ ? OVERLAY_DEFAULTS.presentation
103
+ : normaliseOverlayPresentation(presentation);
104
+ const isSheet = safePresentation === "sheet";
45
105
  const base = theme && typeof theme === "object" ? theme : DEFAULT_THEME_TOKENS;
46
106
  const fallback = DEFAULT_THEME_TOKENS;
47
107
  const colors = base.colors || fallback.colors;
48
108
  const radii = base.radii || fallback.radii;
49
109
  const spacing = base.spacing || fallback.spacing;
50
- const elevation = (base.elevation || fallback.elevation).lg || {};
110
+ const elevation =
111
+ (base.elevation || fallback.elevation)[OVERLAY_DEFAULTS.elevation] || {};
51
112
 
52
113
  return {
53
114
  size: safeSize,
54
- scrim: OVERLAY_DEFAULTS.scrim,
115
+ presentation: safePresentation,
116
+ scrim: themedScrim(modalThemeScope(base).scrim),
55
117
  surface: colors.surface || fallback.colors.surface,
56
- radius: safeSize === "full" ? 0 : radii.lg || fallback.radii.lg,
118
+ radius: isFull ? 0 : radii.lg || fallback.radii.lg,
57
119
  padding: spacing.lg || fallback.spacing.lg,
58
120
  // A `full` overlay runs edge to edge; every other size keeps a margin so
59
121
  // the scrim stays visible and the panel reads as a layer, not a screen.
60
- screenPadding: safeSize === "full" ? 0 : OVERLAY_DEFAULTS.screenPadding,
122
+ screenPadding: isFull || isSheet ? 0 : OVERLAY_DEFAULTS.screenPadding,
123
+ topGap: isSheet ? OVERLAY_DEFAULTS.sheetTopGap : 0,
124
+ verticalAlign: isSheet ? "flex-end" : "center",
61
125
  maxWidth: SIZE_MAX_WIDTH[safeSize],
62
126
  elevation,
63
127
  };
@@ -16,30 +16,51 @@
16
16
 
17
17
  import React from "react";
18
18
  import { useHostTheme } from "./hooks.js";
19
- import { resolveOverlayTokens } from "./overlay-tokens.js";
19
+ import { overlayCornerStyle, resolveOverlayTokens } from "./overlay-tokens.js";
20
+
21
+ // Kept clickable, but out of the tab order and the accessibility tree.
22
+ const SILENT_BACKDROP = {
23
+ accessible: false,
24
+ focusable: false,
25
+ tabIndex: -1,
26
+ "aria-hidden": true,
27
+ importantForAccessibility: "no",
28
+ };
20
29
 
21
30
  /**
22
31
  * Bind the shared overlay component to one platform's primitives.
23
32
  *
24
- * @param {{Modal: any, View: any, Pressable: any, StyleSheet: any}} rn
33
+ * `namesPanel` is for a host whose `Modal` cannot carry the accessible name
34
+ * (React Native's): the panel carries it there, the dialog node on the web.
35
+ *
36
+ * @param {{Modal: any, View: any, Pressable: any, StyleSheet: any,
37
+ * namesPanel?: boolean}} rn
25
38
  */
26
39
  export function makeOverlay(rn) {
27
40
  const { Modal, View, Pressable, StyleSheet } = rn;
41
+ const namesPanel = Boolean(rn.namesPanel);
28
42
 
29
43
  function Overlay({
30
44
  visible = false,
31
45
  onRequestClose,
32
46
  size = "md",
47
+ presentation = "dialog",
33
48
  dismissOnBackdropPress = true,
49
+ backdropAccessible = true,
50
+ role = "dialog",
34
51
  accessibilityLabel,
52
+ accessibilityLabelledBy,
35
53
  closeAccessibilityLabel = "Close",
54
+ initialFocusRef,
55
+ panelFrame: PanelFrame,
36
56
  style,
57
+ testID,
37
58
  children,
38
59
  }) {
39
60
  const theme = useHostTheme();
40
61
  const tokens = React.useMemo(
41
- () => resolveOverlayTokens(theme, size),
42
- [theme, size],
62
+ () => resolveOverlayTokens(theme, size, presentation),
63
+ [theme, size, presentation],
43
64
  );
44
65
  const requestClose = React.useCallback(() => {
45
66
  if (typeof onRequestClose === "function") onRequestClose();
@@ -52,8 +73,9 @@ export function makeOverlay(rn) {
52
73
  const backdrop = {
53
74
  flex: 1,
54
75
  alignItems: "center",
55
- justifyContent: "center",
76
+ justifyContent: tokens.verticalAlign,
56
77
  padding: tokens.screenPadding,
78
+ ...(tokens.topGap ? { paddingTop: tokens.topGap } : null),
57
79
  backgroundColor: tokens.scrim,
58
80
  };
59
81
  const panel = {
@@ -64,11 +86,21 @@ export function makeOverlay(rn) {
64
86
  flexShrink: 1,
65
87
  ...(tokens.maxWidth ? { maxWidth: tokens.maxWidth } : { flex: 1 }),
66
88
  backgroundColor: tokens.surface,
67
- borderRadius: tokens.radius,
89
+ ...overlayCornerStyle(tokens.radius, tokens.presentation),
68
90
  padding: tokens.padding,
69
91
  ...tokens.elevation,
70
92
  };
71
93
 
94
+ const panelNode = React.createElement(
95
+ View,
96
+ {
97
+ style: [panel, style],
98
+ accessibilityLabel: namesPanel ? accessibilityLabel : undefined,
99
+ testID,
100
+ },
101
+ children,
102
+ );
103
+
72
104
  return React.createElement(
73
105
  Modal,
74
106
  {
@@ -77,6 +109,10 @@ export function makeOverlay(rn) {
77
109
  // Not animated: a sheet must answer the tap that opened it at once.
78
110
  animationType: "none",
79
111
  onRequestClose: requestClose,
112
+ role: role === "alertdialog" ? "alertdialog" : "dialog",
113
+ "aria-label": accessibilityLabelledBy ? undefined : accessibilityLabel,
114
+ "aria-labelledby": accessibilityLabelledBy,
115
+ initialFocusRef,
80
116
  },
81
117
  React.createElement(
82
118
  View,
@@ -85,18 +121,18 @@ export function makeOverlay(rn) {
85
121
  ? React.createElement(Pressable, {
86
122
  style: StyleSheet.absoluteFill,
87
123
  onPress: requestClose,
88
- accessibilityRole: "button",
89
- accessibilityLabel: closeAccessibilityLabel,
124
+ ...(backdropAccessible
125
+ ? {
126
+ accessibilityRole: "button",
127
+ accessibilityLabel: closeAccessibilityLabel,
128
+ }
129
+ : SILENT_BACKDROP),
90
130
  })
91
131
  : null,
92
- React.createElement(
93
- View,
94
- {
95
- style: [panel, style],
96
- accessibilityLabel,
97
- },
98
- children,
99
- ),
132
+ // @internal `panelFrame`: `<Modal>` wraps its panel, never the scrim.
133
+ PanelFrame
134
+ ? React.createElement(PanelFrame, { verticalAlign: tokens.verticalAlign }, panelNode)
135
+ : panelNode,
100
136
  ),
101
137
  );
102
138
  }
@@ -8,4 +8,11 @@
8
8
  import { Modal, View, Pressable, StyleSheet } from "react-native";
9
9
  import { makeOverlay } from "./overlay-view.js";
10
10
 
11
- export const Overlay = makeOverlay({ Modal, View, Pressable, StyleSheet });
11
+ // RN's `Modal` forwards no ARIA props, so the panel carries the name here.
12
+ export const Overlay = makeOverlay({
13
+ Modal,
14
+ View,
15
+ Pressable,
16
+ StyleSheet,
17
+ namesPanel: true,
18
+ });