@crisp-ui-kit/crisp 0.53.5 → 0.54.1

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 (124) hide show
  1. package/dist/activityfeed.css +12 -0
  2. package/dist/activityfeed.d.ts +7 -0
  3. package/dist/activityfeed.layered.css +14 -0
  4. package/dist/badge.css +3 -0
  5. package/dist/badge.d.ts +2 -2
  6. package/dist/badge.layered.css +3 -0
  7. package/dist/cjs/components/badge/badge.recipe.js +10 -0
  8. package/dist/cjs/components/bulkbar/bulkbar.js +5 -1
  9. package/dist/cjs/components/fileupload/fileupload.js +19 -3
  10. package/dist/cjs/components/formfield/formfield.js +8 -1
  11. package/dist/cjs/components/heatmapchart/heatmapchart.js +14 -2
  12. package/dist/cjs/components/skiplink/index.js +17 -0
  13. package/dist/cjs/components/skiplink/skiplink.js +16 -0
  14. package/dist/cjs/components/skiplink/skiplink.recipe.js +33 -0
  15. package/dist/cjs/components/textlink/index.js +7 -0
  16. package/dist/cjs/components/textlink/textlink.js +16 -0
  17. package/dist/cjs/components/textlink/textlink.recipe.js +35 -0
  18. package/dist/cjs/components/visuallyhidden/index.js +17 -0
  19. package/dist/cjs/components/visuallyhidden/visuallyhidden.js +16 -0
  20. package/dist/cjs/components/visuallyhidden/visuallyhidden.recipe.js +20 -0
  21. package/dist/cjs/index.js +5 -0
  22. package/dist/cjs/product/activityfeed/activityfeed.js +38 -0
  23. package/dist/cjs/product/activityfeed/activityfeed.recipe.js +35 -0
  24. package/dist/cjs/product/activityfeed/index.js +10 -0
  25. package/dist/cjs/product/board/board.js +11 -2
  26. package/dist/cjs/product/command/command.js +13 -1
  27. package/dist/cjs/product/flowcanvas/flowcanvas.js +9 -2
  28. package/dist/cjs/product/headerbar/headerbar.js +15 -0
  29. package/dist/cjs/product/headerbar/headerbar.recipe.js +35 -0
  30. package/dist/cjs/product/headerbar/index.js +17 -0
  31. package/dist/cjs/product/inbox/inbox.js +13 -1
  32. package/dist/cjs/product/onboarding/onboarding.js +22 -1
  33. package/dist/cjs/product/recordpage/index.js +2 -1
  34. package/dist/cjs/product/recordpage/recordpage.js +197 -123
  35. package/dist/cjs/product/recordpage/recordpage.recipe.js +5 -0
  36. package/dist/cjs/product/richtext/richtext.js +43 -0
  37. package/dist/cjs/product/tasklist/tasklist.js +8 -1
  38. package/dist/cjs/tokens/palette.js +14 -0
  39. package/dist/cjs/tokens/semantic.js +5 -0
  40. package/dist/esm/components/badge/badge.recipe.js +10 -0
  41. package/dist/esm/components/bulkbar/bulkbar.js +5 -1
  42. package/dist/esm/components/fileupload/fileupload.js +20 -4
  43. package/dist/esm/components/formfield/formfield.js +8 -1
  44. package/dist/esm/components/heatmapchart/heatmapchart.js +14 -2
  45. package/dist/esm/components/skiplink/index.js +1 -0
  46. package/dist/esm/components/skiplink/skiplink.js +13 -0
  47. package/dist/esm/components/skiplink/skiplink.recipe.js +30 -0
  48. package/dist/esm/components/textlink/index.js +2 -0
  49. package/dist/esm/components/textlink/textlink.js +13 -0
  50. package/dist/esm/components/textlink/textlink.recipe.js +32 -0
  51. package/dist/esm/components/visuallyhidden/index.js +1 -0
  52. package/dist/esm/components/visuallyhidden/visuallyhidden.js +13 -0
  53. package/dist/esm/components/visuallyhidden/visuallyhidden.recipe.js +17 -0
  54. package/dist/esm/index.js +5 -0
  55. package/dist/esm/product/activityfeed/activityfeed.js +32 -0
  56. package/dist/esm/product/activityfeed/activityfeed.recipe.js +32 -0
  57. package/dist/esm/product/activityfeed/index.js +2 -0
  58. package/dist/esm/product/board/board.js +12 -3
  59. package/dist/esm/product/command/command.js +14 -2
  60. package/dist/esm/product/flowcanvas/flowcanvas.js +9 -2
  61. package/dist/esm/product/headerbar/headerbar.js +12 -0
  62. package/dist/esm/product/headerbar/headerbar.recipe.js +32 -0
  63. package/dist/esm/product/headerbar/index.js +1 -0
  64. package/dist/esm/product/inbox/inbox.js +13 -1
  65. package/dist/esm/product/onboarding/onboarding.js +22 -1
  66. package/dist/esm/product/recordpage/index.js +1 -1
  67. package/dist/esm/product/recordpage/recordpage.js +197 -124
  68. package/dist/esm/product/recordpage/recordpage.recipe.js +5 -0
  69. package/dist/esm/product/richtext/richtext.js +43 -0
  70. package/dist/esm/product/tasklist/tasklist.js +8 -1
  71. package/dist/esm/tokens/palette.js +14 -0
  72. package/dist/esm/tokens/semantic.js +5 -0
  73. package/dist/headerbar.css +7 -0
  74. package/dist/headerbar.d.ts +7 -0
  75. package/dist/headerbar.layered.css +9 -0
  76. package/dist/native/tokens.d.ts +1 -1
  77. package/dist/native/tokens.js +16 -4
  78. package/dist/recordpage.css +17 -31
  79. package/dist/recordpage.layered.css +17 -31
  80. package/dist/skiplink.css +5 -0
  81. package/dist/skiplink.d.ts +7 -0
  82. package/dist/skiplink.layered.css +7 -0
  83. package/dist/styles.css +69 -31
  84. package/dist/styles.layered.css +69 -31
  85. package/dist/textlink.css +11 -0
  86. package/dist/textlink.d.ts +7 -0
  87. package/dist/textlink.layered.css +13 -0
  88. package/dist/tokens.css +6 -0
  89. package/dist/tokens.json +16 -4
  90. package/dist/types/components/badge/badge.d.ts +2 -0
  91. package/dist/types/components/badge/badge.recipe.d.ts +8 -0
  92. package/dist/types/components/bulkbar/bulkbar.d.ts +1 -1
  93. package/dist/types/components/heatmapchart/heatmapchart.d.ts +1 -1
  94. package/dist/types/components/skiplink/index.d.ts +1 -0
  95. package/dist/types/components/skiplink/skiplink.d.ts +18 -0
  96. package/dist/types/components/skiplink/skiplink.recipe.d.ts +3 -0
  97. package/dist/types/components/textlink/index.d.ts +2 -0
  98. package/dist/types/components/textlink/textlink.d.ts +19 -0
  99. package/dist/types/components/textlink/textlink.recipe.d.ts +13 -0
  100. package/dist/types/components/visuallyhidden/index.d.ts +1 -0
  101. package/dist/types/components/visuallyhidden/visuallyhidden.d.ts +15 -0
  102. package/dist/types/components/visuallyhidden/visuallyhidden.recipe.d.ts +3 -0
  103. package/dist/types/index.d.ts +5 -0
  104. package/dist/types/product/activityfeed/activityfeed.d.ts +57 -0
  105. package/dist/types/product/activityfeed/activityfeed.recipe.d.ts +3 -0
  106. package/dist/types/product/activityfeed/index.d.ts +2 -0
  107. package/dist/types/product/board/board.d.ts +1 -1
  108. package/dist/types/product/command/command.d.ts +1 -1
  109. package/dist/types/product/flowcanvas/flowcanvas.d.ts +1 -1
  110. package/dist/types/product/headerbar/headerbar.d.ts +27 -0
  111. package/dist/types/product/headerbar/headerbar.recipe.d.ts +3 -0
  112. package/dist/types/product/headerbar/index.d.ts +1 -0
  113. package/dist/types/product/inbox/inbox.d.ts +1 -1
  114. package/dist/types/product/onboarding/onboarding.d.ts +1 -1
  115. package/dist/types/product/recordpage/index.d.ts +1 -1
  116. package/dist/types/product/recordpage/recordpage.d.ts +90 -1
  117. package/dist/types/product/tasklist/tasklist.d.ts +1 -1
  118. package/dist/types/tokens/contract.d.ts +3 -0
  119. package/dist/types/tokens/palette.d.ts +12 -0
  120. package/dist/types/tokens/semantic.d.ts +5 -0
  121. package/dist/visuallyhidden.css +3 -0
  122. package/dist/visuallyhidden.d.ts +7 -0
  123. package/dist/visuallyhidden.layered.css +5 -0
  124. package/package.json +26 -1
@@ -0,0 +1,12 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { headerbarRecipe } from "./headerbar.recipe.js";
3
+ /**
4
+ * A page-level header row: an optional leading slot, a flexible title/
5
+ * breadcrumb region, and a trailing action cluster, on the measured 48px
6
+ * bar Attio uses for both its page-header and its view-bar row. See the
7
+ * recipe's own doc comment for the live measurement (crisp#462).
8
+ */
9
+ export function HeaderBar({ leading, title, trailing, bordered = true, className, ...rest }) {
10
+ return (_jsxs("div", { className: headerbarRecipe({ className }), "data-bordered": bordered ? "" : undefined, ...rest, children: [leading ? (_jsx("span", { className: "crisp-headerbar-leading", children: leading })) : null, title ? _jsx("div", { className: "crisp-headerbar-title", children: title }) : null, trailing ? (_jsx("span", { className: "crisp-headerbar-trailing", children: trailing })) : null] }));
11
+ }
12
+ HeaderBar.displayName = "HeaderBar";
@@ -0,0 +1,32 @@
1
+ import { defineRecipe, resolveRecipe } from "../../core/index.js";
2
+ import { contract as c } from "../../tokens/index.js";
3
+ // A page-level header row — measured live at app.attio.com/techpartners/companies
4
+ // against two stacked bars: the top utility bar (breadcrumb + trailing icon
5
+ // actions, e.g. search/notifications) and the view bar directly under it
6
+ // ("All Companies" view name + tabs + "New Company"). Both share the same
7
+ // 48px content height and `0 ${space3}` padding; only the outer (first) bar
8
+ // carries the divider -- measured `border-bottom: 1px solid rgb(238,239,241)`
9
+ // (`borderSubtle`/`line100`, exact match) on the utility bar, no border of
10
+ // its own on the view bar directly beneath it. `bordered` (default true)
11
+ // lets a consumer that stacks two HeaderBars turn it off on the inner one,
12
+ // the way GRC's own AppLayout UtilityBar + Page viewBar do today with two
13
+ // hand-rolled, drifting copies of the same 49px bar (crisp#462 GRC audit,
14
+ // crisp#527).
15
+ //
16
+ // No bell, no breadcrumb, no search box baked in -- those are the `leading`/
17
+ // `title`/`trailing` slot content a consumer supplies. `size.header` (48px)
18
+ // is reused rather than a new token, since it's the same "workspace-switcher
19
+ // header height" measured elsewhere in the sidebar.
20
+ const config = defineRecipe({
21
+ name: "headerbar",
22
+ base: `box-sizing: border-box; display: flex; align-items: center; justify-content: space-between; gap: ${c.space["2"]}; height: ${c.size.header}; flex-shrink: 0; padding: 0 ${c.space["3"]}; font-family: ${c.font.sans}; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
23
+ &[data-bordered] { border-bottom: 1px solid ${c.borderSubtle}; }
24
+ & .crisp-headerbar-leading { display: flex; align-items: center; gap: ${c.space["2"]}; flex-shrink: 0; min-width: 0; }
25
+ & .crisp-headerbar-title { flex: 1; min-width: 0; display: flex; align-items: center; gap: ${c.space["2"]}; overflow: hidden; }
26
+ & .crisp-headerbar-trailing { display: flex; align-items: center; gap: ${c.space["2"]}; flex-shrink: 0; }`,
27
+ variants: {},
28
+ defaultVariants: {},
29
+ compoundVariants: [],
30
+ });
31
+ export const headerbarRecipe = resolveRecipe(config);
32
+ export default config;
@@ -0,0 +1 @@
1
+ export * from "./headerbar.js";
@@ -2,6 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Bell } from "lucide-react";
3
3
  import * as React from "react";
4
4
  import { useTablistKeyboard } from "../../components/tabs/index.js";
5
+ import { useCrispMessage, useCrispMessages } from "../../core/index.js";
5
6
  import { inboxRecipe } from "./inbox.recipe.js";
6
7
  /**
7
8
  * A notifications inbox panel — measured from a notification flyout. A
@@ -9,8 +10,19 @@ import { inboxRecipe } from "./inbox.recipe.js";
9
10
  * (avatar + message + time + unread dot) or a centred empty state. Put it inside
10
11
  * a Popover for the flyout.
11
12
  */
12
- export function Inbox({ notifications = [], title = "Notifications", tabs, activeTab, onTabChange, emptyTitle = "No notifications", emptySubtitle = "Notifications such as mentions and important updates will be displayed here.", emptyAction, tabsLabel = "Notification views", className, ...rest }) {
13
+ export function Inbox({ notifications = [], title: titleProp, tabs, activeTab, onTabChange, emptyTitle: emptyTitleProp, emptySubtitle: emptySubtitleProp, emptyAction, tabsLabel: tabsLabelProp, className, ...rest }) {
13
14
  const listRef = React.useRef(null);
15
+ // crisp/#471: `title`/`emptyTitle`/`emptySubtitle` are typed
16
+ // `React.ReactNode`, so they resolve by hand rather than through
17
+ // `useCrispMessage`, which is string-only — same reasoning as DataTable's
18
+ // own `empty`.
19
+ const crispMessages = useCrispMessages();
20
+ const title = titleProp ?? crispMessages.Notifications ?? "Notifications";
21
+ const emptyTitle = emptyTitleProp ?? crispMessages["No notifications"] ?? "No notifications";
22
+ const emptySubtitle = emptySubtitleProp ??
23
+ crispMessages["Notifications such as mentions and important updates will be displayed here."] ??
24
+ "Notifications such as mentions and important updates will be displayed here.";
25
+ const tabsLabel = useCrispMessage(tabsLabelProp, "Notification views");
14
26
  // crisp's shared tablist model, the one `Tabs` runs (#195). Hand-rolling a
15
27
  // second one is how Inbox ended up with no arrow keys, no roving tabindex
16
28
  // and no name while `Tabs` had all three.
@@ -1,5 +1,6 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Check, ChevronDown, MoreHorizontal } from "lucide-react";
3
+ import { useCrispMessage, useCrispMessages } from "../../core/index.js";
3
4
  import { onboardingRecipe } from "./onboarding.recipe.js";
4
5
  /**
5
6
  * The English defaults for the two pieces of copy that are formatters rather
@@ -15,9 +16,29 @@ export const onboardingLabels = {
15
16
  * started" widget. Title + optional menu/minimize, a segmented progress bar,
16
17
  * and step rows (completed rows strike through with a brand check).
17
18
  */
18
- export function OnboardingChecklist({ title = "Getting started", steps, encouragement = "Keep it up!", onStepClick, onMenu, onMinimize, className, optionsLabel = "Checklist options", minimizeLabel = "Minimize", progressLabel = "Onboarding progress", progressText = onboardingLabels.progressText, completedStepLabel = onboardingLabels.completedStepLabel, ...rest }) {
19
+ export function OnboardingChecklist({ title: titleProp, steps, encouragement: encouragementProp, onStepClick, onMenu, onMinimize, className, optionsLabel: optionsLabelProp, minimizeLabel: minimizeLabelProp, progressLabel: progressLabelProp, progressText: progressTextProp, completedStepLabel: completedStepLabelProp, ...rest }) {
19
20
  const total = steps.length;
20
21
  const done = steps.filter((s) => s.done).length;
22
+ // crisp/#471: same resolution order as every other component's own —
23
+ // explicit prop, then a CrispMessagesProvider's translation of the English
24
+ // default, then that default itself. `title`/`encouragement` are typed
25
+ // `React.ReactNode` (a caller can pass a translated element), so they
26
+ // resolve by hand rather than through `useCrispMessage`, which is
27
+ // string-only — same reasoning as DataTable's own `empty`.
28
+ const crispMessages = useCrispMessages();
29
+ const title = titleProp ?? crispMessages["Getting started"] ?? "Getting started";
30
+ const encouragement = encouragementProp ?? crispMessages["Keep it up!"] ?? "Keep it up!";
31
+ const optionsLabel = useCrispMessage(optionsLabelProp, "Checklist options");
32
+ const minimizeLabel = useCrispMessage(minimizeLabelProp, "Minimize");
33
+ const progressLabel = useCrispMessage(progressLabelProp, "Onboarding progress");
34
+ // `progressText`/`completedStepLabel` are formatters, not strings (the
35
+ // count moves within the sentence between languages, #216) — each of
36
+ // their own static fragments gets its own lookup, same as
37
+ // TableToolbar's "Sorted by" (crisp/#217).
38
+ const progressText = progressTextProp ??
39
+ ((doneCount, totalCount) => (_jsxs(_Fragment, { children: [_jsxs("b", { children: [doneCount, "/", totalCount] }), " ", crispMessages["steps completed"] ?? "steps completed"] })));
40
+ const completedStepLabel = completedStepLabelProp ??
41
+ ((label) => `${String(label)} ${crispMessages["— completed"] ?? "— completed"}`);
21
42
  return (_jsxs("div", { className: onboardingRecipe({ className }), ...rest, children: [_jsxs("div", { className: "crisp-onboarding-head", children: [_jsx("span", { className: "crisp-onboarding-title", children: title }), onMenu && (_jsx("button", { type: "button", className: "crisp-onboarding-iconbtn", "aria-label": optionsLabel, onClick: onMenu, children: _jsx(MoreHorizontal, { size: 16, strokeWidth: 1.75 }) })), onMinimize && (_jsx("button", { type: "button", className: "crisp-onboarding-iconbtn", "aria-label": minimizeLabel, onClick: onMinimize, children: _jsx(ChevronDown, { size: 16, strokeWidth: 1.75 }) }))] }), _jsxs("span", { className: "crisp-onboarding-sub", children: [progressText(done, total), encouragement ? _jsxs(_Fragment, { children: [" \u00B7 ", encouragement] }) : null] }), _jsx("div", { className: "crisp-onboarding-progress", role: "progressbar", "aria-valuenow": done, "aria-valuemin": 0, "aria-valuemax": total, "aria-label": progressLabel, children: Array.from({ length: total }, (_, i) => (_jsx("span", { className: "crisp-onboarding-seg", "data-done": i < done ? "" : undefined }, i))) }), _jsx("div", { className: "crisp-onboarding-steps", children: steps.map((s) => (_jsxs("button", { type: "button", className: "crisp-onboarding-step", "data-done": s.done ? "" : undefined, "aria-label": s.done ? completedStepLabel(s.label) : undefined, disabled: s.done, onClick: s.done ? undefined : () => onStepClick?.(s.id), children: [_jsx("span", { className: "crisp-onboarding-step-icon", children: s.icon }), _jsx("span", { className: "crisp-onboarding-step-label", children: s.label }), _jsx("span", { className: "crisp-onboarding-step-check", children: s.done && _jsx(Check, { size: 12, strokeWidth: 3 }) })] }, s.id))) })] }));
22
43
  }
23
44
  OnboardingChecklist.displayName = "OnboardingChecklist";
@@ -1,2 +1,2 @@
1
- export { RecordPage, recordPageLabels, } from "./recordpage.js";
1
+ export { RecordDetails, RecordPage, recordPageLabels, } from "./recordpage.js";
2
2
  export { recordpageRecipe } from "./recordpage.recipe.js";
@@ -8,7 +8,7 @@ import { Select } from "../../components/select/index.js";
8
8
  import { Spinner } from "../../components/spinner/index.js";
9
9
  import { Switch } from "../../components/switch/index.js";
10
10
  import { Tabs } from "../../components/tabs/index.js";
11
- import { isRtlElement, useCrispComponentDefaults } from "../../core/index.js";
11
+ import { cx, isRtlElement, useCrispComponentDefaults } from "../../core/index.js";
12
12
  import { editableInputType, tagTone, useEditableField, } from "../recordstable/index.js";
13
13
  import { recordpageRecipe } from "./recordpage.recipe.js";
14
14
  function valueEmpty(v) {
@@ -87,7 +87,42 @@ export const recordPageLabels = {
87
87
  taggedCategories: (count) => (_jsxs(_Fragment, { children: ["Tagged ", _jsx("b", { children: count }), " categories"] })),
88
88
  recordCreated: (name) => (_jsxs(_Fragment, { children: [_jsx("b", { children: name }), " record was created"] })),
89
89
  noEmailsWith: (name) => _jsxs(_Fragment, { children: ["No emails logged with ", name, " yet."] }),
90
+ noFieldsMatch: (query) => _jsxs(_Fragment, { children: ["No fields match \u201C", query, "\u201D."] }),
90
91
  };
92
+ /**
93
+ * crisp/#471: a `CrispMessagesProvider`'s `componentDefaults.recordPage` sits
94
+ * BETWEEN the built-in English defaults and an instance's own explicit
95
+ * `labels` prop -- a provider translates every `RecordPage`/`RecordDetails`
96
+ * on the page at once, and a caller who still passes `labels` for one
97
+ * instance keeps overriding it there. Not `useCrispMessage`: several of
98
+ * these entries (`domainSetTo`, `noFieldsMatch`, …) are formatter FUNCTIONS,
99
+ * which a flat string dictionary can't hold. Shared by `RecordPage` and
100
+ * `RecordDetails` (#528) -- both merge the same three layers the same way,
101
+ * so this is the one place that merge is written, not two copies of it.
102
+ */
103
+ function useRecordPageLabels(labels) {
104
+ const providerLabels = useCrispComponentDefaults("recordPage");
105
+ return React.useMemo(() => ({
106
+ ...recordPageLabels,
107
+ ...providerLabels,
108
+ ...labels,
109
+ tabs: {
110
+ ...recordPageLabels.tabs,
111
+ ...providerLabels?.tabs,
112
+ ...labels?.tabs,
113
+ },
114
+ highlightLabels: {
115
+ ...recordPageLabels.highlightLabels,
116
+ ...providerLabels?.highlightLabels,
117
+ ...labels?.highlightLabels,
118
+ },
119
+ highlightEmpty: {
120
+ ...recordPageLabels.highlightEmpty,
121
+ ...providerLabels?.highlightEmpty,
122
+ ...labels?.highlightEmpty,
123
+ },
124
+ }), [labels, providerLabels]);
125
+ }
91
126
  /** `Select` owns its own closed-by-default `open` state with no prop or
92
127
  * imperative way to start it open -- the only thing that opens it is a real
93
128
  * click on its trigger button. Mounting one on click (rather than rendering
@@ -143,6 +178,16 @@ function TagsValue({ values }) {
143
178
  }
144
179
  function FieldValue({ col, row, labels, }) {
145
180
  const v = row[col.key];
181
+ // `render` (#528) -- the same full-custom-content escape hatch
182
+ // `RecordsTable`'s own columns already have (recordstable.tsx's own doc:
183
+ // "an escape hatch for a display crisp can't express via type"), reused
184
+ // here so a value this component has no built-in shape for (a reference
185
+ // field's link to another record, say) doesn't need a new `type`/`editor`
186
+ // of its own. Checked before the empty check -- like `RecordsTable`'s own
187
+ // `render`, it owns the cell's content fully, including what "empty"
188
+ // looks like for that column.
189
+ if (col.render)
190
+ return _jsx(_Fragment, { children: col.render(v, row) });
146
191
  if (valueEmpty(v))
147
192
  return _jsx("span", { className: "crisp-recordpage-empty", children: labels.setAValue });
148
193
  // `editor: "select"` at rest (#457) -- checked ahead of `col.type` since
@@ -210,6 +255,147 @@ function TasksSection({ tasks, onAdd, onToggle, labels, }) {
210
255
  function EmptyCard({ icon, children, }) {
211
256
  return (_jsxs("div", { className: "crisp-recordpage-emptycard", children: [icon, children] }));
212
257
  }
258
+ /**
259
+ * The Details column's own field list, on its own (#528) — `RecordPage`'s
260
+ * "Record Details" collapsible + searchable field list, the one piece a
261
+ * consumer with a fixed-width rail of its own (no resize handle, no entity
262
+ * header, no CRM Lists panel — GRC's own persistent 320px column, grc#66)
263
+ * actually needs, rather than rebuilding this same click-to-edit/search/
264
+ * `sections` machinery by hand or pulling in the whole page's chrome for a
265
+ * rail that isn't one. `RecordPage` composes this internally for its own
266
+ * resizable aside (same component either way, not a second copy) — this is
267
+ * that same block, exactly, just not wrapped in `RecordPage`'s own topbar/
268
+ * tabs/resizer.
269
+ *
270
+ * Not wrapped in `recordpageRecipe` itself: doing so would duplicate the
271
+ * recipe's own `display:flex;height:100%` base rule as a nested child
272
+ * inside `RecordPage`'s aside (which already carries that class from
273
+ * `RecordPage`'s own root), breaking that layout. A standalone caller
274
+ * needs exactly one `.crisp-recordpage`-scoped ancestor for this
275
+ * component's classes to resolve — wrap it in one, the same way
276
+ * `RecordPage`'s own root does: `<div className={recordpageRecipe()}>`
277
+ * (also exported from this package).
278
+ */
279
+ export function RecordDetails({ record, columns, sections, onEdit, labels, className, ...rest }) {
280
+ const t = useRecordPageLabels(labels);
281
+ const { editingKey, pendingKey, error, openEditing, closeEditing, commit } = useEditableField(onEdit);
282
+ const [detailsOpen, setDetailsOpen] = React.useState(true);
283
+ const [searchOpen, setSearchOpen] = React.useState(false);
284
+ const [fieldQuery, setFieldQuery] = React.useState("");
285
+ const [showAllValues, setShowAllValues] = React.useState(false);
286
+ const fields = columns.filter((col) => col.type !== "entity");
287
+ const q = fieldQuery.trim().toLowerCase();
288
+ const matches = (col) => !q || col.label.toLowerCase().includes(q);
289
+ // #528: `sections` groups fields under headings, in the caller's own
290
+ // order, and always shows every match — no 5-field reveal (see
291
+ // `RecordPageSection`'s own doc). Omitted, this stays the flat,
292
+ // Attio-measured list with its 5-field "View all values" reveal,
293
+ // unchanged from before #528.
294
+ const FIELD_LIMIT = 5;
295
+ let canRevealFields = false;
296
+ let groups;
297
+ if (sections) {
298
+ const byKey = new Map(fields.map((col) => [col.key, col]));
299
+ groups = sections
300
+ .map((s) => ({
301
+ title: s.title,
302
+ fields: s.fields
303
+ .map((key) => byKey.get(key))
304
+ .filter((col) => col !== undefined)
305
+ .filter(matches),
306
+ }))
307
+ // A section left with no matching fields (a search that matches
308
+ // nothing in it, or a `fields` key naming no real column) doesn't
309
+ // render at all -- heading included, not an empty group under a
310
+ // heading with nothing below it.
311
+ .filter((g) => g.fields.length > 0);
312
+ }
313
+ else {
314
+ const matched = q ? fields.filter(matches) : fields;
315
+ canRevealFields = !q && matched.length > FIELD_LIMIT;
316
+ const shown = canRevealFields && !showAllValues
317
+ ? matched.slice(0, FIELD_LIMIT)
318
+ : matched;
319
+ groups = [{ title: undefined, fields: shown }];
320
+ }
321
+ const totalShown = groups.reduce((n, g) => n + g.fields.length, 0);
322
+ return (_jsxs("div", { className: cx("crisp-recordpage-section", className), ...rest, children: [_jsxs("div", { className: "crisp-recordpage-detailhead", children: [searchOpen ? (_jsx("input", {
323
+ // biome-ignore lint/a11y/noAutofocus: focusing the just-opened field search is expected
324
+ autoFocus: true, type: "text", className: "crisp-recordpage-fieldsearch", placeholder: t.searchPlaceholder, "aria-label": t.searchFields, value: fieldQuery, onChange: (e) => setFieldQuery(e.target.value), onKeyDown: (e) => {
325
+ if (e.key === "Escape") {
326
+ setSearchOpen(false);
327
+ setFieldQuery("");
328
+ }
329
+ } })) : (_jsxs("button", { type: "button", className: "crisp-recordpage-collapse", "data-collapsed": detailsOpen ? undefined : "", "aria-expanded": detailsOpen, onClick: () => setDetailsOpen((o) => !o), children: [t.recordDetails, _jsx(ChevronDown, { className: "crisp-recordpage-chev", size: 12, strokeWidth: 2.5 })] })), _jsx("button", { type: "button", className: "crisp-recordpage-detailsearch", "data-active": searchOpen ? "" : undefined, "aria-label": searchOpen ? t.closeSearch : t.searchFields, "aria-pressed": searchOpen, onClick: () => {
330
+ setSearchOpen((s) => !s);
331
+ setFieldQuery("");
332
+ if (!searchOpen)
333
+ setDetailsOpen(true);
334
+ }, children: searchOpen ? (_jsx(X, { size: 14, strokeWidth: 1.75 })) : (_jsx(Search, { size: 14, strokeWidth: 1.75 })) })] }), detailsOpen &&
335
+ (totalShown > 0 ? (_jsxs(_Fragment, { children: [groups.map((g, i) => (_jsxs(React.Fragment, { children: [sections && g.title != null && (_jsx("div", { className: "crisp-recordpage-section-title crisp-recordpage-fieldsection-title", children: g.title })), g.fields.map((col) => {
336
+ // #431: a field only actually opens when there's an
337
+ // `onEdit` to commit through -- `editable` with nothing
338
+ // to call would be a dead click.
339
+ const canEdit = Boolean(col.editable && onEdit);
340
+ const isPending = pendingKey === col.key;
341
+ const fieldError = error?.key === col.key ? error.message : null;
342
+ const label = (_jsxs("span", { className: "crisp-recordpage-field-label", children: [col.icon ? (_jsx(col.icon, { size: 15, strokeWidth: 1.75 })) : null, _jsx("span", { className: "crisp-recordpage-field-label-text", children: col.label })] }));
343
+ // `switch` is always its own live widget -- no
344
+ // click-to-open, no separate edit/view mode: flipping the
345
+ // toggle IS the commit gesture, and there's no
346
+ // meaningfully different "rest" display for a boolean
347
+ // (same exception `DataTable`'s own `editor` makes, #453).
348
+ if (canEdit && col.editor === "switch") {
349
+ const value = record[col.key];
350
+ return (_jsxs("div", { className: "crisp-recordpage-field", children: [label, _jsxs("span", { className: "crisp-recordpage-field-value", children: [_jsxs("span", { className: "crisp-recordpage-edit-picker", children: [_jsx(Switch, { "aria-label": `Edit ${col.label}`, checked: value === true || value === "true", disabled: isPending, onCheckedChange: (checked) => commit(record.id, col.key, String(checked)) }), isPending && (_jsx(Spinner, { size: 14, className: "crisp-recordpage-edit-pending" }))] }), fieldError && (_jsx("span", { className: "crisp-recordpage-edit-error", children: fieldError }))] })] }, col.key));
351
+ }
352
+ // `select` opens crisp's own `Select` on a SINGLE click
353
+ // (#457, corrects #431/#385's original always-live copy
354
+ // of the same regression #453 fixed on `DataTable`) --
355
+ // like every other editable type here, at rest it shows
356
+ // its value (see `FieldValue`'s own `col.editor ===
357
+ // "select"` case) via the generic click-to-open branch
358
+ // below, and only mounts `Select` once `editingKey`
359
+ // matches. `DataTable`'s own double-click gate doesn't
360
+ // apply: `RecordPage`'s plain text/date fields already
361
+ // open on a single click (measured live for #431), so
362
+ // `select` matches that, not `DataTable`.
363
+ if (canEdit &&
364
+ editingKey === col.key &&
365
+ col.editor === "select") {
366
+ const value = record[col.key];
367
+ return (_jsxs("div", { className: "crisp-recordpage-field", children: [label, _jsxs("span", { className: "crisp-recordpage-field-value", children: [_jsx(RecordPageSelectEditor, { col: col, value: value, isPending: isPending, onCommit: (v) => commit(record.id, col.key, v), onCancel: closeEditing }), fieldError && (_jsx("span", { className: "crisp-recordpage-edit-error", children: fieldError }))] })] }, col.key));
368
+ }
369
+ if (canEdit && editingKey === col.key) {
370
+ const value = record[col.key];
371
+ const editor = editableInputType(col.type);
372
+ return (_jsxs("div", { className: "crisp-recordpage-field", children: [label, _jsxs("span", { className: "crisp-recordpage-field-value", children: [_jsx("input", { className: "crisp-recordpage-edit",
373
+ // biome-ignore lint/a11y/noAutofocus: focus the field the moment it opens
374
+ autoFocus: true, ...editor, disabled: isPending, defaultValue: value == null ? "" : String(value), "aria-label": `Edit ${col.label}`, onKeyDown: (e) => {
375
+ if (isPending)
376
+ return;
377
+ if (e.key === "Enter")
378
+ commit(record.id, col.key, e.currentTarget.value);
379
+ else if (e.key === "Escape")
380
+ closeEditing();
381
+ }, onBlur: (e) => {
382
+ if (isPending)
383
+ return;
384
+ commit(record.id, col.key, e.currentTarget.value);
385
+ } }), isPending && (_jsx(Spinner, { size: 14, className: "crisp-recordpage-edit-pending" })), fieldError && (_jsx("span", { className: "crisp-recordpage-edit-error", children: fieldError }))] })] }, col.key));
386
+ }
387
+ return (_jsxs("div", { className: "crisp-recordpage-field", children: [label, _jsxs("span", { className: "crisp-recordpage-field-value", "data-editable": canEdit ? "" : undefined, children: [canEdit ? (_jsx("button", { type: "button", className: "crisp-recordpage-edit-trigger", onClick: () => openEditing(col.key), children: _jsx(FieldValue, { col: col, row: record, labels: t }) })) : (_jsx(FieldValue, { col: col, row: record, labels: t })), fieldError && (_jsx("span", { className: "crisp-recordpage-edit-error", children: fieldError }))] })] }, col.key));
388
+ })] }, g.title != null ? String(g.title) : `section-${i}`))), canRevealFields && (_jsx("button", { type: "button", className: "crisp-recordpage-viewall crisp-recordpage-viewall--section", onClick: () => {
389
+ // Revealing all fields also opens the (focused) field
390
+ // search; collapsing closes it.
391
+ const next = !showAllValues;
392
+ setShowAllValues(next);
393
+ setSearchOpen(next);
394
+ if (!next)
395
+ setFieldQuery("");
396
+ }, children: showAllValues ? t.viewLess : t.viewAllValues }))] })) : (_jsx("div", { className: "crisp-recordpage-noresults", children: t.noFieldsMatch(fieldQuery) })))] }));
397
+ }
398
+ RecordDetails.displayName = "RecordDetails";
213
399
  /**
214
400
  * The full-page company record view — the page that opens when you click into a
215
401
  * record (as opposed to the peek [RecordPanel]). A top breadcrumb bar with
@@ -218,36 +404,8 @@ function EmptyCard({ icon, children, }) {
218
404
  * Emails sections (Notes & Tasks are live — add and check things off). Drop it
219
405
  * into the app's main content area.
220
406
  */
221
- export function RecordPage({ record, columns, collection = "Records", labels, onBack, onToggleFull, full, className, onEdit, tabs: tabsProp, defaultTab, notes: notesProp, onAddNote, tasks: tasksProp, onAddTask, onToggleTask, ...rest }) {
222
- const { editingKey, pendingKey, error, openEditing, closeEditing, commit } = useEditableField(onEdit);
223
- // crisp/#471: a CrispMessagesProvider's `componentDefaults.recordPage` sits
224
- // BETWEEN the built-in English defaults and this instance's own explicit
225
- // `labels` prop — a provider translates every RecordPage on the page at
226
- // once, and a caller who still passes `labels` for one instance keeps
227
- // overriding it there, same as before this existed. Not `useCrispMessage`:
228
- // several of these entries (`domainSetTo`, `sortedBy`, …) are formatter
229
- // FUNCTIONS, which a flat string dictionary can't hold.
230
- const providerLabels = useCrispComponentDefaults("recordPage");
231
- const t = React.useMemo(() => ({
232
- ...recordPageLabels,
233
- ...providerLabels,
234
- ...labels,
235
- tabs: {
236
- ...recordPageLabels.tabs,
237
- ...providerLabels?.tabs,
238
- ...labels?.tabs,
239
- },
240
- highlightLabels: {
241
- ...recordPageLabels.highlightLabels,
242
- ...providerLabels?.highlightLabels,
243
- ...labels?.highlightLabels,
244
- },
245
- highlightEmpty: {
246
- ...recordPageLabels.highlightEmpty,
247
- ...providerLabels?.highlightEmpty,
248
- ...labels?.highlightEmpty,
249
- },
250
- }), [labels, providerLabels]);
407
+ export function RecordPage({ record, columns, collection = "Records", labels, onBack, onToggleFull, full, className, onEdit, tabs: tabsProp, defaultTab, notes: notesProp, onAddNote, tasks: tasksProp, onAddTask, onToggleTask, sections, highlights: highlightsProp, ...rest }) {
408
+ const t = useRecordPageLabels(labels);
251
409
  const [favorite, setFavorite] = React.useState(false);
252
410
  // Notes/Tasks are controlled when the caller supplies `notes`/`tasks` (even
253
411
  // `[]`) -- otherwise RecordPage keeps its own self-contained state, as
@@ -259,11 +417,6 @@ export function RecordPage({ record, columns, collection = "Records", labels, on
259
417
  const notes = notesProp ?? internalNotes;
260
418
  const tasks = tasksProp ?? internalTasks;
261
419
  const [showAllActivity, setShowAllActivity] = React.useState(false);
262
- // Collapsible + searchable Record Details.
263
- const [detailsOpen, setDetailsOpen] = React.useState(true);
264
- const [searchOpen, setSearchOpen] = React.useState(false);
265
- const [fieldQuery, setFieldQuery] = React.useState("");
266
- const [showAllValues, setShowAllValues] = React.useState(false);
267
420
  // Drag-resizable left details column.
268
421
  const [detailsW, setDetailsW] = React.useState(320);
269
422
  const [resizing, setResizing] = React.useState(false);
@@ -300,17 +453,6 @@ export function RecordPage({ record, columns, collection = "Records", labels, on
300
453
  ? record[entityCol.key]
301
454
  : undefined;
302
455
  const name = entity?.name ?? "Record";
303
- const fields = columns.filter((col) => col.type !== "entity");
304
- const q = fieldQuery.trim().toLowerCase();
305
- const matchedFields = q
306
- ? fields.filter((col) => col.label.toLowerCase().includes(q))
307
- : fields;
308
- // Shows the first 5 fields; "View all values" reveals the rest.
309
- const FIELD_LIMIT = 5;
310
- const canRevealFields = !q && matchedFields.length > FIELD_LIMIT;
311
- const shownFields = canRevealFields && !showAllValues
312
- ? matchedFields.slice(0, FIELD_LIMIT)
313
- : matchedFields;
314
456
  const byKey = React.useMemo(() => new Map(columns.map((col) => [col.key, col])), [columns]);
315
457
  // The Overview "Highlights" — a fixed set of enrichment stats (measured
316
458
  // from the live app), a couple derived from the record, the rest empty.
@@ -328,7 +470,11 @@ export function RecordPage({ record, columns, collection = "Records", labels, on
328
470
  : n >= 1000
329
471
  ? "1K–10K"
330
472
  : "<1K";
331
- const highlights = [
473
+ // #528: the built-in CRM Highlights set, used when the caller omits
474
+ // `highlights` entirely -- a caller passing `highlights` (an array, or
475
+ // `false` to omit the section) replaces this wholesale, the same
476
+ // "caller's own list, not a merge" choice `tabs` already makes (#379).
477
+ const defaultHighlights = [
332
478
  {
333
479
  label: t.highlightLabels.connectionStrength,
334
480
  icon: Zap,
@@ -366,6 +512,7 @@ export function RecordPage({ record, columns, collection = "Records", labels, on
366
512
  empty: t.highlightEmpty.employeeRange,
367
513
  },
368
514
  ];
515
+ const highlights = highlightsProp === undefined ? defaultHighlights : highlightsProp;
369
516
  const ti = { size: 15, strokeWidth: 1.75 };
370
517
  const builtinTabs = [
371
518
  { value: "overview", label: t.tabs.overview, icon: _jsx(LayoutGrid, { ...ti }) },
@@ -448,7 +595,7 @@ export function RecordPage({ record, columns, collection = "Records", labels, on
448
595
  const builtinPanel = (active) => {
449
596
  switch (active) {
450
597
  case "overview":
451
- return (_jsxs("div", { className: "crisp-recordpage-panel", children: [_jsxs("section", { children: [_jsx("div", { className: "crisp-recordpage-section-title", children: t.highlights }), _jsx("div", { className: "crisp-recordpage-hl-card", children: _jsx("div", { className: "crisp-recordpage-hl-grid", children: highlights.map((h) => (_jsxs("div", { className: "crisp-recordpage-hl-cell", children: [_jsx(h.icon, { className: "crisp-recordpage-hl-icon", size: 15, strokeWidth: 1.75 }), _jsx("div", { className: "crisp-recordpage-hl-label", children: h.label }), _jsx("div", { className: "crisp-recordpage-hl-value", children: h.value ?? (_jsx("span", { className: "crisp-recordpage-hl-value--empty", children: h.empty })) })] }, h.label))) }) })] }), activityFeed, _jsxs("section", { children: [_jsx(SectionHead, { title: t.tabs.emails, count: 0 }), emailsEmpty] }), _jsx(NotesSection, { notes: notes, onAdd: addNote, labels: t }), _jsx(TasksSection, { tasks: tasks, onAdd: addTask, onToggle: toggleTask, labels: t })] }));
598
+ return (_jsxs("div", { className: "crisp-recordpage-panel", children: [highlights !== false && (_jsxs("section", { children: [_jsx("div", { className: "crisp-recordpage-section-title", children: t.highlights }), _jsx("div", { className: "crisp-recordpage-hl-card", children: _jsx("div", { className: "crisp-recordpage-hl-grid", children: highlights.map((h) => (_jsxs("div", { className: "crisp-recordpage-hl-cell", children: [h.icon && (_jsx(h.icon, { className: "crisp-recordpage-hl-icon", size: 15, strokeWidth: 1.75 })), _jsx("div", { className: "crisp-recordpage-hl-label", children: h.label }), _jsx("div", { className: "crisp-recordpage-hl-value", children: h.value ?? (_jsx("span", { className: "crisp-recordpage-hl-value--empty", children: h.empty })) })] }, h.label))) }) })] })), activityFeed, _jsxs("section", { children: [_jsx(SectionHead, { title: t.tabs.emails, count: 0 }), emailsEmpty] }), _jsx(NotesSection, { notes: notes, onAdd: addNote, labels: t }), _jsx(TasksSection, { tasks: tasks, onAdd: addTask, onToggle: toggleTask, labels: t })] }));
452
599
  case "activity":
453
600
  return _jsx("div", { className: "crisp-recordpage-panel", children: activityFeed });
454
601
  case "notes":
@@ -474,81 +621,7 @@ export function RecordPage({ record, columns, collection = "Records", labels, on
474
621
  const effectivePanel = tabsProp
475
622
  ? (active) => tabsProp.find((tb) => tb.id === active)?.render()
476
623
  : builtinPanel;
477
- return (_jsxs("div", { className: recordpageRecipe({ className }), ...rest, children: [_jsxs("div", { className: "crisp-recordpage-topbar", children: [_jsxs("nav", { className: "crisp-recordpage-crumbs", "aria-label": t.breadcrumb, children: [_jsx("button", { type: "button", className: "crisp-recordpage-crumb", onClick: onBack, "aria-label": `Back to ${collection}`, children: collection }), _jsx("span", { className: "crisp-recordpage-crumb-sep", children: "/" }), _jsx("span", { className: "crisp-recordpage-crumb-name", children: name })] }), _jsxs("div", { className: "crisp-recordpage-actions", children: [_jsxs("button", { type: "button", className: "crisp-recordpage-action crisp-recordpage-action--ghost", children: [_jsx(Sparkles, { size: 14, strokeWidth: 1.75, color: "var(--crisp-bg-brand-solid)" }), t.askAttio] }), onToggleFull && (_jsx("button", { type: "button", className: "crisp-recordpage-action crisp-recordpage-action--icon", onClick: onToggleFull, "aria-label": full ? t.exitFullScreen : t.fullScreen, children: full ? (_jsx(Minimize2, { size: 15, strokeWidth: 1.75 })) : (_jsx(Maximize2, { size: 15, strokeWidth: 1.75 })) })), _jsxs(Menu, { align: "end", "aria-label": t.recordActions, trigger: _jsx("button", { type: "button", className: "crisp-recordpage-action crisp-recordpage-action--icon", "aria-label": t.recordActions, children: _jsx(MoreHorizontal, { size: 16, strokeWidth: 1.75 }) }), children: [_jsx(MenuItem, { icon: _jsx(Copy, { size: 15, strokeWidth: 1.75 }), shortcut: "\u2318C", children: t.copyRecordLink }), _jsx(MenuItem, { icon: _jsx(Plus, { size: 15, strokeWidth: 1.75 }), children: t.addToList }), _jsx(MenuItem, { icon: _jsx(Star, { size: 15, strokeWidth: 1.75 }), onSelect: () => setFavorite((f) => !f), children: favorite ? t.removeFromFavourites : t.addToFavourites }), _jsx(MenuSeparator, {}), _jsx(MenuItem, { icon: _jsx(Trash2, { size: 15, strokeWidth: 1.75 }), danger: true, shortcut: "\u232B", children: t.deleteRecord })] })] })] }), _jsxs("div", { className: "crisp-recordpage-body", children: [_jsxs("aside", { className: "crisp-recordpage-details", style: { width: detailsW }, children: [_jsxs("div", { className: "crisp-recordpage-entity", children: [entity?.avatar ? (_jsx("img", { className: "crisp-recordpage-entity-logo", src: entity.avatar, alt: "" })) : (_jsx(Avatar, { size: "lg", shape: "rounded", tone: "brand", children: name.charAt(0) })), _jsx("span", { className: "crisp-recordpage-entity-name", children: name }), _jsx("button", { type: "button", className: "crisp-recordpage-entity-iconbtn", "aria-label": t.editRecord, children: _jsx(Pencil, { size: 15, strokeWidth: 1.75 }) }), _jsx("button", { type: "button", className: "crisp-recordpage-entity-iconbtn", "data-active": favorite ? "" : undefined, "aria-pressed": favorite, onClick: () => setFavorite((f) => !f), "aria-label": favorite ? t.removeFromFavourites : t.addToFavourites, children: _jsx(Star, { size: 15, strokeWidth: 1.75 }) })] }), _jsxs("div", { className: "crisp-recordpage-quickactions", children: [_jsxs("button", { type: "button", className: "crisp-recordpage-compose", children: [_jsx(Mail, { size: 16, strokeWidth: 1.75 }), t.composeEmail] }), _jsx("button", { type: "button", className: "crisp-recordpage-quickbtn", "aria-label": t.copyRecordLink, children: _jsx(Copy, { size: 15, strokeWidth: 1.75 }) }), _jsx("button", { type: "button", className: "crisp-recordpage-quickbtn", "aria-label": t.logANote, children: _jsx(FileText, { size: 15, strokeWidth: 1.75 }) }), _jsx("button", { type: "button", className: "crisp-recordpage-quickbtn", "aria-label": t.linkRecords, children: _jsx(Share2, { size: 15, strokeWidth: 1.75 }) }), _jsx("button", { type: "button", className: "crisp-recordpage-quickbtn", "aria-label": t.createATask, children: _jsx(SquareCheck, { size: 15, strokeWidth: 1.75 }) })] }), _jsxs("div", { className: "crisp-recordpage-section", children: [_jsxs("div", { className: "crisp-recordpage-detailhead", children: [searchOpen ? (_jsx("input", {
478
- // biome-ignore lint/a11y/noAutofocus: focusing the just-opened field search is expected
479
- autoFocus: true, type: "text", className: "crisp-recordpage-fieldsearch", placeholder: t.searchPlaceholder, "aria-label": t.searchFields, value: fieldQuery, onChange: (e) => setFieldQuery(e.target.value), onKeyDown: (e) => {
480
- if (e.key === "Escape") {
481
- setSearchOpen(false);
482
- setFieldQuery("");
483
- }
484
- } })) : (_jsxs("button", { type: "button", className: "crisp-recordpage-collapse", "data-collapsed": detailsOpen ? undefined : "", "aria-expanded": detailsOpen, onClick: () => setDetailsOpen((o) => !o), children: [t.recordDetails, _jsx(ChevronDown, { className: "crisp-recordpage-chev", size: 12, strokeWidth: 2.5 })] })), _jsx("button", { type: "button", className: "crisp-recordpage-detailsearch", "data-active": searchOpen ? "" : undefined, "aria-label": searchOpen ? t.closeSearch : t.searchFields, "aria-pressed": searchOpen, onClick: () => {
485
- setSearchOpen((s) => !s);
486
- setFieldQuery("");
487
- if (!searchOpen)
488
- setDetailsOpen(true);
489
- }, children: searchOpen ? (_jsx(X, { size: 14, strokeWidth: 1.75 })) : (_jsx(Search, { size: 14, strokeWidth: 1.75 })) })] }), detailsOpen &&
490
- (shownFields.length > 0 ? (_jsxs(_Fragment, { children: [shownFields.map((col) => {
491
- // #431: a field only actually opens when there's an
492
- // `onEdit` to commit through -- `editable` with nothing
493
- // to call would be a dead click.
494
- const canEdit = Boolean(col.editable && onEdit);
495
- const isPending = pendingKey === col.key;
496
- const fieldError = error?.key === col.key ? error.message : null;
497
- const label = (_jsxs("span", { className: "crisp-recordpage-field-label", children: [col.icon ? (_jsx(col.icon, { size: 15, strokeWidth: 1.75 })) : null, _jsx("span", { className: "crisp-recordpage-field-label-text", children: col.label })] }));
498
- // `switch` is always its own live widget -- no
499
- // click-to-open, no separate edit/view mode: flipping the
500
- // toggle IS the commit gesture, and there's no
501
- // meaningfully different "rest" display for a boolean
502
- // (same exception `DataTable`'s own `editor` makes, #453).
503
- if (canEdit && col.editor === "switch") {
504
- const value = record[col.key];
505
- return (_jsxs("div", { className: "crisp-recordpage-field", children: [label, _jsxs("span", { className: "crisp-recordpage-field-value", children: [_jsxs("span", { className: "crisp-recordpage-edit-picker", children: [_jsx(Switch, { "aria-label": `Edit ${col.label}`, checked: value === true || value === "true", disabled: isPending, onCheckedChange: (checked) => commit(record.id, col.key, String(checked)) }), isPending && (_jsx(Spinner, { size: 14, className: "crisp-recordpage-edit-pending" }))] }), fieldError && (_jsx("span", { className: "crisp-recordpage-edit-error", children: fieldError }))] })] }, col.key));
506
- }
507
- // `select` opens crisp's own `Select` on a SINGLE click
508
- // (#457, corrects #431/#385's original always-live
509
- // copy of the same regression #453 fixed on `DataTable`)
510
- // -- like every other editable type here, at rest it
511
- // shows its value (see `FieldValue`'s own `col.editor
512
- // === "select"` case) via the generic click-to-open
513
- // branch below, and only mounts `Select` once
514
- // `editingKey` matches. `DataTable`'s own double-click
515
- // gate doesn't apply: `RecordPage`'s plain text/date
516
- // fields already open on a single click (measured live
517
- // for #431), so `select` matches that, not `DataTable`.
518
- if (canEdit &&
519
- editingKey === col.key &&
520
- col.editor === "select") {
521
- const value = record[col.key];
522
- return (_jsxs("div", { className: "crisp-recordpage-field", children: [label, _jsxs("span", { className: "crisp-recordpage-field-value", children: [_jsx(RecordPageSelectEditor, { col: col, value: value, isPending: isPending, onCommit: (v) => commit(record.id, col.key, v), onCancel: closeEditing }), fieldError && (_jsx("span", { className: "crisp-recordpage-edit-error", children: fieldError }))] })] }, col.key));
523
- }
524
- if (canEdit && editingKey === col.key) {
525
- const value = record[col.key];
526
- const editor = editableInputType(col.type);
527
- return (_jsxs("div", { className: "crisp-recordpage-field", children: [label, _jsxs("span", { className: "crisp-recordpage-field-value", children: [_jsx("input", { className: "crisp-recordpage-edit",
528
- // biome-ignore lint/a11y/noAutofocus: focus the field the moment it opens
529
- autoFocus: true, ...editor, disabled: isPending, defaultValue: value == null ? "" : String(value), "aria-label": `Edit ${col.label}`, onKeyDown: (e) => {
530
- if (isPending)
531
- return;
532
- if (e.key === "Enter")
533
- commit(record.id, col.key, e.currentTarget.value);
534
- else if (e.key === "Escape")
535
- closeEditing();
536
- }, onBlur: (e) => {
537
- if (isPending)
538
- return;
539
- commit(record.id, col.key, e.currentTarget.value);
540
- } }), isPending && (_jsx(Spinner, { size: 14, className: "crisp-recordpage-edit-pending" })), fieldError && (_jsx("span", { className: "crisp-recordpage-edit-error", children: fieldError }))] })] }, col.key));
541
- }
542
- return (_jsxs("div", { className: "crisp-recordpage-field", children: [label, _jsxs("span", { className: "crisp-recordpage-field-value", "data-editable": canEdit ? "" : undefined, children: [canEdit ? (_jsx("button", { type: "button", className: "crisp-recordpage-edit-trigger", onClick: () => openEditing(col.key), children: _jsx(FieldValue, { col: col, row: record, labels: t }) })) : (_jsx(FieldValue, { col: col, row: record, labels: t })), fieldError && (_jsx("span", { className: "crisp-recordpage-edit-error", children: fieldError }))] })] }, col.key));
543
- }), canRevealFields && (_jsx("button", { type: "button", className: "crisp-recordpage-viewall crisp-recordpage-viewall--section", onClick: () => {
544
- // Revealing all fields also opens the (focused)
545
- // field search; collapsing closes it.
546
- const next = !showAllValues;
547
- setShowAllValues(next);
548
- setSearchOpen(next);
549
- if (!next)
550
- setFieldQuery("");
551
- }, children: showAllValues ? t.viewLess : t.viewAllValues }))] })) : (_jsxs("div", { className: "crisp-recordpage-noresults", children: ["No fields match \u201C", fieldQuery, "\u201D."] })))] }), _jsxs("div", { className: "crisp-recordpage-section", children: [_jsx(SectionHead, { title: t.tabs.lists, action: _jsxs("button", { type: "button", className: "crisp-recordpage-addbtn", children: [_jsx(Plus, { size: 14, strokeWidth: 2 }), t.addToList] }) }), _jsx("div", { className: "crisp-recordpage-emptystate", children: t.noLists })] })] }), _jsx("div", { className: "crisp-recordpage-resizer", "data-active": resizing ? "" : undefined, role: "separator", "aria-orientation": "vertical", "aria-label": t.resizeDetails, "aria-valuenow": Math.round(detailsW), "aria-valuemin": 300, "aria-valuemax": 560, tabIndex: 0, onPointerDown: onResizeDown, onPointerMove: onResizeMove, onPointerUp: onResizeUp, onKeyDown: (e) => {
624
+ return (_jsxs("div", { className: recordpageRecipe({ className }), ...rest, children: [_jsxs("div", { className: "crisp-recordpage-topbar", children: [_jsxs("nav", { className: "crisp-recordpage-crumbs", "aria-label": t.breadcrumb, children: [_jsx("button", { type: "button", className: "crisp-recordpage-crumb", onClick: onBack, "aria-label": `Back to ${collection}`, children: collection }), _jsx("span", { className: "crisp-recordpage-crumb-sep", children: "/" }), _jsx("span", { className: "crisp-recordpage-crumb-name", children: name })] }), _jsxs("div", { className: "crisp-recordpage-actions", children: [_jsxs("button", { type: "button", className: "crisp-recordpage-action crisp-recordpage-action--ghost", children: [_jsx(Sparkles, { size: 14, strokeWidth: 1.75, color: "var(--crisp-bg-brand-solid)" }), t.askAttio] }), onToggleFull && (_jsx("button", { type: "button", className: "crisp-recordpage-action crisp-recordpage-action--icon", onClick: onToggleFull, "aria-label": full ? t.exitFullScreen : t.fullScreen, children: full ? (_jsx(Minimize2, { size: 15, strokeWidth: 1.75 })) : (_jsx(Maximize2, { size: 15, strokeWidth: 1.75 })) })), _jsxs(Menu, { align: "end", "aria-label": t.recordActions, trigger: _jsx("button", { type: "button", className: "crisp-recordpage-action crisp-recordpage-action--icon", "aria-label": t.recordActions, children: _jsx(MoreHorizontal, { size: 16, strokeWidth: 1.75 }) }), children: [_jsx(MenuItem, { icon: _jsx(Copy, { size: 15, strokeWidth: 1.75 }), shortcut: "\u2318C", children: t.copyRecordLink }), _jsx(MenuItem, { icon: _jsx(Plus, { size: 15, strokeWidth: 1.75 }), children: t.addToList }), _jsx(MenuItem, { icon: _jsx(Star, { size: 15, strokeWidth: 1.75 }), onSelect: () => setFavorite((f) => !f), children: favorite ? t.removeFromFavourites : t.addToFavourites }), _jsx(MenuSeparator, {}), _jsx(MenuItem, { icon: _jsx(Trash2, { size: 15, strokeWidth: 1.75 }), danger: true, shortcut: "\u232B", children: t.deleteRecord })] })] })] }), _jsxs("div", { className: "crisp-recordpage-body", children: [_jsxs("aside", { className: "crisp-recordpage-details", style: { width: detailsW }, children: [_jsxs("div", { className: "crisp-recordpage-entity", children: [entity?.avatar ? (_jsx("img", { className: "crisp-recordpage-entity-logo", src: entity.avatar, alt: "" })) : (_jsx(Avatar, { size: "lg", shape: "rounded", tone: "brand", children: name.charAt(0) })), _jsx("span", { className: "crisp-recordpage-entity-name", children: name }), _jsx("button", { type: "button", className: "crisp-recordpage-entity-iconbtn", "aria-label": t.editRecord, children: _jsx(Pencil, { size: 15, strokeWidth: 1.75 }) }), _jsx("button", { type: "button", className: "crisp-recordpage-entity-iconbtn", "data-active": favorite ? "" : undefined, "aria-pressed": favorite, onClick: () => setFavorite((f) => !f), "aria-label": favorite ? t.removeFromFavourites : t.addToFavourites, children: _jsx(Star, { size: 15, strokeWidth: 1.75 }) })] }), _jsxs("div", { className: "crisp-recordpage-quickactions", children: [_jsxs("button", { type: "button", className: "crisp-recordpage-compose", children: [_jsx(Mail, { size: 16, strokeWidth: 1.75 }), t.composeEmail] }), _jsx("button", { type: "button", className: "crisp-recordpage-quickbtn", "aria-label": t.copyRecordLink, children: _jsx(Copy, { size: 15, strokeWidth: 1.75 }) }), _jsx("button", { type: "button", className: "crisp-recordpage-quickbtn", "aria-label": t.logANote, children: _jsx(FileText, { size: 15, strokeWidth: 1.75 }) }), _jsx("button", { type: "button", className: "crisp-recordpage-quickbtn", "aria-label": t.linkRecords, children: _jsx(Share2, { size: 15, strokeWidth: 1.75 }) }), _jsx("button", { type: "button", className: "crisp-recordpage-quickbtn", "aria-label": t.createATask, children: _jsx(SquareCheck, { size: 15, strokeWidth: 1.75 }) })] }), _jsx(RecordDetails, { record: record, columns: columns, sections: sections, onEdit: onEdit, labels: labels }), _jsxs("div", { className: "crisp-recordpage-section", children: [_jsx(SectionHead, { title: t.tabs.lists, action: _jsxs("button", { type: "button", className: "crisp-recordpage-addbtn", children: [_jsx(Plus, { size: 14, strokeWidth: 2 }), t.addToList] }) }), _jsx("div", { className: "crisp-recordpage-emptystate", children: t.noLists })] })] }), _jsx("div", { className: "crisp-recordpage-resizer", "data-active": resizing ? "" : undefined, role: "separator", "aria-orientation": "vertical", "aria-label": t.resizeDetails, "aria-valuenow": Math.round(detailsW), "aria-valuemin": 300, "aria-valuemax": 560, tabIndex: 0, onPointerDown: onResizeDown, onPointerMove: onResizeMove, onPointerUp: onResizeUp, onKeyDown: (e) => {
552
625
  if (e.key !== "ArrowLeft" && e.key !== "ArrowRight")
553
626
  return;
554
627
  // The pointer drag learned its direction in #212; this did not.
@@ -63,6 +63,11 @@ const config = defineRecipe({
63
63
  & .crisp-recordpage-detailsearch { display: inline-flex; align-items: center; justify-content: center; width: 22px; height: ${c.size.chip}; flex-shrink: 0; padding: 0; margin: 0; border: none; border-radius: ${c.radius.sm}; background: none; color: ${c.fgMuted}; font-family: inherit; cursor: pointer; transition: background-color .12s, color .12s; }
64
64
  & .crisp-recordpage-detailsearch:hover, & .crisp-recordpage-detailsearch[data-active] { background: ${c.bgRaised}; color: ${c.fgPrimary}; }
65
65
  & .crisp-recordpage-noresults { padding: ${c.space["1.5"]} ${c.space["1"]}; font-size: ${c.text.md}; color: ${c.fgMuted}; }
66
+ /* A caller-supplied \`sections\` grouping (#528) is not an Attio-measured
67
+ shape (its own Details column is flat) -- this heading reuses
68
+ .crisp-recordpage-section-title's own type treatment verbatim, just
69
+ spaced from the group above it. */
70
+ & .crisp-recordpage-fieldsection-title { margin-top: ${c.space["3"]}; }
66
71
  & .crisp-recordpage-morevalues { display: inline-flex; align-items: center; padding: 0; margin: 0; border: none; background: none; font-family: inherit; font-size: ${c.text.lg}; color: ${c.fgMuted}; cursor: pointer; }
67
72
  & .crisp-recordpage-morevalues:hover { color: ${c.fgPrimary}; }
68
73
  & .crisp-recordpage-viewall { flex-basis: 100%; margin-top: ${c.space["0.5"]}; border: none; background: none; padding: 0; color: color-mix(in srgb, ${c.fgPrimary} 55%, transparent); font-family: inherit; font-size: ${c.text.sm}; font-weight: ${c.weight.regular}; cursor: pointer; text-align: start; }