@colixsystems/widget-sdk 0.246.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.
Files changed (54) hide show
  1. package/README.md +76 -6
  2. package/dist/board-auto-scroll.js +59 -0
  3. package/dist/board-view.js +327 -0
  4. package/dist/chart-logic.js +575 -0
  5. package/dist/chart-tokens.js +131 -0
  6. package/dist/chart-view.js +262 -0
  7. package/dist/chart.js +21 -0
  8. package/dist/chart.native.js +10 -0
  9. package/dist/contract.cjs +119 -7
  10. package/dist/contract.js +119 -7
  11. package/dist/data-table-logic.js +179 -0
  12. package/dist/data-table-view.js +388 -0
  13. package/dist/data-table.js +22 -0
  14. package/dist/data-table.native.js +14 -0
  15. package/dist/drag-kit-view.js +310 -0
  16. package/dist/file-upload-view.js +13 -2
  17. package/dist/filter-bar-logic.js +143 -0
  18. package/dist/filter-bar-view.js +381 -0
  19. package/dist/filter-bar.js +15 -0
  20. package/dist/filter-bar.native.js +13 -0
  21. package/dist/form-control-logic.js +20 -3
  22. package/dist/form-control-view.js +18 -0
  23. package/dist/form-layout-logic.js +92 -0
  24. package/dist/form-view.js +268 -0
  25. package/dist/form.js +15 -0
  26. package/dist/form.native.js +9 -0
  27. package/dist/grid-logic.js +222 -0
  28. package/dist/grid-view.js +109 -0
  29. package/dist/grid.js +7 -0
  30. package/dist/grid.native.js +7 -0
  31. package/dist/host.d.ts +6 -0
  32. package/dist/host.js +2 -0
  33. package/dist/index.d.ts +606 -7
  34. package/dist/index.js +15 -0
  35. package/dist/index.native.js +15 -0
  36. package/dist/menu-view.js +5 -2
  37. package/dist/people-picker-logic.js +203 -0
  38. package/dist/people-picker-view.js +250 -0
  39. package/dist/people-picker.js +11 -0
  40. package/dist/people-picker.native.js +9 -0
  41. package/dist/radio-group-view.js +7 -3
  42. package/dist/select-search-logic.js +150 -0
  43. package/dist/select-view.js +251 -18
  44. package/dist/select.js +6 -2
  45. package/dist/select.native.js +5 -3
  46. package/dist/sortable-list-view.js +228 -0
  47. package/dist/sortable-logic.js +273 -0
  48. package/dist/sortable-tokens.js +120 -0
  49. package/dist/sortable-view.js +24 -0
  50. package/dist/sortable.js +39 -0
  51. package/dist/sortable.native.js +30 -0
  52. package/dist/text-field-view.js +7 -2
  53. package/package.json +1 -1
  54. package/src/contract.js +119 -7
@@ -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 });
@@ -0,0 +1,222 @@
1
+ // The `<Grid>` arithmetic, without a renderer: how many cells fit a line, how
2
+ // the cells split into balanced lines, and how big a definite-size cell is.
3
+ // These are the formulas the AI widget builder is taught for a grid it lays
4
+ // out by hand, so the component and that guidance cannot disagree.
5
+
6
+ import { widthTier } from "./container-width.js";
7
+
8
+ export const GRID_BALANCES = Object.freeze(["cards", "tiles"]);
9
+ export const DEFAULT_GRID_BALANCE = "cards";
10
+ /** The card width a cards grid counts with when it is given none. */
11
+ export const DEFAULT_MIN_CELL_WIDTH = 240;
12
+ /** The smallest a definite-size cell gets when `cell.min` is not given. */
13
+ export const DEFAULT_CELL_MIN = 32;
14
+ export const DEFAULT_GRID_GAP = "md";
15
+ /** A tile row's per-line ceiling at each width tier. */
16
+ export const TILE_COLUMNS_BY_TIER = Object.freeze({ narrow: 2, medium: 3, wide: 4 });
17
+
18
+ function positiveInt(value) {
19
+ const n = Math.floor(Number(value));
20
+ return Number.isFinite(n) && n >= 1 ? n : null;
21
+ }
22
+
23
+ function nonNegative(value) {
24
+ const n = Number(value);
25
+ return Number.isFinite(n) && n >= 0 ? n : null;
26
+ }
27
+
28
+ /** A requested balance, or cards when it names none we draw. */
29
+ export function gridBalance(balance) {
30
+ return GRID_BALANCES.includes(balance) ? balance : DEFAULT_GRID_BALANCE;
31
+ }
32
+
33
+ /**
34
+ * The gap in pixels: a theme spacing name (`"xs"` … `"xl"`) or a number.
35
+ *
36
+ * @param {string|number|undefined} gap
37
+ * @param {Record<string, number>} spacing the theme's spacing scale
38
+ */
39
+ export function resolveGridGap(gap, spacing) {
40
+ const scale = spacing || {};
41
+ const asNumber = nonNegative(gap);
42
+ if (typeof gap === "number" && asNumber !== null) return asNumber;
43
+ const named = typeof gap === "string" ? nonNegative(scale[gap]) : null;
44
+ if (named !== null) return named;
45
+ return nonNegative(scale[DEFAULT_GRID_GAP]) ?? 16;
46
+ }
47
+
48
+ /** How many cells of at least `minCellWidth` fit `width`: at least one. */
49
+ export function cellsPerLine(width, gap, minCellWidth) {
50
+ return Math.max(1, Math.floor((width + gap) / (minCellWidth + gap)));
51
+ }
52
+
53
+ /**
54
+ * Tiles stretch, so the remainder spreads over every line: 5 at 4 are 3 + 2,
55
+ * 7 at 3 are 3 + 2 + 2, and a short line only ever comes last.
56
+ */
57
+ export function tileLineLengths(count, max) {
58
+ if (!(count > 0)) return [];
59
+ const lines = Math.ceil(count / max);
60
+ const base = Math.floor(count / lines);
61
+ const extra = count % lines;
62
+ return Array.from({ length: lines }, (_, i) => base + (i < extra ? 1 : 0));
63
+ }
64
+
65
+ /**
66
+ * Cards keep one width, so every line but the last is full: 6 that fit 5 are
67
+ * 3 + 3, 10 that fit 4 are 4 + 4 + 2. `cols` is the width every line keeps.
68
+ */
69
+ export function cardLineLengths(count, max) {
70
+ if (!(count > 0)) return { cols: 0, lengths: [] };
71
+ const lines = Math.ceil(count / max);
72
+ const cols = Math.ceil(count / lines);
73
+ const lengths = Array.from({ length: lines }, (_, i) => Math.min(cols, count - i * cols));
74
+ return { cols, lengths };
75
+ }
76
+
77
+ /** A definite cell: the share of the line each column gets, clamped. */
78
+ export function clampCellSize(usable, gap, columns, min, max) {
79
+ const share = Math.floor((usable - gap * (columns - 1)) / columns);
80
+ return Math.max(min, Math.min(share, max));
81
+ }
82
+
83
+ function exactLines(count, columns) {
84
+ const lines = Math.ceil(count / columns);
85
+ return Array.from({ length: lines }, (_, i) => Math.min(columns, count - i * columns));
86
+ }
87
+
88
+ function definiteCell(cell) {
89
+ if (!cell || typeof cell !== "object") return null;
90
+ const max = nonNegative(cell.max);
91
+ if (max === null || max <= 0) return null;
92
+ const min = Math.min(nonNegative(cell.min) ?? DEFAULT_CELL_MIN, max);
93
+ return { min, max };
94
+ }
95
+
96
+ /**
97
+ * Lay out `count` cells in a `width`-wide box.
98
+ *
99
+ * Returns `null` while a count or a size depends on a width not yet measured
100
+ * (0), so the grid renders nothing size-dependent until it arrives. Otherwise
101
+ * `lengths` is the number of cells on each line, `slots` the positions every
102
+ * line is padded up to (null when lines stretch, as tiles do), and, for
103
+ * definite cells, `cellSize`, the block's `blockWidth` and its `align`: the
104
+ * block centres, but one wider than the grid starts at the leading edge so
105
+ * only its far end is cut off.
106
+ *
107
+ * @param {{count: number, width: number, gap: number, balance?: string,
108
+ * minCellWidth?: number, maxColumns?: number, columns?: number,
109
+ * cell?: {min?: number, max: number}}} input
110
+ */
111
+ export function planGrid(input) {
112
+ const count = Math.max(0, Math.floor(Number(input.count) || 0));
113
+ const width = Number(input.width) > 0 ? Number(input.width) : 0;
114
+ const gap = nonNegative(input.gap) ?? 0;
115
+ const balance = gridBalance(input.balance);
116
+ const columns = positiveInt(input.columns);
117
+ const maxColumns = positiveInt(input.maxColumns);
118
+ const minCellWidth = nonNegative(input.minCellWidth);
119
+ const cell = definiteCell(input.cell);
120
+ const capped = (n) => (maxColumns ? Math.min(n, maxColumns) : n);
121
+
122
+ if (cell) {
123
+ if (width === 0) return null;
124
+ let perLine = columns;
125
+ if (!perLine) {
126
+ // As many columns as keep each cell at or under its max, filling the line.
127
+ perLine = capped(Math.max(1, Math.ceil((width + gap) / (cell.max + gap))));
128
+ while (perLine > 1 && clampCellSize(width, gap, perLine, 0, cell.max) < cell.min) perLine -= 1;
129
+ }
130
+ const plan = columns
131
+ ? { cols: columns, lengths: exactLines(count, columns) }
132
+ : cardLineLengths(count, perLine);
133
+ const cols = plan.cols || perLine;
134
+ const cellSize = clampCellSize(width, gap, cols, cell.min, cell.max);
135
+ const blockWidth = cellSize * cols + gap * (cols - 1);
136
+ return {
137
+ lengths: plan.lengths,
138
+ slots: cols,
139
+ cellSize,
140
+ blockWidth,
141
+ align: blockWidth > width ? "flex-start" : "center",
142
+ };
143
+ }
144
+
145
+ if (columns) {
146
+ return { lengths: exactLines(count, columns), slots: columns, cellSize: null, blockWidth: null };
147
+ }
148
+
149
+ const counted = minCellWidth !== null && minCellWidth > 0 ? minCellWidth : null;
150
+ if (balance === "tiles") {
151
+ let max;
152
+ if (counted) {
153
+ if (width === 0) return null;
154
+ max = capped(cellsPerLine(width, gap, counted));
155
+ } else if (maxColumns) {
156
+ max = maxColumns;
157
+ } else {
158
+ if (width === 0) return null;
159
+ max = TILE_COLUMNS_BY_TIER[widthTier(width)];
160
+ }
161
+ return { lengths: tileLineLengths(count, max), slots: null, cellSize: null, blockWidth: null };
162
+ }
163
+
164
+ if (width === 0) return null;
165
+ const max = capped(cellsPerLine(width, gap, counted || DEFAULT_MIN_CELL_WIDTH));
166
+ const { cols, lengths } = cardLineLengths(count, max);
167
+ return { lengths, slots: cols, cellSize: null, blockWidth: null };
168
+ }
169
+
170
+ /** Split `items` into lines of the planned lengths. */
171
+ export function splitIntoLines(items, lengths) {
172
+ const lines = [];
173
+ let start = 0;
174
+ for (const length of lengths) {
175
+ lines.push(items.slice(start, start + length));
176
+ start += length;
177
+ }
178
+ return lines;
179
+ }
180
+
181
+ // Floored so a line of percentages never sums past 100% and wraps early.
182
+ function percentOf(parts) {
183
+ return `${Math.floor((100 / parts) * 10000) / 10000}%`;
184
+ }
185
+
186
+ function place(plan, gap, position, perLine) {
187
+ if (plan.cellSize !== null) return { width: plan.cellSize, padStart: 0, padEnd: 0 };
188
+ // The gap is split between neighbours, so every cell keeps an equal share.
189
+ return {
190
+ width: percentOf(perLine),
191
+ padStart: (gap * position) / perLine,
192
+ padEnd: (gap * (perLine - 1 - position)) / perLine,
193
+ };
194
+ }
195
+
196
+ /**
197
+ * Where each cell sits in the ONE wrapping box the grid draws: its `line`,
198
+ * its `width` (pixels for a definite cell, else a share of the box) and the
199
+ * `padStart` / `padEnd` that make up the gap between flexible neighbours.
200
+ * `spacers` are the empty cells that pad the last line to `slots`. Every
201
+ * cell stays a child of the same box, so moving it to another line never
202
+ * remounts it.
203
+ *
204
+ * @param {{lengths: number[], slots: number|null, cellSize: number|null}} plan
205
+ * @param {number} gap
206
+ */
207
+ export function placeCells(plan, gap) {
208
+ const cells = [];
209
+ let spacers = [];
210
+ plan.lengths.forEach((length, line) => {
211
+ const perLine = plan.slots ?? length;
212
+ for (let position = 0; position < length; position += 1) {
213
+ cells.push({ line, ...place(plan, gap, position, perLine) });
214
+ }
215
+ if (line === plan.lengths.length - 1 && plan.slots !== null) {
216
+ spacers = Array.from({ length: Math.max(0, plan.slots - length) }, (_, s) =>
217
+ place(plan, gap, length + s, perLine),
218
+ );
219
+ }
220
+ });
221
+ return { cells, spacers };
222
+ }
@@ -0,0 +1,109 @@
1
+ // The ONE implementation behind the `<Grid>` SDK primitive. The platform
2
+ // bindings (`grid.js` / `grid.native.js`) differ only in where `View` comes
3
+ // from, so the hosts lay a grid out the same way.
4
+ //
5
+ // The grid measures an inner View that carries no padding, so a `style`
6
+ // padding on the grid is never counted into the width its cells share.
7
+
8
+ import React from "react";
9
+ import { useContainerWidth, useHostTheme } from "./hooks.js";
10
+ import { DEFAULT_THEME_TOKENS } from "./_theme-tokens.js";
11
+ import { placeCells, planGrid, resolveGridGap } from "./grid-logic.js";
12
+
13
+ const h = React.createElement;
14
+
15
+ const HIDDEN_FROM_READERS = {
16
+ "aria-hidden": true,
17
+ accessibilityElementsHidden: true,
18
+ importantForAccessibility: "no-hide-descendants",
19
+ };
20
+
21
+ function cellStyle(place, plan, square, hidden) {
22
+ const own = hidden ? { pointerEvents: "none" } : null;
23
+ if (plan.cellSize !== null) {
24
+ const size = { width: plan.cellSize, flexShrink: 0, ...own };
25
+ return square ? { ...size, height: plan.cellSize } : size;
26
+ }
27
+ return { width: place.width, paddingStart: place.padStart, paddingEnd: place.padEnd, ...own };
28
+ }
29
+
30
+ /**
31
+ * Bind the shared grid to one platform's primitives.
32
+ *
33
+ * @param {{View: any}} rn
34
+ */
35
+ export function makeGrid(rn) {
36
+ const { View } = rn;
37
+
38
+ // A flexible square cell squares its content box, inside the gap padding.
39
+ function cell(key, style, square, flexible, child, extra) {
40
+ const inner = square && flexible ? h(View, { style: { aspectRatio: 1 } }, child) : child;
41
+ return h(View, { key, style, ...extra }, inner);
42
+ }
43
+
44
+ function Grid({
45
+ children,
46
+ balance,
47
+ minCellWidth,
48
+ maxColumns,
49
+ columns,
50
+ cell: cellSize,
51
+ square = false,
52
+ gap,
53
+ style,
54
+ testID,
55
+ accessibilityLabel,
56
+ }) {
57
+ const theme = useHostTheme() || DEFAULT_THEME_TOKENS;
58
+ const spacing = theme.spacing || DEFAULT_THEME_TOKENS.spacing;
59
+ const space = resolveGridGap(gap, spacing);
60
+ const [width, onLayout] = useContainerWidth();
61
+ const items = React.Children.toArray(children);
62
+ const plan = planGrid({
63
+ count: items.length,
64
+ width,
65
+ gap: space,
66
+ balance,
67
+ minCellWidth,
68
+ maxColumns,
69
+ columns,
70
+ cell: cellSize,
71
+ });
72
+
73
+ let content = null;
74
+ if (plan) {
75
+ const isSquare = square === true;
76
+ const flexible = plan.cellSize === null;
77
+ const { cells, spacers } = placeCells(plan, space);
78
+ const wrap = flexible
79
+ ? { flexDirection: "row", flexWrap: "wrap", rowGap: space }
80
+ : {
81
+ flexDirection: "row",
82
+ flexWrap: "wrap",
83
+ gap: space,
84
+ width: plan.blockWidth,
85
+ alignSelf: plan.align,
86
+ };
87
+ // One box for every cell, so a cell changing line keeps its state.
88
+ content = h(
89
+ View,
90
+ { style: wrap },
91
+ ...items.map((child, i) =>
92
+ cell(child.key, cellStyle(cells[i], plan, isSquare, false), isSquare, flexible, child),
93
+ ),
94
+ ...spacers.map((place, s) =>
95
+ cell(`spacer-${s}`, cellStyle(place, plan, isSquare, true), isSquare, flexible, null, HIDDEN_FROM_READERS),
96
+ ),
97
+ );
98
+ }
99
+
100
+ const named = accessibilityLabel ? { role: "group", accessibilityLabel } : null;
101
+ return h(
102
+ View,
103
+ { testID, ...named, style: [{ alignSelf: "stretch" }, style] },
104
+ h(View, { onLayout }, content),
105
+ );
106
+ }
107
+
108
+ return Grid;
109
+ }
package/dist/grid.js ADDED
@@ -0,0 +1,7 @@
1
+ // `<Grid>` (web). Binds ./grid-view.js to react-native-web; see
2
+ // ./primitives.js for why not the bare specifier.
3
+
4
+ import * as ReactNative from "react-native-web";
5
+ import { makeGrid } from "./grid-view.js";
6
+
7
+ export const Grid = makeGrid({ View: ReactNative.View });
@@ -0,0 +1,7 @@
1
+ // `<Grid>` (native). Binds ./grid-view.js to React Native's own primitives;
2
+ // only the import differs from ./grid.js.
3
+
4
+ import { View } from "react-native";
5
+ import { makeGrid } from "./grid-view.js";
6
+
7
+ export const Grid = makeGrid({ View });
package/dist/host.d.ts CHANGED
@@ -51,6 +51,12 @@ export function bannerScopeDefaults(theme: unknown): Record<string, string | num
51
51
  */
52
52
  export function chipScopeDefaults(theme: unknown): Record<string, string | number>;
53
53
 
54
+ /**
55
+ * What each token of the `chart` component scope resolves to while unset,
56
+ * keyed by token name: the theme colours `<Chart>` paints an unthemed app with.
57
+ */
58
+ export function chartScopeDefaults(theme: unknown): Record<string, string | number>;
59
+
54
60
  /**
55
61
  * What each token of the `search` component scope resolves to while unset,
56
62
  * keyed by token name: the input look `<SearchField>` falls back to.
package/dist/host.js CHANGED
@@ -54,6 +54,8 @@ export { resolveModalTokens, modalScopeDefaults } from "./modal-tokens.js";
54
54
  // What each `chip` scope token resolves to while unset, for the Studio's
55
55
  // Design page to preview beside its controls.
56
56
  export { chipScopeDefaults } from "./chip-tokens.js";
57
+ // The theme colours each unset `chart` token falls back to, for the same page.
58
+ export { chartScopeDefaults } from "./chart-tokens.js";
57
59
  // The input look each unset `search` token falls back to, for the same page.
58
60
  export { searchScopeDefaults } from "./search-field-tokens.js";
59
61