hs-uix 2.0.0 → 2.1.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.
@@ -0,0 +1,201 @@
1
+ import type { ReactElement, ReactNode } from "react";
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Primitives
5
+ // ---------------------------------------------------------------------------
6
+
7
+ export type CalendarView = "month" | "week" | "day" | "agenda";
8
+ export type CalendarOverlayMode = "popover" | "modal" | "panel" | "none";
9
+ export type CalendarFilterType = "select" | "multiselect" | "dateRange";
10
+
11
+ /** Any date shape the calendar can coerce: Date, epoch ms, ISO string, or a
12
+ * HubSpot DateInput value object ({ year, month, date } with 0-indexed month). */
13
+ export type CalendarDateInput =
14
+ | Date
15
+ | number
16
+ | string
17
+ | { year: number; month: number; date: number; hour?: number; minute?: number };
18
+
19
+ export interface CalendarOption<T = string> {
20
+ label: string;
21
+ value: T;
22
+ }
23
+
24
+ export interface CalendarFilterConfig<Event = Record<string, unknown>> {
25
+ name: string;
26
+ type?: CalendarFilterType;
27
+ placeholder?: string;
28
+ /** Prefix used in the active-filter chip. Defaults to `placeholder` or `name`. */
29
+ chipLabel?: string;
30
+ options?: CalendarOption[];
31
+ filterFn?: (event: Event, value: unknown) => boolean;
32
+ }
33
+
34
+ export type CalendarHref =
35
+ | string
36
+ | { url: string; external?: boolean };
37
+
38
+ /** Maps fields on each event object to the roles the calendar understands. Each
39
+ * entry is either a key on the event object or an accessor function. */
40
+ export interface CalendarEventFields<Event = Record<string, unknown>> {
41
+ id?: string | ((event: Event) => string | number);
42
+ start?: string | ((event: Event) => CalendarDateInput | null | undefined);
43
+ end?: string | ((event: Event) => CalendarDateInput | null | undefined);
44
+ title?: string | ((event: Event) => ReactNode);
45
+ subtitle?: string | ((event: Event) => ReactNode);
46
+ /** A Tag/StatusTag variant: "default" | "info" | "success" | "warning" | "error". */
47
+ color?: string | ((event: Event) => string);
48
+ href?: CalendarHref | ((event: Event) => CalendarHref | null | undefined);
49
+ }
50
+
51
+ /** The normalized event passed to render callbacks. */
52
+ export interface CalendarNormalizedEvent<Event = Record<string, unknown>> {
53
+ key: string;
54
+ id: string | number | undefined;
55
+ start: Date | null;
56
+ end: Date | null;
57
+ title: ReactNode;
58
+ subtitle: ReactNode;
59
+ color: string | undefined;
60
+ href: { url: string; external: boolean } | null;
61
+ raw: Event;
62
+ }
63
+
64
+ export interface CalendarRange {
65
+ start: Date;
66
+ end: Date;
67
+ view: CalendarView;
68
+ }
69
+
70
+ /** A timezone option for the built-in selector: an IANA id, or an id with an
71
+ * explicit label (otherwise a DST-aware "UTC −05:00 Central Time" is computed). */
72
+ export type CalendarTimeZoneOption = string | { value: string; label?: string };
73
+
74
+ export interface CalendarToolbarApi {
75
+ title: string;
76
+ view: CalendarView;
77
+ views: CalendarView[];
78
+ focusedDate: Date;
79
+ onViewChange: (view: CalendarView) => void;
80
+ onPrev: () => void;
81
+ onNext: () => void;
82
+ onToday: () => void;
83
+ /** Active IANA timezone (undefined when the tz layer is disabled). */
84
+ timeZone?: string;
85
+ onTimeZoneChange: (tz: string) => void;
86
+ /** Selector options ({ value, label }), or null when `showTimeZoneSelect` is off. */
87
+ timeZoneOptions: { value: string; label: string }[] | null;
88
+ }
89
+
90
+ export interface CalendarLabels {
91
+ today?: string;
92
+ previous?: string;
93
+ next?: string;
94
+ search?: string;
95
+ clearAll?: string;
96
+ more?: (count: number) => string;
97
+ noEventsTitle?: string;
98
+ noEventsMessage?: string;
99
+ loading?: string;
100
+ errorTitle?: string;
101
+ errorMessage?: string;
102
+ open?: string;
103
+ allDay?: string;
104
+ }
105
+
106
+ export interface CalendarProps<Event = Record<string, unknown>> {
107
+ /** Event objects. The calendar is presentational — the caller owns fetching. */
108
+ events: Event[];
109
+ /** Maps event fields to calendar roles (start/end/title/color/href/…). */
110
+ eventFields?: CalendarEventFields<Event>;
111
+
112
+ // View control
113
+ view?: CalendarView;
114
+ defaultView?: CalendarView;
115
+ onViewChange?: (view: CalendarView) => void;
116
+ /** Which views to expose in the switcher. Defaults to all (month, week, day, agenda). */
117
+ views?: CalendarView[];
118
+
119
+ // Date control
120
+ focusedDate?: CalendarDateInput;
121
+ defaultFocusedDate?: CalendarDateInput;
122
+ onNavigate?: (date: Date, ctx: { view: CalendarView }) => void;
123
+ /** 0 = Sunday (default), 1 = Monday. */
124
+ weekStartsOn?: 0 | 1;
125
+ hideWeekends?: boolean;
126
+ /** Max chips per day cell in month view before collapsing to "N more". */
127
+ maxEventsPerDay?: number;
128
+
129
+ // Time grid (week / day views)
130
+ /** First hour row in week/day view (0–23). Default 8. */
131
+ dayStartHour?: number;
132
+ /** Last hour row in week/day view (0–23). Default 20. */
133
+ dayEndHour?: number;
134
+
135
+ // Timezone — OFF by default: with none of these props set, events render exactly
136
+ // as provided (no conversion, browser-local). Opt in via `timeZone`,
137
+ // `defaultTimeZone`, or `showTimeZoneSelect` and all times, grid placement, and
138
+ // day-grouping resolve in the chosen IANA zone (DST-correct).
139
+ /** Controlled IANA timezone, e.g. "America/Chicago". Pair with `onTimeZoneChange`.
140
+ * Engaging the tz layer at all is opt-in; unset = events render as-sent. */
141
+ timeZone?: string;
142
+ /** Initial (uncontrolled) IANA timezone. Only applies once the tz layer is
143
+ * engaged; the layer itself defaults to `"UTC"` when opted in without this. */
144
+ defaultTimeZone?: string;
145
+ /** Fires when the timezone changes (via the built-in selector or prop). */
146
+ onTimeZoneChange?: (timeZone: string) => void;
147
+ /** Show the built-in timezone dropdown in the toolbar. Default `false`. Enabling
148
+ * it opts into the timezone layer (which starts at UTC). */
149
+ showTimeZoneSelect?: boolean;
150
+ /** Override the selector's zone list. IANA ids, or `{ value, label }` to supply
151
+ * a custom label (otherwise a DST-aware label is computed). */
152
+ timeZoneOptions?: CalendarTimeZoneOption[];
153
+
154
+ // Filter / search (reuses the DataTable/Kanban query pipeline)
155
+ filters?: CalendarFilterConfig<Event>[];
156
+ /** Top-level keys on each raw event to search. REQUIRED for `showSearch` to do
157
+ * anything — with no `searchFields` the search box matches nothing. */
158
+ searchFields?: string[];
159
+ searchValue?: string;
160
+ onSearchChange?: (value: string) => void;
161
+ filterValues?: Record<string, unknown>;
162
+ onFilterChange?: (values: Record<string, unknown>) => void;
163
+ fuzzySearch?: boolean;
164
+ /** Show the search box. Pair with `searchFields` (search is a no-op without it). */
165
+ showSearch?: boolean;
166
+ /** Flex weight for the toolbar's left search/filter column. Default 3 (60% with default right flex 2). */
167
+ toolbarLeftFlex?: number;
168
+ /** Flex weight for the toolbar's right nav/view controls. Default 2 (40% with default left flex 3). */
169
+ toolbarRightFlex?: number;
170
+
171
+ // Server-side
172
+ serverSide?: boolean;
173
+ loading?: boolean;
174
+ error?: string | boolean;
175
+ /** Fires on mount and whenever the visible range changes — fetch here. */
176
+ onRangeChange?: (range: CalendarRange) => void;
177
+
178
+ // Event interactivity
179
+ /** How an event opens. Default "popover" (experimental). */
180
+ overlayMode?: CalendarOverlayMode;
181
+ /** Override the overlay body for an event. */
182
+ renderEventDetail?: (event: CalendarNormalizedEvent<Event>) => ReactNode;
183
+ /** Always fires on event click, regardless of overlay mode. */
184
+ onEventClick?: (raw: Event, event: CalendarNormalizedEvent<Event>) => void;
185
+
186
+ // Render overrides
187
+ renderToolbar?: (api: CalendarToolbarApi) => ReactNode;
188
+ renderDayCell?: (day: Date, events: CalendarNormalizedEvent<Event>[]) => ReactNode;
189
+ /** Replace the empty state in the agenda view when the visible range has no
190
+ * events. The month / week / day grids always render their grid, so this does
191
+ * not apply there. */
192
+ renderEmptyState?: (ctx: Record<string, never>) => ReactNode;
193
+ renderLoadingState?: (ctx: Record<string, never>) => ReactNode;
194
+ renderErrorState?: (ctx: { error: string | boolean }) => ReactNode;
195
+
196
+ labels?: CalendarLabels;
197
+ }
198
+
199
+ export declare function Calendar<Event = Record<string, unknown>>(
200
+ props: CalendarProps<Event>
201
+ ): ReactElement | null;
@@ -0,0 +1,400 @@
1
+ # common-components
2
+
3
+ Reusable UI wrappers built on top of HubSpot UI Extensions primitives.
4
+
5
+ ## Current components
6
+
7
+ - `AutoStatusTag` — status tag whose variant is inferred from the value
8
+ - `AutoTag` — generic tag with inferred variant + display text
9
+ - `AvatarStack` — overlapping circular avatars (letters or image URLs)
10
+ - `Icon` — superset of HubSpot's native `<Icon>`: custom glyphs, any CSS color, pixel sizes
11
+ - `CrmLookupSelect` — CRM-backed `Select` / `MultiSelect` with live, debounced search
12
+ - `CollectionToolbar`, `CollectionFilterControl`, `ActiveFilterChips`, `CollectionSortSelect`, `CollectionCount` — shared search/filter/sort/count primitives used by DataTable, Kanban, Feed, and Calendar
13
+ - `SectionHeader` — title + optional description row
14
+ - `KeyValueList` — vertical list of label/value rows
15
+ - `StyledText` — SVG-rendered text with rotation, custom colors, pill backgrounds
16
+ - `Spinner` — animated unicode/braille loading indicator
17
+
18
+ Plus utilities + constants:
19
+
20
+ - `makeAvatarStackDataUri`, `makeStyledTextDataUri`, `makeIconDataUri` — low-level builders that return `{ src, width, height }` for composing into larger SVGs
21
+ - `ICONS`, `ICON_NAMES`, `NATIVE_ICON_NAME_LIST`, `svgToIconEntry` — the custom icon registry and helpers behind `Icon`
22
+ - `SPINNERS`, `SPINNER_NAMES` — spinner presets and registry
23
+ - `HS_DATE_PRESETS`, `HS_DATE_DIRECTION_LABELS` — HubSpot's native quick-date preset list
24
+ - `HS_FONT_FAMILY`, `HS_TEXT_COLOR`, `HS_SUBTLE_BG`, `HS_MUTED_TEXT`, `HS_NEUTRAL_CHIP` — style constants matching native HubSpot CSS
25
+
26
+ ## Purpose
27
+
28
+ This folder is for composable visual building blocks.
29
+
30
+ Use `common-components` when the export renders JSX and wraps HubSpot primitives into a reusable display pattern, or when the export is a style-related constant (fonts, colors, preset option lists) that sits alongside those visual wrappers.
31
+
32
+ ## Import paths
33
+
34
+ ```js
35
+ import {
36
+ AutoStatusTag,
37
+ AutoTag,
38
+ AvatarStack,
39
+ Icon,
40
+ CrmLookupSelect,
41
+ CollectionToolbar,
42
+ CollectionSortSelect,
43
+ SectionHeader,
44
+ KeyValueList,
45
+ StyledText,
46
+ Spinner,
47
+ HS_DATE_PRESETS,
48
+ } from "hs-uix/common-components";
49
+ ```
50
+
51
+ Or from the root package:
52
+
53
+ ```js
54
+ import { AvatarStack, Icon, StyledText } from "hs-uix";
55
+ ```
56
+
57
+ ---
58
+
59
+ ## Collection controls
60
+
61
+ The collection controls are the low-level toolbar primitives used internally by `DataTable`, `Kanban`, `Feed`, and `Calendar`. Use them when you are building a custom collection view but want the same search/filter/sort/count UX as the packaged components.
62
+
63
+ ```jsx
64
+ import {
65
+ CollectionToolbar,
66
+ CollectionSortSelect,
67
+ } from "hs-uix/common-components";
68
+ import {
69
+ buildActiveFilterChips,
70
+ resetFilterValues,
71
+ } from "hs-uix/utils";
72
+
73
+ const activeChips = buildActiveFilterChips(filters, filterValues);
74
+
75
+ <CollectionToolbar
76
+ search={{
77
+ name: "deals-search",
78
+ value: search,
79
+ placeholder: "Search deals...",
80
+ onChange: setSearch,
81
+ }}
82
+ filters={{
83
+ items: filters,
84
+ values: filterValues,
85
+ inlineLimit: 2,
86
+ onChange: (name, value) => setFilterValues((prev) => ({ ...prev, [name]: value })),
87
+ }}
88
+ chips={{
89
+ items: activeChips,
90
+ onRemove: (key) => setFilterValues((prev) => resetFilterValues(filters, prev, key)),
91
+ }}
92
+ right={
93
+ <CollectionSortSelect
94
+ value={sort}
95
+ options={sortOptions}
96
+ placeholder="Sort"
97
+ onChange={setSort}
98
+ />
99
+ }
100
+ />
101
+ ```
102
+
103
+ ### Design notes
104
+
105
+ - These primitives are **controlled**. They render controls and emit changes; callers own query state and data filtering.
106
+ - `CollectionToolbar` automatically appends a per-toolbar suffix to child input/select names using React `useId()`. This prevents collisions when multiple tables, boards, feeds, or calendars render in the same extension. Pass `idPrefix` for a stable suffix or `uniqueNames={false}` to opt out.
107
+ - The right-side slot defaults to `alignSelf="end"`, so counts/sort controls sit on the lowest toolbar row. This keeps them aligned with active filter chips when chips are visible.
108
+ - `CollectionToolbar` accepts `leftFlex` / `rightFlex` for view-specific space allocation. Calendar uses a 3/2 split (60/40) by default because its right side contains Today, previous/next, and view controls.
109
+
110
+ ### Shared filter config vocabulary
111
+
112
+ ```js
113
+ {
114
+ name: "stage",
115
+ type: "select", // "select" | "multiselect" | "dateRange"
116
+ label: "Stage",
117
+ placeholder: "All stages",
118
+ chipLabel: "Stage",
119
+ emptyValue: "",
120
+ options: [
121
+ { label: "Open", value: "open" },
122
+ { label: "Closed", value: "closed" },
123
+ ],
124
+ }
125
+ ```
126
+
127
+ `CollectionFilterControl` also supports `includeAll`, `allValue`, `allLabel`, `fromLabel`, and `toLabel` for advanced cases. Prefer `emptyValue` for new filter configs; the library default is `""` for select filters. Feed keeps its legacy `"all"` empty value internally for compatibility, but custom shared configs should generally use `emptyValue: ""`.
128
+
129
+ ---
130
+
131
+ ## AvatarStack
132
+
133
+ 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.
134
+
135
+ ```jsx
136
+ import { AvatarStack } from "hs-uix/common-components";
137
+
138
+ <AvatarStack
139
+ items={["AR", "JK", "SP"]}
140
+ size="medium"
141
+ maxVisible={4}
142
+ overlap={8}
143
+ />
144
+
145
+ // Mixed letters + image URLs
146
+ <AvatarStack
147
+ items={[
148
+ "AR",
149
+ "https://cdn.example.com/photos/jordan.png",
150
+ { letter: "SP", color: "#8B0000" },
151
+ ]}
152
+ />
153
+ ```
154
+
155
+ ### Props
156
+
157
+ | Prop | Type | Default | Description |
158
+ | ---- | ---- | ------- | ----------- |
159
+ | `items` | `(string \| { letter, color, src })[]` | — | Each entry: a letter (2-char initials), an image URL (http(s)/data:image), or an explicit `{letter, color, src}` object. Empty/`null` values are filtered out. |
160
+ | `size` | t-shirt token \| number | `"medium"` | Diameter. Tokens: `xs`/`extra-small` (16), `sm`/`small` (20), `md`/`medium` (24), `lg`/`large` (32), `xl`/`extra-large` (40). Or any pixel number. |
161
+ | `overlap` | number | ~35% of `size` | Pixels each chip overlaps its neighbor. `0` = side-by-side, `size` = fully stacked. Clamped internally. |
162
+ | `step` | number | (derived from `overlap`) | Advanced: explicit center-to-center offset. Overrides `overlap` when set. |
163
+ | `maxVisible` | number | `4` | Cap on visible chips; extras become the `+N` overflow chip. |
164
+ | `colors` | `string[]` | built-in palette | Background palette for letter avatars (picked via char-code hash). |
165
+ | `overflowBg` | string | `HS_NEUTRAL_CHIP` | Background color for the `+N` chip. |
166
+ | `overflowColor` | string | `HS_TEXT_COLOR` | Text color for the `+N` chip. |
167
+ | `fontFamily` | string | `HS_FONT_FAMILY` | CSS font-family for letter initials. |
168
+ | `alt` | string | `"N associated records"` | Accessibility label on the underlying `<Image>`. |
169
+
170
+ ### Low-level builder
171
+
172
+ ```js
173
+ import { makeAvatarStackDataUri } from "hs-uix/common-components";
174
+
175
+ const { src, width, height } = makeAvatarStackDataUri(items, { size: "sm", overlap: 6 });
176
+ // → paint anywhere an <Image> is valid
177
+ ```
178
+
179
+ Returns `null` when `items` resolves to zero valid entries — callers can unconditionally render without guarding.
180
+
181
+ **Image-URL caveat:** SVG `<image>` loads external assets via the browser's fetcher; the host must serve CORS-friendly headers. HubSpot-served avatars and most CDN hosts work; self-hosted images behind restricted CORS may not paint.
182
+
183
+ ---
184
+
185
+ ## Icon
186
+
187
+ A drop-in superset of HubSpot's native `<Icon>`. The native component is great but boxed in three ways: a fixed `name` whitelist, only 4 colors (`inherit` / `alert` / `warning` / `success`), and only 3 sizes (`small` / `medium` / `large`). `Icon` lifts all three.
188
+
189
+ When a request is **fully native-expressible** (a whitelisted `name`, a semantic `color`, and an `sm`/`md`/`lg` `size`) it **delegates to the real `<Icon>`** — so you keep native auto-sizing, real `color="inherit"`, and proper screen-reader semantics. Otherwise it falls back to rendering a registered SVG glyph as a data-URI `<Image>`, which is what unlocks custom glyphs, arbitrary colors, and pixel sizes.
190
+
191
+ ```jsx
192
+ import { Icon } from "hs-uix/common-components";
193
+
194
+ // Native-expressible → delegates to HubSpot's <Icon>
195
+ <Icon name="email" size="md" color="inherit" />
196
+
197
+ // Custom glyph + arbitrary color + pixel size → SVG fallback
198
+ <Icon name="AdvancedFilters" color="#516f90" size={20} />
199
+
200
+ // Semantic color on a custom glyph
201
+ <Icon name="trophy" color="success" size="lg" screenReaderText="Top performer" />
202
+ ```
203
+
204
+ ### Props
205
+
206
+ | Prop | Type | Default | Description |
207
+ | ---- | ---- | ------- | ----------- |
208
+ | `name` | string | — | A registered glyph name — native (see `NATIVE_ICON_NAME_LIST`) or custom (see `ICON_NAMES`). An unknown, non-native name renders nothing. |
209
+ | `color` | string | `"inherit"` | A semantic token (`inherit` / `alert` / `warning` / `success`) or **any CSS color** (e.g. `#516f90`). Non-semantic colors force the SVG fallback. |
210
+ | `size` | t-shirt token \| number | `"md"` | `xs`/`extra-small` (12), `sm`/`small` (14), `md`/`medium` (16), `lg`/`large` (20), `xl`/`extra-large` (24), or a raw pixel number. Only `sm`/`md`/`lg` stay on the native path. |
211
+ | `screenReaderText` | string | `name` | Accessible label. On the fallback path it becomes the `<Image alt>`. |
212
+
213
+ ### Custom glyphs & helpers
214
+
215
+ `ICONS` is the bundled custom-glyph registry (~248 glyphs scraped from HubSpot's web app); `ICON_NAMES` are its keys and `NATIVE_ICON_NAME_LIST` is the native whitelist. To use a glyph the native component doesn't expose, add it to the registry — or build one from a copied `<svg>`:
216
+
217
+ ```js
218
+ import { makeIconDataUri, svgToIconEntry } from "hs-uix/common-components";
219
+
220
+ // Build a data URI directly (returns { src, width, height }, or null for an unknown name)
221
+ const { src, width, height } = makeIconDataUri("AdvancedFilters", { size: 20, color: "#516f90" });
222
+
223
+ // Turn a raw <svg> string into a registry entry (drops <mask>/<defs> and `currentColor`
224
+ // fills so `color` can recolor it; keeps explicit fills / fill-rules for multi-color glyphs)
225
+ const entry = svgToIconEntry('<svg viewBox="0 0 24 24"><path d="…" /></svg>');
226
+ ```
227
+
228
+ **Fallback caveat:** a data-URI glyph can't inherit `currentColor`, so a fallback `Icon` won't auto-match surrounding text color — pass `color` explicitly. For multi-color glyphs, give individual paths their own `fill` in the registry entry; a single `color` prop only recolors paths that don't declare one.
229
+
230
+ ---
231
+
232
+ ## StyledText
233
+
234
+ Drop-in enhancement over HubSpot's `<Text>` for cases native `<Text>` can't express — rotation, custom colors, pill backgrounds, specific font sizes. Rendered as an inline-SVG data URI through `<Image>`.
235
+
236
+ Accepts the same `variant` / `format` props as HubSpot's `<Text>` so existing usage patterns carry over; adds SVG-only extras.
237
+
238
+ ```jsx
239
+ import { StyledText } from "hs-uix/common-components";
240
+
241
+ // Rotated column-header label in a collapsed rail
242
+ <StyledText
243
+ text="Pricing Complete"
244
+ variant="bodytext"
245
+ format={{ fontWeight: "demibold" }}
246
+ orientation="vertical-down"
247
+ />
248
+
249
+ // Pill-wrapped count indicator
250
+ <StyledText
251
+ text="339"
252
+ variant="microcopy"
253
+ format={{ fontWeight: "demibold" }}
254
+ background={{ preset: "tag" }}
255
+ />
256
+
257
+ // Custom color (native <Text> can't do this)
258
+ <StyledText text="High priority" color="#f2545b" format={{ fontWeight: "bold" }} />
259
+
260
+ // Semantic tag colors with HubSpot-style tag chrome
261
+ <StyledText
262
+ text="At risk"
263
+ variant="microcopy"
264
+ format={{ fontWeight: "demibold" }}
265
+ background={{ preset: "tag", variant: "warning" }}
266
+ />
267
+ ```
268
+
269
+ ### Props
270
+
271
+ | Prop | Type | Default | Description |
272
+ | ---- | ---- | ------- | ----------- |
273
+ | `text` / `children` | string | — | The text to render. `text` prop or a string child. |
274
+ | `variant` | `"bodytext"` \| `"microcopy"` | `"bodytext"` | Size preset. `bodytext` → 14px, `microcopy` → 12px. Matches HubSpot's native CSS. |
275
+ | `format` | object | `{}` | `{ fontWeight: "bold" \| "demibold" \| "regular", italic, lineDecoration: "underline" \| "strikethrough", textTransform: "uppercase" \| "lowercase" \| "capitalize" \| "sentenceCase" }`. Same shape as HubSpot's `<Text format>`. |
276
+ | `orientation` | `"horizontal"` \| `"vertical-up"` \| `"vertical-down"` \| number | `"horizontal"` | Rotation. Number = custom degrees. |
277
+ | `color` | string | `HS_TEXT_COLOR` | Glyph color. Native `<Text>` can't override color; this can. |
278
+ | `background` | `{ preset, variant, color, textColor, borderColor, borderWidth, radius, paddingX, paddingY, height, fontSize, canvasPaddingX, canvasPaddingY }` | — | Optional pill behind the text. `preset: "tag"` uses the native HubSpot `Tag` component for plain horizontal tags, and falls back to SVG only for rotated/custom cases. `variant` supports `default`, `success`, `warning`, `error`/`danger`, and `info`. |
279
+ | `fontFamily` | string | `HS_FONT_FAMILY` | CSS font-family string. |
280
+ | `fontSize` | number | (from `variant`) | Override the computed font size. |
281
+ | `paddingX`, `paddingY` | number | `4, 2` | Canvas padding. |
282
+ | `width`, `height` | number | auto | Override computed canvas dimensions (useful for custom rotation angles). |
283
+ | `alt` | string | `text` | Accessibility label on the underlying `<Image>`. |
284
+
285
+ ### Low-level builder
286
+
287
+ ```js
288
+ import { makeStyledTextDataUri } from "hs-uix/common-components";
289
+
290
+ const { src, width, height } = makeStyledTextDataUri("Sort", {
291
+ variant: "microcopy",
292
+ orientation: "vertical-down",
293
+ });
294
+ ```
295
+
296
+ ### ⚠️ Selection caveat
297
+
298
+ Text rendered through `<Image>` as a data URI is **not user-selectable** — glyphs live inside a rasterized image boundary, not the DOM tree. If the text needs to be selectable/copyable, use the native `<Text>` component instead. `StyledText` is for cases where you need visual effects `<Text>` can't provide.
299
+
300
+ For `background={{ preset: "tag" }}` specifically: plain horizontal tags now render through native HubSpot `Tag` so they match the platform exactly. The SVG path is still used when you rotate the tag or override the tag chrome.
301
+
302
+ ---
303
+
304
+ ## CrmLookupSelect
305
+
306
+ A CRM-backed `Select` (or `MultiSelect` when `multiple`) that searches live as the user types. It wraps HubSpot's CRM search so you point it at an object type and properties and get a debounced, paginated lookup — no manual data-source wiring.
307
+
308
+ ![CrmLookupSelect live search](https://raw.githubusercontent.com/05bmckay/hs-uix/main/src/common-components/assets/crmLookUp.gif)
309
+
310
+ A picked option stays valid after the live results change (the component remembers selected options internally), it shows `loadingOption` during the debounce window — not just the in-flight request — and only shows `noResultsOption` once a query has settled, so it never flashes "no results" while you're still typing. It fetches the first `pageLength` matches for each query; for custom lookup UIs that need native cursor controls, use `useCrmSearchOptions`, which exposes `pagination` / `hasMore` from HubSpot's fixed `useCrmSearch` response.
311
+
312
+ ```jsx
313
+ import { CrmLookupSelect } from "hs-uix/common-components";
314
+
315
+ <CrmLookupSelect
316
+ objectType="contact"
317
+ properties={["firstname", "lastname", "email"]}
318
+ label="Primary contact"
319
+ value={contactId}
320
+ onChange={setContactId}
321
+ labelProperty={(r) => `${r.firstname} ${r.lastname}`}
322
+ valueProperty="hs_object_id"
323
+ descriptionProperty="email"
324
+ />
325
+ ```
326
+
327
+ ### Props
328
+
329
+ | Prop | Type | Default | Description |
330
+ | ---- | ---- | ------- | ----------- |
331
+ | `objectType` | string | — | CRM object to search (`"contact"`, `"company"`, `"deal"`, or any object type id/name). |
332
+ | `properties` | `string[]` | — | Properties to fetch and search across. |
333
+ | `value` / `onChange` | value \| `(value) => void` | — | Controlled selected value(s). |
334
+ | `multiple` | boolean | `false` | Render a `MultiSelect` and allow multiple picks. |
335
+ | `labelProperty` / `valueProperty` / `descriptionProperty` | string \| `(row) => unknown` | — | How to derive each option's label / value / description from a record. |
336
+ | `option` | object | — | Advanced mapping: `{ label, value, description, fallbackLabel, mapOption }` for full control over option shape. |
337
+ | `debounce` | number | — | Milliseconds to debounce the search query. |
338
+ | `minSearchLength` | number | — | Minimum query length before searching. |
339
+ | `pageLength` | number | — | Results fetched per query. |
340
+ | `loadingOption` / `noResultsOption` | option | — | Placeholder options shown while debouncing/loading and after an empty settled query. |
341
+ | `query` / `onSearchChange` | string \| `(q) => void` | — | Controlled search query. |
342
+ | `variant` | `"transparent"` \| `"input"` | — | Visual variant passed to the underlying select. |
343
+ | `placeholder`, `description`, `tooltip`, `required`, `readOnly`, `error`, `validationMessage` | — | — | Standard field props forwarded to the native select. |
344
+
345
+ ---
346
+
347
+ ## HS_DATE_PRESETS
348
+
349
+ 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.
350
+
351
+ ```jsx
352
+ import { HS_DATE_PRESETS } from "hs-uix/common-components";
353
+
354
+ filters={[
355
+ {
356
+ name: "createDate",
357
+ type: "select",
358
+ placeholder: "Create date",
359
+ chipLabel: "Created",
360
+ options: HS_DATE_PRESETS,
361
+ },
362
+ ]}
363
+ ```
364
+
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`).
366
+
367
+ Also exports `HS_DATE_DIRECTION_LABELS` (`{ asc: "Ascending", desc: "Descending" }`) for pairing with direction-specific sort UIs.
368
+
369
+ ---
370
+
371
+ ## Style constants (svgDefaults)
372
+
373
+ Raw style tokens used internally by `StyledText` and `AvatarStack` so they match the rest of HubSpot's UI. Exported so consumers can reuse them when composing their own SVG/data-URI visuals.
374
+
375
+ | Export | Value | Use |
376
+ | ------ | ----- | --- |
377
+ | `HS_FONT_FAMILY` | `"Lexend Deca", Helvetica, Arial, sans-serif` | All SVG text |
378
+ | `HS_TEXT_COLOR` | `#33475b` | Primary body text |
379
+ | `HS_SUBTLE_BG` | `#F5F8FA` | Tag `variant="subtle"` background |
380
+ | `HS_MUTED_TEXT` | `#7C98B6` | Secondary / microcopy gray |
381
+ | `HS_NEUTRAL_CHIP` | `#CBD6E2` | Neutral chip background (`+N` overflow) |
382
+ | `HS_TAG_SUBTLE_BORDER` | — | Border color for subtle-variant tag pills |
383
+ | `HS_TAG_TEXT_COLOR` | — | Text color inside tag pills |
384
+ | `HS_TAG_FONT_SIZE` | — | Font size (px) for tag pill text |
385
+ | `HS_TAG_LINE_HEIGHT` | — | Line height (px) for tag pill text |
386
+ | `HS_TAG_PADDING_X` / `HS_TAG_PADDING_Y` | — | Horizontal / vertical padding inside tag pills |
387
+ | `HS_TAG_BORDER_RADIUS` | — | Corner radius for tag pills |
388
+ | `HS_TAG_BORDER_WIDTH` | — | Border width for tag pills |
389
+ | `DEFAULT_SVG_FONT_WEIGHT` | `600` | Default demibold weight inside SVG text |
390
+
391
+ The `HS_TAG_*` constants mirror the computed styles of HubSpot's native `<Tag>` so `StyledText` can draw pixel-matching pill backgrounds when the SVG fallback is needed.
392
+
393
+ ---
394
+
395
+ ## Guidelines
396
+
397
+ - Keep components thin and composable
398
+ - Prefer wrapping native HubSpot primitives over inventing new abstractions
399
+ - Reach for `StyledText` only when native `<Text>` can't do what you need (rotation, custom color, pill background) — selection/copy-paste breaks with SVG-as-image
400
+ - Put non-visual helper logic in `src/utils/`