hs-uix 2.1.1 → 2.2.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 (49) hide show
  1. package/README.md +3 -1
  2. package/common-components.d.ts +319 -68
  3. package/dist/calendar.js +355 -57
  4. package/dist/calendar.mjs +356 -57
  5. package/dist/common-components.js +3546 -88
  6. package/dist/common-components.mjs +3530 -84
  7. package/dist/datatable.js +108 -18
  8. package/dist/datatable.mjs +108 -18
  9. package/dist/experimental.js +2876 -0
  10. package/dist/experimental.mjs +2883 -0
  11. package/dist/feed.js +267 -38
  12. package/dist/feed.mjs +260 -37
  13. package/dist/filter.js +1379 -0
  14. package/dist/filter.mjs +1334 -0
  15. package/dist/form.js +222 -26
  16. package/dist/form.mjs +227 -27
  17. package/dist/index.js +3208 -287
  18. package/dist/index.mjs +3156 -283
  19. package/dist/kanban.js +282 -62
  20. package/dist/kanban.mjs +273 -61
  21. package/dist/safe.js +9207 -0
  22. package/dist/safe.mjs +9298 -0
  23. package/dist/utils.js +491 -75
  24. package/dist/utils.mjs +491 -75
  25. package/experimental.d.ts +1 -0
  26. package/filter.d.ts +1 -0
  27. package/index.d.ts +45 -3
  28. package/package.json +19 -1
  29. package/safe.d.ts +1 -0
  30. package/src/calendar/README.md +74 -5
  31. package/src/calendar/index.d.ts +95 -1
  32. package/src/common-components/README.md +140 -1
  33. package/src/datatable/README.md +0 -2
  34. package/src/experimental/README.md +126 -0
  35. package/src/experimental/index.d.ts +346 -0
  36. package/src/feed/README.md +69 -0
  37. package/src/feed/index.d.ts +103 -0
  38. package/src/filter/README.md +148 -0
  39. package/src/filter/index.d.ts +221 -0
  40. package/src/form/README.md +132 -4
  41. package/src/form/index.d.ts +82 -1
  42. package/src/kanban/README.md +119 -6
  43. package/src/kanban/index.d.ts +153 -2
  44. package/src/safe/README.md +108 -0
  45. package/src/safe/index.d.ts +158 -0
  46. package/src/utils/README.md +39 -0
  47. package/src/wizard/README.md +158 -0
  48. package/src/wizard/index.d.ts +138 -0
  49. package/utils.d.ts +17 -0
@@ -86,6 +86,13 @@ export interface KanbanStage<Row = Record<string, unknown>> {
86
86
  icon?: string;
87
87
  terminal?: boolean;
88
88
  order?: number;
89
+ /**
90
+ * WIP limit for this stage. The header shows "count / limit" and an
91
+ * "Over WIP" StatusTag when exceeded. 0 is valid ("stay empty"). The
92
+ * top-level `wipLimits` prop overrides this per stage. Advisory only —
93
+ * transitions that exceed the limit still complete.
94
+ */
95
+ wipLimit?: number;
89
96
  footer?: (rows: Row[]) => ReactNode;
90
97
  canEnter?: (row: Row) => boolean;
91
98
  onEnterRequired?: {
@@ -93,6 +100,46 @@ export interface KanbanStage<Row = Record<string, unknown>> {
93
100
  };
94
101
  }
95
102
 
103
+ // ---------------------------------------------------------------------------
104
+ // WIP limits
105
+ // ---------------------------------------------------------------------------
106
+
107
+ export interface KanbanWipStatus {
108
+ /** Cards counted against the limit (stageMeta.totalCount when present, else the loaded bucket size). */
109
+ count: number;
110
+ /** Effective limit (top-level override beats stage.wipLimit), or null when the stage has none. */
111
+ limit: number | null;
112
+ /** True only when count is STRICTLY greater than limit. */
113
+ exceeded: boolean;
114
+ }
115
+
116
+ export type KanbanWipEvaluation = Record<string, KanbanWipStatus>;
117
+
118
+ export interface KanbanWipExceededEvent {
119
+ stageId: string;
120
+ count: number;
121
+ limit: number;
122
+ }
123
+
124
+ // ---------------------------------------------------------------------------
125
+ // Swimlanes
126
+ // ---------------------------------------------------------------------------
127
+
128
+ export type KanbanSwimlaneBy<Row = Record<string, unknown>> =
129
+ | string
130
+ | ((row: Row) => string | number | boolean | null | undefined);
131
+
132
+ export type KanbanSwimlaneLabels<Row = Record<string, unknown>> =
133
+ | Record<string, string>
134
+ | ((laneKey: string, rows: Row[]) => ReactNode);
135
+
136
+ export interface KanbanLanePartition<Row = Record<string, unknown>> {
137
+ /** Lane keys in render order (swimlaneOrder first — including empty explicit lanes — then first-seen). */
138
+ laneKeys: string[];
139
+ /** Rows per lane in original data order. Every key in laneKeys is present. */
140
+ rowsByLane: Record<string, Row[]>;
141
+ }
142
+
96
143
  // ---------------------------------------------------------------------------
97
144
  // Pagination
98
145
  // ---------------------------------------------------------------------------
@@ -147,6 +194,7 @@ export interface KanbanParams {
147
194
  filters: Record<string, unknown>;
148
195
  sort: string | null;
149
196
  collapsedStages: string[];
197
+ collapsedLanes: string[];
150
198
  }
151
199
 
152
200
  export interface KanbanEmptyStateRenderContext {
@@ -183,6 +231,13 @@ export interface KanbanLabels {
183
231
  errorTitle?: string;
184
232
  errorMessage?: string;
185
233
  cardCount?: (n: number) => string;
234
+ /** Stage-header count when a WIP limit is set. Default "{count} / {limit}". */
235
+ wipCount?: (count: number, limit: number) => string;
236
+ /** Warning StatusTag text on over-limit stage headers. Default "Over WIP". */
237
+ overWip?: string;
238
+ laneCount?: (n: number) => string;
239
+ /** Lane header label for rows whose swimlane value is null/undefined/"". Default "Unassigned". */
240
+ unassignedLane?: string;
186
241
  moveTo?: string;
187
242
  clearAll?: string;
188
243
  selectAll?: string | ((count: number, label: string) => string);
@@ -278,6 +333,37 @@ export interface KanbanProps<Row = Record<string, unknown>, Id = string | number
278
333
  stageMeta?: Record<string, KanbanStageMeta>;
279
334
  onLoadMore?: (stage: string) => void;
280
335
 
336
+ // WIP limits
337
+ /** Top-level per-stage limit overrides: `{ [stageId]: n }`. Beats `stage.wipLimit`. */
338
+ wipLimits?: Record<string, number>;
339
+ /**
340
+ * Fires when a stage TRANSITIONS into the exceeded state (count > limit) —
341
+ * once per crossing, including a board that mounts already over a limit;
342
+ * never on every render. Over-limit transitions still complete: the server
343
+ * is the source of truth, so WIP limits are a signal, not a gate.
344
+ */
345
+ onWipExceeded?: (stageId: string, count: number, limit: number) => void;
346
+
347
+ // Swimlanes
348
+ /** Field name or accessor that groups the board vertically into lanes. */
349
+ swimlaneBy?: KanbanSwimlaneBy<Row>;
350
+ /** `{ key: label }` map or `(laneKey, rows) => label` function for lane headers. */
351
+ swimlaneLabels?: KanbanSwimlaneLabels<Row>;
352
+ /** Explicit lane order. Listed keys render first (even when empty); unlisted lanes append first-seen. */
353
+ swimlaneOrder?: string[];
354
+ /** Show the collapse chevron on lane headers. Default true. */
355
+ collapseLanes?: boolean;
356
+ /** Controlled list of collapsed lane keys. */
357
+ collapsedLanes?: string[];
358
+ /** Initial collapsed lane keys (uncontrolled). */
359
+ defaultCollapsedLanes?: string[];
360
+ onCollapsedLanesChange?: (laneKeys: string[]) => void;
361
+ /**
362
+ * Render the metrics panel inside each lane instead of globally. Requires
363
+ * `metrics` to be a function — it's called per lane with `(laneRows, laneKey)`.
364
+ */
365
+ metricsPerLane?: boolean;
366
+
281
367
  // Selection
282
368
  selectable?: boolean;
283
369
  selectedIds?: Id[];
@@ -345,8 +431,16 @@ export interface KanbanProps<Row = Record<string, unknown>, Id = string | number
345
431
  onCollapsedStagesChange?: (stages: string[]) => void;
346
432
 
347
433
  // Metrics panel (toggled via the toolbar's Metrics button)
348
- /** Array of stat items for the <Statistics> panel, or a custom ReactNode. */
349
- metrics?: KanbanMetricItem[] | ReactNode;
434
+ /**
435
+ * Array of stat items for the <Statistics> panel, a custom ReactNode, or a
436
+ * function of the (filtered) rows. The function form receives
437
+ * `(rows, laneKey)` — laneKey is null for the global panel and the lane key
438
+ * when `metricsPerLane` is active.
439
+ */
440
+ metrics?:
441
+ | KanbanMetricItem[]
442
+ | ReactNode
443
+ | ((rows: Row[], laneKey: string | null) => KanbanMetricItem[] | ReactNode);
350
444
  /** Controlled visibility of the metrics panel. */
351
445
  showMetrics?: boolean;
352
446
  /** Fires when the Metrics button is clicked. Receives the new visible state. */
@@ -376,3 +470,60 @@ export interface KanbanProps<Row = Record<string, unknown>, Id = string | number
376
470
  export declare function Kanban<Row = Record<string, unknown>, Id = string | number>(
377
471
  props: KanbanProps<Row, Id>
378
472
  ): ReactElement | null;
473
+
474
+ // ---------------------------------------------------------------------------
475
+ // Pure lane / WIP helpers (from kanbanLanes.js) — usable outside the component,
476
+ // e.g. to evaluate WIP server-side or render custom lane summaries.
477
+ // ---------------------------------------------------------------------------
478
+
479
+ /** Lane key used for rows whose swimlane value is null/undefined/"". */
480
+ export declare const UNASSIGNED_LANE_KEY: "__unassigned";
481
+
482
+ /** Resolve the swimlane key for a row (String-coerced; empty values map to UNASSIGNED_LANE_KEY). */
483
+ export declare function getLaneKey<Row = Record<string, unknown>>(
484
+ row: Row,
485
+ swimlaneBy: KanbanSwimlaneBy<Row>
486
+ ): string;
487
+
488
+ /** Order lane keys: explicit swimlaneOrder first (deduped, kept even when empty), then first-seen. */
489
+ export declare function orderLaneKeys(seenKeys: string[], swimlaneOrder?: string[]): string[];
490
+
491
+ /** Partition rows into swimlanes — render order plus rows per lane. */
492
+ export declare function partitionLanes<Row = Record<string, unknown>>(
493
+ rows: Row[],
494
+ options?: { swimlaneBy?: KanbanSwimlaneBy<Row>; swimlaneOrder?: string[] }
495
+ ): KanbanLanePartition<Row>;
496
+
497
+ /** Resolve a lane's display label from a map or function, with unassigned/key fallbacks. */
498
+ export declare function resolveLaneLabel<Row = Record<string, unknown>>(
499
+ laneKey: string,
500
+ swimlaneLabels?: KanbanSwimlaneLabels<Row>,
501
+ rows?: Row[],
502
+ unassignedLabel?: string
503
+ ): ReactNode;
504
+
505
+ /** Effective WIP limit for a stage (wipLimits override beats stage.wipLimit; invalid → null). */
506
+ export declare function resolveWipLimit<Row = Record<string, unknown>>(
507
+ stage: KanbanStage<Row>,
508
+ wipLimits?: Record<string, number>
509
+ ): number | null;
510
+
511
+ /** Per-stage counts for WIP evaluation (stageMeta.totalCount when present, else bucket length). */
512
+ export declare function computeStageCounts<Row = Record<string, unknown>>(
513
+ stages: KanbanStage<Row>[],
514
+ buckets: Record<string, Row[]>,
515
+ stageMeta?: Record<string, KanbanStageMeta>
516
+ ): Record<string, number>;
517
+
518
+ /** Evaluate `{ count, limit, exceeded }` for every stage. */
519
+ export declare function evaluateWip<Row = Record<string, unknown>>(
520
+ stages: KanbanStage<Row>[],
521
+ counts: Record<string, number>,
522
+ wipLimits?: Record<string, number>
523
+ ): KanbanWipEvaluation;
524
+
525
+ /** Diff two evaluations; returns stages that newly crossed into exceeded (the onWipExceeded contract). */
526
+ export declare function findNewlyExceededWip(
527
+ prev: KanbanWipEvaluation | null | undefined,
528
+ next: KanbanWipEvaluation
529
+ ): KanbanWipExceededEvent[];
@@ -0,0 +1,108 @@
1
+ # Safe wrappers (`hs-uix/safe`)
2
+
3
+ Hardened drop-ins for the components that fail worst when given bad props.
4
+ HubSpot's primitives have two ugly failure modes:
5
+
6
+ - **Silent fails** — `Icon` renders *nothing* for an invalid `name` (no error,
7
+ no fallback, just empty space). `StatisticsTrend` rejects any `direction`
8
+ that isn't exactly `"increase"`/`"decrease"`.
9
+ - **Throw-blanks-page** — `EmptyState` throws on a bad `imageName`, and
10
+ collection components (`DataTable`, `Select`, …) throw inside HubSpot's
11
+ reconciler when a required array prop arrives `undefined` — which blanks the
12
+ **whole extension** in production.
13
+
14
+ Every wrapper here keeps the native prop API and turns those failures into
15
+ safe degrades plus a **one-time** `console.warn`. They were battle-tested in
16
+ [hs-uix-studio](https://github.com/05bmckay/hs-uix-studio)'s renderer, where
17
+ LLM-generated UIs hit every one of these traps constantly.
18
+
19
+ ## Quick Start
20
+
21
+ ```jsx
22
+ import {
23
+ SafeIcon, // bad name → alias repair, else red xCircle placeholder
24
+ SafeEmptyState, // bad imageName → known alias or "components", not a throw
25
+ SafeDataTable, // data/columns/… undefined → [] (empty table, not a blank page)
26
+ SafePopover, // children auto-padded in a compact Tile
27
+ } from "hs-uix/safe";
28
+
29
+ <SafeIcon name="duplicate" /> {/* renders "copy", warns once */}
30
+ <SafeEmptyState imageName="new-project" title="Nothing here" />
31
+ <SafeDataTable data={rows} columns={columns} /> {/* rows may be undefined while loading */}
32
+ ```
33
+
34
+ ## What's included
35
+
36
+ ### Repairing wrappers (native components)
37
+
38
+ | Component | Repairs | Behavior |
39
+ |---|---|---|
40
+ | `SafeIcon` | `name` | Valid → native pass-through. Known alias (`duplicate`→`copy`, `alert`→`warning`, `trash`→`delete`, …) → repaired with one-time warn. Unknown → alert-colored `xCircle` placeholder with `screenReaderText="Invalid icon: <name>"`. |
41
+ | `SafeEmptyState` | `imageName` | `null`/valid → pass-through. Known alias (`new-project`→`components`, …) or unknown → falls back (default `"components"`) instead of throwing. |
42
+ | `SafeStatisticsTrend` | `direction` | Valid / non-string → pass-through. Alias (`increasing`, `up`, `positive`, …) → repaired. Unknown string → `"increase"`. |
43
+ | `SafePopover` | padding | Wraps children in `<Tile compact>` so popover content gets default padding (the experimental Popover renders children flush). Nesting your own Tile still works. |
44
+
45
+ ### Array-coercing wrappers
46
+
47
+ Required collection props are forced to arrays: `null`/`undefined` → `[]`
48
+ silently, any other non-array → `[]` with a one-time warn. The component's own
49
+ empty state renders instead of the page blanking. Refs forward through, so
50
+ `SafeFormBuilder` keeps FormBuilder's imperative ref API.
51
+
52
+ | Component | Coerced props |
53
+ |---|---|
54
+ | `SafeSelect` / `SafeMultiSelect` / `SafeToggleGroup` | `options` |
55
+ | `SafeStepIndicator` | `stepNames` |
56
+ | `SafeDataTable` | `data`, `columns`, `searchFields`, `filters`, `selectionActions` |
57
+ | `SafeKanban` | `data`, `stages` |
58
+ | `SafeFormBuilder` | `fields` |
59
+ | `SafeAvatarStack` / `SafeKeyValueList` | `items` |
60
+ | `SafeFeed` | `items`, `fields` |
61
+ | `SafeCalendar` | `events` |
62
+ | `SafeCrmKanban` | `cardFields` |
63
+
64
+ `SafeCrmDataTable.columns` and `SafeCrmKanban.stages` are different: those
65
+ props **auto-derive when omitted** (columns from CRM properties, stages from
66
+ the batch), and a coerced `[]` would silently turn derivation off. They pass
67
+ `null`/`undefined` through untouched, and an invalid non-array is *dropped*
68
+ (with a warn) so the derive path takes over.
69
+
70
+ Wrap anything else yourself:
71
+
72
+ ```js
73
+ import { withSafeArrayProps, SAFE_ARRAY_PROPS, SAFE_DERIVE_PROPS } from "hs-uix/safe";
74
+
75
+ const SafeMyList = withSafeArrayProps(MyList, "MyList", ["entries"]);
76
+ // withSafeArrayProps(Component, name, arrayProps, deriveProps?) — the
77
+ // SAFE_ARRAY_PROPS / SAFE_DERIVE_PROPS registries hold the lists used above
78
+ ```
79
+
80
+ ### Catalogs
81
+
82
+ The validation data is exported for building your own linting/repair layers
83
+ (spec validators, codegen checks):
84
+
85
+ | Export | Contents |
86
+ |---|---|
87
+ | `NATIVE_ICON_NAMES` | the native `<Icon>` `name` whitelist (190 names) |
88
+ | `ICON_NAME_ALIASES` | common mistakes → nearest valid icon |
89
+ | `EMPTY_STATE_IMAGES` / `EMPTY_STATE_IMAGE_ALIASES` | valid `imageName` values + repairs |
90
+ | `TREND_DIRECTIONS` / `TREND_DIRECTION_ALIASES` | valid `direction` values + repairs |
91
+ | `SAFE_ARRAY_PROPS` | required collection props per component |
92
+
93
+ ### Warnings
94
+
95
+ Each distinct problem warns **once** per session (a bad icon name in a 50-row
96
+ table logs one line, not fifty). `resetSafeWarnings()` clears the dedup memory
97
+ (useful in tests); `warnOnce(key, message)` is the underlying helper.
98
+
99
+ ## When to use
100
+
101
+ - Rendering **model-generated or user-configured** UI, where prop values are
102
+ data, not code you control.
103
+ - Any surface where `props.data` comes from an async source that can resolve
104
+ `undefined` — the Safe collection components keep the page alive while you
105
+ fix the data path.
106
+
107
+ If your props are static and hand-written, the natives are fine — these
108
+ wrappers add one function call per render and nothing else.
@@ -0,0 +1,158 @@
1
+ import type { ComponentType, ReactNode } from "react";
2
+ import type { DataTableProps } from "../datatable/index";
3
+ import type { KanbanProps } from "../kanban/index";
4
+ import type { FormBuilderProps } from "../form/index";
5
+ import type { FeedItem, FeedProps } from "../feed/index";
6
+ import type { CalendarProps } from "../calendar/index";
7
+ import type { AvatarStackProps, KeyValueListProps } from "../../common-components";
8
+ import type { CrmDataTableProps, CrmKanbanProps } from "../../utils";
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // Catalogs
12
+ // ---------------------------------------------------------------------------
13
+
14
+ /** The native <Icon> `name` whitelist. */
15
+ export declare const NATIVE_ICON_NAMES: ReadonlySet<string>;
16
+ /** Common icon-name mistakes → the nearest valid native name. */
17
+ export declare const ICON_NAME_ALIASES: Readonly<Record<string, string>>;
18
+ /** Valid EmptyState `imageName` values. */
19
+ export declare const EMPTY_STATE_IMAGES: ReadonlySet<string>;
20
+ /** Common imageName mistakes → a valid value. */
21
+ export declare const EMPTY_STATE_IMAGE_ALIASES: Readonly<Record<string, string>>;
22
+ /** Valid StatisticsTrend `direction` values ("increase" | "decrease"). */
23
+ export declare const TREND_DIRECTIONS: ReadonlySet<string>;
24
+ /** Common direction mistakes ("increasing", "up", …) → a valid value. */
25
+ export declare const TREND_DIRECTION_ALIASES: Readonly<Record<string, string>>;
26
+ /** Required collection props per component name (coerced to []), as used by the Safe* exports. */
27
+ export declare const SAFE_ARRAY_PROPS: Readonly<Record<string, readonly string[]>>;
28
+ /**
29
+ * Auto-derived-when-omitted collection props (CrmDataTable columns, CrmKanban
30
+ * stages). Never coerced to [] — that would suppress the derive path; invalid
31
+ * non-array values are dropped instead.
32
+ */
33
+ export declare const SAFE_DERIVE_PROPS: Readonly<Record<string, readonly string[]>>;
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // Warning dedup
37
+ // ---------------------------------------------------------------------------
38
+
39
+ /** console.warn `message` the first time `key` is seen; no-op after that. */
40
+ export declare function warnOnce(key: string, message: string): void;
41
+ /** Clear the warn-once memory — for tests or long-lived sessions. */
42
+ export declare function resetSafeWarnings(): void;
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // Generic hardening wrapper
46
+ // ---------------------------------------------------------------------------
47
+
48
+ /**
49
+ * Wrap any component so the listed props are always arrays: null/undefined
50
+ * coerce to [] silently, any other non-array coerces to [] with a one-time
51
+ * console.warn. `derivePropNames` lists auto-derived-when-omitted props,
52
+ * which instead pass null/undefined through and DROP invalid non-arrays so
53
+ * the component's own derivation still runs. Refs forward through. Returns a
54
+ * drop-in with displayName `Safe<componentName>`.
55
+ */
56
+ export declare function withSafeArrayProps<Props extends object>(
57
+ Component: ComponentType<Props>,
58
+ componentName: string,
59
+ propNames: ReadonlyArray<string>,
60
+ derivePropNames?: ReadonlyArray<string>
61
+ ): ComponentType<Props>;
62
+
63
+ // ---------------------------------------------------------------------------
64
+ // Hardened native components (drop-ins for @hubspot/ui-extensions)
65
+ // ---------------------------------------------------------------------------
66
+
67
+ export interface SafeIconProps {
68
+ /** Native icon name. Known aliases auto-repair; anything else renders an alert-colored xCircle placeholder. */
69
+ name: string;
70
+ color?: "alert" | "warning" | "success" | "inherit";
71
+ size?: "sm" | "small" | "md" | "medium" | "lg" | "large";
72
+ screenReaderText?: string;
73
+ [prop: string]: unknown;
74
+ }
75
+
76
+ export interface SafeEmptyStateProps {
77
+ /** Invalid values fall back to a known alias or "components" instead of throwing. */
78
+ imageName?: string;
79
+ children?: ReactNode;
80
+ [prop: string]: unknown;
81
+ }
82
+
83
+ export interface SafeStatisticsTrendProps {
84
+ /** "increase" | "decrease"; aliases like "increasing"/"up" auto-repair, anything else defaults to "increase". */
85
+ direction?: "increase" | "decrease" | (string & {});
86
+ value?: string;
87
+ [prop: string]: unknown;
88
+ }
89
+
90
+ export interface SafePopoverProps {
91
+ /** Wrapped in a compact <Tile> so content gets default padding. */
92
+ children?: ReactNode;
93
+ [prop: string]: unknown;
94
+ }
95
+
96
+ export interface SafeOptionsProps {
97
+ /** Coerced to [] when missing or non-array. */
98
+ options?: Array<Record<string, unknown>> | null;
99
+ [prop: string]: unknown;
100
+ }
101
+
102
+ export interface SafeStepIndicatorProps {
103
+ /** Coerced to [] when missing or non-array. */
104
+ stepNames?: string[] | null;
105
+ [prop: string]: unknown;
106
+ }
107
+
108
+ export declare const SafeIcon: ComponentType<SafeIconProps>;
109
+ export declare const SafeEmptyState: ComponentType<SafeEmptyStateProps>;
110
+ export declare const SafeStatisticsTrend: ComponentType<SafeStatisticsTrendProps>;
111
+ export declare const SafePopover: ComponentType<SafePopoverProps>;
112
+ export declare const SafeSelect: ComponentType<SafeOptionsProps>;
113
+ export declare const SafeMultiSelect: ComponentType<SafeOptionsProps>;
114
+ export declare const SafeToggleGroup: ComponentType<SafeOptionsProps>;
115
+ export declare const SafeStepIndicator: ComponentType<SafeStepIndicatorProps>;
116
+
117
+ // ---------------------------------------------------------------------------
118
+ // Hardened hs-uix components — same props as the originals, except the
119
+ // coerced collection props (see SAFE_ARRAY_PROPS / SAFE_DERIVE_PROPS) also
120
+ // accept null/undefined, matching the runtime tolerance that is the point of
121
+ // these wrappers.
122
+ // ---------------------------------------------------------------------------
123
+
124
+ /** The original props with the hardened collection props widened to optional-and-nullable. */
125
+ type WithNullableArrays<Props, K extends keyof Props> = Omit<Props, K> & {
126
+ [P in K]?: Props[P] | null;
127
+ };
128
+
129
+ export declare function SafeDataTable<Row = Record<string, unknown>>(
130
+ props: WithNullableArrays<
131
+ DataTableProps<Row>,
132
+ "data" | "columns" | "searchFields" | "filters" | "selectionActions"
133
+ >
134
+ ): ReactNode;
135
+ export declare function SafeKanban<Row = Record<string, unknown>, Id = string | number>(
136
+ props: WithNullableArrays<KanbanProps<Row, Id>, "data" | "stages">
137
+ ): ReactNode;
138
+ export declare const SafeFormBuilder: ComponentType<
139
+ WithNullableArrays<FormBuilderProps, "fields">
140
+ >;
141
+ export declare function SafeFeed<Row = FeedItem>(
142
+ props: WithNullableArrays<FeedProps<Row>, "items" | "fields">
143
+ ): ReactNode;
144
+ export declare function SafeCalendar<Event = Record<string, unknown>>(
145
+ props: WithNullableArrays<CalendarProps<Event>, "events">
146
+ ): ReactNode;
147
+ export declare const SafeAvatarStack: ComponentType<
148
+ WithNullableArrays<AvatarStackProps, "items">
149
+ >;
150
+ export declare const SafeKeyValueList: ComponentType<
151
+ WithNullableArrays<KeyValueListProps, "items">
152
+ >;
153
+ export declare function SafeCrmDataTable<Row = Record<string, unknown>>(
154
+ props: WithNullableArrays<CrmDataTableProps<Row>, "columns">
155
+ ): ReactNode;
156
+ export declare function SafeCrmKanban<Row = Record<string, unknown>>(
157
+ props: WithNullableArrays<CrmKanbanProps<Row>, "stages" | "cardFields">
158
+ ): ReactNode;
@@ -12,6 +12,7 @@ Pure helper functions for formatting, mapping, guards, and lightweight data tran
12
12
  - `viewAdapters.js` — shape transforms between DataTable columns and Kanban cardFields (power a single "same data, different view" toggle)
13
13
  - `query.js` — shared collection query helpers: empty filter values, filter reset, active-filter chips, filtering, and search
14
14
  - `crmSearchAdapters.js` — CRM-bound data components (`CrmDataTable`, `CrmKanban`) plus the lower-level CRM search hooks and config builders behind them
15
+ - `applyPatches.js` — RFC 6902 JSON Patch applier (add/replace/remove/move/copy) with structural sharing, built for streaming-UI flows
15
16
 
16
17
  ## Purpose
17
18
 
@@ -503,6 +504,44 @@ If you need to drive a custom view, the hooks and helpers are exported directly:
503
504
 
504
505
  ---
505
506
 
507
+ ## applyPatches.js
508
+
509
+ RFC 6902 JSON Patch applier — the minimal subset that streaming UIs need.
510
+ Supported ops: `add`, `replace`, `remove`, `move`, `copy`; anything else
511
+ (including `test`) is skipped with a console warning.
512
+
513
+ ### `applyPatches(doc, patches)`
514
+
515
+ ```js
516
+ import { applyPatches } from "hs-uix/utils";
517
+
518
+ const doc = { meta: { title: "Dashboard" }, rows: [] };
519
+
520
+ const next = applyPatches(doc, [
521
+ { op: "replace", path: "/meta/title", value: "Pipeline" },
522
+ { op: "add", path: "/rows/-", value: { id: "r1" } },
523
+ ]);
524
+ // → { meta: { title: "Pipeline" }, rows: [{ id: "r1" }] }
525
+
526
+ next === doc; // → false (never mutates input)
527
+ next.meta === doc.meta; // → false (changed branch)
528
+ ```
529
+
530
+ Behavior worth knowing:
531
+
532
+ | Behavior | Detail |
533
+ |---|---|
534
+ | Immutability | Returns a **new** document; untouched branches keep reference equality (structural sharing), so memoized renderers only re-render what changed. |
535
+ | Permissive paths | Where RFC 6902 would error on a missing path prefix, `add`/`replace` create it — objects by default, arrays when the next segment is numeric or `-`. Built for patch streams where `/elements/x/props` can arrive before `/elements`. |
536
+ | Root pointer | `path: ""` (or `"/"`) replaces the whole document. |
537
+ | Array `add` vs `replace` | Per RFC 6902, `add` at an existing index **inserts** (shifts the rest right); `replace` overwrites in place. The standard `-` index appends (`{ op: "add", path: "/rows/-", value }`). |
538
+ | Array `remove` | An invalid or out-of-range index (`/rows/-`, `/rows/foo`, `/rows/99`) is a safe no-op — same spirit as removing a missing object key. |
539
+ | Escaping | Standard RFC 6901 unescaping: `~1` → `/`, `~0` → `~`. |
540
+ | `copy` / `move` | `copy` deep-clones the source (JSON-safe values only — no `Date`/`Map`/`Set`). Pointers resolve own properties only, so `/constructor`-style segments read as missing instead of leaking functions into the document. |
541
+ | Null doc | `applyPatches(null, patches)` starts from `{}`. Empty/missing `patches` returns the input as-is. |
542
+
543
+ ---
544
+
506
545
  ## Guidelines
507
546
 
508
547
  - Keep helpers pure and side-effect free
@@ -0,0 +1,158 @@
1
+ # Wizard (hs-uix/experimental)
2
+
3
+ Orchestrated multi-step flows for HubSpot UI Extensions — plus the "getting started" checklist card. `Wizard` steps render **arbitrary content** (tables, CRM pickers, review summaries — anything), which makes it the right tool when FormBuilder's form-only multi-step mode isn't enough. The Wizard owns the orchestration: a shared values bag, validate-gated Next, linear step reachability with success markers, a side step-nav (vertical) or native StepIndicator (horizontal), and a Back/Next/Finish footer. `OnboardingChecklist` is the companion setup tracker: a ProgressBar headline over rows of done/pending tasks with inline action buttons.
4
+
5
+ ## Quick Start
6
+
7
+ ```jsx
8
+ import { Wizard } from "hs-uix/experimental";
9
+ import { Input, Flex, Text } from "@hubspot/ui-extensions";
10
+
11
+ <Wizard
12
+ steps={[
13
+ {
14
+ id: "details",
15
+ title: "Details",
16
+ description: "Who is this for?",
17
+ render: ({ values, setValues }) => (
18
+ <Input
19
+ name="email"
20
+ label="Email"
21
+ value={values.email || ""}
22
+ onChange={(email) => setValues({ email })}
23
+ />
24
+ ),
25
+ validate: ({ values }) => (values.email ? true : "Email is required."),
26
+ },
27
+ {
28
+ id: "options",
29
+ title: "Options",
30
+ optional: true,
31
+ render: ({ values, setValues }) => (
32
+ <Flex direction="column" gap="xs">{/* anything */}</Flex>
33
+ ),
34
+ },
35
+ {
36
+ id: "review",
37
+ title: "Review",
38
+ render: ({ values }) => <Text>Sending to {values.email || "--"}</Text>,
39
+ },
40
+ ]}
41
+ onComplete={(values) => createRecord(values)}
42
+ />
43
+ ```
44
+
45
+ ```jsx
46
+ import { OnboardingChecklist } from "hs-uix/experimental";
47
+
48
+ <OnboardingChecklist
49
+ title="Getting started"
50
+ items={[
51
+ { id: "connect", title: "Connect your calendar", done: true },
52
+ {
53
+ id: "import",
54
+ title: "Import contacts",
55
+ description: "CSV or CRM sync",
56
+ done: false,
57
+ action: { label: "Import", onClick: openImportPanel },
58
+ },
59
+ ]}
60
+ onItemClick={(item) => openDetail(item.id)}
61
+ />
62
+ ```
63
+
64
+ ## Features
65
+
66
+ - **Arbitrary step content** — each step's `render(ctx)` returns any node; the wizard is layout, navigation, and gating only.
67
+ - **Shared values bag** — `ctx.values` / `ctx.setValues` let steps accumulate data; `onComplete(values)` hands the finished bag back.
68
+ - **Validate gating** — a step's `validate(ctx)` returning a non-empty string blocks Next/Finish and renders the message as an inline error `Alert`.
69
+ - **Linear reachability** — future steps are disabled until every prior required step is completed; `optional` steps are skippable; `allowJumpAhead` opens everything.
70
+ - **Side step-nav** (vertical, default): `checkCircle` success markers for completed steps, filled circle for the current step, hollow circles for upcoming; reachable steps are clickable Links.
71
+ - **Native StepIndicator** (horizontal): `stepNames` + `currentStep` + click-to-navigate, with `stepIndicatorProps` pass-through.
72
+ - **Back / Next / Finish footer** — secondary Back (disabled on the first step), primary Next/Finish; replace it wholesale with `renderFooter`.
73
+ - **Controlled or uncontrolled** — `step` / `defaultStep` / `onStepChange`, by step id or index.
74
+ - **OnboardingChecklist** — native `ProgressBar` (done/total), success-marker rows, inline action buttons, optional Accordion collapse that defaults open while work remains.
75
+
76
+ ## Review / summary step
77
+
78
+ A review step is just a step that renders the values bag. Pair it with `KeyValueList` from `hs-uix/common-components`:
79
+
80
+ ```jsx
81
+ import { KeyValueList } from "hs-uix/common-components";
82
+
83
+ {
84
+ id: "review",
85
+ title: "Review",
86
+ description: "Confirm before creating the record.",
87
+ render: ({ values, goTo }) => (
88
+ <Flex direction="column" gap="sm">
89
+ <KeyValueList
90
+ items={[
91
+ { label: "Email", value: values.email },
92
+ { label: "Plan", value: values.plan },
93
+ ]}
94
+ />
95
+ <Link onClick={() => goTo("details")}>Edit details</Link>
96
+ </Flex>
97
+ ),
98
+ }
99
+ ```
100
+
101
+ The Finish button on the last step runs that step's `validate` (if any) and then calls `onComplete(values)`.
102
+
103
+ ## `<Wizard>` props
104
+
105
+ | Prop | Type | Default | Notes |
106
+ |------|------|---------|-------|
107
+ | `steps` | `WizardStep[]` | `[]` | `{ id, title, description?, optional?, render?, validate? }`. Missing ids become `step-<index>`. |
108
+ | `step` | `string \| number` | — | Controlled current step (id or zero-based index). |
109
+ | `defaultStep` | `string \| number` | `0` | Initial step when uncontrolled. |
110
+ | `onStepChange` | `(stepId, stepIndex) => void` | — | Fires on every transition (Next, Back, nav click). |
111
+ | `onComplete` | `(values) => void` | — | Finish pressed on the last step and its validate passed. |
112
+ | `defaultValues` | `object` | `{}` | Initial contents of the shared values bag. |
113
+ | `onValuesChange` | `(values) => void` | — | Observer for every `setValues` call. |
114
+ | `orientation` | `"vertical" \| "horizontal"` | `"vertical"` | Vertical = side step-nav; horizontal = native StepIndicator above content. |
115
+ | `showStepNav` | `boolean` | `true` | Hide the nav entirely (footer still navigates). |
116
+ | `allowJumpAhead` | `boolean` | `false` | Let users click any future step without completing prior ones. |
117
+ | `showStepHeader` | `boolean` | `true` | Render the active step's title/description above its content. |
118
+ | `labels` | `WizardLabels` | — | `{ back, next, finish, optional, errorTitle }` overrides. |
119
+ | `renderFooter` | `(ctx) => node` | — | Replaces the footer; ctx adds `{ isFirst, isLast, error, labels }`. |
120
+ | `navFlex` / `contentFlex` | `number` | `1` / `3` | Flex ratios for the vertical layout columns. |
121
+ | `stepIndicatorProps` | `object` | — | Spread onto the native StepIndicator (e.g. `variant="compact"`). |
122
+
123
+ ### Step context (`ctx`)
124
+
125
+ Passed to `render`, `validate`, and `renderFooter`:
126
+
127
+ | Key | Type | Notes |
128
+ |-----|------|-------|
129
+ | `stepId` | `string` | Active step id. |
130
+ | `stepIndex` | `number` | Zero-based index. |
131
+ | `goNext()` | `() => void` | Validates, then advances (or completes on the last step). |
132
+ | `goBack()` | `() => void` | Previous step — never gated. |
133
+ | `goTo(stepOrId)` | `(string \| number) => void` | No-op when the target isn't reachable. |
134
+ | `values` | `object` | Shared values bag. |
135
+ | `setValues(patch)` | `(object \| fn) => void` | Object patches shallow-merge; updater functions replace. |
136
+
137
+ ### Gating rules
138
+
139
+ - **Back** is never gated; revisiting a completed step keeps its success marker.
140
+ - **Next/Finish** runs the active step's `validate(ctx)`. A non-empty string blocks and shows an error Alert; `true`/`undefined`/`false` pass. Validation is sync-only.
141
+ - A future step is clickable only when every step before it is completed or `optional` (linear), or when `allowJumpAhead` is on.
142
+ - Completion is recorded per step id and is not invalidated by later edits — re-validate in `onComplete` if steps can be undone retroactively.
143
+
144
+ ## `<OnboardingChecklist>` props
145
+
146
+ | Prop | Type | Default | Notes |
147
+ |------|------|---------|-------|
148
+ | `items` | `OnboardingChecklistItem[]` | `[]` | `{ id, title, description?, done, action? }`; `action` is `{ label, onClick, variant?, disabled? }`. |
149
+ | `title` | `ReactNode` | — | Card heading (Accordion title in collapsible mode). |
150
+ | `description` | `ReactNode` | — | Microcopy under the heading (non-collapsible mode only). |
151
+ | `progress` | `boolean` | `true` | Show the `ProgressBar` headline (`value=done`, `maxValue=total`). |
152
+ | `onItemClick` | `(item) => void` | — | Makes item titles clickable Links. |
153
+ | `collapsible` | `boolean` | `false` | Wrap in an Accordion; defaults open while items remain incomplete. |
154
+ | `defaultOpen` | `boolean` | data-driven | Override the collapsible default-open behavior. |
155
+ | `showCompletedActions` | `boolean` | `false` | Keep action buttons on done rows. |
156
+ | `labels` | `{ progress }` | — | `progress(done, total)` formats the bar's value description. |
157
+
158
+ Completion is data-driven: the host marks items `done`. The checklist renders state, it never owns it.