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
@@ -0,0 +1 @@
1
+ export * from "./src/experimental/index";
package/filter.d.ts ADDED
@@ -0,0 +1 @@
1
+ export * from "./src/filter/index";
package/index.d.ts CHANGED
@@ -1,12 +1,17 @@
1
1
  // Each component module prefixes its type names (DataTable*, Kanban*, Feed*,
2
- // Calendar*, FormBuilder*) and its value exports are unique, so `export *` is
3
- // collision-free and keeps this root barrel automatically in sync with every
4
- // type each component declares — no hand-maintained list to fall out of date.
2
+ // Calendar*, FormBuilder*, and Filter*) and its value exports are unique, so
3
+ // `export *` is collision-free and keeps this root barrel automatically in
4
+ // sync with every type each component declares — no hand-maintained list to
5
+ // fall out of date.
5
6
  export * from "./src/datatable/index";
6
7
  export * from "./src/kanban/index";
7
8
  export * from "./src/feed/index";
8
9
  export * from "./src/calendar/index";
9
10
  export * from "./src/form/index";
11
+ export * from "./src/filter/index";
12
+ // safe prefixes everything with Safe* / catalog-style ALL_CAPS names, so it's
13
+ // also collision-free under export *.
14
+ export * from "./src/safe/index";
10
15
 
11
16
  export {
12
17
  ActiveFilterChips,
@@ -16,10 +21,20 @@ export {
16
21
  CollectionFilterControl,
17
22
  CollectionSortSelect,
18
23
  CollectionToolbar,
24
+ compareHsDateValues,
19
25
  CrmLookupSelect,
26
+ CrmRecordPicker,
27
+ DATE_FILTER_OPERATORS,
28
+ DATE_RANGE_CUSTOM_VALUE,
29
+ DATE_ROLLING_UNIT_OPTIONS,
30
+ DateRangePicker,
20
31
  formatCollectionCount,
32
+ isValidDateRange,
33
+ presetToRange,
34
+ toHsDateValue,
21
35
  KeyValueList,
22
36
  SectionHeader,
37
+ SKELETON_FILL,
23
38
  Spinner,
24
39
  SPINNERS,
25
40
  SPINNER_NAMES,
@@ -55,6 +70,7 @@ export {
55
70
  DEFAULT_SVG_FONT_WEIGHT,
56
71
  } from "./common-components";
57
72
  export {
73
+ applyPatches,
58
74
  buildActiveFilterChips,
59
75
  buildCrmSearchConfig,
60
76
  buildOptions,
@@ -89,6 +105,8 @@ export {
89
105
  export type {
90
106
  AutoTagOptions,
91
107
  AutoTagVariant,
108
+ JsonPatchOp,
109
+ JsonPatchOperation,
92
110
  AutoStatusTagOptions,
93
111
  AutoStatusTagVariant,
94
112
  StatusTagSortComparatorOptions,
@@ -124,8 +142,32 @@ export type {
124
142
  CollectionSortSelectProps,
125
143
  CollectionToolbarProps,
126
144
  CrmLookupSelectProps,
145
+ CrmRecordPickerCreateConfig,
146
+ CrmRecordPickerCreateOptionRules,
147
+ CrmRecordPickerFieldAccessor,
148
+ CrmRecordPickerId,
149
+ CrmRecordPickerOption,
150
+ CrmRecordPickerOptionConfig,
151
+ CrmRecordPickerProps,
152
+ CrmRecordPickerRecord,
153
+ CrmRecordPickerSelection,
154
+ CrmRecordPickerValue,
127
155
  DateDirectionLabels,
128
156
  DatePresetOption,
157
+ DateRangePickerChangeMeta,
158
+ DateRangePickerDateValue,
159
+ DateRangePickerExplicitRangeValue,
160
+ DateRangePickerOperator,
161
+ DateRangePickerPresetOption,
162
+ DateRangePickerPresetValue,
163
+ DateRangePickerPresenceValue,
164
+ DateRangePickerProps,
165
+ DateRangePickerRangeValue,
166
+ DateRangePickerRollingDirection,
167
+ DateRangePickerRollingUnit,
168
+ DateRangePickerRollingValue,
169
+ DateRangePickerSingleDateValue,
170
+ DateRangePickerValue,
129
171
  KeyValueListItem,
130
172
  KeyValueListProps,
131
173
  SectionHeaderProps,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hs-uix",
3
- "version": "2.1.1",
3
+ "version": "2.2.0",
4
4
  "description": "Production-ready UI components for HubSpot UI Extensions — DataTable, FormBuilder, and more",
5
5
  "license": "MIT",
6
6
  "main": "./dist/index.js",
@@ -37,6 +37,16 @@
37
37
  "import": "./dist/calendar.mjs",
38
38
  "require": "./dist/calendar.js"
39
39
  },
40
+ "./filter": {
41
+ "types": "./filter.d.ts",
42
+ "import": "./dist/filter.mjs",
43
+ "require": "./dist/filter.js"
44
+ },
45
+ "./experimental": {
46
+ "types": "./experimental.d.ts",
47
+ "import": "./dist/experimental.mjs",
48
+ "require": "./dist/experimental.js"
49
+ },
40
50
  "./common-components": {
41
51
  "types": "./common-components.d.ts",
42
52
  "import": "./dist/common-components.mjs",
@@ -46,6 +56,11 @@
46
56
  "types": "./utils.d.ts",
47
57
  "import": "./dist/utils.mjs",
48
58
  "require": "./dist/utils.js"
59
+ },
60
+ "./safe": {
61
+ "types": "./safe.d.ts",
62
+ "import": "./dist/safe.mjs",
63
+ "require": "./dist/safe.js"
49
64
  }
50
65
  },
51
66
  "files": [
@@ -56,8 +71,11 @@
56
71
  "kanban.d.ts",
57
72
  "feed.d.ts",
58
73
  "calendar.d.ts",
74
+ "filter.d.ts",
75
+ "experimental.d.ts",
59
76
  "common-components.d.ts",
60
77
  "utils.d.ts",
78
+ "safe.d.ts",
61
79
  "src/**/*.d.ts",
62
80
  "README.md"
63
81
  ],
package/safe.d.ts ADDED
@@ -0,0 +1 @@
1
+ export * from "./src/safe/index";
@@ -4,7 +4,7 @@
4
4
  [![npm downloads](https://img.shields.io/npm/dm/hs-uix)](https://www.npmjs.com/package/hs-uix)
5
5
  [![license](https://img.shields.io/npm/l/hs-uix)](https://github.com/05bmckay/hs-uix/blob/main/LICENSE)
6
6
 
7
- A presentational calendar surface for HubSpot UI Extensions. Hand it an array of records plus an `eventFields` map and it renders a **Month**, **Week**, **Day**, or **Agenda** view with a Today / ‹ › / view-switcher toolbar, optional search + filters, and click-to-open event overlays (Popover, Modal, or Panel). Like Kanban and Feed, the calendar is data-driven and presentational — the caller owns fetching.
7
+ A presentational calendar surface for HubSpot UI Extensions. Hand it an array of records plus an `eventFields` map and it renders a **Month**, **Week**, **Day**, **Agenda**, or **Resource** view with a Today / ‹ › / view-switcher toolbar, optional search + filters, click-to-open event overlays (Popover, Modal, or Panel), and an optional drag-free **reschedule** affordance. Like Kanban and Feed, the calendar is data-driven and presentational — the caller owns fetching *and persisting*: even a reschedule only **emits** the new `{ start, end }`, it never mutates the events you passed in.
8
8
 
9
9
  ```jsx
10
10
  import { Calendar } from "hs-uix/calendar";
@@ -36,7 +36,8 @@ const fields = {
36
36
 
37
37
  ## Features
38
38
 
39
- - Four stable views behind a `Select` switcher: **Month** (7-column day grid), **Week** / **Day** (hour-row time grid), and **Agenda** (day-grouped list)
39
+ - Five views behind a `Select` switcher: **Month** (7-column day grid), **Week** / **Day** (hour-row time grid), **Agenda** (day-grouped list), and **Resource** (rows = owners/rooms/teams × the focused week's days — only offered when `resources` / `resourceField` is provided)
40
+ - **Drag-free reschedule** (the platform has no drag-and-drop): `rescheduleOptions` adds preset buttons ("+1 hour", "+1 day", "Next week", custom shifts or accessors) plus a "Pick date" `DateInput` to the event-detail overlay; `onEventReschedule` emits the shifted `{ start, end }` with the event's duration preserved (DST-safe) — you persist, the Calendar never moves events itself
40
41
  - Controlled or uncontrolled `view` and `focusedDate`, with `onViewChange` / `onNavigate`
41
42
  - Date coercion for every shape a HubSpot field arrives in: `Date`, epoch ms (number **or** string), ISO string, or a `DateInput` value object (`{ year, month, date }`, 0-indexed month) — date-only strings are parsed as **local** midnight so events never land a day early
42
43
  - Multi-day events render across each day they touch; timed events show their start time
@@ -73,6 +74,7 @@ Requires `@hubspot/ui-extensions` >= 0.14.0 and `react` >= 18.0.0 as peer depend
73
74
  | `week` | An hour-row time grid for the focused week, plus an all-day band. | ± 1 week |
74
75
  | `day` | The single-day hour schedule (the same grid as week, one wide column) with an all-day band and a "now" current-hour marker. | ± 1 day |
75
76
  | `agenda` | The focused week's events grouped under day headers (time · title · owner rows). | ± 1 week |
77
+ | `resource` | Rows = resources (owners / rooms / teams), columns = the focused week's days. Each cell stacks the same chips as a month cell, with the same "+N more" overflow popover. **Only joins the switcher when `resources` or `resourceField` is provided.** | ± 1 week |
76
78
 
77
79
  The view switcher is a `Select` (not `Tabs`) because the platform caches `Tabs` bodies and they won't re-render on data changes.
78
80
 
@@ -130,6 +132,67 @@ A rep's week in an hour-row grid, Monday start, 8 AM–6 PM, opening a Panel per
130
132
  />
131
133
  ```
132
134
 
135
+ ### Resource lanes (owners / rooms / teams)
136
+
137
+ Rows = resources, columns = the focused week. `resourceField` (a key or accessor on **your** record) lanes each event; events whose id matches no declared resource get an appended derived lane (labeled from `resourceLabels` or the raw id), and events with **no** id land in a trailing "Unassigned" lane.
138
+
139
+ ```jsx
140
+ <Calendar
141
+ events={meetings}
142
+ eventFields={{ id: "id", start: "start", end: "end", title: "title", color: "color" }}
143
+ defaultView="resource"
144
+ resources={[
145
+ { id: "u1", label: "Ana Souza" },
146
+ { id: "u2", label: "Ben Liu" },
147
+ { id: "room-a", label: "Conference Room A" },
148
+ ]}
149
+ resourceField="ownerId" // or (meeting) => meeting.owner?.id
150
+ resourceLabels={{ "u3": "Cara Diaz" }} // labels for derived (undeclared) lanes
151
+ showUnassignedLane // default true; false omits id-less events
152
+ weekStartsOn={1}
153
+ hideWeekends
154
+ />
155
+ ```
156
+
157
+ - Declared `resources` always render, in order, even with zero events — so a free room reads as *free*.
158
+ - Ids are matched by `String(id)`, so a numeric `7` and a string `"7"` are the same lane.
159
+ - The "Unassigned" lane renders only when it has events; relabel it via `labels.unassigned`.
160
+ - The view appears in the switcher **only** when `resources` or `resourceField` is provided.
161
+
162
+ ### Drag-free reschedule
163
+
164
+ The platform has no drag-and-drop, so rescheduling is explicit: `rescheduleOptions` adds preset buttons and a "Pick date" `DateInput` to the event-detail overlay. The Calendar computes the new range — **both** endpoints shifted, duration preserved — and **emits** it. You persist and pass updated `events` back in; the Calendar never mutates its own events (the same presentational contract as Kanban).
165
+
166
+ ```jsx
167
+ // true → "+1 hour" / "+1 day" / "Next week" + the date picker:
168
+ <Calendar
169
+ events={tasks}
170
+ eventFields={fields}
171
+ rescheduleOptions
172
+ onEventReschedule={async (raw, { start, end }, event) => {
173
+ await patchTask(raw.id, { start, end }); // you persist…
174
+ refetch(); // …and feed updated events back in
175
+ }}
176
+ />
177
+
178
+ // Custom presets — relative shifts, or accessors returning the new start:
179
+ <Calendar
180
+ events={tasks}
181
+ eventFields={fields}
182
+ overlayMode="panel" // roomier home for the picker than the experimental popover
183
+ rescheduleOptions={[
184
+ { label: "+30 min", shift: { minutes: 30 } },
185
+ { label: "+2 days", shift: { days: 2 } },
186
+ { label: "Next sprint", shift: (event) => nextSprintStart(event.raw) },
187
+ ]}
188
+ onEventReschedule={persistRange}
189
+ />
190
+ ```
191
+
192
+ DST semantics (tested against America/Chicago): day/week shifts are **calendar** shifts — a 9 AM meeting moved across the spring-forward weekend is still 9 AM, and a midnight-to-midnight all-day span stays anchored to midnight; hour/minute shifts are **exact clock durations** ("+1 hour" always moves the instant 60 real minutes). "Pick date…" keeps the event's original time-of-day. When the timezone layer is engaged, the math runs on your **original** instants (`event.sourceStart` / `sourceEnd`), not the converted display dates, so the emitted range stays in your data's time domain.
193
+
194
+ A custom `renderEventDetail` replaces the whole overlay body, reschedule section included — wire your own buttons there if you override it.
195
+
133
196
  ### Server-driven data
134
197
 
135
198
  Fetch only the visible range. `onRangeChange` fires on mount and on every navigation / view change.
@@ -223,7 +286,7 @@ How it works: each event instant is converted to a "wall-clock" `Date` in the ac
223
286
  | `view` | `CalendarView` | — | Controlled current view. |
224
287
  | `defaultView` | `CalendarView` | `"month"` | Uncontrolled initial view. |
225
288
  | `onViewChange` | `(view) => void` | — | Fires when the view changes. |
226
- | `views` | `CalendarView[]` | all | Which views to expose in the switcher (month / week / day / agenda). |
289
+ | `views` | `CalendarView[]` | all | Which views to expose in the switcher (month / week / day / agenda / resource — resource still requires `resources`/`resourceField`). |
227
290
  | `focusedDate` | `CalendarDateInput` | — | Controlled focused date. |
228
291
  | `defaultFocusedDate` | `CalendarDateInput` | today | Uncontrolled initial date. |
229
292
  | `onNavigate` | `(date, { view }) => void` | — | Fires on prev / next / today. |
@@ -234,6 +297,12 @@ How it works: each event instant is converted to a "wall-clock" `Date` in the ac
234
297
  | `monthEventMaxChars` | `number` | per-style, from column width | Max characters for a month-cell label before "…". Labels are truncated up front so every token truncates consistently (the tag's native truncation measures unreliably while the table is still sizing columns). |
235
298
  | `dayStartHour` | `number` | `8` | First hour row in week/day (0–23). |
236
299
  | `dayEndHour` | `number` | `20` | Last hour row in week/day (0–23). |
300
+ | `resources` | `(id \| { id, label? })[]` | — | Declared resource lanes, in order. Always render, even when empty. Providing this (or `resourceField`) adds the `resource` view to the switcher. |
301
+ | `resourceField` | `string \| (event) => id` | — | How to read an event's resource id from your record. `null` / `""` ⇒ unassigned. Ids matched by `String(id)`; undeclared ids get appended derived lanes. |
302
+ | `resourceLabels` | `{ [id]: label }` | — | Labels for resources declared without one and for derived lanes. Falls back to `String(id)`. |
303
+ | `showUnassignedLane` | `boolean` | `true` | Trailing "Unassigned" lane for id-less events (rendered only when non-empty). `false` omits those events from the resource view. |
304
+ | `rescheduleOptions` | `true \| (option \| fn)[]` | — | Adds reschedule presets + a "Pick date" `DateInput` to the event overlay. `true` ⇒ "+1 hour" / "+1 day" / "Next week". Entries: `{ label, shift: { days?, weeks?, hours?, minutes? } }`, `{ label, shift: (event) => newStart }`, or a bare `(event) => newStart`. |
305
+ | `onEventReschedule` | `(raw, { start, end }, event) => void` | — | Fires when a reschedule affordance is used. Both endpoints shifted, duration preserved (DST-safe), computed from your original instants. **You persist** — the Calendar never mutates its events. |
237
306
  | `timeZone` | `string` | — | Controlled IANA zone (e.g. `"America/Chicago"`). Setting it opts into the tz layer; all times/placement/grouping resolve here. |
238
307
  | `defaultTimeZone` | `string` | — | Uncontrolled initial zone. Opts into the tz layer; the layer itself starts at `"UTC"` if engaged without this. |
239
308
  | `onTimeZoneChange` | `(tz) => void` | — | Fires when the zone changes. |
@@ -277,10 +346,10 @@ How it works: each event instant is converted to a "wall-clock" `Date` in the ac
277
346
  Render callbacks (`renderEventDetail`, `onEventClick`, `renderDayCell`) receive the normalized event:
278
347
 
279
348
  ```ts
280
- { key, id, start: Date | null, end: Date | null, title, subtitle, color, href, raw }
349
+ { key, id, start: Date | null, end: Date | null, sourceStart, sourceEnd, title, subtitle, color, href, raw }
281
350
  ```
282
351
 
283
- `raw` is your original record.
352
+ `raw` is your original record. `start` / `end` are the dates the calendar *displays* (wall-clock converted when the timezone layer is engaged); `sourceStart` / `sourceEnd` are your **original** instants, which is what the reschedule math runs on.
284
353
 
285
354
  ---
286
355
 
@@ -4,7 +4,7 @@ import type { ReactElement, ReactNode } from "react";
4
4
  // Primitives
5
5
  // ---------------------------------------------------------------------------
6
6
 
7
- export type CalendarView = "month" | "week" | "day" | "agenda";
7
+ export type CalendarView = "month" | "week" | "day" | "agenda" | "resource";
8
8
  export type CalendarOverlayMode = "popover" | "modal" | "panel" | "none";
9
9
  export type CalendarFilterType = "select" | "multiselect" | "dateRange";
10
10
 
@@ -54,6 +54,12 @@ export interface CalendarNormalizedEvent<Event = Record<string, unknown>> {
54
54
  id: string | number | undefined;
55
55
  start: Date | null;
56
56
  end: Date | null;
57
+ /** The UN-converted start instant from your data (no timezone-layer wall-clock
58
+ * conversion). Reschedule math runs on this, so emitted dates stay in your
59
+ * data's time domain. */
60
+ sourceStart: Date | null;
61
+ /** The UN-converted end instant (mirrors `sourceStart` when the event has no end). */
62
+ sourceEnd: Date | null;
57
63
  title: ReactNode;
58
64
  subtitle: ReactNode;
59
65
  color: string | undefined;
@@ -61,6 +67,60 @@ export interface CalendarNormalizedEvent<Event = Record<string, unknown>> {
61
67
  raw: Event;
62
68
  }
63
69
 
70
+ // ---------------------------------------------------------------------------
71
+ // Resource / lane view
72
+ // ---------------------------------------------------------------------------
73
+
74
+ /** A declared resource lane: `{ id, label? }`, or a bare id. Declared lanes
75
+ * always render (in order), even with zero events. */
76
+ export type CalendarResource =
77
+ | string
78
+ | number
79
+ | { id: string | number; label?: ReactNode };
80
+
81
+ /** How to read an event's resource id: a key on the raw event, or an accessor.
82
+ * `null` / `undefined` / `""` mean unassigned. Ids are matched by `String(id)`. */
83
+ export type CalendarResourceField<Event = Record<string, unknown>> =
84
+ | string
85
+ | ((event: Event) => string | number | null | undefined);
86
+
87
+ // ---------------------------------------------------------------------------
88
+ // Drag-free reschedule
89
+ // ---------------------------------------------------------------------------
90
+
91
+ /** A relative shift. Days/weeks are CALENDAR shifts (wall-clock time-of-day
92
+ * preserved on both endpoints, DST-safe); hours/minutes are exact clock
93
+ * durations. */
94
+ export interface CalendarRescheduleShift {
95
+ days?: number;
96
+ weeks?: number;
97
+ hours?: number;
98
+ minutes?: number;
99
+ }
100
+
101
+ /** One reschedule affordance: a labeled relative shift, a labeled accessor
102
+ * returning the new start (any `CalendarDateInput` shape), or a bare accessor
103
+ * (labeled from `fn.name`, falling back to "Reschedule"). */
104
+ export type CalendarRescheduleOption<Event = Record<string, unknown>> =
105
+ | {
106
+ label: string;
107
+ shift:
108
+ | CalendarRescheduleShift
109
+ | ((event: CalendarNormalizedEvent<Event>) => CalendarDateInput | null | undefined);
110
+ }
111
+ | {
112
+ label: string;
113
+ getStart: (event: CalendarNormalizedEvent<Event>) => CalendarDateInput | null | undefined;
114
+ }
115
+ | ((event: CalendarNormalizedEvent<Event>) => CalendarDateInput | null | undefined);
116
+
117
+ /** The new range emitted by `onEventReschedule` — BOTH endpoints shifted, the
118
+ * event's duration/shape preserved (DST-safe). */
119
+ export interface CalendarRescheduleRange {
120
+ start: Date;
121
+ end: Date;
122
+ }
123
+
64
124
  export interface CalendarRange {
65
125
  start: Date;
66
126
  end: Date;
@@ -101,6 +161,10 @@ export interface CalendarLabels {
101
161
  errorMessage?: string;
102
162
  open?: string;
103
163
  allDay?: string;
164
+ reschedule?: string;
165
+ pickDate?: string;
166
+ unassigned?: string;
167
+ resource?: string;
104
168
  }
105
169
 
106
170
  export interface CalendarProps<Event = Record<string, unknown>> {
@@ -145,6 +209,36 @@ export interface CalendarProps<Event = Record<string, unknown>> {
145
209
  /** Last hour row in week/day view (0–23). Default 20. */
146
210
  dayEndHour?: number;
147
211
 
212
+ // Resource / lane view — rows = resources, columns = the focused week's days.
213
+ // The "resource" view only joins the view switcher when `resources` or
214
+ // `resourceField` is provided.
215
+ /** Declared lanes, in display order. Always render, even when empty. */
216
+ resources?: CalendarResource[];
217
+ /** How to read an event's resource id (key on the raw event, or accessor).
218
+ * Ids found in the data but never declared get appended derived lanes. */
219
+ resourceField?: CalendarResourceField<Event>;
220
+ /** `{ [id]: label }` lookup for resources declared without a label and for
221
+ * derived lanes. Falls back to `String(id)`. */
222
+ resourceLabels?: Record<string, ReactNode>;
223
+ /** Append a trailing "Unassigned" lane for events with no resource id
224
+ * (rendered only when non-empty). When false those events are omitted from
225
+ * the resource view. Default `true`. */
226
+ showUnassignedLane?: boolean;
227
+
228
+ // Drag-free reschedule — adds preset buttons + a "Pick date" DateInput to the
229
+ // default event-detail overlay. `true` = "+1 hour" / "+1 day" / "Next week".
230
+ // The Calendar EMITS the shifted range and never mutates its own events.
231
+ rescheduleOptions?: true | CalendarRescheduleOption<Event>[];
232
+ /** Fires when a reschedule affordance is used. `range` has BOTH endpoints
233
+ * shifted (duration preserved, DST-safe), computed from the event's original
234
+ * (`sourceStart`/`sourceEnd`) instants. Persist it and pass updated `events`
235
+ * back in — the Calendar will not move the event on its own. */
236
+ onEventReschedule?: (
237
+ raw: Event,
238
+ range: CalendarRescheduleRange,
239
+ event: CalendarNormalizedEvent<Event>
240
+ ) => void;
241
+
148
242
  // Timezone — OFF by default: with none of these props set, events render exactly
149
243
  // as provided (no conversion, browser-local). Opt in via `timeZone`,
150
244
  // `defaultTimeZone`, or `showTimeZoneSelect` and all times, grid placement, and
@@ -9,7 +9,9 @@ Reusable UI wrappers built on top of HubSpot UI Extensions primitives.
9
9
  - `AvatarStack` — overlapping circular avatars (letters or image URLs)
10
10
  - `Icon` — superset of HubSpot's native `<Icon>`: custom glyphs, any CSS color, pixel sizes
11
11
  - `CrmLookupSelect` — CRM-backed `Select` / `MultiSelect` with live, debounced search
12
+ - `CrmRecordPicker` — multi-association record picker: search CRM records, select many, get ids AND records back, optional inline create
12
13
  - `CollectionToolbar`, `CollectionFilterControl`, `ActiveFilterChips`, `CollectionSortSelect`, `CollectionCount` — shared search/filter/sort/count primitives used by DataTable, Kanban, Feed, and Calendar
14
+ - `DateRangePicker` — HubSpot-style date filter value control with preset, static-date, rolling, range, and presence operator layouts
13
15
  - `SectionHeader` — title + optional description row
14
16
  - `KeyValueList` — vertical list of label/value rows
15
17
  - `StyledText` — SVG-rendered text with rotation, custom colors, pill backgrounds
@@ -21,6 +23,7 @@ Plus utilities + constants:
21
23
  - `ICONS`, `ICON_NAMES`, `NATIVE_ICON_NAME_LIST`, `svgToIconEntry` — the custom icon registry and helpers behind `Icon`
22
24
  - `SPINNERS`, `SPINNER_NAMES` — spinner presets and registry
23
25
  - `HS_DATE_PRESETS`, `HS_DATE_DIRECTION_LABELS` — HubSpot's native quick-date preset list
26
+ - `presetToRange`, `toHsDateValue`, `compareHsDateValues`, `isValidDateRange`, `DATE_FILTER_OPERATORS`, `DATE_ROLLING_UNIT_OPTIONS`, `DATE_RANGE_CUSTOM_VALUE` — date-filter constants and math behind `DateRangePicker`
24
27
  - `HS_FONT_FAMILY`, `HS_TEXT_COLOR`, `HS_SUBTLE_BG`, `HS_MUTED_TEXT`, `HS_NEUTRAL_CHIP` — style constants matching native HubSpot CSS
25
28
 
26
29
  ## Purpose
@@ -38,8 +41,10 @@ import {
38
41
  AvatarStack,
39
42
  Icon,
40
43
  CrmLookupSelect,
44
+ CrmRecordPicker,
41
45
  CollectionToolbar,
42
46
  CollectionSortSelect,
47
+ DateRangePicker,
43
48
  SectionHeader,
44
49
  KeyValueList,
45
50
  StyledText,
@@ -128,6 +133,76 @@ const activeChips = buildActiveFilterChips(filters, filterValues);
128
133
 
129
134
  ---
130
135
 
136
+ ## DateRangePicker
137
+
138
+ HubSpot's CRM filter editor changes the value control based on the selected date operator. `DateRangePicker` follows that pattern: `is` renders a quick-preset `Select`, static comparisons render one date input, rolling comparisons render a number plus unit dropdown, `is between` renders two date inputs with a `to` separator, and known / unknown render no value input. Its `onChange` payload preserves the operator so server-side CRM search builders can distinguish preset, static-date, rolling, explicit-range, and presence filters.
139
+
140
+ ```jsx
141
+ import { DateRangePicker } from "hs-uix/common-components";
142
+
143
+ const [range, setRange] = useState({
144
+ operator: "InRollingDateRange",
145
+ preset: "today",
146
+ });
147
+
148
+ <DateRangePicker
149
+ label="Close date"
150
+ name="close-date"
151
+ value={range}
152
+ onChange={setRange}
153
+ clearable
154
+ />
155
+ ```
156
+
157
+ Features:
158
+
159
+ - **HubSpot-style operator values.** The built-in operators are `InRollingDateRange` (`is`), `Equal` (`is equal to`), `BeforeDateStaticOrDynamic` (`is before`), `AfterDateStaticOrDynamic` (`is after`), `InRange` (`is between`), `GreaterRolling` (`is more than`), `LessRolling` (`is less than`), `Known` (`is known`), and `NotKnown` (`is unknown`).
160
+ - **Presets can still resolve to dates.** Picking "Last quarter" includes the computed `{ from, to }` bounds in `meta.range` via `presetToRange`.
161
+ - **Compact date inputs by default.** Date inputs use the same no-label treatment as `FilterBuilder` for CRM-filter-style rows. Set `showDateLabels` to `true` to render labels.
162
+ - **Only valid explicit ranges escape.** If an `InRange` edit would make `from > to`, the invalid half is held locally with an error message and `onChange` is NOT called until the user fixes either side.
163
+ - **Controlled or uncontrolled** via `value` / `defaultValue` / `onChange`.
164
+ - **Plain `{ from, to }` compatibility.** Passing the older range shape is treated as `operator: "InRange"`.
165
+
166
+ | Prop | Type | Default | Notes |
167
+ | ---- | ---- | ------- | ----- |
168
+ | `value` | operator value | — | Controlled value. Use `{ operator: "InRollingDateRange", preset }`, `{ operator: "Equal", date }`, `{ operator: "BeforeDateStaticOrDynamic", date }`, `{ operator: "AfterDateStaticOrDynamic", date }`, `{ operator: "GreaterRolling", amount, unit, direction }`, `{ operator: "LessRolling", amount, unit, direction }`, `{ operator: "InRange", from, to }`, `{ operator: "Known" }`, `{ operator: "NotKnown" }`, or legacy `{ from, to }`. |
169
+ | `defaultValue` | operator value | `{ operator: "InRollingDateRange", preset: "today" }` | Initial value for uncontrolled usage. |
170
+ | `onChange` | `(value, meta) => void` | — | Fires with the normalized operator value. `meta` includes `{ operator, field?, preset, range? }`. |
171
+ | `label` | `ReactNode` | — | Group label rendered above the control. |
172
+ | `name` | `string` | `"date-range"` | Base for inner input names. |
173
+ | `field` / `defaultField` | `string` | — | Controlled or uncontrolled selected CRM property when `showFieldSelect` is enabled. |
174
+ | `onFieldChange` | `(field) => void` | — | Fires when the field dropdown changes. |
175
+ | `showFieldSelect` | `boolean` | `false` | Render a CRM property dropdown before the operator and value controls. |
176
+ | `fieldOptions` | array | `[]` | Options for the field dropdown, shaped like `{ label, value }`. |
177
+ | `operator` / `defaultOperator` | date operator | — / `"InRollingDateRange"` | Controlled or uncontrolled operator. |
178
+ | `showOperatorSelect` | `boolean` | `true` | Render the operator dropdown. |
179
+ | `operatorOptions` | array | `DATE_FILTER_OPERATORS` | Override operator labels or available operators. |
180
+ | `presets` | `boolean \| array` | `true` | `true` = `HS_DATE_PRESETS`; `false` = no preset Select; or a custom `{ label, value, getRange? }` array — `value` is a `presetToRange` key, or supply `getRange(now)` for fully custom presets. |
181
+ | `rollingUnitOptions` | array | `DATE_ROLLING_UNIT_OPTIONS` | Options for the rolling amount unit/direction dropdown. |
182
+ | `direction` | `"row" \| "column"` | `"row"` | Layout direction for the operator/value controls. |
183
+ | `clearable` | `boolean` | `false` | Show a Clear link when the current operator has a non-empty value. |
184
+ | `min` / `max` | date object | — | Passed through to both `DateInput`s. |
185
+ | `fromLabel` / `toLabel` / `dateLabel` | `string` | `"Start date"` / `"End date"` / `"Date"` | Date input labels used when `showDateLabels` is true. |
186
+ | `showDateLabels` | `boolean` | `false` | Render DateInput labels visibly instead of the compact no-label CRM-filter layout. |
187
+ | `format` | `string` | `"medium"` | `DateInput` display format. |
188
+ | `presetPlaceholder` | `string` | `"Enter value"` | Placeholder for the preset Select. |
189
+ | `customPresetLabel` | `string` | `"Custom"` | Label for the appended Custom option. |
190
+ | `clearLabel` | `ReactNode` | `"Clear"` | Clear link text. |
191
+ | `invalidRangeMessage` | `string` | `"Start date must be on or before end date"` | Shown on the held invalid input. |
192
+ | `readOnly` | `boolean` | `false` | Pass-through to all inner controls (also hides the Clear link). |
193
+ | `gap` | `string` | `"xs"` row / `"sm"` column | Flex gap between controls. |
194
+ | `gridColumnWidth` | `number` | `260` | Minimum column width for the field-enabled flexible AutoGrid layout. |
195
+
196
+ ### Pure helpers (`dateRangePresets.js`)
197
+
198
+ - `presetToRange(presetKey, now?)` — translate an `HS_DATE_PRESETS` key into `{ from, to }` HubSpot date objects. Weeks run Sunday–Saturday; `7d`/`30d`/`90d` are rolling windows ending (and including) today; months/quarters/years are full calendar units. Returns `null` for unknown/`"custom"`/empty keys. Pass a fixed `now` Date for determinism.
199
+ - `toHsDateValue(date)` — JS `Date` → `{ year, month, date }` (or `null`).
200
+ - `compareHsDateValues(a, b)` — sort-style comparator; `null` sides compare as `0`.
201
+ - `isValidDateRange(range)` — `true` when open-ended or `from <= to`.
202
+ - `DATE_RANGE_CUSTOM_VALUE` — the `"custom"` sentinel used by the preset Select.
203
+
204
+ ---
205
+
131
206
  ## AvatarStack
132
207
 
133
208
  Overlapping circular avatars rendered as a single SVG via `<Image>`. Letters get colored circles with white initials; `http(s):` or `data:image/...` URIs get circular-clipped images. Extras beyond `maxVisible` collapse into a neutral `+N` chip.
@@ -344,6 +419,69 @@ import { CrmLookupSelect } from "hs-uix/common-components";
344
419
 
345
420
  ---
346
421
 
422
+ ## CrmRecordPicker
423
+
424
+ The association picker. `CrmLookupSelect` answers "pick ONE record" — `CrmRecordPicker` answers "pick MANY, give me the records back, and let the user create one inline". Use it whenever you're managing a record's associations (contacts on a deal, companies on a ticket) or any selection where you need the full record objects, not just ids.
425
+
426
+ What you get over a hand-rolled `MultiSelect` + `useCrmSearchOptions`:
427
+
428
+ - **Selections never vanish.** A selected record stays visible as an option even when the live search page no longer contains it.
429
+ - **ids AND records.** `onChange(ids, records)` hands back both — no second fetch to resolve what the user picked. `value` accepts ids, records, or a mix.
430
+ - **`max` enforcement.** Picks beyond the cap are rejected (the existing selection is kept), and the create option hides at the cap.
431
+ - **Guarded inline create.** With `allowCreate`, a settled search with no exact label match appends `Create "<term>"`; choosing it awaits `onCreate(term)`, selects the result, and merges it into the options. Double-fires are blocked while the create call is pending.
432
+
433
+ ```jsx
434
+ import { CrmRecordPicker } from "hs-uix/common-components";
435
+
436
+ <CrmRecordPicker
437
+ objectType="contact"
438
+ properties={["firstname", "lastname", "email"]}
439
+ label="Associated contacts"
440
+ labelField={(r) => `${r.firstname} ${r.lastname}`}
441
+ descriptionField="email"
442
+ value={contactIds}
443
+ onChange={(ids, records) => {
444
+ setContactIds(ids);
445
+ syncAssociations(records);
446
+ }}
447
+ max={10}
448
+ allowCreate={{
449
+ label: (term) => `Create contact "${term}"`,
450
+ onCreate: async (term) => {
451
+ const created = await hubspot.serverless("createContact", { parameters: { email: term } });
452
+ return created; // record object or its id — both work
453
+ },
454
+ }}
455
+ />
456
+ ```
457
+
458
+ Single-select mode (`multi={false}`) behaves like `CrmLookupSelect` but keeps this component's richer API: `onChange(id, record)` with a scalar id (or `null` when cleared), plus `allowCreate` — which `CrmLookupSelect` doesn't have.
459
+
460
+ ### Props
461
+
462
+ | Prop | Type | Default | Notes |
463
+ | ---- | ---- | ------- | ----- |
464
+ | `objectType` | string | — | CRM object to search (`"contact"`, `"company"`, `"deal"`, or any object type id/name). |
465
+ | `properties` | `string[]` | — | Properties to fetch (drive labels/descriptions). |
466
+ | `labelField` | string \| `(record) => unknown` | — | Option label: dotted property path or accessor. Falls back to `name`, `properties.name`, then `fallbackLabel`. |
467
+ | `descriptionField` | string \| `(record) => unknown` | — | Option description (omitted when empty). |
468
+ | `value` / `defaultValue` | array of ids and/or records (scalar when `multi={false}`) | — | Controlled / uncontrolled selection. Record objects seed the id→record registry. |
469
+ | `onChange` | `(ids, records) => void` | — | Multi: arrays. Single: scalar id (or `null`) + record (or `null`). Never-seen ids come back as `{ objectId: id }` stubs. |
470
+ | `multi` | boolean | `true` | `MultiSelect` vs `Select`. |
471
+ | `max` | number | — | Selection cap. The pick that would exceed it is rejected. |
472
+ | `allowCreate` | `false` \| `{ label?, onCreate }` | `false` | `onCreate: async (term) => recordOrId`. `label` is a string or `(term) => string`; default `Create "<term>"`. |
473
+ | `filterMap` | `(filters, params) => filterGroups` | — | Scope the search with full HubSpot CRM search syntax (e.g. restrict to a pipeline). |
474
+ | `pageLength` | number | `20` | Results fetched per query. |
475
+ | `debounce` / `minSearchLength` | number | `300` / `0` | Search tuning. |
476
+ | `fallbackLabel` | string | `"Untitled record"` | Label for records whose `labelField` resolves empty. |
477
+ | `onSearchChange` | `(query) => void` | — | Observe the live search input. |
478
+ | `format` / `baseConfig` | object | — | Advanced passthroughs to the CRM search config. |
479
+ | `label`, `name`, `placeholder`, `description`, `tooltip`, `required`, `readOnly`, `error`, `validationMessage`, `variant` | — | — | Standard field props forwarded to the native select; any other props are spread through too. |
480
+
481
+ The pure decision logic (option merging, max enforcement, create-option rules, id↔record mapping) is exported from `recordPickerCore.js` — `mergePickerOptions`, `enforceSelectionMax`, `shouldShowCreateOption`, `makeCreateOption`, `splitCreateSelection`, `normalizeRecordSelection`, `mapIdsToRecords`, `getRecordId`, `upsertRecords`, `CREATE_OPTION_VALUE` — if you're building a custom picker UI on the same rules.
482
+
483
+ ---
484
+
347
485
  ## HS_DATE_PRESETS
348
486
 
349
487
  HubSpot's native quick-date preset list — matches the Create date dropdown on the Deals board. Use as the `options` for a `select` filter on Kanban / DataTable so consumers don't have to retype the list.
@@ -362,7 +500,7 @@ filters={[
362
500
  ]}
363
501
  ```
364
502
 
365
- The preset values are stable identifiers (`"today"`, `"7d"`, `"this_quarter"`, etc.) it's up to the consumer to translate them into actual date bounds (via `filterFn` on the filter config or server-side in `onFilterChange`).
503
+ The preset values are stable identifiers (`"today"`, `"7d"`, `"this_quarter"`, etc.). To translate them into actual date bounds, use `presetToRange(presetKey, now?)` from this module (see [DateRangePicker](#daterangepicker)) — or do it yourself via `filterFn` on the filter config / server-side in `onFilterChange`.
366
504
 
367
505
  Also exports `HS_DATE_DIRECTION_LABELS` (`{ asc: "Ascending", desc: "Descending" }`) for pairing with direction-specific sort UIs.
368
506
 
@@ -379,6 +517,7 @@ Raw style tokens used internally by `StyledText` and `AvatarStack` so they match
379
517
  | `HS_SUBTLE_BG` | `#F5F8FA` | Tag `variant="subtle"` background |
380
518
  | `HS_MUTED_TEXT` | `#7C98B6` | Secondary / microcopy gray |
381
519
  | `HS_NEUTRAL_CHIP` | `#CBD6E2` | Neutral chip background (`+N` overflow) |
520
+ | `SKELETON_FILL` | `#DFE3EB` | Skeleton placeholder gray |
382
521
  | `HS_TAG_SUBTLE_BORDER` | — | Border color for subtle-variant tag pills |
383
522
  | `HS_TAG_TEXT_COLOR` | — | Text color inside tag pills |
384
523
  | `HS_TAG_FONT_SIZE` | — | Font size (px) for tag pill text |
@@ -1020,7 +1020,6 @@ These come from HubSpot UI Extensions itself, not DataTable:
1020
1020
  | No pixel widths | `TableCell` `width` only accepts `"min"`, `"max"`, or `"auto"`. Numeric pixel values are silently ignored by HubSpot. |
1021
1021
  | Input alignment | HubSpot input components (Input, NumberInput, CurrencyInput, etc.) ignore parent `text-align` CSS. DataTable strips `align` when inputs are visible so headers and cells stay consistent. |
1022
1022
  | No multi-column sort | Only one column can be sorted at a time. |
1023
- | No row expansion | No expand/collapse for individual row detail views. Row grouping works, but per-row expansion does not. |
1024
1023
  | No export | No built-in CSV/Excel export. You'd need to implement this in a serverless function. |
1025
1024
  | Validation on select/toggle/checkbox | `editValidate` only shows error UI on text-based inputs (text, number, currency, textarea, stepper). Select, toggle, and checkbox commit immediately and don't show `validationMessage`. |
1026
1025
 
@@ -1031,7 +1030,6 @@ These come from HubSpot UI Extensions itself, not DataTable:
1031
1030
  Planned for future releases:
1032
1031
 
1033
1032
  - Column visibility toggle so users can show/hide columns
1034
- - Expandable rows with detail content below each row
1035
1033
  - Click-to-copy on individual cell values
1036
1034
  - Conditional formatting to color-code cells based on value rules
1037
1035
  - Per-column filter dropdowns in the header row