@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/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: "Accent",
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: "Destructive",
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: "Muted",
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: "Popover",
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
- // sc-5392 -- the app-wide focus-visible outline. A themed emphasis border
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: "Focus ring",
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 > Muted). `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`.',
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>` lets a widget surface a native
3131
- // file-picker on the web Player so end users can upload files to the
3132
- // backend (paired with useFilestoreUpload). Web wraps
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 `false` on the native Expo export — widgets should branch on it to hide the trigger when uploads are not yet wired natively.',
3154
+ 'End-user file picker primitive. `<FilePicker accept="image/*" multiple={false} disabled={false} onPick={(file) => …}>…trigger…</FilePicker>`. Children render as the visible click target (a Pressable + Icon + Text is the common shape) and are wrapped in a hidden-input <label> so the click reaches the file dialog without any ref plumbing. Pair with useFilestoreUpload to upload the picked File to the end-user filestore. `onFocus` / `onBlur` report the picker\'s keyboard focus, so a trigger you draw can show it. `FilePicker.isSupported` is `true` on the web Player and on the native Expo export, which opens the system document picker; a host without a picker sets it `false`, so disable the trigger and say why. For an upload field use `<FileUpload>`, which draws the button, the chosen files and the uploading and error states around this picker; the bare FilePicker is for a trigger that is not a field, such as a photo the user taps to replace.',
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 Destructive 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.',
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 (Theme Settings > Buttons, and Destructive / Focus ring 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.',
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 (Theme Settings > Muted), 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`.',
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 > Muted) 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 "✓".',
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 single-choice picker. `<Select label="Status" value={status} onValueChange={setStatus} options={[{ value: "open", label: "Open" }, { value: "done", label: "Done" }]} placeholder="Choose a status" />` — THE way to let the user pick ONE of a known list. The trigger is the app\'s input field (Design > Components > Muted) showing the chosen label; pressing it opens the options at SCREEN level, in a sheet painted from Design > Components > Popover, so no card or scroll container can clip them, and a long list scrolls inside the sheet. The trigger is a button that opens a dialog (expanded state included) and each option carries the option role and its selected state on both hosts; the list around them is a listbox on the web only. On the web Enter, Space or an arrow key opens it, the arrow keys, Home and End move between options, Enter or Space chooses, Escape closes, and focus returns to the trigger; on Android the back button closes it. Props: `options` (`[{ value, label, disabled?, testID? }]`, `value` a string or number, unique), `value`, `onValueChange(value, option)`, `placeholder` (default "Select"), `title` (the sheet\'s heading; defaults to a text `label`), `emptyText` (default "No options"), `label`, `helper`, `error`, `disabled`, `accessibilityLabel`, `closeAccessibilityLabel`, `background` / `textColor` / `borderColor` / `radius` / `height` to override one field, `style` (the trigger), `containerStyle`, `testID`; any other prop lands on the trigger. Never hand-roll a dropdown from `Pressable` + an absolutely positioned list: it is clipped by the widget\'s container on both hosts.',
3388
+ 'Themed picker for ONE value of a known list, or several with `multiple`. `<Select label="Status" value={status} onValueChange={setStatus} options={[{ value: "open", label: "Open" }, { value: "done", label: "Done" }]} placeholder="Choose a status" />` — THE way to let the user pick ONE of a known list. The trigger is the app\'s input field (Design > Components > Inputs) showing the chosen label; pressing it opens the options at SCREEN level, in a sheet painted from Design > Components > Dropdown menus, so no card or scroll container can clip them, and a long list scrolls inside the sheet. The trigger is a button that opens a dialog (expanded state included) and each option carries the option role and its selected state on both hosts; the list around them is a listbox on the web only. On the web Enter, Space or an arrow key opens it, the arrow keys, Home and End move between options, Enter or Space chooses, Escape closes, and focus returns to the trigger; on Android the back button closes it. Props: `options` (`[{ value, label, disabled?, testID? }]`, `value` a string or number, unique), `value`, `onValueChange(value, option)`, `placeholder` (default "Select"), `title` (the sheet\'s heading; defaults to a text `label`), `emptyText` (default "No options"), `label`, `helper`, `error`, `disabled`, `accessibilityLabel`, `closeAccessibilityLabel`, `background` / `textColor` / `borderColor` / `radius` / `height` to override one field, `style` (the trigger), `containerStyle`, `testID`; any other prop lands on the trigger. Never hand-roll a dropdown from `Pressable` + an absolutely positioned list: it is clipped by the widget\'s container on both hosts. MULTIPLE: `<Select multiple label="Tags" values={tags} onValuesChange={setTags} options={tagOptions} />` — THE way to let the user pick SEVERAL of a known list. The same trigger and sheet; each option shows a tick box and toggles, the listbox is multiselectable on the web, and the sheet\'s Done button commits the choice through `onValuesChange(values, options)` (values in listed order, called only when they changed); closing the sheet any other way (backdrop, Escape, Android back) keeps the previous choice. Space toggles the focused option on the web. The trigger shows the picked labels as small chips while they fit on its one line, otherwise "N selected" (`selectedCountText(count)` to translate it), and `placeholder` while nothing is picked; `doneText` (default "Done") names the button. In this mode `value` / `onValueChange` are ignored. A handful of options always shown inline is a `<ChipGroup selection="multiple">` of `<Chip>`s instead.',
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 look, same props, same `label` / `helper` / `error` / `disabled`) 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). Debounce the query yourself before you fetch. Never hand-roll a search box from a `TextInput` + an icon.',
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
- version: "1.190.0",
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
+ }