@colixsystems/widget-sdk 0.245.0 → 0.247.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 (56) hide show
  1. package/README.md +60 -2
  2. package/dist/button-tokens.js +154 -0
  3. package/dist/button-view.js +251 -93
  4. package/dist/button.js +5 -3
  5. package/dist/button.native.js +6 -4
  6. package/dist/chart-logic.js +575 -0
  7. package/dist/chart-tokens.js +131 -0
  8. package/dist/chart-view.js +262 -0
  9. package/dist/chart.js +21 -0
  10. package/dist/chart.native.js +10 -0
  11. package/dist/contract.cjs +132 -35
  12. package/dist/contract.js +132 -35
  13. package/dist/detail-parts-view.js +31 -2
  14. package/dist/file-image-logic.js +162 -0
  15. package/dist/file-image-source.js +71 -0
  16. package/dist/file-image-view.js +119 -0
  17. package/dist/file-image.js +8 -0
  18. package/dist/file-image.native.js +8 -0
  19. package/dist/form-control-logic.js +23 -0
  20. package/dist/form-control-tokens.js +25 -10
  21. package/dist/grid-logic.js +222 -0
  22. package/dist/grid-view.js +109 -0
  23. package/dist/grid.js +7 -0
  24. package/dist/grid.native.js +7 -0
  25. package/dist/host.d.ts +6 -0
  26. package/dist/host.js +2 -0
  27. package/dist/index.d.ts +481 -20
  28. package/dist/index.js +14 -1
  29. package/dist/index.native.js +14 -1
  30. package/dist/menu-view.js +168 -0
  31. package/dist/overlay-tokens.js +50 -0
  32. package/dist/overlay-view.js +67 -24
  33. package/dist/overlay.native.js +3 -1
  34. package/dist/people-picker-logic.js +203 -0
  35. package/dist/people-picker-view.js +247 -0
  36. package/dist/people-picker.js +11 -0
  37. package/dist/people-picker.native.js +9 -0
  38. package/dist/popover-tokens.js +145 -0
  39. package/dist/popover-view.js +252 -0
  40. package/dist/popover.js +23 -0
  41. package/dist/popover.native.js +22 -0
  42. package/dist/radio-group-logic.js +61 -0
  43. package/dist/radio-group-tokens.js +62 -0
  44. package/dist/radio-group-view.js +262 -0
  45. package/dist/radio-group.js +12 -0
  46. package/dist/radio-group.native.js +7 -0
  47. package/dist/select-search-logic.js +150 -0
  48. package/dist/select-view.js +255 -43
  49. package/dist/select.js +6 -2
  50. package/dist/select.native.js +5 -3
  51. package/dist/tabs-tokens.js +59 -0
  52. package/dist/tabs-view.js +9 -43
  53. package/dist/tooltip-view.js +137 -0
  54. package/dist/web-modal.js +95 -23
  55. package/package.json +1 -1
  56. package/src/contract.js +132 -35
@@ -0,0 +1,22 @@
1
+ // `<Popover>`, `<Menu>` and `<Tooltip>` (native). Bind the shared views to
2
+ // React Native's own primitives and the native `<Overlay>`; only the imports
3
+ // differ from ./popover.js, plus the safe-area inset a bottom sheet pads by.
4
+
5
+ import React from "react";
6
+ import { View, Text, Pressable, ScrollView, Platform, useWindowDimensions } from "react-native";
7
+ // Host-provided: the exported app pins it and mounts its provider at the root.
8
+ import { SafeAreaInsetsContext } from "react-native-safe-area-context";
9
+ import { makePopover } from "./popover-view.js";
10
+ import { makeMenu } from "./menu-view.js";
11
+ import { makeTooltip } from "./tooltip-view.js";
12
+ import { Overlay } from "./overlay.native.js";
13
+
14
+ // Null with no provider above, which the view reads as "no insets".
15
+ const useSafeAreaInsets = () => React.useContext(SafeAreaInsetsContext);
16
+
17
+ const primitives = { View, Text, Pressable, ScrollView, Platform, useWindowDimensions, useSafeAreaInsets };
18
+
19
+ const { Popover, AnchoredSurface } = makePopover(primitives, Overlay);
20
+ export { Popover };
21
+ export const Menu = makeMenu(primitives, AnchoredSurface);
22
+ export const Tooltip = makeTooltip(primitives, AnchoredSurface);
@@ -0,0 +1,61 @@
1
+ // The `<RadioGroup>` rules that need no renderer: which option is picked,
2
+ // which one holds the group's single tab stop, and where an arrow key moves
3
+ // the choice. Both hosts share them, and the node suite tests them.
4
+
5
+ export const RADIO_GROUP_VARIANTS = Object.freeze(["list", "card", "segmented"]);
6
+ export const DEFAULT_RADIO_GROUP_VARIANT = "list";
7
+
8
+ const ARROW_STEP = Object.freeze({ ArrowDown: 1, ArrowRight: 1, ArrowUp: -1, ArrowLeft: -1 });
9
+
10
+ /** A requested variant, or the list when it names none we draw. */
11
+ export function radioGroupVariant(variant) {
12
+ return RADIO_GROUP_VARIANTS.includes(variant) ? variant : DEFAULT_RADIO_GROUP_VARIANT;
13
+ }
14
+
15
+ function optionList(options) {
16
+ return Array.isArray(options) ? options.filter((option) => option && typeof option === "object") : [];
17
+ }
18
+
19
+ function pickable(option, groupDisabled) {
20
+ return !groupDisabled && !option.disabled;
21
+ }
22
+
23
+ /** The index of the option whose `value` is `value` (strict), or -1. */
24
+ export function checkedIndex(options, value) {
25
+ return optionList(options).findIndex((option) => option.value === value);
26
+ }
27
+
28
+ /**
29
+ * The option a Tab key lands on: the picked one when it can be picked, else
30
+ * the first that can, else -1 (nothing to focus).
31
+ *
32
+ * @param {Array<{value: any, disabled?: boolean}>} options
33
+ * @param {any} value
34
+ * @param {boolean} [groupDisabled]
35
+ */
36
+ export function tabStopIndex(options, value, groupDisabled = false) {
37
+ const list = optionList(options);
38
+ const picked = checkedIndex(list, value);
39
+ if (picked !== -1 && pickable(list[picked], groupDisabled)) return picked;
40
+ return list.findIndex((option) => pickable(option, groupDisabled));
41
+ }
42
+
43
+ /**
44
+ * The option an arrow key moves the choice to: the next pickable one in that
45
+ * direction, wrapping at either end. Returns -1 for any other key, or when no
46
+ * other option can be picked.
47
+ *
48
+ * @param {Array<{disabled?: boolean}>} options
49
+ * @param {number} from — the focused option's index.
50
+ * @param {string} key — a KeyboardEvent `key`.
51
+ */
52
+ export function arrowTargetIndex(options, from, key) {
53
+ const step = ARROW_STEP[key];
54
+ const list = optionList(options);
55
+ if (!step || list.length < 2) return -1;
56
+ for (let hop = 1; hop < list.length; hop += 1) {
57
+ const at = (((from + step * hop) % list.length) + list.length) % list.length;
58
+ if (pickable(list[at], false)) return at;
59
+ }
60
+ return -1;
61
+ }
@@ -0,0 +1,62 @@
1
+ // The values `<RadioGroup>` paints with. Its list and card variants are the
2
+ // form controls' own `muted` scope (`resolveFormControlTokens`); the segmented
3
+ // one is the `tabs` scope's segmented strip. Platform-free.
4
+
5
+ import { DEFAULT_THEME_TOKENS } from "./_theme-tokens.js";
6
+ import { CHECKBOX_SIZE, resolveFormControlTokens } from "./form-control-tokens.js";
7
+ import { resolveTabsTokens } from "./tabs-tokens.js";
8
+
9
+ export const RADIO_DOT_SIZE = 8;
10
+ // Narrow enough for two cards side by side in a phone-width panel.
11
+ export const RADIO_CARD_MIN_WIDTH = 140;
12
+
13
+ /**
14
+ * Resolve one radio group from the workspace theme.
15
+ *
16
+ * @param {object} theme — the resolved workspace theme (`useTheme()` shape).
17
+ */
18
+ export function resolveRadioGroupTokens(theme) {
19
+ const base = theme && typeof theme === "object" ? theme : DEFAULT_THEME_TOKENS;
20
+ const colors = base.colors || DEFAULT_THEME_TOKENS.colors;
21
+ const spacing = base.spacing || DEFAULT_THEME_TOKENS.spacing;
22
+ const typography = base.typography || DEFAULT_THEME_TOKENS.typography;
23
+ const field = resolveFormControlTokens(base);
24
+ return {
25
+ ...field,
26
+ // A picked row lifts out of the muted panel onto the surface.
27
+ pickedBackground: colors.surface,
28
+ descriptionColor: colors.onSurfaceMuted,
29
+ descriptionSize: typography.sizes.sm,
30
+ optionGap: spacing.sm,
31
+ cardPadding: spacing.md,
32
+ segmented: resolveTabsTokens(base, { style: "segmented" }),
33
+ };
34
+ }
35
+
36
+ /**
37
+ * The round mark of one option: the input surface, filled with the accent
38
+ * and a dot when picked, so it reads as the `<Checkbox>` box's sibling.
39
+ *
40
+ * @param {object} t — resolved radio group tokens.
41
+ * @param {{checked: boolean, invalid?: boolean}} state
42
+ */
43
+ export function radioMarkStyles(t, { checked, invalid = false }) {
44
+ return {
45
+ ring: {
46
+ width: CHECKBOX_SIZE,
47
+ height: CHECKBOX_SIZE,
48
+ borderRadius: CHECKBOX_SIZE / 2,
49
+ borderWidth: 1,
50
+ alignItems: "center",
51
+ justifyContent: "center",
52
+ borderColor: checked ? t.accent : invalid ? t.errorColor : t.borderColor,
53
+ backgroundColor: checked ? t.accent : t.background,
54
+ },
55
+ dot: {
56
+ width: RADIO_DOT_SIZE,
57
+ height: RADIO_DOT_SIZE,
58
+ borderRadius: RADIO_DOT_SIZE / 2,
59
+ backgroundColor: t.onAccent,
60
+ },
61
+ };
62
+ }
@@ -0,0 +1,262 @@
1
+ // The ONE implementation behind the `<RadioGroup>` SDK primitive. The platform
2
+ // bindings (`radio-group.js` / `radio-group.native.js`) differ only in where
3
+ // the primitives come from, so the hosts cannot drift.
4
+ //
5
+ // Widgets each drew their own pick-one list, marking the choice with a glyph,
6
+ // an icon or a tab role. This one has the radio role and the app's look.
7
+
8
+ import React from "react";
9
+ import { useHostTheme } from "./hooks.js";
10
+ import { DEFAULT_THEME_TOKENS } from "./_theme-tokens.js";
11
+ import { Icon } from "./icon.js";
12
+ import { CHECKBOX_SIZE, CONTROL_MIN_TARGET, focusHaloStyle } from "./form-control-tokens.js";
13
+ import {
14
+ errorMessage,
15
+ hasNote,
16
+ isShown,
17
+ isText,
18
+ needsFrame,
19
+ spaceActivates,
20
+ spokenLabel,
21
+ } from "./form-control-logic.js";
22
+ import { makeFieldFrame } from "./form-control-view.js";
23
+ import { RADIO_CARD_MIN_WIDTH, radioMarkStyles, resolveRadioGroupTokens } from "./radio-group-tokens.js";
24
+ import {
25
+ arrowTargetIndex,
26
+ radioGroupVariant,
27
+ tabStopIndex,
28
+ } from "./radio-group-logic.js";
29
+ import { TAB_ITEM_LAYOUT, segmentedStripStyles, tabLabelStyle } from "./tabs-tokens.js";
30
+
31
+ const h = React.createElement;
32
+
33
+ function groupStyle(kind, t, theme, invalid) {
34
+ if (kind === "segmented") {
35
+ const strip = segmentedStripStyles(t.segmented, theme);
36
+ return [
37
+ strip.bar,
38
+ strip.content,
39
+ // A form row's control spans the row; its segments share it evenly.
40
+ { flexDirection: "row", alignSelf: "stretch", borderWidth: 1, borderColor: "transparent" },
41
+ invalid ? { borderColor: t.errorColor } : null,
42
+ ];
43
+ }
44
+ if (kind === "card") return { flexDirection: "row", flexWrap: "wrap", gap: t.optionGap };
45
+ return {
46
+ borderWidth: 1,
47
+ borderColor: invalid ? t.errorColor : t.borderColor,
48
+ borderRadius: t.radius,
49
+ backgroundColor: t.background,
50
+ overflow: "hidden",
51
+ };
52
+ }
53
+
54
+ function optionStyle(kind, t, theme, { checked, invalid, focused, web }) {
55
+ if (kind === "segmented") {
56
+ return [
57
+ TAB_ITEM_LAYOUT,
58
+ segmentedStripStyles(t.segmented, theme).item(checked),
59
+ { flexGrow: 1, flexShrink: 1, flexBasis: 0 },
60
+ // The segment's own border is its lift, so focus draws outside it.
61
+ focused && web
62
+ ? { outlineWidth: 2, outlineStyle: "solid", outlineColor: t.focusColor, outlineOffset: 1 }
63
+ : null,
64
+ ];
65
+ }
66
+ const row = {
67
+ flexDirection: "row",
68
+ alignItems: kind === "card" ? "flex-start" : "center",
69
+ gap: t.optionGap,
70
+ minHeight: CONTROL_MIN_TARGET,
71
+ };
72
+ if (kind === "card") {
73
+ return [
74
+ row,
75
+ {
76
+ flexGrow: 1,
77
+ flexBasis: RADIO_CARD_MIN_WIDTH,
78
+ padding: t.cardPadding,
79
+ borderWidth: 1,
80
+ borderRadius: t.radius,
81
+ borderColor: checked ? t.accent : invalid ? t.errorColor : t.borderColor,
82
+ backgroundColor: checked ? t.pickedBackground : t.background,
83
+ },
84
+ ];
85
+ }
86
+ return [
87
+ row,
88
+ { paddingHorizontal: t.paddingHorizontal, paddingVertical: t.paddingVertical },
89
+ checked ? { backgroundColor: t.pickedBackground } : null,
90
+ ];
91
+ }
92
+
93
+ /**
94
+ * Bind the shared radio group to one platform's primitives.
95
+ *
96
+ * @param {{View: any, Text: any, Pressable: any, Platform: any}} rn
97
+ */
98
+ export function makeRadioGroup(rn) {
99
+ const { View, Text, Pressable, Platform } = rn;
100
+ const FieldFrame = makeFieldFrame(rn);
101
+ const web = Boolean(Platform && Platform.OS === "web");
102
+
103
+ function RadioMark({ t, checked, invalid, focused }) {
104
+ const mark = radioMarkStyles(t, { checked, invalid });
105
+ return h(
106
+ View,
107
+ { style: focusHaloStyle(t, focused, CHECKBOX_SIZE / 2) },
108
+ h(View, { style: mark.ring }, checked ? h(View, { style: mark.dot }) : null),
109
+ );
110
+ }
111
+
112
+ function OptionText({ t, option, kind }) {
113
+ const label = isText(option.label)
114
+ ? h(
115
+ Text,
116
+ {
117
+ style: {
118
+ color: t.textColor,
119
+ fontFamily: t.fontFamily,
120
+ fontSize: t.fontSize,
121
+ ...(kind === "card" ? { fontWeight: "600" } : null),
122
+ },
123
+ },
124
+ option.label,
125
+ )
126
+ : option.label;
127
+ const description = isText(option.description)
128
+ ? h(
129
+ Text,
130
+ { style: { color: t.descriptionColor, fontFamily: t.fontFamily, fontSize: t.descriptionSize } },
131
+ option.description,
132
+ )
133
+ : isShown(option.description)
134
+ ? option.description
135
+ : null;
136
+ return h(View, { style: { flexGrow: 1, flexShrink: 1, gap: 2 } }, label, description);
137
+ }
138
+
139
+ function optionContent({ t, theme, kind, option, checked, invalid, focused }) {
140
+ if (kind === "segmented") {
141
+ const s = t.segmented;
142
+ return [
143
+ option.icon
144
+ ? h(Icon, { key: "icon", name: option.icon, size: 16, color: checked ? s.activeTextColor : s.textColor })
145
+ : null,
146
+ h(Text, { key: "label", numberOfLines: 1, style: tabLabelStyle(s, theme, checked) }, option.label),
147
+ ];
148
+ }
149
+ return [
150
+ h(RadioMark, { key: "mark", t, checked, invalid, focused }),
151
+ option.icon
152
+ ? h(Icon, { key: "icon", name: option.icon, size: 18, color: checked ? t.accent : t.descriptionColor })
153
+ : null,
154
+ h(OptionText, { key: "text", t, option, kind }),
155
+ ];
156
+ }
157
+
158
+ const RadioGroup = React.forwardRef(function RadioGroup(props, ref) {
159
+ const {
160
+ options,
161
+ value,
162
+ onValueChange,
163
+ variant,
164
+ label,
165
+ helper,
166
+ error,
167
+ disabled = false,
168
+ accessibilityLabel,
169
+ style,
170
+ containerStyle,
171
+ ...rest
172
+ } = props;
173
+ const theme = useHostTheme() || DEFAULT_THEME_TOKENS;
174
+ const t = resolveRadioGroupTokens(theme);
175
+ const kind = radioGroupVariant(variant);
176
+ const list = Array.isArray(options) ? options.filter((o) => o && typeof o === "object") : [];
177
+ const nodes = React.useRef([]);
178
+ const [focusedIndex, setFocusedIndex] = React.useState(-1);
179
+ const noteId = React.useId();
180
+ const invalid = isShown(error);
181
+ const noted = hasNote({ helper, error });
182
+ const framed = needsFrame({ label, helper, error });
183
+ const tabStop = tabStopIndex(list, value, disabled);
184
+
185
+ const pick = (option) => {
186
+ if (disabled || option.disabled || option.value === value) return;
187
+ if (typeof onValueChange === "function") onValueChange(option.value, option);
188
+ };
189
+ const keyHandler = (option, index) => (event) => {
190
+ if (event.altKey || event.ctrlKey || event.metaKey) return;
191
+ const target = arrowTargetIndex(list, index, event.key);
192
+ if (target === -1) return spaceActivates(() => pick(option))(event);
193
+ event.preventDefault();
194
+ pick(list[target]);
195
+ const node = nodes.current[target];
196
+ if (node && typeof node.focus === "function") node.focus();
197
+ };
198
+
199
+ const renderOption = (option, index) => {
200
+ const checked = option.value === value;
201
+ const optionDisabled = disabled || Boolean(option.disabled);
202
+ const focused = focusedIndex === index;
203
+ return h(
204
+ Pressable,
205
+ {
206
+ key: String(option.value),
207
+ ref: (node) => {
208
+ nodes.current[index] = node;
209
+ },
210
+ onPress: () => pick(option),
211
+ ...(optionDisabled ? null : { onKeyDown: keyHandler(option, index) }),
212
+ onFocus: () => setFocusedIndex(index),
213
+ onBlur: () => setFocusedIndex((at) => (at === index ? -1 : at)),
214
+ disabled: optionDisabled,
215
+ // accessibilityRole + aria-*: the pair that announces on both hosts.
216
+ accessibilityRole: "radio",
217
+ "aria-checked": checked,
218
+ "aria-disabled": optionDisabled,
219
+ accessibilityLabel: option.accessibilityLabel,
220
+ testID: option.testID,
221
+ // One tab stop per group on the web; the arrows move between options.
222
+ ...(web ? { tabIndex: index === tabStop ? 0 : -1 } : null),
223
+ style: ({ pressed }) => [
224
+ optionStyle(kind, t, theme, { checked, invalid, focused, web }),
225
+ pressed && !optionDisabled ? { opacity: 0.7 } : null,
226
+ option.disabled && !disabled ? { opacity: t.disabledOpacity } : null,
227
+ ],
228
+ },
229
+ ...optionContent({ t, theme, kind, option, checked, invalid, focused }),
230
+ );
231
+ };
232
+
233
+ const group = h(
234
+ View,
235
+ {
236
+ ...rest,
237
+ ref,
238
+ accessibilityRole: "radiogroup",
239
+ accessibilityLabel: spokenLabel(accessibilityLabel, label),
240
+ "aria-disabled": disabled,
241
+ ...(invalid ? { "aria-invalid": true } : null),
242
+ ...(noted ? { "aria-describedby": noteId } : null),
243
+ style: [
244
+ groupStyle(kind, t, theme, invalid),
245
+ // The frame dims a framed group as a whole.
246
+ disabled && !framed ? { opacity: t.disabledOpacity } : null,
247
+ style,
248
+ ],
249
+ },
250
+ ...list.map(renderOption),
251
+ );
252
+ if (!framed) return group;
253
+ return h(
254
+ FieldFrame,
255
+ { t, label, helper, error: errorMessage(error), disabled, noteId, style: containerStyle },
256
+ group,
257
+ );
258
+ });
259
+
260
+ RadioGroup.displayName = "RadioGroup";
261
+ return RadioGroup;
262
+ }
@@ -0,0 +1,12 @@
1
+ // `<RadioGroup>` (web). Binds ./radio-group-view.js to react-native-web; see
2
+ // ./primitives.js for why not the bare specifier.
3
+
4
+ import * as ReactNative from "react-native-web";
5
+ import { makeRadioGroup } from "./radio-group-view.js";
6
+
7
+ export const RadioGroup = makeRadioGroup({
8
+ View: ReactNative.View,
9
+ Text: ReactNative.Text,
10
+ Pressable: ReactNative.Pressable,
11
+ Platform: ReactNative.Platform,
12
+ });
@@ -0,0 +1,7 @@
1
+ // `<RadioGroup>` (native). Binds ./radio-group-view.js to React Native's own
2
+ // primitives; only the import differs from ./radio-group.js.
3
+
4
+ import { View, Text, Pressable, Platform } from "react-native";
5
+ import { makeRadioGroup } from "./radio-group-view.js";
6
+
7
+ export const RadioGroup = makeRadioGroup({ View, Text, Pressable, Platform });
@@ -0,0 +1,150 @@
1
+ // The searchable `<Select>` rules that need no renderer: when a typed query
2
+ // is sent, which answer is still wanted, what the sheet shows meanwhile, and
3
+ // the labels it keeps for picked values the current page no longer holds.
4
+ // Both hosts share them, and the node suite tests them.
5
+
6
+ import { normaliseThemeComponents } from "./theme-components.js";
7
+
8
+ /** How long typing must pause before the query is sent, in ms. */
9
+ export const DEFAULT_SEARCH_DEBOUNCE_MS = 250;
10
+
11
+ /** A typed query as it is sent: trimmed, never null. */
12
+ export function normaliseQuery(text) {
13
+ return typeof text === "string" ? text.trim() : "";
14
+ }
15
+
16
+ /**
17
+ * A trailing debounce: `push` restarts the wait, and only the last text
18
+ * pushed is delivered, once typing pauses. `cancel` drops a pending one.
19
+ *
20
+ * @param {number|(() => number)} delay — ms, or a getter read at each push
21
+ * so a changed delay applies to the next pause; below 0 counts as 0.
22
+ * @param {(query: string) => void} deliver
23
+ * @param {{setTimeout: Function, clearTimeout: Function}} [timers]
24
+ */
25
+ export function createDebouncer(delay, deliver, timers = globalThis) {
26
+ const waitFor = () => {
27
+ const ms = typeof delay === "function" ? delay() : delay;
28
+ return Number.isFinite(ms) && ms > 0 ? ms : 0;
29
+ };
30
+ let pending = null;
31
+ const cancel = () => {
32
+ if (pending !== null) timers.clearTimeout(pending);
33
+ pending = null;
34
+ };
35
+ return {
36
+ push(text) {
37
+ cancel();
38
+ pending = timers.setTimeout(() => {
39
+ pending = null;
40
+ deliver(normaliseQuery(text));
41
+ }, waitFor());
42
+ },
43
+ cancel,
44
+ };
45
+ }
46
+
47
+ /**
48
+ * Numbers each request so only the latest one's answer is used; an answer to
49
+ * a query the person has already typed past is dropped.
50
+ */
51
+ export function createRequestGate() {
52
+ let latest = 0;
53
+ return {
54
+ begin: () => ++latest,
55
+ isCurrent: (ticket) => ticket === latest,
56
+ };
57
+ }
58
+
59
+ /**
60
+ * The options whose label holds the query, case-insensitively; all of them
61
+ * for an empty query. For a list that is searched where it is held.
62
+ *
63
+ * @param {Array<{label: unknown}>} options
64
+ * @param {string} query
65
+ */
66
+ export function filterOptions(options, query) {
67
+ const list = Array.isArray(options) ? options : [];
68
+ const needle = normaliseQuery(query).toLocaleLowerCase();
69
+ if (!needle) return list;
70
+ return list.filter((option) =>
71
+ String(option && option.label != null ? option.label : "")
72
+ .toLocaleLowerCase()
73
+ .includes(needle),
74
+ );
75
+ }
76
+
77
+ /**
78
+ * What the sheet's body shows: a spinner while the answer is pending, the
79
+ * error, the "nothing matches" note for a query with no result, the empty
80
+ * note for no options at all, else the list.
81
+ *
82
+ * @param {{loading?: boolean, error?: boolean, query?: string, count: number}} state
83
+ * @returns {"loading"|"error"|"noResults"|"empty"|"list"}
84
+ */
85
+ export function sheetBody({ loading, error, query, count }) {
86
+ if (loading) return "loading";
87
+ if (error) return "error";
88
+ if (count > 0) return "list";
89
+ return normaliseQuery(query) ? "noResults" : "empty";
90
+ }
91
+
92
+ // 1 and "1" are two values; the key keeps them apart.
93
+ function valueKey(value) {
94
+ return `${typeof value}:${value}`;
95
+ }
96
+
97
+ /**
98
+ * A copy of `known` (value key → option) holding every option in `lists`,
99
+ * later lists winning. A searchable select remembers each option it has
100
+ * shown, so a picked one keeps its label after a search drops it.
101
+ *
102
+ * @param {Map<string, object>} known
103
+ * @param {...Array<{value: string|number}>} lists
104
+ */
105
+ export function rememberOptions(known, ...lists) {
106
+ const next = new Map(known instanceof Map ? known : []);
107
+ for (const list of lists) {
108
+ for (const option of Array.isArray(list) ? list : []) {
109
+ if (option && (typeof option.value === "string" || typeof option.value === "number")) {
110
+ next.set(valueKey(option.value), option);
111
+ }
112
+ }
113
+ }
114
+ return next;
115
+ }
116
+
117
+ /** The remembered option for one value, or undefined. */
118
+ export function knownOption(known, value) {
119
+ return known instanceof Map ? known.get(valueKey(value)) : undefined;
120
+ }
121
+
122
+ /**
123
+ * The remembered options for `values`, in their order; a value nothing has
124
+ * named is left out rather than shown by its raw value.
125
+ *
126
+ * @param {Map<string, object>} known
127
+ * @param {Array<string|number>} values
128
+ */
129
+ export function knownOptions(known, values) {
130
+ return (Array.isArray(values) ? values : [])
131
+ .map((value) => knownOption(known, value))
132
+ .filter(Boolean);
133
+ }
134
+
135
+ /**
136
+ * The row size a searchable sheet takes from the theme's `list` scope: only
137
+ * the values the theme sets, so an unthemed list keeps the option row's own.
138
+ *
139
+ * @param {object} theme — the resolved workspace theme.
140
+ * @returns {{minHeight?: number, paddingHorizontal?: number}}
141
+ */
142
+ export function searchRowOverrides(theme) {
143
+ const components = theme && typeof theme === "object" ? theme.components : undefined;
144
+ const list = normaliseThemeComponents({ list: components && components.list }).list || {};
145
+ const out = {};
146
+ // A row stays a 44px target however small the theme draws list rows.
147
+ if (Number.isFinite(list.rowMinHeight)) out.minHeight = Math.max(44, list.rowMinHeight);
148
+ if (Number.isFinite(list.rowPadding)) out.paddingHorizontal = list.rowPadding;
149
+ return out;
150
+ }