hs-uix 2.1.0 → 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 +397 -119
  4. package/dist/calendar.mjs +399 -119
  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 +3255 -353
  18. package/dist/index.mjs +3199 -344
  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 +76 -5
  31. package/src/calendar/index.d.ts +108 -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
@@ -555,18 +555,61 @@ For centralized alert config:
555
555
  />
556
556
  ```
557
557
 
558
- ## Dirty Tracking
558
+ ## Dirty Tracking & Unsaved-Changes Guard
559
+
560
+ FormBuilder deep-compares current values against the initial snapshot (the
561
+ resolved `initialValues` / first controlled `values`). `onDirtyChange` fires on
562
+ transitions only — once when the form becomes dirty, once when it goes clean
563
+ again (reset, submit-reset, or values edited back to their originals).
559
564
 
560
565
  ```jsx
561
566
  <FormBuilder
562
567
  fields={fields}
563
568
  onSubmit={save}
564
569
  onDirtyChange={(isDirty) => {
565
- // e.g., show unsaved changes warning
570
+ // e.g., flip a "you have unsaved changes" banner outside the form
566
571
  }}
567
572
  />
568
573
  ```
569
574
 
575
+ The ref exposes the same state imperatively: `formRef.current.isDirty()` and
576
+ `formRef.current.getDirtyFields()` (names of changed fields).
577
+
578
+ ### Confirm Before Discarding (`confirmDiscard`)
579
+
580
+ When `confirmDiscard` is set, the built-in Cancel button stops firing
581
+ `onCancel` directly while the form is dirty. Instead it opens a native Modal
582
+ confirmation — "Keep editing" closes the modal, the destructive confirm
583
+ closes it and then calls `onCancel`. A clean form cancels immediately, no
584
+ modal.
585
+
586
+ ```jsx
587
+ <FormBuilder
588
+ fields={fields}
589
+ onSubmit={save}
590
+ showCancel={true}
591
+ onCancel={() => actions.closeOverlay("edit-panel")}
592
+ confirmDiscard={true} // or customize:
593
+ // confirmDiscard={{
594
+ // title: "Discard this deal?",
595
+ // message: "Your edits to this deal will be lost.",
596
+ // confirmLabel: "Discard edits",
597
+ // cancelLabel: "Keep editing",
598
+ // }}
599
+ />
600
+ ```
601
+
602
+ The confirmation modal closes itself via `actions.closeOverlay` (FormBuilder
603
+ calls `useExtensionActions` internally — nothing to thread in).
604
+
605
+ **Host-close caveat.** UI extensions cannot intercept the host panel/modal
606
+ close — the X button, ESC, and outside clicks discard silently and there is no
607
+ `beforeunload` equivalent. `confirmDiscard` only guards FormBuilder's own
608
+ Cancel button (and the imperative `ref.reset()` is intentionally unguarded —
609
+ it's your code calling it). For everything else, `onDirtyChange` is your hook:
610
+ track dirty state in the parent and surface your own warning UI (a banner, a
611
+ disabled close affordance, an alert on reopen).
612
+
570
613
  ## Custom Render Escape Hatch
571
614
 
572
615
  For fields that need custom rendering:
@@ -1059,6 +1102,89 @@ const initialValues = useFormPrefill(properties, {
1059
1102
  <FormBuilder fields={fields} initialValues={initialValues} onSubmit={save} />
1060
1103
  ```
1061
1104
 
1105
+ ## Fields from HubSpot Properties
1106
+
1107
+ `fieldsFromHubSpotProperties` turns HubSpot property definitions (the objects
1108
+ returned by `GET /crm/v3/properties/{objectType}`) into FormBuilder field
1109
+ configs, so a CRM edit form is one fetch + one function call instead of a
1110
+ hand-maintained field list that drifts from the portal.
1111
+
1112
+ ```jsx
1113
+ import { FormBuilder, fieldsFromHubSpotProperties } from "hs-uix/form";
1114
+
1115
+ // properties = the `results` array from the properties API (via hubspot.fetch
1116
+ // or a serverless function)
1117
+ const fields = fieldsFromHubSpotProperties(properties, {
1118
+ include: ["dealname", "dealstage", "amount", "closedate"], // also sets order
1119
+ requiredOverrides: ["dealname", "dealstage"],
1120
+ overrides: {
1121
+ amount: { min: 0, description: "USD" },
1122
+ },
1123
+ });
1124
+
1125
+ <FormBuilder fields={fields} onSubmit={save} />
1126
+ ```
1127
+
1128
+ Type mapping (HubSpot `fieldType` first, storage `type` as tie-breaker):
1129
+
1130
+ | HubSpot | FormBuilder field type |
1131
+ |---|---|
1132
+ | `select` (enumeration) | `select` — options from property options, `hidden: true` options filtered |
1133
+ | `radio` | `radioGroup` |
1134
+ | `checkbox` (multi-enum) | `multiselect` |
1135
+ | `booleancheckbox` | `toggle` — with `transformIn`/`transformOut` normalizing HubSpot's `"true"`/`"false"` strings |
1136
+ | `date` (type `date`) | `date` |
1137
+ | `date` (type `datetime`) | `datetime` |
1138
+ | `number` | `number` — with `transformIn` parsing HubSpot's numeric strings |
1139
+ | `textarea` | `textarea` |
1140
+ | `text` / `phonenumber` | `text` |
1141
+ | anything else | falls back on storage type (`enumeration` → `select`, `bool` → `toggle`, `number` → `number`, else `text`) |
1142
+
1143
+ Options:
1144
+
1145
+ | Option | Type | Default | Notes |
1146
+ |---|---|---|---|
1147
+ | `include` | `string[]` | - | Property names to keep. Also sets the output field order. |
1148
+ | `exclude` | `string[]` | - | Property names to drop. |
1149
+ | `overrides` | `Record<string, Partial<Field>>` | - | Partial field configs merged over the generated config per property. |
1150
+ | `requiredOverrides` | `string[] \| Record<string, boolean>` | - | Mark properties required (property definitions don't carry required-ness — that's per-form in HubSpot). |
1151
+ | `includeDescriptions` | `boolean` | `false` | Copy property descriptions into field `description` help text (off by default — portal descriptions are often internal notes). |
1152
+
1153
+ Hard-earned defaults: `hidden: true` properties are skipped unless explicitly
1154
+ listed in `include`; `calculated` and `modificationMetadata.readOnlyValue`
1155
+ properties come back `readOnly: true` (an editable input for a value HubSpot
1156
+ will refuse to save is a silent-fail trap). Date/datetime **values** are not
1157
+ transformed — epoch-ms ↔ `{ year, month, date }` conversion is timezone
1158
+ sensitive, so wire `transformIn`/`transformOut` yourself (see `dateToTimestamp`
1159
+ in `hs-uix/utils`).
1160
+
1161
+ ## Field-Level Loading
1162
+
1163
+ Set `loading: true` on a field while its options (or any backing data) are
1164
+ still in flight: the input is disabled, and `select` / `multiselect` fields
1165
+ render an inline `LoadingSpinner` (`size="xs"`) beside the control so the
1166
+ empty dropdown reads as "still fetching" rather than broken.
1167
+
1168
+ ```jsx
1169
+ const { options, loading } = useCrmSearchOptions({ objectType: "companies", query });
1170
+
1171
+ const fields = [
1172
+ { name: "company", type: "select", label: "Company", options, loading },
1173
+ ];
1174
+ ```
1175
+
1176
+ The CRM search adapters in `hs-uix/utils` set exactly this key, so the two
1177
+ compose with zero glue:
1178
+
1179
+ ```jsx
1180
+ import { useCrmSearchOptions, makeCrmSearchSelectField } from "hs-uix/utils";
1181
+
1182
+ const search = useCrmSearchOptions({ objectType: "companies" });
1183
+ const fields = [
1184
+ makeCrmSearchSelectField({ name: "company", label: "Company" }, search),
1185
+ ];
1186
+ ```
1187
+
1062
1188
  ## Auto-Save
1063
1189
 
1064
1190
  Debounced auto-save on field changes:
@@ -1121,6 +1247,7 @@ try {
1121
1247
  | `submitVariant` | `"primary" \| "secondary"` | `"primary"` | Button variant |
1122
1248
  | `showCancel` | `boolean` | `false` | Show cancel button |
1123
1249
  | `onCancel` | `() => void` | - | Cancel callback |
1250
+ | `confirmDiscard` | `boolean \| { title?, message?, confirmLabel?, cancelLabel? }` | - | When set, the built-in Cancel button opens a native Modal confirmation before discarding dirty changes. Cannot intercept the host panel/modal close — use `onDirtyChange` for that |
1124
1251
  | `submitPosition` | `"bottom" \| "none"` | `"bottom"` | Button placement |
1125
1252
  | `submitAlign` | `"start" \| "end" \| "between"` | auto | Default single-step button-row alignment. Defaults to `"between"` when `showCancel` is true, otherwise `"start"` |
1126
1253
  | `loading` | `boolean` | - | Controlled loading state |
@@ -1156,7 +1283,7 @@ try {
1156
1283
  | `onSubmitError` | `(error, helpers) => void` | - | Post-submit error |
1157
1284
  | `resetOnSuccess` | `boolean` | `false` | Auto-reset after success |
1158
1285
  | `autoSave` | `{ debounce?, onAutoSave }` | - | Debounced auto-save |
1159
- | `onDirtyChange` | `(isDirty) => void` | - | Dirty state callback |
1286
+ | `onDirtyChange` | `(isDirty) => void` | - | Dirty state callback (fires on transitions only) |
1160
1287
  | `ref` | `Ref<FormBuilderRef>` | - | Imperative ref |
1161
1288
 
1162
1289
  ### Field Props
@@ -1183,7 +1310,7 @@ try {
1183
1310
  | `useDefaultValidators` | `boolean` | All | Enable/disable built-in type/shape validation (default `true`) |
1184
1311
  | `validateDebounce` | `number` | All | Debounce async validation (ms) |
1185
1312
  | `debounce` | `number` | All | Debounce onChange callback (ms) |
1186
- | `loading` | `boolean` | All | Field-level loading indicator |
1313
+ | `loading` | `boolean` | All | Field-level loading: disables the input; select/multiselect also render an inline `LoadingSpinner` (set automatically by `makeCrmSearchSelectField` / `makeCrmSearchMultiSelectField`) |
1187
1314
  | `group` | `string` | All | Divider-based field grouping |
1188
1315
  | `onFieldChange` | `(value, allValues, helpers) => void` | All | Cross-field side effects |
1189
1316
  | `transformIn` | `(rawValue) => displayValue` | All | Storage → display transform (on load) |
@@ -1233,6 +1360,7 @@ try {
1233
1360
  | `reset()` | `void` | Reset to initial values |
1234
1361
  | `getValues()` | `Record<string, unknown>` | Current form values |
1235
1362
  | `isDirty()` | `boolean` | Whether values differ from initial |
1363
+ | `getDirtyFields()` | `string[]` | Names of fields whose value differs from initial |
1236
1364
  | `setFieldValue(name, value)` | `void` | Set a field value programmatically |
1237
1365
  | `setFieldError(name, message)` | `void` | Set a field error programmatically |
1238
1366
  | `setErrors(errors)` | `void` | Batch set field errors (server-side validation) |
@@ -106,6 +106,17 @@ export interface FormBuilderAlertConfig {
106
106
  successTitle?: string;
107
107
  }
108
108
 
109
+ export interface FormBuilderConfirmDiscardConfig {
110
+ /** Modal title. Default "Discard changes?". */
111
+ title?: string;
112
+ /** Modal body text. Default "You have unsaved changes. If you discard now, they will be lost.". */
113
+ message?: string;
114
+ /** Destructive confirm button label. Default "Discard changes". */
115
+ confirmLabel?: string;
116
+ /** Keep-editing button label. Default "Keep editing". */
117
+ cancelLabel?: string;
118
+ }
119
+
109
120
  export interface FormBuilderButtonsRenderContext {
110
121
  isMultiStep: boolean;
111
122
  isFirstStep: boolean;
@@ -114,6 +125,8 @@ export interface FormBuilderButtonsRenderContext {
114
125
  totalSteps: number;
115
126
  disabled: boolean;
116
127
  loading: boolean;
128
+ /** True when current values deep-differ from the initial snapshot. */
129
+ isDirty: boolean;
117
130
  labels: Required<Pick<FormBuilderLabels, "submit" | "cancel" | "back" | "next">>;
118
131
  onBack: () => void;
119
132
  onNext: () => void;
@@ -164,7 +177,12 @@ export interface FormBuilderField {
164
177
  minValidationMessage?: string;
165
178
  maxValidationMessage?: string;
166
179
 
167
- // Field-level loading indicator
180
+ /**
181
+ * Field-level loading indicator. While true the input is disabled and
182
+ * select/multiselect fields render an inline LoadingSpinner beside the
183
+ * control — `makeCrmSearchSelectField` / `makeCrmSearchMultiSelectField`
184
+ * (hs-uix/utils) set this automatically while CRM search options load.
185
+ */
168
186
  loading?: boolean;
169
187
 
170
188
  // Conditional visibility
@@ -357,6 +375,8 @@ export interface FormBuilderRef {
357
375
  reset: () => void;
358
376
  getValues: () => Record<string, unknown>;
359
377
  isDirty: () => boolean;
378
+ /** Names of fields whose current value deep-differs from the initial snapshot. */
379
+ getDirtyFields: () => string[];
360
380
  setFieldValue: (name: string, value: unknown) => void;
361
381
  setFieldError: (name: string, message: string) => void;
362
382
  setErrors: (errors: Record<string, string>) => void;
@@ -424,6 +444,14 @@ export interface FormBuilderProps {
424
444
  submitVariant?: "primary" | "secondary";
425
445
  showCancel?: boolean;
426
446
  onCancel?: () => void;
447
+ /**
448
+ * Guard the built-in Cancel button while the form is dirty: clicking it
449
+ * opens a native Modal confirmation ("Keep editing" / destructive confirm)
450
+ * before onCancel fires. Pass `true` for default copy or an object to
451
+ * customize it. Note: extensions cannot intercept the host panel/modal
452
+ * close (the X button) — pair this with onDirtyChange for those cases.
453
+ */
454
+ confirmDiscard?: boolean | FormBuilderConfirmDiscardConfig;
427
455
  submitPosition?: "bottom" | "none";
428
456
  /**
429
457
  * Controls the default single-step action-row alignment.
@@ -496,3 +524,56 @@ export declare function useFormPrefill(
496
524
  properties: Record<string, unknown> | undefined,
497
525
  mapping?: Record<string, string>
498
526
  ): Record<string, unknown>;
527
+
528
+ // ---------------------------------------------------------------------------
529
+ // HubSpot property schema mapping
530
+ // ---------------------------------------------------------------------------
531
+
532
+ /** One enumeration option on a HubSpot property definition. */
533
+ export interface FormBuilderHubSpotPropertyOption {
534
+ label?: string;
535
+ value: string | number | boolean;
536
+ description?: string;
537
+ displayOrder?: number;
538
+ hidden?: boolean;
539
+ }
540
+
541
+ /** A HubSpot property definition (GET /crm/v3/properties/{objectType}). Extra API fields are tolerated. */
542
+ export interface FormBuilderHubSpotProperty {
543
+ name: string;
544
+ label?: string;
545
+ type?: string; // "string" | "number" | "date" | "datetime" | "enumeration" | "bool" | ...
546
+ fieldType?: string; // "text" | "textarea" | "select" | "radio" | "checkbox" | "booleancheckbox" | "number" | "date" | "phonenumber" | ...
547
+ description?: string;
548
+ options?: FormBuilderHubSpotPropertyOption[];
549
+ hidden?: boolean;
550
+ calculated?: boolean;
551
+ modificationMetadata?: { readOnlyValue?: boolean; [k: string]: unknown };
552
+ [k: string]: unknown;
553
+ }
554
+
555
+ export interface FormBuilderHubSpotSchemaOptions {
556
+ /** Property names to keep. Also sets the output field order. */
557
+ include?: string[];
558
+ /** Property names to drop. */
559
+ exclude?: string[];
560
+ /** Per-property partial field configs merged over the generated config. */
561
+ overrides?: Record<string, Partial<FormBuilderField>>;
562
+ /** Property names to mark required — an array of names or a name → required map. */
563
+ requiredOverrides?: string[] | Record<string, boolean>;
564
+ /** Copy property descriptions into field `description` help text. Default false. */
565
+ includeDescriptions?: boolean;
566
+ }
567
+
568
+ /**
569
+ * Maps HubSpot property definitions to FormBuilder field configs.
570
+ * select → select · radio → radioGroup · checkbox → multiselect ·
571
+ * booleancheckbox → toggle (with "true"/"false" string normalization) ·
572
+ * date → date · datetime → datetime · number → number (string parsing) ·
573
+ * textarea → textarea · text/phonenumber → text. Hidden enumeration options
574
+ * are filtered; calculated / readOnlyValue properties come back readOnly.
575
+ */
576
+ export declare function fieldsFromHubSpotProperties(
577
+ properties: FormBuilderHubSpotProperty[] | null | undefined,
578
+ options?: FormBuilderHubSpotSchemaOptions
579
+ ): FormBuilderField[];
@@ -33,6 +33,8 @@ That's a filterable, sortable, stage-bucketed board with per-stage footers and i
33
33
  - Full-text search across any combination of fields, with optional fuzzy matching via Fuse.js
34
34
  - Headline metrics panel rendered above the board (`<Statistics>` under the hood) via a `metrics` prop
35
35
  - Stage transition prompts — async confirmation or extra-property capture before committing a stage change, declared per-stage via `stage.onEnterRequired.render`
36
+ - WIP limits — per-stage `wipLimit` (or a top-level `wipLimits` override) renders `count / limit` headers, an "Over WIP" warning tag on over-limit stages, and fires `onWipExceeded` once per crossing
37
+ - Swimlanes — `swimlaneBy` groups the board vertically into stacked lane sections (each with its own header, count, and row of stage columns), with explicit ordering, custom labels, collapsible lanes, and optional per-lane metrics
36
38
  - Per-card selection with a bulk action bar, plus `KanbanCardActions` for per-card actions
37
39
  - Per-stage pagination via `stageMeta` + `onLoadMore` — mix client-side, server-load-more, and pre-bucketed server data column-by-column
38
40
  - Empty / loading / error render slots that mirror DataTable's override API
@@ -329,6 +331,91 @@ Invalid target stages are disabled in the inline control; no callback fires.
329
331
 
330
332
  ---
331
333
 
334
+ ### WIP limits
335
+
336
+ Declare a limit per stage (or override centrally with `wipLimits`) and the stage header switches from a plain count to `count / limit`. When the count goes **over** the limit — at the limit is full, not over — the header grows a warning `StatusTag` ("Over WIP") and `onWipExceeded` fires.
337
+
338
+ ```jsx
339
+ const STAGES = [
340
+ { value: "qualified", label: "Qualified", variant: "info" },
341
+ { value: "in_progress", label: "In Progress", variant: "info", wipLimit: 5 },
342
+ { value: "review", label: "Review", variant: "warning", wipLimit: 3 },
343
+ { value: "done", label: "Done", variant: "success", terminal: true },
344
+ ];
345
+
346
+ <Kanban
347
+ data={tickets}
348
+ stages={STAGES}
349
+ groupBy="status"
350
+ cardFields={CARD_FIELDS}
351
+ // Optional central override — beats stage.wipLimit per stage. Useful when
352
+ // limits come from team settings rather than the stage config.
353
+ wipLimits={{ in_progress: teamSettings.wipLimit }}
354
+ onWipExceeded={(stageId, count, limit) => {
355
+ notify(`${stageId} is over its WIP limit (${count}/${limit})`);
356
+ }}
357
+ onStageChange={handleStageChange}
358
+ />
359
+ ```
360
+
361
+ Semantics worth knowing:
362
+
363
+ - **Limits never block transitions.** A move that pushes a stage over its limit still completes and `onStageChange` still fires. The component is stateless on the write path — your server is the source of truth — so blocking client-side would only desync the board from reality and prevent legitimate over-limit moves (expedites, bulk reassignment). WIP limits are a signal, not a gate. Enforce hard caps server-side if you need them.
364
+ - **`onWipExceeded` fires on the transition into exceeded**, not on every render: once when a stage crosses its limit (including a board that mounts already over a limit — that reports once on mount), and again only after the stage recovers below the limit and re-crosses.
365
+ - **Counts follow the header.** The number checked against the limit is the same one the column header shows: `stageMeta[stage].totalCount` when present (server truth), otherwise the loaded, filtered bucket size.
366
+ - **`wipLimit: 0` is valid** ("this stage should stay empty"); negative or non-numeric limits are ignored.
367
+ - Customize the header strings via `labels.wipCount` (`(count, limit) => string`, default `"5 / 4"` style) and `labels.overWip` (default `"Over WIP"`).
368
+
369
+ ---
370
+
371
+ ### Swimlanes
372
+
373
+ `swimlaneBy` (field name or `(row) => key` accessor) splits the board vertically into stacked lane sections — by owner, priority, team, SLA tier. Each lane renders a header (label + count + collapse chevron) above its own row of stage columns.
374
+
375
+ ```jsx
376
+ <Kanban
377
+ data={deals}
378
+ stages={STAGES}
379
+ groupBy="stage"
380
+ cardFields={CARD_FIELDS}
381
+ swimlaneBy="priority"
382
+ swimlaneOrder={["high", "medium", "low"]}
383
+ swimlaneLabels={{ high: "High priority", medium: "Medium", low: "Low" }}
384
+ defaultCollapsedLanes={["low"]}
385
+ onStageChange={handleStageChange}
386
+ />
387
+ ```
388
+
389
+ Behavior:
390
+
391
+ - **Lane order**: keys in `swimlaneOrder` render first, in that order — and hold their slot even when empty (an explicit order doubles as an explicit lane list). Lanes not listed append in first-seen data order. Without `swimlaneOrder`, all lanes render first-seen.
392
+ - **Labels**: `swimlaneLabels` is a `{ key: label }` map or a `(laneKey, rows) => ReactNode` function. Unlabeled lanes show their key. Rows whose lane value is `null`/`undefined`/`""` collect in a shared lane keyed `"__unassigned"` (exported as `UNASSIGNED_LANE_KEY`), labeled via `labels.unassignedLane` (default `"Unassigned"`).
393
+ - **Collapsible lanes**: on by default; collapse state is the usual controlled/uncontrolled trio — `collapsedLanes` + `onCollapsedLanesChange` (controlled) or `defaultCollapsedLanes` (uncontrolled). Set `collapseLanes={false}` to remove the affordance. Collapsed lane keys are also included in `onParamsChange` payloads.
394
+ - **Empty lane×stage cells render compactly** — a single header row with the empty placeholder instead of a full column shell, so sparse boards don't drown in empty columns.
395
+ - **WIP limits stay per-stage** (a limit applies to the stage total across all lanes, not to each lane's slice). Lane cells show lane-local counts; every cell of an over-limit stage carries the "Over WIP" tag, while the `count / limit` fraction renders on the flat (non-swimlane) board where the header count *is* the stage count.
396
+ - **Per-stage pagination (`stageMeta` / `onLoadMore`) is flat-board only.** In swimlane mode the per-cell footers describe lane slices, so load-more / totalCount displays are omitted; WIP evaluation still honors `stageMeta.totalCount`.
397
+ - **Stage collapse/expand stays global** — collapsing a stage collapses it in every lane.
398
+
399
+ #### Per-lane metrics
400
+
401
+ The headline metrics row stays global by default. For per-lane summaries, pass `metricsPerLane={true}` and make `metrics` a *function* — it's called per lane with `(laneRows, laneKey)` and rendered under each lane header while the toolbar's Metrics toggle is on:
402
+
403
+ ```jsx
404
+ <Kanban
405
+ {...rest}
406
+ swimlaneBy="owner"
407
+ metricsPerLane
408
+ metrics={(rows, laneKey) => [
409
+ { label: "Pipeline", number: formatCurrencyCompact(sumBy(rows, "amount")) },
410
+ { label: "Deals", number: rows.length },
411
+ ]}
412
+ />
413
+ ```
414
+
415
+ Without `metricsPerLane` (or without lanes), a metrics function is called once with all filtered rows — so the same function serves both modes. Arrays and ReactNodes keep rendering globally regardless of `metricsPerLane`.
416
+
417
+ ---
418
+
332
419
  ### Row selection with bulk actions
333
420
 
334
421
  Add `selectable={true}` and checkboxes appear on each card (top-right of the title row). When any card is selected, a compact selection bar appears above the board with selected count, "Select all", "Deselect all", and any custom action buttons.
@@ -497,7 +584,7 @@ If your data comes from an API or you have too many records to load up-front, dr
497
584
  searchValue={params.search}
498
585
  filterValues={params.filters}
499
586
  sort={params.sort}
500
- onParamsChange={fetchBoard} // { search, filters, sort, collapsedStages }
587
+ onParamsChange={fetchBoard} // { search, filters, sort, collapsedStages, collapsedLanes }
501
588
 
502
589
  stageMeta={stageMeta} // per-column totals / hasMore / loading
503
590
  onLoadMore={loadMoreForStage}
@@ -506,7 +593,7 @@ If your data comes from an API or you have too many records to load up-front, dr
506
593
  />
507
594
  ```
508
595
 
509
- `onParamsChange` fires on any toolbar change with a unified `{ search, filters, sort, collapsedStages }` object so you can avoid wiring four separate callbacks. The component never mutates `data` — updating the board after a stage change or load-more is the caller's job, same as DataTable's `onRowEdit`.
596
+ `onParamsChange` fires on any toolbar change with a unified `{ search, filters, sort, collapsedStages, collapsedLanes }` object so you can avoid wiring five separate callbacks. The component never mutates `data` — updating the board after a stage change or load-more is the caller's job, same as DataTable's `onRowEdit`.
510
597
 
511
598
  ---
512
599
 
@@ -533,6 +620,16 @@ If your data comes from an API or you have too many records to load up-front, dr
533
620
  | `onExpandedStagesChange` | `(stages) => void` | — | Controlled-expansion callback |
534
621
  | `stageMeta` | `Record<string, KanbanStageMeta>` | — | Per-stage `hasMore` / `totalCount` / `loading` / `error` |
535
622
  | `onLoadMore` | `(stage) => void` | — | Per-column "Load more" callback |
623
+ | `wipLimits` | `Record<string, number>` | — | Top-level per-stage WIP limit overrides (`{ [stageId]: n }`). Beats `stage.wipLimit`. |
624
+ | `onWipExceeded` | `(stageId, count, limit) => void` | — | Fires once when a stage transitions over its limit (incl. mounting already over). Never blocks the move. |
625
+ | `swimlaneBy` | `string \| (row) => key` | — | Groups the board vertically into stacked lane sections |
626
+ | `swimlaneLabels` | `Record<string, string> \| (laneKey, rows) => ReactNode` | — | Lane header labels. Unlabeled lanes show their key. |
627
+ | `swimlaneOrder` | string[] | first-seen | Explicit lane order; listed keys render first and persist even when empty |
628
+ | `collapseLanes` | boolean | `true` | Show the collapse chevron on lane headers |
629
+ | `collapsedLanes` | string[] | — | Controlled list of collapsed lane keys |
630
+ | `defaultCollapsedLanes` | string[] | `[]` | Initial collapsed lane keys (uncontrolled) |
631
+ | `onCollapsedLanesChange` | `(laneKeys) => void` | — | Lane collapse callback |
632
+ | `metricsPerLane` | boolean | `false` | Render the metrics panel inside each lane. Requires `metrics` to be a function. |
536
633
  | `selectable` | boolean | `false` | Show a selection checkbox on each card |
537
634
  | `selectedIds` | `Id[]` | — | Controlled selection — array of row IDs |
538
635
  | `onSelectionChange` | `(ids) => void` | — | Called when selection changes |
@@ -565,14 +662,14 @@ If your data comes from an API or you have too many records to load up-front, dr
565
662
  | `columnWidth` | number | `350` | Min per-column width in px (AutoGrid `columnWidth`). Clamped to a 350px minimum. |
566
663
  | `collapsedStages` | string[] | — | Controlled list of collapsed stage values |
567
664
  | `onCollapsedStagesChange` | `(stages) => void` | — | Controlled-collapse callback |
568
- | `metrics` | `KanbanMetricItem[] \| ReactNode` | — | Headline metrics panel. Array → `<StatisticsItem>` shorthand; ReactNode → full custom render. |
665
+ | `metrics` | `KanbanMetricItem[] \| ReactNode \| (rows, laneKey) => items \| node` | — | Headline metrics panel. Array → `<StatisticsItem>` shorthand; ReactNode → full custom render; function → computed from the filtered rows (and per lane with `metricsPerLane`). |
569
666
  | `showMetrics` | boolean | — | Controlled visibility of the metrics panel |
570
667
  | `onMetricsToggle` | `(visible) => void` | — | Called when the toolbar Metrics button is clicked |
571
668
  | `searchValue` | string | — | Controlled search term |
572
669
  | `onSearchChange` | `(term) => void` | — | Search callback |
573
670
  | `filterValues` | `Record<string, unknown>` | — | Controlled filter values |
574
671
  | `onFilterChange` | `(values) => void` | — | Filter callback |
575
- | `onParamsChange` | `({ search, filters, sort, collapsedStages }) => void` | — | Unified callback fired on any toolbar change |
672
+ | `onParamsChange` | `({ search, filters, sort, collapsedStages, collapsedLanes }) => void` | — | Unified callback fired on any toolbar change |
576
673
  | `loading` | boolean | `false` | Show a loading skeleton in place of the board |
577
674
  | `error` | `string \| boolean` | — | Show an error state. String value is used as the title. |
578
675
  | `labels` | `KanbanLabels` | — | Override hardcoded UI strings for i18n |
@@ -592,6 +689,7 @@ If your data comes from an API or you have too many records to load up-front, dr
592
689
  | `color` | string | Optional dot color for the header |
593
690
  | `icon` | string | Optional HubSpot `Icon` name for the header |
594
691
  | `terminal` | boolean | Mark as a "closed" stage (hidden behind a "Show closed" toggle) |
692
+ | `wipLimit` | number | WIP limit. Header shows `count / limit` plus an "Over WIP" tag when exceeded. `0` is valid; overridden per stage by the `wipLimits` prop. Advisory only — never blocks transitions. |
595
693
  | `order` | number | Explicit order override (otherwise array order wins) |
596
694
  | `footer` | `(rows) => ReactNode` | Per-stage footer content (overrides `columnFooter`) |
597
695
  | `canEnter` | `(row) => boolean` | Gate whether a row can move *into* this stage |
@@ -679,7 +777,23 @@ If your data comes from an API or you have too many records to load up-front, dr
679
777
 
680
778
  ### Labels
681
779
 
682
- `labels` accepts overrides for every hardcoded UI string. See `KanbanLabels` in `kanban.d.ts` for the full list — the most common overrides are `search`, `filtersButton`, `sortButton`, `loadMore`, `loadingMore`, `showMore`, `emptyTitle`, `emptyMessage`, `selected`, `selectAll`, `deselectAll`, `moveTo`, `metricsButton`.
780
+ `labels` accepts overrides for every hardcoded UI string. See `KanbanLabels` in `kanban.d.ts` for the full list — the most common overrides are `search`, `filtersButton`, `sortButton`, `loadMore`, `loadingMore`, `showMore`, `emptyTitle`, `emptyMessage`, `selected`, `selectAll`, `deselectAll`, `moveTo`, `metricsButton`, plus the WIP/swimlane strings `wipCount` (`(count, limit) => string`), `overWip`, `laneCount`, and `unassignedLane`.
781
+
782
+ ### Lane & WIP helper functions
783
+
784
+ The pure logic behind swimlanes and WIP limits is exported for reuse (custom lane summaries, server-side WIP alerting, tests):
785
+
786
+ | Export | Signature | Description |
787
+ |---|---|---|
788
+ | `UNASSIGNED_LANE_KEY` | `"__unassigned"` | Lane key for rows with a null/undefined/empty swimlane value |
789
+ | `getLaneKey` | `(row, swimlaneBy) => string` | Resolve a row's lane key (String-coerced) |
790
+ | `orderLaneKeys` | `(seenKeys, swimlaneOrder?) => string[]` | Explicit order first (kept even when empty), then first-seen |
791
+ | `partitionLanes` | `(rows, { swimlaneBy, swimlaneOrder }) => { laneKeys, rowsByLane }` | Full lane partition in render order |
792
+ | `resolveLaneLabel` | `(laneKey, swimlaneLabels?, rows?, unassignedLabel?) => label` | Lane display label with fallbacks |
793
+ | `resolveWipLimit` | `(stage, wipLimits?) => number \| null` | Effective limit (override beats `stage.wipLimit`; invalid → null) |
794
+ | `computeStageCounts` | `(stages, buckets, stageMeta?) => Record<string, number>` | Header-consistent per-stage counts (`totalCount` else bucket size) |
795
+ | `evaluateWip` | `(stages, counts, wipLimits?) => Record<string, { count, limit, exceeded }>` | Per-stage WIP status (`exceeded` = strictly over) |
796
+ | `findNewlyExceededWip` | `(prev, next) => { stageId, count, limit }[]` | Stages that newly crossed into exceeded — the `onWipExceeded` contract |
683
797
 
684
798
  ---
685
799
 
@@ -695,7 +809,6 @@ These come from HubSpot UI Extensions itself, not Kanban:
695
809
  | Rotated column labels | Collapsed columns stack each character vertically in its own `Text` (no CSS transforms available). Long stage names become tall — use `shortLabel` for those. |
696
810
  | External-link glyph on title links | HubSpot's `Link` primitive always shows the external-link glyph when `href.external === true`. To get a title link *without* the glyph, omit `external: true`. New-tab + no-glyph is not possible today. |
697
811
  | No row expansion | Cards are read-mostly; expandable detail rows aren't supported. Route to the CRM record for full edits. |
698
- | No swimlanes | Secondary grouping (e.g. by owner within stage) isn't supported. On the roadmap. |
699
812
  | No export | No built-in CSV/Excel export. Pair with a serverless function. |
700
813
 
701
814
  See [`src/kanban/SPEC.md`](./SPEC.md) for the full design doc, decision log, and roadmap.