@colixsystems/widget-sdk 0.247.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 +53 -5
- package/dist/board-auto-scroll.js +59 -0
- package/dist/board-view.js +327 -0
- package/dist/contract.cjs +70 -2
- package/dist/contract.js +70 -2
- 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/index.d.ts +372 -5
- package/dist/index.js +8 -0
- package/dist/index.native.js +8 -0
- package/dist/menu-view.js +5 -2
- package/dist/people-picker-view.js +3 -0
- package/dist/radio-group-view.js +7 -3
- package/dist/select-view.js +7 -2
- 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 +70 -2
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
// The ONE implementation behind the `<FilterBar>` SDK primitive: a search
|
|
2
|
+
// box and one compact dropdown per facet, composed from the SDK's own
|
|
3
|
+
// `<SearchField>`, `<Select>`, `<Chip>`, `<Button>` and `<Modal>`, so every
|
|
4
|
+
// part wears the app's look. The platform bindings (`filter-bar.js` /
|
|
5
|
+
// `filter-bar.native.js`) differ only in which host's parts they pass.
|
|
6
|
+
//
|
|
7
|
+
// It measures its own unpadded box. Medium and wide lay out one toolbar row;
|
|
8
|
+
// narrow, or a row too full for its dropdowns, is the search field and one
|
|
9
|
+
// Filters button opening the facets in a sheet that applies on its Apply
|
|
10
|
+
// button. The applied filters sit beneath as removable chips at every width.
|
|
11
|
+
|
|
12
|
+
import React from "react";
|
|
13
|
+
import { useContainerWidth, useHostTheme } from "./hooks.js";
|
|
14
|
+
import { DEFAULT_THEME_TOKENS } from "./_theme-tokens.js";
|
|
15
|
+
import { DEFAULT_SEARCH_DEBOUNCE_MS, createDebouncer, normaliseQuery } from "./select-search-logic.js";
|
|
16
|
+
import {
|
|
17
|
+
FILTER_FACET_WIDTH,
|
|
18
|
+
FILTER_SEARCH_MIN_WIDTH,
|
|
19
|
+
appliedFacets,
|
|
20
|
+
chipLabel,
|
|
21
|
+
facetPlaceholder,
|
|
22
|
+
fillText,
|
|
23
|
+
filterBarLayout,
|
|
24
|
+
filtersButtonText,
|
|
25
|
+
sameFilters,
|
|
26
|
+
usableFacets,
|
|
27
|
+
withFacet,
|
|
28
|
+
} from "./filter-bar-logic.js";
|
|
29
|
+
|
|
30
|
+
const h = React.createElement;
|
|
31
|
+
const NO_FILTERS = Object.freeze({});
|
|
32
|
+
const MAX_UNECHOED = 20;
|
|
33
|
+
|
|
34
|
+
function part(testID, name) {
|
|
35
|
+
return testID ? `${testID}-${name}` : undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Typed text reported once typing pauses; a clear or submit reports at once.
|
|
39
|
+
function useQuery(query, onQueryChange, debounceMs) {
|
|
40
|
+
const given = typeof query === "string" ? query : "";
|
|
41
|
+
const [text, setText] = React.useState(given);
|
|
42
|
+
const reported = React.useRef(normaliseQuery(given));
|
|
43
|
+
// Reported queries the widget has not handed back yet, oldest first.
|
|
44
|
+
const unechoed = React.useRef([]);
|
|
45
|
+
const report = React.useRef(null);
|
|
46
|
+
report.current = (next) => {
|
|
47
|
+
if (next === reported.current) return;
|
|
48
|
+
reported.current = next;
|
|
49
|
+
unechoed.current = [...unechoed.current.slice(1 - MAX_UNECHOED), next];
|
|
50
|
+
if (typeof onQueryChange === "function") onQueryChange(next);
|
|
51
|
+
};
|
|
52
|
+
const delay = React.useRef(debounceMs);
|
|
53
|
+
delay.current = debounceMs;
|
|
54
|
+
const debouncer = React.useRef(null);
|
|
55
|
+
if (debouncer.current === null) {
|
|
56
|
+
debouncer.current = createDebouncer(() => delay.current, (next) => report.current(next));
|
|
57
|
+
}
|
|
58
|
+
React.useEffect(() => () => debouncer.current.cancel(), []);
|
|
59
|
+
// A query set from outside (a route param, a reset) replaces the typed text;
|
|
60
|
+
// a late hand-back of one this bar reported must not undo newer typing.
|
|
61
|
+
React.useEffect(() => {
|
|
62
|
+
const next = normaliseQuery(given);
|
|
63
|
+
const echo = unechoed.current.indexOf(next);
|
|
64
|
+
if (echo !== -1) {
|
|
65
|
+
unechoed.current = unechoed.current.slice(echo + 1);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (next === reported.current) return;
|
|
69
|
+
debouncer.current.cancel();
|
|
70
|
+
reported.current = next;
|
|
71
|
+
setText(given);
|
|
72
|
+
}, [given]);
|
|
73
|
+
|
|
74
|
+
const now = (next) => {
|
|
75
|
+
debouncer.current.cancel();
|
|
76
|
+
report.current(normaliseQuery(next));
|
|
77
|
+
};
|
|
78
|
+
const type = (next) => {
|
|
79
|
+
setText(next);
|
|
80
|
+
if (normaliseQuery(next) === "") now(next);
|
|
81
|
+
else debouncer.current.push(next);
|
|
82
|
+
};
|
|
83
|
+
const clear = () => {
|
|
84
|
+
setText("");
|
|
85
|
+
now("");
|
|
86
|
+
};
|
|
87
|
+
return { text, type, submit: () => now(text), clear };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Bind the shared filter bar to one host's parts.
|
|
92
|
+
*
|
|
93
|
+
* @param {{View: any, SearchField: any, Select: any, Chip: any, ChipGroup: any,
|
|
94
|
+
* Button: any, Modal: any}} rn
|
|
95
|
+
*/
|
|
96
|
+
export function makeFilterBar(rn) {
|
|
97
|
+
const { View, SearchField, Select, Chip, ChipGroup, Button, Modal } = rn;
|
|
98
|
+
|
|
99
|
+
function FacetSelect({ facet, value, onPick, anyText, compact, testID }) {
|
|
100
|
+
const shared = {
|
|
101
|
+
title: facet.label,
|
|
102
|
+
searchable: facet.searchable === true,
|
|
103
|
+
testID,
|
|
104
|
+
...(compact
|
|
105
|
+
? {
|
|
106
|
+
accessibilityLabel: facet.label,
|
|
107
|
+
placeholder: facetPlaceholder(facet, anyText),
|
|
108
|
+
containerStyle: { width: FILTER_FACET_WIDTH, flexShrink: 0 },
|
|
109
|
+
}
|
|
110
|
+
: { label: facet.label, placeholder: anyText }),
|
|
111
|
+
};
|
|
112
|
+
if (facet.multiple) {
|
|
113
|
+
return h(Select, {
|
|
114
|
+
...shared,
|
|
115
|
+
multiple: true,
|
|
116
|
+
options: facet.options,
|
|
117
|
+
values: Array.isArray(value) ? value : [],
|
|
118
|
+
onValuesChange: (next) => onPick(next),
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
// An "Any" choice first, so a single facet can be cleared where it is set.
|
|
122
|
+
return h(Select, {
|
|
123
|
+
...shared,
|
|
124
|
+
options: [{ value: null, label: anyText }, ...facet.options],
|
|
125
|
+
value,
|
|
126
|
+
onValueChange: (next) => onPick(next),
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function SortSelect({ sort, value, onPick, compact, testID }) {
|
|
131
|
+
const label = typeof sort.label === "string" ? sort.label : "Sort";
|
|
132
|
+
return h(Select, {
|
|
133
|
+
title: label,
|
|
134
|
+
options: Array.isArray(sort.options) ? sort.options : [],
|
|
135
|
+
value,
|
|
136
|
+
onValueChange: (next) => onPick(next),
|
|
137
|
+
testID,
|
|
138
|
+
...(compact
|
|
139
|
+
? {
|
|
140
|
+
accessibilityLabel: label,
|
|
141
|
+
placeholder: label,
|
|
142
|
+
containerStyle: { width: FILTER_FACET_WIDTH, flexShrink: 0 },
|
|
143
|
+
}
|
|
144
|
+
: { label, placeholder: label }),
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function FilterBar({
|
|
149
|
+
query,
|
|
150
|
+
onQueryChange,
|
|
151
|
+
queryDebounceMs = DEFAULT_SEARCH_DEBOUNCE_MS,
|
|
152
|
+
facets,
|
|
153
|
+
value,
|
|
154
|
+
onChange,
|
|
155
|
+
sort,
|
|
156
|
+
searchPlaceholder = "Search",
|
|
157
|
+
anyText = "Any",
|
|
158
|
+
filtersText = "Filters",
|
|
159
|
+
activeCountText = "{count} active",
|
|
160
|
+
clearAllText = "Clear all",
|
|
161
|
+
applyText = "Apply",
|
|
162
|
+
clearText = "Clear",
|
|
163
|
+
activeFiltersText = "Active filters",
|
|
164
|
+
removeText = "Remove {label}",
|
|
165
|
+
closeAccessibilityLabel,
|
|
166
|
+
style,
|
|
167
|
+
testID,
|
|
168
|
+
}) {
|
|
169
|
+
const theme = useHostTheme() || DEFAULT_THEME_TOKENS;
|
|
170
|
+
const spacing = theme.spacing || DEFAULT_THEME_TOKENS.spacing;
|
|
171
|
+
const gap = spacing.sm;
|
|
172
|
+
const [width, onLayout] = useContainerWidth();
|
|
173
|
+
const list = usableFacets(facets);
|
|
174
|
+
const filters = value && typeof value === "object" ? value : NO_FILTERS;
|
|
175
|
+
const applied = appliedFacets(list, filters);
|
|
176
|
+
const hasSearch = typeof onQueryChange === "function";
|
|
177
|
+
const hasSort = !!sort && typeof sort.onChange === "function";
|
|
178
|
+
const search = useQuery(query, onQueryChange, queryDebounceMs);
|
|
179
|
+
const layout = filterBarLayout({
|
|
180
|
+
width,
|
|
181
|
+
dropdowns: list.length + (hasSort ? 1 : 0),
|
|
182
|
+
gap,
|
|
183
|
+
search: hasSearch,
|
|
184
|
+
});
|
|
185
|
+
const narrow = layout === "sheet";
|
|
186
|
+
const [open, setOpen] = React.useState(false);
|
|
187
|
+
const [draft, setDraft] = React.useState(NO_FILTERS);
|
|
188
|
+
const [draftSort, setDraftSort] = React.useState(undefined);
|
|
189
|
+
|
|
190
|
+
const change = (next) => {
|
|
191
|
+
if (!sameFilters(next, filters) && typeof onChange === "function") onChange(next);
|
|
192
|
+
};
|
|
193
|
+
const pickSort = (next) => {
|
|
194
|
+
if (hasSort && next !== sort.value) sort.onChange(next);
|
|
195
|
+
};
|
|
196
|
+
const clearAll = () => {
|
|
197
|
+
search.clear();
|
|
198
|
+
change({});
|
|
199
|
+
};
|
|
200
|
+
const firstSort = hasSort && Array.isArray(sort.options) && sort.options[0] ? sort.options[0].value : undefined;
|
|
201
|
+
const clearSheet = () => {
|
|
202
|
+
setDraft(NO_FILTERS);
|
|
203
|
+
if (hasSort) setDraftSort(firstSort);
|
|
204
|
+
};
|
|
205
|
+
const showSheet = () => {
|
|
206
|
+
setDraft(filters);
|
|
207
|
+
setDraftSort(hasSort ? sort.value : undefined);
|
|
208
|
+
setOpen(true);
|
|
209
|
+
};
|
|
210
|
+
const apply = () => {
|
|
211
|
+
setOpen(false);
|
|
212
|
+
change(draft);
|
|
213
|
+
pickSort(draftSort);
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
const searchField = hasSearch
|
|
217
|
+
? h(SearchField, {
|
|
218
|
+
key: "search",
|
|
219
|
+
value: search.text,
|
|
220
|
+
onChangeText: search.type,
|
|
221
|
+
onSubmitEditing: search.submit,
|
|
222
|
+
placeholder: searchPlaceholder,
|
|
223
|
+
testID: part(testID, "search"),
|
|
224
|
+
containerStyle: { flex: 1, minWidth: narrow ? 0 : FILTER_SEARCH_MIN_WIDTH },
|
|
225
|
+
})
|
|
226
|
+
: null;
|
|
227
|
+
|
|
228
|
+
const dropdowns = narrow
|
|
229
|
+
? []
|
|
230
|
+
: [
|
|
231
|
+
...list.map((facet) =>
|
|
232
|
+
h(FacetSelect, {
|
|
233
|
+
key: `facet-${facet.key}`,
|
|
234
|
+
facet,
|
|
235
|
+
value: filters[facet.key],
|
|
236
|
+
onPick: (next) => change(withFacet(filters, facet.key, next)),
|
|
237
|
+
anyText,
|
|
238
|
+
compact: true,
|
|
239
|
+
testID: part(testID, `facet-${facet.key}`),
|
|
240
|
+
}),
|
|
241
|
+
),
|
|
242
|
+
hasSort
|
|
243
|
+
? h(SortSelect, {
|
|
244
|
+
key: "sort",
|
|
245
|
+
sort,
|
|
246
|
+
value: sort.value,
|
|
247
|
+
onPick: pickSort,
|
|
248
|
+
compact: true,
|
|
249
|
+
testID: part(testID, "sort"),
|
|
250
|
+
})
|
|
251
|
+
: null,
|
|
252
|
+
];
|
|
253
|
+
|
|
254
|
+
const filtersButton =
|
|
255
|
+
narrow && (list.length > 0 || hasSort)
|
|
256
|
+
? h(Button, {
|
|
257
|
+
key: "filters",
|
|
258
|
+
title: filtersButtonText(filtersText, applied.length),
|
|
259
|
+
accessibilityLabel:
|
|
260
|
+
applied.length > 0
|
|
261
|
+
? `${filtersText}, ${fillText(activeCountText, "count", applied.length)}`
|
|
262
|
+
: filtersText,
|
|
263
|
+
icon: "sliders-horizontal",
|
|
264
|
+
variant: "secondary",
|
|
265
|
+
onPress: showSheet,
|
|
266
|
+
"aria-haspopup": "dialog",
|
|
267
|
+
"aria-expanded": open,
|
|
268
|
+
testID: part(testID, "filters"),
|
|
269
|
+
})
|
|
270
|
+
: null;
|
|
271
|
+
|
|
272
|
+
// Chips show at every width: a narrow user drops one without the sheet.
|
|
273
|
+
const chipSize = narrow ? "md" : "sm";
|
|
274
|
+
const chips =
|
|
275
|
+
applied.length > 0
|
|
276
|
+
? h(
|
|
277
|
+
View,
|
|
278
|
+
{
|
|
279
|
+
key: "applied",
|
|
280
|
+
style: { flexDirection: "row", flexWrap: "wrap", alignItems: "center", gap },
|
|
281
|
+
},
|
|
282
|
+
h(
|
|
283
|
+
ChipGroup,
|
|
284
|
+
{ accessibilityLabel: activeFiltersText, testID: part(testID, "chips") },
|
|
285
|
+
...applied.map((facet) => {
|
|
286
|
+
const label = chipLabel(facet, filters[facet.key]);
|
|
287
|
+
return h(Chip, {
|
|
288
|
+
key: facet.key,
|
|
289
|
+
label,
|
|
290
|
+
size: chipSize,
|
|
291
|
+
onRemove: () => change(withFacet(filters, facet.key, undefined)),
|
|
292
|
+
removeAccessibilityLabel: fillText(removeText, "label", label),
|
|
293
|
+
testID: part(testID, `chip-${facet.key}`),
|
|
294
|
+
});
|
|
295
|
+
}),
|
|
296
|
+
),
|
|
297
|
+
h(Button, {
|
|
298
|
+
title: clearAllText,
|
|
299
|
+
variant: "link",
|
|
300
|
+
onPress: clearAll,
|
|
301
|
+
testID: part(testID, "clear-all"),
|
|
302
|
+
}),
|
|
303
|
+
)
|
|
304
|
+
: null;
|
|
305
|
+
|
|
306
|
+
const sheet = narrow
|
|
307
|
+
? h(
|
|
308
|
+
Modal,
|
|
309
|
+
{
|
|
310
|
+
key: "sheet",
|
|
311
|
+
visible: open,
|
|
312
|
+
onRequestClose: () => setOpen(false),
|
|
313
|
+
presentation: "sheet",
|
|
314
|
+
title: filtersText,
|
|
315
|
+
...(closeAccessibilityLabel ? { closeAccessibilityLabel } : null),
|
|
316
|
+
testID: part(testID, "sheet"),
|
|
317
|
+
footer: h(
|
|
318
|
+
View,
|
|
319
|
+
{ style: { flexDirection: "row", justifyContent: "flex-end", gap } },
|
|
320
|
+
h(Button, {
|
|
321
|
+
title: clearText,
|
|
322
|
+
variant: "ghost",
|
|
323
|
+
onPress: clearSheet,
|
|
324
|
+
testID: part(testID, "clear"),
|
|
325
|
+
}),
|
|
326
|
+
h(Button, { title: applyText, onPress: apply, testID: part(testID, "apply") }),
|
|
327
|
+
),
|
|
328
|
+
},
|
|
329
|
+
h(
|
|
330
|
+
View,
|
|
331
|
+
{ style: { gap: spacing.md } },
|
|
332
|
+
...list.map((facet) =>
|
|
333
|
+
h(FacetSelect, {
|
|
334
|
+
key: facet.key,
|
|
335
|
+
facet,
|
|
336
|
+
value: draft[facet.key],
|
|
337
|
+
onPick: (next) => setDraft((own) => withFacet(own, facet.key, next)),
|
|
338
|
+
anyText,
|
|
339
|
+
compact: false,
|
|
340
|
+
testID: part(testID, `sheet-facet-${facet.key}`),
|
|
341
|
+
}),
|
|
342
|
+
),
|
|
343
|
+
hasSort
|
|
344
|
+
? h(SortSelect, {
|
|
345
|
+
key: "sort",
|
|
346
|
+
sort,
|
|
347
|
+
value: draftSort,
|
|
348
|
+
onPick: setDraftSort,
|
|
349
|
+
compact: false,
|
|
350
|
+
testID: part(testID, "sheet-sort"),
|
|
351
|
+
})
|
|
352
|
+
: null,
|
|
353
|
+
),
|
|
354
|
+
)
|
|
355
|
+
: null;
|
|
356
|
+
|
|
357
|
+
return h(
|
|
358
|
+
View,
|
|
359
|
+
{ style: [{ alignSelf: "stretch" }, style], testID },
|
|
360
|
+
h(
|
|
361
|
+
View,
|
|
362
|
+
{ onLayout, style: { gap } },
|
|
363
|
+
h(
|
|
364
|
+
View,
|
|
365
|
+
{
|
|
366
|
+
style: { flexDirection: "row", alignItems: "center", gap },
|
|
367
|
+
testID: part(testID, narrow ? "compact" : "row"),
|
|
368
|
+
},
|
|
369
|
+
searchField,
|
|
370
|
+
...dropdowns,
|
|
371
|
+
filtersButton,
|
|
372
|
+
),
|
|
373
|
+
chips,
|
|
374
|
+
),
|
|
375
|
+
sheet,
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
FilterBar.displayName = "FilterBar";
|
|
380
|
+
return FilterBar;
|
|
381
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// `<FilterBar>` (web). Binds ./filter-bar-view.js to react-native-web and the
|
|
2
|
+
// web `<SearchField>`, `<Select>`, `<Chip>`, `<Button>` and `<Modal>`; see
|
|
3
|
+
// ./primitives.js for why not the bare specifier.
|
|
4
|
+
|
|
5
|
+
import * as ReactNative from "react-native-web";
|
|
6
|
+
import { makeFilterBar } from "./filter-bar-view.js";
|
|
7
|
+
import { SearchField } from "./search-field.js";
|
|
8
|
+
import { Select } from "./select.js";
|
|
9
|
+
import { Chip, ChipGroup } from "./chip.js";
|
|
10
|
+
import { Button } from "./button.js";
|
|
11
|
+
import { Modal } from "./modal.js";
|
|
12
|
+
|
|
13
|
+
const { View } = ReactNative;
|
|
14
|
+
|
|
15
|
+
export const FilterBar = makeFilterBar({ View, SearchField, Select, Chip, ChipGroup, Button, Modal });
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// `<FilterBar>` (native). Binds ./filter-bar-view.js to React Native's `View`
|
|
2
|
+
// and the native `<SearchField>`, `<Select>`, `<Chip>`, `<Button>` and
|
|
3
|
+
// `<Modal>`; only the imports differ from ./filter-bar.js.
|
|
4
|
+
|
|
5
|
+
import { View } from "react-native";
|
|
6
|
+
import { makeFilterBar } from "./filter-bar-view.js";
|
|
7
|
+
import { SearchField } from "./search-field.native.js";
|
|
8
|
+
import { Select } from "./select.native.js";
|
|
9
|
+
import { Chip, ChipGroup } from "./chip.native.js";
|
|
10
|
+
import { Button } from "./button.native.js";
|
|
11
|
+
import { Modal } from "./modal.native.js";
|
|
12
|
+
|
|
13
|
+
export const FilterBar = makeFilterBar({ View, SearchField, Select, Chip, ChipGroup, Button, Modal });
|
|
@@ -12,10 +12,27 @@ export function isText(value) {
|
|
|
12
12
|
return typeof value === "string" || typeof value === "number";
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
/**
|
|
16
|
-
|
|
15
|
+
/**
|
|
16
|
+
* The spoken name of a control: its own label, else its visible one. A
|
|
17
|
+
* required control's visible label is followed by `requiredWord`, because
|
|
18
|
+
* the drawn mark is hidden from readers and a device has no `aria-required`.
|
|
19
|
+
*/
|
|
20
|
+
export function spokenLabel(accessibilityLabel, label, requiredWord) {
|
|
17
21
|
if (isShown(accessibilityLabel)) return accessibilityLabel;
|
|
18
|
-
|
|
22
|
+
if (!isText(label)) return undefined;
|
|
23
|
+
return isShown(requiredWord) ? `${label}, ${requiredWord}` : String(label);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The static a form control carries when `<FormField>` should hand it its
|
|
28
|
+
* label, helper, error and `required` instead of framing it a second time.
|
|
29
|
+
*/
|
|
30
|
+
export const DRAWS_FIELD_FRAME = "drawsFieldFrame";
|
|
31
|
+
|
|
32
|
+
/** Whether `element` is a form control that frames its own label. */
|
|
33
|
+
export function drawsOwnFrame(element) {
|
|
34
|
+
const type = element && element.type;
|
|
35
|
+
return Boolean(type && (typeof type === "function" || typeof type === "object") && type[DRAWS_FIELD_FRAME]);
|
|
19
36
|
}
|
|
20
37
|
|
|
21
38
|
/** An `error` that carries a message (`true` only marks the control). */
|
|
@@ -8,6 +8,13 @@ import { errorMessage, isShown, isText } from "./form-control-logic.js";
|
|
|
8
8
|
|
|
9
9
|
const h = React.createElement;
|
|
10
10
|
|
|
11
|
+
// Readers hear the required word in the control's name instead.
|
|
12
|
+
const HIDDEN_FROM_READERS = {
|
|
13
|
+
"aria-hidden": true,
|
|
14
|
+
accessibilityElementsHidden: true,
|
|
15
|
+
importantForAccessibility: "no-hide-descendants",
|
|
16
|
+
};
|
|
17
|
+
|
|
11
18
|
/**
|
|
12
19
|
* Bind the shared frame to one platform's primitives.
|
|
13
20
|
*
|
|
@@ -28,6 +35,9 @@ export function makeFieldFrame(rn) {
|
|
|
28
35
|
* control dims itself and `disabled` would dim it twice.
|
|
29
36
|
* @param {boolean} [props.alertOnError] — an error interrupts, as an alert,
|
|
30
37
|
* for one that follows an action rather than typing.
|
|
38
|
+
* @param {boolean} [props.required] — a mark after a text label.
|
|
39
|
+
* @param {string} [props.spokenRequired] — the required word the label
|
|
40
|
+
* itself speaks, when no control's name carries it.
|
|
31
41
|
*/
|
|
32
42
|
function FieldFrame({
|
|
33
43
|
t,
|
|
@@ -39,15 +49,22 @@ export function makeFieldFrame(rn) {
|
|
|
39
49
|
onLabelPress,
|
|
40
50
|
noteDisabled,
|
|
41
51
|
alertOnError,
|
|
52
|
+
required,
|
|
53
|
+
spokenRequired,
|
|
42
54
|
style,
|
|
43
55
|
children,
|
|
44
56
|
}) {
|
|
45
57
|
const message = errorMessage(error);
|
|
46
58
|
const note = message || (isShown(helper) ? helper : null);
|
|
59
|
+
const marked = required === true;
|
|
47
60
|
const labelText = isText(label)
|
|
48
61
|
? h(
|
|
49
62
|
Text,
|
|
50
63
|
{
|
|
64
|
+
// A device reads the nested mark aloud unless the name excludes it.
|
|
65
|
+
...(marked
|
|
66
|
+
? { accessibilityLabel: isText(spokenRequired) ? `${label}, ${spokenRequired}` : String(label) }
|
|
67
|
+
: null),
|
|
51
68
|
style: {
|
|
52
69
|
color: t.labelColor,
|
|
53
70
|
fontFamily: t.fontFamily,
|
|
@@ -56,6 +73,7 @@ export function makeFieldFrame(rn) {
|
|
|
56
73
|
},
|
|
57
74
|
},
|
|
58
75
|
label,
|
|
76
|
+
marked ? h(Text, { ...HIDDEN_FROM_READERS, style: { color: t.errorColor } }, " *") : null,
|
|
59
77
|
)
|
|
60
78
|
: null;
|
|
61
79
|
return h(
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// The `<Form>` layout rules, without a renderer: how many columns a form's
|
|
2
|
+
// fields take at each width tier, how wide a form may grow, and which
|
|
3
|
+
// children take a whole line. They are the rules the AI widget builder is
|
|
4
|
+
// taught for a form it lays out by hand, so the two cannot disagree.
|
|
5
|
+
|
|
6
|
+
import { widthTier } from "./container-width.js";
|
|
7
|
+
|
|
8
|
+
/** A form wider than this is a wall of giant inputs; it centres instead. */
|
|
9
|
+
export const FORM_MAX_WIDTH = 1000;
|
|
10
|
+
/** More fields to a line and each is too narrow to read or tap. */
|
|
11
|
+
export const MAX_FORM_COLUMNS = 3;
|
|
12
|
+
export const DEFAULT_FORM_GAP = "md";
|
|
13
|
+
|
|
14
|
+
function columnCeiling(columns) {
|
|
15
|
+
const n = Math.floor(Number(columns));
|
|
16
|
+
if (columns === undefined || columns === null || columns === "auto" || !Number.isFinite(n)) {
|
|
17
|
+
return MAX_FORM_COLUMNS;
|
|
18
|
+
}
|
|
19
|
+
return Math.min(MAX_FORM_COLUMNS, Math.max(1, n));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* How many fields share a line: one when narrow, at most two at medium, and
|
|
24
|
+
* the ceiling (`columns`, else three) when wide.
|
|
25
|
+
*
|
|
26
|
+
* @param {"auto"|number|undefined} columns
|
|
27
|
+
* @param {number} width — the form's measured width; 0 is not yet measured.
|
|
28
|
+
*/
|
|
29
|
+
export function formColumns(columns, width) {
|
|
30
|
+
const ceiling = columnCeiling(columns);
|
|
31
|
+
const tier = widthTier(width);
|
|
32
|
+
if (tier === "narrow") return 1;
|
|
33
|
+
if (tier === "medium") return Math.min(2, ceiling);
|
|
34
|
+
return ceiling;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* How many fields of a `<FieldRow>` share its line: all of them, up to three,
|
|
39
|
+
* except when narrow, where they stack.
|
|
40
|
+
*/
|
|
41
|
+
export function fieldRowColumns(count, width) {
|
|
42
|
+
if (widthTier(width) === "narrow") return 1;
|
|
43
|
+
return Math.min(MAX_FORM_COLUMNS, Math.max(1, Math.floor(count) || 1));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The form's cap: `FORM_MAX_WIDTH` by default, a positive number as given, or
|
|
48
|
+
* none for `null` (a form in a side panel or a card that sets its own measure).
|
|
49
|
+
*/
|
|
50
|
+
export function formMaxWidth(maxWidth) {
|
|
51
|
+
if (maxWidth === null) return undefined;
|
|
52
|
+
const n = Number(maxWidth);
|
|
53
|
+
return maxWidth !== undefined && Number.isFinite(n) && n > 0 ? n : FORM_MAX_WIDTH;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** A `<FormActions>` row at medium and wide; a full-width stack when narrow. */
|
|
57
|
+
export function actionsStack(width) {
|
|
58
|
+
return widthTier(width) === "narrow";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Split a form's children into the runs it draws: fields share lines in a
|
|
63
|
+
* run, and a child that takes the whole line stands alone between runs.
|
|
64
|
+
*
|
|
65
|
+
* @param {boolean[]} fullLine — per child, whether it takes the whole line.
|
|
66
|
+
* @returns {Array<{full: boolean, indices: number[]}>}
|
|
67
|
+
*/
|
|
68
|
+
export function formRuns(fullLine) {
|
|
69
|
+
const runs = [];
|
|
70
|
+
fullLine.forEach((full, index) => {
|
|
71
|
+
const last = runs[runs.length - 1];
|
|
72
|
+
if (!full && last && !last.full) last.indices.push(index);
|
|
73
|
+
else runs.push({ full: Boolean(full), indices: [index] });
|
|
74
|
+
});
|
|
75
|
+
return runs;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Which props `<FormField>` hands a control that frames its own label: each of
|
|
80
|
+
* its own, unless the control already sets that prop itself.
|
|
81
|
+
*
|
|
82
|
+
* @param {object} field — the FormField's `label`, `required`, `helper`, `error`.
|
|
83
|
+
* @param {object} own — the control's props.
|
|
84
|
+
*/
|
|
85
|
+
export function handedFieldProps(field, own) {
|
|
86
|
+
const handed = {};
|
|
87
|
+
// A prop the control sets itself, even `disabled={false}`, wins.
|
|
88
|
+
for (const key of ["label", "required", "helper", "error", "disabled", "testID"]) {
|
|
89
|
+
if (own[key] === undefined && field[key] !== undefined) handed[key] = field[key];
|
|
90
|
+
}
|
|
91
|
+
return handed;
|
|
92
|
+
}
|