@colixsystems/widget-sdk 0.236.0 → 0.238.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,83 @@
1
+ // The `<Select>` rules that need no renderer: where focus lands when the
2
+ // sheet opens, the draft a `multiple` select edits until Done, and what its
3
+ // trigger shows. Both hosts share them, and the node suite tests them.
4
+
5
+ /** The default trigger text once the picked labels no longer fit. */
6
+ export function defaultSelectedCountText(count) {
7
+ return `${count} selected`;
8
+ }
9
+
10
+ /** A `values` prop as a list; anything else is no choice. */
11
+ export function valueList(values) {
12
+ return Array.isArray(values) ? values : [];
13
+ }
14
+
15
+ /**
16
+ * The option focused when the sheet opens: the first picked one that can be
17
+ * picked, else the first enabled one, else -1.
18
+ *
19
+ * @param {Array<{disabled?: boolean}>} options
20
+ * @param {(option: object) => boolean} isPicked
21
+ */
22
+ export function initialFocusIndex(options, isPicked) {
23
+ const list = Array.isArray(options) ? options : [];
24
+ const picked = list.findIndex((option) => isPicked(option) && !option.disabled);
25
+ return picked !== -1 ? picked : list.findIndex((option) => !option.disabled);
26
+ }
27
+
28
+ /**
29
+ * The draft with `value` added, or removed when it is already in it. Values
30
+ * compare strictly, so 1 and "1" are two choices.
31
+ *
32
+ * @param {Array<string|number>} draft
33
+ * @param {string|number} value
34
+ */
35
+ export function toggleValue(draft, value) {
36
+ const list = valueList(draft);
37
+ return list.includes(value) ? list.filter((own) => own !== value) : [...list, value];
38
+ }
39
+
40
+ /**
41
+ * The values Done reports: the picked options in the order they are listed,
42
+ * then any picked value no option carries any more, so confirming without a
43
+ * change never drops a stored value.
44
+ *
45
+ * @param {Array<{value: string|number}>} options
46
+ * @param {Array<string|number>} draft
47
+ */
48
+ export function committedValues(options, draft) {
49
+ const list = valueList(draft);
50
+ const known = (Array.isArray(options) ? options : []).map((option) => option.value);
51
+ return [
52
+ ...known.filter((value) => list.includes(value)),
53
+ ...list.filter((value) => !known.includes(value)),
54
+ ];
55
+ }
56
+
57
+ /** Whether two value lists hold the same values, whatever their order. */
58
+ export function sameValues(a, b) {
59
+ const left = valueList(a);
60
+ const right = valueList(b);
61
+ return left.length === right.length && left.every((value) => right.includes(value));
62
+ }
63
+
64
+ /** The options whose value is picked, in the order they are listed. */
65
+ export function pickedOptions(options, values) {
66
+ const list = valueList(values);
67
+ return (Array.isArray(options) ? options : []).filter((option) => list.includes(option.value));
68
+ }
69
+
70
+ /**
71
+ * What a `multiple` trigger shows: the placeholder with nothing picked, the
72
+ * picked labels as chips once they are measured to fit on its one line,
73
+ * otherwise the count. Until both widths are known it shows the count, so
74
+ * the trigger never draws chips that overflow it.
75
+ *
76
+ * @param {{picked: number, available?: number|null, needed?: number|null}} sizes
77
+ * @returns {"placeholder"|"chips"|"count"}
78
+ */
79
+ export function triggerSummary({ picked, available, needed }) {
80
+ if (!picked) return "placeholder";
81
+ const measured = Number.isFinite(available) && Number.isFinite(needed) && available > 0;
82
+ return measured && needed <= available ? "chips" : "count";
83
+ }
@@ -4,7 +4,8 @@
4
4
  //
5
5
  // The trigger wears the field `<TextField>` does. The options open in
6
6
  // `<Overlay>`, at screen level, so no card or scroll container the widget
7
- // sits in can clip them; a long list scrolls inside the sheet.
7
+ // sits in can clip them; a long list scrolls inside the sheet. With
8
+ // `multiple` the same sheet ticks options into a draft that Done commits.
8
9
 
9
10
  import React from "react";
10
11
  import { useHostTheme } from "./hooks.js";
@@ -29,17 +30,34 @@ import {
29
30
  spokenLabel,
30
31
  } from "./form-control-logic.js";
31
32
  import { makeFieldFrame } from "./form-control-view.js";
33
+ import { checkboxBox } from "./checkbox-view.js";
34
+ import {
35
+ committedValues,
36
+ defaultSelectedCountText,
37
+ initialFocusIndex,
38
+ pickedOptions,
39
+ sameValues,
40
+ toggleValue,
41
+ triggerSummary,
42
+ valueList,
43
+ } from "./select-logic.js";
32
44
 
33
45
  const h = React.createElement;
34
46
  const NAVIGATION_KEYS = ["ArrowDown", "ArrowUp", "Home", "End"];
47
+ // Wide enough that no picked chip shrinks while it is measured.
48
+ const MEASURE_SPAN = 10000;
49
+ // A compact chip that fits the field's own line, so the trigger keeps its height.
50
+ const TRIGGER_CHIP_STYLE = Object.freeze({ minHeight: 0, paddingVertical: 2 });
51
+ const UNMEASURED = Object.freeze({ available: null, needed: null });
35
52
 
36
53
  /**
37
54
  * Bind the shared select to one platform's primitives.
38
55
  *
39
- * @param {{View: any, Text: any, Pressable: any, ScrollView: any, Platform: any, Overlay: any}} rn
56
+ * @param {{View: any, Text: any, Pressable: any, ScrollView: any, Platform: any,
57
+ * Overlay: any, Button: any, Chip: any}} rn
40
58
  */
41
59
  export function makeSelect(rn) {
42
- const { View, Text, Pressable, ScrollView, Platform, Overlay } = rn;
60
+ const { View, Text, Pressable, ScrollView, Platform, Overlay, Button, Chip } = rn;
43
61
  const FieldFrame = makeFieldFrame(rn);
44
62
 
45
63
  // Keyboard focus is a web concern: a device moves its reader's focus into
@@ -48,11 +66,11 @@ export function makeSelect(rn) {
48
66
  if (Platform.OS === "web" && node && typeof node.focus === "function") node.focus();
49
67
  }
50
68
 
51
- function OptionSheet({ t, options, value, title, emptyText, listId, onPick, testID }) {
69
+ function OptionSheet(props) {
70
+ const { t, options, isPicked, multiple, title, emptyText, listId, onPick, testID, footer } =
71
+ props;
52
72
  const nodes = React.useRef([]);
53
- const chosen = options.findIndex((option) => option.value === value);
54
- const firstEnabled = options.findIndex((option) => !option.disabled);
55
- const initial = chosen !== -1 && !options[chosen].disabled ? chosen : firstEnabled;
73
+ const initial = initialFocusIndex(options, isPicked);
56
74
  // Focus lands on the current choice once, when the sheet mounts it; the
57
75
  // browser scrolls a focused option into view in a long list.
58
76
  const pendingFocus = React.useRef(true);
@@ -81,12 +99,13 @@ export function makeSelect(rn) {
81
99
  nativeID: listId,
82
100
  // Web only: native has no listbox, and ignores an unknown `role`.
83
101
  role: "listbox",
102
+ ...(multiple ? { "aria-multiselectable": true } : null),
84
103
  accessibilityLabel: isText(title) ? String(title) : undefined,
85
104
  keyboardShouldPersistTaps: "handled",
86
105
  testID: testID ? `${testID}-options` : undefined,
87
106
  },
88
107
  ...options.map((option, index) => {
89
- const selected = option.value === value;
108
+ const selected = isPicked(option);
90
109
  // The web host still delivers keys to a disabled Pressable.
91
110
  const pick = () => {
92
111
  if (!option.disabled) onPick(option);
@@ -121,7 +140,7 @@ export function makeSelect(rn) {
121
140
  {
122
141
  flexDirection: "row",
123
142
  alignItems: "center",
124
- justifyContent: "space-between",
143
+ justifyContent: multiple ? "flex-start" : "space-between",
125
144
  gap: t.rowGap,
126
145
  minHeight: CONTROL_MIN_TARGET,
127
146
  paddingHorizontal: t.paddingHorizontal,
@@ -132,15 +151,93 @@ export function makeSelect(rn) {
132
151
  option.disabled ? { opacity: t.disabledOpacity } : null,
133
152
  ],
134
153
  },
154
+ multiple ? checkboxBox(View, t, { checked: selected }) : null,
135
155
  h(
136
156
  Text,
137
157
  { style: [text, { flexShrink: 1 }, selected ? { fontWeight: "600" } : null] },
138
158
  option.label,
139
159
  ),
140
- selected ? h(Icon, { name: "check", size: 18, color: t.accent }) : null,
160
+ selected && !multiple ? h(Icon, { name: "check", size: 18, color: t.accent }) : null,
141
161
  );
142
162
  }),
143
163
  ),
164
+ footer || null,
165
+ );
166
+ }
167
+
168
+ function DoneFooter({ doneText, onDone, testID }) {
169
+ return h(
170
+ View,
171
+ { style: { flexDirection: "row", justifyContent: "flex-end" } },
172
+ h(Button, { title: doneText, onPress: onDone, testID: testID ? `${testID}-done` : undefined }),
173
+ );
174
+ }
175
+
176
+ // Chips when they fit the field's one line, else the count; measured on
177
+ // both hosts, so a narrow trigger never grows to hold them.
178
+ function MultiSummary({ t, picked, placeholder, selectedCountText, testID }) {
179
+ const [widths, setWidths] = React.useState(UNMEASURED);
180
+ const countText =
181
+ typeof selectedCountText === "function" ? selectedCountText : defaultSelectedCountText;
182
+ const measure = (key) => (event) => {
183
+ const width = event.nativeEvent.layout.width;
184
+ setWidths((own) => (own[key] === width ? own : { ...own, [key]: width }));
185
+ };
186
+ const mode = triggerSummary({ picked: picked.length, ...widths });
187
+ const text = { fontFamily: t.fontFamily, fontSize: t.fontSize };
188
+ const chips = () =>
189
+ picked.map((option) =>
190
+ h(Chip, {
191
+ key: `${typeof option.value}:${option.value}`,
192
+ label: option.label,
193
+ size: "sm",
194
+ style: TRIGGER_CHIP_STYLE,
195
+ }),
196
+ );
197
+ return h(
198
+ View,
199
+ {
200
+ onLayout: measure("available"),
201
+ style: { flexGrow: 1, flexShrink: 1, minWidth: 0, overflow: "hidden" },
202
+ testID: testID ? `${testID}-summary` : undefined,
203
+ },
204
+ mode === "chips"
205
+ ? h(View, { style: { flexDirection: "row", gap: t.gap } }, ...chips())
206
+ : h(
207
+ Text,
208
+ {
209
+ numberOfLines: 1,
210
+ style: { ...text, color: mode === "count" ? t.textColor : t.placeholderColor },
211
+ },
212
+ mode === "count" ? countText(picked.length) : placeholder,
213
+ ),
214
+ picked.length > 0
215
+ ? h(
216
+ View,
217
+ {
218
+ "aria-hidden": true,
219
+ importantForAccessibility: "no-hide-descendants",
220
+ accessibilityElementsHidden: true,
221
+ style: {
222
+ position: "absolute",
223
+ top: 0,
224
+ left: 0,
225
+ width: MEASURE_SPAN,
226
+ opacity: 0,
227
+ pointerEvents: "none",
228
+ },
229
+ },
230
+ h(
231
+ View,
232
+ {
233
+ onLayout: measure("needed"),
234
+ style: { flexDirection: "row", alignSelf: "flex-start", gap: t.gap },
235
+ testID: testID ? `${testID}-measure` : undefined,
236
+ },
237
+ ...chips(),
238
+ ),
239
+ )
240
+ : null,
144
241
  );
145
242
  }
146
243
 
@@ -148,6 +245,11 @@ export function makeSelect(rn) {
148
245
  const {
149
246
  value,
150
247
  onValueChange,
248
+ multiple = false,
249
+ values,
250
+ onValuesChange,
251
+ selectedCountText = defaultSelectedCountText,
252
+ doneText = "Done",
151
253
  options,
152
254
  placeholder = "Select",
153
255
  title,
@@ -174,6 +276,8 @@ export function makeSelect(rn) {
174
276
  }
175
277
  const t = resolveFormControlTokens(theme, overrides);
176
278
  const [open, setOpen] = React.useState(false);
279
+ // Null until the person ticks something: an untouched sheet follows `values`.
280
+ const [ownDraft, setDraft] = React.useState(null);
177
281
  const [focused, setFocused] = React.useState(false);
178
282
  const triggerRef = React.useRef(null);
179
283
  const setRef = React.useMemo(() => shareRef(ref, triggerRef), [ref]);
@@ -181,14 +285,41 @@ export function makeSelect(rn) {
181
285
  const noteId = React.useId();
182
286
  const list = Array.isArray(options) ? options.filter(Boolean) : [];
183
287
  const current = selectedOption(list, value);
288
+ const picked = multiple ? pickedOptions(list, values) : [];
289
+ const draft = ownDraft === null ? valueList(values) : ownDraft;
184
290
  const invalid = isShown(error);
185
291
  const sheetTitle = isShown(title) ? title : isText(label) ? label : undefined;
186
292
  const name = spokenLabel(accessibilityLabel, label);
187
293
  const shown = current ? current.label : placeholder;
294
+ const spokenChoice = multiple
295
+ ? picked.length > 0
296
+ ? picked.map((option) => option.label).filter(isText).join(", ")
297
+ : placeholder
298
+ : shown;
188
299
  const show = () => {
189
- if (!disabled) setOpen(true);
300
+ if (disabled) return;
301
+ if (multiple) setDraft(null);
302
+ setOpen(true);
190
303
  };
304
+ // Closing any other way leaves the draft behind: only Done commits it.
191
305
  const close = () => setOpen(false);
306
+ const done = () => {
307
+ const next = committedValues(list, draft);
308
+ close();
309
+ if (!sameValues(next, valueList(values)) && typeof onValuesChange === "function") {
310
+ onValuesChange(next, pickedOptions(list, next));
311
+ }
312
+ };
313
+ const pick = (option) => {
314
+ if (multiple) {
315
+ setDraft((own) => toggleValue(own === null ? valueList(values) : own, option.value));
316
+ return;
317
+ }
318
+ close();
319
+ if (option.value !== value && typeof onValueChange === "function") {
320
+ onValueChange(option.value, option);
321
+ }
322
+ };
192
323
  // The option that held focus is gone once the sheet closes. After the
193
324
  // sheet unmounts, or its focus trap would pull focus straight back in.
194
325
  const wasOpen = React.useRef(false);
@@ -239,7 +370,8 @@ export function makeSelect(rn) {
239
370
  ...(invalid ? { "aria-invalid": true } : null),
240
371
  ...(hasNote({ helper, error }) ? { "aria-describedby": noteId } : null),
241
372
  // The choice is part of the name: a button's own label replaces its text.
242
- accessibilityLabel: [name, isText(shown) ? shown : null].filter(Boolean).join(", ") || undefined,
373
+ accessibilityLabel:
374
+ [name, isText(spokenChoice) ? spokenChoice : null].filter(Boolean).join(", ") || undefined,
243
375
  testID,
244
376
  style: [
245
377
  {
@@ -260,19 +392,21 @@ export function makeSelect(rn) {
260
392
  style,
261
393
  ],
262
394
  },
263
- h(
264
- Text,
265
- {
266
- numberOfLines: 1,
267
- style: {
268
- flexShrink: 1,
269
- color: current ? t.textColor : t.placeholderColor,
270
- fontFamily: t.fontFamily,
271
- fontSize: t.fontSize,
272
- },
273
- },
274
- shown,
275
- ),
395
+ multiple
396
+ ? h(MultiSummary, { t, picked, placeholder, selectedCountText, testID })
397
+ : h(
398
+ Text,
399
+ {
400
+ numberOfLines: 1,
401
+ style: {
402
+ flexShrink: 1,
403
+ color: current ? t.textColor : t.placeholderColor,
404
+ fontFamily: t.fontFamily,
405
+ fontSize: t.fontSize,
406
+ },
407
+ },
408
+ shown,
409
+ ),
276
410
  h(Icon, { name: "chevron-down", size: 18, color: t.placeholderColor }),
277
411
  ),
278
412
  h(
@@ -293,17 +427,16 @@ export function makeSelect(rn) {
293
427
  h(OptionSheet, {
294
428
  t,
295
429
  options: list,
296
- value,
430
+ multiple,
431
+ isPicked: multiple
432
+ ? (option) => draft.includes(option.value)
433
+ : (option) => option.value === value,
297
434
  title: sheetTitle,
298
435
  emptyText,
299
436
  listId,
300
437
  testID,
301
- onPick: (option) => {
302
- close();
303
- if (option.value !== value && typeof onValueChange === "function") {
304
- onValueChange(option.value, option);
305
- }
306
- },
438
+ onPick: pick,
439
+ footer: multiple ? h(DoneFooter, { doneText, onDone: done, testID }) : null,
307
440
  }),
308
441
  ),
309
442
  );
package/dist/select.js CHANGED
@@ -1,9 +1,12 @@
1
1
  // `<Select>` (web). Binds ./select-view.js to react-native-web and the web
2
- // `<Overlay>`; see ./primitives.js for why not the bare specifier.
2
+ // `<Overlay>`, `<Button>` and `<Chip>`; see ./primitives.js for why not the
3
+ // bare specifier.
3
4
 
4
5
  import * as ReactNative from "react-native-web";
5
6
  import { makeSelect } from "./select-view.js";
6
7
  import { Overlay } from "./overlay.js";
8
+ import { Button } from "./button.js";
9
+ import { Chip } from "./chip.js";
7
10
 
8
11
  export const Select = makeSelect({
9
12
  View: ReactNative.View,
@@ -12,4 +15,6 @@ export const Select = makeSelect({
12
15
  ScrollView: ReactNative.ScrollView,
13
16
  Platform: ReactNative.Platform,
14
17
  Overlay,
18
+ Button,
19
+ Chip,
15
20
  });
@@ -1,9 +1,11 @@
1
1
  // `<Select>` (native). Binds ./select-view.js to React Native's own
2
- // primitives and the native `<Overlay>`; only the imports differ from
3
- // ./select.js.
2
+ // primitives and the native `<Overlay>`, `<Button>` and `<Chip>`; only the
3
+ // imports differ from ./select.js.
4
4
 
5
5
  import { View, Text, Pressable, ScrollView, Platform } from "react-native";
6
6
  import { makeSelect } from "./select-view.js";
7
7
  import { Overlay } from "./overlay.native.js";
8
+ import { Button } from "./button.native.js";
9
+ import { Chip } from "./chip.native.js";
8
10
 
9
- export const Select = makeSelect({ View, Text, Pressable, ScrollView, Platform, Overlay });
11
+ export const Select = makeSelect({ View, Text, Pressable, ScrollView, Platform, Overlay, Button, Chip });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colixsystems/widget-sdk",
3
- "version": "0.236.0",
3
+ "version": "0.238.0",
4
4
  "description": "Common widget interface for AppStudio. Implements WidgetManifest, WidgetContext, property schema, and helper hooks.",
5
5
  "homepage": "https://github.com/Colix-AB/AppStudio",
6
6
  "type": "module",