@colixsystems/widget-sdk 0.246.0 → 0.248.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 (54) hide show
  1. package/README.md +76 -6
  2. package/dist/board-auto-scroll.js +59 -0
  3. package/dist/board-view.js +327 -0
  4. package/dist/chart-logic.js +575 -0
  5. package/dist/chart-tokens.js +131 -0
  6. package/dist/chart-view.js +262 -0
  7. package/dist/chart.js +21 -0
  8. package/dist/chart.native.js +10 -0
  9. package/dist/contract.cjs +119 -7
  10. package/dist/contract.js +119 -7
  11. package/dist/data-table-logic.js +179 -0
  12. package/dist/data-table-view.js +388 -0
  13. package/dist/data-table.js +22 -0
  14. package/dist/data-table.native.js +14 -0
  15. package/dist/drag-kit-view.js +310 -0
  16. package/dist/file-upload-view.js +13 -2
  17. package/dist/filter-bar-logic.js +143 -0
  18. package/dist/filter-bar-view.js +381 -0
  19. package/dist/filter-bar.js +15 -0
  20. package/dist/filter-bar.native.js +13 -0
  21. package/dist/form-control-logic.js +20 -3
  22. package/dist/form-control-view.js +18 -0
  23. package/dist/form-layout-logic.js +92 -0
  24. package/dist/form-view.js +268 -0
  25. package/dist/form.js +15 -0
  26. package/dist/form.native.js +9 -0
  27. package/dist/grid-logic.js +222 -0
  28. package/dist/grid-view.js +109 -0
  29. package/dist/grid.js +7 -0
  30. package/dist/grid.native.js +7 -0
  31. package/dist/host.d.ts +6 -0
  32. package/dist/host.js +2 -0
  33. package/dist/index.d.ts +606 -7
  34. package/dist/index.js +15 -0
  35. package/dist/index.native.js +15 -0
  36. package/dist/menu-view.js +5 -2
  37. package/dist/people-picker-logic.js +203 -0
  38. package/dist/people-picker-view.js +250 -0
  39. package/dist/people-picker.js +11 -0
  40. package/dist/people-picker.native.js +9 -0
  41. package/dist/radio-group-view.js +7 -3
  42. package/dist/select-search-logic.js +150 -0
  43. package/dist/select-view.js +251 -18
  44. package/dist/select.js +6 -2
  45. package/dist/select.native.js +5 -3
  46. package/dist/sortable-list-view.js +228 -0
  47. package/dist/sortable-logic.js +273 -0
  48. package/dist/sortable-tokens.js +120 -0
  49. package/dist/sortable-view.js +24 -0
  50. package/dist/sortable.js +39 -0
  51. package/dist/sortable.native.js +30 -0
  52. package/dist/text-field-view.js +7 -2
  53. package/package.json +1 -1
  54. package/src/contract.js +119 -7
@@ -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
+ }
@@ -6,9 +6,11 @@
6
6
  // `<Overlay>`, at screen level, so no card or scroll container the widget
7
7
  // sits in can clip them; a long list scrolls inside the sheet. With
8
8
  // `multiple` the same sheet ticks options into a draft that Done commits.
9
+ // Searchable, the sheet leads with a `<SearchField>`: the list filters where
10
+ // it is held, or each pause in typing asks `onSearch` / async `options`.
9
11
 
10
12
  import React from "react";
11
- import { useHostTheme } from "./hooks.js";
13
+ import { useHostSharedText, useHostTheme } from "./hooks.js";
12
14
  import { DEFAULT_THEME_TOKENS } from "./_theme-tokens.js";
13
15
  import { Icon } from "./icon.js";
14
16
  import { TEXT_FIELD_TOKEN_NAMES } from "./text-field-tokens.js";
@@ -20,6 +22,7 @@ import {
20
22
  selectedOption,
21
23
  } from "./form-control-tokens.js";
22
24
  import {
25
+ DRAWS_FIELD_FRAME,
23
26
  errorMessage,
24
27
  hasNote,
25
28
  isShown,
@@ -41,6 +44,18 @@ import {
41
44
  triggerSummary,
42
45
  valueList,
43
46
  } from "./select-logic.js";
47
+ import {
48
+ DEFAULT_SEARCH_DEBOUNCE_MS,
49
+ createDebouncer,
50
+ createRequestGate,
51
+ filterOptions,
52
+ knownOption,
53
+ knownOptions,
54
+ normaliseQuery,
55
+ rememberOptions,
56
+ searchRowOverrides,
57
+ sheetBody,
58
+ } from "./select-search-logic.js";
44
59
 
45
60
  const h = React.createElement;
46
61
  // Wide enough that no picked chip shrinks while it is measured.
@@ -53,10 +68,12 @@ const UNMEASURED = Object.freeze({ available: null, needed: null });
53
68
  * Bind the shared select to one platform's primitives.
54
69
  *
55
70
  * @param {{View: any, Text: any, Pressable: any, ScrollView: any, Platform: any,
56
- * Overlay: any, Button: any, Chip: any}} rn
71
+ * Overlay: any, Button: any, Chip: any, SearchField?: any, Spinner?: any}} rn
72
+ * — `SearchField` and `Spinner` are needed only by a searchable select.
57
73
  */
58
74
  export function makeSelect(rn) {
59
- const { View, Text, Pressable, ScrollView, Platform, Overlay, Button, Chip } = rn;
75
+ const { View, Text, Pressable, ScrollView, Platform, Overlay, Button, Chip, SearchField, Spinner } =
76
+ rn;
60
77
  const FieldFrame = makeFieldFrame(rn);
61
78
 
62
79
  // Keyboard focus is a web concern: a device moves its reader's focus into
@@ -66,14 +83,31 @@ export function makeSelect(rn) {
66
83
  }
67
84
 
68
85
  function OptionSheet(props) {
69
- const { t, options, isPicked, multiple, title, emptyText, listId, onPick, testID, footer } =
70
- props;
86
+ const {
87
+ t,
88
+ options,
89
+ isPicked,
90
+ multiple,
91
+ title,
92
+ emptyText,
93
+ listId,
94
+ onPick,
95
+ testID,
96
+ footer,
97
+ search,
98
+ body = options.length === 0 ? "empty" : "list",
99
+ bodyText,
100
+ rowStyle,
101
+ } = props;
71
102
  const nodes = React.useRef([]);
72
103
  const initial = initialFocusIndex(options, isPicked);
73
104
  // Focus lands on the current choice once, when the sheet mounts it; the
74
- // browser scrolls a focused option into view in a long list.
75
- const pendingFocus = React.useRef(true);
105
+ // browser scrolls a focused option into view in a long list. A search
106
+ // field takes that first focus instead.
107
+ const pendingFocus = React.useRef(!search);
76
108
  const text = { fontFamily: t.fontFamily, fontSize: t.fontSize, color: t.popover.textColor };
109
+ const note = { ...text, color: t.helperColor, paddingHorizontal: t.paddingHorizontal };
110
+ const focusFirst = () => focusNode(nodes.current[initialFocusIndex(options, () => false)]);
77
111
 
78
112
  return h(
79
113
  View,
@@ -85,11 +119,23 @@ export function makeSelect(rn) {
85
119
  title,
86
120
  )
87
121
  : null,
88
- options.length === 0
122
+ search ? search(focusFirst) : null,
123
+ body === "loading"
124
+ ? h(
125
+ View,
126
+ { style: { paddingVertical: t.rowGap }, testID: testID ? `${testID}-loading` : undefined },
127
+ h(Spinner, { size: "sm", label: bodyText }),
128
+ )
129
+ : body !== "list"
89
130
  ? h(
90
131
  Text,
91
- { style: { ...text, color: t.helperColor, paddingHorizontal: t.paddingHorizontal } },
92
- emptyText,
132
+ {
133
+ style: body === "error" ? { ...note, color: t.errorColor } : note,
134
+ // Results arrive after typing; a status line says what came back.
135
+ ...(search ? { role: "status", accessibilityLiveRegion: "polite" } : null),
136
+ testID: search && testID ? `${testID}-${body}` : undefined,
137
+ },
138
+ body === "empty" ? emptyText : bodyText,
93
139
  )
94
140
  : h(
95
141
  ScrollView,
@@ -133,17 +179,28 @@ export function makeSelect(rn) {
133
179
  "aria-selected": selected,
134
180
  "aria-disabled": !!option.disabled,
135
181
  testID: option.testID,
136
- style: ({ pressed }) =>
137
- optionRowStyle(t, {
182
+ style: ({ pressed }) => [
183
+ ...optionRowStyle(t, {
138
184
  pressed,
139
185
  disabled: option.disabled,
140
186
  justifyContent: multiple ? "flex-start" : "space-between",
141
187
  }),
188
+ rowStyle,
189
+ ],
142
190
  },
143
191
  multiple ? checkboxBox(View, t, { checked: selected }) : null,
192
+ option.leading ? h(View, { style: { flexShrink: 0 } }, option.leading) : null,
144
193
  h(
145
194
  Text,
146
- { style: [text, { flexShrink: 1 }, selected ? { fontWeight: "600" } : null] },
195
+ {
196
+ style: [
197
+ text,
198
+ { flexShrink: 1 },
199
+ // Keeps the check mark at the end once a leading node is drawn.
200
+ option.leading ? { flexGrow: 1 } : null,
201
+ selected ? { fontWeight: "600" } : null,
202
+ ],
203
+ },
147
204
  option.label,
148
205
  ),
149
206
  selected && !multiple ? h(Icon, { name: "check", size: 18, color: t.accent }) : null,
@@ -230,6 +287,50 @@ export function makeSelect(rn) {
230
287
  );
231
288
  }
232
289
 
290
+ // The sheet's search field and what it shows instead of the list.
291
+ function searchSheetProps({ theme, search, count, loading, failed, texts, testID }) {
292
+ const body = sheetBody({ loading, error: failed, query: search.text, count });
293
+ return {
294
+ body,
295
+ bodyText:
296
+ body === "loading"
297
+ ? texts.loadingText
298
+ : body === "error"
299
+ ? texts.loadErrorText
300
+ : texts.noResultsText,
301
+ rowStyle: searchRowOverrides(theme),
302
+ search: (focusFirst) =>
303
+ h(SheetSearchField, {
304
+ value: search.text,
305
+ onChangeText: search.type,
306
+ placeholder: texts.searchPlaceholder,
307
+ onArrowDown: focusFirst,
308
+ testID: testID ? `${testID}-search` : undefined,
309
+ }),
310
+ };
311
+ }
312
+
313
+ function SheetSearchField({ value, onChangeText, placeholder, onArrowDown, testID }) {
314
+ const node = React.useRef(null);
315
+ React.useEffect(() => {
316
+ focusNode(node.current);
317
+ }, []);
318
+ return h(SearchField, {
319
+ ref: node,
320
+ value,
321
+ onChangeText,
322
+ placeholder,
323
+ // Down from the field reaches the first option, as in a combobox.
324
+ onKeyPress: (event) => {
325
+ const key = event && event.nativeEvent ? event.nativeEvent.key : undefined;
326
+ if (key !== "ArrowDown") return;
327
+ if (typeof event.preventDefault === "function") event.preventDefault();
328
+ onArrowDown();
329
+ },
330
+ testID,
331
+ });
332
+ }
333
+
233
334
  const Select = React.forwardRef(function Select(props, ref) {
234
335
  const {
235
336
  value,
@@ -247,6 +348,7 @@ export function makeSelect(rn) {
247
348
  helper,
248
349
  error,
249
350
  disabled = false,
351
+ required = false,
250
352
  accessibilityLabel,
251
353
  closeAccessibilityLabel,
252
354
  style,
@@ -254,6 +356,16 @@ export function makeSelect(rn) {
254
356
  testID,
255
357
  onFocus,
256
358
  onBlur,
359
+ searchable: searchableProp = false,
360
+ onSearch,
361
+ searchDebounceMs = DEFAULT_SEARCH_DEBOUNCE_MS,
362
+ loading: loadingProp = false,
363
+ optionsError,
364
+ selectedOptions,
365
+ searchPlaceholder = "Search",
366
+ noResultsText = "No results",
367
+ loadingText = "Loading…",
368
+ loadErrorText = "Could not load the options",
257
369
  ...others
258
370
  } = props;
259
371
  const theme = useHostTheme() || DEFAULT_THEME_TOKENS;
@@ -264,6 +376,7 @@ export function makeSelect(rn) {
264
376
  else rest[key] = own;
265
377
  }
266
378
  const t = resolveFormControlTokens(theme, overrides);
379
+ const requiredWord = useHostSharedText("required", "Required");
267
380
  const [open, setOpen] = React.useState(false);
268
381
  // Null until the person ticks something: an untouched sheet follows `values`.
269
382
  const [ownDraft, setDraft] = React.useState(null);
@@ -272,13 +385,31 @@ export function makeSelect(rn) {
272
385
  const setRef = React.useMemo(() => shareRef(ref, triggerRef), [ref]);
273
386
  const listId = React.useId();
274
387
  const noteId = React.useId();
275
- const list = Array.isArray(options) ? options.filter(Boolean) : [];
276
- const current = selectedOption(list, value);
277
- const picked = multiple ? pickedOptions(list, values) : [];
388
+ const search = useSearchState({
389
+ open,
390
+ options,
391
+ searchable: searchableProp,
392
+ onSearch,
393
+ debounceMs: searchDebounceMs,
394
+ });
395
+ const { searchable } = search;
396
+ const held = search.fetched
397
+ ? search.fetched.options
398
+ : Array.isArray(options)
399
+ ? options.filter(Boolean)
400
+ : [];
401
+ const list = search.filtersHeld ? filterOptions(held, search.text) : held;
402
+ // Searchable, a picked option keeps its label after a search drops it.
403
+ const known = React.useRef(new Map());
404
+ if (searchable) known.current = rememberOptions(known.current, selectedOptions, held);
405
+ const optionsFor = (own) =>
406
+ searchable ? knownOptions(known.current, own) : pickedOptions(list, own);
407
+ const current = searchable ? knownOption(known.current, value) : selectedOption(list, value);
408
+ const picked = multiple ? optionsFor(values) : [];
278
409
  const draft = ownDraft === null ? valueList(values) : ownDraft;
279
410
  const invalid = isShown(error);
280
411
  const sheetTitle = isShown(title) ? title : isText(label) ? label : undefined;
281
- const name = spokenLabel(accessibilityLabel, label);
412
+ const name = spokenLabel(accessibilityLabel, label, required ? requiredWord : null);
282
413
  const shown = current ? current.label : placeholder;
283
414
  const spokenChoice = multiple
284
415
  ? picked.length > 0
@@ -296,7 +427,7 @@ export function makeSelect(rn) {
296
427
  const next = committedValues(list, draft);
297
428
  close();
298
429
  if (!sameValues(next, valueList(values)) && typeof onValuesChange === "function") {
299
- onValuesChange(next, pickedOptions(list, next));
430
+ onValuesChange(next, optionsFor(next));
300
431
  }
301
432
  };
302
433
  const pick = (option) => {
@@ -325,6 +456,7 @@ export function makeSelect(rn) {
325
456
  helper,
326
457
  error: errorMessage(error),
327
458
  disabled,
459
+ required,
328
460
  noteId,
329
461
  onLabelPress: () => focusNode(triggerRef.current),
330
462
  style: containerStyle,
@@ -421,11 +553,112 @@ export function makeSelect(rn) {
421
553
  testID,
422
554
  onPick: pick,
423
555
  footer: multiple ? h(DoneFooter, { doneText, onDone: done, testID }) : null,
556
+ ...(searchable
557
+ ? searchSheetProps({
558
+ theme,
559
+ search,
560
+ count: list.length,
561
+ loading:
562
+ search.pending || (search.fetched ? search.fetched.loading : !!loadingProp),
563
+ failed: search.fetched ? search.fetched.error : isShown(optionsError),
564
+ texts: {
565
+ searchPlaceholder,
566
+ noResultsText,
567
+ loadingText,
568
+ loadErrorText: typeof optionsError === "string" ? optionsError : loadErrorText,
569
+ },
570
+ testID,
571
+ })
572
+ : null),
424
573
  }),
425
574
  ),
426
575
  );
427
576
  });
428
577
 
429
578
  Select.displayName = "Select";
579
+ Select[DRAWS_FIELD_FRAME] = true;
430
580
  return Select;
431
581
  }
582
+
583
+ /**
584
+ * A searchable select's query: the text typed into the sheet, sent after a
585
+ * pause to `onSearch` or to async `options`, whose latest answer it keeps.
586
+ * Opening the sheet starts from an empty query; a plain select is inert here.
587
+ */
588
+ function useSearchState({ open, options, searchable, onSearch, debounceMs }) {
589
+ const asyncOptions = typeof options === "function";
590
+ const remote = typeof onSearch === "function" || asyncOptions;
591
+ const enabled = !!searchable || remote;
592
+ const [text, setText] = React.useState("");
593
+ // The query last handed on; typed text that differs is still pending.
594
+ const [sentQuery, setSentQuery] = React.useState("");
595
+ const [fetched, setFetched] = React.useState({ options: [], loading: false, error: false });
596
+ const latest = React.useRef({ onSearch, options });
597
+ latest.current = { onSearch, options };
598
+ const gate = React.useRef(null);
599
+ if (gate.current === null) gate.current = createRequestGate();
600
+ const mounted = React.useRef(false);
601
+ // Set in the body too: StrictMode runs the cleanup, then the effect again.
602
+ React.useEffect(() => {
603
+ mounted.current = true;
604
+ return () => {
605
+ mounted.current = false;
606
+ };
607
+ }, []);
608
+
609
+ const send = React.useCallback((query) => {
610
+ setSentQuery(query);
611
+ const own = latest.current;
612
+ if (typeof own.onSearch === "function") own.onSearch(query);
613
+ if (typeof own.options !== "function") return;
614
+ const ticket = gate.current.begin();
615
+ setFetched((state) => ({ ...state, loading: true, error: false }));
616
+ Promise.resolve()
617
+ .then(() => own.options(query))
618
+ .then(
619
+ (rows) => {
620
+ if (!mounted.current || !gate.current.isCurrent(ticket)) return;
621
+ const list = Array.isArray(rows) ? rows.filter(Boolean) : [];
622
+ setFetched({ options: list, loading: false, error: false });
623
+ },
624
+ () => {
625
+ if (!mounted.current || !gate.current.isCurrent(ticket)) return;
626
+ setFetched({ options: [], loading: false, error: true });
627
+ },
628
+ );
629
+ }, []);
630
+
631
+ const delay = React.useRef(debounceMs);
632
+ delay.current = debounceMs;
633
+ const debouncer = React.useRef(null);
634
+ if (debouncer.current === null) debouncer.current = createDebouncer(() => delay.current, send);
635
+ React.useEffect(() => () => debouncer.current.cancel(), []);
636
+
637
+ // Each opening starts from an empty query, asked for at once; it is
638
+ // cleared on closing so the next opening never draws the old filter.
639
+ React.useEffect(() => {
640
+ if (!enabled) return;
641
+ if (!open) {
642
+ debouncer.current.cancel();
643
+ setText("");
644
+ return;
645
+ }
646
+ if (remote) send("");
647
+ // eslint-disable-next-line react-hooks/exhaustive-deps
648
+ }, [open, enabled, remote]);
649
+
650
+ const type = (next) => {
651
+ const own = typeof next === "string" ? next : "";
652
+ setText(own);
653
+ if (remote) debouncer.current.push(own);
654
+ };
655
+
656
+ return {
657
+ searchable: enabled,
658
+ filtersHeld: enabled && !remote,
659
+ text: enabled ? text : "",
660
+ pending: remote && normaliseQuery(text) !== sentQuery,
661
+ type,
662
+ fetched: asyncOptions ? fetched : null,
663
+ };
664
+ }
package/dist/select.js CHANGED
@@ -1,12 +1,14 @@
1
1
  // `<Select>` (web). Binds ./select-view.js to react-native-web and the web
2
- // `<Overlay>`, `<Button>` and `<Chip>`; see ./primitives.js for why not the
3
- // bare specifier.
2
+ // `<Overlay>`, `<Button>`, `<Chip>`, `<SearchField>` and `<Spinner>`; see
3
+ // ./primitives.js for why not the bare specifier.
4
4
 
5
5
  import * as ReactNative from "react-native-web";
6
6
  import { makeSelect } from "./select-view.js";
7
7
  import { Overlay } from "./overlay.js";
8
8
  import { Button } from "./button.js";
9
9
  import { Chip } from "./chip.js";
10
+ import { SearchField } from "./search-field.js";
11
+ import { Spinner } from "./loading.js";
10
12
 
11
13
  export const Select = makeSelect({
12
14
  View: ReactNative.View,
@@ -17,4 +19,6 @@ export const Select = makeSelect({
17
19
  Overlay,
18
20
  Button,
19
21
  Chip,
22
+ SearchField,
23
+ Spinner,
20
24
  });
@@ -1,11 +1,13 @@
1
1
  // `<Select>` (native). Binds ./select-view.js to React Native's own
2
- // primitives and the native `<Overlay>`, `<Button>` and `<Chip>`; only the
3
- // imports differ from ./select.js.
2
+ // primitives and the native `<Overlay>`, `<Button>`, `<Chip>`,
3
+ // `<SearchField>` and `<Spinner>`; only the 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
8
  import { Button } from "./button.native.js";
9
9
  import { Chip } from "./chip.native.js";
10
+ import { SearchField } from "./search-field.native.js";
11
+ import { Spinner } from "./loading.native.js";
10
12
 
11
- export const Select = makeSelect({ View, Text, Pressable, ScrollView, Platform, Overlay, Button, Chip });
13
+ export const Select = makeSelect({ View, Text, Pressable, ScrollView, Platform, Overlay, Button, Chip, SearchField, Spinner });