@colixsystems/widget-sdk 0.246.0 → 0.247.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/dist/index.d.ts CHANGED
@@ -2406,6 +2406,60 @@ export const WIDE_WIDTH_PX: number;
2406
2406
  */
2407
2407
  export function widthTier(width: number): "narrow" | "medium" | "wide";
2408
2408
 
2409
+ /** How a `<Grid>` splits its cells into lines. */
2410
+ export type GridBalance = "cards" | "tiles";
2411
+
2412
+ /** A theme spacing name, or a number of pixels. */
2413
+ export type GridGap = "xs" | "sm" | "md" | "lg" | "xl" | number;
2414
+
2415
+ /**
2416
+ * Lays its children out in balanced lines of cells, sized from the grid's own
2417
+ * measured width, on the web Player and the native export alike. It measures
2418
+ * an inner box with no padding, so a `style` padding is not counted twice.
2419
+ *
2420
+ * - `balance="cards"` (default): as many cells per line as fit at
2421
+ * `minCellWidth` (240 when unset); every line but the last is full, and the
2422
+ * last is padded with empty cells so its cards keep the width of the lines
2423
+ * above. 6 that fit 5 are 3 + 3.
2424
+ * - `balance="tiles"`: the remainder spreads over every line and each line
2425
+ * stretches, so 5 tiles at 4 per line are 3 + 2, never 4 + 1. The per-line
2426
+ * ceiling is `maxColumns`, or what fits at `minCellWidth`, or else 4 / 3 / 2
2427
+ * for the wide / medium / narrow width tier.
2428
+ * - `columns`: exactly that many cells per line, the last line padded.
2429
+ * - `cell={{ min, max }}`: definite-size cells (calendar days, seats,
2430
+ * swatches) as large as fit up to `max`, never below `min` (32 when unset),
2431
+ * in a block centred in the grid (a block whose `min` cells cannot fit
2432
+ * starts at the leading edge, so only its far end is cut off); with
2433
+ * `columns` a fixed count per line. `square` makes each cell as tall as it
2434
+ * is wide.
2435
+ *
2436
+ * Each child is one cell and keeps its own look; a child with `flex: 1` fills
2437
+ * its cell's height, so a line of cards ends level. Nothing is drawn until the
2438
+ * width is measured when the layout depends on it. In a row, give the grid
2439
+ * `style={{ flex: 1, minWidth: 0 }}` so it has a width to measure.
2440
+ *
2441
+ * @example
2442
+ * <Grid minCellWidth={240}>{items.map((item) => <Card key={item.id}>…</Card>)}</Grid>
2443
+ * <Grid columns={7} cell={{ min: 32, max: 64 }} square gap="xs">{days}</Grid>
2444
+ */
2445
+ export const Grid: (props: {
2446
+ children?: ReactNode;
2447
+ balance?: GridBalance;
2448
+ /** The narrowest a cell may be; the per-line count is derived from it. */
2449
+ minCellWidth?: number;
2450
+ maxColumns?: number;
2451
+ columns?: number;
2452
+ cell?: { min?: number; max: number };
2453
+ square?: boolean;
2454
+ /** Space between cells and lines; `"md"` when unset. */
2455
+ gap?: GridGap;
2456
+ /** The grid's outer box; padding here stays out of the measured width. */
2457
+ style?: any;
2458
+ testID?: string;
2459
+ /** Names the grid as one group to a screen reader. */
2460
+ accessibilityLabel?: string;
2461
+ }) => any;
2462
+
2409
2463
  /** Pass-through options for `useGeolocation().getCurrentPosition(...)`. */
2410
2464
  export interface GeolocationOptions {
2411
2465
  enableHighAccuracy?: boolean;
@@ -3140,6 +3194,8 @@ export interface AppUserGroupRow {
3140
3194
 
3141
3195
  export interface GroupsQuery {
3142
3196
  q?: string;
3197
+ /** Only these groups (1 to 100 ids): name the groups behind ids you hold. */
3198
+ ids?: string[];
3143
3199
  limit?: number;
3144
3200
  offset?: number;
3145
3201
  }
@@ -4187,12 +4243,53 @@ export interface SelectOption<V extends string | number = string | number> {
4187
4243
  label: string;
4188
4244
  disabled?: boolean;
4189
4245
  testID?: string;
4246
+ /** Drawn before the label in the sheet: an `<Avatar>`, an icon. */
4247
+ leading?: ReactNode;
4248
+ }
4249
+
4250
+ /**
4251
+ * The searchable mode of `<Select>`: a `<SearchField>` heads the sheet. Turn
4252
+ * it on with `searchable` (the options are filtered where they are held),
4253
+ * `onSearch` (you fetch per query) or async `options` (Select fetches).
4254
+ */
4255
+ export interface SelectSearchProps<V extends string | number = string | number> {
4256
+ /** Filter the given `options` by label as the person types. */
4257
+ searchable?: boolean;
4258
+ /**
4259
+ * Called with the trimmed query when the sheet opens ("") and once typing
4260
+ * pauses; answer by passing new `options` (and `loading` meanwhile).
4261
+ */
4262
+ onSearch?: (query: string) => void;
4263
+ /** The pause, in ms, before a typed query is sent. Default 250. */
4264
+ searchDebounceMs?: number;
4265
+ /** The options for the last query are on their way. */
4266
+ loading?: boolean;
4267
+ /** The last query failed; a string replaces `loadErrorText`. */
4268
+ optionsError?: boolean | string;
4269
+ /**
4270
+ * The options behind picked values the current `options` may not hold (a
4271
+ * saved choice outside the first page), so the trigger names them.
4272
+ */
4273
+ selectedOptions?: Array<SelectOption<V>>;
4274
+ /** Default "Search". */
4275
+ searchPlaceholder?: string;
4276
+ /** Shown when a query finds nothing. Default "No results". */
4277
+ noResultsText?: string;
4278
+ /** Default "Loading…". */
4279
+ loadingText?: string;
4280
+ /** Default "Could not load the options". */
4281
+ loadErrorText?: string;
4190
4282
  }
4191
4283
 
4192
4284
  /** What every `<Select>` takes, in either mode. */
4193
4285
  export type SelectBaseProps<V extends string | number = string | number> = FormControlProps &
4194
- FormFieldOverrides & {
4195
- options: Array<SelectOption<V>>;
4286
+ FormFieldOverrides &
4287
+ SelectSearchProps<V> & {
4288
+ /**
4289
+ * The options, or a function Select calls with each query (searchable
4290
+ * mode) that resolves to them; only its latest answer is shown.
4291
+ */
4292
+ options: Array<SelectOption<V>> | ((query: string) => Promise<Array<SelectOption<V>>>);
4196
4293
  /** Shown while nothing is chosen. Default "Select". */
4197
4294
  placeholder?: string;
4198
4295
  /** The sheet's heading. Defaults to a text `label`. */
@@ -4220,6 +4317,11 @@ export type SelectBaseProps<V extends string | number = string | number> = FormC
4220
4317
  * trigger shows the picked labels as small chips while they fit on its line,
4221
4318
  * else "N selected".
4222
4319
  *
4320
+ * Searchable (`searchable`, `onSearch` or async `options`), the sheet leads
4321
+ * with a `<SearchField>`, says when it is loading and when nothing matches,
4322
+ * and keeps the label of every option it has shown; its rows take the theme's
4323
+ * Lists row height and padding when they are set.
4324
+ *
4223
4325
  * @example
4224
4326
  * <Select label="Status" value={status} onValueChange={setStatus}
4225
4327
  * options={[{ value: "open", label: "Open" }, { value: "done", label: "Done" }]} />
@@ -4246,6 +4348,62 @@ export const Select: <V extends string | number = string | number>(
4246
4348
  }),
4247
4349
  ) => any;
4248
4350
 
4351
+ /** Where `<PeoplePicker>` reads its options. */
4352
+ export type PeoplePickerSource = "users" | "groups" | "records";
4353
+
4354
+ /**
4355
+ * A searchable `<Select>` of the workspace's users (`useDirectory`, scope
4356
+ * `directory.read:users`), its groups (`useGroups`, `groups.read:*`) or one
4357
+ * table's records (`useDatastoreQuery`, the table's `datastore.read` scope).
4358
+ * Nothing loads until the sheet opens; each pause in typing searches on the
4359
+ * server. A saved value the page does not hold is looked up once by id, so the
4360
+ * trigger shows its name, `loadingText` meanwhile, or `unknownText` when it is
4361
+ * gone, never the id. Every other `<Select>` prop works.
4362
+ *
4363
+ * @example
4364
+ * <PeoplePicker label="Assignee" value={assignee} onValueChange={setAssignee} />
4365
+ * <PeoplePicker source="groups" multiple label="Teams" values={teams} onValuesChange={setTeams} />
4366
+ * <PeoplePicker source="records" table={tableId} label="Customer" value={customerId}
4367
+ * onValueChange={setCustomerId} />
4368
+ */
4369
+ export const PeoplePicker: (
4370
+ props: Omit<SelectBaseProps<string>, "options" | "onSearch" | "selectedOptions" | "searchable"> & {
4371
+ /** Default "users". */
4372
+ source?: PeoplePickerSource;
4373
+ /** `source="records"`: the table id. */
4374
+ table?: string;
4375
+ /** `source="records"`: the column that names a record. Default the identification column. */
4376
+ labelColumn?: string;
4377
+ /** `source="records"`: a FILE column drawn as each row's avatar. */
4378
+ avatarColumn?: string;
4379
+ /** `source="users"`: only members of this group (a `useGroups()` row id). */
4380
+ groupId?: string;
4381
+ /** `source="records"`: narrows the rows, as `useDatastoreQuery`'s `filter`. */
4382
+ filter?: Query["filter"];
4383
+ /** Ids never offered (already members, the signed-in user). */
4384
+ excludeIds?: string[];
4385
+ /** Rows per search. Default 50, at most 200. */
4386
+ pageSize?: number;
4387
+ /** Draw an avatar before each user or group. Default true. */
4388
+ showAvatars?: boolean;
4389
+ /** A saved value whose row is gone, or a row with no name. Default "Unknown". */
4390
+ unknownText?: string;
4391
+ } & (
4392
+ | {
4393
+ multiple?: false;
4394
+ value?: string | null;
4395
+ onValueChange?: (value: string, option: SelectOption<string>) => void;
4396
+ }
4397
+ | {
4398
+ multiple: true;
4399
+ values?: string[] | null;
4400
+ onValuesChange?: (values: string[], options: Array<SelectOption<string>>) => void;
4401
+ selectedCountText?: (count: number) => string;
4402
+ doneText?: string;
4403
+ }
4404
+ ),
4405
+ ) => any;
4406
+
4249
4407
  /**
4250
4408
  * Space-key activation for a control whose role is not a button: on the web a
4251
4409
  * `Pressable` answers Space only for a button. Native ignores `onKeyDown`.
@@ -4462,6 +4620,80 @@ export const ChipGroup: (props: {
4462
4620
  testID?: string;
4463
4621
  }) => any;
4464
4622
 
4623
+ export type ChartType = "bar" | "hbar" | "line" | "area" | "donut" | "sparkline";
4624
+ export type ChartValueFormat = "number" | "compact" | "percent" | ((value: number) => string);
4625
+
4626
+ /** One series of a multi-series `<Chart>`: the key each `data` item carries its value under. */
4627
+ export interface ChartSeries {
4628
+ key: string;
4629
+ label: string;
4630
+ /** A colour of the series' own; leave unset so the theme's chart look applies. */
4631
+ color?: string;
4632
+ }
4633
+
4634
+ /**
4635
+ * Themed chart drawn with react-native-svg. Its series colours, axis and label
4636
+ * colour, gridline colour and bar corners come from the theme's `chart` scope
4637
+ * (falling back to the theme's colours), so every chart in the app matches. It
4638
+ * measures its own width, shows a `<Skeleton>` while `loading` and an
4639
+ * `<EmptyState>` when there is nothing to plot, draws a legend for a donut and
4640
+ * for several series, and gives a screen reader a summary and the values.
4641
+ *
4642
+ * @example
4643
+ * const { groups, loading } = useDatastoreAggregate(tableId, { groupBy: "city", sumColumn: "visits" });
4644
+ * <Chart type="bar" loading={loading}
4645
+ * data={groups.map((g) => ({ label: String(g.group), value: Number(g.sum) || 0 }))} />
4646
+ */
4647
+ export const Chart: (props: {
4648
+ /** Default "bar". "hbar" is horizontal bars; "sparkline" a small line with no axes. */
4649
+ type?: ChartType;
4650
+ /** `[{ label, value }]`, or with `series` one number per series key. */
4651
+ data: Array<{ label: string | number | { label: string }; [key: string]: unknown }>;
4652
+ /** Several series sharing `data`. A donut and a sparkline draw the first. */
4653
+ series?: ChartSeries[];
4654
+ /**
4655
+ * How values read on the axis, the legend and to a screen reader, grouped
4656
+ * for the app's language. Default "number". "percent" appends "%" to values
4657
+ * already in percent (42 reads "42%"); it does not multiply by 100.
4658
+ */
4659
+ valueFormat?: ChartValueFormat;
4660
+ /** The plot's height in px. Default 220, or 48 for a sparkline. */
4661
+ height?: number;
4662
+ loading?: boolean;
4663
+ /** Defaults to the shared "No results" text. */
4664
+ emptyTitle?: string;
4665
+ emptyMessage?: string;
4666
+ /** Replaces the spoken summary sentence; the values are still read after it. */
4667
+ accessibilityLabel?: string;
4668
+ /** The tail of a spoken value list cut short; default `(count) => \`and ${count} more\``. */
4669
+ moreText?: (count: number) => string;
4670
+ /** For hosts rendering outside a widget context; widgets omit it. */
4671
+ theme?: any;
4672
+ style?: any;
4673
+ testID?: string;
4674
+ }) => any;
4675
+
4676
+ /**
4677
+ * The colours `<Chart>` paints with, for a chart a widget draws itself (a
4678
+ * scatter plot, a timeline) so it matches Design > Components > Charts.
4679
+ *
4680
+ * @example
4681
+ * const colors = chartColors(useTheme());
4682
+ * <Circle fill={colors.series(0)} fillOpacity={colors.seriesOpacity(0)} />
4683
+ */
4684
+ export function chartColors(theme: any): {
4685
+ /** The six series colours, in order. */
4686
+ palette: string[];
4687
+ /** Axis line and label colour. */
4688
+ axis: string;
4689
+ /** Gridline colour. */
4690
+ grid: string;
4691
+ /** Series `index`'s colour; a seventh and later one is lighter. */
4692
+ series(index: number): string;
4693
+ /** Series `index`'s opacity: 1, below 1 for a repeat of a colour that cannot be lightened. */
4694
+ seriesOpacity(index: number): number;
4695
+ };
4696
+
4465
4697
  /**
4466
4698
  * A file as `<FilePicker>` and `<FileUpload>` hand it over: the browser's
4467
4699
  * `File` on the web, expo-file-system's `File` on the Expo export. Pass it to
package/dist/index.js CHANGED
@@ -112,6 +112,8 @@ 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";
115
117
  // REQ-WSDK-PLATFORM §6 — Tier A hooks. Each ships in a per-platform file
116
118
  // (./clipboard.js / .native.js, ./toast.js / .native.js); index.js picks
117
119
  // the web variant and index.native.js picks the native variant.
@@ -280,6 +282,8 @@ export { Checkbox } from "./checkbox.js";
280
282
  export { Switch } from "./switch.js";
281
283
  export { Select } from "./select.js";
282
284
  export { SearchField } from "./search-field.js";
285
+ // A searchable `<Select>` of users, groups or one table's records.
286
+ export { PeoplePicker } from "./people-picker.js";
283
287
  // Pick one of a short visible list; `muted` scope, or `tabs` when segmented.
284
288
  export { RadioGroup } from "./radio-group.js";
285
289
  // Space for a control whose role is not a button; a no-op on native.
@@ -296,6 +300,9 @@ export { Modal } from "./modal.js";
296
300
  export { ConfirmDialog } from "./confirm-dialog.js";
297
301
  // Themed filter chips, tags and pills; follow the theme's `chip` scope.
298
302
  export { Chip, ChipGroup } from "./chip.js";
303
+ // Themed bar, line, area and donut charts; follow the theme's `chart` scope.
304
+ export { Chart } from "./chart.js";
305
+ export { chartColors } from "./chart-tokens.js";
299
306
  // A themed file chooser on `<FilePicker>`; follows the theme's `muted` scope.
300
307
  export { FileUpload } from "./file-upload.js";
301
308
  // Anchored actions, content and labels; follow the theme's `popover` scope.
@@ -112,6 +112,8 @@ 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";
115
117
  // REQ-WSDK-PLATFORM §6 — Tier A hooks (native variants).
116
118
  export { useClipboard, ClipboardError } from "./clipboard.native.js";
117
119
  export { useToast } from "./toast.native.js";
@@ -273,6 +275,8 @@ export { Checkbox } from "./checkbox.native.js";
273
275
  export { Switch } from "./switch.native.js";
274
276
  export { Select } from "./select.native.js";
275
277
  export { SearchField } from "./search-field.native.js";
278
+ // A searchable `<Select>` of users, groups or one table's records.
279
+ export { PeoplePicker } from "./people-picker.native.js";
276
280
  // Pick one of a short visible list; `muted` scope, or `tabs` when segmented.
277
281
  export { RadioGroup } from "./radio-group.native.js";
278
282
  // Space for a control whose role is not a button; a no-op on native.
@@ -289,6 +293,9 @@ export { Modal } from "./modal.native.js";
289
293
  export { ConfirmDialog } from "./confirm-dialog.native.js";
290
294
  // Themed filter chips, tags and pills; follow the theme's `chip` scope.
291
295
  export { Chip, ChipGroup } from "./chip.native.js";
296
+ // Themed bar, line, area and donut charts; follow the theme's `chart` scope.
297
+ export { Chart } from "./chart.native.js";
298
+ export { chartColors } from "./chart-tokens.js";
292
299
  // A themed file chooser on `<FilePicker>`; follows the theme's `muted` scope.
293
300
  export { FileUpload } from "./file-upload.native.js";
294
301
  // Anchored actions, content and labels; follow the theme's `popover` scope.
@@ -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
+ }