@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.
package/dist/hooks.js CHANGED
@@ -89,6 +89,19 @@ export function BackdropScope({ basis, children }) {
89
89
  return React.createElement(HostWidgetContext.Provider, { value }, children);
90
90
  }
91
91
 
92
+ /**
93
+ * @internal — hands a subtree `theme` in place of the host's (`<Modal>` uses
94
+ * it for the theme re-inked for its own background). No theme, no change.
95
+ */
96
+ export function ThemeScope({ theme, children }) {
97
+ const ctx = useContext(HostWidgetContext);
98
+ const value = useMemo(
99
+ () => (theme && ctx ? { ...ctx, workspace: { ...ctx.workspace, theme } } : ctx),
100
+ [ctx, theme],
101
+ );
102
+ return React.createElement(HostWidgetContext.Provider, { value }, children);
103
+ }
104
+
92
105
  // Exported for ./toast.js + future hook modules outside hooks.js. Internal
93
106
  // utility — widget code should not import this directly.
94
107
  export function useWidgetContextOrThrow(hookName) {
package/dist/host.d.ts CHANGED
@@ -39,6 +39,19 @@ export type ThemeWidgetStyles = Record<string, ThemeComponentStyle>;
39
39
  */
40
40
  export function normaliseThemeComponents(raw: unknown): ThemeComponents;
41
41
 
42
+ /**
43
+ * What each token of the `alert` component scope resolves to while unset,
44
+ * keyed by token name: the values `<Banner>` paints an unthemed app with.
45
+ */
46
+ export function bannerScopeDefaults(theme: unknown): Record<string, string | number>;
47
+
48
+ /**
49
+ * The theme a surface painting `backgroundColor` hands its subtree: surface
50
+ * tokens derived for that fill, and any type tier whose own ink cannot be read
51
+ * on it re-inked. Returns `theme` itself when the colour is unusable.
52
+ */
53
+ export function surfaceAwareTheme<T>(theme: T, backgroundColor: unknown): T;
54
+
42
55
  /** Minimum contrast: `text` for labels (WCAG 1.4.3), `boundary` for edges (1.4.11). */
43
56
  export const INK_CONTRAST: Readonly<{ text: number; boundary: number }>;
44
57
 
@@ -146,6 +159,45 @@ export interface ToastControllerOptions {
146
159
  clearTimer?: (handle: unknown) => void;
147
160
  }
148
161
 
162
+ /** What one `<Modal>` paints with; see `resolveModalTokens`. */
163
+ export interface ModalTokens {
164
+ size: "sm" | "md" | "lg" | "full";
165
+ presentation: "dialog" | "sheet";
166
+ scrim: string;
167
+ /** The `background` token as set (it may be see-through), else the surface. */
168
+ background: string;
169
+ /** What the panel paints: `background` composited over the overlay surface. */
170
+ panelColor: string;
171
+ /** `panelColor` when a background is set, for re-inking the body; else null. */
172
+ bodyBackdrop: string | null;
173
+ radius: number;
174
+ padding: number;
175
+ /** The themed elevation as a style, or null for the overlay's own. */
176
+ shadow: Record<string, unknown> | null;
177
+ /** The elevation level in force: the themed one, else the overlay's. */
178
+ shadowLevel: "none" | "sm" | "md" | "lg";
179
+ titleColor: string;
180
+ closeColor: string;
181
+ sectionGap: number;
182
+ actionGap: number;
183
+ }
184
+
185
+ /**
186
+ * The look of the SDK `<Modal>` under a resolved theme: its `modal` component
187
+ * scope over the overlay's defaults. For a host previewing Theme Settings >
188
+ * Dialogs with the values the dialog itself paints.
189
+ */
190
+ export function resolveModalTokens(
191
+ theme: unknown,
192
+ options?: { size?: string; presentation?: string },
193
+ ): ModalTokens;
194
+
195
+ /**
196
+ * What the `modal` scope's tokens without a `uiDefault` path resolve to while
197
+ * unset: the scrim, and the title colour on the background currently set.
198
+ */
199
+ export function modalScopeDefaults(theme: unknown): { scrim: string; titleColor: string };
200
+
149
201
  export const TOAST_DEFAULTS: { durationMs: number; maxVisible: number };
150
202
 
151
203
  export function normalizeToastKind(kind: unknown): ToastKind;
package/dist/host.js CHANGED
@@ -42,12 +42,16 @@ export {
42
42
  // Host plumbing, deliberately off the author entry point: a widget reads the
43
43
  // already-correct `useTheme().colors`, it never derives them itself.
44
44
  export { deriveSurfaceTokens, inkForBackdrop, INK_CONTRAST } from "./contract.js";
45
+ export { surfaceAwareTheme } from "./surface-theme.js";
45
46
 
46
47
  // REQ-WSDK-PLATFORM §6: the host half of `useToast()` — the queue, the
47
48
  // auto-dismiss timing, and the themed values a toast is painted with. The
48
49
  // widget-facing hook only forwards to `ctx.toast.showToast`; the host owns the
49
50
  // surface, and sharing everything but the JSX is what keeps the web Player and
50
51
  // the Expo export rendering the same notification (CLAUDE.md §8).
52
+ // The dialog's resolved look, for the Studio's Design > Components > Dialogs preview.
53
+ export { resolveModalTokens, modalScopeDefaults } from "./modal-tokens.js";
54
+
51
55
  export {
52
56
  createToastController,
53
57
  resolveToastTokens,
@@ -55,6 +59,10 @@ export {
55
59
  TOAST_DEFAULTS,
56
60
  } from "./toast-host.js";
57
61
 
62
+ // What each `alert` scope token resolves to while unset, for the Studio's
63
+ // Design page to preview beside its controls.
64
+ export { bannerScopeDefaults } from "./banner-tokens.js";
65
+
58
66
  // REQ-NAV-STRUCTURE: the navigation resolver both hosts draw their chrome from.
59
67
  // `normaliseNavigation` turns a stored `theme_config.navigation` block into the
60
68
  // resolved `{ menuType }` — always `sidebar` since sc-7352 retired the other
package/dist/index.d.ts CHANGED
@@ -3257,13 +3257,107 @@ export const Overlay: (props: {
3257
3257
  onRequestClose?: () => void;
3258
3258
  /** Panel width tier. `full` runs edge to edge. Defaults to `md`. */
3259
3259
  size?: "sm" | "md" | "lg" | "full";
3260
+ /** Centred (`dialog`, the default) or anchored to the bottom edge (`sheet`). */
3261
+ presentation?: OverlayPresentation;
3260
3262
  /** Set false when only an explicit control may close the overlay. */
3261
3263
  dismissOnBackdropPress?: boolean;
3264
+ /**
3265
+ * Set false when the content has its own close control: the backdrop stays
3266
+ * pressable but leaves the tab order and the accessibility tree.
3267
+ */
3268
+ backdropAccessible?: boolean;
3269
+ /** The web dialog node's role. Defaults to "dialog". No native equivalent. */
3270
+ role?: "dialog" | "alertdialog";
3271
+ /**
3272
+ * Names the dialog: on the web the node that carries the role, in the
3273
+ * exported app the panel (React Native's Modal carries no name).
3274
+ */
3262
3275
  accessibilityLabel?: string;
3276
+ /** The id of the element that names the dialog; wins over the label. Web only. */
3277
+ accessibilityLabelledBy?: string;
3263
3278
  /** Label for the backdrop's dismiss target. Defaults to "Close". */
3264
3279
  closeAccessibilityLabel?: string;
3280
+ /** Focused on open instead of the dialog itself. Web only; a no-op on native. */
3281
+ initialFocusRef?: { current: any };
3265
3282
  /** Extra styles merged onto the themed panel. */
3266
3283
  style?: any;
3284
+ /** Set on the panel. */
3285
+ testID?: string;
3286
+ children?: ReactNode;
3287
+ }) => any;
3288
+
3289
+ export type OverlayPresentation = "dialog" | "sheet";
3290
+
3291
+ /**
3292
+ * The standard dialog, built on `<Overlay>`: a title, a close button, a body
3293
+ * that scrolls when it is long, and a footer action row laid out below it.
3294
+ * Its background, corner radius, padding, scrim, shadow and title colour come
3295
+ * from the theme's `modal` scope (Design > Components > Dialogs), so every dialog in
3296
+ * the app matches. Use it instead of hand-building a header and an action row
3297
+ * inside a bare `<Overlay>`.
3298
+ *
3299
+ * A dialog that paints its own background hands what is rendered inside it a
3300
+ * theme re-inked for that background, so SDK components and any component
3301
+ * calling `useTheme()` inside the Modal stay readable.
3302
+ *
3303
+ * In the exported app the dialog pads a `sheet` by the bottom safe-area inset
3304
+ * and a `full` dialog by the top and bottom ones, and wraps its panel in React
3305
+ * Native's `KeyboardAvoidingView`. This is not yet verified on a device, and
3306
+ * `KeyboardAvoidingView` inside a React Native `Modal` window is a known risk
3307
+ * on Android: until it is verified, keep a form with text inputs in the
3308
+ * default `dialog` presentation. A bare `<Overlay>` gets neither.
3309
+ *
3310
+ * @example
3311
+ * <Modal
3312
+ * visible={open}
3313
+ * onRequestClose={() => setOpen(false)}
3314
+ * title="Edit order"
3315
+ * footer={
3316
+ * <>
3317
+ * <Button variant="secondary" title="Cancel" onPress={() => setOpen(false)} />
3318
+ * <Button title="Save" onPress={save} />
3319
+ * </>
3320
+ * }
3321
+ * >
3322
+ * <TextField value={note} onChangeText={setNote} placeholder="Note" />
3323
+ * </Modal>
3324
+ */
3325
+ export const Modal: (props: {
3326
+ /** Nothing renders (and nothing is mounted) while this is false. */
3327
+ visible?: boolean;
3328
+ /**
3329
+ * The close button, backdrop press, Escape (web) and Android back fire this.
3330
+ * Omitted, no close button is drawn and only your own controls close it.
3331
+ */
3332
+ onRequestClose?: () => void;
3333
+ /**
3334
+ * Defaults to true. False while the dialog must stay open (a save in
3335
+ * flight): no close button, and the backdrop, Escape and Android back do
3336
+ * not fire `onRequestClose`. Only while that work runs, and always restore
3337
+ * it: with no footer button or in-body control, nobody can close the dialog.
3338
+ */
3339
+ dismissible?: boolean;
3340
+ /** With no title and no close button the dialog has no header row. */
3341
+ title?: string;
3342
+ /** The action buttons: one right-aligned row that wraps. Omit for no footer. */
3343
+ footer?: ReactNode;
3344
+ /** Centred (`dialog`, the default) or anchored to the bottom edge (`sheet`). */
3345
+ presentation?: OverlayPresentation;
3346
+ /** Panel width tier. `full` runs edge to edge. Defaults to `md`. */
3347
+ size?: "sm" | "md" | "lg" | "full";
3348
+ /** Set false when only the close button or a footer action may close it. */
3349
+ dismissOnBackdropPress?: boolean;
3350
+ /** Defaults to "dialog". Web only; native has no equivalent. */
3351
+ role?: "dialog" | "alertdialog";
3352
+ /** Focused on open instead of the dialog itself. Web only; a no-op on native. */
3353
+ initialFocusRef?: { current: any };
3354
+ /** Label for the close button. Defaults to "Close". */
3355
+ closeAccessibilityLabel?: string;
3356
+ /** Names a dialog that has no title; the title names it otherwise. */
3357
+ accessibilityLabel?: string;
3358
+ /** On the panel; the parts take `-close`, `-body` and `-footer`. */
3359
+ testID?: string;
3360
+ /** The body. It scrolls on its own: do not wrap it in a ScrollView. */
3267
3361
  children?: ReactNode;
3268
3362
  }) => any;
3269
3363
 
@@ -3571,6 +3665,40 @@ export const Tabs: (props: {
3571
3665
  testID?: string;
3572
3666
  }) => any;
3573
3667
 
3668
+ export type BannerTone = "info" | "success" | "warning" | "error";
3669
+
3670
+ /**
3671
+ * Themed inline message: an error, warning, success or notice that stays in
3672
+ * place until it is resolved. Its fill, text colour, border and corners come
3673
+ * from the theme's `alert` scope, so every banner in the app matches. An
3674
+ * `error` is marked as an alert and the other tones as a polite status: the
3675
+ * web and Android announce them when they appear, iOS reads them in place.
3676
+ * For a brief confirmation that may disappear on its own, use `useToast()`.
3677
+ *
3678
+ * @example
3679
+ * <Banner tone="error" message={error.message} action={{ label: "Retry", onPress: refetch }} />
3680
+ */
3681
+ export const Banner: (props: {
3682
+ /** Default "info". */
3683
+ tone?: BannerTone;
3684
+ /** Text is themed; an element renders as given. */
3685
+ title?: ReactNode;
3686
+ /** Text wraps onto as many lines as it needs; an element renders as given. */
3687
+ message?: ReactNode;
3688
+ /** A lucide name replacing the tone's glyph, or `false` for none. */
3689
+ icon?: string | false | null;
3690
+ action?: { label: string; onPress: () => void; accessibilityLabel?: string; testID?: string };
3691
+ /** Shows a close control; hide the banner in the handler. */
3692
+ onDismiss?: () => void;
3693
+ /** Default "Dismiss". */
3694
+ dismissAccessibilityLabel?: string;
3695
+ /** For hosts rendering outside a widget context; widgets omit it. */
3696
+ theme?: any;
3697
+ style?: any;
3698
+ /** The action and close control default to `<testID>-action` / `<testID>-dismiss`. */
3699
+ testID?: string;
3700
+ }) => any;
3701
+
3574
3702
  /**
3575
3703
  * Themed collapsible sections. The surface comes from the theme's `card`
3576
3704
  * scope and the headers and dividers from its `list` scope, so every
package/dist/index.js CHANGED
@@ -270,6 +270,10 @@ export { Accordion, AccordionItem } from "./accordion.js";
270
270
  export { Tabs } from "./tabs.js";
271
271
  // Themed loading states; follow the theme's `loading` scope.
272
272
  export { Spinner, Skeleton } from "./loading.js";
273
+ // A themed inline message; follows the theme's `alert` scope.
274
+ export { Banner } from "./banner.js";
275
+ // The standard dialog, on `<Overlay>`; follows the theme's `modal` scope.
276
+ export { Modal } from "./modal.js";
273
277
  // The semantic text tiers, shared with the built-in widgets.
274
278
  export {
275
279
  textRoleStyle,
@@ -263,6 +263,10 @@ export { Accordion, AccordionItem } from "./accordion.native.js";
263
263
  export { Tabs } from "./tabs.native.js";
264
264
  // Themed loading states; follow the theme's `loading` scope.
265
265
  export { Spinner, Skeleton } from "./loading.native.js";
266
+ // A themed inline message; follows the theme's `alert` scope.
267
+ export { Banner } from "./banner.native.js";
268
+ // The standard dialog, on `<Overlay>`; follows the theme's `modal` scope.
269
+ export { Modal } from "./modal.native.js";
266
270
  // The semantic text tiers, shared with the built-in widgets.
267
271
  export {
268
272
  textRoleStyle,
@@ -34,9 +34,9 @@ const TOOLS = Object.freeze([
34
34
  },
35
35
  ]);
36
36
 
37
- export function makeMarkdownInput(rn, RichText, Overlay) {
37
+ export function makeMarkdownInput(rn, RichText, Modal, Button) {
38
38
  const { Text, View, Pressable, TextInput } = rn;
39
- const MarkdownLinkDialog = makeMarkdownLinkDialog(rn, Overlay);
39
+ const MarkdownLinkDialog = makeMarkdownLinkDialog(rn, Modal, Button);
40
40
 
41
41
  function MarkdownInput({
42
42
  value,
@@ -210,7 +210,6 @@ export function makeMarkdownInput(rn, RichText, Overlay) {
210
210
  visible: linkOpen,
211
211
  onClose: () => setLinkOpen(false),
212
212
  onSubmit: insertLink,
213
- tokens,
214
213
  pages,
215
214
  initialLabel: selected,
216
215
  testID,
@@ -5,11 +5,13 @@
5
5
  import * as ReactNative from "react-native-web";
6
6
  import { makeMarkdownInput } from "./markdown-input-view.js";
7
7
  import { RichText } from "./richtext.js";
8
- import { Overlay } from "./overlay.js";
8
+ import { Modal } from "./modal.js";
9
+ import { Button } from "./button.js";
9
10
  import { TextInput } from "./primitives.js";
10
11
 
11
12
  export const MarkdownInput = makeMarkdownInput(
12
13
  { ...ReactNative, TextInput },
13
14
  RichText,
14
- Overlay,
15
+ Modal,
16
+ Button,
15
17
  );
@@ -5,12 +5,14 @@
5
5
  import { Text, View, Pressable, TextInput, ScrollView } from "react-native";
6
6
  import { makeMarkdownInput } from "./markdown-input-view.js";
7
7
  import { RichText } from "./richtext.native.js";
8
- import { Overlay } from "./overlay.native.js";
8
+ import { Modal } from "./modal.native.js";
9
+ import { Button } from "./button.native.js";
9
10
 
10
11
  // sc-7349: ScrollView joins the set for the link form's page list, which must
11
12
  // scroll rather than grow an app's every page past the panel.
12
13
  export const MarkdownInput = makeMarkdownInput(
13
14
  { Text, View, Pressable, TextInput, ScrollView },
14
15
  RichText,
15
- Overlay,
16
+ Modal,
17
+ Button,
16
18
  );
@@ -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