hs-uix 1.0.0 → 1.0.2

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/README.md CHANGED
@@ -1,80 +1,768 @@
1
1
  # hs-uix
2
2
 
3
- [![@hs-uix/datatable](https://img.shields.io/npm/v/@hs-uix/datatable?label=%40hs-uix%2Fdatatable)](https://www.npmjs.com/package/@hs-uix/datatable)
4
- [![@hs-uix/form](https://img.shields.io/npm/v/@hs-uix/form?label=%40hs-uix%2Fform)](https://www.npmjs.com/package/@hs-uix/form)
5
- [![license](https://img.shields.io/npm/l/@hs-uix/datatable)](./LICENSE)
3
+ [![npm version](https://img.shields.io/npm/v/hs-uix)](https://www.npmjs.com/package/hs-uix)
4
+ [![license](https://img.shields.io/npm/l/hs-uix)](./LICENSE)
6
5
 
7
- Production-ready UI components for [HubSpot UI Extensions](https://developers.hubspot.com/docs/platform/ui-extensions-overview). Built entirely on HubSpot's native primitives — no custom HTML, no CSS, no iframes.
8
-
9
- ## Packages
10
-
11
- | Package | Version | Description |
12
- |---------|---------|-------------|
13
- | [`@hs-uix/datatable`](./packages/datatable) | [![npm](https://img.shields.io/npm/v/@hs-uix/datatable)](https://www.npmjs.com/package/@hs-uix/datatable) | Filterable, sortable, paginated DataTable with auto-sized columns, inline editing, row grouping, and more |
14
- | [`@hs-uix/form`](./packages/form) | [![npm](https://img.shields.io/npm/v/@hs-uix/form)](https://www.npmjs.com/package/@hs-uix/form) | Declarative, config-driven FormBuilder with validation, multi-step wizards, and 20+ field types |
6
+ Production-ready UI components for [HubSpot UI Extensions](https://developers.hubspot.com/docs/apps/developer-platform/add-features/ui-extensions/overview). Built entirely on HubSpot's native primitives — no custom HTML, no CSS, no iframes.
15
7
 
16
8
  ## Install
17
9
 
18
10
  ```bash
19
- npm install @hs-uix/datatable
20
- npm install @hs-uix/form
11
+ npm install hs-uix
12
+ ```
13
+
14
+ ```jsx
15
+ import { DataTable } from "hs-uix/datatable";
16
+ import { FormBuilder } from "hs-uix/form";
17
+
18
+ // or import everything from the root
19
+ import { DataTable, FormBuilder } from "hs-uix";
20
+ ```
21
+
22
+ Requires `react` >= 18.0.0 and `@hubspot/ui-extensions` >= 0.12.0 as peer dependencies (already present in any HubSpot UI Extensions project).
23
+
24
+ ---
25
+
26
+ # DataTable
27
+
28
+ A drop-in table component for HubSpot UI Extensions. Define your columns, pass your data, and you get search, filtering, sorting, pagination, inline editing, row grouping, and auto-sized columns out of the box.
29
+
30
+ ![Full-Featured DataTable](https://raw.githubusercontent.com/05bmckay/hs-uix/main/packages/datatable/assets/fully-featured-table.png)
31
+
32
+ ## Why DataTable?
33
+
34
+ If you've built tables with HubSpot's `Table`, `TableRow`, and `TableCell` primitives, you know the drill: wire up search, sorting, pagination, and filtering yourself, then spend an hour tweaking column widths that still look wrong. DataTable does all of that for you.
35
+
36
+ The column sizing alone is worth it. DataTable looks at your actual data (types, string lengths, unique values, whether a column has edit controls) and picks widths automatically. Booleans and dates get compact columns, text gets room, and editable columns are never too narrow for their inputs. You don't configure any widths unless you want to.
37
+
38
+ ```jsx
39
+ import { DataTable } from "hs-uix/datatable";
40
+
41
+ const COLUMNS = [
42
+ { field: "name", label: "Company", sortable: true, renderCell: (val) => val },
43
+ { field: "status", label: "Status", renderCell: (val) => <StatusTag>{val}</StatusTag> },
44
+ { field: "amount", label: "Amount", sortable: true, renderCell: (val) => formatCurrency(val) },
45
+ ];
46
+
47
+ <DataTable data={deals} columns={COLUMNS} searchFields={["name"]} pageSize={10} />
48
+ ```
49
+
50
+ That's a searchable, sortable, paginated table with auto-sized columns in 5 lines of config.
51
+
52
+ ## DataTable Features
53
+
54
+ - Full-text search across any combination of fields, with optional fuzzy matching via Fuse.js
55
+ - Select, multi-select, and date range filters with configurable active badges and clear/reset controls
56
+ - Click-to-sort headers with three-state cycling (none, ascending, descending)
57
+ - Client-side or server-side pagination with configurable page size, visible page buttons, and First/Last navigation
58
+ - Collapsible row groups with per-column aggregation functions
59
+ - Row selection via checkboxes with client/server-aware "Select all" behavior
60
+ - Selection action bar with selected count, select/deselect all, and custom bulk action buttons
61
+ - Per-row actions via `rowActions` (static array or dynamic function)
62
+ - Two edit modes (discrete and inline/full-row) supporting 12 input types with per-column validation
63
+ - Auto-width column sizing based on data analysis, with manual overrides
64
+ - Text truncation helpers (`truncate: true` or `truncate: { maxLength }`)
65
+ - Customizable record label, row count display, and table appearance (`bordered`, `flush`, `scrollable`)
66
+ - Column-level footer for totals rows
67
+ - Works with `useAssociations` for live CRM data
68
+ - Server-side mode with loading/error states, search debounce, controlled state, and unified `onParamsChange` callback
69
+
70
+ ## Filters and Footer Totals
71
+
72
+ ![Active Filters](https://raw.githubusercontent.com/05bmckay/hs-uix/main/packages/datatable/assets/fully-featured-table-active-filters.png)
73
+
74
+ When more than 2 filters are defined, the first 2 appear inline and the rest are tucked behind a **Filters** button. Active filters display as removable chips with a "Clear all" option.
75
+
76
+ ```jsx
77
+ const FILTERS = [
78
+ {
79
+ name: "status",
80
+ type: "select",
81
+ placeholder: "All statuses",
82
+ options: [
83
+ { label: "Active", value: "active" },
84
+ { label: "At Risk", value: "at-risk" },
85
+ { label: "Churned", value: "churned" },
86
+ ],
87
+ },
88
+ { name: "closeDate", type: "dateRange", placeholder: "Close date" },
89
+ ];
90
+
91
+ const COLUMNS = [
92
+ { field: "company", label: "Company", sortable: true, footer: "Total",
93
+ renderCell: (val) => <Text format={{ fontWeight: "demibold" }}>{val}</Text> },
94
+ { field: "amount", label: "Amount", sortable: true, align: "right",
95
+ footer: (rows) => formatCurrency(rows.reduce((sum, r) => sum + r.amount, 0)),
96
+ renderCell: (val) => formatCurrency(val) },
97
+ ];
98
+
99
+ <DataTable
100
+ data={DEALS}
101
+ columns={COLUMNS}
102
+ filters={FILTERS}
103
+ searchFields={["company"]}
104
+ pageSize={5}
105
+ />
21
106
  ```
22
107
 
23
- ## Quick Start
108
+ ## Row Selection with Bulk Actions
24
109
 
25
- ### DataTable
110
+ ![Row Selection with Action Bar](https://raw.githubusercontent.com/05bmckay/hs-uix/main/packages/datatable/assets/action-bar-per-row-actions.png)
26
111
 
27
112
  ```jsx
28
- import { DataTable } from "@hs-uix/datatable";
113
+ const selectionActions = [
114
+ { label: "Edit", icon: "edit", onClick: (ids) => console.log("Edit", ids) },
115
+ { label: "Delete", icon: "delete", onClick: (ids) => console.log("Delete", ids) },
116
+ { label: "Export", icon: "dataExport", onClick: (ids) => console.log("Export", ids) },
117
+ ];
29
118
 
119
+ <DataTable
120
+ data={COMPANIES}
121
+ columns={columns}
122
+ selectable={true}
123
+ rowIdField="id"
124
+ recordLabel={{ singular: "Company", plural: "Companies" }}
125
+ onSelectionChange={setSelected}
126
+ selectionActions={selectionActions}
127
+ searchFields={["name", "contact"]}
128
+ pageSize={10}
129
+ />
130
+ ```
131
+
132
+ ## Inline Editing
133
+
134
+ ![Discrete Editing](https://raw.githubusercontent.com/05bmckay/hs-uix/main/packages/datatable/assets/inline-editing-discreet.png)
135
+
136
+ Two edit modes: **discrete** (click-to-edit, default) and **inline** (always-visible inputs).
137
+
138
+ ```jsx
30
139
  const columns = [
31
- { field: "name", label: "Name", sortable: true },
32
- { field: "status", label: "Status" },
33
- { field: "amount", label: "Amount", sortable: true },
140
+ { field: "company", label: "Company", editable: true, editType: "text",
141
+ renderCell: (val) => <Text format={{ fontWeight: "demibold" }}>{val}</Text> },
142
+ { field: "status", label: "Status", editable: true, editType: "select",
143
+ editOptions: [
144
+ { label: "Active", value: "active" },
145
+ { label: "At Risk", value: "at-risk" },
146
+ ],
147
+ renderCell: (val) => <StatusTag variant={STATUS_COLORS[val]}>{val}</StatusTag> },
148
+ { field: "amount", label: "Amount", editable: true, editType: "currency",
149
+ renderCell: (val) => formatCurrency(val) },
34
150
  ];
35
151
 
36
- <DataTable data={deals} columns={columns} searchFields={["name"]} pageSize={10} />;
152
+ <DataTable
153
+ data={data}
154
+ columns={columns}
155
+ rowIdField="id"
156
+ onRowEdit={(row, field, newValue) => handleEdit(row, field, newValue)}
157
+ />
158
+ ```
159
+
160
+ **Supported edit types:** `text`, `textarea`, `number`, `currency`, `stepper`, `select`, `multiselect`, `date`, `time`, `datetime`, `toggle`, `checkbox`
161
+
162
+ ## Row Grouping
163
+
164
+ ![Row Grouping](https://raw.githubusercontent.com/05bmckay/hs-uix/main/packages/datatable/assets/row-grouping.png)
165
+
166
+ Collapsible groups with per-column aggregation functions:
167
+
168
+ ```jsx
169
+ <DataTable
170
+ data={DEALS}
171
+ columns={COLUMNS}
172
+ groupBy={{
173
+ field: "segment",
174
+ label: (value, rows) => `${value} (${rows.length})`,
175
+ sort: "asc",
176
+ defaultExpanded: true,
177
+ aggregations: {
178
+ amount: (rows) => formatCurrency(rows.reduce((sum, r) => sum + r.amount, 0)),
179
+ },
180
+ }}
181
+ />
182
+ ```
183
+
184
+ ## Full-Row Editing
185
+
186
+ ![Full-Row Editing](https://raw.githubusercontent.com/05bmckay/hs-uix/main/packages/datatable/assets/full-row-editing.png)
187
+
188
+ Use `rowActions` with `editingRowId` for an Edit/Done flow:
189
+
190
+ ```jsx
191
+ <DataTable
192
+ data={rows}
193
+ columns={columns}
194
+ rowIdField="id"
195
+ editingRowId={editingRowId}
196
+ onRowEdit={handleCommittedEdit}
197
+ rowActions={(row) => editingRowId === row.id
198
+ ? [{ label: "Done", icon: "success", onClick: () => saveRow(row.id) }]
199
+ : [{ label: "Edit", icon: "edit", onClick: () => setEditingRowId(row.id) }]}
200
+ />
201
+ ```
202
+
203
+ ## Scrollable Wide Tables
204
+
205
+ ![Scrollable Wide Table](https://raw.githubusercontent.com/05bmckay/hs-uix/main/packages/datatable/assets/scrollable-wide-table.png)
206
+
207
+ ```jsx
208
+ <DataTable data={data} columns={manyColumns} scrollable={true} pageSize={10} />
209
+ ```
210
+
211
+ ## useAssociations
212
+
213
+ ![useAssociations + DataTable](https://raw.githubusercontent.com/05bmckay/hs-uix/main/packages/datatable/assets/useAssociations.png)
214
+
215
+ Connect live CRM data to a DataTable in two lines:
216
+
217
+ ```jsx
218
+ import { useAssociations } from "@hubspot/ui-extensions/crm";
219
+ import { DataTable } from "hs-uix/datatable";
220
+
221
+ const { results, isLoading } = useAssociations({
222
+ toObjectType: "0-3",
223
+ properties: ["dealname", "dealstage", "amount", "closedate"],
224
+ });
225
+
226
+ const deals = results.map((a) => ({
227
+ id: a.toObjectId,
228
+ dealname: a.properties.dealname,
229
+ amount: Number(a.properties.amount) || 0,
230
+ }));
231
+
232
+ <DataTable data={deals} columns={columns} searchFields={["dealname"]} />
233
+ ```
234
+
235
+ ## Server-Side Mode
236
+
237
+ For API-backed data or large datasets, use `serverSide={true}`. DataTable renders all the UI and fires callbacks — you handle the fetching.
238
+
239
+ ```jsx
240
+ <DataTable
241
+ serverSide={true}
242
+ loading={loading}
243
+ error={error}
244
+ data={pageRows}
245
+ totalCount={totalCount}
246
+ columns={COLUMNS}
247
+ searchFields={["name", "email"]}
248
+ filters={FILTERS}
249
+ pageSize={25}
250
+ page={params.page}
251
+ searchDebounce={300}
252
+ onParamsChange={(p) => fetchData(p)}
253
+ />
37
254
  ```
38
255
 
39
- ### FormBuilder
256
+ ## DataTable Props
257
+
258
+ | Prop | Type | Default | Description |
259
+ |---|---|---|---|
260
+ | `data` | Array | *required* | Array of row objects |
261
+ | `columns` | Array | *required* | Column definitions |
262
+ | `renderRow` | `(row) => ReactNode` | — | Full row renderer (alternative to `renderCell`) |
263
+ | `searchFields` | string[] | `[]` | Fields to search across |
264
+ | `fuzzySearch` | boolean | `false` | Enable fuzzy matching via Fuse.js |
265
+ | `searchPlaceholder` | string | `"Search..."` | Placeholder text |
266
+ | `filters` | Array | `[]` | Filter configurations |
267
+ | `showFilterBadges` | boolean | `true` | Show active filter chips |
268
+ | `showClearFiltersButton` | boolean | `true` | Show "Clear all" button |
269
+ | `pageSize` | number | `10` | Rows per page |
270
+ | `maxVisiblePageButtons` | number | — | Max page buttons |
271
+ | `showButtonLabels` | boolean | `true` | Show First/Prev/Next/Last text |
272
+ | `showFirstLastButtons` | boolean | auto | Show First/Last buttons |
273
+ | `showRowCount` | boolean | `true` | Show record count text |
274
+ | `rowCountBold` | boolean | `false` | Bold the row count |
275
+ | `rowCountText` | `(shownOnPage, totalMatching) => string` | — | Custom row count formatter |
276
+ | `bordered` | boolean | `true` | Show table borders |
277
+ | `flush` | boolean | `true` | Remove bottom margin |
278
+ | `scrollable` | boolean | `false` | Allow horizontal scrolling |
279
+ | `defaultSort` | object | `{}` | Initial sort state |
280
+ | `groupBy` | object | — | Grouping config |
281
+ | `footer` | `(filteredData) => ReactNode` | — | Footer row renderer |
282
+ | `emptyTitle` | string | `"No results found"` | Empty state heading |
283
+ | `emptyMessage` | string | — | Empty state body |
284
+ | `recordLabel` | `{ singular, plural }` | `{ singular: "record", plural: "records" }` | Entity name |
285
+ | `selectable` | boolean | `false` | Enable row selection |
286
+ | `rowIdField` | string | `"id"` | Unique row identifier field |
287
+ | `selectedIds` | Array | — | Controlled selection |
288
+ | `onSelectionChange` | `(ids[]) => void` | — | Selection change callback |
289
+ | `onSelectAllRequest` | `(context) => void` | — | Server-side select all callback |
290
+ | `selectionActions` | Array | `[]` | Bulk action buttons |
291
+ | `selectionResetKey` | any | — | Reset key for uncontrolled selection |
292
+ | `rowActions` | Array \| Function | — | Per-row action buttons |
293
+ | `hideRowActionsWhenSelectionActive` | boolean | `false` | Hide row actions during selection |
294
+ | `editMode` | `"discrete"` \| `"inline"` | `"discrete"` | Edit mode |
295
+ | `editingRowId` | string \| number | — | Full-row edit target |
296
+ | `onRowEdit` | `(row, field, newValue) => void` | — | Edit commit callback |
297
+ | `onRowEditInput` | `(row, field, inputValue) => void` | — | Live input callback |
298
+ | `autoWidth` | boolean | `true` | Auto-compute column widths |
299
+ | `serverSide` | boolean | `false` | Enable server-side mode |
300
+ | `loading` | boolean | `false` | Show loading spinner |
301
+ | `error` | string \| boolean | — | Show error state |
302
+ | `totalCount` | number | — | Total records (server-side) |
303
+ | `page` | number | — | Current page (controlled) |
304
+ | `searchValue` | string | — | Controlled search term |
305
+ | `filterValues` | object | — | Controlled filter values |
306
+ | `sort` | object | — | Controlled sort state |
307
+ | `searchDebounce` | number | `0` | Debounce search callback (ms) |
308
+ | `onSearchChange` | `(term) => void` | — | Search callback |
309
+ | `onFilterChange` | `(filterValues) => void` | — | Filter callback |
310
+ | `onSortChange` | `(field, direction) => void` | — | Sort callback |
311
+ | `onPageChange` | `(page) => void` | — | Page callback |
312
+ | `onParamsChange` | `({ search, filters, sort, page }) => void` | — | Unified change callback |
313
+
314
+ ### Column Definition
315
+
316
+ | Property | Type | Description |
317
+ |---|---|---|
318
+ | `field` | string | Key in the row object |
319
+ | `label` | string | Column header text |
320
+ | `sortable` | boolean | Enable sorting |
321
+ | `width` | `"min"` \| `"max"` \| `"auto"` \| `number` | Column width |
322
+ | `cellWidth` | `"min"` \| `"max"` \| `"auto"` | Cell-only width override |
323
+ | `align` | `"left"` \| `"center"` \| `"right"` | Text alignment |
324
+ | `renderCell` | `(value, row) => ReactNode` | Cell renderer |
325
+ | `truncate` | `true` \| `{ maxLength?: number }` | Text truncation with tooltip |
326
+ | `editable` | boolean | Enable inline editing |
327
+ | `editType` | string | Input type |
328
+ | `editOptions` | Array | Options for select/multiselect |
329
+ | `editValidate` | `(value, row) => true \| string` | Validation function |
330
+ | `editProps` | object | Pass-through props to edit input |
331
+ | `footer` | `string \| (rows) => ReactNode` | Footer cell content |
332
+
333
+ ---
334
+
335
+ # FormBuilder
336
+
337
+ Declarative, config-driven forms for HubSpot UI Extensions. Define fields as data, get a complete form with validation, layout, multi-step wizards, and full HubSpot component integration.
338
+
339
+ ![Basic Form](https://raw.githubusercontent.com/05bmckay/hs-uix/main/packages/form/assets/basic-form.png)
40
340
 
41
341
  ```jsx
42
- import { FormBuilder } from "@hs-uix/form";
342
+ import { FormBuilder } from "hs-uix/form";
43
343
 
44
344
  const fields = [
45
- { name: "email", label: "Email", type: "text", required: true },
46
- { name: "role", label: "Role", type: "select", options: [
47
- { label: "Admin", value: "admin" },
48
- { label: "User", value: "user" },
49
- ]},
345
+ { name: "firstName", type: "text", label: "First name", required: true },
346
+ { name: "lastName", type: "text", label: "Last name", required: true },
347
+ { name: "email", type: "text", label: "Email", pattern: /^[^\s@]+@[^\s@]+$/, patternMessage: "Enter a valid email" },
50
348
  ];
51
349
 
52
- <FormBuilder fields={fields} onSubmit={(values) => console.log(values)} />;
350
+ <FormBuilder
351
+ columns={2}
352
+ fields={fields}
353
+ onSubmit={(values) => console.log(values)}
354
+ />
53
355
  ```
54
356
 
55
- ## Local Development
357
+ ## Field Types
56
358
 
57
- ```bash
58
- # Install dependencies
59
- npm install
359
+ Every field maps to a native HubSpot UI Extension component with full prop support:
360
+
361
+ | `type` | Component | Key Props |
362
+ |---|---|---|
363
+ | `text` | `Input` | `placeholder`, `onInput`, `onBlur` |
364
+ | `password` | `Input type="password"` | Same as text |
365
+ | `textarea` | `TextArea` | `rows`, `cols`, `resize`, `maxLength` |
366
+ | `number` | `NumberInput` | `min`, `max`, `precision`, `formatStyle` |
367
+ | `stepper` | `StepperInput` | `min`, `max`, `stepSize`, `precision` |
368
+ | `currency` | `CurrencyInput` | `currency` (ISO 4217), `min`, `max`, `precision` |
369
+ | `date` | `DateInput` | `format`, `min`, `max`, `timezone` |
370
+ | `time` | `TimeInput` | `interval`, `min`, `max`, `timezone` |
371
+ | `datetime` | `DateInput` + `TimeInput` | All date and time props |
372
+ | `select` | `Select` | `options`, `variant` |
373
+ | `multiselect` | `MultiSelect` | `options` |
374
+ | `toggle` | `Toggle` | `size`, `labelDisplay`, `textChecked`, `textUnchecked` |
375
+ | `checkbox` | `Checkbox` | `inline`, `variant` |
376
+ | `checkboxGroup` | `ToggleGroup checkboxList` | `options`, `inline`, `variant` |
377
+ | `radioGroup` | `ToggleGroup radioButtonList` | `options`, `inline`, `variant` |
378
+ | `display` | Custom render | Render-only, no form value |
379
+ | `repeater` | Sub-field rows | `fields`, `min`, `max` |
380
+ | `crmPropertyList` | `CrmPropertyList` | `properties`, `direction` |
381
+ | `crmAssociationPropertyList` | `CrmAssociationPropertyList` | `objectTypeId`, `properties`, `filters`, `sort` |
382
+
383
+ ## Layout
384
+
385
+ FormBuilder provides four layout modes. Use `columns` or `columnWidth` to match HubSpot's standard look.
386
+
387
+ ### Fixed Columns
388
+
389
+ ```jsx
390
+ <FormBuilder columns={2} fields={fields} />
391
+ ```
392
+
393
+ Use `colSpan` on individual fields to span multiple columns.
394
+
395
+ ### Responsive (AutoGrid)
396
+
397
+ ```jsx
398
+ <FormBuilder columnWidth={200} fields={fields} />
399
+ ```
60
400
 
61
- # Build all packages
62
- npm run build
401
+ Columns collapse automatically on narrow screens.
63
402
 
64
- # Watch mode
65
- npm run dev
403
+ ### Explicit Layout
404
+
405
+ ![Explicit Layout](https://raw.githubusercontent.com/05bmckay/hs-uix/main/packages/form/assets/explicit-layout-weighted.png)
406
+
407
+ ```jsx
408
+ <FormBuilder
409
+ layout={[
410
+ ["firstName", "lastName"], // 2 equal columns
411
+ ["email"], // full width
412
+ ["city", "state", "zip"], // 3 columns
413
+ ]}
414
+ fields={fields}
415
+ />
416
+ ```
417
+
418
+ Weighted columns:
419
+
420
+ ```jsx
421
+ layout={[
422
+ [{ field: "address", flex: 2 }, { field: "apt", flex: 1 }], // 2:1 ratio
423
+ ]}
424
+ ```
425
+
426
+ **Layout priority:** `layout` > `columnWidth` > `columns` > legacy
427
+
428
+ ## Validation
429
+
430
+ Built-in validators run in order, first failure wins:
431
+
432
+ ```jsx
433
+ {
434
+ name: "email",
435
+ type: "text",
436
+ label: "Email",
437
+ required: true,
438
+ pattern: /^[^\s@]+@[^\s@]+$/,
439
+ patternMessage: "Enter a valid email",
440
+ minLength: 5,
441
+ maxLength: 100,
442
+ validators: [
443
+ (value) => value.endsWith("@example.com") ? true : "Use your company email",
444
+ ],
445
+ validate: async (value, allValues, { signal }) => {
446
+ const exists = await checkEmailExists(value, { signal });
447
+ return exists ? "Email already in use" : true;
448
+ },
449
+ }
450
+ ```
451
+
452
+ | Timing | Default | When |
453
+ |---|---|---|
454
+ | `validateOnChange` | `false` | Every keystroke |
455
+ | `validateOnBlur` | `true` | Field loses focus |
456
+ | `validateOnSubmit` | `true` | Submit attempt |
457
+
458
+ ## Async Validation
459
+
460
+ ![Async Validation](https://raw.githubusercontent.com/05bmckay/hs-uix/main/packages/form/assets/async-validation-side-effects.png)
461
+
462
+ Fields show a loading indicator while async validation runs. Pending requests are versioned and prior requests are aborted when supported (`signal`).
463
+
464
+ ```jsx
465
+ {
466
+ name: "email",
467
+ type: "text",
468
+ label: "Email",
469
+ validate: async (value, allValues, { signal }) => {
470
+ const exists = await checkEmailExists(value, { signal });
471
+ return exists ? "Email already in use" : true;
472
+ },
473
+ validateDebounce: 500,
474
+ }
475
+ ```
476
+
477
+ ## Conditional Visibility & Dependent Properties
478
+
479
+ ![Dependent & Cascading](https://raw.githubusercontent.com/05bmckay/hs-uix/main/packages/form/assets/dependent-cascading.gif)
480
+
481
+ ```jsx
482
+ const fields = [
483
+ { name: "hasCompany", type: "toggle", label: "Has company?" },
484
+ {
485
+ name: "companyName",
486
+ type: "text",
487
+ label: "Company name",
488
+ visible: (values) => values.hasCompany === true,
489
+ },
490
+ ];
491
+ ```
492
+
493
+ Dependent fields are grouped in a HubSpot Tile container below their parent:
494
+
495
+ ```jsx
496
+ {
497
+ name: "contractLength",
498
+ type: "number",
499
+ label: "Contract length (months)",
500
+ dependsOnConfig: {
501
+ field: "dealType",
502
+ display: "grouped",
503
+ label: "Contract details",
504
+ message: (parentLabel) => `These properties depend on ${parentLabel}`,
505
+ },
506
+ visible: (values) => values.dealType === "recurring",
507
+ }
508
+ ```
509
+
510
+ ## Multi-Step Wizard
511
+
512
+ ```jsx
513
+ <FormBuilder
514
+ fields={allFields}
515
+ steps={[
516
+ { title: "Contact Info", fields: ["firstName", "lastName", "email"] },
517
+ { title: "Company", fields: ["company", "role"] },
518
+ { title: "Review", render: ({ values, goBack }) => (
519
+ <ReviewPanel values={values} onEdit={goBack} />
520
+ )},
521
+ ]}
522
+ showStepIndicator={true}
523
+ validateStepOnNext={true}
524
+ onSubmit={handleSubmit}
525
+ />
526
+ ```
527
+
528
+ ## Repeater Fields
529
+
530
+ ![Repeater Fields](https://raw.githubusercontent.com/05bmckay/hs-uix/main/packages/form/assets/repeater-fields.png)
531
+
532
+ ```jsx
533
+ { name: "phones", type: "repeater", label: "Phone Numbers",
534
+ fields: [
535
+ { name: "number", type: "text", label: "Number" },
536
+ { name: "type", type: "select", label: "Type", options: PHONE_TYPES },
537
+ ],
538
+ min: 1, max: 5 }
539
+ ```
540
+
541
+ ## Sections (Accordion Grouping)
542
+
543
+ ![Sections & Groups](https://raw.githubusercontent.com/05bmckay/hs-uix/main/packages/form/assets/section-and-groups.png)
544
+
545
+ ```jsx
546
+ <FormBuilder
547
+ fields={fields}
548
+ sections={[
549
+ { id: "basic", label: "Basic Info", fields: ["firstName", "lastName", "email"], defaultOpen: true },
550
+ { id: "social", label: "Social Links", fields: ["facebook", "instagram"], defaultOpen: false },
551
+ ]}
552
+ onSubmit={handleSubmit}
553
+ />
554
+ ```
555
+
556
+ ## Custom Field Types
557
+
558
+ ![Custom Field Types](https://raw.githubusercontent.com/05bmckay/hs-uix/main/packages/form/assets/custom-field-types.png)
559
+
560
+ ```jsx
561
+ <FormBuilder
562
+ fieldTypes={{
563
+ imageGallery: {
564
+ render: ({ value, onChange, error, field }) => (
565
+ <ImageGalleryInput urls={value} onUpdate={onChange} error={error} />
566
+ ),
567
+ getEmptyValue: () => [],
568
+ isEmpty: (v) => v.length === 0,
569
+ },
570
+ }}
571
+ fields={[
572
+ { name: "photos", type: "imageGallery", label: "Photos", required: true },
573
+ ]}
574
+ />
575
+ ```
576
+
577
+ ## Display Options
578
+
579
+ ![Display Options](https://raw.githubusercontent.com/05bmckay/hs-uix/main/packages/form/assets/display-options.png)
580
+
581
+ ## Read-Only Mode
582
+
583
+ ![Read-Only Mode](https://raw.githubusercontent.com/05bmckay/hs-uix/main/packages/form/assets/readonly-autosave-dirty.png)
584
+
585
+ ```jsx
586
+ <FormBuilder
587
+ fields={fields}
588
+ readOnly={isPremiumAccount}
589
+ readOnlyMessage="This is a premium account. Editing is disabled."
590
+ onSubmit={handleSubmit}
591
+ />
592
+ ```
593
+
594
+ ## Controlled vs Uncontrolled
595
+
596
+ **Uncontrolled (default):**
597
+
598
+ ```jsx
599
+ <FormBuilder
600
+ fields={fields}
601
+ initialValues={{ firstName: "John" }}
602
+ onSubmit={(values) => save(values)}
603
+ />
604
+ ```
605
+
606
+ **Controlled:**
607
+
608
+ ```jsx
609
+ const [values, setValues] = useState({});
610
+
611
+ <FormBuilder
612
+ fields={fields}
613
+ values={values}
614
+ onChange={setValues}
615
+ onSubmit={(values) => save(values)}
616
+ />
617
+ ```
618
+
619
+ ## Ref API
620
+
621
+ ```jsx
622
+ const formRef = useRef();
623
+
624
+ <FormBuilder ref={formRef} fields={fields} onSubmit={save} />
625
+
626
+ formRef.current.submit(); // trigger validation + submit
627
+ formRef.current.validate(); // { valid: boolean, errors: {} }
628
+ formRef.current.reset(); // reset to initial values
629
+ formRef.current.getValues(); // current form values
630
+ formRef.current.isDirty(); // true if values changed
631
+ formRef.current.setFieldValue("email", "new@test.com"); // programmatic update
632
+ formRef.current.setFieldError("email", "Taken"); // programmatic error
633
+ formRef.current.setErrors({ email: "Exists", phone: "Invalid" }); // batch set
66
634
  ```
67
635
 
68
- ## Monorepo Structure
636
+ ## Submit Lifecycle
69
637
 
638
+ ```jsx
639
+ <FormBuilder
640
+ fields={fields}
641
+ transformValues={(values) => ({
642
+ ...values,
643
+ fullName: `${values.firstName} ${values.lastName}`.trim(),
644
+ })}
645
+ onBeforeSubmit={async (values) => await showConfirmDialog()}
646
+ onSubmit={saveRecord}
647
+ onSubmitSuccess={(result, { reset }) => actions.addAlert({ type: "success", message: "Saved!" })}
648
+ onSubmitError={(err) => actions.addAlert({ type: "danger", message: err.message })}
649
+ resetOnSuccess={true}
650
+ />
70
651
  ```
71
- hs-uix/
72
- ├── packages/
73
- │ ├── datatable/ ← @hs-uix/datatable
74
- │ └── form/ ← @hs-uix/form
75
- └── package.json ← npm workspaces root
652
+
653
+ ## Buttons
654
+
655
+ ```jsx
656
+ <FormBuilder
657
+ fields={fields}
658
+ onSubmit={save}
659
+ labels={{ submit: "Save record", cancel: "Discard", back: "Previous", next: "Continue" }}
660
+ submitVariant="primary"
661
+ showCancel={true}
662
+ onCancel={() => actions.closeOverlay()}
663
+ loading={isSaving}
664
+ disabled={!canEdit}
665
+ />
76
666
  ```
77
667
 
668
+ ## FormBuilder Props
669
+
670
+ | Prop | Type | Default | Description |
671
+ |---|---|---|---|
672
+ | `fields` | `FormBuilderField[]` | required | Field definitions |
673
+ | `onSubmit` | `(values, { reset }) => void \| Promise` | required | Called on valid submit |
674
+ | `initialValues` | `Record<string, unknown>` | `{}` | Starting values (uncontrolled) |
675
+ | `values` | `Record<string, unknown>` | — | Controlled values |
676
+ | `onChange` | `(values) => void` | — | Change callback (controlled) |
677
+ | `errors` | `Record<string, string>` | — | Controlled validation errors |
678
+ | `onFieldChange` | `(name, value, allValues) => void` | — | Per-field change |
679
+ | `validateOnChange` | `boolean` | `false` | Validate on keystroke |
680
+ | `validateOnBlur` | `boolean` | `true` | Validate on blur |
681
+ | `validateOnSubmit` | `boolean` | `true` | Validate all before submit |
682
+ | `onValidationChange` | `(errors) => void` | — | Validation state callback |
683
+ | `steps` | `FormBuilderStep[]` | — | Enables multi-step mode |
684
+ | `step` | `number` | — | Controlled step (0-based) |
685
+ | `onStepChange` | `(step) => void` | — | Step change callback |
686
+ | `showStepIndicator` | `boolean` | `true` | Show StepIndicator |
687
+ | `validateStepOnNext` | `boolean` | `true` | Validate before Next |
688
+ | `submitVariant` | `"primary" \| "secondary"` | `"primary"` | Button variant |
689
+ | `showCancel` | `boolean` | `false` | Show cancel button |
690
+ | `onCancel` | `() => void` | — | Cancel callback |
691
+ | `submitPosition` | `"bottom" \| "none"` | `"bottom"` | Button placement |
692
+ | `loading` | `boolean` | — | Controlled loading state |
693
+ | `disabled` | `boolean` | `false` | Disable entire form |
694
+ | `labels` | `{ submit?, cancel?, back?, next? }` | — | Button label i18n object |
695
+ | `renderButtons` | `(context) => ReactNode` | — | Custom button-row renderer |
696
+ | `columns` | `number` | `1` | Fixed column count |
697
+ | `columnWidth` | `number` | — | AutoGrid responsive column width (px) |
698
+ | `layout` | `FormBuilderLayout` | — | Explicit row layout |
699
+ | `gap` | `string` | `"sm"` | Spacing between fields |
700
+ | `showRequiredIndicator` | `boolean` | `true` | Show * on required fields |
701
+ | `sections` | `FormBuilderSection[]` | — | Accordion field grouping |
702
+ | `fieldTypes` | `Record<string, FieldTypePlugin>` | — | Custom field type registry |
703
+ | `readOnly` | `boolean` | `false` | Lock all fields |
704
+ | `readOnlyMessage` | `string` | — | Warning alert in read-only mode |
705
+ | `alerts` | `{ addAlert?, errorTitle?, successTitle? }` | — | Grouped alert config |
706
+ | `error` | `string \| boolean` | — | Form-level error alert |
707
+ | `success` | `string` | — | Form-level success alert |
708
+ | `transformValues` | `(values) => values` | — | Reshape values before submit |
709
+ | `onBeforeSubmit` | `(values) => boolean \| Promise` | — | Intercept submit |
710
+ | `onSubmitSuccess` | `(result, helpers) => void` | — | Post-submit success |
711
+ | `onSubmitError` | `(error, helpers) => void` | — | Post-submit error |
712
+ | `resetOnSuccess` | `boolean` | `false` | Auto-reset after success |
713
+ | `autoSave` | `{ debounce?, onAutoSave }` | — | Debounced auto-save |
714
+ | `onDirtyChange` | `(isDirty) => void` | — | Dirty state callback |
715
+ | `ref` | `Ref<FormBuilderRef>` | — | Imperative ref |
716
+
717
+ ### Field Props
718
+
719
+ | Prop | Type | Description |
720
+ |---|---|---|
721
+ | `name` | `string` | Unique field identifier |
722
+ | `type` | `FormBuilderFieldType` | Field type |
723
+ | `label` | `string` | Field label |
724
+ | `description` | `string` | Helper text |
725
+ | `placeholder` | `string` | Placeholder text |
726
+ | `tooltip` | `string` | Tooltip next to label |
727
+ | `required` | `boolean \| (values) => boolean` | Required validation |
728
+ | `readOnly` | `boolean` | Prevent editing |
729
+ | `disabled` | `boolean` | Disable this field |
730
+ | `defaultValue` | `unknown` | Default value |
731
+ | `colSpan` | `number` | Columns to span |
732
+ | `visible` | `(values) => boolean` | Conditional visibility |
733
+ | `dependsOnConfig` | `{ field, display?, label?, message? }` | Grouped dependent config |
734
+ | `validate` | `(value, allValues, context?) => true \| string \| Promise` | Custom validation |
735
+ | `validators` | `Array<Function>` | Additional custom validators |
736
+ | `validateDebounce` | `number` | Debounce async validation (ms) |
737
+ | `debounce` | `number` | Debounce onChange (ms) |
738
+ | `options` | `Option[] \| (values) => Option[]` | Dropdown/toggle options |
739
+ | `render` | `(props) => ReactNode` | Custom render escape hatch |
740
+ | `fieldProps` | `Record<string, unknown>` | Pass-through to HubSpot component |
741
+ | `onFieldChange` | `(value, allValues, helpers) => void` | Cross-field side effects |
742
+
743
+ ---
744
+
745
+ ## Migrating from `@hs-uix/datatable` or `@hs-uix/form`
746
+
747
+ Both packages have been merged into `hs-uix`. Update your imports:
748
+
749
+ ```diff
750
+ - import { DataTable } from "@hs-uix/datatable";
751
+ + import { DataTable } from "hs-uix/datatable";
752
+
753
+ - import { FormBuilder } from "@hs-uix/form";
754
+ + import { FormBuilder } from "hs-uix/form";
755
+ ```
756
+
757
+ Then remove the old packages:
758
+
759
+ ```bash
760
+ npm uninstall @hs-uix/datatable @hs-uix/form
761
+ npm install hs-uix
762
+ ```
763
+
764
+ ---
765
+
78
766
  ## License
79
767
 
80
768
  MIT