@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
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,
@@ -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
- onOpen: () => setOpen(true),
144
- extraProps: { "aria-haspopup": "menu", "aria-expanded": open },
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 });
@@ -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
  }