@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.
@@ -6,6 +6,8 @@
6
6
  // `<Overlay>`, at screen level, so no card or scroll container the widget
7
7
  // sits in can clip them; a long list scrolls inside the sheet. With
8
8
  // `multiple` the same sheet ticks options into a draft that Done commits.
9
+ // Searchable, the sheet leads with a `<SearchField>`: the list filters where
10
+ // it is held, or each pause in typing asks `onSearch` / async `options`.
9
11
 
10
12
  import React from "react";
11
13
  import { useHostTheme } from "./hooks.js";
@@ -41,6 +43,18 @@ import {
41
43
  triggerSummary,
42
44
  valueList,
43
45
  } from "./select-logic.js";
46
+ import {
47
+ DEFAULT_SEARCH_DEBOUNCE_MS,
48
+ createDebouncer,
49
+ createRequestGate,
50
+ filterOptions,
51
+ knownOption,
52
+ knownOptions,
53
+ normaliseQuery,
54
+ rememberOptions,
55
+ searchRowOverrides,
56
+ sheetBody,
57
+ } from "./select-search-logic.js";
44
58
 
45
59
  const h = React.createElement;
46
60
  // Wide enough that no picked chip shrinks while it is measured.
@@ -53,10 +67,12 @@ const UNMEASURED = Object.freeze({ available: null, needed: null });
53
67
  * Bind the shared select to one platform's primitives.
54
68
  *
55
69
  * @param {{View: any, Text: any, Pressable: any, ScrollView: any, Platform: any,
56
- * Overlay: any, Button: any, Chip: any}} rn
70
+ * Overlay: any, Button: any, Chip: any, SearchField?: any, Spinner?: any}} rn
71
+ * — `SearchField` and `Spinner` are needed only by a searchable select.
57
72
  */
58
73
  export function makeSelect(rn) {
59
- const { View, Text, Pressable, ScrollView, Platform, Overlay, Button, Chip } = rn;
74
+ const { View, Text, Pressable, ScrollView, Platform, Overlay, Button, Chip, SearchField, Spinner } =
75
+ rn;
60
76
  const FieldFrame = makeFieldFrame(rn);
61
77
 
62
78
  // Keyboard focus is a web concern: a device moves its reader's focus into
@@ -66,14 +82,31 @@ export function makeSelect(rn) {
66
82
  }
67
83
 
68
84
  function OptionSheet(props) {
69
- const { t, options, isPicked, multiple, title, emptyText, listId, onPick, testID, footer } =
70
- props;
85
+ const {
86
+ t,
87
+ options,
88
+ isPicked,
89
+ multiple,
90
+ title,
91
+ emptyText,
92
+ listId,
93
+ onPick,
94
+ testID,
95
+ footer,
96
+ search,
97
+ body = options.length === 0 ? "empty" : "list",
98
+ bodyText,
99
+ rowStyle,
100
+ } = props;
71
101
  const nodes = React.useRef([]);
72
102
  const initial = initialFocusIndex(options, isPicked);
73
103
  // Focus lands on the current choice once, when the sheet mounts it; the
74
- // browser scrolls a focused option into view in a long list.
75
- const pendingFocus = React.useRef(true);
104
+ // browser scrolls a focused option into view in a long list. A search
105
+ // field takes that first focus instead.
106
+ const pendingFocus = React.useRef(!search);
76
107
  const text = { fontFamily: t.fontFamily, fontSize: t.fontSize, color: t.popover.textColor };
108
+ const note = { ...text, color: t.helperColor, paddingHorizontal: t.paddingHorizontal };
109
+ const focusFirst = () => focusNode(nodes.current[initialFocusIndex(options, () => false)]);
77
110
 
78
111
  return h(
79
112
  View,
@@ -85,11 +118,23 @@ export function makeSelect(rn) {
85
118
  title,
86
119
  )
87
120
  : null,
88
- options.length === 0
121
+ search ? search(focusFirst) : null,
122
+ body === "loading"
123
+ ? h(
124
+ View,
125
+ { style: { paddingVertical: t.rowGap }, testID: testID ? `${testID}-loading` : undefined },
126
+ h(Spinner, { size: "sm", label: bodyText }),
127
+ )
128
+ : body !== "list"
89
129
  ? h(
90
130
  Text,
91
- { style: { ...text, color: t.helperColor, paddingHorizontal: t.paddingHorizontal } },
92
- emptyText,
131
+ {
132
+ style: body === "error" ? { ...note, color: t.errorColor } : note,
133
+ // Results arrive after typing; a status line says what came back.
134
+ ...(search ? { role: "status", accessibilityLiveRegion: "polite" } : null),
135
+ testID: search && testID ? `${testID}-${body}` : undefined,
136
+ },
137
+ body === "empty" ? emptyText : bodyText,
93
138
  )
94
139
  : h(
95
140
  ScrollView,
@@ -133,17 +178,28 @@ export function makeSelect(rn) {
133
178
  "aria-selected": selected,
134
179
  "aria-disabled": !!option.disabled,
135
180
  testID: option.testID,
136
- style: ({ pressed }) =>
137
- optionRowStyle(t, {
181
+ style: ({ pressed }) => [
182
+ ...optionRowStyle(t, {
138
183
  pressed,
139
184
  disabled: option.disabled,
140
185
  justifyContent: multiple ? "flex-start" : "space-between",
141
186
  }),
187
+ rowStyle,
188
+ ],
142
189
  },
143
190
  multiple ? checkboxBox(View, t, { checked: selected }) : null,
191
+ option.leading ? h(View, { style: { flexShrink: 0 } }, option.leading) : null,
144
192
  h(
145
193
  Text,
146
- { style: [text, { flexShrink: 1 }, selected ? { fontWeight: "600" } : null] },
194
+ {
195
+ style: [
196
+ text,
197
+ { flexShrink: 1 },
198
+ // Keeps the check mark at the end once a leading node is drawn.
199
+ option.leading ? { flexGrow: 1 } : null,
200
+ selected ? { fontWeight: "600" } : null,
201
+ ],
202
+ },
147
203
  option.label,
148
204
  ),
149
205
  selected && !multiple ? h(Icon, { name: "check", size: 18, color: t.accent }) : null,
@@ -230,6 +286,50 @@ export function makeSelect(rn) {
230
286
  );
231
287
  }
232
288
 
289
+ // The sheet's search field and what it shows instead of the list.
290
+ function searchSheetProps({ theme, search, count, loading, failed, texts, testID }) {
291
+ const body = sheetBody({ loading, error: failed, query: search.text, count });
292
+ return {
293
+ body,
294
+ bodyText:
295
+ body === "loading"
296
+ ? texts.loadingText
297
+ : body === "error"
298
+ ? texts.loadErrorText
299
+ : texts.noResultsText,
300
+ rowStyle: searchRowOverrides(theme),
301
+ search: (focusFirst) =>
302
+ h(SheetSearchField, {
303
+ value: search.text,
304
+ onChangeText: search.type,
305
+ placeholder: texts.searchPlaceholder,
306
+ onArrowDown: focusFirst,
307
+ testID: testID ? `${testID}-search` : undefined,
308
+ }),
309
+ };
310
+ }
311
+
312
+ function SheetSearchField({ value, onChangeText, placeholder, onArrowDown, testID }) {
313
+ const node = React.useRef(null);
314
+ React.useEffect(() => {
315
+ focusNode(node.current);
316
+ }, []);
317
+ return h(SearchField, {
318
+ ref: node,
319
+ value,
320
+ onChangeText,
321
+ placeholder,
322
+ // Down from the field reaches the first option, as in a combobox.
323
+ onKeyPress: (event) => {
324
+ const key = event && event.nativeEvent ? event.nativeEvent.key : undefined;
325
+ if (key !== "ArrowDown") return;
326
+ if (typeof event.preventDefault === "function") event.preventDefault();
327
+ onArrowDown();
328
+ },
329
+ testID,
330
+ });
331
+ }
332
+
233
333
  const Select = React.forwardRef(function Select(props, ref) {
234
334
  const {
235
335
  value,
@@ -254,6 +354,16 @@ export function makeSelect(rn) {
254
354
  testID,
255
355
  onFocus,
256
356
  onBlur,
357
+ searchable: searchableProp = false,
358
+ onSearch,
359
+ searchDebounceMs = DEFAULT_SEARCH_DEBOUNCE_MS,
360
+ loading: loadingProp = false,
361
+ optionsError,
362
+ selectedOptions,
363
+ searchPlaceholder = "Search",
364
+ noResultsText = "No results",
365
+ loadingText = "Loading…",
366
+ loadErrorText = "Could not load the options",
257
367
  ...others
258
368
  } = props;
259
369
  const theme = useHostTheme() || DEFAULT_THEME_TOKENS;
@@ -272,9 +382,27 @@ export function makeSelect(rn) {
272
382
  const setRef = React.useMemo(() => shareRef(ref, triggerRef), [ref]);
273
383
  const listId = React.useId();
274
384
  const noteId = React.useId();
275
- const list = Array.isArray(options) ? options.filter(Boolean) : [];
276
- const current = selectedOption(list, value);
277
- const picked = multiple ? pickedOptions(list, values) : [];
385
+ const search = useSearchState({
386
+ open,
387
+ options,
388
+ searchable: searchableProp,
389
+ onSearch,
390
+ debounceMs: searchDebounceMs,
391
+ });
392
+ const { searchable } = search;
393
+ const held = search.fetched
394
+ ? search.fetched.options
395
+ : Array.isArray(options)
396
+ ? options.filter(Boolean)
397
+ : [];
398
+ const list = search.filtersHeld ? filterOptions(held, search.text) : held;
399
+ // Searchable, a picked option keeps its label after a search drops it.
400
+ const known = React.useRef(new Map());
401
+ if (searchable) known.current = rememberOptions(known.current, selectedOptions, held);
402
+ const optionsFor = (own) =>
403
+ searchable ? knownOptions(known.current, own) : pickedOptions(list, own);
404
+ const current = searchable ? knownOption(known.current, value) : selectedOption(list, value);
405
+ const picked = multiple ? optionsFor(values) : [];
278
406
  const draft = ownDraft === null ? valueList(values) : ownDraft;
279
407
  const invalid = isShown(error);
280
408
  const sheetTitle = isShown(title) ? title : isText(label) ? label : undefined;
@@ -296,7 +424,7 @@ export function makeSelect(rn) {
296
424
  const next = committedValues(list, draft);
297
425
  close();
298
426
  if (!sameValues(next, valueList(values)) && typeof onValuesChange === "function") {
299
- onValuesChange(next, pickedOptions(list, next));
427
+ onValuesChange(next, optionsFor(next));
300
428
  }
301
429
  };
302
430
  const pick = (option) => {
@@ -421,6 +549,23 @@ export function makeSelect(rn) {
421
549
  testID,
422
550
  onPick: pick,
423
551
  footer: multiple ? h(DoneFooter, { doneText, onDone: done, testID }) : null,
552
+ ...(searchable
553
+ ? searchSheetProps({
554
+ theme,
555
+ search,
556
+ count: list.length,
557
+ loading:
558
+ search.pending || (search.fetched ? search.fetched.loading : !!loadingProp),
559
+ failed: search.fetched ? search.fetched.error : isShown(optionsError),
560
+ texts: {
561
+ searchPlaceholder,
562
+ noResultsText,
563
+ loadingText,
564
+ loadErrorText: typeof optionsError === "string" ? optionsError : loadErrorText,
565
+ },
566
+ testID,
567
+ })
568
+ : null),
424
569
  }),
425
570
  ),
426
571
  );
@@ -429,3 +574,86 @@ export function makeSelect(rn) {
429
574
  Select.displayName = "Select";
430
575
  return Select;
431
576
  }
577
+
578
+ /**
579
+ * A searchable select's query: the text typed into the sheet, sent after a
580
+ * pause to `onSearch` or to async `options`, whose latest answer it keeps.
581
+ * Opening the sheet starts from an empty query; a plain select is inert here.
582
+ */
583
+ function useSearchState({ open, options, searchable, onSearch, debounceMs }) {
584
+ const asyncOptions = typeof options === "function";
585
+ const remote = typeof onSearch === "function" || asyncOptions;
586
+ const enabled = !!searchable || remote;
587
+ const [text, setText] = React.useState("");
588
+ // The query last handed on; typed text that differs is still pending.
589
+ const [sentQuery, setSentQuery] = React.useState("");
590
+ const [fetched, setFetched] = React.useState({ options: [], loading: false, error: false });
591
+ const latest = React.useRef({ onSearch, options });
592
+ latest.current = { onSearch, options };
593
+ const gate = React.useRef(null);
594
+ if (gate.current === null) gate.current = createRequestGate();
595
+ const mounted = React.useRef(false);
596
+ // Set in the body too: StrictMode runs the cleanup, then the effect again.
597
+ React.useEffect(() => {
598
+ mounted.current = true;
599
+ return () => {
600
+ mounted.current = false;
601
+ };
602
+ }, []);
603
+
604
+ const send = React.useCallback((query) => {
605
+ setSentQuery(query);
606
+ const own = latest.current;
607
+ if (typeof own.onSearch === "function") own.onSearch(query);
608
+ if (typeof own.options !== "function") return;
609
+ const ticket = gate.current.begin();
610
+ setFetched((state) => ({ ...state, loading: true, error: false }));
611
+ Promise.resolve()
612
+ .then(() => own.options(query))
613
+ .then(
614
+ (rows) => {
615
+ if (!mounted.current || !gate.current.isCurrent(ticket)) return;
616
+ const list = Array.isArray(rows) ? rows.filter(Boolean) : [];
617
+ setFetched({ options: list, loading: false, error: false });
618
+ },
619
+ () => {
620
+ if (!mounted.current || !gate.current.isCurrent(ticket)) return;
621
+ setFetched({ options: [], loading: false, error: true });
622
+ },
623
+ );
624
+ }, []);
625
+
626
+ const delay = React.useRef(debounceMs);
627
+ delay.current = debounceMs;
628
+ const debouncer = React.useRef(null);
629
+ if (debouncer.current === null) debouncer.current = createDebouncer(() => delay.current, send);
630
+ React.useEffect(() => () => debouncer.current.cancel(), []);
631
+
632
+ // Each opening starts from an empty query, asked for at once; it is
633
+ // cleared on closing so the next opening never draws the old filter.
634
+ React.useEffect(() => {
635
+ if (!enabled) return;
636
+ if (!open) {
637
+ debouncer.current.cancel();
638
+ setText("");
639
+ return;
640
+ }
641
+ if (remote) send("");
642
+ // eslint-disable-next-line react-hooks/exhaustive-deps
643
+ }, [open, enabled, remote]);
644
+
645
+ const type = (next) => {
646
+ const own = typeof next === "string" ? next : "";
647
+ setText(own);
648
+ if (remote) debouncer.current.push(own);
649
+ };
650
+
651
+ return {
652
+ searchable: enabled,
653
+ filtersHeld: enabled && !remote,
654
+ text: enabled ? text : "",
655
+ pending: remote && normaliseQuery(text) !== sentQuery,
656
+ type,
657
+ fetched: asyncOptions ? fetched : null,
658
+ };
659
+ }
package/dist/select.js CHANGED
@@ -1,12 +1,14 @@
1
1
  // `<Select>` (web). Binds ./select-view.js to react-native-web and the web
2
- // `<Overlay>`, `<Button>` and `<Chip>`; see ./primitives.js for why not the
3
- // bare specifier.
2
+ // `<Overlay>`, `<Button>`, `<Chip>`, `<SearchField>` and `<Spinner>`; see
3
+ // ./primitives.js for why not the bare specifier.
4
4
 
5
5
  import * as ReactNative from "react-native-web";
6
6
  import { makeSelect } from "./select-view.js";
7
7
  import { Overlay } from "./overlay.js";
8
8
  import { Button } from "./button.js";
9
9
  import { Chip } from "./chip.js";
10
+ import { SearchField } from "./search-field.js";
11
+ import { Spinner } from "./loading.js";
10
12
 
11
13
  export const Select = makeSelect({
12
14
  View: ReactNative.View,
@@ -17,4 +19,6 @@ export const Select = makeSelect({
17
19
  Overlay,
18
20
  Button,
19
21
  Chip,
22
+ SearchField,
23
+ Spinner,
20
24
  });
@@ -1,11 +1,13 @@
1
1
  // `<Select>` (native). Binds ./select-view.js to React Native's own
2
- // primitives and the native `<Overlay>`, `<Button>` and `<Chip>`; only the
3
- // imports differ from ./select.js.
2
+ // primitives and the native `<Overlay>`, `<Button>`, `<Chip>`,
3
+ // `<SearchField>` and `<Spinner>`; only the imports differ from ./select.js.
4
4
 
5
5
  import { View, Text, Pressable, ScrollView, Platform } from "react-native";
6
6
  import { makeSelect } from "./select-view.js";
7
7
  import { Overlay } from "./overlay.native.js";
8
8
  import { Button } from "./button.native.js";
9
9
  import { Chip } from "./chip.native.js";
10
+ import { SearchField } from "./search-field.native.js";
11
+ import { Spinner } from "./loading.native.js";
10
12
 
11
- export const Select = makeSelect({ View, Text, Pressable, ScrollView, Platform, Overlay, Button, Chip });
13
+ export const Select = makeSelect({ View, Text, Pressable, ScrollView, Platform, Overlay, Button, Chip, SearchField, Spinner });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colixsystems/widget-sdk",
3
- "version": "0.246.0",
3
+ "version": "0.247.0",
4
4
  "description": "Common widget interface for AppStudio. Implements WidgetManifest, WidgetContext, property schema, and helper hooks.",
5
5
  "homepage": "https://github.com/Colix-AB/AppStudio",
6
6
  "type": "module",
package/src/contract.js CHANGED
@@ -622,7 +622,7 @@ const THEME_COMPONENTS = Object.freeze({
622
622
  muted: Object.freeze({
623
623
  label: "Inputs",
624
624
  // The SDK's form controls read this scope directly, in every widget.
625
- sdkComponents: Object.freeze(["TextField", "Checkbox", "Switch", "Select", "SearchField", "FileUpload", "RadioGroup"]),
625
+ sdkComponents: Object.freeze(["TextField", "Checkbox", "Switch", "Select", "SearchField", "FileUpload", "RadioGroup", "PeoplePicker"]),
626
626
  tokens: Object.freeze({
627
627
  background: Object.freeze({ type: "color", uiDefault: "colors.surfaceMuted" }),
628
628
  textColor: Object.freeze({ type: "color", uiDefault: "colors.onSurfaceMuted" }),
@@ -641,7 +641,7 @@ const THEME_COMPONENTS = Object.freeze({
641
641
  // radius and shadow keep the `<Overlay>` panel's own.
642
642
  popover: Object.freeze({
643
643
  label: "Dropdown menus",
644
- sdkComponents: Object.freeze(["Select", "Menu", "Popover", "Tooltip"]),
644
+ sdkComponents: Object.freeze(["Select", "Menu", "Popover", "Tooltip", "PeoplePicker"]),
645
645
  tokens: Object.freeze({
646
646
  background: Object.freeze({ type: "color", uiDefault: "colors.surface" }),
647
647
  textColor: Object.freeze({ type: "color", uiDefault: "colors.onSurface" }),
@@ -784,6 +784,25 @@ const THEME_COMPONENTS = Object.freeze({
784
784
  }),
785
785
  targets: Object.freeze({}),
786
786
  }),
787
+ // Charts: `<Chart>` reads this scope directly, so it names the SDK
788
+ // component it styles and binds no widget field. Unset series colours
789
+ // follow the theme's own colours, so their defaults are derived.
790
+ chart: Object.freeze({
791
+ label: "Charts",
792
+ sdkComponents: Object.freeze(["Chart"]),
793
+ tokens: Object.freeze({
794
+ series1: Object.freeze({ type: "color" }),
795
+ series2: Object.freeze({ type: "color" }),
796
+ series3: Object.freeze({ type: "color" }),
797
+ series4: Object.freeze({ type: "color" }),
798
+ series5: Object.freeze({ type: "color" }),
799
+ series6: Object.freeze({ type: "color" }),
800
+ axisColor: Object.freeze({ type: "color", uiDefault: "colors.onSurfaceMuted" }),
801
+ gridColor: Object.freeze({ type: "color", uiDefault: "colors.border" }),
802
+ barRadius: Object.freeze({ type: "size", min: 0, max: 24, uiDefault: "radii.sm" }),
803
+ }),
804
+ targets: Object.freeze({}),
805
+ }),
787
806
  });
788
807
 
789
808
  // sc-4505 — the value types a widget event payload field may declare. Narrower
@@ -2536,7 +2555,8 @@ const HOOKS = [
2536
2555
  "Returns { groups, loading, error, refetch, create, remove, addMember, " +
2537
2556
  "removeMember }. list returns the { data, meta } envelope verbatim — the " +
2538
2557
  "hook unwraps res.data; rows are snake_case. Reads need groups.read:*; " +
2539
- "mutations need groups.write:*.",
2558
+ "mutations need groups.write:*. query { q?, ids?, limit?, offset? }: " +
2559
+ "`ids` (1 to 100) names the groups behind ids you already hold.",
2540
2560
  returnShape: {
2541
2561
  groups: "Array<{ id, name, member_count }> // snake_case rows; unwrapped from { data, meta }",
2542
2562
  loading: "boolean",
@@ -3377,6 +3397,13 @@ const PRIMITIVES = [
3377
3397
  rnComponent: null,
3378
3398
  docsUrl: null,
3379
3399
  },
3400
+ {
3401
+ name: "Chart",
3402
+ description:
3403
+ 'Themed chart. `<Chart type="bar" data={groups.map((g) => ({ label: String(g.group), value: Number(g.sum) || 0 }))} loading={loading} valueFormat="compact" />`. Use it by default to plot a table\'s figures: feed it ONE `useDatastoreAggregate` call (`groups` for a breakdown by a column, `buckets` for a series over time) mapped to `[{ label, value }]`. `type` is "bar" (default), "hbar" (horizontal bars, for long category names), "line", "area", "donut" (the parts of a whole) or "sparkline" (a small line inside a tile, with no axes). It measures its own width and redraws when its container resizes, shows a `<Skeleton>` while `loading` and an `<EmptyState>` when there is nothing to plot, draws a legend for a donut and for several series, and gives a screen reader a one-line summary followed by the values. Its series colours, axis and label colour, gridline colour and bar corners come from the app\'s chart look (Design > Components > Charts), falling back to the theme\'s colours, so pass no colours of your own. Props: `data` (`[{ label, value }]`, or one number per series key), `series` (`[{ key, label, color? }]` for several series sharing one `data`: bars stand side by side, lines and areas overlay; a donut and a sparkline draw the first), `type`, `valueFormat` ("number" default | "compact", 1.2k | "percent", which appends % to a value already in percent, 42 reads 42% | a function of the number; used for the axis, the legend and the spoken values, grouped for the app\'s language), `height` (px, default 220; 48 for a sparkline), `loading`, `emptyTitle` (default the shared "No results" text) and `emptyMessage`, `accessibilityLabel` (replaces the spoken summary sentence; pass your translated title), `moreText` (`(count) => string`, the tail of a long spoken value list, default "and N more"; pass it translated), `style`, `testID`. When the request needs a chart it cannot draw (a scatter plot, a gantt or timeline, brushing or tapping a bar to filter), build your own with react-native-svg in the app\'s look (see DRAWING A CHART), painting it with `chartColors(useTheme())`: `series(i)` and `seriesOpacity(i)` for the i-th series, `axis` and `grid`, the colours `<Chart>` uses.',
3404
+ rnComponent: null,
3405
+ docsUrl: null,
3406
+ },
3380
3407
  {
3381
3408
  name: "Divider",
3382
3409
  description:
@@ -3384,6 +3411,13 @@ const PRIMITIVES = [
3384
3411
  rnComponent: null,
3385
3412
  docsUrl: null,
3386
3413
  },
3414
+ {
3415
+ name: "Grid",
3416
+ description:
3417
+ 'Balanced lines of cells sized from the grid\'s own measured width. `<Grid minCellWidth={240}>{items.map((item) => <Card key={item.id} style={{ flex: 1 }}>…</Card>)}</Grid>` — use it by default for a card grid, a tile or stat row, and a grid of definite-size cells (calendar days, seats, swatches); when the layout needs what it cannot express (masonry, a bento layout whose cells span lines), lay it out yourself with `useContainerWidth()`. It measures an inner box with no padding, so padding in its `style` is never counted into the width. `balance` "cards" (default): as many cells per line as fit at `minCellWidth` (240 when unset), every line but the last full and the last padded with empty cells so its cards keep the width above (6 that fit 5 are 3 + 3). "tiles": the remainder spreads over every line and each line stretches (5 at 4 per line are 3 + 2, never 4 + 1); the per-line ceiling is `maxColumns`, or what fits at `minCellWidth`, or else 4 / 3 / 2 for the wide / medium / narrow width tier. `columns` fixes the count per line. `cell={{ min, max }}` makes definite-size cells as large as fit up to `max` (never below `min`, 32 when unset) in a block centred in the grid (a block whose `min` cells cannot fit starts at the leading edge, so only its far end is cut off), `square` makes them as tall as wide: `<Grid columns={7} cell={{ min: 32, max: 64 }} square gap="xs">`. Props: `children` (one cell each; each keeps its own look and theme scope, and a child with `flex: 1` fills its cell\'s height so a line of cards ends level), `balance`, `minCellWidth`, `maxColumns`, `columns`, `cell`, `square`, `gap` (a theme spacing name, default "md", or a number), `style` (the outer box), `testID`, `accessibilityLabel`. A layout that depends on the width draws nothing until it is measured. In a row, give it `style={{ flex: 1, minWidth: 0 }}` so it has a width to measure.',
3418
+ rnComponent: null,
3419
+ docsUrl: null,
3420
+ },
3387
3421
  {
3388
3422
  name: "Banner",
3389
3423
  description:
@@ -3464,7 +3498,7 @@ const PRIMITIVES = [
3464
3498
  {
3465
3499
  name: "Select",
3466
3500
  description:
3467
- 'Themed picker for ONE value of a known list, or several with `multiple`. `<Select label="Status" value={status} onValueChange={setStatus} options={[{ value: "open", label: "Open" }, { value: "done", label: "Done" }]} placeholder="Choose a status" />` — the default way to let the user pick ONE of a known list. The trigger is the app\'s input field (Design > Components > Inputs) showing the chosen label; pressing it opens the options at SCREEN level, in a sheet painted from Design > Components > Dropdown menus, so no card or scroll container can clip them, and a long list scrolls inside the sheet. The trigger is a button that opens a dialog (expanded state included) and each option carries the option role and its selected state on both hosts; the list around them is a listbox on the web only. On the web Enter, Space or an arrow key opens it, the arrow keys, Home and End move between options, Enter or Space chooses, Escape closes, and focus returns to the trigger; on Android the back button closes it. Props: `options` (`[{ value, label, disabled?, testID? }]`, `value` a string or number, unique), `value`, `onValueChange(value, option)`, `placeholder` (default "Select"), `title` (the sheet\'s heading; defaults to a text `label`), `emptyText` (default "No options"), `label`, `helper`, `error`, `disabled`, `accessibilityLabel`, `closeAccessibilityLabel`, `background` / `textColor` / `borderColor` / `radius` / `height` to override one field, `style` (the trigger), `containerStyle`, `testID`; any other prop lands on the trigger. Use it rather than a dropdown of your own; when the request needs options it cannot show (colour swatches, photos with two-line captions), put a small swatch or photo picker in a `<Popover>` at its button, or a long or full-screen option list in a `<Modal>` (an emoji choice is `<EmojiPicker>`) — never an absolutely positioned list, which the widget\'s container clips on both hosts. MULTIPLE: `<Select multiple label="Tags" values={tags} onValuesChange={setTags} options={tagOptions} />` — the default way to let the user pick SEVERAL of a known list. The same trigger and sheet; each option shows a tick box and toggles, the listbox is multiselectable on the web, and the sheet\'s Done button commits the choice through `onValuesChange(values, options)` (values in listed order, called only when they changed); closing the sheet any other way (backdrop, Escape, Android back) keeps the previous choice. Space toggles the focused option on the web. The trigger shows the picked labels as small chips while they fit on its one line, otherwise "N selected" (`selectedCountText(count)` to translate it), and `placeholder` while nothing is picked; `doneText` (default "Done") names the button. In this mode `value` / `onValueChange` are ignored. A handful of options always shown inline is a `<ChipGroup selection="multiple">` of `<Chip>`s instead.',
3501
+ 'Themed picker for ONE value of a known list, or several with `multiple`. `<Select label="Status" value={status} onValueChange={setStatus} options={[{ value: "open", label: "Open" }, { value: "done", label: "Done" }]} placeholder="Choose a status" />` — the default way to let the user pick ONE of a known list. The trigger is the app\'s input field (Design > Components > Inputs) showing the chosen label; pressing it opens the options at SCREEN level, in a sheet painted from Design > Components > Dropdown menus, so no card or scroll container can clip them, and a long list scrolls inside the sheet. The trigger is a button that opens a dialog (expanded state included) and each option carries the option role and its selected state on both hosts; the list around them is a listbox on the web only. On the web Enter, Space or an arrow key opens it, the arrow keys, Home and End move between options, Enter or Space chooses, Escape closes, and focus returns to the trigger; on Android the back button closes it. Props: `options` (`[{ value, label, disabled?, testID? }]`, `value` a string or number, unique), `value`, `onValueChange(value, option)`, `placeholder` (default "Select"), `title` (the sheet\'s heading; defaults to a text `label`), `emptyText` (default "No options"), `label`, `helper`, `error`, `disabled`, `accessibilityLabel`, `closeAccessibilityLabel`, `background` / `textColor` / `borderColor` / `radius` / `height` to override one field, `style` (the trigger), `containerStyle`, `testID`; any other prop lands on the trigger. Use it rather than a dropdown of your own; when the request needs options it cannot show (colour swatches, photos with two-line captions), put a small swatch or photo picker in a `<Popover>` at its button, or a long or full-screen option list in a `<Modal>` (an emoji choice is `<EmojiPicker>`) — never an absolutely positioned list, which the widget\'s container clips on both hosts. MULTIPLE: `<Select multiple label="Tags" values={tags} onValuesChange={setTags} options={tagOptions} />` — the default way to let the user pick SEVERAL of a known list. The same trigger and sheet; each option shows a tick box and toggles, the listbox is multiselectable on the web, and the sheet\'s Done button commits the choice through `onValuesChange(values, options)` (values in listed order, called only when they changed); closing the sheet any other way (backdrop, Escape, Android back) keeps the previous choice. Space toggles the focused option on the web. The trigger shows the picked labels as small chips while they fit on its one line, otherwise "N selected" (`selectedCountText(count)` to translate it), and `placeholder` while nothing is picked; `doneText` (default "Done") names the button. In this mode `value` / `onValueChange` are ignored. A handful of options always shown inline is a `<ChipGroup selection="multiple">` of `<Chip>`s instead. SEARCHABLE: a long list the user should type to narrow. `searchable` filters the given `options` by label as they type; `onSearch(query)` hands you the trimmed query when the sheet opens (\"\") and once typing pauses (`searchDebounceMs`, default 250), and you answer with new `options` and `loading` meanwhile (`optionsError` when it failed); or pass `options` as an async function of the query and Select fetches, keeping only the latest answer. The sheet then leads with the app\'s search field (Design > Components > Search fields), shows a spinner while loading, `noResultsText` (default \"No results\") when a query finds nothing and `loadErrorText` on failure, and its rows take the Lists row height and padding when the theme sets them. It remembers the label of every option it has shown; pass `selectedOptions` for a saved value the first page may not hold, or the trigger shows the placeholder. An option may carry `leading` (an `<Avatar>`, an icon) drawn before its label. Picking people, groups or a table\'s records is `<PeoplePicker>`, which does all of this for you.',
3468
3502
  rnComponent: null,
3469
3503
  docsUrl: null,
3470
3504
  },
@@ -3475,6 +3509,13 @@ const PRIMITIVES = [
3475
3509
  rnComponent: null,
3476
3510
  docsUrl: null,
3477
3511
  },
3512
+ {
3513
+ name: "PeoplePicker",
3514
+ description:
3515
+ 'Searchable picker of people, groups or records. `<PeoplePicker label="Assignee" value={assignee} onValueChange={setAssignee} />` — use it by default whenever the user picks a workspace user (an assignee, a reviewer, a recipient), a group (`source="groups"`), or a related record of a table they should search (`source="records" table={tableId}`, named by the table\'s identification column or `labelColumn`). It is a searchable `<Select>`: the same field, sheet, `label` / `helper` / `error` / `disabled`, and `multiple` with `values` / `onValuesChange`. Nothing loads until the sheet opens, each pause in typing searches on the server (never one page filtered in the widget), each user or group row leads with an `<Avatar>` (`avatarColumn` names a record\'s photo column; `showAvatars={false}` hides them), `excludeIds` leaves ids out (the current members), `groupId` limits users to one group, `filter` narrows records, and `pageSize` (default 50) sets the page. A saved value outside the first page is looked up once by id, so the trigger shows its name — `loadingText` meanwhile, `unknownText` (default "Unknown") when it is gone — never the raw id. Values are the row ids, exactly what a USER, USER_GROUP or RELATION column stores. Scopes: `directory.read:users` for users, `groups.read:*` for groups, the table\'s `datastore.read` scope for records. When the request needs what it cannot do — a people list that stays on screen with an action per row, a picker of users with presence dots or a role column — build your own with `useDirectory` / `useGroups` and the app\'s look.',
3516
+ rnComponent: null,
3517
+ docsUrl: null,
3518
+ },
3478
3519
  {
3479
3520
  name: "RadioGroup",
3480
3521
  description:
@@ -4540,7 +4581,7 @@ const VETTED_IMPORTS = [
4540
4581
  platforms: ["web", "native"],
4541
4582
  category: "drawing",
4542
4583
  description:
4543
- "SVG path generators — `line`, `area`, `arc`, `pie`, `curve*`. Each returns a path string you hand to the vetted react-native-svg's `<Path d={…} />`, so a bespoke line/area/donut chart renders identically in the Player and the Expo export from ONE source file. Pure JS.",
4584
+ "SVG path generators — `line`, `area`, `arc`, `pie`, `curve*`. Each returns a path string you hand to the vetted react-native-svg's `<Path d={…} />`, so a chart `<Chart>` does not draw renders identically in the Player and the Expo export from ONE source file. Pure JS.",
4544
4585
  },
4545
4586
  {
4546
4587
  specifier: "d3-array",
@@ -6210,7 +6251,10 @@ const CONTRACT = deepFreeze({
6210
6251
  // `<Menu>`, `<Popover>`, `<Tooltip>` (popover scope gains radius,
6211
6252
  // shadow), `<IconButton>`, Button ghost/link/size/busy/selected and
6212
6253
  // trigger pass-through, `<FileImage>`, `Avatar fileId`, `<RadioGroup>`.
6213
- version: "1.200.0",
6254
+ // 1.201.0: additive (sc-12070, sc-12071, sc-12072) — new `<Grid>`,
6255
+ // `<Chart>` + `chart` theme scope + `chartColors`, `<PeoplePicker>`,
6256
+ // searchable `<Select>`; `useGroups` takes `ids`.
6257
+ version: "1.201.0",
6214
6258
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
6215
6259
  hooks: HOOKS,
6216
6260
  primitives: PRIMITIVES,