@colixsystems/widget-sdk 0.237.0 → 0.238.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -84,7 +84,26 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
84
84
 
85
85
  ## Status
86
86
 
87
- `v0.237.0` — pre-publish. The package surface (types, function names, export paths) is the v1 contract; runtime behaviour for some hooks is stubbed (each hook documents what's wired and what isn't). It is **not yet published to npm**.
87
+ `v0.238.0` — pre-publish. The package surface (types, function names, export paths) is the v1 contract; runtime behaviour for some hooks is stubbed (each hook documents what's wired and what isn't). It is **not yet published to npm**.
88
+
89
+ ### What's new in 0.238.0 (contract 1.192.0)
90
+
91
+ **A standard upload field: `<FileUpload>`.** Widgets wrapped the headless `<FilePicker>` round buttons they drew themselves, so every upload control looked different. `<FileUpload>` is one view bound to each host's own `FilePicker`: the app's button inside the input field (Design > Components > Buttons and Inputs), the chosen files listed with their size and a remove control, and the form controls' `label`, `helper`, `error` and `disabled`. A file that `accept` rules out is refused with an error. It never uploads itself: call `upload(file)` from `useFilestoreUpload` in `onPick` and pass `uploading`, which shows an indeterminate spinner and locks the field. With `multiple`, `onPick` receives an array of the new files; `files` + `onFilesChange` let you own the list. On the web a file can also be dropped onto the field; React Native has no file drop, so on a device the button, which opens the system document picker, is the control. `<FilePicker>` gains `onFocus` / `onBlur`, and `FilePicker.isSupported` is documented as true on both hosts.
92
+
93
+ ```jsx
94
+ const { upload, uploading } = useFilestoreUpload({ spaceType: "project" });
95
+ <FileUpload label="Attachment" accept="application/pdf,image/*" uploading={uploading} onPick={(file) => upload(file)} />
96
+ ```
97
+
98
+ **Lists of cards and row actions.** `<List variant="card">` turns every `<ListRow>` into its own card: the surface follows Design > Components > Cards and the inside follows Lists, so a list of cards matches the rest of the app without wrapping rows in `<Card>` by hand. Buttons that act on one record go in `<ListRow actions>`, which stay outside the row's own tap target. `defaults` on `<List>` / `<ListRow>` is a widget's own row look, used only where the theme's Lists settings are unset. Existing rows render unchanged.
99
+
100
+ **Pick several with `<Select multiple>`.** `Select` takes `multiple` with `values` / `onValuesChange(values, options)`: the same field and option sheet, a tick box beside each option, and a Done button that hands you the new array in listed order (only when it changed). Closing the sheet by the backdrop, Escape or Android back keeps the previous choice. The trigger shows the picked labels as compact chips while they fit on its line, otherwise "N selected" (`selectedCountText`; `doneText` translates the button). On the web the list is multiselectable and Space or Enter toggles the focused option. For a handful of options always on show, use `<ChipGroup selection="multiple">` instead.
101
+
102
+ ```jsx
103
+ <Select multiple label={t("tags", "Tags")} values={tags} onValuesChange={setTags}
104
+ doneText={t("done", "Done")} selectedCountText={(n) => `${n} ${t("picked", "picked")}`}
105
+ options={[{ value: "red", label: "Red" }, { value: "blue", label: "Blue" }]} />
106
+ ```
88
107
 
89
108
  ### What's new in 0.237.0 (contract 1.191.0)
90
109
 
@@ -2930,7 +2949,7 @@ import { defineWidget, validateManifest, useDatastoreQuery, Text, View } from "@
2930
2949
  - `Banner` — the themed inline message. `<Banner tone message>` shows an error, warning, success or notice in place, styled by the theme's `alert` scope; `action` adds one button and `onDismiss` a close control. Use `useToast()` for a brief confirmation instead. Never hand-roll a tinted error box.
2931
2950
  - `Accordion`, `AccordionItem` — themed collapsible sections. `<Accordion><AccordionItem title subtitle trailing>…</AccordionItem></Accordion>` draws its surface from the theme's `card` scope and its headers and dividers from the `list` scope; one section open at a time, or `multiple`. Never hand-roll a collapsible from `Pressable` + a chevron.
2932
2951
  - `Spinner`, `Skeleton` — the themed loading states. `<Spinner size label>` for a pending action; `<Skeleton shape="text" | "block" | "circle" | "listRow">` (or a group of bones) for content whose layout you know. The spinner wears `colors.loader`, the skeleton follows the theme's `loading` scope; both stop under reduced motion and mark one busy region. Never use `ActivityIndicator` or hand-build placeholder bars.
2933
- - `Checkbox`, `Switch`, `Select`, `SearchField` — the themed tick box, on/off toggle, single-choice picker and search box. Like `TextField` they follow the theme's `muted` scope, Design > Components > Inputs (and `popover`, Dropdown menus, for the Select's option sheet; `SearchField` also reads `search`, Search fields, for its glyph position, corner radius, background, border colour and height, each falling back to Inputs) and take `label`, `helper`, `error` and `disabled`. `Select` opens its options at screen level. Never hand-roll one from `Pressable` and `View`.
2952
+ - `Checkbox`, `Switch`, `Select`, `SearchField` — the themed tick box, on/off toggle, picker and search box. Like `TextField` they follow the theme's `muted` scope, Design > Components > Inputs (and `popover`, Dropdown menus, for the Select's option sheet; `SearchField` also reads `search`, Search fields, for its glyph position, corner radius, background, border colour and height, each falling back to Inputs) and take `label`, `helper`, `error` and `disabled`. `Select` opens its options at screen level; with `multiple` it takes `values` / `onValuesChange`, ticks options in the same sheet and commits them on Done. Never hand-roll one from `Pressable` and `View`.
2934
2953
  - `Tabs` — the themed tab strip. `<Tabs items value onChange>` draws a segmented control, pills or an underline as the theme's `tabs` scope says; render the selected panel yourself. Never hand-roll tabs from `Pressable` chips.
2935
2954
  - `Chip`, `ChipGroup` — the themed filter chip, tag or pill. `<Chip label selected onPress onRemove disabled>` follows the theme's `chip` scope; `<ChipGroup>` spaces and wraps its chips and, with `selection="single"`, announces them as one choice. Never hand-roll a chip from `Pressable` + a pill radius.
2936
2955
  - `textRoleStyle(role, typography, colors)`, `TEXT_ROLES` — the style of a semantic text tier, shared with the built-in widgets.
@@ -28,6 +28,33 @@ import { makeFieldFrame } from "./form-control-view.js";
28
28
 
29
29
  const h = React.createElement;
30
30
 
31
+ /**
32
+ * The tick box itself, drawn by `<Checkbox>` and by each option of a
33
+ * `<Select multiple>`, so the two always look alike.
34
+ *
35
+ * @param {any} View — the host's View primitive.
36
+ * @param {object} t — resolved form-control tokens.
37
+ * @param {{checked: boolean, invalid?: boolean}} state
38
+ */
39
+ export function checkboxBox(View, t, { checked, invalid = false }) {
40
+ return h(
41
+ View,
42
+ {
43
+ style: {
44
+ width: CHECKBOX_SIZE,
45
+ height: CHECKBOX_SIZE,
46
+ alignItems: "center",
47
+ justifyContent: "center",
48
+ borderWidth: 1,
49
+ borderRadius: Math.min(t.radius, CHECKBOX_MAX_RADIUS),
50
+ borderColor: checked ? t.accent : invalid ? t.errorColor : t.borderColor,
51
+ backgroundColor: checked ? t.accent : t.background,
52
+ },
53
+ },
54
+ checked ? h(Icon, { name: "check", size: 16, color: t.onAccent }) : null,
55
+ );
56
+ }
57
+
31
58
  /**
32
59
  * Bind the shared checkbox to one platform's primitives.
33
60
  *
@@ -103,22 +130,7 @@ export function makeCheckbox(rn) {
103
130
  h(
104
131
  View,
105
132
  { style: focusHaloStyle(t, focused, radius) },
106
- h(
107
- View,
108
- {
109
- style: {
110
- width: CHECKBOX_SIZE,
111
- height: CHECKBOX_SIZE,
112
- alignItems: "center",
113
- justifyContent: "center",
114
- borderWidth: 1,
115
- borderRadius: radius,
116
- borderColor: checked ? t.accent : invalid ? t.errorColor : t.borderColor,
117
- backgroundColor: checked ? t.accent : t.background,
118
- },
119
- },
120
- checked ? h(Icon, { name: "check", size: 16, color: t.onAccent }) : null,
121
- ),
133
+ checkboxBox(View, t, { checked, invalid }),
122
134
  ),
123
135
  isText(label)
124
136
  ? h(
package/dist/contract.cjs 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" }),
@@ -3145,16 +3145,13 @@ const PRIMITIVES = [
3145
3145
  rnComponent: "@react-native-community/datetimepicker",
3146
3146
  docsUrl: "https://github.com/react-native-datetimepicker/datetimepicker",
3147
3147
  },
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.
3148
+ // sc-1378 — `<FilePicker accept onPick>` opens the system file picker so
3149
+ // end users can upload files (paired with useFilestoreUpload). Web wraps a
3150
+ // hidden DOM `<input type="file">`; native wraps expo-document-picker.
3154
3151
  {
3155
3152
  name: "FilePicker",
3156
3153
  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.',
3154
+ '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
3155
  rnComponent: null,
3159
3156
  docsUrl: null,
3160
3157
  },
@@ -3255,14 +3252,14 @@ const PRIMITIVES = [
3255
3252
  {
3256
3253
  name: "List",
3257
3254
  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.",
3255
+ "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
3256
  rnComponent: null,
3260
3257
  docsUrl: null,
3261
3258
  },
3262
3259
  {
3263
3260
  name: "ListRow",
3264
3261
  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.',
3262
+ '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
3263
  rnComponent: null,
3267
3264
  docsUrl: null,
3268
3265
  },
@@ -3388,7 +3385,7 @@ const PRIMITIVES = [
3388
3385
  {
3389
3386
  name: "Select",
3390
3387
  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.',
3388
+ '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
3389
  rnComponent: null,
3393
3390
  docsUrl: null,
3394
3391
  },
@@ -3420,6 +3417,14 @@ const PRIMITIVES = [
3420
3417
  rnComponent: null,
3421
3418
  docsUrl: null,
3422
3419
  },
3420
+ // sc-11751 — the standard upload field, built on `<FilePicker>`.
3421
+ {
3422
+ name: "FileUpload",
3423
+ description:
3424
+ '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.',
3425
+ rnComponent: null,
3426
+ docsUrl: null,
3427
+ },
3423
3428
  ];
3424
3429
 
3425
3430
  const CATEGORIES = [
@@ -6076,7 +6081,11 @@ const CONTRACT = deepFreeze({
6076
6081
  // colour, height, each falling back to the `muted` field. The field
6077
6082
  // takes an `iconPosition` prop. Scope labels renamed (sc-11763); keys
6078
6083
  // unchanged.
6079
- version: "1.191.0",
6084
+ // 1.192.0: additive (sc-11751, sc-11752, sc-11758) — new `<FileUpload>`
6085
+ // on `<FilePicker>` (which gains onFocus/onBlur; `muted` lists it).
6086
+ // `<List variant="card">`, `<ListRow actions>`, `defaults` on both.
6087
+ // `<Select multiple values onValuesChange>`.
6088
+ version: "1.192.0",
6080
6089
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
6081
6090
  hooks: HOOKS,
6082
6091
  primitives: PRIMITIVES,
package/dist/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" }),
@@ -3145,16 +3145,13 @@ const PRIMITIVES = [
3145
3145
  rnComponent: "@react-native-community/datetimepicker",
3146
3146
  docsUrl: "https://github.com/react-native-datetimepicker/datetimepicker",
3147
3147
  },
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.
3148
+ // sc-1378 — `<FilePicker accept onPick>` opens the system file picker so
3149
+ // end users can upload files (paired with useFilestoreUpload). Web wraps a
3150
+ // hidden DOM `<input type="file">`; native wraps expo-document-picker.
3154
3151
  {
3155
3152
  name: "FilePicker",
3156
3153
  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.',
3154
+ '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
3155
  rnComponent: null,
3159
3156
  docsUrl: null,
3160
3157
  },
@@ -3255,14 +3252,14 @@ const PRIMITIVES = [
3255
3252
  {
3256
3253
  name: "List",
3257
3254
  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.",
3255
+ "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
3256
  rnComponent: null,
3260
3257
  docsUrl: null,
3261
3258
  },
3262
3259
  {
3263
3260
  name: "ListRow",
3264
3261
  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.',
3262
+ '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
3263
  rnComponent: null,
3267
3264
  docsUrl: null,
3268
3265
  },
@@ -3388,7 +3385,7 @@ const PRIMITIVES = [
3388
3385
  {
3389
3386
  name: "Select",
3390
3387
  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.',
3388
+ '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
3389
  rnComponent: null,
3393
3390
  docsUrl: null,
3394
3391
  },
@@ -3420,6 +3417,14 @@ const PRIMITIVES = [
3420
3417
  rnComponent: null,
3421
3418
  docsUrl: null,
3422
3419
  },
3420
+ // sc-11751 — the standard upload field, built on `<FilePicker>`.
3421
+ {
3422
+ name: "FileUpload",
3423
+ description:
3424
+ '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.',
3425
+ rnComponent: null,
3426
+ docsUrl: null,
3427
+ },
3423
3428
  ];
3424
3429
 
3425
3430
  const CATEGORIES = [
@@ -6072,7 +6077,11 @@ const CONTRACT = deepFreeze({
6072
6077
  // colour, height, each falling back to the `muted` field. The field
6073
6078
  // takes an `iconPosition` prop. Scope labels renamed (sc-11763); keys
6074
6079
  // unchanged.
6075
- version: "1.191.0",
6080
+ // 1.192.0: additive (sc-11751, sc-11752, sc-11758) — new `<FileUpload>`
6081
+ // on `<FilePicker>` (which gains onFocus/onBlur; `muted` lists it).
6082
+ // `<List variant="card">`, `<ListRow actions>`, `defaults` on both.
6083
+ // `<Select multiple values onValuesChange>`.
6084
+ version: "1.192.0",
6076
6085
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
6077
6086
  hooks: HOOKS,
6078
6087
  primitives: PRIMITIVES,
@@ -0,0 +1,178 @@
1
+ // `<FileUpload>`'s pure rules: how a size reads, whether a file matches an
2
+ // HTML `accept` value, and how a pick joins the chosen list. No renderer
3
+ // here, so the node suite tests them and both hosts share them.
4
+
5
+ const SIZE_UNITS = ["KB", "MB", "GB", "TB"];
6
+
7
+ /**
8
+ * A byte count as people read it: "512 B", "1.5 KB", "12 MB". Null when the
9
+ * host gave no usable size, so the caller shows nothing rather than "0 B".
10
+ *
11
+ * @param {unknown} bytes
12
+ */
13
+ export function formatFileSize(bytes) {
14
+ if (typeof bytes !== "number" || !Number.isFinite(bytes) || bytes < 0) return null;
15
+ if (bytes < 1024) return `${Math.round(bytes)} B`;
16
+ let value = bytes / 1024;
17
+ let unit = 0;
18
+ while (value >= 1024 && unit < SIZE_UNITS.length - 1) {
19
+ value /= 1024;
20
+ unit += 1;
21
+ }
22
+ const rounded = value < 10 ? Math.round(value * 10) / 10 : Math.round(value);
23
+ // 1023.95 KB rounds to 1024, which reads better as the next unit.
24
+ if (rounded >= 1024 && unit < SIZE_UNITS.length - 1) return `1 ${SIZE_UNITS[unit + 1]}`;
25
+ return `${rounded} ${SIZE_UNITS[unit]}`;
26
+ }
27
+
28
+ /**
29
+ * The tokens of an HTML `accept` value, lower-cased: ".pdf", "image/*",
30
+ * "application/pdf". Empty when every file is accepted.
31
+ *
32
+ * @param {unknown} accept
33
+ */
34
+ export function parseAccept(accept) {
35
+ if (typeof accept !== "string") return [];
36
+ const tokens = accept
37
+ .split(",")
38
+ .map((token) => token.trim().toLowerCase().split(";")[0].trim())
39
+ .filter(Boolean);
40
+ return tokens.some((token) => token === "*" || token === "*/*") ? [] : tokens;
41
+ }
42
+
43
+ // What a host reports for a file whose type it could not tell.
44
+ const UNKNOWN_TYPES = new Set(["", "application/octet-stream"]);
45
+
46
+ function fileType(file) {
47
+ return file && typeof file.type === "string" ? file.type.trim().toLowerCase().split(";")[0] : "";
48
+ }
49
+
50
+ function fileName(file) {
51
+ return file && typeof file.name === "string" ? file.name.toLowerCase() : "";
52
+ }
53
+
54
+ /**
55
+ * Whether `file` matches `accept`, read as the browser reads the attribute.
56
+ *
57
+ * A file whose type the host could not tell passes a type-only `accept`:
58
+ * the server, which sniffs the bytes, stays the authority on those, so a
59
+ * real document is never refused here for a missing type.
60
+ *
61
+ * @param {{name?: string, type?: string}} file
62
+ * @param {unknown} accept
63
+ */
64
+ export function isAccepted(file, accept) {
65
+ const tokens = parseAccept(accept);
66
+ if (tokens.length === 0) return true;
67
+ const type = fileType(file);
68
+ const name = fileName(file);
69
+ const matches = tokens.some((token) => {
70
+ if (token.startsWith(".")) return name.endsWith(token);
71
+ if (token.endsWith("/*")) return type.startsWith(token.slice(0, -1));
72
+ return type === token;
73
+ });
74
+ if (matches) return true;
75
+ const byExtension = tokens.some((token) => token.startsWith("."));
76
+ return UNKNOWN_TYPES.has(type) && !byExtension;
77
+ }
78
+
79
+ /**
80
+ * Split picked or dropped files into the ones `accept` admits and the rest.
81
+ *
82
+ * @param {unknown} picked — one file or an array of them.
83
+ * @param {unknown} accept
84
+ */
85
+ export function partitionAccepted(picked, accept) {
86
+ const list = toFileList(picked);
87
+ const accepted = [];
88
+ const rejected = [];
89
+ for (const file of list) (isAccepted(file, accept) ? accepted : rejected).push(file);
90
+ return { accepted, rejected };
91
+ }
92
+
93
+ /** One file or an array of them, as an array without holes. */
94
+ export function toFileList(picked) {
95
+ if (Array.isArray(picked)) return picked.filter(Boolean);
96
+ return picked ? [picked] : [];
97
+ }
98
+
99
+ /** A key that tells two chosen files apart in a list. */
100
+ export function fileKey(file, index) {
101
+ const name = file && typeof file.name === "string" ? file.name : "file";
102
+ const size = file && typeof file.size === "number" ? file.size : "";
103
+ return `${index}:${name}:${size}`;
104
+ }
105
+
106
+ function sameFile(a, b) {
107
+ if (a === b) return true;
108
+ return (
109
+ Boolean(a && b) &&
110
+ a.name === b.name &&
111
+ a.size === b.size &&
112
+ (a.lastModified === undefined || a.lastModified === b.lastModified) &&
113
+ (a.uri === undefined || a.uri === b.uri)
114
+ );
115
+ }
116
+
117
+ /**
118
+ * The picked files that are new to the list. One file replaces the list, so
119
+ * it is always new; with `multiple` a file already listed, or picked twice at
120
+ * once, is not.
121
+ *
122
+ * @param {Array<object>} current
123
+ * @param {Array<object>} accepted — the picked files that passed `accept`.
124
+ * @param {boolean} multiple
125
+ */
126
+ export function newlyPicked(current, accepted, multiple) {
127
+ if (accepted.length === 0) return [];
128
+ if (!multiple) return [accepted[0]];
129
+ const seen = Array.isArray(current) ? [...current] : [];
130
+ const added = [];
131
+ for (const file of accepted) {
132
+ if (seen.some((kept) => sameFile(kept, file))) continue;
133
+ seen.push(file);
134
+ added.push(file);
135
+ }
136
+ return added;
137
+ }
138
+
139
+ /**
140
+ * The chosen list after a pick: one file replaces the list, several join it
141
+ * without adding the same file twice.
142
+ *
143
+ * @param {Array<object>} current
144
+ * @param {Array<object>} accepted — the picked files that passed `accept`.
145
+ * @param {boolean} multiple
146
+ */
147
+ export function mergePicked(current, accepted, multiple) {
148
+ const now = Array.isArray(current) ? current : [];
149
+ const added = newlyPicked(now, accepted, multiple);
150
+ if (added.length === 0) return now;
151
+ return multiple ? [...now, ...added] : added;
152
+ }
153
+
154
+ /**
155
+ * What one pick or drop does: the files to add and send to `onPick`, the
156
+ * ones `accept` refused, and in single mode the files beyond the first.
157
+ *
158
+ * @param {Array<object>} current — the listed files.
159
+ * @param {unknown} picked — one file or an array of them.
160
+ * @param {unknown} accept
161
+ * @param {boolean} multiple
162
+ */
163
+ export function planPick(current, picked, accept, multiple) {
164
+ const now = Array.isArray(current) ? current : [];
165
+ const { accepted, rejected } = partitionAccepted(picked, accept);
166
+ const added = newlyPicked(now, accepted, multiple);
167
+ return {
168
+ added,
169
+ rejected,
170
+ extra: multiple ? [] : accepted.slice(1),
171
+ next: mergePicked(now, accepted, multiple),
172
+ };
173
+ }
174
+
175
+ /** What `onPick` receives: one file, or an array when `multiple`. */
176
+ export function pickPayload(accepted, multiple) {
177
+ return multiple ? accepted : accepted[0];
178
+ }