@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.
- package/README.md +76 -6
- package/dist/board-auto-scroll.js +59 -0
- package/dist/board-view.js +327 -0
- package/dist/chart-logic.js +575 -0
- package/dist/chart-tokens.js +131 -0
- package/dist/chart-view.js +262 -0
- package/dist/chart.js +21 -0
- package/dist/chart.native.js +10 -0
- package/dist/contract.cjs +119 -7
- package/dist/contract.js +119 -7
- package/dist/data-table-logic.js +179 -0
- package/dist/data-table-view.js +388 -0
- package/dist/data-table.js +22 -0
- package/dist/data-table.native.js +14 -0
- package/dist/drag-kit-view.js +310 -0
- package/dist/file-upload-view.js +13 -2
- package/dist/filter-bar-logic.js +143 -0
- package/dist/filter-bar-view.js +381 -0
- package/dist/filter-bar.js +15 -0
- package/dist/filter-bar.native.js +13 -0
- package/dist/form-control-logic.js +20 -3
- package/dist/form-control-view.js +18 -0
- package/dist/form-layout-logic.js +92 -0
- package/dist/form-view.js +268 -0
- package/dist/form.js +15 -0
- package/dist/form.native.js +9 -0
- package/dist/grid-logic.js +222 -0
- package/dist/grid-view.js +109 -0
- package/dist/grid.js +7 -0
- package/dist/grid.native.js +7 -0
- package/dist/host.d.ts +6 -0
- package/dist/host.js +2 -0
- package/dist/index.d.ts +606 -7
- package/dist/index.js +15 -0
- package/dist/index.native.js +15 -0
- package/dist/menu-view.js +5 -2
- package/dist/people-picker-logic.js +203 -0
- package/dist/people-picker-view.js +250 -0
- package/dist/people-picker.js +11 -0
- package/dist/people-picker.native.js +9 -0
- package/dist/radio-group-view.js +7 -3
- package/dist/select-search-logic.js +150 -0
- package/dist/select-view.js +251 -18
- package/dist/select.js +6 -2
- package/dist/select.native.js +5 -3
- package/dist/sortable-list-view.js +228 -0
- package/dist/sortable-logic.js +273 -0
- package/dist/sortable-tokens.js +120 -0
- package/dist/sortable-view.js +24 -0
- package/dist/sortable.js +39 -0
- package/dist/sortable.native.js +30 -0
- package/dist/text-field-view.js +7 -2
- package/package.json +1 -1
- 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
|
+
}
|
package/dist/select-view.js
CHANGED
|
@@ -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 } =
|
|
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 {
|
|
70
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
92
|
-
|
|
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
|
-
{
|
|
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
|
|
276
|
-
|
|
277
|
-
|
|
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,
|
|
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 `<
|
|
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
|
});
|
package/dist/select.native.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
// `<Select>` (native). Binds ./select-view.js to React Native's own
|
|
2
|
-
// primitives and the native `<Overlay>`, `<Button
|
|
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 });
|