@colixsystems/widget-sdk 0.207.0 → 0.212.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.
Files changed (42) hide show
  1. package/README.md +97 -2
  2. package/dist/checkbox-view.js +150 -0
  3. package/dist/checkbox.js +11 -0
  4. package/dist/checkbox.native.js +7 -0
  5. package/dist/contract.cjs +78 -4
  6. package/dist/contract.js +78 -4
  7. package/dist/form-control-logic.js +55 -0
  8. package/dist/form-control-tokens.js +121 -0
  9. package/dist/form-control-view.js +98 -0
  10. package/dist/hooks.js +13 -0
  11. package/dist/host.d.ts +46 -0
  12. package/dist/host.js +4 -0
  13. package/dist/index.d.ts +230 -6
  14. package/dist/index.js +11 -1
  15. package/dist/index.native.js +11 -1
  16. package/dist/markdown-input-view.js +2 -3
  17. package/dist/markdown-input.js +4 -2
  18. package/dist/markdown-input.native.js +4 -2
  19. package/dist/markdown-link-dialog.js +94 -108
  20. package/dist/modal-tokens.js +107 -0
  21. package/dist/modal-view.js +255 -0
  22. package/dist/modal.js +8 -0
  23. package/dist/modal.native.js +45 -0
  24. package/dist/overlay-tokens.js +69 -5
  25. package/dist/overlay-view.js +52 -16
  26. package/dist/overlay.native.js +8 -1
  27. package/dist/search-field-view.js +155 -0
  28. package/dist/search-field.js +13 -0
  29. package/dist/search-field.native.js +9 -0
  30. package/dist/select-view.js +312 -0
  31. package/dist/select.js +15 -0
  32. package/dist/select.native.js +9 -0
  33. package/dist/surface-theme.js +108 -0
  34. package/dist/switch-view.js +149 -0
  35. package/dist/switch.js +80 -0
  36. package/dist/switch.native.js +7 -0
  37. package/dist/text-field-view.js +54 -8
  38. package/dist/text-field.js +6 -1
  39. package/dist/text-field.native.js +3 -3
  40. package/dist/web-modal.js +24 -5
  41. package/package.json +1 -1
  42. package/src/contract.js +78 -4
@@ -0,0 +1,149 @@
1
+ // The ONE implementation behind the `<Switch>` SDK primitive. The platform
2
+ // bindings (`switch.js` / `switch.native.js`) differ only in where the
3
+ // primitives come from, so the hosts cannot drift.
4
+ //
5
+ // It is the platform's own switch, so it keeps the role, state, keys and
6
+ // disabled look each host gives it; this view adds the theme's track colours,
7
+ // a label, a helper and an error.
8
+
9
+ import React from "react";
10
+ import { useHostTheme } from "./hooks.js";
11
+ import { DEFAULT_THEME_TOKENS } from "./_theme-tokens.js";
12
+ import {
13
+ CONTROL_MIN_TARGET,
14
+ focusHaloStyle,
15
+ resolveFormControlTokens,
16
+ resolveSwitchColors,
17
+ } from "./form-control-tokens.js";
18
+ import {
19
+ errorMessage,
20
+ hasNote,
21
+ isShown,
22
+ isText,
23
+ needsFrame,
24
+ spokenLabel,
25
+ } from "./form-control-logic.js";
26
+ import { makeFieldFrame } from "./form-control-view.js";
27
+
28
+ const h = React.createElement;
29
+ const TRACK_RADIUS = 16;
30
+
31
+ /**
32
+ * Bind the shared switch to one platform's primitives.
33
+ *
34
+ * @param {{View: any, Text: any, Pressable: any, Switch: any}} rn
35
+ */
36
+ export function makeSwitch(rn) {
37
+ const { View, Text, Pressable, Switch: PlatformSwitch } = rn;
38
+ const FieldFrame = makeFieldFrame(rn);
39
+
40
+ const Switch = React.forwardRef(function Switch(props, ref) {
41
+ const {
42
+ value = false,
43
+ onValueChange,
44
+ label,
45
+ helper,
46
+ error,
47
+ disabled = false,
48
+ accessibilityLabel,
49
+ trackColor,
50
+ ios_backgroundColor: iosBackgroundColor,
51
+ containerStyle,
52
+ ...rest
53
+ } = props;
54
+ const theme = useHostTheme() || DEFAULT_THEME_TOKENS;
55
+ const t = resolveFormControlTokens(theme);
56
+ const [focused, setFocused] = React.useState(false);
57
+ const noteId = React.useId();
58
+ const on = Boolean(value);
59
+ const invalid = isShown(error);
60
+ const noted = hasNote({ helper, error });
61
+ const colors = resolveSwitchColors(t, { trackColor, iosBackgroundColor, invalid });
62
+
63
+ // The thumb is left to the platform unless the caller colours it.
64
+ const control = h(PlatformSwitch, {
65
+ ...rest,
66
+ ref,
67
+ value: on,
68
+ onValueChange,
69
+ disabled,
70
+ trackColor: colors.trackColor,
71
+ ios_backgroundColor: colors.iosBackgroundColor,
72
+ accessibilityLabel: spokenLabel(accessibilityLabel, label),
73
+ ...(invalid ? { "aria-invalid": true } : null),
74
+ ...(noted ? { "aria-describedby": noteId } : null),
75
+ });
76
+ if (!needsFrame({ label, helper, error })) return control;
77
+
78
+ const toggle = () => {
79
+ if (!disabled && typeof onValueChange === "function") onValueChange(!on);
80
+ };
81
+ const row = h(
82
+ View,
83
+ {
84
+ style: {
85
+ flexDirection: "row",
86
+ alignItems: "center",
87
+ justifyContent: "space-between",
88
+ gap: t.rowGap,
89
+ minHeight: CONTROL_MIN_TARGET,
90
+ },
91
+ },
92
+ isShown(label)
93
+ ? h(
94
+ Pressable,
95
+ {
96
+ onPress: toggle,
97
+ disabled,
98
+ // The switch itself is the one stop for a keyboard and a reader.
99
+ tabIndex: -1,
100
+ accessible: false,
101
+ style: [
102
+ { flexShrink: 1, minHeight: CONTROL_MIN_TARGET, justifyContent: "center" },
103
+ // The control dims itself on each host; only the label is ours.
104
+ disabled ? { opacity: t.disabledOpacity } : null,
105
+ ],
106
+ },
107
+ isText(label)
108
+ ? h(
109
+ Text,
110
+ {
111
+ style: {
112
+ color: t.labelColor,
113
+ fontFamily: t.fontFamily,
114
+ fontSize: t.fontSize,
115
+ },
116
+ },
117
+ label,
118
+ )
119
+ : label,
120
+ )
121
+ : null,
122
+ h(
123
+ View,
124
+ {
125
+ // Focus events bubble from the control on the web host.
126
+ onFocus: () => setFocused(true),
127
+ onBlur: () => setFocused(false),
128
+ style: focusHaloStyle(t, focused, TRACK_RADIUS),
129
+ },
130
+ control,
131
+ ),
132
+ );
133
+ return h(
134
+ FieldFrame,
135
+ {
136
+ t,
137
+ helper,
138
+ error: errorMessage(error),
139
+ noteId,
140
+ noteDisabled: disabled,
141
+ style: containerStyle,
142
+ },
143
+ row,
144
+ );
145
+ });
146
+
147
+ Switch.displayName = "Switch";
148
+ return Switch;
149
+ }
package/dist/switch.js ADDED
@@ -0,0 +1,80 @@
1
+ // `<Switch>` (web). Binds ./switch-view.js to react-native-web; see
2
+ // ./primitives.js for why not the bare specifier.
3
+ //
4
+ // This host's switch differs from a device's in ways the shared view cannot
5
+ // see, so they are bridged here and nowhere else:
6
+ // - it takes `activeTrackColor` and a STRING `trackColor` (the off colour),
7
+ // and ignores both once it is handed the `{ false, true }` object;
8
+ // - it colours an "on" thumb from `activeThumbColor`, not `thumbColor`;
9
+ // - it stops greying a disabled switch once it is given any track colour;
10
+ // - it forwards only a label to its `<input>`, dropping the other ARIA props.
11
+
12
+ import React from "react";
13
+ import * as ReactNative from "react-native-web";
14
+ import { makeSwitch } from "./switch-view.js";
15
+ import { DISABLED_OPACITY } from "./form-control-tokens.js";
16
+ import { shareRef } from "./form-control-logic.js";
17
+
18
+ const ON_THUMB_COLOR = "#ffffff";
19
+ // A caller's own `style.opacity` comes later in the list and wins.
20
+ const DIMMED = { opacity: DISABLED_OPACITY };
21
+
22
+ function isSet(value) {
23
+ return value !== undefined && value !== null;
24
+ }
25
+
26
+ /** The caller's colours in the object form the shared view merges. */
27
+ export function webTrackColor(trackColor, activeTrackColor) {
28
+ if (trackColor && typeof trackColor === "object") return trackColor;
29
+ const own = {};
30
+ if (typeof trackColor === "string") own.false = trackColor;
31
+ if (isSet(activeTrackColor)) own.true = activeTrackColor;
32
+ return Object.keys(own).length > 0 ? own : undefined;
33
+ }
34
+
35
+ const INPUT_ARIA = ["aria-describedby", "aria-invalid"];
36
+
37
+ const PlatformSwitch = React.forwardRef(function PlatformSwitch(props, ref) {
38
+ const { "aria-describedby": describedBy, "aria-invalid": invalid, ...rest } = props;
39
+ const inputRef = React.useRef(null);
40
+ const setRef = React.useMemo(() => shareRef(ref, inputRef), [ref]);
41
+ React.useEffect(() => {
42
+ const input = inputRef.current;
43
+ if (!input || typeof input.setAttribute !== "function") return;
44
+ const values = { "aria-describedby": describedBy, "aria-invalid": invalid ? "true" : null };
45
+ for (const name of INPUT_ARIA) {
46
+ if (isSet(values[name])) input.setAttribute(name, values[name]);
47
+ else input.removeAttribute(name);
48
+ }
49
+ }, [describedBy, invalid]);
50
+ return React.createElement(ReactNative.Switch, {
51
+ activeThumbColor: rest.thumbColor ?? ON_THUMB_COLOR,
52
+ ...rest,
53
+ ref: setRef,
54
+ });
55
+ });
56
+
57
+ const ThemedSwitch = makeSwitch({
58
+ View: ReactNative.View,
59
+ Text: ReactNative.Text,
60
+ Pressable: ReactNative.Pressable,
61
+ Switch: PlatformSwitch,
62
+ });
63
+
64
+ export const Switch = React.forwardRef(function Switch(props, ref) {
65
+ const { trackColor, activeTrackColor, style, ...rest } = props;
66
+ const ownColours =
67
+ isSet(trackColor) ||
68
+ isSet(activeTrackColor) ||
69
+ isSet(rest.thumbColor) ||
70
+ isSet(rest.activeThumbColor);
71
+ return React.createElement(ThemedSwitch, {
72
+ ...rest,
73
+ ref,
74
+ trackColor: webTrackColor(trackColor, activeTrackColor),
75
+ // Only a switch that left its colours to us lost the host's grey look.
76
+ style: rest.disabled && !ownColours ? [DIMMED, style] : style,
77
+ });
78
+ });
79
+
80
+ Switch.displayName = "Switch";
@@ -0,0 +1,7 @@
1
+ // `<Switch>` (native). Binds ./switch-view.js to React Native's own
2
+ // primitives; only the import differs from ./switch.js.
3
+
4
+ import { View, Text, Pressable, Switch as PlatformSwitch } from "react-native";
5
+ import { makeSwitch } from "./switch-view.js";
6
+
7
+ export const Switch = makeSwitch({ View, Text, Pressable, Switch: PlatformSwitch });
@@ -1,14 +1,21 @@
1
1
  // The ONE text-entry field, behind the `<TextField>` SDK primitive and the
2
- // built-in form fields. The bindings differ only in where `TextInput` comes
2
+ // built-in form fields. The bindings differ only in where the primitives come
3
3
  // from, so the hosts cannot drift.
4
4
 
5
5
  import React from "react";
6
6
  import { useHostTheme } from "./hooks.js";
7
7
  import { DEFAULT_THEME_TOKENS } from "./_theme-tokens.js";
8
+ import { TEXT_FIELD_TOKEN_NAMES } from "./text-field-tokens.js";
9
+ import { resolveFormControlTokens } from "./form-control-tokens.js";
8
10
  import {
9
- resolveTextFieldTokens,
10
- TEXT_FIELD_TOKEN_NAMES,
11
- } from "./text-field-tokens.js";
11
+ errorMessage,
12
+ hasNote,
13
+ isShown,
14
+ needsFrame,
15
+ shareRef,
16
+ spokenLabel,
17
+ } from "./form-control-logic.js";
18
+ import { makeFieldFrame } from "./form-control-view.js";
12
19
 
13
20
  // A paragraph field grows to a ceiling, then scrolls inside itself.
14
21
  const MULTILINE = { minHeight: 88, maxHeight: 220, textAlignVertical: "top" };
@@ -16,10 +23,11 @@ const MULTILINE = { minHeight: 88, maxHeight: 220, textAlignVertical: "top" };
16
23
  /**
17
24
  * Bind the shared field to one platform's primitives.
18
25
  *
19
- * @param {{TextInput: any}} rn
26
+ * @param {{TextInput: any, View: any, Text: any, Pressable: any}} rn
20
27
  */
21
28
  export function makeTextField(rn) {
22
29
  const { TextInput } = rn;
30
+ const FieldFrame = makeFieldFrame(rn);
23
31
 
24
32
  const TextField = React.forwardRef(function TextField(props, ref) {
25
33
  const {
@@ -28,6 +36,12 @@ export function makeTextField(rn) {
28
36
  placeholderTextColor,
29
37
  onFocus,
30
38
  onBlur,
39
+ label,
40
+ helper,
41
+ error,
42
+ disabled = false,
43
+ containerStyle,
44
+ accessibilityLabel,
31
45
  ...others
32
46
  } = props;
33
47
  const theme = useHostTheme() || DEFAULT_THEME_TOKENS;
@@ -39,11 +53,21 @@ export function makeTextField(rn) {
39
53
  if (TEXT_FIELD_TOKEN_NAMES.includes(key)) overrides[key] = value;
40
54
  else rest[key] = value;
41
55
  }
42
- const t = resolveTextFieldTokens(theme, overrides);
43
- return React.createElement(TextInput, {
44
- ref,
56
+ const t = resolveFormControlTokens(theme, overrides);
57
+ const invalid = isShown(error);
58
+ const framed = needsFrame({ label, helper, error });
59
+ const inputRef = React.useRef(null);
60
+ // Stable, so the host does not detach and re-attach the node every render.
61
+ const setRef = React.useMemo(() => shareRef(ref, inputRef), [ref]);
62
+ const noteId = React.useId();
63
+ const input = React.createElement(TextInput, {
64
+ ref: setRef,
45
65
  multiline,
46
66
  placeholderTextColor: placeholderTextColor || t.placeholderColor,
67
+ accessibilityLabel: spokenLabel(accessibilityLabel, label),
68
+ ...(disabled ? { editable: false, "aria-disabled": true } : null),
69
+ ...(invalid ? { "aria-invalid": true } : null),
70
+ ...(hasNote({ helper, error }) ? { "aria-describedby": noteId } : null),
47
71
  style: [
48
72
  {
49
73
  borderWidth: 1,
@@ -58,6 +82,10 @@ export function makeTextField(rn) {
58
82
  fontSize: t.fontSize,
59
83
  },
60
84
  focused ? { borderColor: t.focusColor } : null,
85
+ // After focus: an invalid field stays marked while it is being fixed.
86
+ invalid ? { borderColor: t.errorColor } : null,
87
+ // The frame dims a framed field as a whole.
88
+ disabled && !framed ? { opacity: t.disabledOpacity } : null,
61
89
  style,
62
90
  // After the caller's style: a paragraph field keeps its 88px floor.
63
91
  multiline ? MULTILINE : null,
@@ -72,6 +100,24 @@ export function makeTextField(rn) {
72
100
  },
73
101
  ...rest,
74
102
  });
103
+ if (!framed) return input;
104
+ return React.createElement(
105
+ FieldFrame,
106
+ {
107
+ t,
108
+ label,
109
+ helper,
110
+ error: errorMessage(error),
111
+ disabled,
112
+ noteId,
113
+ onLabelPress: () => {
114
+ const node = inputRef.current;
115
+ if (node && typeof node.focus === "function") node.focus();
116
+ },
117
+ style: containerStyle,
118
+ },
119
+ input,
120
+ );
75
121
  });
76
122
 
77
123
  TextField.displayName = "TextField";
@@ -5,4 +5,9 @@ import * as ReactNative from "react-native-web";
5
5
  import { makeTextField } from "./text-field-view.js";
6
6
  import { TextInput } from "./primitives.js";
7
7
 
8
- export const TextField = makeTextField({ ...ReactNative, TextInput });
8
+ export const TextField = makeTextField({
9
+ TextInput,
10
+ View: ReactNative.View,
11
+ Text: ReactNative.Text,
12
+ Pressable: ReactNative.Pressable,
13
+ });
@@ -1,7 +1,7 @@
1
1
  // `<TextField>` (native). Binds ./text-field-view.js to React Native's own
2
- // `TextInput`; only the import differs from ./text-field.js.
2
+ // primitives; only the import differs from ./text-field.js.
3
3
 
4
- import { TextInput } from "react-native";
4
+ import { TextInput, View, Text, Pressable } from "react-native";
5
5
  import { makeTextField } from "./text-field-view.js";
6
6
 
7
- export const TextField = makeTextField({ TextInput });
7
+ export const TextField = makeTextField({ TextInput, View, Text, Pressable });
package/dist/web-modal.js CHANGED
@@ -7,7 +7,8 @@
7
7
  // re-render rebuilt it, and its Escape and focus handling never re-armed.
8
8
  //
9
9
  // Implements only the `Modal` props `<Overlay>` passes: `visible`,
10
- // `onRequestClose` and `children`; it is never animated.
10
+ // `onRequestClose`, the dialog's role and name, `initialFocusRef` and
11
+ // `children`; it is never animated.
11
12
 
12
13
  import React from "react";
13
14
  import { createPortal } from "react-dom";
@@ -37,13 +38,25 @@ function tabStops(dialog) {
37
38
  }
38
39
 
39
40
  /**
40
- * @param {{visible?: boolean, onRequestClose?: () => void, children?: any}} props
41
+ * @param {{visible?: boolean, onRequestClose?: () => void, role?: string,
42
+ * "aria-label"?: string, "aria-labelledby"?: string,
43
+ * initialFocusRef?: {current: any}, children?: any}} props
41
44
  */
42
- export function WebModal({ visible = true, onRequestClose, children }) {
45
+ export function WebModal({
46
+ visible = true,
47
+ onRequestClose,
48
+ role = "dialog",
49
+ "aria-label": ariaLabel,
50
+ "aria-labelledby": ariaLabelledBy,
51
+ initialFocusRef,
52
+ children,
53
+ }) {
43
54
  const [container, setContainer] = React.useState(null);
44
55
  const dialogRef = React.useRef(null);
45
56
  const onCloseRef = React.useRef(onRequestClose);
46
57
  onCloseRef.current = onRequestClose;
58
+ const initialFocusRefRef = React.useRef(initialFocusRef);
59
+ initialFocusRefRef.current = initialFocusRef;
47
60
  // Taken before the dialog mounts, so a child that focuses itself on mount
48
61
  // cannot become the place focus returns to.
49
62
  const returnFocusRef = React.useRef(null);
@@ -67,7 +80,11 @@ export function WebModal({ visible = true, onRequestClose, children }) {
67
80
  openLayers.push(layer);
68
81
  const isTop = () => openLayers[openLayers.length - 1] === layer;
69
82
  const returnFocusTo = returnFocusRef.current;
70
- if (!dialog.contains(document.activeElement)) {
83
+ const wanted = initialFocusRefRef.current;
84
+ const first = wanted && wanted.current;
85
+ if (first && typeof first.focus === "function") {
86
+ first.focus({ preventScroll: true });
87
+ } else if (!dialog.contains(document.activeElement)) {
71
88
  dialog.focus({ preventScroll: true });
72
89
  }
73
90
 
@@ -127,8 +144,10 @@ export function WebModal({ visible = true, onRequestClose, children }) {
127
144
  "div",
128
145
  {
129
146
  ref: dialogRef,
130
- role: "dialog",
147
+ role,
131
148
  "aria-modal": "true",
149
+ "aria-label": ariaLabel,
150
+ "aria-labelledby": ariaLabelledBy,
132
151
  tabIndex: -1,
133
152
  style: layerStyle,
134
153
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colixsystems/widget-sdk",
3
- "version": "0.207.0",
3
+ "version": "0.212.0",
4
4
  "description": "Common widget interface for AppStudio. Implements WidgetManifest, WidgetContext, property schema, and helper hooks.",
5
5
  "homepage": "https://github.com/Colix-AB/AppStudio",
6
6
  "type": "module",
package/src/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" }),
@@ -723,6 +726,25 @@ const THEME_COMPONENTS = Object.freeze({
723
726
  }),
724
727
  targets: Object.freeze({}),
725
728
  }),
729
+ // Dialogs: `<Modal>` reads this scope directly, so it names the SDK component
730
+ // it styles and binds no widget field. `<Overlay>` reads the scrim alone.
731
+ modal: Object.freeze({
732
+ label: "Dialogs",
733
+ sdkComponents: Object.freeze(["Modal"]),
734
+ tokens: Object.freeze({
735
+ background: Object.freeze({ type: "color", uiDefault: "colors.surface" }),
736
+ titleColor: Object.freeze({ type: "color" }),
737
+ scrim: Object.freeze({
738
+ type: "color",
739
+ translucent: true,
740
+ note: "also the backdrop behind every <Overlay>",
741
+ }),
742
+ radius: Object.freeze({ type: "size", min: 0, max: 48, uiDefault: "radii.lg" }),
743
+ padding: Object.freeze({ type: "size", min: 0, max: 64, uiDefault: "spacing.lg" }),
744
+ shadow: Object.freeze({ type: "shadow" }),
745
+ }),
746
+ targets: Object.freeze({}),
747
+ }),
726
748
  });
727
749
 
728
750
  // sc-4505 — the value types a widget event payload field may declare. Narrower
@@ -2792,7 +2814,7 @@ const PRIMITIVES = [
2792
2814
  {
2793
2815
  name: "Switch",
2794
2816
  description:
2795
- "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`.',
2796
2818
  rnComponent: "Switch",
2797
2819
  docsUrl: "https://reactnative.dev/docs/switch",
2798
2820
  },
@@ -2886,7 +2908,15 @@ const PRIMITIVES = [
2886
2908
  {
2887
2909
  name: "Overlay",
2888
2910
  description:
2889
- 'Screen-level overlay. `<Overlay visible={open} onRequestClose={() => setOpen(false)} size="md">…panel…</Overlay>`. THE way to open anything that must cover the SCREEN rather than the widget — a preview (PDF, image, video), a lightbox, a confirm dialog, a full detail panel. It renders OUTSIDE the widget layout box on both hosts (react-native-web portals it to the document root; native uses the OS modal), so no `overflow: hidden` card, scroll container or sibling widget can clip or cover it — which absolute positioning inside the widget cannot achieve on either host. Children stay in your own React tree, so state and hooks work normally. Props: `visible` (boolean, required to show), `onRequestClose` (fired by the backdrop press, the Escape key on web, and the Android back button — always wire it or the overlay cannot be closed), `size` ("sm" | "md" | "lg" | "full", default "md"; `full` runs edge to edge for media), `dismissOnBackdropPress` (default true), `accessibilityLabel`, and `style` for extra panel styles. The scrim, surface, radius, padding and elevation come from the workspace theme — never re-style them. Keep it for screen-level surfaces: a dropdown anchored to its trigger still belongs inside the widget.',
2911
+ 'Screen-level overlay. `<Overlay visible={open} onRequestClose={() => setOpen(false)} size="md">…panel…</Overlay>`. THE way to open anything that must cover the SCREEN rather than the widget and is not a dialog — a preview (PDF, image, video), a lightbox, a full-screen viewer. A dialog (a title, a close button, actions) is `<Modal>`, which is built on this. It renders OUTSIDE the widget layout box on both hosts (react-native-web portals it to the document root; native uses the OS modal), so no `overflow: hidden` card, scroll container or sibling widget can clip or cover it — which absolute positioning inside the widget cannot achieve on either host. Children stay in your own React tree, so state and hooks work normally. Props: `visible` (boolean, required to show), `onRequestClose` (fired by the backdrop press, the Escape key on web, and the Android back button — always wire it or the overlay cannot be closed), `size` ("sm" | "md" | "lg" | "full", default "md"; `full` runs edge to edge for media), `presentation` ("dialog", centred, the default | "sheet", anchored to the bottom edge), `dismissOnBackdropPress` (default true), `role` ("dialog" default | "alertdialog"; web only), `initialFocusRef` (web only), `accessibilityLabel` (names the dialog), `testID`, and `style` for extra panel styles. The scrim, surface, radius, padding and elevation come from the workspace theme (the scrim from Design > Components > Dialogs) — never re-style them. Keep it for screen-level surfaces: a dropdown anchored to its trigger still belongs inside the widget.',
2912
+ rnComponent: null,
2913
+ docsUrl: null,
2914
+ },
2915
+ // The standard dialog, built on `<Overlay>`, so widgets stop hand-building one.
2916
+ {
2917
+ name: "Modal",
2918
+ description:
2919
+ 'Standard dialog. `<Modal visible={open} onRequestClose={() => setOpen(false)} title="Edit order" footer={<><Button variant="secondary" title="Cancel" onPress={() => setOpen(false)} /><Button title="Save" onPress={save} /></>}>…body…</Modal>`. THE way to open a dialog, a form in a pop-up, a detail panel or a bottom sheet: it draws the title, the close button, a body that scrolls when it is long, and a footer action row laid out below it, and its background, corner radius, padding, scrim, shadow and title colour come from the theme (Design > Components > Dialogs), so every dialog in the app matches. It is built on `<Overlay>`, so it renders OUTSIDE the widget layout box on both hosts and nothing can clip it. Never hand-build a header, a close button or an action row inside a bare `<Overlay>`; keep `<Overlay>` for surfaces that are not a dialog (a media or document preview, a lightbox). Props: `visible` (boolean), `onRequestClose` (fired by the close button, the backdrop press, the Escape key on web and the Android back button — always wire it), `dismissible` (default true; false hides the close button and stops the backdrop, Escape and Android back from closing it, for a dialog that must stay open while it saves — only while that work runs, and always restore it, or nobody can close the dialog), `title` (string), `footer` (the action buttons, laid out as one right-aligned row that wraps on a phone; omit for no footer), `presentation` ("dialog", centred, the default | "sheet", anchored to the bottom edge; keep a form with text inputs in "dialog" — in the exported app the keyboard handling of a sheet is not yet verified on a device), `size` ("sm" | "md" | "lg" | "full", default "md"), `dismissOnBackdropPress` (default true), `role` ("dialog" default | "alertdialog"; web only), `initialFocusRef` (a ref to the control focused on open; web only), `closeAccessibilityLabel` (default "Close" — pass your translated label), `accessibilityLabel` (names a dialog with no title) and `testID`. With no `onRequestClose` it draws no close button. Components rendered inside it read a theme re-inked for the background the dialog paints, so build the body as its own component. Do not wrap the body in your own `ScrollView` and do not re-style the panel.',
2890
2920
  rnComponent: null,
2891
2921
  docsUrl: null,
2892
2922
  },
@@ -3026,7 +3056,35 @@ const PRIMITIVES = [
3026
3056
  {
3027
3057
  name: "TextField",
3028
3058
  description:
3029
- '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`.',
3059
+ '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`.',
3060
+ rnComponent: null,
3061
+ docsUrl: null,
3062
+ },
3063
+ {
3064
+ name: "Checkbox",
3065
+ description:
3066
+ '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 "✓".',
3067
+ rnComponent: null,
3068
+ docsUrl: null,
3069
+ },
3070
+ {
3071
+ name: "Select",
3072
+ description:
3073
+ '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.',
3074
+ rnComponent: null,
3075
+ docsUrl: null,
3076
+ },
3077
+ {
3078
+ name: "SearchField",
3079
+ description:
3080
+ '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.',
3081
+ rnComponent: null,
3082
+ docsUrl: null,
3083
+ },
3084
+ {
3085
+ name: "spaceActivates",
3086
+ description:
3087
+ '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.',
3030
3088
  rnComponent: null,
3031
3089
  docsUrl: null,
3032
3090
  },
@@ -5550,7 +5608,23 @@ const CONTRACT = deepFreeze({
5550
5608
  // 1.166.0: additive (sc-10927) — the `Banner` primitive (a themed inline
5551
5609
  // error, warning, success or notice) and the `alert` component scope it
5552
5610
  // reads.
5553
- version: "1.166.0",
5611
+ // 1.169.0: additive (sc-10924) — the `Modal` primitive: the standard dialog
5612
+ // (title, close button, scrolling body, footer action row; `presentation`
5613
+ // "dialog" | "sheet") built on `Overlay`. New `themeComponents.modal`
5614
+ // scope (background, titleColor, scrim, radius, padding, shadow), read by
5615
+ // `Modal`; `Overlay` reads its `scrim` and gains `presentation`, `role`,
5616
+ // `initialFocusRef`, `backdropAccessible` and `testID`. A scope may now
5617
+ // name `sdkComponents` and bind no widget field; a colour token may be
5618
+ // `translucent` (always painted see-through) and carry a `note`.
5619
+ // 1.171.0: (sc-10929) — the form controls `Checkbox`, `Select` and
5620
+ // `SearchField`, and the `spaceActivates` helper. `TextField` gains
5621
+ // `label`, `helper`, `error`, `disabled` and `containerStyle`. The `muted`
5622
+ // and `popover` scopes name the `sdkComponents` that read them directly.
5623
+ // VISIBLE CHANGE, not additive: `Switch` is now the themed platform
5624
+ // switch, so an existing `<Switch>` with no colour props is repainted:
5625
+ // its tracks follow the theme on both hosts and its "on" thumb is white
5626
+ // on the web. A caller's `trackColor` / `thumbColor` still win.
5627
+ version: "1.171.0",
5554
5628
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
5555
5629
  hooks: HOOKS,
5556
5630
  primitives: PRIMITIVES,