@colixsystems/widget-sdk 0.133.0 → 0.134.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
@@ -188,6 +188,17 @@ export function useHostTheme() {
188
188
  return ctx && ctx.workspace ? ctx.workspace.theme : undefined;
189
189
  }
190
190
 
191
+ // @internal — the navigation counterpart of useHostTheme: reads the host's
192
+ // navigation slice WITHOUT throwing when no provider is mounted, unlike
193
+ // useNavigation() which asserts a widget context. `<RichText>` reads it so a
194
+ // link in formatted content is followable inside a widget yet the component
195
+ // stays renderable anywhere (a Studio preview, a test harness). Not part of the
196
+ // public widget hook surface — not re-exported from index.js.
197
+ export function useHostNavigation() {
198
+ const ctx = useContext(HostWidgetContext);
199
+ return ctx ? ctx.navigation : undefined;
200
+ }
201
+
191
202
  /**
192
203
  * REQ-THEME-13 — returns the author-set per-widget style values: the object the
193
204
  * host delivers under `props.style`, keyed by the style-field names the widget
package/dist/index.d.ts CHANGED
@@ -2600,6 +2600,8 @@ export function lintSource(
2600
2600
  export interface ContractHookEntry {
2601
2601
  name: string;
2602
2602
  signature: string;
2603
+ /** Arguments and option keys in `signature` that may be omitted (sc-6946). */
2604
+ optionalArgs?: string[];
2603
2605
  returnShape: Record<string, string>;
2604
2606
  requiredContextSlice: string[];
2605
2607
  scopes: string[] | null;
package/dist/index.js CHANGED
@@ -113,6 +113,8 @@ export {
113
113
  isHttpImageSrc,
114
114
  isSafeMarkdownImageSrc,
115
115
  normaliseMarkdownImageSize,
116
+ escapeMarkdownLabel,
117
+ formatMarkdownTable,
116
118
  MARKDOWN_IMAGE_SIZES,
117
119
  } from "./markdown.js";
118
120
  export {
@@ -107,6 +107,8 @@ export {
107
107
  isHttpImageSrc,
108
108
  isSafeMarkdownImageSrc,
109
109
  normaliseMarkdownImageSize,
110
+ escapeMarkdownLabel,
111
+ formatMarkdownTable,
110
112
  MARKDOWN_IMAGE_SIZES,
111
113
  } from "./markdown.js";
112
114
  export {
@@ -5,8 +5,13 @@
5
5
  // author's SELECTION rather than appending at the end, and that is a thing
6
6
  // tests can pin without a renderer.
7
7
 
8
+ import { escapeMarkdownLabel, formatMarkdownTable } from "./markdown.js";
9
+
8
10
  const LINE_RE = /^(\s*)((?:#{1,3}\s)|(?:[-*]\s)|(?:\d+[.)]\s))?([\s\S]*)$/;
9
11
 
12
+ // The literal the inserted table skeleton opens with; the caret lands on it.
13
+ const FIRST_HEADING = "Column 1";
14
+
10
15
  const LINE_KINDS = Object.freeze({
11
16
  heading2: { marker: "## ", test: /^##\s/ },
12
17
  heading3: { marker: "### ", test: /^###\s/ },
@@ -86,6 +91,66 @@ function linePrefix(kind) {
86
91
  };
87
92
  }
88
93
 
94
+ // sc-7349 — a link needs a TARGET, which no selection can supply, so this is
95
+ // the one op that takes a third argument. The target is stored verbatim: the
96
+ // host's `navigation.openLink` is what decides page / external / refuse, so
97
+ // this must not pre-judge it (see markdown.js).
98
+ function linkOp() {
99
+ return (text, selection, options) => {
100
+ const { src, start, end } = readRange(text, selection);
101
+ // A bare string is accepted as the target, so the common case stays terse.
102
+ const opts =
103
+ options && typeof options === "object" ? options : { target: options };
104
+ const href = typeof opts.target === "string" ? opts.target.trim() : "";
105
+ // Nothing to point at — leave the text exactly as the author left it.
106
+ if (!href) return { value: src, selection: { start, end } };
107
+
108
+ // An explicit label wins, so a dialog can name the link when the author
109
+ // had nothing selected; otherwise the selection IS the label.
110
+ const chosen =
111
+ typeof opts.label === "string" && opts.label.trim()
112
+ ? opts.label
113
+ : src.slice(start, end);
114
+ const label = escapeMarkdownLabel(chosen) || href;
115
+ const markup = `[${label}](${href})`;
116
+ return {
117
+ value: src.slice(0, start) + markup + src.slice(end),
118
+ // Leaves the LABEL selected, so typing replaces the link text rather
119
+ // than the markup around it.
120
+ selection: { start: start + 1, end: start + 1 + label.length },
121
+ };
122
+ };
123
+ }
124
+
125
+ // A table is a block, so it needs its own lines: a blank line before keeps it
126
+ // from fusing with the paragraph the caret was in.
127
+ function tableOp() {
128
+ return (text, selection) => {
129
+ const { src, start, end } = readRange(text, selection);
130
+ const before = src.slice(0, start);
131
+ const after = src.slice(end);
132
+ const lead =
133
+ before.length === 0 || before.endsWith("\n\n")
134
+ ? ""
135
+ : before.endsWith("\n")
136
+ ? "\n"
137
+ : "\n\n";
138
+ const trail = after.startsWith("\n") ? "" : "\n";
139
+
140
+ const skeleton = formatMarkdownTable();
141
+ const offset = before.length + lead.length;
142
+ return {
143
+ value: before + lead + skeleton + trail + after,
144
+ // Selects the first header cell, so the author types over "Column 1"
145
+ // instead of hunting for the caret inside the pipes.
146
+ selection: {
147
+ start: offset + 2,
148
+ end: offset + 2 + FIRST_HEADING.length,
149
+ },
150
+ };
151
+ };
152
+ }
153
+
89
154
  export const markdownEditOps = Object.freeze({
90
155
  bold: wrap("**"),
91
156
  italic: wrap("*"),
@@ -94,4 +159,6 @@ export const markdownEditOps = Object.freeze({
94
159
  heading3: linePrefix("heading3"),
95
160
  bullet: linePrefix("bullet"),
96
161
  ordered: linePrefix("ordered"),
162
+ link: linkOp(),
163
+ table: tableOp(),
97
164
  });
@@ -13,6 +13,7 @@ import React from "react";
13
13
  import { useHostTheme } from "./hooks.js";
14
14
  import { resolveRichTextTokens } from "./richtext-tokens.js";
15
15
  import { markdownEditOps } from "./markdown-edit.js";
16
+ import { makeMarkdownLinkDialog } from "./markdown-link-dialog.js";
16
17
 
17
18
  const TOOLS = Object.freeze([
18
19
  { key: "bold", label: "B", accessibilityLabel: "Bold", weight: "700" },
@@ -22,10 +23,20 @@ const TOOLS = Object.freeze([
22
23
  { key: "heading3", label: "H3", accessibilityLabel: "Subheading" },
23
24
  { key: "bullet", label: "•", accessibilityLabel: "Bulleted list" },
24
25
  { key: "ordered", label: "1.", accessibilityLabel: "Numbered list" },
26
+ { key: "table", label: "Table", accessibilityLabel: "Insert table" },
27
+ // sc-7349: the one tool a selection cannot complete — a link needs a target,
28
+ // so this button opens the form instead of editing the string directly.
29
+ {
30
+ key: "link",
31
+ label: "Link",
32
+ accessibilityLabel: "Insert link",
33
+ opensDialog: true,
34
+ },
25
35
  ]);
26
36
 
27
- export function makeMarkdownInput(rn, RichText) {
37
+ export function makeMarkdownInput(rn, RichText, Overlay) {
28
38
  const { Text, View, Pressable, TextInput } = rn;
39
+ const MarkdownLinkDialog = makeMarkdownLinkDialog(rn, Overlay);
29
40
 
30
41
  function MarkdownInput({
31
42
  value,
@@ -34,6 +45,7 @@ export function makeMarkdownInput(rn, RichText) {
34
45
  previewLabel = "Preview",
35
46
  showPreview = true,
36
47
  renderImage,
48
+ pages,
37
49
  minHeight = 150,
38
50
  maxHeight = 280,
39
51
  accessibilityLabel,
@@ -43,6 +55,7 @@ export function makeMarkdownInput(rn, RichText) {
43
55
  const theme = useHostTheme();
44
56
  const tokens = React.useMemo(() => resolveRichTextTokens(theme), [theme]);
45
57
  const [selection, setSelection] = React.useState({ start: 0, end: 0 });
58
+ const [linkOpen, setLinkOpen] = React.useState(false);
46
59
 
47
60
  const text = typeof value === "string" ? value : "";
48
61
 
@@ -52,17 +65,43 @@ export function makeMarkdownInput(rn, RichText) {
52
65
  }, []);
53
66
 
54
67
  const applyTool = React.useCallback(
55
- (toolKey) => {
68
+ (toolKey, options) => {
56
69
  if (typeof onChange !== "function") return;
57
70
  const edit = markdownEditOps[toolKey];
58
71
  if (!edit) return;
59
- const next = edit(text, selection);
72
+ const next = edit(text, selection, options);
60
73
  onChange(next.value);
61
74
  setSelection(next.selection);
62
75
  },
63
76
  [onChange, text, selection],
64
77
  );
65
78
 
79
+ const pressTool = React.useCallback(
80
+ (tool) => {
81
+ if (tool.opensDialog) {
82
+ setLinkOpen(true);
83
+ return;
84
+ }
85
+ applyTool(tool.key);
86
+ },
87
+ [applyTool],
88
+ );
89
+
90
+ const insertLink = React.useCallback(
91
+ (result) => {
92
+ setLinkOpen(false);
93
+ applyTool("link", result);
94
+ },
95
+ [applyTool],
96
+ );
97
+
98
+ // The selection seeds the dialog's Link text, so highlighting a phrase and
99
+ // pressing Link keeps that phrase as the label.
100
+ const selected = text.slice(
101
+ Math.min(selection.start, selection.end),
102
+ Math.max(selection.start, selection.end),
103
+ );
104
+
66
105
  const fieldStyle = {
67
106
  fontFamily: tokens.bodyFont,
68
107
  fontSize: tokens.bodySize,
@@ -96,7 +135,7 @@ export function makeMarkdownInput(rn, RichText) {
96
135
  Pressable,
97
136
  {
98
137
  key: tool.key,
99
- onPress: () => applyTool(tool.key),
138
+ onPress: () => pressTool(tool),
100
139
  accessibilityRole: "button",
101
140
  accessibilityLabel: tool.accessibilityLabel,
102
141
  testID: testID ? `${testID}-${tool.key}` : undefined,
@@ -159,10 +198,23 @@ export function makeMarkdownInput(rn, RichText) {
159
198
  React.createElement(RichText, {
160
199
  value: text,
161
200
  renderImage,
201
+ // sc-7349: the preview shows the reader's document, but a tap
202
+ // must NOT leave the page — that would throw away the draft the
203
+ // author is still writing.
204
+ followLinks: false,
162
205
  testID: testID ? `${testID}-preview` : undefined,
163
206
  }),
164
207
  )
165
208
  : null,
209
+ React.createElement(MarkdownLinkDialog, {
210
+ visible: linkOpen,
211
+ onClose: () => setLinkOpen(false),
212
+ onSubmit: insertLink,
213
+ tokens,
214
+ pages,
215
+ initialLabel: selected,
216
+ testID,
217
+ }),
166
218
  );
167
219
  }
168
220
 
@@ -5,5 +5,6 @@
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
9
 
9
- export const MarkdownInput = makeMarkdownInput(ReactNative, RichText);
10
+ export const MarkdownInput = makeMarkdownInput(ReactNative, RichText, Overlay);
@@ -2,11 +2,15 @@
2
2
  // ./markdown-input-view.js to React Native's own primitives and the native
3
3
  // `<RichText>`. Only the imports differ from the web mirror.
4
4
 
5
- import { Text, View, Pressable, TextInput } from "react-native";
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
9
 
10
+ // sc-7349: ScrollView joins the set for the link form's page list, which must
11
+ // scroll rather than grow an app's every page past the panel.
9
12
  export const MarkdownInput = makeMarkdownInput(
10
- { Text, View, Pressable, TextInput },
13
+ { Text, View, Pressable, TextInput, ScrollView },
11
14
  RichText,
15
+ Overlay,
12
16
  );
@@ -0,0 +1,284 @@
1
+ // sc-7349 — the link form `<MarkdownInput>`'s Link button opens.
2
+ //
3
+ // A link needs a TARGET, and no selection can supply one: the author has to
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.
8
+ //
9
+ // Split out of markdown-input-view.js so each file stays one component's worth
10
+ // of markup. Like every view module here it is bound per host by the caller
11
+ // (see markdown-input.js / .native.js), so the two hosts cannot drift.
12
+
13
+ import React from "react";
14
+
15
+ // The page list scrolls rather than growing an app's 40 pages past the panel.
16
+ const PAGE_LIST_MAX_HEIGHT = 180;
17
+
18
+ export function makeMarkdownLinkDialog(rn, Overlay) {
19
+ const { Text, View, Pressable, TextInput, ScrollView } = rn;
20
+
21
+ function Field({ label, tokens, children }) {
22
+ return React.createElement(
23
+ View,
24
+ { style: { marginBottom: tokens.blockGap } },
25
+ React.createElement(
26
+ Text,
27
+ {
28
+ style: {
29
+ fontFamily: tokens.bodyFont,
30
+ fontSize: tokens.codeSize,
31
+ color: tokens.mutedColor,
32
+ marginBottom: tokens.markerGap,
33
+ },
34
+ },
35
+ label,
36
+ ),
37
+ children,
38
+ );
39
+ }
40
+
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
+ };
85
+
86
+ const inputStyle = {
87
+ fontFamily: tokens.bodyFont,
88
+ fontSize: tokens.bodySize,
89
+ color: tokens.color,
90
+ backgroundColor: tokens.surface,
91
+ borderWidth: 1,
92
+ borderColor: tokens.border,
93
+ borderRadius: tokens.radius,
94
+ padding: tokens.markerGap,
95
+ };
96
+
97
+ const tab = (key, text) =>
98
+ React.createElement(
99
+ Pressable,
100
+ {
101
+ key,
102
+ onPress: () => setMode(key),
103
+ accessibilityRole: "button",
104
+ accessibilityState: { selected: mode === key },
105
+ testID: testID ? `${testID}-mode-${key}` : undefined,
106
+ style: {
107
+ paddingVertical: tokens.markerGap,
108
+ paddingHorizontal: tokens.padding,
109
+ marginRight: tokens.markerGap,
110
+ borderWidth: 1,
111
+ borderColor: mode === key ? tokens.accent : tokens.border,
112
+ borderRadius: tokens.radius,
113
+ ...(mode === key ? { backgroundColor: tokens.accent } : null),
114
+ },
115
+ },
116
+ React.createElement(
117
+ Text,
118
+ {
119
+ style: {
120
+ fontFamily: tokens.bodyFont,
121
+ fontSize: tokens.codeSize,
122
+ fontWeight: "500",
123
+ color: mode === key ? tokens.onAccent : tokens.color,
124
+ },
125
+ },
126
+ text,
127
+ ),
128
+ );
129
+
130
+ const action = (key, text, { primary, disabled, onPress }) =>
131
+ React.createElement(
132
+ Pressable,
133
+ {
134
+ key,
135
+ onPress,
136
+ disabled,
137
+ accessibilityRole: "button",
138
+ testID: testID ? `${testID}-${key}` : undefined,
139
+ style: {
140
+ paddingVertical: tokens.markerGap,
141
+ paddingHorizontal: tokens.padding,
142
+ marginLeft: tokens.markerGap,
143
+ borderWidth: 1,
144
+ borderColor: primary ? tokens.accent : tokens.border,
145
+ borderRadius: tokens.radius,
146
+ opacity: disabled ? 0.5 : 1,
147
+ ...(primary ? { backgroundColor: tokens.accent } : null),
148
+ },
149
+ },
150
+ React.createElement(
151
+ Text,
152
+ {
153
+ style: {
154
+ fontFamily: tokens.bodyFont,
155
+ fontSize: tokens.codeSize,
156
+ fontWeight: "500",
157
+ color: primary ? tokens.onAccent : tokens.color,
158
+ },
159
+ },
160
+ text,
161
+ ),
162
+ );
163
+
164
+ return React.createElement(
165
+ Overlay,
166
+ {
167
+ visible: !!visible,
168
+ onRequestClose: onClose,
169
+ size: "sm",
170
+ accessibilityLabel: "Insert link",
171
+ testID: testID ? `${testID}-dialog` : undefined,
172
+ },
173
+ React.createElement(
174
+ Field,
175
+ { label: "Link text", tokens },
176
+ React.createElement(TextInput, {
177
+ value: label,
178
+ onChangeText: setLabel,
179
+ placeholder: "Text the reader sees",
180
+ placeholderTextColor: tokens.mutedColor,
181
+ accessibilityLabel: "Link text",
182
+ style: inputStyle,
183
+ testID: testID ? `${testID}-label` : undefined,
184
+ }),
185
+ ),
186
+
187
+ // With no pages to offer there is nothing to choose between, so the
188
+ // switcher is hidden rather than shown with one dead option.
189
+ canPickPage
190
+ ? React.createElement(
191
+ View,
192
+ { style: { flexDirection: "row", marginBottom: tokens.blockGap } },
193
+ tab("page", "A page in this app"),
194
+ tab("url", "An external URL"),
195
+ )
196
+ : null,
197
+
198
+ mode === "page" && canPickPage
199
+ ? React.createElement(
200
+ Field,
201
+ { label: "Page", tokens },
202
+ React.createElement(
203
+ ScrollView,
204
+ {
205
+ style: {
206
+ maxHeight: PAGE_LIST_MAX_HEIGHT,
207
+ borderWidth: 1,
208
+ borderColor: tokens.border,
209
+ borderRadius: tokens.radius,
210
+ },
211
+ testID: testID ? `${testID}-pages` : undefined,
212
+ },
213
+ pageList.map((page) =>
214
+ React.createElement(
215
+ Pressable,
216
+ {
217
+ key: page.id,
218
+ onPress: () => setPageId(page.id),
219
+ accessibilityRole: "button",
220
+ accessibilityState: { selected: pageId === page.id },
221
+ style: {
222
+ padding: tokens.markerGap,
223
+ ...(pageId === page.id
224
+ ? { backgroundColor: tokens.surface }
225
+ : null),
226
+ },
227
+ },
228
+ React.createElement(
229
+ Text,
230
+ {
231
+ style: {
232
+ fontFamily: tokens.bodyFont,
233
+ fontSize: tokens.bodySize,
234
+ color: pageId === page.id ? tokens.accent : tokens.color,
235
+ fontWeight: pageId === page.id ? "700" : "400",
236
+ },
237
+ },
238
+ page.name || page.id,
239
+ ),
240
+ ),
241
+ ),
242
+ ),
243
+ )
244
+ : React.createElement(
245
+ Field,
246
+ { label: "Address", tokens },
247
+ React.createElement(TextInput, {
248
+ value: url,
249
+ onChangeText: setUrl,
250
+ placeholder: "https://example.com",
251
+ placeholderTextColor: tokens.mutedColor,
252
+ accessibilityLabel: "Link address",
253
+ autoCapitalize: "none",
254
+ autoCorrect: false,
255
+ keyboardType: "url",
256
+ style: inputStyle,
257
+ testID: testID ? `${testID}-url` : undefined,
258
+ }),
259
+ ),
260
+
261
+ React.createElement(
262
+ View,
263
+ {
264
+ style: {
265
+ flexDirection: "row",
266
+ justifyContent: "flex-end",
267
+ marginTop: tokens.markerGap,
268
+ },
269
+ },
270
+ action("cancel", "Cancel", { onPress: onClose }),
271
+ // Disabled until there is somewhere to go — an empty target would
272
+ // write `[text]()`, which reads as a link and goes nowhere.
273
+ action("insert", "Insert link", {
274
+ primary: true,
275
+ disabled: !target,
276
+ onPress: submit,
277
+ }),
278
+ ),
279
+ );
280
+ }
281
+
282
+ MarkdownLinkDialog.displayName = "MarkdownLinkDialog";
283
+ return MarkdownLinkDialog;
284
+ }