@colixsystems/widget-sdk 0.207.0 → 0.212.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +97 -2
  2. package/dist/checkbox-view.js +150 -0
  3. package/dist/checkbox.js +11 -0
  4. package/dist/checkbox.native.js +7 -0
  5. package/dist/contract.cjs +78 -4
  6. package/dist/contract.js +78 -4
  7. package/dist/form-control-logic.js +55 -0
  8. package/dist/form-control-tokens.js +121 -0
  9. package/dist/form-control-view.js +98 -0
  10. package/dist/hooks.js +13 -0
  11. package/dist/host.d.ts +46 -0
  12. package/dist/host.js +4 -0
  13. package/dist/index.d.ts +230 -6
  14. package/dist/index.js +11 -1
  15. package/dist/index.native.js +11 -1
  16. package/dist/markdown-input-view.js +2 -3
  17. package/dist/markdown-input.js +4 -2
  18. package/dist/markdown-input.native.js +4 -2
  19. package/dist/markdown-link-dialog.js +94 -108
  20. package/dist/modal-tokens.js +107 -0
  21. package/dist/modal-view.js +255 -0
  22. package/dist/modal.js +8 -0
  23. package/dist/modal.native.js +45 -0
  24. package/dist/overlay-tokens.js +69 -5
  25. package/dist/overlay-view.js +52 -16
  26. package/dist/overlay.native.js +8 -1
  27. package/dist/search-field-view.js +155 -0
  28. package/dist/search-field.js +13 -0
  29. package/dist/search-field.native.js +9 -0
  30. package/dist/select-view.js +312 -0
  31. package/dist/select.js +15 -0
  32. package/dist/select.native.js +9 -0
  33. package/dist/surface-theme.js +108 -0
  34. package/dist/switch-view.js +149 -0
  35. package/dist/switch.js +80 -0
  36. package/dist/switch.native.js +7 -0
  37. package/dist/text-field-view.js +54 -8
  38. package/dist/text-field.js +6 -1
  39. package/dist/text-field.native.js +3 -3
  40. package/dist/web-modal.js +24 -5
  41. package/package.json +1 -1
  42. package/src/contract.js +78 -4
package/dist/index.d.ts CHANGED
@@ -3167,7 +3167,6 @@ export const TextInput: any;
3167
3167
  export const FlatList: any;
3168
3168
  export const SectionList: any;
3169
3169
  export const ActivityIndicator: any;
3170
- export const Switch: any;
3171
3170
  export const StyleSheet: any;
3172
3171
 
3173
3172
  /**
@@ -3257,13 +3256,107 @@ export const Overlay: (props: {
3257
3256
  onRequestClose?: () => void;
3258
3257
  /** Panel width tier. `full` runs edge to edge. Defaults to `md`. */
3259
3258
  size?: "sm" | "md" | "lg" | "full";
3259
+ /** Centred (`dialog`, the default) or anchored to the bottom edge (`sheet`). */
3260
+ presentation?: OverlayPresentation;
3260
3261
  /** Set false when only an explicit control may close the overlay. */
3261
3262
  dismissOnBackdropPress?: boolean;
3263
+ /**
3264
+ * Set false when the content has its own close control: the backdrop stays
3265
+ * pressable but leaves the tab order and the accessibility tree.
3266
+ */
3267
+ backdropAccessible?: boolean;
3268
+ /** The web dialog node's role. Defaults to "dialog". No native equivalent. */
3269
+ role?: "dialog" | "alertdialog";
3270
+ /**
3271
+ * Names the dialog: on the web the node that carries the role, in the
3272
+ * exported app the panel (React Native's Modal carries no name).
3273
+ */
3262
3274
  accessibilityLabel?: string;
3275
+ /** The id of the element that names the dialog; wins over the label. Web only. */
3276
+ accessibilityLabelledBy?: string;
3263
3277
  /** Label for the backdrop's dismiss target. Defaults to "Close". */
3264
3278
  closeAccessibilityLabel?: string;
3279
+ /** Focused on open instead of the dialog itself. Web only; a no-op on native. */
3280
+ initialFocusRef?: { current: any };
3265
3281
  /** Extra styles merged onto the themed panel. */
3266
3282
  style?: any;
3283
+ /** Set on the panel. */
3284
+ testID?: string;
3285
+ children?: ReactNode;
3286
+ }) => any;
3287
+
3288
+ export type OverlayPresentation = "dialog" | "sheet";
3289
+
3290
+ /**
3291
+ * The standard dialog, built on `<Overlay>`: a title, a close button, a body
3292
+ * that scrolls when it is long, and a footer action row laid out below it.
3293
+ * Its background, corner radius, padding, scrim, shadow and title colour come
3294
+ * from the theme's `modal` scope (Design > Components > Dialogs), so every dialog in
3295
+ * the app matches. Use it instead of hand-building a header and an action row
3296
+ * inside a bare `<Overlay>`.
3297
+ *
3298
+ * A dialog that paints its own background hands what is rendered inside it a
3299
+ * theme re-inked for that background, so SDK components and any component
3300
+ * calling `useTheme()` inside the Modal stay readable.
3301
+ *
3302
+ * In the exported app the dialog pads a `sheet` by the bottom safe-area inset
3303
+ * and a `full` dialog by the top and bottom ones, and wraps its panel in React
3304
+ * Native's `KeyboardAvoidingView`. This is not yet verified on a device, and
3305
+ * `KeyboardAvoidingView` inside a React Native `Modal` window is a known risk
3306
+ * on Android: until it is verified, keep a form with text inputs in the
3307
+ * default `dialog` presentation. A bare `<Overlay>` gets neither.
3308
+ *
3309
+ * @example
3310
+ * <Modal
3311
+ * visible={open}
3312
+ * onRequestClose={() => setOpen(false)}
3313
+ * title="Edit order"
3314
+ * footer={
3315
+ * <>
3316
+ * <Button variant="secondary" title="Cancel" onPress={() => setOpen(false)} />
3317
+ * <Button title="Save" onPress={save} />
3318
+ * </>
3319
+ * }
3320
+ * >
3321
+ * <TextField value={note} onChangeText={setNote} placeholder="Note" />
3322
+ * </Modal>
3323
+ */
3324
+ export const Modal: (props: {
3325
+ /** Nothing renders (and nothing is mounted) while this is false. */
3326
+ visible?: boolean;
3327
+ /**
3328
+ * The close button, backdrop press, Escape (web) and Android back fire this.
3329
+ * Omitted, no close button is drawn and only your own controls close it.
3330
+ */
3331
+ onRequestClose?: () => void;
3332
+ /**
3333
+ * Defaults to true. False while the dialog must stay open (a save in
3334
+ * flight): no close button, and the backdrop, Escape and Android back do
3335
+ * not fire `onRequestClose`. Only while that work runs, and always restore
3336
+ * it: with no footer button or in-body control, nobody can close the dialog.
3337
+ */
3338
+ dismissible?: boolean;
3339
+ /** With no title and no close button the dialog has no header row. */
3340
+ title?: string;
3341
+ /** The action buttons: one right-aligned row that wraps. Omit for no footer. */
3342
+ footer?: ReactNode;
3343
+ /** Centred (`dialog`, the default) or anchored to the bottom edge (`sheet`). */
3344
+ presentation?: OverlayPresentation;
3345
+ /** Panel width tier. `full` runs edge to edge. Defaults to `md`. */
3346
+ size?: "sm" | "md" | "lg" | "full";
3347
+ /** Set false when only the close button or a footer action may close it. */
3348
+ dismissOnBackdropPress?: boolean;
3349
+ /** Defaults to "dialog". Web only; native has no equivalent. */
3350
+ role?: "dialog" | "alertdialog";
3351
+ /** Focused on open instead of the dialog itself. Web only; a no-op on native. */
3352
+ initialFocusRef?: { current: any };
3353
+ /** Label for the close button. Defaults to "Close". */
3354
+ closeAccessibilityLabel?: string;
3355
+ /** Names a dialog that has no title; the title names it otherwise. */
3356
+ accessibilityLabel?: string;
3357
+ /** On the panel; the parts take `-close`, `-body` and `-footer`. */
3358
+ testID?: string;
3359
+ /** The body. It scrolls on its own: do not wrap it in a ScrollView. */
3267
3360
  children?: ReactNode;
3268
3361
  }) => any;
3269
3362
 
@@ -3526,19 +3619,150 @@ export const Button: (props: {
3526
3619
  }) => any;
3527
3620
 
3528
3621
  /**
3529
- * Themed text-entry field (Theme Settings > Muted). Every TextInput prop works.
3530
- *
3531
- * @example
3532
- * <TextField value={name} onChangeText={setName} placeholder="Your name" accessibilityLabel="Name" />
3622
+ * What every form control shares: the label drawn with it (and read by a
3623
+ * screen reader), a hint below it, and an error that replaces the hint and
3624
+ * marks the control. `error: true` marks it without a message.
3533
3625
  */
3534
- export const TextField: (props: {
3626
+ export interface FormControlProps {
3627
+ label?: ReactNode;
3628
+ helper?: ReactNode;
3629
+ error?: ReactNode | boolean;
3630
+ disabled?: boolean;
3631
+ /** Required when there is no text `label`. */
3632
+ accessibilityLabel?: string;
3633
+ testID?: string;
3634
+ }
3635
+
3636
+ /** Per-control overrides of the theme's `muted` scope. */
3637
+ export interface FormFieldOverrides {
3535
3638
  background?: string;
3536
3639
  textColor?: string;
3537
3640
  borderColor?: string;
3538
3641
  radius?: number;
3539
3642
  height?: number;
3643
+ }
3644
+
3645
+ /**
3646
+ * Themed text-entry field (Design > Components > Muted). Every TextInput prop
3647
+ * works.
3648
+ *
3649
+ * @example
3650
+ * <TextField label="Name" value={name} onChangeText={setName} placeholder="Your name" />
3651
+ */
3652
+ export const TextField: (props: FormControlProps & FormFieldOverrides & {
3540
3653
  multiline?: boolean;
3541
3654
  style?: any;
3655
+ /** The box around a labelled field. */
3656
+ containerStyle?: any;
3657
+ [prop: string]: any;
3658
+ }) => any;
3659
+
3660
+ /**
3661
+ * Themed tick box: the input surface, filled with the primary colour when
3662
+ * checked. One 44px target with the checkbox role and state on both hosts.
3663
+ *
3664
+ * @example
3665
+ * <Checkbox label="Remember me" value={remember} onValueChange={setRemember} />
3666
+ */
3667
+ export const Checkbox: (props: FormControlProps & {
3668
+ value?: boolean;
3669
+ onValueChange?: (next: boolean) => void;
3670
+ /** The row. */
3671
+ style?: any;
3672
+ /** The box around a row that shows a helper or an error. */
3673
+ containerStyle?: any;
3674
+ [prop: string]: any;
3675
+ }) => any;
3676
+
3677
+ /**
3678
+ * Themed on/off toggle: the platform's own switch with the theme's track
3679
+ * colours. Every React Native `Switch` prop works: a `trackColor` overrides
3680
+ * the theme for the states it names, and the thumb is the platform's unless
3681
+ * `thumbColor` is passed. Without a label, helper or error it renders the
3682
+ * bare switch.
3683
+ *
3684
+ * @example
3685
+ * <Switch label="Email me updates" value={on} onValueChange={setOn} />
3686
+ */
3687
+ export const Switch: (props: FormControlProps & {
3688
+ value?: boolean;
3689
+ onValueChange?: (next: boolean) => void;
3690
+ trackColor?: { false?: string; true?: string };
3691
+ thumbColor?: string;
3692
+ style?: any;
3693
+ /** The box around a labelled switch. */
3694
+ containerStyle?: any;
3695
+ [prop: string]: any;
3696
+ }) => any;
3697
+
3698
+ export interface SelectOption<V extends string | number = string | number> {
3699
+ value: V;
3700
+ label: string;
3701
+ disabled?: boolean;
3702
+ testID?: string;
3703
+ }
3704
+
3705
+ /**
3706
+ * Themed single-choice picker. The trigger is the input field, a button that
3707
+ * opens a dialog; its options open at screen level (through `Overlay`) in a
3708
+ * sheet painted from the `popover` scope, so no container clips them and a
3709
+ * long list scrolls. Each option has the option role; the list is a listbox
3710
+ * on the web only. Other props land on the trigger.
3711
+ *
3712
+ * @example
3713
+ * <Select label="Status" value={status} onValueChange={setStatus}
3714
+ * options={[{ value: "open", label: "Open" }, { value: "done", label: "Done" }]} />
3715
+ */
3716
+ export const Select: <V extends string | number = string | number>(
3717
+ props: FormControlProps & FormFieldOverrides & {
3718
+ options: Array<SelectOption<V>>;
3719
+ value?: V | null;
3720
+ onValueChange?: (value: V, option: SelectOption<V>) => void;
3721
+ /** Shown while nothing is chosen. Default "Select". */
3722
+ placeholder?: string;
3723
+ /** The sheet's heading. Defaults to a text `label`. */
3724
+ title?: string;
3725
+ /** Shown in the sheet when `options` is empty. Default "No options". */
3726
+ emptyText?: string;
3727
+ closeAccessibilityLabel?: string;
3728
+ /** The trigger. */
3729
+ style?: any;
3730
+ containerStyle?: any;
3731
+ [prop: string]: any;
3732
+ },
3733
+ ) => any;
3734
+
3735
+ /**
3736
+ * Space-key activation for a control whose role is not a button: on the web a
3737
+ * `Pressable` answers Space only for a button. Native ignores `onKeyDown`.
3738
+ *
3739
+ * @example
3740
+ * <Pressable accessibilityRole="radio" onPress={pick} onKeyDown={spaceActivates(pick)} />
3741
+ */
3742
+ export function spaceActivates(
3743
+ onPress: () => void,
3744
+ ): (event: { key: string; repeat?: boolean; preventDefault: () => void }) => void;
3745
+
3746
+ /**
3747
+ * Themed search box: a `TextField` with a search glyph, the search key and a
3748
+ * clear control. Every `TextField` prop works.
3749
+ *
3750
+ * @example
3751
+ * <SearchField value={query} onChangeText={setQuery} placeholder="Search orders" />
3752
+ */
3753
+ export const SearchField: (props: FormControlProps & {
3754
+ value?: string;
3755
+ onChangeText?: (text: string) => void;
3756
+ /** Called after the clear control empties the field. */
3757
+ onClear?: () => void;
3758
+ /** Default "Search". */
3759
+ placeholder?: string;
3760
+ /** Default "Clear search". */
3761
+ clearAccessibilityLabel?: string;
3762
+ /** The input. */
3763
+ style?: any;
3764
+ /** The box around it; use it for `flex: 1` in a toolbar. */
3765
+ containerStyle?: any;
3542
3766
  [prop: string]: any;
3543
3767
  }) => any;
3544
3768
 
package/dist/index.js CHANGED
@@ -167,7 +167,6 @@ export {
167
167
  FlatList,
168
168
  SectionList,
169
169
  ActivityIndicator,
170
- Switch,
171
170
  StyleSheet,
172
171
  Linking,
173
172
  Icon,
@@ -266,12 +265,23 @@ export {
266
265
  export { TextField } from "./text-field.js";
267
266
  // Themed collapsible sections, drawn from the `card` and `list` scopes.
268
267
  export { Accordion, AccordionItem } from "./accordion.js";
268
+ // The themed form controls; they follow the theme's `muted` scope as
269
+ // `<TextField>` does, and `popover` for the Select's options. `<Switch>` is
270
+ // the platform's own.
271
+ export { Checkbox } from "./checkbox.js";
272
+ export { Switch } from "./switch.js";
273
+ export { Select } from "./select.js";
274
+ export { SearchField } from "./search-field.js";
275
+ // Space for a control whose role is not a button; a no-op on native.
276
+ export { spaceActivates } from "./form-control-logic.js";
269
277
  // A themed tab strip; follows the theme's `tabs` scope.
270
278
  export { Tabs } from "./tabs.js";
271
279
  // Themed loading states; follow the theme's `loading` scope.
272
280
  export { Spinner, Skeleton } from "./loading.js";
273
281
  // A themed inline message; follows the theme's `alert` scope.
274
282
  export { Banner } from "./banner.js";
283
+ // The standard dialog, on `<Overlay>`; follows the theme's `modal` scope.
284
+ export { Modal } from "./modal.js";
275
285
  // The semantic text tiers, shared with the built-in widgets.
276
286
  export {
277
287
  textRoleStyle,
@@ -160,7 +160,6 @@ export {
160
160
  FlatList,
161
161
  SectionList,
162
162
  ActivityIndicator,
163
- Switch,
164
163
  StyleSheet,
165
164
  Linking,
166
165
  Icon,
@@ -259,12 +258,23 @@ export {
259
258
  export { TextField } from "./text-field.native.js";
260
259
  // Themed collapsible sections, drawn from the `card` and `list` scopes.
261
260
  export { Accordion, AccordionItem } from "./accordion.native.js";
261
+ // The themed form controls; they follow the theme's `muted` scope as
262
+ // `<TextField>` does, and `popover` for the Select's options. `<Switch>` is
263
+ // the platform's own.
264
+ export { Checkbox } from "./checkbox.native.js";
265
+ export { Switch } from "./switch.native.js";
266
+ export { Select } from "./select.native.js";
267
+ export { SearchField } from "./search-field.native.js";
268
+ // Space for a control whose role is not a button; a no-op on native.
269
+ export { spaceActivates } from "./form-control-logic.js";
262
270
  // A themed tab strip; follows the theme's `tabs` scope.
263
271
  export { Tabs } from "./tabs.native.js";
264
272
  // Themed loading states; follow the theme's `loading` scope.
265
273
  export { Spinner, Skeleton } from "./loading.native.js";
266
274
  // A themed inline message; follows the theme's `alert` scope.
267
275
  export { Banner } from "./banner.native.js";
276
+ // The standard dialog, on `<Overlay>`; follows the theme's `modal` scope.
277
+ export { Modal } from "./modal.native.js";
268
278
  // The semantic text tiers, shared with the built-in widgets.
269
279
  export {
270
280
  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