@colixsystems/widget-sdk 0.237.0 → 0.239.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.
@@ -4,7 +4,8 @@
4
4
  //
5
5
  // The trigger wears the field `<TextField>` does. The options open in
6
6
  // `<Overlay>`, at screen level, so no card or scroll container the widget
7
- // sits in can clip them; a long list scrolls inside the sheet.
7
+ // sits in can clip them; a long list scrolls inside the sheet. With
8
+ // `multiple` the same sheet ticks options into a draft that Done commits.
8
9
 
9
10
  import React from "react";
10
11
  import { useHostTheme } from "./hooks.js";
@@ -29,17 +30,34 @@ import {
29
30
  spokenLabel,
30
31
  } from "./form-control-logic.js";
31
32
  import { makeFieldFrame } from "./form-control-view.js";
33
+ import { checkboxBox } from "./checkbox-view.js";
34
+ import {
35
+ committedValues,
36
+ defaultSelectedCountText,
37
+ initialFocusIndex,
38
+ pickedOptions,
39
+ sameValues,
40
+ toggleValue,
41
+ triggerSummary,
42
+ valueList,
43
+ } from "./select-logic.js";
32
44
 
33
45
  const h = React.createElement;
34
46
  const NAVIGATION_KEYS = ["ArrowDown", "ArrowUp", "Home", "End"];
47
+ // Wide enough that no picked chip shrinks while it is measured.
48
+ const MEASURE_SPAN = 10000;
49
+ // A compact chip that fits the field's own line, so the trigger keeps its height.
50
+ const TRIGGER_CHIP_STYLE = Object.freeze({ minHeight: 0, paddingVertical: 2 });
51
+ const UNMEASURED = Object.freeze({ available: null, needed: null });
35
52
 
36
53
  /**
37
54
  * Bind the shared select to one platform's primitives.
38
55
  *
39
- * @param {{View: any, Text: any, Pressable: any, ScrollView: any, Platform: any, Overlay: any}} rn
56
+ * @param {{View: any, Text: any, Pressable: any, ScrollView: any, Platform: any,
57
+ * Overlay: any, Button: any, Chip: any}} rn
40
58
  */
41
59
  export function makeSelect(rn) {
42
- const { View, Text, Pressable, ScrollView, Platform, Overlay } = rn;
60
+ const { View, Text, Pressable, ScrollView, Platform, Overlay, Button, Chip } = rn;
43
61
  const FieldFrame = makeFieldFrame(rn);
44
62
 
45
63
  // Keyboard focus is a web concern: a device moves its reader's focus into
@@ -48,11 +66,11 @@ export function makeSelect(rn) {
48
66
  if (Platform.OS === "web" && node && typeof node.focus === "function") node.focus();
49
67
  }
50
68
 
51
- function OptionSheet({ t, options, value, title, emptyText, listId, onPick, testID }) {
69
+ function OptionSheet(props) {
70
+ const { t, options, isPicked, multiple, title, emptyText, listId, onPick, testID, footer } =
71
+ props;
52
72
  const nodes = React.useRef([]);
53
- const chosen = options.findIndex((option) => option.value === value);
54
- const firstEnabled = options.findIndex((option) => !option.disabled);
55
- const initial = chosen !== -1 && !options[chosen].disabled ? chosen : firstEnabled;
73
+ const initial = initialFocusIndex(options, isPicked);
56
74
  // Focus lands on the current choice once, when the sheet mounts it; the
57
75
  // browser scrolls a focused option into view in a long list.
58
76
  const pendingFocus = React.useRef(true);
@@ -81,12 +99,13 @@ export function makeSelect(rn) {
81
99
  nativeID: listId,
82
100
  // Web only: native has no listbox, and ignores an unknown `role`.
83
101
  role: "listbox",
102
+ ...(multiple ? { "aria-multiselectable": true } : null),
84
103
  accessibilityLabel: isText(title) ? String(title) : undefined,
85
104
  keyboardShouldPersistTaps: "handled",
86
105
  testID: testID ? `${testID}-options` : undefined,
87
106
  },
88
107
  ...options.map((option, index) => {
89
- const selected = option.value === value;
108
+ const selected = isPicked(option);
90
109
  // The web host still delivers keys to a disabled Pressable.
91
110
  const pick = () => {
92
111
  if (!option.disabled) onPick(option);
@@ -121,7 +140,7 @@ export function makeSelect(rn) {
121
140
  {
122
141
  flexDirection: "row",
123
142
  alignItems: "center",
124
- justifyContent: "space-between",
143
+ justifyContent: multiple ? "flex-start" : "space-between",
125
144
  gap: t.rowGap,
126
145
  minHeight: CONTROL_MIN_TARGET,
127
146
  paddingHorizontal: t.paddingHorizontal,
@@ -132,15 +151,93 @@ export function makeSelect(rn) {
132
151
  option.disabled ? { opacity: t.disabledOpacity } : null,
133
152
  ],
134
153
  },
154
+ multiple ? checkboxBox(View, t, { checked: selected }) : null,
135
155
  h(
136
156
  Text,
137
157
  { style: [text, { flexShrink: 1 }, selected ? { fontWeight: "600" } : null] },
138
158
  option.label,
139
159
  ),
140
- selected ? h(Icon, { name: "check", size: 18, color: t.accent }) : null,
160
+ selected && !multiple ? h(Icon, { name: "check", size: 18, color: t.accent }) : null,
141
161
  );
142
162
  }),
143
163
  ),
164
+ footer || null,
165
+ );
166
+ }
167
+
168
+ function DoneFooter({ doneText, onDone, testID }) {
169
+ return h(
170
+ View,
171
+ { style: { flexDirection: "row", justifyContent: "flex-end" } },
172
+ h(Button, { title: doneText, onPress: onDone, testID: testID ? `${testID}-done` : undefined }),
173
+ );
174
+ }
175
+
176
+ // Chips when they fit the field's one line, else the count; measured on
177
+ // both hosts, so a narrow trigger never grows to hold them.
178
+ function MultiSummary({ t, picked, placeholder, selectedCountText, testID }) {
179
+ const [widths, setWidths] = React.useState(UNMEASURED);
180
+ const countText =
181
+ typeof selectedCountText === "function" ? selectedCountText : defaultSelectedCountText;
182
+ const measure = (key) => (event) => {
183
+ const width = event.nativeEvent.layout.width;
184
+ setWidths((own) => (own[key] === width ? own : { ...own, [key]: width }));
185
+ };
186
+ const mode = triggerSummary({ picked: picked.length, ...widths });
187
+ const text = { fontFamily: t.fontFamily, fontSize: t.fontSize };
188
+ const chips = () =>
189
+ picked.map((option) =>
190
+ h(Chip, {
191
+ key: `${typeof option.value}:${option.value}`,
192
+ label: option.label,
193
+ size: "sm",
194
+ style: TRIGGER_CHIP_STYLE,
195
+ }),
196
+ );
197
+ return h(
198
+ View,
199
+ {
200
+ onLayout: measure("available"),
201
+ style: { flexGrow: 1, flexShrink: 1, minWidth: 0, overflow: "hidden" },
202
+ testID: testID ? `${testID}-summary` : undefined,
203
+ },
204
+ mode === "chips"
205
+ ? h(View, { style: { flexDirection: "row", gap: t.gap } }, ...chips())
206
+ : h(
207
+ Text,
208
+ {
209
+ numberOfLines: 1,
210
+ style: { ...text, color: mode === "count" ? t.textColor : t.placeholderColor },
211
+ },
212
+ mode === "count" ? countText(picked.length) : placeholder,
213
+ ),
214
+ picked.length > 0
215
+ ? h(
216
+ View,
217
+ {
218
+ "aria-hidden": true,
219
+ importantForAccessibility: "no-hide-descendants",
220
+ accessibilityElementsHidden: true,
221
+ style: {
222
+ position: "absolute",
223
+ top: 0,
224
+ left: 0,
225
+ width: MEASURE_SPAN,
226
+ opacity: 0,
227
+ pointerEvents: "none",
228
+ },
229
+ },
230
+ h(
231
+ View,
232
+ {
233
+ onLayout: measure("needed"),
234
+ style: { flexDirection: "row", alignSelf: "flex-start", gap: t.gap },
235
+ testID: testID ? `${testID}-measure` : undefined,
236
+ },
237
+ ...chips(),
238
+ ),
239
+ )
240
+ : null,
144
241
  );
145
242
  }
146
243
 
@@ -148,6 +245,11 @@ export function makeSelect(rn) {
148
245
  const {
149
246
  value,
150
247
  onValueChange,
248
+ multiple = false,
249
+ values,
250
+ onValuesChange,
251
+ selectedCountText = defaultSelectedCountText,
252
+ doneText = "Done",
151
253
  options,
152
254
  placeholder = "Select",
153
255
  title,
@@ -174,6 +276,8 @@ export function makeSelect(rn) {
174
276
  }
175
277
  const t = resolveFormControlTokens(theme, overrides);
176
278
  const [open, setOpen] = React.useState(false);
279
+ // Null until the person ticks something: an untouched sheet follows `values`.
280
+ const [ownDraft, setDraft] = React.useState(null);
177
281
  const [focused, setFocused] = React.useState(false);
178
282
  const triggerRef = React.useRef(null);
179
283
  const setRef = React.useMemo(() => shareRef(ref, triggerRef), [ref]);
@@ -181,14 +285,41 @@ export function makeSelect(rn) {
181
285
  const noteId = React.useId();
182
286
  const list = Array.isArray(options) ? options.filter(Boolean) : [];
183
287
  const current = selectedOption(list, value);
288
+ const picked = multiple ? pickedOptions(list, values) : [];
289
+ const draft = ownDraft === null ? valueList(values) : ownDraft;
184
290
  const invalid = isShown(error);
185
291
  const sheetTitle = isShown(title) ? title : isText(label) ? label : undefined;
186
292
  const name = spokenLabel(accessibilityLabel, label);
187
293
  const shown = current ? current.label : placeholder;
294
+ const spokenChoice = multiple
295
+ ? picked.length > 0
296
+ ? picked.map((option) => option.label).filter(isText).join(", ")
297
+ : placeholder
298
+ : shown;
188
299
  const show = () => {
189
- if (!disabled) setOpen(true);
300
+ if (disabled) return;
301
+ if (multiple) setDraft(null);
302
+ setOpen(true);
190
303
  };
304
+ // Closing any other way leaves the draft behind: only Done commits it.
191
305
  const close = () => setOpen(false);
306
+ const done = () => {
307
+ const next = committedValues(list, draft);
308
+ close();
309
+ if (!sameValues(next, valueList(values)) && typeof onValuesChange === "function") {
310
+ onValuesChange(next, pickedOptions(list, next));
311
+ }
312
+ };
313
+ const pick = (option) => {
314
+ if (multiple) {
315
+ setDraft((own) => toggleValue(own === null ? valueList(values) : own, option.value));
316
+ return;
317
+ }
318
+ close();
319
+ if (option.value !== value && typeof onValueChange === "function") {
320
+ onValueChange(option.value, option);
321
+ }
322
+ };
192
323
  // The option that held focus is gone once the sheet closes. After the
193
324
  // sheet unmounts, or its focus trap would pull focus straight back in.
194
325
  const wasOpen = React.useRef(false);
@@ -239,7 +370,8 @@ export function makeSelect(rn) {
239
370
  ...(invalid ? { "aria-invalid": true } : null),
240
371
  ...(hasNote({ helper, error }) ? { "aria-describedby": noteId } : null),
241
372
  // The choice is part of the name: a button's own label replaces its text.
242
- accessibilityLabel: [name, isText(shown) ? shown : null].filter(Boolean).join(", ") || undefined,
373
+ accessibilityLabel:
374
+ [name, isText(spokenChoice) ? spokenChoice : null].filter(Boolean).join(", ") || undefined,
243
375
  testID,
244
376
  style: [
245
377
  {
@@ -260,19 +392,21 @@ export function makeSelect(rn) {
260
392
  style,
261
393
  ],
262
394
  },
263
- h(
264
- Text,
265
- {
266
- numberOfLines: 1,
267
- style: {
268
- flexShrink: 1,
269
- color: current ? t.textColor : t.placeholderColor,
270
- fontFamily: t.fontFamily,
271
- fontSize: t.fontSize,
272
- },
273
- },
274
- shown,
275
- ),
395
+ multiple
396
+ ? h(MultiSummary, { t, picked, placeholder, selectedCountText, testID })
397
+ : h(
398
+ Text,
399
+ {
400
+ numberOfLines: 1,
401
+ style: {
402
+ flexShrink: 1,
403
+ color: current ? t.textColor : t.placeholderColor,
404
+ fontFamily: t.fontFamily,
405
+ fontSize: t.fontSize,
406
+ },
407
+ },
408
+ shown,
409
+ ),
276
410
  h(Icon, { name: "chevron-down", size: 18, color: t.placeholderColor }),
277
411
  ),
278
412
  h(
@@ -293,17 +427,16 @@ export function makeSelect(rn) {
293
427
  h(OptionSheet, {
294
428
  t,
295
429
  options: list,
296
- value,
430
+ multiple,
431
+ isPicked: multiple
432
+ ? (option) => draft.includes(option.value)
433
+ : (option) => option.value === value,
297
434
  title: sheetTitle,
298
435
  emptyText,
299
436
  listId,
300
437
  testID,
301
- onPick: (option) => {
302
- close();
303
- if (option.value !== value && typeof onValueChange === "function") {
304
- onValueChange(option.value, option);
305
- }
306
- },
438
+ onPick: pick,
439
+ footer: multiple ? h(DoneFooter, { doneText, onDone: done, testID }) : null,
307
440
  }),
308
441
  ),
309
442
  );
package/dist/select.js CHANGED
@@ -1,9 +1,12 @@
1
1
  // `<Select>` (web). Binds ./select-view.js to react-native-web and the web
2
- // `<Overlay>`; see ./primitives.js for why not the bare specifier.
2
+ // `<Overlay>`, `<Button>` and `<Chip>`; see ./primitives.js for why not the
3
+ // bare specifier.
3
4
 
4
5
  import * as ReactNative from "react-native-web";
5
6
  import { makeSelect } from "./select-view.js";
6
7
  import { Overlay } from "./overlay.js";
8
+ import { Button } from "./button.js";
9
+ import { Chip } from "./chip.js";
7
10
 
8
11
  export const Select = makeSelect({
9
12
  View: ReactNative.View,
@@ -12,4 +15,6 @@ export const Select = makeSelect({
12
15
  ScrollView: ReactNative.ScrollView,
13
16
  Platform: ReactNative.Platform,
14
17
  Overlay,
18
+ Button,
19
+ Chip,
15
20
  });
@@ -1,9 +1,11 @@
1
1
  // `<Select>` (native). Binds ./select-view.js to React Native's own
2
- // primitives and the native `<Overlay>`; only the imports differ from
3
- // ./select.js.
2
+ // primitives and the native `<Overlay>`, `<Button>` and `<Chip>`; only the
3
+ // 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
+ import { Button } from "./button.native.js";
9
+ import { Chip } from "./chip.native.js";
8
10
 
9
- export const Select = makeSelect({ View, Text, Pressable, ScrollView, Platform, Overlay });
11
+ export const Select = makeSelect({ View, Text, Pressable, ScrollView, Platform, Overlay, Button, Chip });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colixsystems/widget-sdk",
3
- "version": "0.237.0",
3
+ "version": "0.239.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"]),
625
+ sdkComponents: Object.freeze(["TextField", "Checkbox", "Switch", "Select", "SearchField", "FileUpload"]),
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" }),
@@ -1203,6 +1203,8 @@ const HOOKS = [
1203
1203
  openLink: "(link: string) => boolean",
1204
1204
  goToStartPage:
1205
1205
  "() => void — opens the signed-in user's own start page, the page the app opens on for them (it can differ per user, since page access is restricted per group). Use it for a 'back to start' or 'home' button instead of hard-coding a page. A no-op where there is no live router (the Studio canvas, a badge counter).",
1206
+ scrollToSection:
1207
+ "(sectionId: string) => void — scrolls THIS page to one of its sections (a root section or a layout container), landing its top just below any pinned site header; smooth unless the visitor prefers reduced motion. Take the id from a `sectionRef` prop the author picks; never hard-code a node id. An unknown, deleted or hidden section is a no-op, and so is the Studio canvas.",
1206
1208
  goBack: "() => void",
1207
1209
  push: "(pageIdOrSlug: string, params?: object) => void",
1208
1210
  replace: "(pageIdOrSlug: string, params?: object) => void",
@@ -3145,16 +3147,13 @@ const PRIMITIVES = [
3145
3147
  rnComponent: "@react-native-community/datetimepicker",
3146
3148
  docsUrl: "https://github.com/react-native-datetimepicker/datetimepicker",
3147
3149
  },
3148
- // sc-1378 — `<FilePicker accept onPick>` lets a widget surface a native
3149
- // file-picker on the web Player so end users can upload files to the
3150
- // backend (paired with useFilestoreUpload). Web wraps
3151
- // a hidden DOM `<input type="file">`; native renders a disabled trigger
3152
- // and exposes `FilePicker.isSupported = false` until a vetted Expo
3153
- // picker pin lands.
3150
+ // sc-1378 — `<FilePicker accept onPick>` opens the system file picker so
3151
+ // end users can upload files (paired with useFilestoreUpload). Web wraps a
3152
+ // hidden DOM `<input type="file">`; native wraps expo-document-picker.
3154
3153
  {
3155
3154
  name: "FilePicker",
3156
3155
  description:
3157
- 'End-user file picker primitive. `<FilePicker accept="image/*" multiple={false} disabled={false} onPick={(file) => …}>…trigger…</FilePicker>`. Children render as the visible click target (a Pressable + Icon + Text is the common shape) and are wrapped in a hidden-input <label> so the click reaches the file dialog without any ref plumbing. Pair with useFilestoreUpload to upload the picked File to the end-user filestore. `FilePicker.isSupported` is `true` on the web Player and `false` on the native Expo export — widgets should branch on it to hide the trigger when uploads are not yet wired natively.',
3156
+ 'End-user file picker primitive. `<FilePicker accept="image/*" multiple={false} disabled={false} onPick={(file) => …}>…trigger…</FilePicker>`. Children render as the visible click target (a Pressable + Icon + Text is the common shape) and are wrapped in a hidden-input <label> so the click reaches the file dialog without any ref plumbing. Pair with useFilestoreUpload to upload the picked File to the end-user filestore. `onFocus` / `onBlur` report the picker\'s keyboard focus, so a trigger you draw can show it. `FilePicker.isSupported` is `true` on the web Player and on the native Expo export, which opens the system document picker; a host without a picker sets it `false`, so disable the trigger and say why. For an upload field use `<FileUpload>`, which draws the button, the chosen files and the uploading and error states around this picker; the bare FilePicker is for a trigger that is not a field, such as a photo the user taps to replace.',
3158
3157
  rnComponent: null,
3159
3158
  docsUrl: null,
3160
3159
  },
@@ -3255,14 +3254,14 @@ const PRIMITIVES = [
3255
3254
  {
3256
3255
  name: "List",
3257
3256
  description:
3258
- "Themed list container. `<List>{rows.map((r) => <ListRow key={r.id} title={r.name} />)}</List>`. Spaces and insets its `<ListRow>` children the way the app's look says (flush rows with hairlines, or separated rounded rows), and hands any row override you pass (`rowPadding`, `rowMinHeight`, `gap`, `radius`, `background`, `dividerColor`, `dividerWidth`) down to every row. Props: those overrides, `style`, `testID`. Put it inside a `ScrollView` (or bound its height) for long lists.",
3257
+ "Themed list container. `<List>{rows.map((r) => <ListRow key={r.id} title={r.name} />)}</List>`. Spaces and insets its `<ListRow>` children the way the app's look says (flush rows with hairlines, or separated rounded rows), and hands any row override you pass (`rowPadding`, `rowMinHeight`, `gap`, `radius`, `background`, `dividerColor`, `dividerWidth`) down to every row. `<List variant=\"card\">` draws EVERY row as its own card instead: THE way to build a list of cards (one record per card, stacked in one column). Each card's surface (fill, border, corner radius, padding, shadow, gradient) is the app's card look (Design > Components > Cards), what is inside it (height, the leading / body / trailing arrangement) is its Lists look (Design > Components > Lists), and the cards are spaced by the Lists gap, or a small step when that is unset; never wrap hand-made rows in `<Card>`s for this. `defaults` takes the same keys as the overrides and is used only where the app's Lists settings leave a value unset (a built-in widget keeps its own row look with it); leave it out in a new widget so the app decides. Props: those overrides, `variant` (\"plain\" default | \"card\"), `defaults`, `style`, `testID`. Put it inside a `ScrollView` (or bound its height) for long lists.",
3259
3258
  rnComponent: null,
3260
3259
  docsUrl: null,
3261
3260
  },
3262
3261
  {
3263
3262
  name: "ListRow",
3264
3263
  description:
3265
- 'Themed list row. `<ListRow title={item.name} subtitle={item.email} leading={<Icon name="user" size={20} />} trailing={<Text>…</Text>} onPress={() => open(item)} selected={item.id === selectedId} />`. THE way to draw one row of records: its padding, minimum height, divider, corner radius and background come from the theme\'s Lists settings, so every list in the app matches — never hand-roll a row from `View` + `Pressable`. Props: `title`, `subtitle` (rendered in the subtitle / caption text tiers), `leading` / `trailing` nodes, `children` (replaces the title block: a single `View` child — your own row of cells — spans the full width between `leading` and `trailing`; anything else, such as several cells, sits in the row as given, which is the shape a table row takes under a `<ListHeader>`), `onPress` (makes it a button with a press state), `selected`, `disabled`, `divider` (default true; pass false on the last row), `accessibilityLabel`, `style`, `testID`, plus the same per-row overrides `<List>` takes. Declare `listRowPadding` / `listRowRadius` / … style fields and pass them through to let an author override one widget.',
3264
+ 'Themed list row. `<ListRow title={item.name} subtitle={item.email} leading={<Icon name="user" size={20} />} trailing={<Text>…</Text>} onPress={() => open(item)} selected={item.id === selectedId} />`. THE way to draw one row of records: its padding, minimum height, divider, corner radius and background come from the theme\'s Lists settings, so every list in the app matches — never hand-roll a row from `View` + `Pressable`. Props: `title`, `subtitle` (rendered in the subtitle / caption text tiers), `leading` / `trailing` nodes, `children` (replaces the title block: a single `View` child — your own row of cells — spans the full width between `leading` and `trailing`; anything else, such as several cells, sits in the row as given, which is the shape a table row takes under a `<ListHeader>`), `onPress` (makes it a button with a press state), `actions` (the row\'s OWN buttons, such as remove, move up or sign: drawn after `trailing` and, on a row with `onPress`, kept OUTSIDE its tap target so each stays a separate button the keyboard and a screen reader reach; never put a `Pressable` in `trailing` of a pressable row), `selected`, `disabled`, `divider` (default true; pass false on the last row), `accessibilityLabel`, `style`, `testID`, `defaults`, plus the same per-row overrides `<List>` takes. Inside a `<List variant="card">` the row is drawn as one card. Declare `listRowPadding` / `listRowRadius` / … style fields and pass them through to let an author override one widget.',
3266
3265
  rnComponent: null,
3267
3266
  docsUrl: null,
3268
3267
  },
@@ -3388,7 +3387,7 @@ const PRIMITIVES = [
3388
3387
  {
3389
3388
  name: "Select",
3390
3389
  description:
3391
- 'Themed single-choice picker. `<Select label="Status" value={status} onValueChange={setStatus} options={[{ value: "open", label: "Open" }, { value: "done", label: "Done" }]} placeholder="Choose a status" />` — THE 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. Never hand-roll a dropdown from `Pressable` + an absolutely positioned list: it is clipped by the widget\'s container on both hosts.',
3390
+ '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 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. Never hand-roll a dropdown from `Pressable` + an absolutely positioned list: it is clipped by the widget\'s container on both hosts. MULTIPLE: `<Select multiple label="Tags" values={tags} onValuesChange={setTags} options={tagOptions} />` — THE 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.',
3392
3391
  rnComponent: null,
3393
3392
  docsUrl: null,
3394
3393
  },
@@ -3420,6 +3419,14 @@ const PRIMITIVES = [
3420
3419
  rnComponent: null,
3421
3420
  docsUrl: null,
3422
3421
  },
3422
+ // sc-11751 — the standard upload field, built on `<FilePicker>`.
3423
+ {
3424
+ name: "FileUpload",
3425
+ description:
3426
+ 'Themed file chooser. `<FileUpload label="Attachments" accept="image/*,.pdf" multiple files={files} onFilesChange={setFiles} />` — THE way to let the user pick files to upload, whether they upload at once or with a form. It is built on `<FilePicker>`: a button (Design > Components > Buttons) inside the app\'s input field (Design > Components > Inputs), the chosen files listed under it with their name, size and a remove control, and the same `label` / `helper` / `error` / `disabled` as every form control. The button opens the system file picker on both hosts and is the only way in on a device; on the web a file can also be dropped onto the field. A picked or dropped file that `accept` rules out is not added and the field shows an error. It uploads nothing itself: call `upload(file)` from `useFilestoreUpload` in `onPick` (or when the form is submitted) and pass `uploading` while it runs; the field then shows a `<Spinner>` with "Uploading…" and locks (no percentage is reported). Props: `accept` (an HTML accept value: "image/*", ".pdf", "application/pdf"), `multiple` (default false: a new pick replaces the file), `files` + `onFilesChange(files)` to own the list (omit both and it keeps its own; pass `files={[]}` to list nothing), `onPick(file)` (an array when `multiple`) once per pick or drop with only the new files, `onRemove(file, index)`, `onReject(files, reason)` (reason "type" for a file `accept` rules out, "count" for the files beyond the first when not `multiple`), `uploading`, `label`, `helper`, `error` (a message, or `true`), `disabled`, `buttonLabel` (default "Choose file" / "Choose files"), `buttonVariant` ("primary" | "secondary" | "outlined", the default), `uploadingLabel`, `emptyLabel`, `dropLabel`, `rejectedMessage`, `singleFileMessage`, `unsupportedMessage`, `removeAccessibilityLabel`, `accessibilityLabel`, `style` (the field), `containerStyle`, `testID` (also tags `<testID>-button`, `<testID>-file-<i>` and `<testID>-remove-<i>`). Pass translated copy for every label. Never hand-build an upload control from `<FilePicker>` plus your own button, file list or "Uploading…" text.',
3427
+ rnComponent: null,
3428
+ docsUrl: null,
3429
+ },
3423
3430
  ];
3424
3431
 
3425
3432
  const CATEGORIES = [
@@ -6072,7 +6079,14 @@ const CONTRACT = deepFreeze({
6072
6079
  // colour, height, each falling back to the `muted` field. The field
6073
6080
  // takes an `iconPosition` prop. Scope labels renamed (sc-11763); keys
6074
6081
  // unchanged.
6075
- version: "1.191.0",
6082
+ // 1.192.0: additive (sc-11751, sc-11752, sc-11758) — new `<FileUpload>`
6083
+ // on `<FilePicker>` (which gains onFocus/onBlur; `muted` lists it).
6084
+ // `<List variant="card">`, `<ListRow actions>`, `defaults` on both.
6085
+ // `<Select multiple values onValuesChange>`.
6086
+ // 1.193.0: additive (sc-11821) — `useNavigation().scrollToSection(id)`
6087
+ // scrolls the page to one of its sections on both hosts, and the
6088
+ // `sectionRef` propertySchema type gives the author a section picker.
6089
+ version: "1.193.0",
6076
6090
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
6077
6091
  hooks: HOOKS,
6078
6092
  primitives: PRIMITIVES,