@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.
- package/README.md +39 -4
- package/dist/checkbox-view.js +28 -16
- package/dist/contract.cjs +26 -12
- package/dist/contract.js +26 -12
- package/dist/file-upload-logic.js +178 -0
- package/dist/file-upload-view.js +321 -0
- package/dist/file-upload.js +62 -0
- package/dist/file-upload.native.js +11 -0
- package/dist/filepicker.js +11 -0
- package/dist/filepicker.native.js +10 -1
- package/dist/form-control-view.js +8 -1
- package/dist/index.d.ts +162 -16
- package/dist/index.js +2 -0
- package/dist/index.native.js +2 -0
- package/dist/list-tokens.js +54 -14
- package/dist/list-view.js +199 -82
- package/dist/list.js +7 -1
- package/dist/list.native.js +3 -2
- package/dist/primitives.js +1 -3
- package/dist/primitives.native.js +2 -4
- package/dist/property-schema.js +6 -0
- package/dist/select-logic.js +83 -0
- package/dist/select-view.js +165 -32
- package/dist/select.js +6 -1
- package/dist/select.native.js +5 -3
- package/package.json +1 -1
- package/src/contract.js +26 -12
package/README.md
CHANGED
|
@@ -24,7 +24,7 @@ The data layer lives in **four separate domain-client packages**, each instantia
|
|
|
24
24
|
| **CORE** | `useUser()` | `{ id, email, displayName, photoUrl, roles, groupIds }` | `ctx.user` (host-built context, **camelCase** — not a wire payload; `id` null when anonymous) — no scope. `photoUrl` is the user's own profile photo as an absolute URL, or `null`. |
|
|
25
25
|
| **CORE** | `useUserPhoto()` | `{ url, canEdit, upload, remove, uploading, error }` | `ctx.user` + the optional `ctx.account` — no scope. Changes the signed-in user's OWN profile photo. `canEdit` is false when nobody is signed in or the host cannot change it; `upload` / `remove` then reject. |
|
|
26
26
|
| **CORE** | `useAnonymousAccount()` | `{ supported, canCreate, creating, error, create }` | The optional `ctx.account.anonymous` — no scope. Offers an account with a generated login code and password and no email. `create()` opens the app's own dialog, which confirms, creates the account, shows the pair once and signs the visitor in; the widget never receives the pair and `create()` resolves `{ created: true }`. `supported` is false where the workspace has not turned anonymous accounts on, on the Builder canvas, in a preview and for a badge counter; `canCreate` is false while someone is signed in. |
|
|
27
|
-
| **CORE** | `useNavigation()` | `{ goTo, goToStartPage, goBack, push, replace, back, currentRoute, openLink }` | `ctx.navigation` — no scope. `goTo(pageIdOrSlug, params?)` accepts a page UUID (a `pageRef` prop) **or its slug** (a row's page-key field) — both hosts resolve either; `goToStartPage()` opens the signed-in user's own start page (`openLink` for a link of unknown shape; a known external URL can also use the `Linking` primitive) |
|
|
27
|
+
| **CORE** | `useNavigation()` | `{ goTo, goToStartPage, scrollToSection, goBack, push, replace, back, currentRoute, openLink }` | `ctx.navigation` — no scope. `goTo(pageIdOrSlug, params?)` accepts a page UUID (a `pageRef` prop) **or its slug** (a row's page-key field) — both hosts resolve either; `goToStartPage()` opens the signed-in user's own start page; `scrollToSection(id)` scrolls this page to a section picked with a `sectionRef` prop (`openLink` for a link of unknown shape; a known external URL can also use the `Linking` primitive) |
|
|
28
28
|
| **CORE** | `useFilePreview(file)` | `{ uri }` | No scope. A temporary address an `<Image>` can show for a file the person picked but has not uploaded yet (a photo in a composer). Native picks use their own uri; on the web the SDK creates and releases an object URL, which widgets may not do themselves. `uri` is null for no file. |
|
|
29
29
|
| **CORE** | `usePageBadge(count)` | `{ supported }` | `ctx.navigation.setBadge` — no scope. Puts `count` on the menu entry or tab of the page the widget is on (a chat's unread messages). The host sums the page's widgets and keeps the last count after the widget leaves the screen, until it reports again; `0` clears this widget's share. `supported` is false where the host draws no page badges (the Builder canvas). |
|
|
30
30
|
| **CORE** | `useRouteParams()` | `{ [paramKey]: value }` | `ctx.navigation.currentRoute.params` — no scope. The nav params the previous page passed via `goTo(pageId, params)`; the flat accessor for master→detail (read `recordId` on a detail page). Empty object when none. |
|
|
@@ -84,7 +84,42 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
|
|
|
84
84
|
|
|
85
85
|
## Status
|
|
86
86
|
|
|
87
|
-
`v0.
|
|
87
|
+
`v0.239.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.239.0 (contract 1.193.0)
|
|
90
|
+
|
|
91
|
+
**A widget can scroll its page to a section — `useNavigation().scrollToSection(sectionId)`.** A one-page site jumps from "See pricing" to the pricing section further down. Bind the target with a `sectionRef` property: the Studio renders a picker of the page's sections (its root sections and every layout container), and the value is the id you pass to `scrollToSection`.
|
|
92
|
+
|
|
93
|
+
```jsx
|
|
94
|
+
import { useNavigation, Button } from "@colixsystems/widget-sdk";
|
|
95
|
+
|
|
96
|
+
export default function JumpButton({ label, target }) {
|
|
97
|
+
const { scrollToSection } = useNavigation();
|
|
98
|
+
return <Button title={label} onPress={() => scrollToSection(target)} />;
|
|
99
|
+
}
|
|
100
|
+
// propertySchema: { target: { type: "sectionRef", label: "Section" } }
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
The web Player and the exported app both scroll smoothly (instantly under reduced motion) and stop with the section just below a pinned site header. An unknown, deleted or hidden section does nothing, and so does the Studio canvas. Additive.
|
|
104
|
+
|
|
105
|
+
### What's new in 0.238.0 (contract 1.192.0)
|
|
106
|
+
|
|
107
|
+
**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.
|
|
108
|
+
|
|
109
|
+
```jsx
|
|
110
|
+
const { upload, uploading } = useFilestoreUpload({ spaceType: "project" });
|
|
111
|
+
<FileUpload label="Attachment" accept="application/pdf,image/*" uploading={uploading} onPick={(file) => upload(file)} />
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
**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.
|
|
115
|
+
|
|
116
|
+
**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.
|
|
117
|
+
|
|
118
|
+
```jsx
|
|
119
|
+
<Select multiple label={t("tags", "Tags")} values={tags} onValuesChange={setTags}
|
|
120
|
+
doneText={t("done", "Done")} selectedCountText={(n) => `${n} ${t("picked", "picked")}`}
|
|
121
|
+
options={[{ value: "red", label: "Red" }, { value: "blue", label: "Blue" }]} />
|
|
122
|
+
```
|
|
88
123
|
|
|
89
124
|
### What's new in 0.237.0 (contract 1.191.0)
|
|
90
125
|
|
|
@@ -2914,7 +2949,7 @@ import { defineWidget, validateManifest, useDatastoreQuery, Text, View } from "@
|
|
|
2914
2949
|
|
|
2915
2950
|
- `defineWidget({ manifest, component })` — validates the manifest and produces a widget module the host can register.
|
|
2916
2951
|
- `validateManifest(m)` / `validatePropertySchema(s)` / `validateProps(schema, props)` — shape validation; no third-party deps.
|
|
2917
|
-
- `useDatastoreQuery`, `useDatastoreRecord`, `useDatastoreSchema`, `useDatastoreMutation`, `useDirectory`, `useUsers`, `useGroups`, `useRecordPermissions`, `useAsset`, `useWidgetEvent`, `useWidgetInput`, `usePayments`, `useSendNotification`, `useTheme`, `useI18n`, `useUser`, `useNavigation`, `useRouteParams`, `usePageContext`, `useWidgetRoute`, `useChildRenderer`, `useClipboard`, `useToast` — hooks that read from the host-provided `WidgetContext` (or, for `useClipboard`, the platform clipboard API directly). `useDirectory(query?)` returns `{ users, loading, error, refetch }` (each user `{ id, name, role }`) and requires the `directory.read:users` scope. `useUsers(query?)` returns `{ users, loading, error, refetch, invite, deactivate, reactivate, remove }` and requires `users.read:*` (mutations also need `users.write:*`); rejections are a `DirectoryError`. `useGroups(query?)` returns `{ groups, loading, error, refetch, create, remove, addMember, removeMember }` and requires `groups.read:*` (mutations also need `groups.write:*`). `usePayments()` returns `{ requestPayment, getPayment }` and requires the `payments.charge:appUser` scope; `requestPayment(...)` rejects with a `PaymentError` carrying `code`, the server's user-safe `message`, and `retryable` (`false` = this charge cannot succeed until the workspace, manifest, or amount changes — show the message, not a retry). `useSendNotification()` returns `{ send, sending, error }` and requires the `notifications.send:appUser` scope; `send({ recipient_user_id, title, body, link?, payload? })` notifies one app user in the same workspace (cross-workspace `recipient_user_id` is rejected), must be called from an event handler rather than render, and rejects with a `NotificationError`. `useUser()` returns the active end-user identity `{ id, email, displayName, roles, groupIds }` (camelCase — the host-built context object, not a wire payload; `id` is `null` for anonymous / preview). `useNavigation()` returns `{ goTo, goToStartPage, goBack, push, replace, back, currentRoute, openLink }` for internal page navigation; `goToStartPage()` opens the signed-in user's own start page (the page the app opens on for them); `openLink(link)` follows an author- or data-supplied link of unknown shape through the host's shared resolver (in-app page → internal route, off-app http(s) → opened outside, unsafe → refused) and is the safe choice for any value your code did not construct, while `Linking.openURL(url)` is for an external URL you built yourself. `useRouteParams()` returns the current route's params object (`currentRoute.params`) — the flat master→detail accessor; read a param off it (e.g. `recordId`), never call it. `useDatastoreRecord(tableId, recordId)` returns `{ data, loading, error, refetch }` for a single record (data is one row or null). `useDatastoreSchema(tableId)` returns `{ schema, loading, error, refetch }` where `schema` is `{ id, name, columns: [{ id, name, data_type, required, relation_type, target_table_id, is_identification }] }` (structure only, no row data; snake_case verbatim) — use it to resolve a stored `columnId` to its column type at runtime; requires the `datastore.read:<table>` scope. `useAsset(fileId)` returns `{ url, file, loading, error, refetch }` — the `url` is an absolute URL composed against the host's API base. `useChildRenderer()` returns `{ renderNode(node) }` — container widgets call it to render arbitrary child page-tree nodes (prefer the `WidgetTree` component for the common case). `useWidgetInput(inputName)` returns the latest payload a sibling widget published on the event the page author wired to this widget's declared `inputs` entry (`undefined` when unwired or not yet published).
|
|
2952
|
+
- `useDatastoreQuery`, `useDatastoreRecord`, `useDatastoreSchema`, `useDatastoreMutation`, `useDirectory`, `useUsers`, `useGroups`, `useRecordPermissions`, `useAsset`, `useWidgetEvent`, `useWidgetInput`, `usePayments`, `useSendNotification`, `useTheme`, `useI18n`, `useUser`, `useNavigation`, `useRouteParams`, `usePageContext`, `useWidgetRoute`, `useChildRenderer`, `useClipboard`, `useToast` — hooks that read from the host-provided `WidgetContext` (or, for `useClipboard`, the platform clipboard API directly). `useDirectory(query?)` returns `{ users, loading, error, refetch }` (each user `{ id, name, role }`) and requires the `directory.read:users` scope. `useUsers(query?)` returns `{ users, loading, error, refetch, invite, deactivate, reactivate, remove }` and requires `users.read:*` (mutations also need `users.write:*`); rejections are a `DirectoryError`. `useGroups(query?)` returns `{ groups, loading, error, refetch, create, remove, addMember, removeMember }` and requires `groups.read:*` (mutations also need `groups.write:*`). `usePayments()` returns `{ requestPayment, getPayment }` and requires the `payments.charge:appUser` scope; `requestPayment(...)` rejects with a `PaymentError` carrying `code`, the server's user-safe `message`, and `retryable` (`false` = this charge cannot succeed until the workspace, manifest, or amount changes — show the message, not a retry). `useSendNotification()` returns `{ send, sending, error }` and requires the `notifications.send:appUser` scope; `send({ recipient_user_id, title, body, link?, payload? })` notifies one app user in the same workspace (cross-workspace `recipient_user_id` is rejected), must be called from an event handler rather than render, and rejects with a `NotificationError`. `useUser()` returns the active end-user identity `{ id, email, displayName, roles, groupIds }` (camelCase — the host-built context object, not a wire payload; `id` is `null` for anonymous / preview). `useNavigation()` returns `{ goTo, goToStartPage, scrollToSection, goBack, push, replace, back, currentRoute, openLink }` for internal page navigation; `goToStartPage()` opens the signed-in user's own start page (the page the app opens on for them); `scrollToSection(sectionId)` scrolls the current page to one of its sections (bind the id with a `sectionRef` prop); `openLink(link)` follows an author- or data-supplied link of unknown shape through the host's shared resolver (in-app page → internal route, off-app http(s) → opened outside, unsafe → refused) and is the safe choice for any value your code did not construct, while `Linking.openURL(url)` is for an external URL you built yourself. `useRouteParams()` returns the current route's params object (`currentRoute.params`) — the flat master→detail accessor; read a param off it (e.g. `recordId`), never call it. `useDatastoreRecord(tableId, recordId)` returns `{ data, loading, error, refetch }` for a single record (data is one row or null). `useDatastoreSchema(tableId)` returns `{ schema, loading, error, refetch }` where `schema` is `{ id, name, columns: [{ id, name, data_type, required, relation_type, target_table_id, is_identification }] }` (structure only, no row data; snake_case verbatim) — use it to resolve a stored `columnId` to its column type at runtime; requires the `datastore.read:<table>` scope. `useAsset(fileId)` returns `{ url, file, loading, error, refetch }` — the `url` is an absolute URL composed against the host's API base. `useChildRenderer()` returns `{ renderNode(node) }` — container widgets call it to render arbitrary child page-tree nodes (prefer the `WidgetTree` component for the common case). `useWidgetInput(inputName)` returns the latest payload a sibling widget published on the event the page author wired to this widget's declared `inputs` entry (`undefined` when unwired or not yet published).
|
|
2918
2953
|
- `WidgetTree({ node })` — component that renders an author-authored child node through the host's renderer; used by Tabs / Card / custom containers to host arbitrary child widgets.
|
|
2919
2954
|
- `Text`, `View`, `Pressable`, `Image`, `ScrollView`, `TextInput`, `FlatList`, `SectionList`, `ActivityIndicator`, `Switch`, `StyleSheet`, `Linking`, `Icon`, `DateTimePicker` — re-exported from `react-native` (the RN primitives) or implemented in the SDK (`Icon` wraps `lucide-react-native`; `DateTimePicker` wraps `@react-native-community/datetimepicker` on native and renders `<input type="date|time|datetime-local">` directly on web because the RN library has no react-native-web mapping). The web build aliases `react-native` to `react-native-web` so the RN-re-exported primitives render in the browser without any per-platform code; the exported Expo app's Metro bundler resolves the real `react-native` library. `Linking` is a static API (`Linking.openURL(url)`) — use it for external URLs, and use `useNavigation().goTo(pageId)` for internal page navigation. See https://reactnative.dev/docs/ for per-component props.
|
|
2920
2955
|
- `Overlay` — the screen-level overlay (sc-6607). `<Overlay visible={!!preview} onRequestClose={() => setPreview(null)} size="full">…</Overlay>` renders its children OUTSIDE the widget's layout box on both hosts (web portals it to the document root, native uses the OS modal), so no clipping card, `ScrollView` or neighbouring widget can cut off a document/media preview, lightbox or confirm dialog — which absolute positioning inside the widget cannot achieve on either platform. Children stay in your own React tree, so state and hooks work normally. `onRequestClose` carries the backdrop press, Escape on web, and the Android back button; `size` is `sm` | `md` (default) | `lg` | `full`; the scrim, surface, radius, padding and elevation come from the workspace theme. An anchored dropdown still belongs inside your own root.
|
|
@@ -2930,7 +2965,7 @@ import { defineWidget, validateManifest, useDatastoreQuery, Text, View } from "@
|
|
|
2930
2965
|
- `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
2966
|
- `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
2967
|
- `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,
|
|
2968
|
+
- `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
2969
|
- `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
2970
|
- `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
2971
|
- `textRoleStyle(role, typography, colors)`, `TEXT_ROLES` — the style of a semantic text tier, shared with the built-in widgets.
|
package/dist/checkbox-view.js
CHANGED
|
@@ -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
|
-
|
|
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" }),
|
|
@@ -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>`
|
|
3149
|
-
//
|
|
3150
|
-
//
|
|
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
|
|
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
|
|
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 = [
|
|
@@ -6076,7 +6083,14 @@ const CONTRACT = deepFreeze({
|
|
|
6076
6083
|
// colour, height, each falling back to the `muted` field. The field
|
|
6077
6084
|
// takes an `iconPosition` prop. Scope labels renamed (sc-11763); keys
|
|
6078
6085
|
// unchanged.
|
|
6079
|
-
|
|
6086
|
+
// 1.192.0: additive (sc-11751, sc-11752, sc-11758) — new `<FileUpload>`
|
|
6087
|
+
// on `<FilePicker>` (which gains onFocus/onBlur; `muted` lists it).
|
|
6088
|
+
// `<List variant="card">`, `<ListRow actions>`, `defaults` on both.
|
|
6089
|
+
// `<Select multiple values onValuesChange>`.
|
|
6090
|
+
// 1.193.0: additive (sc-11821) — `useNavigation().scrollToSection(id)`
|
|
6091
|
+
// scrolls the page to one of its sections on both hosts, and the
|
|
6092
|
+
// `sectionRef` propertySchema type gives the author a section picker.
|
|
6093
|
+
version: "1.193.0",
|
|
6080
6094
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
6081
6095
|
hooks: HOOKS,
|
|
6082
6096
|
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" }),
|
|
@@ -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>`
|
|
3149
|
-
//
|
|
3150
|
-
//
|
|
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
|
|
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
|
|
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
|
-
|
|
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,
|
|
@@ -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
|
+
}
|