@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
package/dist/index.js
CHANGED
|
@@ -112,6 +112,10 @@ export {
|
|
|
112
112
|
widthTier,
|
|
113
113
|
WIDE_WIDTH_PX,
|
|
114
114
|
} from "./container-width.js";
|
|
115
|
+
// Balanced lines of cells sized from the grid's own measured width.
|
|
116
|
+
export { Grid } from "./grid.js";
|
|
117
|
+
// A form laid out in width tiers: fields, paired rows, sections, actions.
|
|
118
|
+
export { Form, FieldRow, FormSection, FormActions, FormField } from "./form.js";
|
|
115
119
|
// REQ-WSDK-PLATFORM §6 — Tier A hooks. Each ships in a per-platform file
|
|
116
120
|
// (./clipboard.js / .native.js, ./toast.js / .native.js); index.js picks
|
|
117
121
|
// the web variant and index.native.js picks the native variant.
|
|
@@ -237,6 +241,8 @@ export {
|
|
|
237
241
|
export { insertAtCaret, pushRecentEmoji } from "./emoji-picker-tokens.js";
|
|
238
242
|
// Themed list building blocks; rows follow the theme's `list` scope.
|
|
239
243
|
export { List, ListRow, ListHeader, EmptyState } from "./list.js";
|
|
244
|
+
// A record list in three width tiers: stacked cards, cards, sticky-header columns.
|
|
245
|
+
export { DataTable } from "./data-table.js";
|
|
240
246
|
// Themed detail parts; the stock Badge/Avatar/Progress/Stat/Divider wrap these.
|
|
241
247
|
export { Badge, Avatar, Progress, Stat, Divider } from "./detail-parts.js";
|
|
242
248
|
export { initialsFor } from "./initials.js";
|
|
@@ -280,6 +286,10 @@ export { Checkbox } from "./checkbox.js";
|
|
|
280
286
|
export { Switch } from "./switch.js";
|
|
281
287
|
export { Select } from "./select.js";
|
|
282
288
|
export { SearchField } from "./search-field.js";
|
|
289
|
+
// A searchable `<Select>` of users, groups or one table's records.
|
|
290
|
+
export { PeoplePicker } from "./people-picker.js";
|
|
291
|
+
// Search plus facet dropdowns, one toolbar row or a sheet by width.
|
|
292
|
+
export { FilterBar } from "./filter-bar.js";
|
|
283
293
|
// Pick one of a short visible list; `muted` scope, or `tabs` when segmented.
|
|
284
294
|
export { RadioGroup } from "./radio-group.js";
|
|
285
295
|
// Space for a control whose role is not a button; a no-op on native.
|
|
@@ -296,10 +306,15 @@ export { Modal } from "./modal.js";
|
|
|
296
306
|
export { ConfirmDialog } from "./confirm-dialog.js";
|
|
297
307
|
// Themed filter chips, tags and pills; follow the theme's `chip` scope.
|
|
298
308
|
export { Chip, ChipGroup } from "./chip.js";
|
|
309
|
+
// Themed bar, line, area and donut charts; follow the theme's `chart` scope.
|
|
310
|
+
export { Chart } from "./chart.js";
|
|
311
|
+
export { chartColors } from "./chart-tokens.js";
|
|
299
312
|
// A themed file chooser on `<FilePicker>`; follows the theme's `muted` scope.
|
|
300
313
|
export { FileUpload } from "./file-upload.js";
|
|
301
314
|
// Anchored actions, content and labels; follow the theme's `popover` scope.
|
|
302
315
|
export { Menu, Popover, Tooltip } from "./popover.js";
|
|
316
|
+
// Rows reordered and cards moved between columns, by drag or by move options.
|
|
317
|
+
export { SortableList, Board } from "./sortable.js";
|
|
303
318
|
// The semantic text tiers, shared with the built-in widgets.
|
|
304
319
|
export {
|
|
305
320
|
textRoleStyle,
|
package/dist/index.native.js
CHANGED
|
@@ -112,6 +112,10 @@ export {
|
|
|
112
112
|
widthTier,
|
|
113
113
|
WIDE_WIDTH_PX,
|
|
114
114
|
} from "./container-width.js";
|
|
115
|
+
// Balanced lines of cells sized from the grid's own measured width.
|
|
116
|
+
export { Grid } from "./grid.native.js";
|
|
117
|
+
// A form laid out in width tiers: fields, paired rows, sections, actions.
|
|
118
|
+
export { Form, FieldRow, FormSection, FormActions, FormField } from "./form.native.js";
|
|
115
119
|
// REQ-WSDK-PLATFORM §6 — Tier A hooks (native variants).
|
|
116
120
|
export { useClipboard, ClipboardError } from "./clipboard.native.js";
|
|
117
121
|
export { useToast } from "./toast.native.js";
|
|
@@ -230,6 +234,8 @@ export {
|
|
|
230
234
|
export { insertAtCaret, pushRecentEmoji } from "./emoji-picker-tokens.js";
|
|
231
235
|
// Themed list building blocks; rows follow the theme's `list` scope.
|
|
232
236
|
export { List, ListRow, ListHeader, EmptyState } from "./list.native.js";
|
|
237
|
+
// A record list in three width tiers: stacked cards, cards, sticky-header columns.
|
|
238
|
+
export { DataTable } from "./data-table.native.js";
|
|
233
239
|
// Themed detail parts; the stock Badge/Avatar/Progress/Stat/Divider wrap these.
|
|
234
240
|
export { Badge, Avatar, Progress, Stat, Divider } from "./detail-parts.native.js";
|
|
235
241
|
export { initialsFor } from "./initials.js";
|
|
@@ -273,6 +279,10 @@ export { Checkbox } from "./checkbox.native.js";
|
|
|
273
279
|
export { Switch } from "./switch.native.js";
|
|
274
280
|
export { Select } from "./select.native.js";
|
|
275
281
|
export { SearchField } from "./search-field.native.js";
|
|
282
|
+
// A searchable `<Select>` of users, groups or one table's records.
|
|
283
|
+
export { PeoplePicker } from "./people-picker.native.js";
|
|
284
|
+
// Search plus facet dropdowns, one toolbar row or a sheet by width.
|
|
285
|
+
export { FilterBar } from "./filter-bar.native.js";
|
|
276
286
|
// Pick one of a short visible list; `muted` scope, or `tabs` when segmented.
|
|
277
287
|
export { RadioGroup } from "./radio-group.native.js";
|
|
278
288
|
// Space for a control whose role is not a button; a no-op on native.
|
|
@@ -289,10 +299,15 @@ export { Modal } from "./modal.native.js";
|
|
|
289
299
|
export { ConfirmDialog } from "./confirm-dialog.native.js";
|
|
290
300
|
// Themed filter chips, tags and pills; follow the theme's `chip` scope.
|
|
291
301
|
export { Chip, ChipGroup } from "./chip.native.js";
|
|
302
|
+
// Themed bar, line, area and donut charts; follow the theme's `chart` scope.
|
|
303
|
+
export { Chart } from "./chart.native.js";
|
|
304
|
+
export { chartColors } from "./chart-tokens.js";
|
|
292
305
|
// A themed file chooser on `<FilePicker>`; follows the theme's `muted` scope.
|
|
293
306
|
export { FileUpload } from "./file-upload.native.js";
|
|
294
307
|
// Anchored actions, content and labels; follow the theme's `popover` scope.
|
|
295
308
|
export { Menu, Popover, Tooltip } from "./popover.native.js";
|
|
309
|
+
// Rows reordered and cards moved between columns, by drag or by move options.
|
|
310
|
+
export { SortableList, Board } from "./sortable.native.js";
|
|
296
311
|
// The semantic text tiers, shared with the built-in widgets.
|
|
297
312
|
export {
|
|
298
313
|
textRoleStyle,
|
package/dist/menu-view.js
CHANGED
|
@@ -140,8 +140,11 @@ export function makeMenu(rn, AnchoredSurface) {
|
|
|
140
140
|
null,
|
|
141
141
|
triggerWith(trigger, {
|
|
142
142
|
anchorRef,
|
|
143
|
-
|
|
144
|
-
|
|
143
|
+
// An empty menu never opens: expanded with nothing shown misleads a reader.
|
|
144
|
+
onOpen: () => {
|
|
145
|
+
if (list.length > 0) setOpen(true);
|
|
146
|
+
},
|
|
147
|
+
extraProps: { "aria-haspopup": "menu", "aria-expanded": open && list.length > 0 },
|
|
145
148
|
}),
|
|
146
149
|
h(
|
|
147
150
|
AnchoredSurface,
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
// The `<PeoplePicker>` rules that need no renderer: which source it reads,
|
|
2
|
+
// the query each source is asked, how a row is named, and which saved values
|
|
3
|
+
// still need a name. Both hosts share them, and the node suite tests them.
|
|
4
|
+
|
|
5
|
+
export const PEOPLE_PICKER_SOURCES = Object.freeze(["users", "groups", "records"]);
|
|
6
|
+
export const DEFAULT_PEOPLE_PAGE_SIZE = 50;
|
|
7
|
+
// The directory resolves at most this many ids per request.
|
|
8
|
+
export const MAX_RESOLVE_IDS = 100;
|
|
9
|
+
|
|
10
|
+
/** A `source` prop as the picker reads it; anything unknown is "users". */
|
|
11
|
+
export function pickerSource(source) {
|
|
12
|
+
return PEOPLE_PICKER_SOURCES.includes(source) ? source : "users";
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function isId(value) {
|
|
16
|
+
return (typeof value === "string" && value !== "") || typeof value === "number";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function text(value) {
|
|
20
|
+
if (typeof value === "number") return String(value);
|
|
21
|
+
if (typeof value !== "string") return "";
|
|
22
|
+
return value.trim();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The page query a source's hook is asked: the typed search sent to the
|
|
27
|
+
* server, never filtered from one page here.
|
|
28
|
+
*
|
|
29
|
+
* @param {"users"|"groups"|"records"} source
|
|
30
|
+
* @param {{query?: string, pageSize?: number, groupId?: string, filter?: object}} own
|
|
31
|
+
*/
|
|
32
|
+
export function pageQuery(source, { query, pageSize, groupId, filter } = {}) {
|
|
33
|
+
const q = text(query);
|
|
34
|
+
const limit =
|
|
35
|
+
Number.isInteger(pageSize) && pageSize > 0 ? Math.min(pageSize, 200) : DEFAULT_PEOPLE_PAGE_SIZE;
|
|
36
|
+
const out = { ...(q ? { q } : null), limit };
|
|
37
|
+
if (source === "users" && isId(groupId)) out.group_id = groupId;
|
|
38
|
+
if (source === "records" && filter && typeof filter === "object") out.filter = filter;
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Schema columns arrive as wire `data_type` / `is_identification`; a
|
|
43
|
+
// manifest-derived column carries `dataType` / `isIdentification`, and a
|
|
44
|
+
// column being written names the flag `is_identification_column`.
|
|
45
|
+
function columnType(column) {
|
|
46
|
+
return column.data_type ?? column.dataType;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function isIdentificationColumn(column) {
|
|
50
|
+
return Boolean(column.is_identification ?? column.isIdentification ?? column.is_identification_column);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The column a record is named by: the one given, else the table's
|
|
55
|
+
* identification column, else its first text column, else none.
|
|
56
|
+
*
|
|
57
|
+
* @param {{columns?: Array<{name: string, data_type?: string, dataType?: string,
|
|
58
|
+
* is_identification?: boolean, isIdentification?: boolean}>}|null} schema
|
|
59
|
+
* @param {string} [own]
|
|
60
|
+
*/
|
|
61
|
+
export function recordLabelColumn(schema, own) {
|
|
62
|
+
if (typeof own === "string" && own !== "") return own;
|
|
63
|
+
const columns = (schema && Array.isArray(schema.columns) ? schema.columns : []).filter(Boolean);
|
|
64
|
+
const named = columns.find(isIdentificationColumn);
|
|
65
|
+
if (named) return named.name;
|
|
66
|
+
const textual = columns.find((column) => ["STRING", "TEXT"].includes(columnType(column)));
|
|
67
|
+
return textual ? textual.name : null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* A row's name: a user's name or email, a group's name, a record's label
|
|
72
|
+
* column. A row with none is `unknownText`, never its id.
|
|
73
|
+
*
|
|
74
|
+
* @param {object} row
|
|
75
|
+
* @param {{source: string, labelColumn?: string|null, unknownText: string}} how
|
|
76
|
+
*/
|
|
77
|
+
export function rowLabel(row, { source, labelColumn, unknownText }) {
|
|
78
|
+
if (!row || typeof row !== "object") return unknownText;
|
|
79
|
+
if (source === "records") {
|
|
80
|
+
const cell = labelColumn ? row[labelColumn] : undefined;
|
|
81
|
+
// A relation cell is hydrated as `{ id, label }`.
|
|
82
|
+
const shown = cell && typeof cell === "object" ? text(cell.label) : text(cell);
|
|
83
|
+
return shown || unknownText;
|
|
84
|
+
}
|
|
85
|
+
if (source === "users") return text(row.name) || text(row.email) || unknownText;
|
|
86
|
+
return text(row.name) || unknownText;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The rows a page offers: each with an id, none of `excludeIds`. */
|
|
90
|
+
export function offeredRows(rows, excludeIds) {
|
|
91
|
+
const excluded = new Set(Array.isArray(excludeIds) ? excludeIds.map(String) : []);
|
|
92
|
+
return (Array.isArray(rows) ? rows : []).filter(
|
|
93
|
+
(row) => row && isId(row.id) && !excluded.has(String(row.id)),
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** A `value` or `values` prop as the list of picked ids. */
|
|
98
|
+
export function pickedIds({ multiple, value, values }) {
|
|
99
|
+
if (multiple) return (Array.isArray(values) ? values : []).filter(isId);
|
|
100
|
+
return isId(value) ? [value] : [];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The picked ids no row has named and no lookup has tried yet: each is asked
|
|
105
|
+
* for once, so a deleted person is not looked up on every render.
|
|
106
|
+
*
|
|
107
|
+
* @param {Array<string|number>} ids
|
|
108
|
+
* @param {Map<string, object>} knownRows — id → row.
|
|
109
|
+
* @param {Set<string>} attempted — ids already looked up.
|
|
110
|
+
*/
|
|
111
|
+
export function unresolvedIds(ids, knownRows, attempted) {
|
|
112
|
+
return (Array.isArray(ids) ? ids : []).filter(
|
|
113
|
+
(id) => !knownRows.has(String(id)) && !attempted.has(String(id)),
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Split saved ids into those a users or groups lookup can ask for and those
|
|
121
|
+
* it cannot: the directory refuses a whole batch over one id that is not a
|
|
122
|
+
* UUID, so such an id is named unknown at once instead. Record ids are each
|
|
123
|
+
* read alone, so every one is asked for.
|
|
124
|
+
*
|
|
125
|
+
* @param {Array<string|number>} ids
|
|
126
|
+
* @param {"users"|"groups"|"records"} source
|
|
127
|
+
*/
|
|
128
|
+
export function lookupableIds(ids, source) {
|
|
129
|
+
const list = Array.isArray(ids) ? ids : [];
|
|
130
|
+
if (source === "records") return { lookup: list, unknown: [] };
|
|
131
|
+
const lookup = [];
|
|
132
|
+
const unknown = [];
|
|
133
|
+
for (const id of list) (typeof id === "string" && UUID.test(id) ? lookup : unknown).push(id);
|
|
134
|
+
return { lookup, unknown };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The lookups in flight, with `ids` that none of them holds yet added as new
|
|
139
|
+
* batches. A batch keeps the key it was given, so an id resolving elsewhere
|
|
140
|
+
* never restarts a lookup still out. Returns `queue` itself when nothing is new.
|
|
141
|
+
*
|
|
142
|
+
* @param {{pending: Array<{key: number, ids: Array<string|number>}>, next: number}} queue
|
|
143
|
+
* @param {Array<string|number>} ids
|
|
144
|
+
* @param {number} [size]
|
|
145
|
+
*/
|
|
146
|
+
export function queueLookups(queue, ids, size = MAX_RESOLVE_IDS) {
|
|
147
|
+
const out = new Set(queue.pending.flatMap((batch) => batch.ids.map(String)));
|
|
148
|
+
const fresh = (Array.isArray(ids) ? ids : []).filter((id) => !out.has(String(id)));
|
|
149
|
+
if (fresh.length === 0) return queue;
|
|
150
|
+
const added = batches(fresh, size).map((batch, i) => ({ key: queue.next + i, ids: batch }));
|
|
151
|
+
return { pending: [...queue.pending, ...added], next: queue.next + added.length };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** `queue` without the lookup `key`. */
|
|
155
|
+
export function settleLookup(queue, key) {
|
|
156
|
+
return { ...queue, pending: queue.pending.filter((batch) => batch.key !== key) };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Whether a page hook's result answers the query it was last asked. After the
|
|
161
|
+
* query changes, the hook still holds the previous answer until it starts
|
|
162
|
+
* loading or returns new rows, so that answer is not reported as this one.
|
|
163
|
+
*
|
|
164
|
+
* @param {{queryKey: string|null, rows: unknown, error: unknown}|null} last — what was last reported.
|
|
165
|
+
* @param {{queryKey: string|null, rows: unknown, error: unknown, loading: boolean}} now
|
|
166
|
+
*/
|
|
167
|
+
export function answersQuery(last, now) {
|
|
168
|
+
if (!last || last.queryKey === now.queryKey) return true;
|
|
169
|
+
return now.loading || now.rows !== last.rows || now.error !== last.error;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** `ids` in batches of at most `size`. */
|
|
173
|
+
export function batches(ids, size = MAX_RESOLVE_IDS) {
|
|
174
|
+
const list = Array.isArray(ids) ? ids : [];
|
|
175
|
+
const out = [];
|
|
176
|
+
for (let start = 0; start < list.length; start += size) out.push(list.slice(start, start + size));
|
|
177
|
+
return out;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* What the trigger shows for each picked id: its row's name, "loading" while
|
|
182
|
+
* the lookup is out, `unknownText` once it found nothing. Never the raw id.
|
|
183
|
+
*
|
|
184
|
+
* @param {Array<string|number>} ids
|
|
185
|
+
* @param {{knownRows: Map<string, object>, attempted: Set<string>,
|
|
186
|
+
* nameOf: (row: object) => string, loadingText: string, unknownText: string}} how
|
|
187
|
+
*/
|
|
188
|
+
export function pickedLabels(ids, { knownRows, attempted, nameOf, loadingText, unknownText }) {
|
|
189
|
+
return (Array.isArray(ids) ? ids : []).map((id) => {
|
|
190
|
+
const row = knownRows.get(String(id));
|
|
191
|
+
if (row) return { value: id, label: nameOf(row), row };
|
|
192
|
+
return { value: id, label: attempted.has(String(id)) ? unknownText : loadingText };
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** `knownRows` with every row in `rows` added by id. */
|
|
197
|
+
export function rememberRows(knownRows, rows) {
|
|
198
|
+
const next = new Map(knownRows);
|
|
199
|
+
for (const row of Array.isArray(rows) ? rows : []) {
|
|
200
|
+
if (row && isId(row.id)) next.set(String(row.id), row);
|
|
201
|
+
}
|
|
202
|
+
return next;
|
|
203
|
+
}
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
// The ONE implementation behind the `<PeoplePicker>` SDK primitive: a
|
|
2
|
+
// searchable `<Select>` over the workspace's users, its groups, or one
|
|
3
|
+
// table's records, read through the SDK's own directory and datastore hooks.
|
|
4
|
+
// The platform bindings (`people-picker.js` / `people-picker.native.js`)
|
|
5
|
+
// differ only in which host's `<Select>` and `<Avatar>` they pass.
|
|
6
|
+
//
|
|
7
|
+
// Nothing is fetched until the sheet first opens; each pause in typing asks
|
|
8
|
+
// the server again. A saved choice the page does not hold is looked up once
|
|
9
|
+
// by id, so the trigger names it and never shows the raw id.
|
|
10
|
+
|
|
11
|
+
import React from "react";
|
|
12
|
+
import {
|
|
13
|
+
useDatastoreQuery,
|
|
14
|
+
useDatastoreRecord,
|
|
15
|
+
useDatastoreSchema,
|
|
16
|
+
useDirectory,
|
|
17
|
+
useGroups,
|
|
18
|
+
} from "./hooks.js";
|
|
19
|
+
import {
|
|
20
|
+
answersQuery,
|
|
21
|
+
lookupableIds,
|
|
22
|
+
offeredRows,
|
|
23
|
+
pageQuery,
|
|
24
|
+
pickedIds,
|
|
25
|
+
pickedLabels,
|
|
26
|
+
pickerSource,
|
|
27
|
+
queueLookups,
|
|
28
|
+
recordLabelColumn,
|
|
29
|
+
rememberRows,
|
|
30
|
+
rowLabel,
|
|
31
|
+
settleLookup,
|
|
32
|
+
unresolvedIds,
|
|
33
|
+
} from "./people-picker-logic.js";
|
|
34
|
+
import { DRAWS_FIELD_FRAME } from "./form-control-logic.js";
|
|
35
|
+
|
|
36
|
+
const h = React.createElement;
|
|
37
|
+
const NO_ROWS = Object.freeze([]);
|
|
38
|
+
const NO_LOOKUPS = Object.freeze({ pending: [], next: 0 });
|
|
39
|
+
|
|
40
|
+
function serialize(query) {
|
|
41
|
+
try {
|
|
42
|
+
return JSON.stringify(query);
|
|
43
|
+
} catch (_e) {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Bind the shared people picker to one host's `<Select>` and `<Avatar>`.
|
|
50
|
+
*
|
|
51
|
+
* @param {{View: any, Select: any, Avatar: any}} rn
|
|
52
|
+
*/
|
|
53
|
+
export function makePeoplePicker(rn) {
|
|
54
|
+
const { View, Select, Avatar } = rn;
|
|
55
|
+
|
|
56
|
+
// Hands one hook result up once it answers the current query.
|
|
57
|
+
function useReport(onReport, query, rows, loading, error, sent) {
|
|
58
|
+
const queryKey = serialize(query);
|
|
59
|
+
const last = React.useRef(null);
|
|
60
|
+
React.useEffect(() => {
|
|
61
|
+
const now = { queryKey, rows, error, loading };
|
|
62
|
+
if (!answersQuery(last.current, now)) return;
|
|
63
|
+
last.current = now;
|
|
64
|
+
onReport({ rows: Array.isArray(rows) ? rows : NO_ROWS, loading, error: error || null, query: sent });
|
|
65
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
66
|
+
}, [queryKey, rows, loading, error, sent]);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function UsersPage({ query, sent, onReport }) {
|
|
70
|
+
const { users, loading, error } = useDirectory(query);
|
|
71
|
+
useReport(onReport, query, users, loading, error, sent);
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function GroupsPage({ query, sent, onReport }) {
|
|
76
|
+
const { groups, loading, error } = useGroups(query);
|
|
77
|
+
useReport(onReport, query, groups, loading, error, sent);
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function RecordsPage({ table, query, sent, onReport }) {
|
|
82
|
+
const { data, loading, fetching, error } = useDatastoreQuery(table, query);
|
|
83
|
+
useReport(onReport, query, data, loading || fetching, error, sent);
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// A lookup reports once it settles; a failed one names nothing.
|
|
88
|
+
function useResolved(onResolved, lookup, settled, rows) {
|
|
89
|
+
React.useEffect(() => {
|
|
90
|
+
if (settled) onResolved(lookup, rows || NO_ROWS);
|
|
91
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
92
|
+
}, [settled]);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function UsersLookup({ lookup, onResolved }) {
|
|
96
|
+
const { users, loading, error } = useDirectory({ ids: lookup.ids, limit: lookup.ids.length });
|
|
97
|
+
useResolved(onResolved, lookup, !loading, error ? null : users);
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function GroupsLookup({ lookup, onResolved }) {
|
|
102
|
+
const { groups, loading, error } = useGroups({ ids: lookup.ids, limit: lookup.ids.length });
|
|
103
|
+
useResolved(onResolved, lookup, !loading, error ? null : groups);
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// The datastore list filters no system `id`, so each saved record is read alone.
|
|
108
|
+
function RecordLookup({ table, lookup, onResolved }) {
|
|
109
|
+
const { data, loading, error } = useDatastoreRecord(table, lookup.ids[0]);
|
|
110
|
+
useResolved(onResolved, lookup, !loading, error || !data ? null : [data]);
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const LOOKUPS = { users: UsersLookup, groups: GroupsLookup, records: RecordLookup };
|
|
115
|
+
|
|
116
|
+
function avatarFor(source, row, label, avatarColumn) {
|
|
117
|
+
if (source === "records") {
|
|
118
|
+
if (!avatarColumn) return undefined;
|
|
119
|
+
const fileId = typeof row[avatarColumn] === "string" ? row[avatarColumn] : null;
|
|
120
|
+
return h(Avatar, { name: label, fileId, seed: String(row.id), size: "sm" });
|
|
121
|
+
}
|
|
122
|
+
return h(Avatar, {
|
|
123
|
+
name: label,
|
|
124
|
+
seed: String(row.id),
|
|
125
|
+
size: "sm",
|
|
126
|
+
shape: source === "groups" ? "rounded" : "circle",
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// The row's name is its text; the picture only decorates it.
|
|
131
|
+
function decoration(node) {
|
|
132
|
+
if (!node) return undefined;
|
|
133
|
+
return h(
|
|
134
|
+
View,
|
|
135
|
+
{
|
|
136
|
+
"aria-hidden": true,
|
|
137
|
+
importantForAccessibility: "no-hide-descendants",
|
|
138
|
+
accessibilityElementsHidden: true,
|
|
139
|
+
},
|
|
140
|
+
node,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function SourcePicker(props) {
|
|
145
|
+
const {
|
|
146
|
+
source,
|
|
147
|
+
table,
|
|
148
|
+
labelColumn,
|
|
149
|
+
avatarColumn,
|
|
150
|
+
groupId,
|
|
151
|
+
filter,
|
|
152
|
+
excludeIds,
|
|
153
|
+
pageSize,
|
|
154
|
+
showAvatars = true,
|
|
155
|
+
unknownText = "Unknown",
|
|
156
|
+
loadingText = "Loading…",
|
|
157
|
+
multiple = false,
|
|
158
|
+
value,
|
|
159
|
+
values,
|
|
160
|
+
forwardedRef,
|
|
161
|
+
...rest
|
|
162
|
+
} = props;
|
|
163
|
+
const [sent, setSent] = React.useState(null);
|
|
164
|
+
const [page, setPage] = React.useState({ rows: NO_ROWS, loading: false, error: null, query: null });
|
|
165
|
+
const [knownRows, setKnownRows] = React.useState(() => new Map());
|
|
166
|
+
const [attempted, setAttempted] = React.useState(() => new Set());
|
|
167
|
+
const [lookups, setLookups] = React.useState(NO_LOOKUPS);
|
|
168
|
+
|
|
169
|
+
const how = { source, labelColumn, unknownText };
|
|
170
|
+
const nameOf = (row) => rowLabel(row, how);
|
|
171
|
+
const toOption = (row) => {
|
|
172
|
+
const label = nameOf(row);
|
|
173
|
+
return {
|
|
174
|
+
value: row.id,
|
|
175
|
+
label,
|
|
176
|
+
...(showAvatars ? { leading: decoration(avatarFor(source, row, label, avatarColumn)) } : null),
|
|
177
|
+
};
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
const onReport = React.useCallback((result) => {
|
|
181
|
+
setPage(result);
|
|
182
|
+
if (result.rows.length) setKnownRows((own) => rememberRows(own, result.rows));
|
|
183
|
+
}, []);
|
|
184
|
+
const onResolved = React.useCallback((lookup, rows) => {
|
|
185
|
+
setKnownRows((own) => rememberRows(own, rows));
|
|
186
|
+
setAttempted((own) => new Set([...own, ...lookup.ids.map(String)]));
|
|
187
|
+
setLookups((own) => settleLookup(own, lookup.key));
|
|
188
|
+
}, []);
|
|
189
|
+
|
|
190
|
+
// A record source with no table bound yet has nothing to read.
|
|
191
|
+
const readable = source !== "records" || (typeof table === "string" && table !== "");
|
|
192
|
+
const ids = pickedIds({ multiple, value, values });
|
|
193
|
+
const missing = readable ? unresolvedIds(ids, knownRows, attempted) : [];
|
|
194
|
+
const { lookup, unknown } = lookupableIds(missing, source);
|
|
195
|
+
const named = unknown.length ? new Set([...attempted, ...unknown.map(String)]) : attempted;
|
|
196
|
+
const query = sent === null || !readable ? null : pageQuery(source, { query: sent, pageSize, groupId, filter });
|
|
197
|
+
const options = offeredRows(page.rows, excludeIds).map(toOption);
|
|
198
|
+
const selectedOptions = pickedLabels(ids, { knownRows, attempted: named, nameOf, loadingText, unknownText }).map(
|
|
199
|
+
(own) => (own.row ? toOption(own.row) : own),
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
const lookupKey = lookup.join(",");
|
|
203
|
+
React.useEffect(() => {
|
|
204
|
+
if (lookup.length) setLookups((own) => queueLookups(own, lookup, source === "records" ? 1 : undefined));
|
|
205
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
206
|
+
}, [lookupKey]);
|
|
207
|
+
|
|
208
|
+
const pages = { users: UsersPage, groups: GroupsPage, records: RecordsPage };
|
|
209
|
+
const Lookup = LOOKUPS[source];
|
|
210
|
+
|
|
211
|
+
return h(
|
|
212
|
+
React.Fragment,
|
|
213
|
+
null,
|
|
214
|
+
h(Select, {
|
|
215
|
+
...rest,
|
|
216
|
+
ref: forwardedRef,
|
|
217
|
+
multiple,
|
|
218
|
+
value,
|
|
219
|
+
values,
|
|
220
|
+
options,
|
|
221
|
+
selectedOptions,
|
|
222
|
+
onSearch: setSent,
|
|
223
|
+
loading: sent !== null && (page.query !== sent || page.loading),
|
|
224
|
+
optionsError: !!page.error,
|
|
225
|
+
loadingText,
|
|
226
|
+
}),
|
|
227
|
+
query ? h(pages[source], { table, query, sent, onReport }) : null,
|
|
228
|
+
...lookups.pending.map((own) => h(Lookup, { key: own.key, table, lookup: own, onResolved })),
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// A record source names rows by a column, so it waits for the table's schema.
|
|
233
|
+
function RecordsPicker(props) {
|
|
234
|
+
const { schema } = useDatastoreSchema(props.table || null);
|
|
235
|
+
return h(SourcePicker, { ...props, labelColumn: recordLabelColumn(schema, props.labelColumn) });
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const PeoplePicker = React.forwardRef(function PeoplePicker(props, ref) {
|
|
239
|
+
const source = pickerSource(props.source);
|
|
240
|
+
const own = { ...props, source, forwardedRef: ref };
|
|
241
|
+
// Changing the source starts a fresh picker rather than mixing rows.
|
|
242
|
+
if (source === "records") return h(RecordsPicker, { ...own, key: `records:${props.table}` });
|
|
243
|
+
return h(SourcePicker, { ...own, key: source });
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
PeoplePicker.displayName = "PeoplePicker";
|
|
247
|
+
// Its `<Select>` frames the label, so `required` passes straight through.
|
|
248
|
+
PeoplePicker[DRAWS_FIELD_FRAME] = true;
|
|
249
|
+
return PeoplePicker;
|
|
250
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// `<PeoplePicker>` (web). Binds ./people-picker-view.js to the web `<Select>`
|
|
2
|
+
// and `<Avatar>`; see ./primitives.js for why not the bare specifier.
|
|
3
|
+
|
|
4
|
+
import * as ReactNative from "react-native-web";
|
|
5
|
+
import { makePeoplePicker } from "./people-picker-view.js";
|
|
6
|
+
import { Select } from "./select.js";
|
|
7
|
+
import { Avatar } from "./detail-parts.js";
|
|
8
|
+
|
|
9
|
+
const { View } = ReactNative;
|
|
10
|
+
|
|
11
|
+
export const PeoplePicker = makePeoplePicker({ View, Select, Avatar });
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// `<PeoplePicker>` (native). Binds ./people-picker-view.js to the native
|
|
2
|
+
// `<Select>` and `<Avatar>`; only the imports differ from ./people-picker.js.
|
|
3
|
+
|
|
4
|
+
import { View } from "react-native";
|
|
5
|
+
import { makePeoplePicker } from "./people-picker-view.js";
|
|
6
|
+
import { Select } from "./select.native.js";
|
|
7
|
+
import { Avatar } from "./detail-parts.native.js";
|
|
8
|
+
|
|
9
|
+
export const PeoplePicker = makePeoplePicker({ View, Select, Avatar });
|
package/dist/radio-group-view.js
CHANGED
|
@@ -6,11 +6,12 @@
|
|
|
6
6
|
// an icon or a tab role. This one has the radio role and the app's look.
|
|
7
7
|
|
|
8
8
|
import React from "react";
|
|
9
|
-
import { useHostTheme } from "./hooks.js";
|
|
9
|
+
import { useHostSharedText, useHostTheme } from "./hooks.js";
|
|
10
10
|
import { DEFAULT_THEME_TOKENS } from "./_theme-tokens.js";
|
|
11
11
|
import { Icon } from "./icon.js";
|
|
12
12
|
import { CHECKBOX_SIZE, CONTROL_MIN_TARGET, focusHaloStyle } from "./form-control-tokens.js";
|
|
13
13
|
import {
|
|
14
|
+
DRAWS_FIELD_FRAME,
|
|
14
15
|
errorMessage,
|
|
15
16
|
hasNote,
|
|
16
17
|
isShown,
|
|
@@ -165,11 +166,13 @@ export function makeRadioGroup(rn) {
|
|
|
165
166
|
helper,
|
|
166
167
|
error,
|
|
167
168
|
disabled = false,
|
|
169
|
+
required = false,
|
|
168
170
|
accessibilityLabel,
|
|
169
171
|
style,
|
|
170
172
|
containerStyle,
|
|
171
173
|
...rest
|
|
172
174
|
} = props;
|
|
175
|
+
const requiredWord = useHostSharedText("required", "Required");
|
|
173
176
|
const theme = useHostTheme() || DEFAULT_THEME_TOKENS;
|
|
174
177
|
const t = resolveRadioGroupTokens(theme);
|
|
175
178
|
const kind = radioGroupVariant(variant);
|
|
@@ -236,7 +239,7 @@ export function makeRadioGroup(rn) {
|
|
|
236
239
|
...rest,
|
|
237
240
|
ref,
|
|
238
241
|
accessibilityRole: "radiogroup",
|
|
239
|
-
accessibilityLabel: spokenLabel(accessibilityLabel, label),
|
|
242
|
+
accessibilityLabel: spokenLabel(accessibilityLabel, label, required ? requiredWord : null),
|
|
240
243
|
"aria-disabled": disabled,
|
|
241
244
|
...(invalid ? { "aria-invalid": true } : null),
|
|
242
245
|
...(noted ? { "aria-describedby": noteId } : null),
|
|
@@ -252,11 +255,12 @@ export function makeRadioGroup(rn) {
|
|
|
252
255
|
if (!framed) return group;
|
|
253
256
|
return h(
|
|
254
257
|
FieldFrame,
|
|
255
|
-
{ t, label, helper, error: errorMessage(error), disabled, noteId, style: containerStyle },
|
|
258
|
+
{ t, label, helper, error: errorMessage(error), disabled, required, noteId, style: containerStyle },
|
|
256
259
|
group,
|
|
257
260
|
);
|
|
258
261
|
});
|
|
259
262
|
|
|
260
263
|
RadioGroup.displayName = "RadioGroup";
|
|
264
|
+
RadioGroup[DRAWS_FIELD_FRAME] = true;
|
|
261
265
|
return RadioGroup;
|
|
262
266
|
}
|