@crisp-ui-kit/crisp 0.54.2 → 0.54.4

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 (116) hide show
  1. package/dist/board.css +13 -1
  2. package/dist/board.layered.css +13 -1
  3. package/dist/chip.css +7 -1
  4. package/dist/chip.d.ts +2 -1
  5. package/dist/chip.layered.css +7 -1
  6. package/dist/cjs/components/chip/chip.js +3 -2
  7. package/dist/cjs/components/chip/chip.recipe.js +37 -1
  8. package/dist/cjs/components/datatable/datatable.js +489 -111
  9. package/dist/cjs/components/emptystate/emptystate.js +3 -2
  10. package/dist/cjs/components/emptystate/emptystate.recipe.js +26 -1
  11. package/dist/cjs/components/personchip/index.js +17 -0
  12. package/dist/cjs/components/personchip/personchip.js +50 -0
  13. package/dist/cjs/components/table/table.js +2 -2
  14. package/dist/cjs/components/tabletoolbar/tabletoolbar.js +41 -4
  15. package/dist/cjs/components/tabs/tabs.js +2 -2
  16. package/dist/cjs/components/tabs/tabs.recipe.js +30 -3
  17. package/dist/cjs/components/text/index.js +7 -0
  18. package/dist/cjs/components/text/text.js +55 -0
  19. package/dist/cjs/components/text/text.recipe.js +34 -0
  20. package/dist/cjs/index.js +4 -0
  21. package/dist/cjs/product/board/board.js +60 -2
  22. package/dist/cjs/product/board/board.recipe.js +10 -0
  23. package/dist/cjs/product/comments/comments.js +426 -0
  24. package/dist/cjs/product/comments/comments.recipe.js +126 -0
  25. package/dist/cjs/product/comments/index.js +8 -0
  26. package/dist/cjs/product/headerbar/headerbar.recipe.js +9 -1
  27. package/dist/cjs/product/listpage/listpage.recipe.js +10 -1
  28. package/dist/cjs/product/objectsettings/index.js +16 -0
  29. package/dist/cjs/product/objectsettings/objectsettings.js +365 -0
  30. package/dist/cjs/product/objectsettings/objectsettings.recipe.js +65 -0
  31. package/dist/cjs/product/recordpage/recordpage.recipe.js +10 -1
  32. package/dist/cjs/product/recordstable/recordstable.js +89 -6
  33. package/dist/cjs/product/richtext/richtext.recipe.js +26 -1
  34. package/dist/cjs/product/taskstable/taskstable.recipe.js +10 -1
  35. package/dist/comments.css +80 -0
  36. package/dist/comments.d.ts +7 -0
  37. package/dist/comments.layered.css +82 -0
  38. package/dist/emptystate.css +15 -1
  39. package/dist/emptystate.d.ts +2 -2
  40. package/dist/emptystate.layered.css +15 -1
  41. package/dist/esm/components/chip/chip.js +3 -2
  42. package/dist/esm/components/chip/chip.recipe.js +37 -1
  43. package/dist/esm/components/datatable/datatable.js +488 -111
  44. package/dist/esm/components/emptystate/emptystate.js +4 -3
  45. package/dist/esm/components/emptystate/emptystate.recipe.js +26 -1
  46. package/dist/esm/components/emptystate/index.js +1 -1
  47. package/dist/esm/components/personchip/index.js +1 -0
  48. package/dist/esm/components/personchip/personchip.js +47 -0
  49. package/dist/esm/components/table/table.js +2 -2
  50. package/dist/esm/components/tabletoolbar/tabletoolbar.js +43 -6
  51. package/dist/esm/components/tabs/tabs.js +2 -2
  52. package/dist/esm/components/tabs/tabs.recipe.js +30 -3
  53. package/dist/esm/components/text/index.js +2 -0
  54. package/dist/esm/components/text/text.js +19 -0
  55. package/dist/esm/components/text/text.recipe.js +31 -0
  56. package/dist/esm/index.js +4 -0
  57. package/dist/esm/product/board/board.js +61 -3
  58. package/dist/esm/product/board/board.recipe.js +10 -0
  59. package/dist/esm/product/comments/comments.js +388 -0
  60. package/dist/esm/product/comments/comments.recipe.js +123 -0
  61. package/dist/esm/product/comments/index.js +2 -0
  62. package/dist/esm/product/headerbar/headerbar.recipe.js +9 -1
  63. package/dist/esm/product/listpage/listpage.recipe.js +10 -1
  64. package/dist/esm/product/objectsettings/index.js +2 -0
  65. package/dist/esm/product/objectsettings/objectsettings.js +322 -0
  66. package/dist/esm/product/objectsettings/objectsettings.recipe.js +62 -0
  67. package/dist/esm/product/recordpage/recordpage.recipe.js +10 -1
  68. package/dist/esm/product/recordstable/recordstable.js +90 -8
  69. package/dist/esm/product/richtext/richtext.recipe.js +26 -1
  70. package/dist/esm/product/taskstable/taskstable.recipe.js +10 -1
  71. package/dist/headerbar.css +1 -1
  72. package/dist/headerbar.layered.css +1 -1
  73. package/dist/listpage.css +8 -1
  74. package/dist/listpage.layered.css +8 -1
  75. package/dist/objectsettings.css +49 -0
  76. package/dist/objectsettings.d.ts +7 -0
  77. package/dist/objectsettings.layered.css +51 -0
  78. package/dist/recordpage.css +13 -2
  79. package/dist/recordpage.layered.css +13 -2
  80. package/dist/richtext.css +11 -1
  81. package/dist/richtext.layered.css +11 -1
  82. package/dist/styles.css +224 -10
  83. package/dist/styles.layered.css +224 -10
  84. package/dist/tabs.css +7 -1
  85. package/dist/tabs.d.ts +2 -2
  86. package/dist/tabs.layered.css +7 -1
  87. package/dist/taskstable.css +8 -1
  88. package/dist/taskstable.layered.css +8 -1
  89. package/dist/text.css +9 -0
  90. package/dist/text.d.ts +7 -0
  91. package/dist/text.layered.css +11 -0
  92. package/dist/types/components/chip/chip.d.ts +32 -0
  93. package/dist/types/components/chip/chip.recipe.d.ts +31 -0
  94. package/dist/types/components/datatable/datatable.d.ts +28 -4
  95. package/dist/types/components/emptystate/emptystate.d.ts +29 -4
  96. package/dist/types/components/emptystate/emptystate.recipe.d.ts +2 -0
  97. package/dist/types/components/emptystate/index.d.ts +1 -1
  98. package/dist/types/components/personchip/index.d.ts +1 -0
  99. package/dist/types/components/personchip/personchip.d.ts +37 -0
  100. package/dist/types/components/table/table.d.ts +36 -0
  101. package/dist/types/components/tabletoolbar/tabletoolbar.d.ts +8 -2
  102. package/dist/types/components/tabs/tabs.d.ts +4 -2
  103. package/dist/types/components/tabs/tabs.recipe.d.ts +18 -3
  104. package/dist/types/components/text/index.d.ts +2 -0
  105. package/dist/types/components/text/text.d.ts +32 -0
  106. package/dist/types/components/text/text.recipe.d.ts +40 -0
  107. package/dist/types/index.d.ts +4 -0
  108. package/dist/types/product/board/board.d.ts +20 -1
  109. package/dist/types/product/comments/comments.d.ts +192 -0
  110. package/dist/types/product/comments/comments.recipe.d.ts +3 -0
  111. package/dist/types/product/comments/index.d.ts +2 -0
  112. package/dist/types/product/objectsettings/index.d.ts +2 -0
  113. package/dist/types/product/objectsettings/objectsettings.d.ts +261 -0
  114. package/dist/types/product/objectsettings/objectsettings.recipe.d.ts +3 -0
  115. package/dist/types/product/recordstable/recordstable.d.ts +24 -2
  116. package/package.json +21 -1
@@ -0,0 +1,322 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Calendar, CheckSquare, Clock, DollarSign, FileText, Flag, Hash, Link2, ListChecks, MapPin, MoreHorizontal, Pencil, Phone, Search, SlidersHorizontal, Sparkles, Star, Type as TypeIcon, User as UserIcon, } from "lucide-react";
3
+ import * as React from "react";
4
+ import { Badge } from "../../components/badge/index.js";
5
+ import { DataTable } from "../../components/datatable/index.js";
6
+ import { FormField } from "../../components/formfield/index.js";
7
+ import { Input } from "../../components/input/index.js";
8
+ import { Menu, MenuItem } from "../../components/menu/index.js";
9
+ import { Select } from "../../components/select/index.js";
10
+ import { Switch } from "../../components/switch/index.js";
11
+ import { Tabs } from "../../components/tabs/index.js";
12
+ import { Textarea } from "../../components/textarea/index.js";
13
+ import { useCrispMessage } from "../../core/index.js";
14
+ import { FormDialog } from "../formdialog/index.js";
15
+ import { SettingsSection } from "../settingssection/index.js";
16
+ import { objectsettingsRecipe } from "./objectsettings.recipe.js";
17
+ /**
18
+ * Settings → Objects (crisp#637, P-122): H1 + subtitle, a search+Filter row,
19
+ * and a DataTable of every object (icon+name, type, record count,
20
+ * attribute count, a per-row "…" menu). Measured live against
21
+ * app.attio.com/<workspace>/settings/data/objects — the plan-gate banner
22
+ * above the table and the "5/3" object-count pill in the "Object" header
23
+ * (both billing-tier chrome) are skipped by decision, not oversight.
24
+ */
25
+ export function ObjectsList({ objects, onOpenObject, onEditObject, onCopyObjectId, onCreateObject, title: titleProp, subtitle: subtitleProp, newObjectLabel: newObjectLabelProp, searchPlaceholder: searchPlaceholderProp, filterLabel: filterLabelProp, objectColumnLabel: objectColumnLabelProp, typeColumnLabel: typeColumnLabelProp, recordsColumnLabel: recordsColumnLabelProp, attributesColumnLabel: attributesColumnLabelProp, noRecordsLabel: noRecordsLabelProp, editLabel: editLabelProp, copyIdLabel: copyIdLabelProp, rowMenuLabel, emptyLabel, className, ...rest }) {
26
+ const title = useCrispMessage(titleProp, "Objects");
27
+ const subtitle = useCrispMessage(subtitleProp, "Modify and add objects in your workspace");
28
+ const newObjectLabel = useCrispMessage(newObjectLabelProp, "New custom object");
29
+ const searchPlaceholder = useCrispMessage(searchPlaceholderProp, "Search objects");
30
+ const filterLabel = useCrispMessage(filterLabelProp, "Filter");
31
+ const objectColumnLabel = useCrispMessage(objectColumnLabelProp, "Object");
32
+ const typeColumnLabel = useCrispMessage(typeColumnLabelProp, "Type");
33
+ const recordsColumnLabel = useCrispMessage(recordsColumnLabelProp, "Records");
34
+ const attributesColumnLabel = useCrispMessage(attributesColumnLabelProp, "Attributes");
35
+ const noRecordsLabel = useCrispMessage(noRecordsLabelProp, "No records");
36
+ const editLabel = useCrispMessage(editLabelProp, "Edit");
37
+ const copyIdLabel = useCrispMessage(copyIdLabelProp, "Copy ID");
38
+ const [query, setQuery] = React.useState("");
39
+ const filtered = React.useMemo(() => {
40
+ if (!query.trim())
41
+ return objects;
42
+ const q = query.trim().toLowerCase();
43
+ return objects.filter((o) => String(o.name ?? "")
44
+ .toLowerCase()
45
+ .includes(q));
46
+ }, [objects, query]);
47
+ const columns = React.useMemo(() => [
48
+ {
49
+ key: "name",
50
+ header: objectColumnLabel,
51
+ render: (_v, row) => (_jsxs("span", { style: { display: "flex", alignItems: "center", gap: 8 }, children: [_jsx("span", { className: "crisp-objectsettings-icon", style: { background: row.iconColor ?? "currentColor" }, children: row.icon }), row.name] })),
52
+ },
53
+ {
54
+ key: "typeLabel",
55
+ header: typeColumnLabel,
56
+ render: (_v, row) => (_jsx("span", { className: "crisp-objectsettings-typebadge", children: row.typeLabel })),
57
+ },
58
+ {
59
+ key: "recordCount",
60
+ header: recordsColumnLabel,
61
+ align: "left",
62
+ render: (_v, row) => row.recordCount ? (row.recordCount) : (_jsx("span", { className: "crisp-objectsettings-muted", children: noRecordsLabel })),
63
+ },
64
+ {
65
+ key: "attributeCount",
66
+ header: attributesColumnLabel,
67
+ },
68
+ {
69
+ key: "actions",
70
+ header: "",
71
+ align: "right",
72
+ render: (_v, row) => (_jsxs(Menu, { align: "end", trigger: _jsx("button", { type: "button", "aria-label": rowMenuLabel?.(row) ?? `${row.name}`, style: {
73
+ display: "inline-flex",
74
+ border: "none",
75
+ background: "none",
76
+ cursor: "pointer",
77
+ color: "inherit",
78
+ padding: 4,
79
+ }, children: _jsx(MoreHorizontal, { size: 16 }) }), children: [_jsx(MenuItem, { icon: _jsx(Pencil, { size: 14 }), onSelect: () => onEditObject?.(row.id), children: editLabel }), _jsx(MenuItem, { onSelect: () => onCopyObjectId?.(row.id), children: copyIdLabel })] })),
80
+ },
81
+ ], [
82
+ objectColumnLabel,
83
+ typeColumnLabel,
84
+ recordsColumnLabel,
85
+ attributesColumnLabel,
86
+ noRecordsLabel,
87
+ editLabel,
88
+ copyIdLabel,
89
+ onEditObject,
90
+ onCopyObjectId,
91
+ rowMenuLabel,
92
+ ]);
93
+ return (_jsx("div", { className: objectsettingsRecipe({ className }), ...rest, children: _jsxs("div", { className: "crisp-objectsettings-page", children: [_jsxs("div", { className: "crisp-objectsettings-head", children: [_jsxs("div", { className: "crisp-objectsettings-heading", children: [_jsx("h1", { className: "crisp-objectsettings-title", children: title }), _jsx("p", { className: "crisp-objectsettings-subtitle", children: subtitle })] }), onCreateObject && (_jsx("button", { type: "button", className: "crisp-objectsettings-filterbtn", onClick: onCreateObject, children: newObjectLabel }))] }), _jsxs("div", { className: "crisp-objectsettings-toolbar", children: [_jsxs("span", { className: "crisp-objectsettings-search", children: [_jsx(Search, { size: 15, strokeWidth: 1.75, "aria-hidden": "true" }), _jsx("input", { type: "search", placeholder: searchPlaceholder, value: query, onChange: (e) => setQuery(e.target.value), "aria-label": searchPlaceholder })] }), _jsxs("button", { type: "button", className: "crisp-objectsettings-filterbtn", children: [_jsx(SlidersHorizontal, { size: 14 }), filterLabel] })] }), _jsx(DataTable, { columns: columns, data: filtered, onRowClick: onOpenObject ? (row) => onOpenObject(row.id) : undefined, empty: emptyLabel })] }) }));
94
+ }
95
+ ObjectsList.displayName = "ObjectsList";
96
+ /**
97
+ * Settings → Objects → an object (crisp#637, P-123, verified live against
98
+ * .../objects/companies/general): 48px icon tile + name + type badge, a
99
+ * description line, then a Configuration/Attributes tab bar (Attio's own
100
+ * tab bar also carries Permissions/Templates/Notifications/Imports — out of
101
+ * scope here, see #613/#638; only the two rows this task covers are built).
102
+ * Tab content is a render prop, the same shape crisp's own `Tabs` already
103
+ * takes, so a consumer composes `ObjectGeneralSection`/
104
+ * `ObjectAppearanceSection` and `AttributesTable` underneath.
105
+ */
106
+ export function ObjectDetailPage({ icon, iconColor, name, typeLabel, description, tab, defaultTab = "configuration", onTabChange, attributeCount, configurationLabel: configurationLabelProp, attributesLabel: attributesLabelProp, children, }) {
107
+ const configurationLabel = useCrispMessage(configurationLabelProp, "Configuration");
108
+ const attributesLabel = useCrispMessage(attributesLabelProp, "Attributes");
109
+ const [internalTab, setInternalTab] = React.useState(defaultTab);
110
+ const active = tab ?? internalTab;
111
+ const setTab = (v) => {
112
+ const next = v;
113
+ if (tab === undefined)
114
+ setInternalTab(next);
115
+ onTabChange?.(next);
116
+ };
117
+ return (_jsx("div", { className: objectsettingsRecipe(), children: _jsxs("div", { className: "crisp-objectsettings-page", children: [_jsx("div", { className: "crisp-objectsettings-head", children: _jsxs("div", { style: { display: "flex", alignItems: "flex-start", gap: 12 }, children: [_jsx("span", { className: "crisp-objectsettings-icon crisp-objectsettings-icon--lg", style: { background: iconColor ?? "currentColor" }, children: icon }), _jsxs("div", { className: "crisp-objectsettings-heading", children: [_jsxs("h1", { className: "crisp-objectsettings-title", children: [name, typeLabel != null && (_jsx(Badge, { tone: "neutral", size: "small", children: typeLabel }))] }), description != null && (_jsx("p", { className: "crisp-objectsettings-subtitle", children: description }))] })] }) }), _jsx(Tabs, { value: active, onValueChange: setTab, tabs: [
118
+ { value: "configuration", label: configurationLabel },
119
+ {
120
+ value: "attributes",
121
+ label: attributesLabel,
122
+ badge: attributeCount,
123
+ },
124
+ ], children: (v) => children(v) })] }) }));
125
+ }
126
+ ObjectDetailPage.displayName = "ObjectDetailPage";
127
+ /** The Configuration tab's "General" section (crisp#637, P-123): plural/
128
+ * singular noun inputs side by side, then a slug input Attio locks for
129
+ * system objects. Composes `SettingsSection` + `FormField`, no new chrome. */
130
+ export function ObjectGeneralSection({ pluralNoun, onPluralNounChange, singularNoun, onSingularNounChange, slug, slugEditable = false, onSlugChange, titleLabel: titleLabelProp, descriptionLabel: descriptionLabelProp, pluralLabel: pluralLabelProp, singularLabel: singularLabelProp, slugLabel: slugLabelProp, slugHelpLabel: slugHelpLabelProp, }) {
131
+ const titleLabel = useCrispMessage(titleLabelProp, "General");
132
+ const descriptionLabel = useCrispMessage(descriptionLabelProp, "Set words to describe a single and multiple objects of this type");
133
+ const pluralLabel = useCrispMessage(pluralLabelProp, "Plural noun");
134
+ const singularLabel = useCrispMessage(singularLabelProp, "Singular noun");
135
+ const slugLabel = useCrispMessage(slugLabelProp, "Slug");
136
+ const slugHelpLabel = useCrispMessage(slugHelpLabelProp, "You can't change the slug of a system object");
137
+ const pluralId = React.useId();
138
+ const singularId = React.useId();
139
+ const slugId = React.useId();
140
+ return (_jsxs(SettingsSection, { title: titleLabel, description: descriptionLabel, children: [_jsxs("div", { className: "crisp-objectsettings-fieldrow", children: [_jsx(FormField, { label: pluralLabel, htmlFor: pluralId, children: _jsx(Input, { id: pluralId, value: pluralNoun, onChange: (e) => onPluralNounChange?.(e.target.value) }) }), _jsx(FormField, { label: singularLabel, htmlFor: singularId, children: _jsx(Input, { id: singularId, value: singularNoun, onChange: (e) => onSingularNounChange?.(e.target.value) }) })] }), _jsx(FormField, { label: slugLabel, htmlFor: slugId, description: !slugEditable ? slugHelpLabel : undefined, children: _jsx(Input, { id: slugId, value: slug, disabled: !slugEditable, onChange: (e) => onSlugChange?.(e.target.value) }) })] }));
141
+ }
142
+ ObjectGeneralSection.displayName = "ObjectGeneralSection";
143
+ /** The Configuration tab's "Appearance" section (crisp#637, P-123): a single
144
+ * "Record page layout" row with a "Configure record page" button — the
145
+ * target flow itself (crisp's own record-page-layout editor) is a separate,
146
+ * unbuilt surface; this only renders the row and its click. */
147
+ export function ObjectAppearanceSection({ onConfigureRecordPage, titleLabel: titleLabelProp, rowLabel: rowLabelProp, rowDescriptionLabel: rowDescriptionLabelProp, actionLabel: actionLabelProp, }) {
148
+ const titleLabel = useCrispMessage(titleLabelProp, "Appearance");
149
+ const rowLabel = useCrispMessage(rowLabelProp, "Record page layout");
150
+ const rowDescriptionLabel = useCrispMessage(rowDescriptionLabelProp, "Configure tabs, widgets, and layout for this object's record pages");
151
+ const actionLabel = useCrispMessage(actionLabelProp, "Configure record page");
152
+ return (_jsx(SettingsSection, { title: titleLabel, children: _jsxs("div", { className: "crisp-objectsettings-fieldrow", style: { alignItems: "center" }, children: [_jsxs("div", { children: [_jsx("div", { style: { fontWeight: 500 }, children: rowLabel }), _jsx("p", { className: "crisp-objectsettings-subtitle", children: rowDescriptionLabel })] }), onConfigureRecordPage && (_jsx("button", { type: "button", className: "crisp-objectsettings-filterbtn", style: { justifySelf: "end" }, onClick: onConfigureRecordPage, children: actionLabel }))] }) }));
153
+ }
154
+ ObjectAppearanceSection.displayName = "ObjectAppearanceSection";
155
+ /**
156
+ * The Attributes tab's table (crisp#637, P-124, verified live): H2 +
157
+ * "Create attribute", a search+Filter row, then a DataTable of every
158
+ * attribute — drag handle (via `DataTable`'s own `onRowsReorder`) + type
159
+ * icon + name, Data type, a constraint label, and System (grey)/Enriched
160
+ * (purple) property badges. Row height is DataTable's own 36px; Attio's
161
+ * measures 40px here — a real, small divergence left to DataTable's owner
162
+ * rather than patched locally (see the PR).
163
+ */
164
+ export function AttributesTable({ attributes, onOpenAttribute, onEditAttribute, onArchiveAttribute, onReorder, onCreateAttribute, title: titleProp, subtitle: subtitleProp, createLabel: createLabelProp, searchPlaceholder: searchPlaceholderProp, filterLabel: filterLabelProp, nameColumnLabel: nameColumnLabelProp, dataTypeColumnLabel: dataTypeColumnLabelProp, constraintsColumnLabel: constraintsColumnLabelProp, propertiesColumnLabel: propertiesColumnLabelProp, systemLabel: systemLabelProp, enrichedLabel: enrichedLabelProp, editLabel: editLabelProp, archiveLabel: archiveLabelProp, rowActions, emptyLabel, }) {
165
+ const title = useCrispMessage(titleProp, "Attributes");
166
+ const subtitle = useCrispMessage(subtitleProp, "Modify and add object attributes");
167
+ const createLabel = useCrispMessage(createLabelProp, "Create attribute");
168
+ const searchPlaceholder = useCrispMessage(searchPlaceholderProp, "Search attributes");
169
+ const filterLabel = useCrispMessage(filterLabelProp, "Filter");
170
+ const nameColumnLabel = useCrispMessage(nameColumnLabelProp, "Name");
171
+ const dataTypeColumnLabel = useCrispMessage(dataTypeColumnLabelProp, "Data type");
172
+ const constraintsColumnLabel = useCrispMessage(constraintsColumnLabelProp, "Constraints");
173
+ const propertiesColumnLabel = useCrispMessage(propertiesColumnLabelProp, "Properties");
174
+ const systemLabel = useCrispMessage(systemLabelProp, "System");
175
+ const enrichedLabel = useCrispMessage(enrichedLabelProp, "Enriched");
176
+ const editLabel = useCrispMessage(editLabelProp, "Edit");
177
+ const archiveLabel = useCrispMessage(archiveLabelProp, "Archive");
178
+ const [query, setQuery] = React.useState("");
179
+ const filtered = React.useMemo(() => {
180
+ if (!query.trim())
181
+ return attributes;
182
+ const q = query.trim().toLowerCase();
183
+ return attributes.filter((a) => String(a.name ?? "")
184
+ .toLowerCase()
185
+ .includes(q));
186
+ }, [attributes, query]);
187
+ const columns = React.useMemo(() => [
188
+ {
189
+ key: "name",
190
+ header: nameColumnLabel,
191
+ render: (_v, row) => (_jsxs("span", { style: { display: "flex", alignItems: "center", gap: 8 }, children: [_jsx("span", { style: {
192
+ display: "inline-flex",
193
+ color: "var(--crisp-fg-muted, inherit)",
194
+ }, children: row.icon }), row.name] })),
195
+ },
196
+ { key: "dataType", header: dataTypeColumnLabel },
197
+ {
198
+ key: "constraint",
199
+ header: constraintsColumnLabel,
200
+ render: (_v, row) => row.constraint ?? null,
201
+ },
202
+ {
203
+ key: "properties",
204
+ header: propertiesColumnLabel,
205
+ render: (_v, row) => (_jsxs("span", { style: { display: "flex", gap: 6 }, children: [row.system && (_jsx(Badge, { tone: "neutral", size: "small", children: systemLabel })), row.enriched && (_jsx(Badge, { tone: "purple", size: "small", children: enrichedLabel }))] })),
206
+ },
207
+ {
208
+ key: "actions",
209
+ header: "",
210
+ align: "right",
211
+ render: (_v, row) => (_jsx(Menu, { align: "end", trigger: _jsx("button", { type: "button", "aria-label": `${row.name}`, style: {
212
+ display: "inline-flex",
213
+ border: "none",
214
+ background: "none",
215
+ cursor: "pointer",
216
+ color: "inherit",
217
+ padding: 4,
218
+ }, children: _jsx(MoreHorizontal, { size: 16 }) }), children: rowActions ? (rowActions(row)) : (_jsxs(_Fragment, { children: [_jsx(MenuItem, { onSelect: () => onEditAttribute?.(row.id), children: editLabel }), _jsx(MenuItem, { danger: true, onSelect: () => onArchiveAttribute?.(row.id), children: archiveLabel })] })) })),
219
+ },
220
+ ], [
221
+ nameColumnLabel,
222
+ dataTypeColumnLabel,
223
+ constraintsColumnLabel,
224
+ propertiesColumnLabel,
225
+ systemLabel,
226
+ enrichedLabel,
227
+ editLabel,
228
+ archiveLabel,
229
+ onEditAttribute,
230
+ onArchiveAttribute,
231
+ rowActions,
232
+ ]);
233
+ return (_jsx("div", { className: objectsettingsRecipe(), children: _jsxs("div", { className: "crisp-objectsettings-page", children: [_jsxs("div", { className: "crisp-objectsettings-head", children: [_jsxs("div", { className: "crisp-objectsettings-heading", children: [_jsx("h2", { className: "crisp-objectsettings-title", style: { fontSize: 16, lineHeight: "20px" }, children: title }), _jsx("p", { className: "crisp-objectsettings-subtitle", children: subtitle })] }), onCreateAttribute && (_jsx("button", { type: "button", className: "crisp-objectsettings-filterbtn", onClick: onCreateAttribute, children: createLabel }))] }), _jsxs("div", { className: "crisp-objectsettings-toolbar", children: [_jsxs("span", { className: "crisp-objectsettings-search", children: [_jsx(Search, { size: 15, strokeWidth: 1.75, "aria-hidden": "true" }), _jsx("input", { type: "search", placeholder: searchPlaceholder, value: query, onChange: (e) => setQuery(e.target.value), "aria-label": searchPlaceholder })] }), _jsxs("button", { type: "button", className: "crisp-objectsettings-filterbtn", children: [_jsx(SlidersHorizontal, { size: 14 }), filterLabel] })] }), _jsx(DataTable, { columns: columns, data: filtered, onRowClick: onOpenAttribute ? (row) => onOpenAttribute(row.id) : undefined, onRowsReorder: onReorder, empty: emptyLabel })] }) }));
234
+ }
235
+ AttributesTable.displayName = "AttributesTable";
236
+ /** Attio's own "Type" group, verbatim (crisp#637, P-126) — icons are
237
+ * crisp's Lucide choices, not Attio's proprietary set (golden rule 4). */
238
+ export const ATTRIBUTE_TYPE_OPTIONS = [
239
+ { value: "checkbox", label: "Checkbox", icon: CheckSquare },
240
+ { value: "currency", label: "Currency", icon: DollarSign },
241
+ { value: "date", label: "Date", icon: Calendar },
242
+ { value: "location", label: "Location", icon: MapPin },
243
+ { value: "multiselect", label: "Multi-select", icon: ListChecks },
244
+ { value: "number", label: "Number", icon: Hash },
245
+ { value: "phone", label: "Phone Number", icon: Phone },
246
+ { value: "rating", label: "Rating", icon: Star },
247
+ { value: "record", label: "Record", icon: FileText },
248
+ { value: "relationship", label: "Relationship", icon: Link2, badge: "Pro" },
249
+ { value: "select", label: "Select", icon: ListChecks },
250
+ { value: "status", label: "Status", icon: Flag },
251
+ { value: "text", label: "Text", icon: TypeIcon },
252
+ { value: "timestamp", label: "Timestamp", icon: Clock },
253
+ { value: "user", label: "User", icon: UserIcon },
254
+ ];
255
+ /** Attio's own "AI Autofill" group, verbatim (crisp#637, P-126) — opt in via
256
+ * `AttributeTypeSelect`'s `aiAutofillOptions`; omitted by default, since a
257
+ * consumer with no autofill backend of its own (GRC) has nothing to wire
258
+ * these to. */
259
+ export const ATTRIBUTE_AI_AUTOFILL_OPTIONS = [
260
+ { value: "classify-record", label: "Classify record", icon: Sparkles },
261
+ { value: "prompt-completion", label: "Prompt completion", icon: Sparkles },
262
+ { value: "summarize-record", label: "Summarize record", icon: Sparkles },
263
+ { value: "web-agent", label: "Web agent", icon: Sparkles },
264
+ ];
265
+ function toSelectOption(o, group) {
266
+ const Icon = o.icon;
267
+ return {
268
+ value: o.value,
269
+ keywords: o.label,
270
+ group,
271
+ label: (_jsxs("span", { className: "crisp-objectsettings-typeoption", children: [_jsx(Icon, { size: 14 }), _jsx("span", { className: "crisp-objectsettings-typeoption-label", children: o.label }), o.badge != null && (_jsx("span", { className: "crisp-objectsettings-typeoption-badge", children: o.badge }))] })),
272
+ };
273
+ }
274
+ /** The "Attribute Type" dropdown (crisp#637, P-126, verified live): a
275
+ * searchable, grouped `Select` — reused as-is, not a new picker primitive.
276
+ * Grouping and the per-item trailing "Pro" badge are composed through
277
+ * `Select`'s existing `group` field and rich `label` ReactNode. */
278
+ export function AttributeTypeSelect({ value, onValueChange, options = ATTRIBUTE_TYPE_OPTIONS, aiAutofillOptions, aiAutofillGroupLabel: aiAutofillGroupLabelProp, typeGroupLabel: typeGroupLabelProp, searchPlaceholder: searchPlaceholderProp, id, }) {
279
+ const aiAutofillGroupLabel = useCrispMessage(aiAutofillGroupLabelProp, "AI Autofill");
280
+ const typeGroupLabel = useCrispMessage(typeGroupLabelProp, "Type");
281
+ const searchPlaceholder = useCrispMessage(searchPlaceholderProp, "Search attribute types...");
282
+ const selectOptions = React.useMemo(() => {
283
+ const ai = (aiAutofillOptions ?? []).map((o) => toSelectOption(o, aiAutofillGroupLabel));
284
+ const type = options.map((o) => toSelectOption(o, typeGroupLabel));
285
+ return [...ai, ...type];
286
+ }, [aiAutofillOptions, options, aiAutofillGroupLabel, typeGroupLabel]);
287
+ return (_jsx(Select, { id: id, options: selectOptions, value: value, onValueChange: onValueChange, searchable: true, searchPlaceholder: searchPlaceholder, width: "full" }));
288
+ }
289
+ AttributeTypeSelect.displayName = "AttributeTypeSelect";
290
+ /**
291
+ * "Create attribute" (crisp#637, P-125, verified live): Attribute Type
292
+ * select (defaults to Text) + Name (autofocused) + Description (optional)
293
+ * + an AI-autofill switch row, footer Cancel/Create. Built on `FormDialog`
294
+ * in its plain (non-`createMode`) shape — Attio's own footer here has a
295
+ * real Cancel button, unlike the record-create dialog `createMode` models
296
+ * (crisp#548), so `createMode` would be the wrong fit even though the
297
+ * button carries the same ⌘↵ hint; that hint is rendered locally instead.
298
+ */
299
+ export function CreateAttributeDialog({ type, onTypeChange, typeOptions, aiAutofillTypeOptions, name, onNameChange, description, onDescriptionChange, aiAutofillEnabled = false, onAiAutofillChange, onSubmit, onClose, busy, error, title: titleProp, attributeTypeLabel: attributeTypeLabelProp, nameLabel: nameLabelProp, descriptionLabel: descriptionLabelProp, descriptionPlaceholder: descriptionPlaceholderProp, aiAutofillLabel: aiAutofillLabelProp, setUpAiAutofillLabel: setUpAiAutofillLabelProp, createLabel: createLabelProp, }) {
300
+ const title = useCrispMessage(titleProp, "Create attribute");
301
+ const attributeTypeLabel = useCrispMessage(attributeTypeLabelProp, "Attribute Type");
302
+ const nameLabel = useCrispMessage(nameLabelProp, "Name");
303
+ const descriptionLabel = useCrispMessage(descriptionLabelProp, "Description");
304
+ const descriptionPlaceholder = useCrispMessage(descriptionPlaceholderProp, "Add a description for this attribute");
305
+ const aiAutofillLabel = useCrispMessage(aiAutofillLabelProp, "AI autofill");
306
+ const setUpAiAutofillLabel = useCrispMessage(setUpAiAutofillLabelProp, "Set up AI autofill");
307
+ const createLabel = useCrispMessage(createLabelProp, "Create attribute");
308
+ const switchId = React.useId();
309
+ const nameId = React.useId();
310
+ return (_jsxs(FormDialog, { title: title, submitLabel: _jsxs(_Fragment, { children: [createLabel, _jsxs("span", { className: "crisp-objectsettings-kbd", "aria-hidden": "true", children: [_jsx("kbd", { children: "\u2318" }), _jsx("kbd", { children: "\u21B5" })] })] }), onSubmit: onSubmit, onClose: onClose, busy: busy, error: error, size: 520, className: objectsettingsRecipe(), children: [_jsx(FormField, { label: attributeTypeLabel, children: _jsx(AttributeTypeSelect, { value: type, onValueChange: onTypeChange, options: typeOptions, aiAutofillOptions: aiAutofillTypeOptions }) }), _jsx(FormField, { label: nameLabel, htmlFor: nameId, children: _jsx(Input, { id: nameId, autoFocus: true, value: name, onChange: (e) => onNameChange(e.target.value) }) }), _jsx(FormField, { label: descriptionLabel, optional: true, children: _jsx(Textarea, { placeholder: descriptionPlaceholder, value: description, onChange: (e) => onDescriptionChange(e.target.value) }) }), _jsxs("div", { className: "crisp-objectsettings-aiautofill", children: [_jsx("span", { className: "crisp-objectsettings-aiautofill-label", children: aiAutofillLabel }), _jsxs("label", { className: "crisp-objectsettings-aiautofill-row", htmlFor: switchId, children: [_jsx(Sparkles, { size: 14 }), _jsx("span", { children: setUpAiAutofillLabel }), _jsx(Switch, { id: switchId, checked: aiAutofillEnabled, onCheckedChange: onAiAutofillChange })] })] })] }));
311
+ }
312
+ CreateAttributeDialog.displayName = "CreateAttributeDialog";
313
+ /**
314
+ * This folder holds several components (`ObjectsList`, `ObjectDetailPage`,
315
+ * `AttributesTable`, `CreateAttributeDialog`, …), not one named after the
316
+ * slug — unlike every other product dir, whose docs `<ManualInstallation>`
317
+ * resolves an import binding from the folder slug ("avatargroup" →
318
+ * `AvatarGroup`) by reading this file's exports. This alias exists only so
319
+ * that resolver finds a match for "objectsettings"; consumers should import
320
+ * the real names above, never this one.
321
+ */
322
+ export const ObjectSettings = ObjectsList;
@@ -0,0 +1,62 @@
1
+ import { defineRecipe, resolveRecipe } from "../../core/index.js";
2
+ import { contract as c } from "../../tokens/index.js";
3
+ // Measured live against app.attio.com/<workspace>/settings/data/objects and
4
+ // .../objects/companies/{general,attributes} (crisp#637, P-122/P-123/P-124/
5
+ // P-125/P-126). Values with no matching scale token are one-off literals,
6
+ // same precedent as authshell's own 20px title (crisp#637 golden rule 3):
7
+ // the 24/600/28/-0.48 page title, the 12px/16px badge dot sizes, and the
8
+ // 30% squircle radius on the object-icon tile.
9
+ const config = defineRecipe({
10
+ name: "objectsettings",
11
+ base: `& .crisp-objectsettings-page { display: flex; flex-direction: column; gap: ${c.space["6"]}; }
12
+ & .crisp-objectsettings-head { display: flex; align-items: flex-start; justify-content: space-between; gap: ${c.space["4"]}; }
13
+ & .crisp-objectsettings-heading { display: flex; flex-direction: column; gap: ${c.space["0.5"]}; min-width: 0; }
14
+ & .crisp-objectsettings-title { display: flex; align-items: center; gap: ${c.space["2"]}; font-size: 24px; line-height: 28px; font-weight: ${c.weight.semibold}; letter-spacing: -0.48px; color: ${c.fgPrimary}; margin: 0; }
15
+ & .crisp-objectsettings-subtitle { font-size: ${c.text.lg}; line-height: 20px; font-weight: ${c.weight.medium}; color: ${c.fgMuted}; margin: 0; }
16
+
17
+ & .crisp-objectsettings-toolbar { display: flex; align-items: center; gap: ${c.space["2"]}; }
18
+ & .crisp-objectsettings-search { display: flex; align-items: center; flex: 1; gap: ${c.space["1.5"]}; height: ${c.size.menuRow}; padding: 0 ${c.space["2.5"]}; border-radius: ${c.radius.md}; background: ${c.bgSurface}; box-shadow: inset 0 0 0 1px ${c.borderDefault}; color: ${c.fgMuted}; transition: box-shadow .15s; }
19
+ & .crisp-objectsettings-search:focus-within { box-shadow: inset 0 0 0 1px ${c.bgBrandSolid}; }
20
+ & .crisp-objectsettings-search input { flex: 1; min-width: 0; border: none; outline: none; background: none; font-family: inherit; font-size: ${c.text.lg}; color: ${c.fgPrimary}; }
21
+ & .crisp-objectsettings-search input::placeholder { color: ${c.fgMuted}; }
22
+ & .crisp-objectsettings-filterbtn { display: inline-flex; align-items: center; gap: ${c.space["1.5"]}; height: ${c.size.menuRow}; padding: 0 ${c.space["2.5"]} 0 ${c.space["2"]}; border: none; border-radius: ${c.radius.lg}; background: ${c.bgSurface}; box-shadow: inset 0 0 0 1px ${c.borderDefault}; color: ${c.fgPrimary}; font-family: inherit; font-size: ${c.text.lg}; font-weight: ${c.weight.medium}; cursor: pointer; white-space: nowrap; }
23
+ & .crisp-objectsettings-filterbtn:hover { background: ${c.bgRaised}; }
24
+
25
+ /* Object / attribute icon tile — Attio's own squircle (measured
26
+ border-radius: 30%, not a scale token: it's a proportional radius
27
+ tied to the tile's own size, unlike every other radius crisp uses,
28
+ which is a fixed px). */
29
+ & .crisp-objectsettings-icon { display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0; width: 16px; height: 16px; border-radius: 30%; color: ${c.fgOnBrand}; }
30
+ & .crisp-objectsettings-icon--lg { width: 48px; height: 48px; border-radius: 16px; }
31
+
32
+ /* "Standard"/"Custom" type badge and the muted "No records" cell —
33
+ plain text, not a Badge pill (measured: transparent background). */
34
+ & .crisp-objectsettings-typebadge { font-size: ${c.text.sm}; font-weight: ${c.weight.medium}; color: ${c.fgNeutralSoft}; }
35
+ & .crisp-objectsettings-muted { color: ${c.fgMuted}; }
36
+
37
+ /* Attribute-type option row (icon + label + optional trailing badge),
38
+ composed as a Select option's \`label\` ReactNode. */
39
+ & .crisp-objectsettings-typeoption { display: flex; align-items: center; gap: ${c.space["2"]}; width: 100%; min-width: 0; }
40
+ & .crisp-objectsettings-typeoption-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
41
+ & .crisp-objectsettings-typeoption-badge { flex-shrink: 0; font-size: ${c.text.sm}; font-weight: ${c.weight.medium}; color: ${c.fgBrandSoft}; }
42
+
43
+ /* Create-attribute dialog's AI-autofill row and the submit button's
44
+ ⌘↵ hint (measured off the same dialog, same shape as Dialog's own
45
+ .crisp-dialog-submit-key, kept local rather than reaching into
46
+ another component's scoped classes). */
47
+ & .crisp-objectsettings-aiautofill { display: flex; flex-direction: column; gap: ${c.space["1.5"]}; }
48
+ & .crisp-objectsettings-aiautofill-label { font-size: ${c.text.md}; font-weight: ${c.weight.medium}; color: ${c.fgMuted}; }
49
+ & .crisp-objectsettings-aiautofill-row { display: flex; align-items: center; gap: ${c.space["2"]}; height: ${c.size.menuRow}; padding: 0 ${c.space["2.5"]}; border-radius: ${c.radius.md}; box-shadow: inset 0 0 0 1px ${c.borderDefault}; font-size: ${c.text.lg}; color: ${c.fgPrimary}; cursor: pointer; }
50
+ & .crisp-objectsettings-aiautofill-row > svg { flex-shrink: 0; color: ${c.fgMuted}; }
51
+ & .crisp-objectsettings-aiautofill-row > span { flex: 1; }
52
+ & .crisp-objectsettings-kbd { display: inline-flex; align-items: center; gap: ${c.space["1"]}; margin-inline-start: ${c.space["1"]}; }
53
+ & .crisp-objectsettings-kbd kbd { box-sizing: border-box; display: inline-flex; align-items: center; justify-content: center; width: 16px; height: 16px; padding: 0 3px; border-radius: 4px; background: transparent; color: inherit; font-family: inherit; font-style: normal; font-size: 10px; }
54
+
55
+ & .crisp-objectsettings-fieldrow { display: grid; grid-template-columns: 1fr 1fr; gap: ${c.space["4"]}; }
56
+ `,
57
+ variants: {},
58
+ defaultVariants: {},
59
+ compoundVariants: [],
60
+ });
61
+ export const objectsettingsRecipe = resolveRecipe(config);
62
+ export default config;
@@ -6,6 +6,14 @@ import { contract as c, elevation as e } from "../../tokens/index.js";
6
6
  // column (transparent, border-right) and a flexible main area whose tab bar
7
7
  // (14/500 labels over a sliding ink underline) switches content — Overview shows
8
8
  // a bordered Highlights stat-grid and an Activity feed.
9
+ //
10
+ // `.crisp-recordpage-panel`'s own `padding-top` (c.space["4"], 16px) was
11
+ // dropped (your-moon/crisp#588): this panel is only ever rendered as Tabs'
12
+ // own children (every branch of `builtinPanel` in recordpage.tsx, all fed to
13
+ // `<Tabs>` below), so it sat inside `.crisp-tabs-panel`, which now carries
14
+ // its own measured 32px top-inset by default — keeping both would have
15
+ // stacked to 48px. Caught by tracing every consumer of `Tabs` before
16
+ // shipping its new default, not by a screenshot.
9
17
  const config = defineRecipe({
10
18
  name: "recordpage",
11
19
  base: `
@@ -125,7 +133,8 @@ const config = defineRecipe({
125
133
 
126
134
  & .crisp-recordpage-main { flex: 1; min-width: 0; display: flex; flex-direction: column; min-height: 0; overflow-y: auto; padding: ${c.space["3"]} ${c.space["5"]}; }
127
135
 
128
- & .crisp-recordpage-panel { padding-top: ${c.space["4"]}; display: flex; flex-direction: column; gap: ${c.space["4"]}; }
136
+ /* no padding-top: Tabs' own panel supplies it now (#588) */
137
+ & .crisp-recordpage-panel { display: flex; flex-direction: column; gap: ${c.space["4"]}; }
129
138
  & .crisp-recordpage-card { border: 1px solid ${c.borderSubtle}; border-radius: ${c.radius["2xl"]}; background: ${c.bgSurface}; overflow: hidden; }
130
139
  & .crisp-recordpage-card-title { font-size: ${c.text.lg}; font-weight: ${c.weight.semibold}; letter-spacing: -0.14px; color: ${c.fgPrimary}; padding: ${c.space["3"]} ${c.space["3"]} ${c.space["2"]}; }
131
140
  & .crisp-recordpage-hl-card { border: 1px solid ${c.borderSubtle}; border-radius: ${c.radius["2xl"]}; overflow: hidden; }
@@ -1,5 +1,5 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { ArrowDown, ArrowDownWideNarrow, ArrowLeft, ArrowRight, ArrowUp, ArrowUpDown, ArrowUpNarrowWide, ArrowUpRight, AtSign, Briefcase, Building2, Calendar, Check, CircleHelp, CircleUserRound, Copy, CopyPlus, CornerDownLeft, Download, EyeOff, Globe, GripVertical, Hash, Inbox, ListFilter, ListPlus, Mail, MapPin, MoreHorizontal, Pencil, Plus, Star, Tag, Trash2, TrendingUp, Users, Workflow, X, } from "lucide-react";
2
+ import { ArrowDown, ArrowDownWideNarrow, ArrowLeft, ArrowRight, ArrowUp, ArrowUpDown, ArrowUpNarrowWide, ArrowUpRight, AtSign, Briefcase, Building2, Calendar, Check, CircleHelp, CircleUserRound, Copy, CopyPlus, CornerDownLeft, Download, EyeOff, Globe, GripVertical, Hash, Inbox, ListFilter, ListPlus, Mail, MapPin, MoreHorizontal, Pencil, Plug, Plus, Star, Tag, Trash2, TrendingUp, Upload, Users, Workflow, X, } from "lucide-react";
3
3
  import * as React from "react";
4
4
  import { createPortal } from "react-dom";
5
5
  import { Avatar } from "../../components/avatar/index.js";
@@ -8,15 +8,67 @@ import { Button } from "../../components/button/index.js";
8
8
  import { Checkbox } from "../../components/checkbox/index.js";
9
9
  import { DatePickerField } from "../../components/datepickerfield/index.js";
10
10
  import { Dialog, DialogBody, DialogFooter } from "../../components/dialog/index.js";
11
- import { EmptyState } from "../../components/emptystate/index.js";
11
+ import { EmptyState, } from "../../components/emptystate/index.js";
12
12
  import { Input } from "../../components/input/index.js";
13
13
  import { Menu, MenuItem, useMenuKeyboard } from "../../components/menu/index.js";
14
14
  import { MultiSelect } from "../../components/multiselect/index.js";
15
15
  import { Select } from "../../components/select/index.js";
16
16
  import { Switch } from "../../components/switch/index.js";
17
+ import { Tooltip } from "../../components/tooltip/index.js";
17
18
  import { isInsideLaterFloatingLayer, isRtlElement, useCrispComponentDefaults, usePrefersReducedMotion, } from "../../core/index.js";
18
19
  import { companyLogos } from "./company-logos.js";
19
20
  import { recordstableRecipe } from "./recordstable.recipe.js";
21
+ // #534: whether an element's own content is being cut off by its own box --
22
+ // the same fact `.crisp-recordstable-th-label`'s CSS ellipsis is silently
23
+ // applying, exported so the comparison itself (not layout) can be
24
+ // mutation-tested without a real browser. Identical to DataTable's own
25
+ // `isLabelTruncated` (components/datatable/datatable.tsx) -- not shared
26
+ // because `product/` can depend on `components/` but not the other way, and
27
+ // this file already duplicates DataTable's other measured header behaviour
28
+ // rather than reach across that boundary.
29
+ export function isLabelTruncated(el) {
30
+ return el.scrollWidth > el.clientWidth;
31
+ }
32
+ /**
33
+ * The header label span: plain when it fits, wrapped in the shared `Tooltip`
34
+ * (full text, `asChild` so the wrapper never displaces the ellipsis CSS the
35
+ * span already carries -- see `Tooltip`'s own `asChild` doc) once it's
36
+ * actually cut off. Re-measures on mount and on resize (column drag,
37
+ * viewport change, label change) via `ResizeObserver`; SSR-safe the same way
38
+ * `MiddleTruncate` is -- the DOM is only touched inside an effect, so the
39
+ * server (and the first client paint) renders the plain span.
40
+ */
41
+ function ThLabel({ label }) {
42
+ const ref = React.useRef(null);
43
+ const [overflowing, setOverflowing] = React.useState(false);
44
+ // biome-ignore lint/correctness/useExhaustiveDependencies: `label` isn't read in the body, but a rename (col.label changing with the column's own width unchanged) needs a re-measure a ResizeObserver on `el` won't fire for -- same reasoning as Tabs' own re-measure effect.
45
+ React.useLayoutEffect(() => {
46
+ const el = ref.current;
47
+ if (!el)
48
+ return;
49
+ const measure = () => setOverflowing(isLabelTruncated(el));
50
+ measure();
51
+ if (typeof ResizeObserver === "undefined")
52
+ return;
53
+ const ro = new ResizeObserver(measure);
54
+ ro.observe(el);
55
+ return () => ro.disconnect();
56
+ }, [label]);
57
+ // `Tooltip` always wraps the SAME span (never swapped in/out of the tree
58
+ // based on `overflowing`) so the span's own DOM node identity survives
59
+ // every toggle -- a real-browser-only bug (happy-dom has no
60
+ // ResizeObserver, so the test suite never exercised this path): swapping
61
+ // between a bare `<span>` and `<Tooltip asChild><span/></Tooltip>` changes
62
+ // the element type at this position, so React unmounts the old span and
63
+ // mounts a new one; the effect above never re-runs (its deps are
64
+ // `[label]`, unchanged), so its ResizeObserver keeps watching the now
65
+ // -detached old node, whose deferred initial callback then reports 0x0 and
66
+ // flips `overflowing` straight back to false -- the tooltip would never
67
+ // actually show. `open={false}` when it fits keeps the Tooltip mounted
68
+ // (asChild costs no extra DOM either way) but pins it permanently closed,
69
+ // so hovering a non-overflowing header still opens nothing.
70
+ return (_jsx(Tooltip, { content: label, asChild: true, open: overflowing ? undefined : false, children: _jsx("span", { ref: ref, className: "crisp-recordstable-th-label", children: label }) }));
71
+ }
20
72
  const EDITABLE = new Set(["text", "number", "url", "currency"]);
21
73
  // types whose cell is an always-interactive picker (not double-click-to-edit)
22
74
  const PICKER = new Set(["status", "reference", "date", "tags"]);
@@ -100,6 +152,8 @@ export const recordsTableLabels = {
100
152
  setRecordPlaceholder: "Set a record…",
101
153
  noRecords: "No records yet",
102
154
  untitled: "Untitled",
155
+ importCsv: "Import CSV",
156
+ connectDataSource: "Connect data source",
103
157
  agg: {
104
158
  none: "None",
105
159
  count: "Count all",
@@ -111,6 +165,9 @@ export const recordsTableLabels = {
111
165
  },
112
166
  op: {
113
167
  contains: "contains",
168
+ doesnotcontain: "does not contain",
169
+ startswith: "starts with",
170
+ endswith: "ends with",
114
171
  is: "is",
115
172
  isnot: "is not",
116
173
  empty: "is empty",
@@ -137,8 +194,17 @@ const compact = new Intl.NumberFormat("en", {
137
194
  const CHECK_W = 40;
138
195
  const ADDCOL_W = 44;
139
196
  const colWidth = (col) => col.width ?? (col.type === "entity" ? 240 : 180);
140
- const OP_ORDER = ["contains", "is", "isnot", "empty", "notempty"];
141
- const opNeedsValue = (op) => op === "contains" || op === "is" || op === "isnot";
197
+ const OP_ORDER = [
198
+ "contains",
199
+ "doesnotcontain",
200
+ "startswith",
201
+ "endswith",
202
+ "is",
203
+ "isnot",
204
+ "empty",
205
+ "notempty",
206
+ ];
207
+ const opNeedsValue = (op) => op !== "empty" && op !== "notempty";
142
208
  function matchesFilter(col, row, cond) {
143
209
  if (cond.op === "empty")
144
210
  return !isFilled(col, row);
@@ -152,6 +218,12 @@ function matchesFilter(col, row, cond) {
152
218
  return true;
153
219
  if (cond.op === "contains")
154
220
  return t.includes(v);
221
+ if (cond.op === "doesnotcontain")
222
+ return !t.includes(v);
223
+ if (cond.op === "startswith")
224
+ return t.startsWith(v);
225
+ if (cond.op === "endswith")
226
+ return t.endsWith(v);
155
227
  if (cond.op === "is")
156
228
  return t === v;
157
229
  return t !== v; // isnot
@@ -433,7 +505,7 @@ function CreateRecordDialog({ open, onOpenChange, columns, entityLabel, onCreate
433
505
  * and a floating bulk-action bar (delete / deselect) when rows are selected.
434
506
  * Selection can be controlled via `selectedIds`/`onSelectionChange`.
435
507
  */
436
- export function RecordsTable({ labels, columns: columnsProp = defaultColumns, rows: rowsProp = defaultRows, selectedIds, onSelectionChange, showNew = true, allowAddColumn = true, onOpenRecord, onOpenRecordPage, avatarTone = "brand", className, ...rest }) {
508
+ export function RecordsTable({ labels, columns: columnsProp = defaultColumns, rows: rowsProp = defaultRows, selectedIds, onSelectionChange, showNew = true, allowAddColumn = true, onOpenRecord, onOpenRecordPage, avatarTone = "brand", emptyStateExtra, className, ...rest }) {
437
509
  const [cols, setCols] = React.useState(columnsProp);
438
510
  const [data, setData] = React.useState(rowsProp);
439
511
  // The table owns `cols`/`data` so it can rename a column, edit a cell, or
@@ -1101,7 +1173,7 @@ export function RecordsTable({ labels, columns: columnsProp = defaultColumns, ro
1101
1173
  setDragCol(col.key);
1102
1174
  e.dataTransfer.effectAllowed = "move";
1103
1175
  }
1104
- : undefined, onDragEnd: () => setDragCol(null), onClick: (e) => openColumnMenu(col.key, e.currentTarget), children: [Icon ? _jsx(Icon, { size: 16, strokeWidth: 1.75 }) : null, _jsx("span", { className: "crisp-recordstable-th-label", children: col.label }), _jsx("span", { className: "crisp-recordstable-sort", children: _jsx("svg", { width: "12", height: "12", viewBox: "0 0 16 16", fill: "none", stroke: "currentColor", strokeWidth: "1.5", "aria-hidden": "true", children: _jsx("path", { d: "m4 6 4 4 4-4" }) }) })] })), openMenu === `col:${col.key}` &&
1176
+ : undefined, onDragEnd: () => setDragCol(null), onClick: (e) => openColumnMenu(col.key, e.currentTarget), children: [Icon ? _jsx(Icon, { size: 16, strokeWidth: 1.75 }) : null, _jsx(ThLabel, { label: col.label }), _jsx("span", { className: "crisp-recordstable-sort", children: _jsx("svg", { width: "12", height: "12", viewBox: "0 0 16 16", fill: "none", stroke: "currentColor", strokeWidth: "1.5", "aria-hidden": "true", children: _jsx("path", { d: "m4 6 4 4 4-4" }) }) })] })), openMenu === `col:${col.key}` &&
1105
1177
  rootRef.current &&
1106
1178
  createPortal(_jsxs("div", { ref: menuRef, className: "crisp-recordstable-menu crisp-recordstable-menu--fixed crisp-recordstable-menu--hdr", role: "menu", "aria-label": `${col.label} column`, style: menuPos ?? undefined, onKeyDown: onMenuKeyDown, children: [_jsxs("button", { type: "button", className: "crisp-recordstable-menu-item", role: "menuitem", tabIndex: -1, onClick: () => sortBy(col.key, "asc"), children: [_jsx(ArrowUp, { size: 15, strokeWidth: 1.75 }), t.sortAscending] }), _jsxs("button", { type: "button", className: "crisp-recordstable-menu-item", role: "menuitem", tabIndex: -1, onClick: () => sortBy(col.key, "desc"), children: [_jsx(ArrowDown, { size: 15, strokeWidth: 1.75 }), t.sortDescending] }), _jsxs("button", { type: "button", className: "crisp-recordstable-menu-item", role: "menuitem", tabIndex: -1, disabled: i <= 1, onClick: () => moveColumn(col.key, -1), children: [_jsx(ArrowLeft, { "data-rtl-flip": "", size: 15, strokeWidth: 1.75 }), t.moveLeft] }), _jsxs("button", { type: "button", className: "crisp-recordstable-menu-item", role: "menuitem", tabIndex: -1, disabled: i === 0 || i >= cols.length - 1, onClick: () => moveColumn(col.key, 1), children: [_jsx(ArrowRight, { "data-rtl-flip": "", size: 15, strokeWidth: 1.75 }), t.moveRight] }), _jsx("div", { className: "crisp-recordstable-menu-sep" }), _jsxs("button", { type: "button", className: "crisp-recordstable-menu-item", role: "menuitem", tabIndex: -1, onClick: () => {
1107
1179
  setRenamingCol(col.key);
@@ -1170,9 +1242,19 @@ export function RecordsTable({ labels, columns: columnsProp = defaultColumns, ro
1170
1242
  }, children: res ? (_jsxs(_Fragment, { children: [_jsx("span", { className: "crisp-recordstable-sum-label", children: t.agg[a] }), _jsx("span", { className: "crisp-recordstable-sum-value", children: res.value })] })) : (_jsx("span", { className: "crisp-recordstable-sum-label", children: t.calculate })) }), sumOpen &&
1171
1243
  rootRef.current &&
1172
1244
  createPortal(_jsx("div", { ref: menuRef, className: "crisp-recordstable-menu crisp-recordstable-menu--fixed crisp-recordstable-menu--up", role: "menu", "aria-label": `${col.label} summary`, style: menuPos ?? undefined, onKeyDown: onMenuKeyDown, children: aggOptions(col).map((opt) => (_jsxs("button", { type: "button", className: "crisp-recordstable-menu-item", role: "menuitemradio", tabIndex: -1, "aria-checked": a === opt, "data-on": a === opt ? "" : undefined, onClick: () => setAggValue(col.key, opt), children: [t.agg[opt], a === opt ? (_jsx(Check, { className: "crisp-recordstable-menu-check", size: 15, strokeWidth: 1.75 })) : null] }, opt))) }), rootRef.current)] }) }, col.key));
1173
- }), _jsx("td", { className: "crisp-recordstable-sum crisp-recordstable-addcol" })] }) })] }), visibleRows.length === 0 ? (_jsx("div", { className: "crisp-recordstable-empty", children: _jsx(EmptyState, { variant: "page", icon: activeFilters ? (_jsx(ListFilter, { size: 18, strokeWidth: 1.75, "aria-hidden": "true" })) : (_jsx(Inbox, { size: 18, strokeWidth: 1.75, "aria-hidden": "true" })), title: activeFilters ? "No records match that filter" : t.noRecords, description: activeFilters
1245
+ }), _jsx("td", { className: "crisp-recordstable-sum crisp-recordstable-addcol" })] }) })] }), visibleRows.length === 0 ? (_jsx("div", { className: "crisp-recordstable-empty", children: _jsx(EmptyState
1246
+ // #603: the unfiltered zero-records state, when the consumer
1247
+ // opts into the extra CTAs, uses Attio's own larger measured
1248
+ // tile (Users/Workspaces, 138px) instead of the 45px
1249
+ // filtered-to-zero ring -- two different Attio surfaces.
1250
+ , {
1251
+ // #603: the unfiltered zero-records state, when the consumer
1252
+ // opts into the extra CTAs, uses Attio's own larger measured
1253
+ // tile (Users/Workspaces, 138px) instead of the 45px
1254
+ // filtered-to-zero ring -- two different Attio surfaces.
1255
+ variant: !activeFilters && emptyStateExtra ? "records" : "page", icon: activeFilters ? (_jsx(ListFilter, { size: 18, strokeWidth: 1.75, "aria-hidden": "true" })) : (_jsx(Inbox, { size: !activeFilters && emptyStateExtra ? 40 : 18, strokeWidth: 1.25, "aria-hidden": "true" })), title: activeFilters ? "No records match that filter" : t.noRecords, description: activeFilters
1174
1256
  ? "None of your records match the filters that are set."
1175
- : `Create your first ${cols.find((c) => c.type === "entity")?.label?.toLowerCase() ?? "record"} to get started.`, action: activeFilters ? (_jsxs(Button, { intent: "primary", size: "sm", onClick: () => setFilterConds([]), children: [_jsx(ListFilter, { size: 15, strokeWidth: 1.75 }), t.clearFilter] })) : (_jsxs(Button, { intent: "primary", size: "sm", onClick: addRow, children: [_jsx(Plus, { size: 15, strokeWidth: 1.75 }), t.newRecord] })) }) })) : (_jsxs("button", { type: "button", className: "crisp-recordstable-addrow", onClick: addRow, children: [_jsx(Plus, { size: 15, strokeWidth: 1.75, "aria-hidden": "true" }), t.newRecord] }))] }), _jsx("div", { className: "crisp-recordstable-live", role: "status", "aria-live": "polite", children: selectionAnnouncement }), selected.size > 0 && (_jsxs(BulkBar, { count: selected.size, countLabel: t.selectedCount, onClear: () => setSelected(new Set()), children: [_jsx(BulkBarButton, { icon: _jsx(ListPlus, { size: 15, strokeWidth: 1.75 }), onClick: addToList, children: t.addToList }), _jsx(BulkBarButton, { icon: _jsx(Mail, { size: 15, strokeWidth: 1.75 }), onClick: sendEmail, children: t.sendEmail }), _jsx(BulkBarButton, { icon: _jsx(Workflow, { size: 15, strokeWidth: 1.75 }), onClick: runWorkflow, children: t.runWorkflow }), _jsxs(Menu, { align: "end", "aria-label": t.moreActions, trigger: _jsx(BulkBarButton, { icon: _jsx(MoreHorizontal, { size: 15, strokeWidth: 1.75 }), children: t.more }), children: [_jsx(MenuItem, { icon: _jsx(Download, { size: 15, strokeWidth: 1.75 }), onSelect: exportSelected, children: t.exportCsv }), _jsx(MenuItem, { icon: _jsx(Trash2, { size: 15, strokeWidth: 1.75 }), danger: true, onSelect: deleteSelected, children: t.delete })] })] })), _jsx(CreateRecordDialog, { labels: t, open: creating, onOpenChange: setCreating, columns: cols, entityLabel: cols.find((c) => c.type === "entity")?.label ?? "record", onCreate: (row) => setData((prev) => [...prev, row]) }), ctxMenu &&
1257
+ : `Create your first ${cols.find((c) => c.type === "entity")?.label?.toLowerCase() ?? "record"} to get started.`, action: activeFilters ? (_jsxs(Button, { intent: "primary", size: "sm", onClick: () => setFilterConds([]), children: [_jsx(ListFilter, { size: 15, strokeWidth: 1.75 }), t.clearFilter] })) : (_jsxs(_Fragment, { children: [_jsxs(Button, { intent: "primary", size: "sm", onClick: addRow, children: [_jsx(Plus, { size: 15, strokeWidth: 1.75 }), t.newRecord] }), emptyStateExtra?.onImportCsv && (_jsxs(Button, { intent: "neutral", size: "sm", onClick: emptyStateExtra.onImportCsv, children: [_jsx(Upload, { size: 15, strokeWidth: 1.75 }), t.importCsv] })), emptyStateExtra?.onConnectDataSource && (_jsxs(Button, { intent: "neutral", size: "sm", onClick: emptyStateExtra.onConnectDataSource, children: [_jsx(Plug, { size: 15, strokeWidth: 1.75 }), t.connectDataSource] }))] })), learnMore: !activeFilters ? emptyStateExtra?.learnMore : undefined }) })) : (_jsxs("button", { type: "button", className: "crisp-recordstable-addrow", onClick: addRow, children: [_jsx(Plus, { size: 15, strokeWidth: 1.75, "aria-hidden": "true" }), t.newRecord] }))] }), _jsx("div", { className: "crisp-recordstable-live", role: "status", "aria-live": "polite", children: selectionAnnouncement }), selected.size > 0 && (_jsxs(BulkBar, { count: selected.size, countLabel: t.selectedCount, onClear: () => setSelected(new Set()), children: [_jsx(BulkBarButton, { icon: _jsx(ListPlus, { size: 15, strokeWidth: 1.75 }), onClick: addToList, children: t.addToList }), _jsx(BulkBarButton, { icon: _jsx(Mail, { size: 15, strokeWidth: 1.75 }), onClick: sendEmail, children: t.sendEmail }), _jsx(BulkBarButton, { icon: _jsx(Workflow, { size: 15, strokeWidth: 1.75 }), onClick: runWorkflow, children: t.runWorkflow }), _jsxs(Menu, { align: "end", "aria-label": t.moreActions, trigger: _jsx(BulkBarButton, { icon: _jsx(MoreHorizontal, { size: 15, strokeWidth: 1.75 }), children: t.more }), children: [_jsx(MenuItem, { icon: _jsx(Download, { size: 15, strokeWidth: 1.75 }), onSelect: exportSelected, children: t.exportCsv }), _jsx(MenuItem, { icon: _jsx(Trash2, { size: 15, strokeWidth: 1.75 }), danger: true, onSelect: deleteSelected, children: t.delete })] })] })), _jsx(CreateRecordDialog, { labels: t, open: creating, onOpenChange: setCreating, columns: cols, entityLabel: cols.find((c) => c.type === "entity")?.label ?? "record", onCreate: (row) => setData((prev) => [...prev, row]) }), ctxMenu &&
1176
1258
  createPortal(_jsxs("div", { ref: ctxRef, className: "crisp-recordstable-menu crisp-recordstable-ctx", role: "menu", "aria-label": t.recordActions, style: { position: "fixed", top: ctxMenu.y, left: ctxMenu.x }, onKeyDown: onCtxKeyDown, children: [_jsxs("button", { type: "button", className: "crisp-recordstable-menu-item", role: "menuitem", tabIndex: -1, onClick: () => copyRow(ctxMenu.id), children: [_jsx(Copy, { size: 15, strokeWidth: 1.75 }), t.copy] }), _jsxs("button", { type: "button", className: "crisp-recordstable-menu-item", role: "menuitem", tabIndex: -1, onClick: () => duplicateRow(ctxMenu.id), children: [_jsx(CopyPlus, { size: 15, strokeWidth: 1.75 }), t.duplicate] }), _jsxs("button", { type: "button", className: "crisp-recordstable-menu-item", role: "menuitem", tabIndex: -1, onClick: () => pinRow(ctxMenu.id), children: [_jsx(Star, { size: 15, strokeWidth: 1.75 }), t.addToList] }), _jsxs("button", { type: "button", className: "crisp-recordstable-menu-item crisp-recordstable-menu-item--danger", role: "menuitem", tabIndex: -1, onClick: () => deleteRow(ctxMenu.id), children: [_jsx(Trash2, { size: 15, strokeWidth: 1.75 }), t.delete] })] }), rootRef.current ?? document.body)] }));
1177
1259
  }
1178
1260
  export const defaultColumns = [