@colixsystems/widget-sdk 0.247.0 → 0.248.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.
@@ -0,0 +1,268 @@
1
+ // The ONE implementation behind the form layout components: `<Form>`,
2
+ // `<FieldRow>`, `<FormSection>`, `<FormActions>` and `<FormField>`. The
3
+ // bindings (`form.js` / `form.native.js`) differ only in where the primitives
4
+ // and `<Grid>` come from, so the hosts lay a form out the same way.
5
+ //
6
+ // Fields share lines through `<Grid columns>`, the SDK's one grid layout, and
7
+ // a form measures itself with `useContainerWidth`. The rules that need no
8
+ // renderer live in ./form-layout-logic.js.
9
+
10
+ import React from "react";
11
+ import { useContainerWidth, useHostSharedText, useHostTheme } from "./hooks.js";
12
+ import { DEFAULT_THEME_TOKENS } from "./_theme-tokens.js";
13
+ import { resolveGridGap } from "./grid-logic.js";
14
+ import { resolveFormControlTokens } from "./form-control-tokens.js";
15
+ import { drawsOwnFrame, errorMessage, hasNote, isShown, isText, spokenLabel } from "./form-control-logic.js";
16
+ import { makeFieldFrame } from "./form-control-view.js";
17
+ import { textRoleStyle } from "./text-roles.js";
18
+ import {
19
+ DEFAULT_FORM_GAP,
20
+ actionsStack,
21
+ fieldRowColumns,
22
+ formColumns,
23
+ formMaxWidth,
24
+ formRuns,
25
+ handedFieldProps,
26
+ } from "./form-layout-logic.js";
27
+
28
+ const h = React.createElement;
29
+
30
+ function useThemeTokens() {
31
+ return useHostTheme() || DEFAULT_THEME_TOKENS;
32
+ }
33
+
34
+ /**
35
+ * Bind the shared form layout to one platform's primitives and `<Grid>`.
36
+ *
37
+ * @param {{View: any, Text: any, Pressable: any, Platform: any, Grid: any}} rn
38
+ */
39
+ export function makeForm(rn) {
40
+ const { View, Text, Platform, Grid } = rn;
41
+ const web = Boolean(Platform) && Platform.OS === "web";
42
+ const FieldFrame = makeFieldFrame(rn);
43
+ // The enclosing form's measured width, column count and gap.
44
+ const FormLayout = React.createContext(null);
45
+
46
+ // Outside a `<Form>` a part measures its own unpadded box instead.
47
+ function useLayout(gap) {
48
+ const theme = useThemeTokens();
49
+ const form = React.useContext(FormLayout);
50
+ const [ownWidth, onLayout] = useContainerWidth();
51
+ const spacing = theme.spacing || DEFAULT_THEME_TOKENS.spacing;
52
+ if (form) {
53
+ const space = gap === undefined ? form.space : resolveGridGap(gap, spacing);
54
+ return { ...form, space, onLayout: undefined, theme };
55
+ }
56
+ const space = resolveGridGap(gap ?? DEFAULT_FORM_GAP, spacing);
57
+ return { width: ownWidth, columns: formColumns("auto", ownWidth), space, onLayout, theme };
58
+ }
59
+
60
+ function takesWholeLine(child) {
61
+ if (!React.isValidElement(child)) return true;
62
+ if (child.type === FormField) {
63
+ if (child.props.span === "full") return true;
64
+ const inner = React.Children.toArray(child.props.children);
65
+ return inner.length === 1 && React.isValidElement(inner[0]) && inner[0].props.multiline === true;
66
+ }
67
+ return !(drawsOwnFrame(child) && child.props.multiline !== true);
68
+ }
69
+
70
+ // Fields share lines in a Grid; anything else takes a line of its own.
71
+ function renderRuns(children, layout) {
72
+ const items = React.Children.toArray(children);
73
+ const sectionGap = layout.theme.spacing ? layout.theme.spacing.sm : DEFAULT_THEME_TOKENS.spacing.sm;
74
+ return formRuns(items.map(takesWholeLine)).map((run) => {
75
+ if (run.full) {
76
+ const index = run.indices[0];
77
+ const child = items[index];
78
+ // A section after other content stands further apart from it.
79
+ if (index > 0 && React.isValidElement(child) && child.type === FormSection) {
80
+ return React.cloneElement(child, { style: [{ marginTop: sectionGap }, child.props.style] });
81
+ }
82
+ return child;
83
+ }
84
+ const cells = run.indices.map((i) => items[i]);
85
+ const first = cells[0];
86
+ return h(
87
+ Grid,
88
+ { key: `fields${React.isValidElement(first) ? first.key : run.indices[0]}`, columns: layout.columns, gap: layout.space },
89
+ ...cells,
90
+ );
91
+ });
92
+ }
93
+
94
+ /**
95
+ * @param {object} props
96
+ * @param {"auto"|number} [props.columns] — the most fields to a line when
97
+ * wide (1–3); "auto" is three. Medium holds at most two, narrow one.
98
+ * @param {number|null} [props.maxWidth] — the cap the form centres within;
99
+ * 1000 by default, `null` for none.
100
+ * @param {string|number} [props.gap] — a theme spacing name or pixels.
101
+ */
102
+ function Form({ children, columns = "auto", maxWidth, gap, style, testID, accessibilityLabel }) {
103
+ const theme = useThemeTokens();
104
+ const [width, onLayout] = useContainerWidth();
105
+ const space = resolveGridGap(gap ?? DEFAULT_FORM_GAP, theme.spacing || DEFAULT_THEME_TOKENS.spacing);
106
+ const lineColumns = formColumns(columns, width);
107
+ const layout = React.useMemo(
108
+ () => ({ width, columns: lineColumns, space, theme }),
109
+ [width, lineColumns, space, theme],
110
+ );
111
+ const cap = formMaxWidth(maxWidth);
112
+ // Not role "form": the web host renders that as a <form>, which submits on Enter.
113
+ const named = accessibilityLabel ? { role: "group", accessibilityLabel } : null;
114
+ return h(
115
+ View,
116
+ {
117
+ testID,
118
+ ...named,
119
+ // All three, or a capped form sits flush left or shrink-wraps.
120
+ style: [{ width: "100%", alignSelf: "center" }, cap ? { maxWidth: cap } : null, style],
121
+ },
122
+ h(
123
+ View,
124
+ { onLayout, style: { gap: space } },
125
+ h(FormLayout.Provider, { value: layout }, ...renderRuns(children, layout)),
126
+ ),
127
+ );
128
+ }
129
+
130
+ /** Short related fields on one line; stacked when narrow. */
131
+ function FieldRow({ children, gap, style, testID }) {
132
+ const layout = useLayout(gap);
133
+ const items = React.Children.toArray(children);
134
+ const space = gap === undefined ? layout.space : resolveGridGap(gap, layout.theme.spacing);
135
+ return h(
136
+ View,
137
+ { testID, style: [{ alignSelf: "stretch" }, style] },
138
+ h(
139
+ View,
140
+ { onLayout: layout.onLayout },
141
+ h(Grid, { columns: fieldRowColumns(items.length, layout.width), gap: space }, ...items),
142
+ ),
143
+ );
144
+ }
145
+
146
+ /** A titled group of fields, laid out as the form around it lays its own. */
147
+ function FormSection({ title, description, children, gap, style, testID }) {
148
+ const layout = useLayout(gap);
149
+ const { theme } = layout;
150
+ const t = resolveFormControlTokens(theme);
151
+ const heading = title
152
+ ? h(
153
+ Text,
154
+ {
155
+ accessibilityRole: "header",
156
+ style: textRoleStyle("subtitle", theme.typography, theme.colors),
157
+ },
158
+ title,
159
+ )
160
+ : null;
161
+ const about = description
162
+ ? h(Text, { style: { color: t.helperColor, fontFamily: t.fontFamily, fontSize: t.labelSize } }, description)
163
+ : null;
164
+ return h(
165
+ View,
166
+ { testID, style: [{ alignSelf: "stretch" }, style] },
167
+ h(
168
+ View,
169
+ { onLayout: layout.onLayout, style: { gap: layout.space } },
170
+ heading || about ? h(View, { style: { gap: t.gap } }, heading, about) : null,
171
+ ...renderRuns(children, layout),
172
+ ),
173
+ );
174
+ }
175
+
176
+ /**
177
+ * The form's buttons, in normal flow after its fields. Pass the secondary
178
+ * first and the primary last: a row at the trailing edge, primary last;
179
+ * narrow, a full-width stack in the same order, primary nearest the thumb.
180
+ */
181
+ function FormActions({ children, style, testID }) {
182
+ const layout = useLayout();
183
+ const { spacing } = layout.theme;
184
+ const stacked = actionsStack(layout.width);
185
+ const items = React.Children.toArray(children).map((child) =>
186
+ stacked && React.isValidElement(child)
187
+ ? React.cloneElement(child, { style: [child.props.style, { alignSelf: "stretch" }] })
188
+ : child,
189
+ );
190
+ const arrangement = stacked
191
+ ? { gap: spacing.sm }
192
+ : { flexDirection: "row", flexWrap: "wrap", justifyContent: "flex-end", alignItems: "center", gap: spacing.sm };
193
+ return h(
194
+ View,
195
+ { testID, style: [{ alignSelf: "stretch" }, style] },
196
+ h(View, { onLayout: layout.onLayout, style: arrangement }, ...items),
197
+ );
198
+ }
199
+
200
+ // A device has no group name or aria-describedby: name the control itself.
201
+ function namedForDevice(only, name, hint) {
202
+ if (web || !only || !React.isValidElement(only) || !isShown(name)) return null;
203
+ if (isShown(only.props.accessibilityLabel)) return null;
204
+ const hinted = isText(hint) && only.props.accessibilityHint === undefined ? { accessibilityHint: String(hint) } : null;
205
+ return React.cloneElement(only, { accessibilityLabel: name, ...hinted });
206
+ }
207
+
208
+ /**
209
+ * A label, required mark, helper and error round any control. A control
210
+ * that frames itself (`<TextField>`, `<Select>`, `<RadioGroup>`,
211
+ * `<PeoplePicker>`, `<FileUpload>`) is handed them instead, so one label
212
+ * is drawn; a prop the control sets itself wins.
213
+ *
214
+ * @param {object} props
215
+ * @param {"full"} [props.span] — take the form's whole line.
216
+ */
217
+ function FormField({ label, required = false, helper, error, disabled = false, children, style, testID }) {
218
+ const theme = useThemeTokens();
219
+ const t = resolveFormControlTokens(theme);
220
+ const requiredWord = useHostSharedText("required", "Required");
221
+ const noteId = React.useId();
222
+ const items = React.Children.toArray(children);
223
+ const only = items.length === 1 ? items[0] : null;
224
+ if (only && drawsOwnFrame(only)) {
225
+ const field = { label, required: required || undefined, helper, error, disabled: disabled || undefined, testID };
226
+ const handed = handedFieldProps(field, only.props);
227
+ const containerStyle = style ? [only.props.containerStyle, style] : only.props.containerStyle;
228
+ const control = React.cloneElement(only, { ...handed, containerStyle });
229
+ // The control keeps its own testID; the field's then marks a wrapper.
230
+ return testID && handed.testID === undefined ? h(View, { testID }, control) : control;
231
+ }
232
+ const noted = hasNote({ helper, error });
233
+ const name = spokenLabel(undefined, label, required ? requiredWord : null);
234
+ const named = namedForDevice(only, name, errorMessage(error) || helper);
235
+ return h(
236
+ FieldFrame,
237
+ {
238
+ t,
239
+ label,
240
+ required,
241
+ helper,
242
+ error: errorMessage(error),
243
+ disabled,
244
+ noteId,
245
+ // Unnamed, the device reads the label alone, so it carries the word.
246
+ spokenRequired: web || named ? undefined : requiredWord,
247
+ style,
248
+ },
249
+ h(
250
+ View,
251
+ {
252
+ testID,
253
+ role: "group",
254
+ accessibilityLabel: named ? undefined : name,
255
+ ...(noted ? { "aria-describedby": noteId } : null),
256
+ },
257
+ ...(named ? [named] : items),
258
+ ),
259
+ );
260
+ }
261
+
262
+ Form.displayName = "Form";
263
+ FieldRow.displayName = "FieldRow";
264
+ FormSection.displayName = "FormSection";
265
+ FormActions.displayName = "FormActions";
266
+ FormField.displayName = "FormField";
267
+ return { Form, FieldRow, FormSection, FormActions, FormField };
268
+ }
package/dist/form.js ADDED
@@ -0,0 +1,15 @@
1
+ // `<Form>`, `<FieldRow>`, `<FormSection>`, `<FormActions>` and `<FormField>`
2
+ // (web). Binds ./form-view.js to the web primitives and the web `<Grid>`; see
3
+ // ./primitives.js for why not the bare specifier.
4
+
5
+ import * as ReactNative from "react-native-web";
6
+ import { makeForm } from "./form-view.js";
7
+ import { Grid } from "./grid.js";
8
+
9
+ export const { Form, FieldRow, FormSection, FormActions, FormField } = makeForm({
10
+ View: ReactNative.View,
11
+ Text: ReactNative.Text,
12
+ Pressable: ReactNative.Pressable,
13
+ Platform: ReactNative.Platform,
14
+ Grid,
15
+ });
@@ -0,0 +1,9 @@
1
+ // `<Form>`, `<FieldRow>`, `<FormSection>`, `<FormActions>` and `<FormField>`
2
+ // (native). Binds ./form-view.js to React Native's own primitives and the
3
+ // native `<Grid>`; only the imports differ from ./form.js.
4
+
5
+ import { View, Text, Pressable, Platform } from "react-native";
6
+ import { makeForm } from "./form-view.js";
7
+ import { Grid } from "./grid.native.js";
8
+
9
+ export const { Form, FieldRow, FormSection, FormActions, FormField } = makeForm({ View, Text, Pressable, Platform, Grid });