@colixsystems/widget-sdk 0.236.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 +29 -4
- package/dist/checkbox-view.js +28 -16
- package/dist/contract.cjs +57 -24
- package/dist/contract.js +57 -24
- 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/host.d.ts +6 -0
- package/dist/host.js +2 -0
- package/dist/index.d.ts +168 -19
- 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/search-field-tokens.js +85 -0
- package/dist/search-field-view.js +39 -26
- 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 +57 -24
package/dist/contract.js
CHANGED
|
@@ -275,6 +275,9 @@ function formatThemeColorTokenRef(path, percent) {
|
|
|
275
275
|
// The looks a `tabs.style` token may name; `<Tabs>` draws each.
|
|
276
276
|
const THEME_TAB_STYLES = Object.freeze(["segmented", "pill", "underline"]);
|
|
277
277
|
|
|
278
|
+
// Where `<SearchField>` draws its magnifier; "start" is the look it always had.
|
|
279
|
+
const THEME_SEARCH_ICON_POSITIONS = Object.freeze(["start", "end", "none"]);
|
|
280
|
+
|
|
278
281
|
const THEME_COMPONENT_SHADOWS = Object.freeze(["none", "sm", "md", "lg"]);
|
|
279
282
|
|
|
280
283
|
// sc-3857 — the `textTransform` token's closed enum, matching the per-instance
|
|
@@ -585,7 +588,7 @@ const THEME_COMPONENTS = Object.freeze({
|
|
|
585
588
|
// than its `color` field, which the `text` scope already owns -- two
|
|
586
589
|
// scopes writing the same field on the same widget would race.
|
|
587
590
|
accent: Object.freeze({
|
|
588
|
-
label: "
|
|
591
|
+
label: "Labels",
|
|
589
592
|
tokens: Object.freeze({
|
|
590
593
|
background: Object.freeze({ type: "color", uiDefault: "colors.primarySoft" }),
|
|
591
594
|
borderColor: Object.freeze({ type: "color", uiDefault: "colors.primary" }),
|
|
@@ -602,7 +605,7 @@ const THEME_COMPONENTS = Object.freeze({
|
|
|
602
605
|
// sc-5392 -- the Button widget's danger/delete variant. Separate token set
|
|
603
606
|
// from `button` above so "make the buttons coral" never recolours Delete.
|
|
604
607
|
destructive: Object.freeze({
|
|
605
|
-
label: "
|
|
608
|
+
label: "Danger buttons",
|
|
606
609
|
tokens: Object.freeze({
|
|
607
610
|
background: Object.freeze({ type: "color", uiDefault: "colors.danger" }),
|
|
608
611
|
textColor: Object.freeze({ type: "color", uiDefault: "colors.onPrimary" }),
|
|
@@ -617,9 +620,9 @@ const THEME_COMPONENTS = Object.freeze({
|
|
|
617
620
|
// scope) rather than to Text/Label/Data Value, whose `color` field the
|
|
618
621
|
// `text` scope already owns.
|
|
619
622
|
muted: Object.freeze({
|
|
620
|
-
label: "
|
|
623
|
+
label: "Inputs",
|
|
621
624
|
// The SDK's form controls read this scope directly, in every widget.
|
|
622
|
-
sdkComponents: Object.freeze(["TextField", "Checkbox", "Switch", "Select", "SearchField"]),
|
|
625
|
+
sdkComponents: Object.freeze(["TextField", "Checkbox", "Switch", "Select", "SearchField", "FileUpload"]),
|
|
623
626
|
tokens: Object.freeze({
|
|
624
627
|
background: Object.freeze({ type: "color", uiDefault: "colors.surfaceMuted" }),
|
|
625
628
|
textColor: Object.freeze({ type: "color", uiDefault: "colors.onSurfaceMuted" }),
|
|
@@ -636,7 +639,7 @@ const THEME_COMPONENTS = Object.freeze({
|
|
|
636
639
|
// sc-5392 -- dropdown/menu surfaces. The form widgets' choice-field option
|
|
637
640
|
// list is the closest thing the built-in catalog has to a popover panel.
|
|
638
641
|
popover: Object.freeze({
|
|
639
|
-
label: "
|
|
642
|
+
label: "Dropdown menus",
|
|
640
643
|
sdkComponents: Object.freeze(["Select"]),
|
|
641
644
|
tokens: Object.freeze({
|
|
642
645
|
background: Object.freeze({ type: "color", uiDefault: "colors.surface" }),
|
|
@@ -648,11 +651,26 @@ const THEME_COMPONENTS = Object.freeze({
|
|
|
648
651
|
"appstudio.form-builder": POPOVER_FIELDS,
|
|
649
652
|
}),
|
|
650
653
|
}),
|
|
651
|
-
//
|
|
654
|
+
// Search boxes: `<SearchField>` reads this scope directly. Every token is
|
|
655
|
+
// unset by default and falls back to the `muted` field it draws.
|
|
656
|
+
search: Object.freeze({
|
|
657
|
+
label: "Search fields",
|
|
658
|
+
sdkComponents: Object.freeze(["SearchField"]),
|
|
659
|
+
tokens: Object.freeze({
|
|
660
|
+
iconPosition: Object.freeze({ type: "enum", values: THEME_SEARCH_ICON_POSITIONS }),
|
|
661
|
+
// 48 is past half the tallest field, so its ceiling draws a pill.
|
|
662
|
+
radius: Object.freeze({ type: "size", min: 0, max: 48 }),
|
|
663
|
+
background: Object.freeze({ type: "color" }),
|
|
664
|
+
borderColor: Object.freeze({ type: "color" }),
|
|
665
|
+
height: Object.freeze({ type: "size", min: 32, max: 72 }),
|
|
666
|
+
}),
|
|
667
|
+
targets: Object.freeze({}),
|
|
668
|
+
}),
|
|
669
|
+
// sc-5392 -- not a keyboard focus style: a permanent emphasis border
|
|
652
670
|
// on the Button widget, applied after its variant colours and beaten by the
|
|
653
671
|
// widget's own per-instance `borderColor` (REQ-THEME-13 stays final).
|
|
654
672
|
ring: Object.freeze({
|
|
655
|
-
label: "
|
|
673
|
+
label: "Button outline",
|
|
656
674
|
tokens: Object.freeze({
|
|
657
675
|
color: Object.freeze({ type: "color", uiDefault: "colors.primary" }),
|
|
658
676
|
width: Object.freeze({ type: "size", min: 0, max: 8 }),
|
|
@@ -3093,7 +3111,7 @@ const PRIMITIVES = [
|
|
|
3093
3111
|
{
|
|
3094
3112
|
name: "Switch",
|
|
3095
3113
|
description:
|
|
3096
|
-
'Themed on/off toggle. `<Switch label="Email me updates" value={on} onValueChange={setOn} />` — THE way to draw a setting that takes effect at once. It is the platform\'s own switch, so each host gives it its switch role, state and keys; its "on" track is the app\'s primary colour and its "off" track the input border (Design > Components >
|
|
3114
|
+
'Themed on/off toggle. `<Switch label="Email me updates" value={on} onValueChange={setOn} />` — THE way to draw a setting that takes effect at once. It is the platform\'s own switch, so each host gives it its switch role, state and keys; its "on" track is the app\'s primary colour and its "off" track the input border (Design > Components > Inputs). `label` draws the text beside it and makes the text tappable too, `helper` a hint below, `error` a message in its place (or `true` to mark it without one), `disabled` stops it (a device greys it; on the web it is drawn at half strength unless you passed your own colours). Always pass `label`, or `accessibilityLabel` when a visible label already sits next to it. Controlled — `value` + `onValueChange(next)`. Every React Native `Switch` prop still works: a `trackColor` you pass overrides the theme for the states it names, and `thumbColor` colours the thumb. Never hand-roll a toggle from `Pressable` + a sliding `View`.',
|
|
3097
3115
|
rnComponent: "Switch",
|
|
3098
3116
|
docsUrl: "https://reactnative.dev/docs/switch",
|
|
3099
3117
|
},
|
|
@@ -3127,16 +3145,13 @@ const PRIMITIVES = [
|
|
|
3127
3145
|
rnComponent: "@react-native-community/datetimepicker",
|
|
3128
3146
|
docsUrl: "https://github.com/react-native-datetimepicker/datetimepicker",
|
|
3129
3147
|
},
|
|
3130
|
-
// sc-1378 — `<FilePicker accept onPick>`
|
|
3131
|
-
//
|
|
3132
|
-
//
|
|
3133
|
-
// a hidden DOM `<input type="file">`; native renders a disabled trigger
|
|
3134
|
-
// and exposes `FilePicker.isSupported = false` until a vetted Expo
|
|
3135
|
-
// 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.
|
|
3136
3151
|
{
|
|
3137
3152
|
name: "FilePicker",
|
|
3138
3153
|
description:
|
|
3139
|
-
'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
|
|
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.',
|
|
3140
3155
|
rnComponent: null,
|
|
3141
3156
|
docsUrl: null,
|
|
3142
3157
|
},
|
|
@@ -3202,7 +3217,7 @@ const PRIMITIVES = [
|
|
|
3202
3217
|
{
|
|
3203
3218
|
name: "ConfirmDialog",
|
|
3204
3219
|
description:
|
|
3205
|
-
'Standard confirmation prompt, a preset of `<Modal>`. `<ConfirmDialog visible={!!pending} tone="destructive" title="Delete this order?" message="This cannot be undone." confirmLabel={t("delete", "Delete")} onCancel={() => setPending(null)} onConfirm={async () => { await remove(pending.id); setPending(null); }} />`. THE way to ask "are you sure?" before a delete or any other action that cannot be undone, so every confirmation in the app has the same wording slots, button order and colours. Never hand-build one from a `<Modal>` or an `<Overlay>` with your own two buttons, never arm a button to be pressed twice, and never use `Alert.alert` or `window.confirm`. Props: `visible` (boolean), `title` (string, the question), `message` (what confirming does; text or a node), `confirmLabel` and `cancelLabel` (default the app\'s translated "Confirm" and "Cancel" — name the action: "Delete", "Remove", "Send"), `tone` ("default" | "destructive"; destructive draws the confirm button from the theme\'s
|
|
3220
|
+
'Standard confirmation prompt, a preset of `<Modal>`. `<ConfirmDialog visible={!!pending} tone="destructive" title="Delete this order?" message="This cannot be undone." confirmLabel={t("delete", "Delete")} onCancel={() => setPending(null)} onConfirm={async () => { await remove(pending.id); setPending(null); }} />`. THE way to ask "are you sure?" before a delete or any other action that cannot be undone, so every confirmation in the app has the same wording slots, button order and colours. Never hand-build one from a `<Modal>` or an `<Overlay>` with your own two buttons, never arm a button to be pressed twice, and never use `Alert.alert` or `window.confirm`. Props: `visible` (boolean), `title` (string, the question), `message` (what confirming does; text or a node), `confirmLabel` and `cancelLabel` (default the app\'s translated "Confirm" and "Cancel" — name the action: "Delete", "Remove", "Send"), `tone` ("default" | "destructive"; destructive draws the confirm button from the theme\'s Danger buttons colours and opens with focus on Cancel), `busy` (boolean; disables both buttons, shows a `<Spinner>` and keeps the dialog open), `onConfirm` (return the promise of the action and the dialog stays busy until it settles, with a second press ignored; a rejection is NOT caught, so catch a failure yourself and report it), `onCancel` (fired by the Cancel button, the close button, the backdrop press, the Escape key on web and the Android back button — cancelling is always the safe default), `closeAccessibilityLabel` and `testID`. The dialog never closes itself: set `visible` false from `onCancel` and after the action. Its panel follows the theme (Design > Components > Dialogs) and its buttons the Buttons and Destructive settings — do not re-style it.',
|
|
3206
3221
|
rnComponent: null,
|
|
3207
3222
|
docsUrl: null,
|
|
3208
3223
|
},
|
|
@@ -3237,14 +3252,14 @@ const PRIMITIVES = [
|
|
|
3237
3252
|
{
|
|
3238
3253
|
name: "List",
|
|
3239
3254
|
description:
|
|
3240
|
-
"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.",
|
|
3241
3256
|
rnComponent: null,
|
|
3242
3257
|
docsUrl: null,
|
|
3243
3258
|
},
|
|
3244
3259
|
{
|
|
3245
3260
|
name: "ListRow",
|
|
3246
3261
|
description:
|
|
3247
|
-
'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.',
|
|
3248
3263
|
rnComponent: null,
|
|
3249
3264
|
docsUrl: null,
|
|
3250
3265
|
},
|
|
@@ -3349,35 +3364,35 @@ const PRIMITIVES = [
|
|
|
3349
3364
|
{
|
|
3350
3365
|
name: "Button",
|
|
3351
3366
|
description:
|
|
3352
|
-
'Themed button. `<Button title="Save" onPress={save} />` — THE way to draw a button: its fill, label colour, border, corner radius, padding, weight, shadow and gradient come from the app\'s button look (
|
|
3367
|
+
'Themed button. `<Button title="Save" onPress={save} />` — THE way to draw a button: its fill, label colour, border, corner radius, padding, weight, shadow and gradient come from the app\'s button look (Design > Components > Buttons, and Danger buttons / Button outline for the danger variant and emphasis border), so it matches the built-in Button widget. Props: `title`, `onPress` (omit it for a purely visual button inside something else that is pressable), `variant` ("primary" default | "secondary" | "outlined" | "danger"), `icon` (a lucide name) with `iconPosition` ("leading" | "trailing"), `disabled`, `accessibilityLabel`, `testID`, `style` (extra container style, e.g. `alignSelf`) and `labelStyle`. Pass `fields` — `{ background, textColor, borderColor, borderWidth, paddingX, paddingY, radius, fontWeight, fontSize, shadow, gradient }` — to override one button, e.g. from style fields your widget declares. On a section that paints its own fill (`theme.colors.backdrop` there), a variant colour that the section makes unreadable takes that section\'s text colour, as its text does: an outlined label and border, a secondary whose fill disappears becomes outlined, and a primary or danger fill turns inverse (that text colour as its fill, its own colour as its label). A white or lightly tinted panel changes nothing, and a button inside a `<Card>` is measured against that card. A colour you pass in `fields`, `style` or `labelStyle`, or that the app\'s button look sets, is never changed. Never hand-roll a button from `Pressable` + background + radius.',
|
|
3353
3368
|
rnComponent: null,
|
|
3354
3369
|
docsUrl: null,
|
|
3355
3370
|
},
|
|
3356
3371
|
{
|
|
3357
3372
|
name: "TextField",
|
|
3358
3373
|
description:
|
|
3359
|
-
'Themed text-entry field. `<TextField value={name} onChangeText={setName} placeholder="Your name" accessibilityLabel="Name" />` — every `TextInput` prop works, and its background, text colour, border, corner radius and height come from the app\'s input look (
|
|
3374
|
+
'Themed text-entry field. `<TextField value={name} onChangeText={setName} placeholder="Your name" accessibilityLabel="Name" />` — every `TextInput` prop works, and its background, text colour, border, corner radius and height come from the app\'s input look (Design > Components > Inputs), so a widget\'s fields match the built-in forms. `multiline` grows the field to a ceiling and then scrolls inside it. Override one field with `background`, `textColor`, `borderColor`, `radius` or `height`; `style` is merged last. `label` draws a label above the field and names it for a screen reader, `helper` a hint below, `error` a message in the helper\'s place with a danger border (`true` marks it without a message), `disabled` makes it read-only and dimmed; `containerStyle` styles the box around a labelled field. Use it instead of styling a bare `TextInput`.',
|
|
3360
3375
|
rnComponent: null,
|
|
3361
3376
|
docsUrl: null,
|
|
3362
3377
|
},
|
|
3363
3378
|
{
|
|
3364
3379
|
name: "Checkbox",
|
|
3365
3380
|
description:
|
|
3366
|
-
'Themed tick box. `<Checkbox label="Remember me" value={checked} onValueChange={setChecked} />` — THE way to draw a yes/no choice that is submitted with a form, or one row of a multi-select list. The box is the app\'s input surface (Design > Components >
|
|
3381
|
+
'Themed tick box. `<Checkbox label="Remember me" value={checked} onValueChange={setChecked} />` — THE way to draw a yes/no choice that is submitted with a form, or one row of a multi-select list. The box is the app\'s input surface (Design > Components > Inputs) and fills with the primary colour when checked; focus draws a ring in the primary colour around it. The whole row is one 44px target with the checkbox role and checked state on both hosts, and Space toggles it on the web. Props: `value` (boolean), `onValueChange(next)`, `label` (text, or an element), `helper`, `error` (a message, or `true`), `disabled`, `accessibilityLabel` (required when there is no text `label`), `style` (the row), `containerStyle` (the box around a row that shows a helper or error), `testID`; any other prop lands on the row. Never hand-roll a tick box from `Pressable` + a bordered `View` + a "✓".',
|
|
3367
3382
|
rnComponent: null,
|
|
3368
3383
|
docsUrl: null,
|
|
3369
3384
|
},
|
|
3370
3385
|
{
|
|
3371
3386
|
name: "Select",
|
|
3372
3387
|
description:
|
|
3373
|
-
'Themed
|
|
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.',
|
|
3374
3389
|
rnComponent: null,
|
|
3375
3390
|
docsUrl: null,
|
|
3376
3391
|
},
|
|
3377
3392
|
{
|
|
3378
3393
|
name: "SearchField",
|
|
3379
3394
|
description:
|
|
3380
|
-
'Themed search box. `<SearchField value={query} onChangeText={setQuery} placeholder="Search orders" />` — THE way to draw a field that filters or searches. It IS a `TextField` (same
|
|
3395
|
+
'Themed search box. `<SearchField value={query} onChangeText={setQuery} placeholder="Search orders" />` — THE way to draw a field that filters or searches. It IS a `TextField` (same props, same `label` / `helper` / `error` / `disabled`, the input look restyled by Design > Components > Search fields) with a search glyph, the search key on the on-screen keyboard and a clear control that appears once there is text; clearing calls `onChangeText("")`, then `onClear` if you passed one, and keeps focus in the field. `placeholder` defaults to "Search" and `clearAccessibilityLabel` to "Clear search" — pass translated ones. `style` styles the input and `containerStyle` the box around it (use it for `flex: 1` in a toolbar). Where the glyph sits (start, end or hidden), the corner radius, background, border colour and height come from the app; `iconPosition` ("start" | "end" | "none") overrides the glyph for one field, and at the end the clear control takes the glyph\'s place while there is text. Debounce the query yourself before you fetch. Never hand-roll a search box from a `TextInput` + an icon, and never add a magnifier of your own.',
|
|
3381
3396
|
rnComponent: null,
|
|
3382
3397
|
docsUrl: null,
|
|
3383
3398
|
},
|
|
@@ -3402,6 +3417,14 @@ const PRIMITIVES = [
|
|
|
3402
3417
|
rnComponent: null,
|
|
3403
3418
|
docsUrl: null,
|
|
3404
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
|
+
},
|
|
3405
3428
|
];
|
|
3406
3429
|
|
|
3407
3430
|
const CATEGORIES = [
|
|
@@ -6049,7 +6072,16 @@ const CONTRACT = deepFreeze({
|
|
|
6049
6072
|
// calendar sheet instead of the browser's date popup; both hosts show
|
|
6050
6073
|
// the value in the app's language with a clear button. New optional
|
|
6051
6074
|
// props: placeholder, clearLabel, doneLabel, closeAccessibilityLabel, testID.
|
|
6052
|
-
|
|
6075
|
+
// 1.191.0: additive (sc-11759) — `themeComponents.search` styles every
|
|
6076
|
+
// `<SearchField>`: icon position, corner radius, background, border
|
|
6077
|
+
// colour, height, each falling back to the `muted` field. The field
|
|
6078
|
+
// takes an `iconPosition` prop. Scope labels renamed (sc-11763); keys
|
|
6079
|
+
// unchanged.
|
|
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",
|
|
6053
6085
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
6054
6086
|
hooks: HOOKS,
|
|
6055
6087
|
primitives: PRIMITIVES,
|
|
@@ -6065,6 +6097,7 @@ const CONTRACT = deepFreeze({
|
|
|
6065
6097
|
themeColorTokens: THEME_COLOR_TOKENS,
|
|
6066
6098
|
themeComponents: THEME_COMPONENTS,
|
|
6067
6099
|
themeComponentTabStyles: THEME_TAB_STYLES,
|
|
6100
|
+
themeComponentSearchIconPositions: THEME_SEARCH_ICON_POSITIONS,
|
|
6068
6101
|
themeComponentShadows: THEME_COMPONENT_SHADOWS,
|
|
6069
6102
|
themeComponentTextTransforms: THEME_COMPONENT_TEXT_TRANSFORMS,
|
|
6070
6103
|
themeComponentGradient: THEME_COMPONENT_GRADIENT,
|
|
@@ -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
|
+
}
|