@colixsystems/widget-sdk 0.210.0 → 0.213.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.cjs CHANGED
@@ -618,6 +618,8 @@ const THEME_COMPONENTS = Object.freeze({
618
618
  // `text` scope already owns.
619
619
  muted: Object.freeze({
620
620
  label: "Muted",
621
+ // The SDK's form controls read this scope directly, in every widget.
622
+ sdkComponents: Object.freeze(["TextField", "Checkbox", "Switch", "Select", "SearchField"]),
621
623
  tokens: Object.freeze({
622
624
  background: Object.freeze({ type: "color", uiDefault: "colors.surfaceMuted" }),
623
625
  textColor: Object.freeze({ type: "color", uiDefault: "colors.onSurfaceMuted" }),
@@ -635,6 +637,7 @@ const THEME_COMPONENTS = Object.freeze({
635
637
  // list is the closest thing the built-in catalog has to a popover panel.
636
638
  popover: Object.freeze({
637
639
  label: "Popover",
640
+ sdkComponents: Object.freeze(["Select"]),
638
641
  tokens: Object.freeze({
639
642
  background: Object.freeze({ type: "color", uiDefault: "colors.surface" }),
640
643
  textColor: Object.freeze({ type: "color", uiDefault: "colors.onSurface" }),
@@ -2811,7 +2814,7 @@ const PRIMITIVES = [
2811
2814
  {
2812
2815
  name: "Switch",
2813
2816
  description:
2814
- "Toggle. Controlled — `value` + `onValueChange`. See https://reactnative.dev/docs/switch.",
2817
+ '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`.',
2815
2818
  rnComponent: "Switch",
2816
2819
  docsUrl: "https://reactnative.dev/docs/switch",
2817
2820
  },
@@ -2917,6 +2920,13 @@ const PRIMITIVES = [
2917
2920
  rnComponent: null,
2918
2921
  docsUrl: null,
2919
2922
  },
2923
+ {
2924
+ name: "ConfirmDialog",
2925
+ description:
2926
+ '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.',
2927
+ rnComponent: null,
2928
+ docsUrl: null,
2929
+ },
2920
2930
  // sc-8104 — the in-app emoji picker. Typed emoji always worked; what this
2921
2931
  // replaces is being forced out to the OS keyboard to find one.
2922
2932
  {
@@ -3053,7 +3063,35 @@ const PRIMITIVES = [
3053
3063
  {
3054
3064
  name: "TextField",
3055
3065
  description:
3056
- '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. Use it instead of styling a bare `TextInput`.',
3066
+ '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`.',
3067
+ rnComponent: null,
3068
+ docsUrl: null,
3069
+ },
3070
+ {
3071
+ name: "Checkbox",
3072
+ description:
3073
+ '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 "✓".',
3074
+ rnComponent: null,
3075
+ docsUrl: null,
3076
+ },
3077
+ {
3078
+ name: "Select",
3079
+ description:
3080
+ '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.',
3081
+ rnComponent: null,
3082
+ docsUrl: null,
3083
+ },
3084
+ {
3085
+ name: "SearchField",
3086
+ description:
3087
+ '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.',
3088
+ rnComponent: null,
3089
+ docsUrl: null,
3090
+ },
3091
+ {
3092
+ name: "spaceActivates",
3093
+ description:
3094
+ 'Space-key activation for a control whose role is not a button. `<Pressable accessibilityRole="radio" aria-checked={on} onPress={pick} onKeyDown={spaceActivates(pick)} />`. On the web a `Pressable` answers Enter for every role but Space only for a button, and Space is the key a checkbox, radio, switch or option is operated with; this restores it, once per key press. Native ignores `onKeyDown`, so it is safe on both hosts. `<Checkbox>` and `<Select>` already use it — reach for it only for a control you build yourself.',
3057
3095
  rnComponent: null,
3058
3096
  docsUrl: null,
3059
3097
  },
@@ -5589,7 +5627,20 @@ const CONTRACT = deepFreeze({
5589
5627
  // `initialFocusRef`, `backdropAccessible` and `testID`. A scope may now
5590
5628
  // name `sdkComponents` and bind no widget field; a colour token may be
5591
5629
  // `translucent` (always painted see-through) and carry a `note`.
5592
- version: "1.169.0",
5630
+ // 1.171.0: (sc-10929) — the form controls `Checkbox`, `Select` and
5631
+ // `SearchField`, and the `spaceActivates` helper. `TextField` gains
5632
+ // `label`, `helper`, `error`, `disabled` and `containerStyle`. The `muted`
5633
+ // and `popover` scopes name the `sdkComponents` that read them directly.
5634
+ // VISIBLE CHANGE, not additive: `Switch` is now the themed platform
5635
+ // switch, so an existing `<Switch>` with no colour props is repainted:
5636
+ // its tracks follow the theme on both hosts and its "on" thumb is white
5637
+ // on the web. A caller's `trackColor` / `thumbColor` still win.
5638
+ // 1.172.0: additive (sc-10925) — the `ConfirmDialog` primitive: the standard
5639
+ // confirmation, a preset of `Modal` and `Button` (title, message, confirm
5640
+ // and cancel labels, `tone` "default" | "destructive", `busy`). No new
5641
+ // theme scope: the panel reads `modal`, the actions `button` and
5642
+ // `destructive`. `Button` forwards a ref to its pressable.
5643
+ version: "1.172.0",
5593
5644
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
5594
5645
  hooks: HOOKS,
5595
5646
  primitives: PRIMITIVES,
package/dist/contract.js CHANGED
@@ -618,6 +618,8 @@ const THEME_COMPONENTS = Object.freeze({
618
618
  // `text` scope already owns.
619
619
  muted: Object.freeze({
620
620
  label: "Muted",
621
+ // The SDK's form controls read this scope directly, in every widget.
622
+ sdkComponents: Object.freeze(["TextField", "Checkbox", "Switch", "Select", "SearchField"]),
621
623
  tokens: Object.freeze({
622
624
  background: Object.freeze({ type: "color", uiDefault: "colors.surfaceMuted" }),
623
625
  textColor: Object.freeze({ type: "color", uiDefault: "colors.onSurfaceMuted" }),
@@ -635,6 +637,7 @@ const THEME_COMPONENTS = Object.freeze({
635
637
  // list is the closest thing the built-in catalog has to a popover panel.
636
638
  popover: Object.freeze({
637
639
  label: "Popover",
640
+ sdkComponents: Object.freeze(["Select"]),
638
641
  tokens: Object.freeze({
639
642
  background: Object.freeze({ type: "color", uiDefault: "colors.surface" }),
640
643
  textColor: Object.freeze({ type: "color", uiDefault: "colors.onSurface" }),
@@ -2811,7 +2814,7 @@ const PRIMITIVES = [
2811
2814
  {
2812
2815
  name: "Switch",
2813
2816
  description:
2814
- "Toggle. Controlled — `value` + `onValueChange`. See https://reactnative.dev/docs/switch.",
2817
+ '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`.',
2815
2818
  rnComponent: "Switch",
2816
2819
  docsUrl: "https://reactnative.dev/docs/switch",
2817
2820
  },
@@ -2917,6 +2920,13 @@ const PRIMITIVES = [
2917
2920
  rnComponent: null,
2918
2921
  docsUrl: null,
2919
2922
  },
2923
+ {
2924
+ name: "ConfirmDialog",
2925
+ description:
2926
+ '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.',
2927
+ rnComponent: null,
2928
+ docsUrl: null,
2929
+ },
2920
2930
  // sc-8104 — the in-app emoji picker. Typed emoji always worked; what this
2921
2931
  // replaces is being forced out to the OS keyboard to find one.
2922
2932
  {
@@ -3053,7 +3063,35 @@ const PRIMITIVES = [
3053
3063
  {
3054
3064
  name: "TextField",
3055
3065
  description:
3056
- '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. Use it instead of styling a bare `TextInput`.',
3066
+ '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`.',
3067
+ rnComponent: null,
3068
+ docsUrl: null,
3069
+ },
3070
+ {
3071
+ name: "Checkbox",
3072
+ description:
3073
+ '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 "✓".',
3074
+ rnComponent: null,
3075
+ docsUrl: null,
3076
+ },
3077
+ {
3078
+ name: "Select",
3079
+ description:
3080
+ '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.',
3081
+ rnComponent: null,
3082
+ docsUrl: null,
3083
+ },
3084
+ {
3085
+ name: "SearchField",
3086
+ description:
3087
+ '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.',
3088
+ rnComponent: null,
3089
+ docsUrl: null,
3090
+ },
3091
+ {
3092
+ name: "spaceActivates",
3093
+ description:
3094
+ 'Space-key activation for a control whose role is not a button. `<Pressable accessibilityRole="radio" aria-checked={on} onPress={pick} onKeyDown={spaceActivates(pick)} />`. On the web a `Pressable` answers Enter for every role but Space only for a button, and Space is the key a checkbox, radio, switch or option is operated with; this restores it, once per key press. Native ignores `onKeyDown`, so it is safe on both hosts. `<Checkbox>` and `<Select>` already use it — reach for it only for a control you build yourself.',
3057
3095
  rnComponent: null,
3058
3096
  docsUrl: null,
3059
3097
  },
@@ -5585,7 +5623,20 @@ const CONTRACT = deepFreeze({
5585
5623
  // `initialFocusRef`, `backdropAccessible` and `testID`. A scope may now
5586
5624
  // name `sdkComponents` and bind no widget field; a colour token may be
5587
5625
  // `translucent` (always painted see-through) and carry a `note`.
5588
- version: "1.169.0",
5626
+ // 1.171.0: (sc-10929) — the form controls `Checkbox`, `Select` and
5627
+ // `SearchField`, and the `spaceActivates` helper. `TextField` gains
5628
+ // `label`, `helper`, `error`, `disabled` and `containerStyle`. The `muted`
5629
+ // and `popover` scopes name the `sdkComponents` that read them directly.
5630
+ // VISIBLE CHANGE, not additive: `Switch` is now the themed platform
5631
+ // switch, so an existing `<Switch>` with no colour props is repainted:
5632
+ // its tracks follow the theme on both hosts and its "on" thumb is white
5633
+ // on the web. A caller's `trackColor` / `thumbColor` still win.
5634
+ // 1.172.0: additive (sc-10925) — the `ConfirmDialog` primitive: the standard
5635
+ // confirmation, a preset of `Modal` and `Button` (title, message, confirm
5636
+ // and cancel labels, `tone` "default" | "destructive", `busy`). No new
5637
+ // theme scope: the panel reads `modal`, the actions `button` and
5638
+ // `destructive`. `Button` forwards a ref to its pressable.
5639
+ version: "1.172.0",
5589
5640
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
5590
5641
  hooks: HOOKS,
5591
5642
  primitives: PRIMITIVES,
@@ -0,0 +1,55 @@
1
+ // The form controls' pure rules: what counts as shown, how a control is
2
+ // named, when the frame is needed, and the keys a non-button role answers.
3
+ // No renderer here, so the node suite can test them and both hosts share them.
4
+
5
+ export function isShown(value) {
6
+ return value !== undefined && value !== null && value !== false && value !== "";
7
+ }
8
+
9
+ export function isText(value) {
10
+ return typeof value === "string" || typeof value === "number";
11
+ }
12
+
13
+ /** The spoken name of a control: its own label, else its visible one. */
14
+ export function spokenLabel(accessibilityLabel, label) {
15
+ if (isShown(accessibilityLabel)) return accessibilityLabel;
16
+ return isText(label) ? String(label) : undefined;
17
+ }
18
+
19
+ /** An `error` that carries a message (`true` only marks the control). */
20
+ export function errorMessage(error) {
21
+ return isShown(error) && error !== true ? error : null;
22
+ }
23
+
24
+ /** Whether the frame draws a helper or an error line under the control. */
25
+ export function hasNote({ helper, error }) {
26
+ return errorMessage(error) !== null || isShown(helper);
27
+ }
28
+
29
+ /** Whether a control needs the frame at all, or renders bare. */
30
+ export function needsFrame({ label, helper, error }) {
31
+ return isShown(label) || hasNote({ helper, error });
32
+ }
33
+
34
+ /** Hand one node to both a forwarded ref and the control's own. */
35
+ export function shareRef(forwarded, own) {
36
+ return (node) => {
37
+ own.current = node;
38
+ if (typeof forwarded === "function") forwarded(node);
39
+ else if (forwarded) forwarded.current = node;
40
+ };
41
+ }
42
+
43
+ // The web host presses a non-button role on Enter only; Space is the key a
44
+ // checkbox, a radio and an option are operated with. Native ignores onKeyDown.
45
+ export function spaceActivates(onPress) {
46
+ return (event) => {
47
+ if (event.key !== " " && event.key !== "Spacebar") return;
48
+ // A shortcut, or a space typed into a field nested in the control.
49
+ if (event.shiftKey || event.ctrlKey || event.altKey || event.metaKey) return;
50
+ if (event.target && event.currentTarget && event.target !== event.currentTarget) return;
51
+ event.preventDefault();
52
+ // A held key must not also act on whatever its first press focused.
53
+ if (!event.repeat) onPress();
54
+ };
55
+ }
@@ -0,0 +1,121 @@
1
+ // The values the form controls (`<Checkbox>`, `<Switch>`, `<Select>`,
2
+ // `<SearchField>`) paint with. The field itself is `<TextField>`'s own
3
+ // resolution, so all five follow the theme's `muted` scope as one and show
4
+ // focus in the accent; the Select's option list adds the `popover` scope.
5
+ // Platform-free.
6
+
7
+ import { DEFAULT_THEME_TOKENS } from "./_theme-tokens.js";
8
+ import { resolveTextFieldTokens } from "./text-field-tokens.js";
9
+
10
+ // The smallest target a finger can hit reliably, as the field's own floor.
11
+ export const CONTROL_MIN_TARGET = 44;
12
+ export const CHECKBOX_SIZE = 22;
13
+ export const CHECKBOX_MAX_RADIUS = 6;
14
+ export const HALO_WIDTH = 2;
15
+ export const HALO_GAP = 2;
16
+ export const DISABLED_OPACITY = 0.5;
17
+
18
+ function isSet(value) {
19
+ return value !== undefined && value !== null && value !== "";
20
+ }
21
+
22
+ /**
23
+ * Resolve one form control from the workspace theme.
24
+ *
25
+ * @param {object} theme — the resolved workspace theme (`useTheme()` shape).
26
+ * @param {object} [overrides] — per-control values keyed by TEXT_FIELD_TOKEN_NAMES.
27
+ */
28
+ export function resolveFormControlTokens(theme, overrides) {
29
+ const base = theme && typeof theme === "object" ? theme : DEFAULT_THEME_TOKENS;
30
+ const colors = base.colors || DEFAULT_THEME_TOKENS.colors;
31
+ const spacing = base.spacing || DEFAULT_THEME_TOKENS.spacing;
32
+ const typography = base.typography || DEFAULT_THEME_TOKENS.typography;
33
+ const popover = (base.components && base.components.popover) || {};
34
+ const field = resolveTextFieldTokens(base, overrides);
35
+ return {
36
+ ...field,
37
+ // The checked and on states are the accent, as a primary button is.
38
+ accent: colors.primary,
39
+ onAccent: colors.onPrimary,
40
+ labelColor: colors.onSurface,
41
+ helperColor: colors.onSurfaceMuted,
42
+ labelSize: typography.sizes.sm,
43
+ helperSize: typography.sizes.xs,
44
+ gap: spacing.xs,
45
+ rowGap: spacing.sm,
46
+ disabledOpacity: DISABLED_OPACITY,
47
+ errorColor: colors.danger,
48
+ popover: {
49
+ background: isSet(popover.background) ? popover.background : colors.surface,
50
+ textColor: isSet(popover.textColor) ? popover.textColor : colors.onSurface,
51
+ borderColor: isSet(popover.borderColor) ? popover.borderColor : colors.border,
52
+ },
53
+ };
54
+ }
55
+
56
+ /**
57
+ * The ring drawn AROUND a filled control (a checkbox, a switch), whose own
58
+ * border cannot show focus once it is the accent. Its width is always
59
+ * reserved, so focusing never moves the control.
60
+ *
61
+ * @param {object} t — resolved form-control tokens.
62
+ * @param {boolean} focused
63
+ * @param {number} radius — the control's own corner radius.
64
+ */
65
+ export function focusHaloStyle(t, focused, radius) {
66
+ return {
67
+ padding: HALO_GAP,
68
+ borderWidth: HALO_WIDTH,
69
+ borderRadius: radius + HALO_GAP + HALO_WIDTH,
70
+ borderColor: focused ? t.focusColor : "transparent",
71
+ };
72
+ }
73
+
74
+ /**
75
+ * The colours a switch hands the platform control. A caller's `trackColor`
76
+ * overrides the theme per state, and iOS, which paints the off track from
77
+ * `ios_backgroundColor`, follows the off colour unless told otherwise.
78
+ *
79
+ * @param {object} t — resolved form-control tokens.
80
+ * @param {{trackColor?: object, iosBackgroundColor?: string, invalid?: boolean}} own
81
+ */
82
+ export function resolveSwitchColors(t, own) {
83
+ const { trackColor, iosBackgroundColor, invalid } = own || {};
84
+ const offTrack = invalid ? t.errorColor : t.borderColor;
85
+ const track = {
86
+ false: offTrack,
87
+ true: t.accent,
88
+ ...(trackColor && typeof trackColor === "object" ? trackColor : null),
89
+ };
90
+ return { trackColor: track, iosBackgroundColor: iosBackgroundColor ?? track.false };
91
+ }
92
+
93
+ /** The option whose `value` is the selected one, or `undefined`. */
94
+ export function selectedOption(options, value) {
95
+ if (!Array.isArray(options)) return undefined;
96
+ return options.find((option) => option && option.value === value);
97
+ }
98
+
99
+ /**
100
+ * The index focus moves to for a navigation key, skipping disabled options
101
+ * and stopping at either end. Returns `from` when the key moves nothing.
102
+ *
103
+ * @param {Array<{disabled?: boolean}>} options
104
+ * @param {number} from — the focused option's index.
105
+ * @param {string} key — a KeyboardEvent `key`.
106
+ */
107
+ export function nextOptionIndex(options, from, key) {
108
+ const list = Array.isArray(options) ? options : [];
109
+ const enabled = (i) => list[i] && !list[i].disabled;
110
+ const scan = (start, step) => {
111
+ for (let i = start; i >= 0 && i < list.length; i += step) {
112
+ if (enabled(i)) return i;
113
+ }
114
+ return from;
115
+ };
116
+ if (key === "ArrowDown") return scan(from + 1, 1);
117
+ if (key === "ArrowUp") return scan(from - 1, -1);
118
+ if (key === "Home") return scan(0, 1);
119
+ if (key === "End") return scan(list.length - 1, -1);
120
+ return from;
121
+ }
@@ -0,0 +1,98 @@
1
+ // What every form control shares on screen: the label above it and the helper
2
+ // or error below it. One frame, so `<TextField>`, `<Checkbox>`, `<Switch>`,
3
+ // `<Select>` and `<SearchField>` cannot drift apart. Its rules, which need no
4
+ // renderer, live in ./form-control-logic.js.
5
+
6
+ import React from "react";
7
+ import { errorMessage, isShown, isText } from "./form-control-logic.js";
8
+
9
+ const h = React.createElement;
10
+
11
+ /**
12
+ * Bind the shared frame to one platform's primitives.
13
+ *
14
+ * @param {{View: any, Text: any, Pressable: any}} rn
15
+ */
16
+ export function makeFieldFrame(rn) {
17
+ const { View, Text, Pressable } = rn;
18
+
19
+ /**
20
+ * @param {object} props
21
+ * @param {object} props.t — resolved form-control tokens.
22
+ * @param {any} [props.label] — text, or an element rendered as given.
23
+ * @param {any} [props.helper]
24
+ * @param {any} [props.error] — a message replaces the helper; `true` shows none.
25
+ * @param {string} [props.noteId] — the id the control's `aria-describedby` names.
26
+ * @param {() => void} [props.onLabelPress] — a text label then acts for the control.
27
+ * @param {boolean} [props.noteDisabled] — dim the note alone, when the
28
+ * control dims itself and `disabled` would dim it twice.
29
+ */
30
+ function FieldFrame({
31
+ t,
32
+ label,
33
+ helper,
34
+ error,
35
+ disabled,
36
+ noteId,
37
+ onLabelPress,
38
+ noteDisabled,
39
+ style,
40
+ children,
41
+ }) {
42
+ const message = errorMessage(error);
43
+ const note = message || (isShown(helper) ? helper : null);
44
+ const labelText = isText(label)
45
+ ? h(
46
+ Text,
47
+ {
48
+ style: {
49
+ color: t.labelColor,
50
+ fontFamily: t.fontFamily,
51
+ fontSize: t.labelSize,
52
+ fontWeight: "500",
53
+ },
54
+ },
55
+ label,
56
+ )
57
+ : null;
58
+ return h(
59
+ View,
60
+ { style: [{ gap: t.gap }, disabled ? { opacity: t.disabledOpacity } : null, style] },
61
+ labelText && typeof onLabelPress === "function"
62
+ ? h(
63
+ Pressable,
64
+ {
65
+ onPress: onLabelPress,
66
+ disabled,
67
+ // The control is the one stop for a keyboard and a reader.
68
+ tabIndex: -1,
69
+ accessible: false,
70
+ style: { alignSelf: "flex-start" },
71
+ },
72
+ labelText,
73
+ )
74
+ : labelText || (isShown(label) ? label : null),
75
+ children,
76
+ isText(note)
77
+ ? h(
78
+ Text,
79
+ {
80
+ nativeID: noteId,
81
+ // Announced where the host can: the web and Android.
82
+ ...(message ? { accessibilityLiveRegion: "polite" } : null),
83
+ style: {
84
+ color: message ? t.errorColor : t.helperColor,
85
+ fontFamily: t.fontFamily,
86
+ fontSize: t.helperSize,
87
+ ...(noteDisabled ? { opacity: t.disabledOpacity } : null),
88
+ },
89
+ },
90
+ note,
91
+ )
92
+ : note,
93
+ );
94
+ }
95
+
96
+ FieldFrame.displayName = "FieldFrame";
97
+ return FieldFrame;
98
+ }