@crisp-ui-kit/crisp 0.54.10 → 0.54.11

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 (47) hide show
  1. package/dist/cjs/components/datatable/datatable.js +121 -10
  2. package/dist/cjs/components/datatable/datatable.recipe.js +24 -0
  3. package/dist/cjs/components/tabletoolbar/tabletoolbar.chrome.js +25 -2
  4. package/dist/cjs/components/tabletoolbar/tabletoolbar.js +93 -43
  5. package/dist/cjs/components/tabletoolbar/tabletoolbar.recipe.js +28 -9
  6. package/dist/cjs/product/recordpage/column.js +19 -0
  7. package/dist/cjs/product/recordpage/recordpage.js +5 -4
  8. package/dist/cjs/product/recordpage/test-fixtures.js +55 -0
  9. package/dist/cjs/product/recordstable/index.js +1 -1
  10. package/dist/cjs/product/recordstable/recordstable.js +5 -0
  11. package/dist/cjs/product/savedviews/savedviews.recipe.js +13 -11
  12. package/dist/cjs/product/taskstable/taskstable.js +5 -0
  13. package/dist/datatable.css +14 -0
  14. package/dist/datatable.layered.css +14 -0
  15. package/dist/esm/components/datatable/datatable.js +121 -10
  16. package/dist/esm/components/datatable/datatable.recipe.js +24 -0
  17. package/dist/esm/components/tabletoolbar/tabletoolbar.chrome.js +25 -2
  18. package/dist/esm/components/tabletoolbar/tabletoolbar.js +93 -43
  19. package/dist/esm/components/tabletoolbar/tabletoolbar.recipe.js +28 -9
  20. package/dist/esm/product/recordpage/column.js +16 -0
  21. package/dist/esm/product/recordpage/recordpage.js +2 -1
  22. package/dist/esm/product/recordpage/test-fixtures.js +52 -0
  23. package/dist/esm/product/recordstable/index.js +1 -1
  24. package/dist/esm/product/recordstable/recordstable.js +5 -0
  25. package/dist/esm/product/savedviews/savedviews.recipe.js +13 -11
  26. package/dist/esm/product/taskstable/taskstable.js +5 -0
  27. package/dist/recordstable.css +19 -2
  28. package/dist/recordstable.layered.css +19 -2
  29. package/dist/savedviews.css +6 -18
  30. package/dist/savedviews.layered.css +6 -18
  31. package/dist/styles.css +62 -23
  32. package/dist/styles.layered.css +62 -23
  33. package/dist/tabletoolbar.css +23 -3
  34. package/dist/tabletoolbar.layered.css +23 -3
  35. package/dist/types/components/datatable/datatable.d.ts +30 -3
  36. package/dist/types/components/tabletoolbar/tabletoolbar.d.ts +26 -1
  37. package/dist/types/product/recordpage/column.d.ts +112 -0
  38. package/dist/types/product/recordpage/recordpage.d.ts +1 -1
  39. package/dist/types/product/recordpage/test-fixtures.d.ts +3 -0
  40. package/dist/types/product/recordpanel/recordpanel.d.ts +1 -1
  41. package/dist/types/product/recordstable/index.d.ts +1 -1
  42. package/dist/types/product/recordstable/recordstable.d.ts +6 -1
  43. package/dist/types/product/taskstable/taskstable.d.ts +5 -0
  44. package/package.json +1 -1
  45. /package/dist/cjs/product/{recordstable → recordpage}/editable-field.js +0 -0
  46. /package/dist/esm/product/{recordstable → recordpage}/editable-field.js +0 -0
  47. /package/dist/types/product/{recordstable → recordpage}/editable-field.d.ts +0 -0
@@ -47,7 +47,8 @@ const spinner_1 = require("../../components/spinner");
47
47
  const switch_1 = require("../../components/switch");
48
48
  const tabs_1 = require("../../components/tabs");
49
49
  const core_1 = require("../../core");
50
- const recordstable_1 = require("../recordstable");
50
+ const column_1 = require("./column");
51
+ const editable_field_1 = require("./editable-field");
51
52
  const recordpage_recipe_1 = require("./recordpage.recipe");
52
53
  function valueEmpty(v) {
53
54
  return v == null || v === "" || (Array.isArray(v) && v.length === 0);
@@ -212,7 +213,7 @@ function TagsValue({ values }) {
212
213
  const [expanded, setExpanded] = React.useState(false);
213
214
  const overflow = values.length - 1;
214
215
  const shown = expanded ? values : values.slice(0, 1);
215
- return ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [shown.map((t) => ((0, jsx_runtime_1.jsx)("span", { className: "crisp-recordstable-tag", "data-tone": (0, recordstable_1.tagTone)(t), children: t }, t))), !expanded && overflow > 0 && ((0, jsx_runtime_1.jsxs)("button", { type: "button", className: "crisp-recordpage-morevalues", onClick: () => setExpanded(true), children: ["+", overflow] }))] }));
216
+ return ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [shown.map((t) => ((0, jsx_runtime_1.jsx)("span", { className: "crisp-recordstable-tag", "data-tone": (0, column_1.tagTone)(t), children: t }, t))), !expanded && overflow > 0 && ((0, jsx_runtime_1.jsxs)("button", { type: "button", className: "crisp-recordpage-morevalues", onClick: () => setExpanded(true), children: ["+", overflow] }))] }));
216
217
  }
217
218
  function FieldValue({ col, row, labels, }) {
218
219
  const v = row[col.key];
@@ -316,7 +317,7 @@ function EmptyCard({ icon, children, }) {
316
317
  */
317
318
  function RecordDetails({ record, columns, sections, onEdit, labels, className, ...rest }) {
318
319
  const t = useRecordPageLabels(labels);
319
- const { editingKey, pendingKey, error, openEditing, closeEditing, commit } = (0, recordstable_1.useEditableField)(onEdit);
320
+ const { editingKey, pendingKey, error, openEditing, closeEditing, commit } = (0, editable_field_1.useEditableField)(onEdit);
320
321
  const [detailsOpen, setDetailsOpen] = React.useState(true);
321
322
  const [searchOpen, setSearchOpen] = React.useState(false);
322
323
  const [fieldQuery, setFieldQuery] = React.useState("");
@@ -406,7 +407,7 @@ function RecordDetails({ record, columns, sections, onEdit, labels, className, .
406
407
  }
407
408
  if (canEdit && editingKey === col.key) {
408
409
  const value = record[col.key];
409
- const editor = (0, recordstable_1.editableInputType)(col.type);
410
+ const editor = (0, editable_field_1.editableInputType)(col.type);
410
411
  return ((0, jsx_runtime_1.jsxs)("div", { className: "crisp-recordpage-field", children: [label, (0, jsx_runtime_1.jsxs)("span", { className: "crisp-recordpage-field-value", children: [(0, jsx_runtime_1.jsx)("input", { className: "crisp-recordpage-edit",
411
412
  // biome-ignore lint/a11y/noAutofocus: focus the field the moment it opens
412
413
  autoFocus: true, ...editor, disabled: isPending, defaultValue: value == null ? "" : String(value), "aria-label": `Edit ${col.label}`, onKeyDown: (e) => {
@@ -0,0 +1,55 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.testRows = exports.testColumns = void 0;
4
+ const lucide_react_1 = require("lucide-react");
5
+ // Moved here from product/recordstable/recordstable.tsx's `defaultColumns`/
6
+ // `defaultRows` (crisp#786): RecordsTable is deprecated and its whole
7
+ // directory is being deleted, but RecordPage's own tests (and the shared
8
+ // RTL-keyboard-convention tests that live alongside them) still need a
9
+ // real, 12-column-deep dataset to exercise "a field past the first 5 needs
10
+ // View all values" and similar shape-dependent assertions. Simplified from
11
+ // the original: no baked-in company-logo data URIs (nothing here asserts on
12
+ // avatars), a plain favicon URL instead.
13
+ exports.testColumns = [
14
+ { key: "company", label: "Company", type: "entity", icon: lucide_react_1.Building2 },
15
+ { key: "domain", label: "Domain", type: "url", icon: lucide_react_1.Globe },
16
+ {
17
+ key: "categories",
18
+ label: "Categories",
19
+ type: "tags",
20
+ icon: lucide_react_1.Tag,
21
+ width: 240,
22
+ },
23
+ {
24
+ key: "employees",
25
+ label: "Employees",
26
+ type: "number",
27
+ icon: lucide_react_1.Users,
28
+ width: 120,
29
+ },
30
+ { key: "location", label: "Location", type: "text", icon: lucide_react_1.MapPin },
31
+ { key: "stage", label: "Stage", type: "tags", icon: lucide_react_1.TrendingUp, width: 150 },
32
+ { key: "ceo", label: "CEO", type: "text", icon: lucide_react_1.Briefcase, width: 170 },
33
+ { key: "twitter", label: "Twitter", type: "url", icon: lucide_react_1.AtSign, width: 150 },
34
+ {
35
+ key: "followers",
36
+ label: "Followers",
37
+ type: "number",
38
+ icon: lucide_react_1.Users,
39
+ width: 120,
40
+ },
41
+ { key: "linkedin", label: "LinkedIn", type: "url", icon: lucide_react_1.Globe, width: 180 },
42
+ {
43
+ key: "founded",
44
+ label: "Founded",
45
+ type: "date",
46
+ icon: lucide_react_1.Calendar,
47
+ width: 110,
48
+ },
49
+ { key: "arr", label: "Est. ARR", type: "number", icon: lucide_react_1.Hash, width: 130 },
50
+ ];
51
+ // biome-ignore format: one row per line keeps the wide demo dataset readable
52
+ exports.testRows = [
53
+ { id: "google", company: { name: "Google", avatar: "https://www.google.com/s2/favicons?domain=google.com&sz=64" }, domain: "google.com", categories: ["Search", "Ads", "Cloud", "AI", "Android", "Maps", "Hardware", "Workspace", "Chrome"], employees: 182000, location: "Mountain View, CA", stage: ["Public"], ceo: "Sundar Pichai", twitter: "twitter.com/google", followers: 29800000, linkedin: "linkedin.com/company/google", founded: "1998", arr: 307000000000 },
54
+ { id: "stripe", company: { name: "Stripe", avatar: "https://www.google.com/s2/favicons?domain=stripe.com&sz=64" }, domain: "stripe.com", categories: ["Payments", "Fintech"], employees: 8000, location: "San Francisco, CA", stage: ["Series I"], ceo: "Patrick Collison", twitter: "twitter.com/stripe", followers: 1200000, linkedin: "linkedin.com/company/stripe", founded: "2010", arr: 16000000000 },
55
+ ];
@@ -1,7 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.recordstableRecipe = exports.tagTone = exports.RecordsTable = exports.defaultRows = exports.defaultColumns = exports.useEditableField = exports.editableInputType = void 0;
4
- var editable_field_1 = require("./editable-field");
4
+ var editable_field_1 = require("../recordpage/editable-field");
5
5
  Object.defineProperty(exports, "editableInputType", { enumerable: true, get: function () { return editable_field_1.editableInputType; } });
6
6
  Object.defineProperty(exports, "useEditableField", { enumerable: true, get: function () { return editable_field_1.useEditableField; } });
7
7
  var recordstable_1 = require("./recordstable");
@@ -552,6 +552,11 @@ function CreateRecordDialog({ open, onOpenChange, columns, entityLabel, onCreate
552
552
  * editing (double-click a text/number/url/date cell), add-row and add-column,
553
553
  * and a floating bulk-action bar (delete / deselect) when rows are selected.
554
554
  * Selection can be controlled via `selectedIds`/`onSelectionChange`.
555
+ *
556
+ * @deprecated Reach for `DataTable` instead — a single Attio-exact records
557
+ * grid replaces the separate table variants. `RecordsTable` has no
558
+ * consumers left (checked GRC and the fms/Mercury admin) and is slated for
559
+ * removal in a future minor.
555
560
  */
556
561
  function RecordsTable({ labels, columns: columnsProp = exports.defaultColumns, rows: rowsProp = exports.defaultRows, selectedIds, onSelectionChange, showNew = true, allowAddColumn = true, onOpenRecord, onOpenRecordPage, avatarTone = "brand", emptyStateExtra, className, ...rest }) {
557
562
  const [cols, setCols] = React.useState(columnsProp);
@@ -26,24 +26,26 @@ const config = (0, core_1.defineRecipe)({
26
26
  position: relative; display: inline-flex; font-family: ${tokens_1.contract.font.sans};
27
27
  -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
28
28
 
29
- /* Trigger fill measured rgb(246,247,247); bgRaised's light value is
30
- #f4f4f5 (244,244,245) — a 2-unit-per-channel difference, inside
31
- screenshot/anti-aliasing noise, not a second real colour. Reusing the
32
- token (Lesson 3 — tokens, not raw px/hex) gets a correct,
33
- already-established dark-mode fill for free instead of a light-only
34
- literal; AUTHORING.md Lesson 29 is the case where that shortcut went
35
- wrong (a flat colour that stayed visible where the reference had
36
- none) — this is the opposite risk (a token that already flips per
37
- theme, applied to the closest measured match). */
29
+ /* crisp/#772 follow-up, re-measured live 2026-09-25 in the trigger's
30
+ actual position (Attio's real view bar, next to View settings --
31
+ fixed there in #766/#768/#778): white (bgSurface, rgb(255,255,255)
32
+ exact) with elevation.field's 3-layer shadow, the same raised
33
+ chrome View settings and Import/Export both carry
34
+ (tabletoolbar.recipe.ts's own --raised, not reused here directly
35
+ since this is a separate recipe/component, but the identical measured
36
+ values). Supersedes the original #555 measurement (rgb(246,247,247),
37
+ reused as bgRaised -- a flat grey fill with no shadow at all), which
38
+ was wrong: this trigger was never re-checked once it moved into its
39
+ real view-bar position. */
38
40
  & .crisp-savedviews-trigger {
39
41
  box-sizing: border-box; display: inline-flex; align-items: center; gap: ${tokens_1.contract.space["1.5"]};
40
42
  height: ${tokens_1.contract.size.row}; margin: 0; padding: ${tokens_1.contract.space["1"]} ${tokens_1.contract.space["1.5"]};
41
- border: none; border-radius: ${tokens_1.contract.radius.md}; background: ${tokens_1.contract.bgRaised};
43
+ border: none; border-radius: ${tokens_1.contract.radius.md}; background: ${tokens_1.contract.bgSurface}; box-shadow: ${tokens_1.elevation.field};
42
44
  font-family: inherit; font-size: ${tokens_1.contract.text.lg}; font-weight: ${tokens_1.contract.weight.medium}; color: ${tokens_1.contract.fgPrimary};
43
45
  cursor: pointer; white-space: nowrap; transition: background-color .12s ease;
44
46
  }
45
47
  & .crisp-savedviews-trigger:hover,
46
- & .crisp-savedviews-trigger[data-open] { background: color-mix(in srgb, ${tokens_1.contract.fgPrimary} 4%, ${tokens_1.contract.bgRaised}); }
48
+ & .crisp-savedviews-trigger[data-open] { background: color-mix(in srgb, ${tokens_1.contract.fgPrimary} 4%, ${tokens_1.contract.bgSurface}); }
47
49
  & .crisp-savedviews-trigger-icon { flex-shrink: 0; display: inline-flex; width: ${tokens_1.contract.size.icon}; height: ${tokens_1.contract.size.icon}; }
48
50
  & .crisp-savedviews-trigger-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
49
51
  & .crisp-savedviews-trigger-chevron { flex-shrink: 0; color: ${tokens_1.contract.fgMuted}; }
@@ -14,6 +14,11 @@ function Cell({ flex, className, children, }) {
14
14
  * taskstable.recipe.ts) with a typed column/cell layer: a title+checkbox
15
15
  * row grouped into due-date sections, a due-date cell that goes red past
16
16
  * due, and consumer-supplied record/assignee chip slots.
17
+ *
18
+ * @deprecated Reach for `DataTable`'s `groupBy` instead — a single
19
+ * Attio-exact records grid replaces the separate table variants.
20
+ * `TasksTable` has no consumers left (checked GRC and the fms/Mercury
21
+ * admin) and is slated for removal in a future minor.
17
22
  */
18
23
  function TasksTable({ columns, groups, leading, trailing, emptyLabel: emptyLabelProp, completeLabel: completeLabelProp, incompleteLabel: incompleteLabelProp, className, ...rest }) {
19
24
  const crispMessages = (0, core_1.useCrispMessages)();
@@ -152,6 +152,10 @@
152
152
  & th[data-drop-after]::after { inset-inline-end: -1.5px; }
153
153
  /* typed cells — entity (avatar + name), colour-coded tags, numbers, dates */
154
154
  & .crisp-datatable-entity { display: flex; align-items: center; gap: var(--crisp-space-1-5); min-width: 0; color: var(--crisp-fg-primary); font-weight: var(--crisp-weight-medium); }
155
+ /* crisp/#601 supersession follow-up -- the same flex-row shape .crisp-datatable-entity gives its own avatar+name+actions, but structural only (no entity-specific colour/weight): lets onOpenComments/onOpenPreview compose alongside ANY column's own
156
+ content (a custom render, or a non-entity built-in type), not just
157
+ type: "entity"'s own inline rendering. */
158
+ & .crisp-datatable-cell-actions-row { display: flex; align-items: center; gap: var(--crisp-space-1-5); min-width: 0; width: 100%; }
155
159
  /* crisp/#601, G-050: measured LIVE (getComputedStyle on the name text, plain hover vs rest) that Attio's underline is unconditional, not a
156
160
  hover reveal -- identical at rest and on hover: text-decoration-color
157
161
  is rgba(0,0,0,0.09), a 9% tint, not the name's own full-contrast text colour. The previous :hover-gated, full-contrast rule here was itself unmeasured and wrong in two ways at once. */
@@ -183,6 +187,16 @@
183
187
  /* url/email cell types (#654) -- same measured values as RecordsTable's own .crisp-recordstable-link, which this mirrors: no underline at rest, underline on hover, own-colour text rather than a browser-blue link. */
184
188
  & .crisp-datatable-link { color: var(--crisp-fg-primary); text-decoration: none; overflow: hidden; text-overflow: ellipsis; }
185
189
  & .crisp-datatable-link:hover { text-decoration: underline; }
190
+ /* rating cell type/editor (#760) -- measured live: 16px stars on an 18px pitch (a 2px gap), an empty star at rgba(0,0,0,0.1), a committed/filled star at brand600 (--crisp-bg-brand-solid, an exact match to the measured #266df0), and a distinct hover-preview colour -- rgb(33,91,196) -- that's close to but NOT brand700/bgBrandSolidPressed
191
+ (#1f5fd6); kept as its own literal rather than forced onto that token,
192
+ since the two were measured stable and different. The read-only svg
193
+ and the editable button's own inner svg both carry the crisp-datatable-rating-star class on the svg itself (not the button), so one set of fill rules covers both -- the button only adds its own reset + cursor via a separate -btn class. */
194
+ & .crisp-datatable-rating { display: inline-flex; align-items: center; gap: 2px; }
195
+ & .crisp-datatable-rating-star { display: block; }
196
+ & .crisp-datatable-rating-star path { fill: rgba(0, 0, 0, 0.1); }
197
+ & .crisp-datatable-rating-star[data-filled] path { fill: var(--crisp-bg-brand-solid); }
198
+ & .crisp-datatable-rating-star[data-hovering] path { fill: #215bc4; }
199
+ & .crisp-datatable-rating-star-btn { display: inline-flex; padding: 0; margin: 0; border: none; background: none; font-family: inherit; cursor: pointer; }
186
200
  /* row drag-to-reorder -- a Settings > Objects > Attributes screen measures a leading 20px grip column, a 16px grip icon, and the row's own
187
201
  hairline doubling as the drop indicator (it tints toward the brand
188
202
  colour as the active drop target). The affordance + geometry are
@@ -153,6 +153,10 @@
153
153
  & th[data-drop-after]::after { inset-inline-end: -1.5px; }
154
154
  /* typed cells — entity (avatar + name), colour-coded tags, numbers, dates */
155
155
  & .crisp-datatable-entity { display: flex; align-items: center; gap: var(--crisp-space-1-5); min-width: 0; color: var(--crisp-fg-primary); font-weight: var(--crisp-weight-medium); }
156
+ /* crisp/#601 supersession follow-up -- the same flex-row shape .crisp-datatable-entity gives its own avatar+name+actions, but structural only (no entity-specific colour/weight): lets onOpenComments/onOpenPreview compose alongside ANY column's own
157
+ content (a custom render, or a non-entity built-in type), not just
158
+ type: "entity"'s own inline rendering. */
159
+ & .crisp-datatable-cell-actions-row { display: flex; align-items: center; gap: var(--crisp-space-1-5); min-width: 0; width: 100%; }
156
160
  /* crisp/#601, G-050: measured LIVE (getComputedStyle on the name text, plain hover vs rest) that Attio's underline is unconditional, not a
157
161
  hover reveal -- identical at rest and on hover: text-decoration-color
158
162
  is rgba(0,0,0,0.09), a 9% tint, not the name's own full-contrast text colour. The previous :hover-gated, full-contrast rule here was itself unmeasured and wrong in two ways at once. */
@@ -184,6 +188,16 @@
184
188
  /* url/email cell types (#654) -- same measured values as RecordsTable's own .crisp-recordstable-link, which this mirrors: no underline at rest, underline on hover, own-colour text rather than a browser-blue link. */
185
189
  & .crisp-datatable-link { color: var(--crisp-fg-primary); text-decoration: none; overflow: hidden; text-overflow: ellipsis; }
186
190
  & .crisp-datatable-link:hover { text-decoration: underline; }
191
+ /* rating cell type/editor (#760) -- measured live: 16px stars on an 18px pitch (a 2px gap), an empty star at rgba(0,0,0,0.1), a committed/filled star at brand600 (--crisp-bg-brand-solid, an exact match to the measured #266df0), and a distinct hover-preview colour -- rgb(33,91,196) -- that's close to but NOT brand700/bgBrandSolidPressed
192
+ (#1f5fd6); kept as its own literal rather than forced onto that token,
193
+ since the two were measured stable and different. The read-only svg
194
+ and the editable button's own inner svg both carry the crisp-datatable-rating-star class on the svg itself (not the button), so one set of fill rules covers both -- the button only adds its own reset + cursor via a separate -btn class. */
195
+ & .crisp-datatable-rating { display: inline-flex; align-items: center; gap: 2px; }
196
+ & .crisp-datatable-rating-star { display: block; }
197
+ & .crisp-datatable-rating-star path { fill: rgba(0, 0, 0, 0.1); }
198
+ & .crisp-datatable-rating-star[data-filled] path { fill: var(--crisp-bg-brand-solid); }
199
+ & .crisp-datatable-rating-star[data-hovering] path { fill: #215bc4; }
200
+ & .crisp-datatable-rating-star-btn { display: inline-flex; padding: 0; margin: 0; border: none; background: none; font-family: inherit; cursor: pointer; }
187
201
  /* row drag-to-reorder -- a Settings > Objects > Attributes screen measures a leading 20px grip column, a 16px grip icon, and the row's own
188
202
  hairline doubling as the drop indicator (it tints toward the brand
189
203
  colour as the active drop target). The affordance + geometry are
@@ -15,6 +15,7 @@ import { Checkbox } from "../checkbox/index.js";
15
15
  import { ColumnCalc } from "../columncalc/index.js";
16
16
  import { ContextMenu } from "../contextmenu/index.js";
17
17
  import { Menu, MenuItem } from "../menu/index.js";
18
+ import { useRadioGroupKeyboard } from "../radiogroup/index.js";
18
19
  import { Select } from "../select/index.js";
19
20
  import { Spinner } from "../spinner/index.js";
20
21
  import { Switch } from "../switch/index.js";
@@ -254,6 +255,73 @@ function formatCell(type, v, symbol, locale = "en-US") {
254
255
  }
255
256
  return asText(v);
256
257
  }
258
+ // crisp/#760 -- the exact 5-point star Attio's own Rating attribute draws
259
+ // (path traced from the live widget's own SVG, `app.attio.com`, both the
260
+ // attribute-creation default-value picker and the grid cell render the
261
+ // identical path). Kept as one shared `d` string rather than a lucide icon
262
+ // so the star's silhouette matches Attio verbatim rather than an
263
+ // approximation, and so `RatingStars` below can flip its own `fill`
264
+ // per-star instead of drawing a whole new icon per state.
265
+ const RATING_STAR_D = "M7.32203 2.81085C7.65793 1.97438 8.84207 1.97438 9.17797 2.81085L10.0851 5.06967C10.2281 5.42587 10.5624 5.66876 10.9454 5.69473L13.3739 5.8594C14.2733 5.92038 14.6392 7.04656 13.9475 7.62451L12.0795 9.18521C11.7849 9.43132 11.6572 9.82433 11.7509 10.1966L12.3447 12.5572C12.5647 13.4313 11.6067 14.1273 10.8433 13.6481L8.78171 12.3538C8.45661 12.1497 8.04339 12.1497 7.71829 12.3538L5.65675 13.6481C4.89333 14.1273 3.93534 13.4313 4.15526 12.5572L4.74911 10.1966C4.84276 9.82433 4.71507 9.43132 4.4205 9.18521L2.55254 7.62451C1.86081 7.04656 2.22673 5.92038 3.12606 5.8594L5.55463 5.69473C5.9376 5.66876 6.27191 5.42587 6.41495 5.06967L7.32203 2.81085Z";
266
+ /** A star row for `type: "rating"` / `editor: "rating"` (crisp/#760).
267
+ * Read-only (a plain `<span>` row, no interaction) unless `onChange` is
268
+ * given, which renders Attio's own measured accessible shape: a
269
+ * `role="radiogroup"` of `role="radio"` buttons, one per star. Clicking a
270
+ * star sets the rating to its 1-based index; clicking the star that
271
+ * already equals the current value clears it to 0 -- confirmed live
272
+ * (rating a record 5, then clicking the 5th star again cleared it
273
+ * entirely rather than re-committing 5). */
274
+ function RatingStars({ value, max = 5, onChange, "aria-label": ariaLabel, }) {
275
+ const [hoverIdx, setHoverIdx] = React.useState(null);
276
+ // The house radiogroup keyboard model (crisp's own spec check requires it
277
+ // on every role="radiogroup", regardless of Attio parity): arrows move
278
+ // focus AND select, Home/End jump to the ends. Landing on a star always
279
+ // SETS the rating to that star's position -- arrow nav has no "clear on
280
+ // re-land" toggle, that's a click-only gesture (see onClick below).
281
+ const { onKeyDown, groupRef } = useRadioGroupKeyboard({
282
+ select: (v) => onChange?.(Number(v)),
283
+ });
284
+ const stars = Array.from({ length: max }, (_, i) => i);
285
+ const content = stars.map((i) => {
286
+ // Hovering star i previews filling every star up to and including it,
287
+ // whether or not it's already part of the committed value -- measured:
288
+ // hovering the 5th star while 4 were already filled turned all 5 the
289
+ // hover colour, not just the would-be-new one.
290
+ const hovering = onChange != null && hoverIdx != null && i <= hoverIdx;
291
+ const filled = hovering ? true : i < value;
292
+ // A native <input type="radio"> can't be unchecked by re-clicking the
293
+ // already-selected one, which is exactly the measured behaviour
294
+ // (clicking the star that already equals the rating clears it to 0) --
295
+ // Attio's own live widget is a <button role="radio"> for the same
296
+ // reason.
297
+ return onChange ? (_jsx("button", { type: "button", role: "radio", "aria-checked": i < value, "aria-label": `${i + 1} star${i === 0 ? "" : "s"}`, "data-value": String(i + 1), className: "crisp-datatable-rating-star-btn", onMouseEnter: () => setHoverIdx(i), onMouseLeave: () => setHoverIdx(null), onClick: (e) => {
298
+ e.stopPropagation();
299
+ onChange(i + 1 === value ? 0 : i + 1);
300
+ }, children: _jsx("svg", { width: "16", height: "16", viewBox: "0 0 16 16", "aria-hidden": "true", className: "crisp-datatable-rating-star", "data-filled": filled ? "" : undefined, "data-hovering": hovering ? "" : undefined, children: _jsx("path", { d: RATING_STAR_D }) }) }, i)) : (_jsx("svg", { width: "16", height: "16", viewBox: "0 0 16 16", "aria-hidden": "true", className: "crisp-datatable-rating-star", "data-filled": filled ? "" : undefined, children: _jsx("path", { d: RATING_STAR_D }) }, i));
301
+ });
302
+ return onChange ? (_jsx("span", { role: "radiogroup", "aria-label": ariaLabel, className: "crisp-datatable-rating", ref: groupRef, onKeyDown: onKeyDown, children: content })) : (_jsx("span", { className: "crisp-datatable-rating", children: content }));
303
+ }
304
+ /** The preview/comments action icons a column's `onOpenPreview`/
305
+ * `onOpenComments`/`commentCount` produce — the exact markup `type: "entity"`
306
+ * already renders inline, extracted so a column that ISN'T `"entity"` (a
307
+ * custom `render`, or any other built-in type) can compose the same actions
308
+ * alongside its own content instead of the props silently doing nothing
309
+ * (crisp/#601 supersession follow-up: these three props used to only have
310
+ * an effect inside `renderTyped`'s `"entity"` case). `null` when neither
311
+ * prop is set, so a caller can render nothing rather than an empty
312
+ * flex wrapper. */
313
+ function renderCellActions(col, row, rowHovered, openPreviewLabel) {
314
+ if (col.onOpenComments) {
315
+ return (_jsx("span", { className: "crisp-datatable-entity-actions", children: _jsx(CommentCountBadge, { count: col.commentCount?.(row) ?? 0, hovered: rowHovered, onOpen: () => col.onOpenComments?.(row), onOpenPreview: col.onOpenPreview ? () => col.onOpenPreview?.(row) : undefined, previewLabel: openPreviewLabel }) }));
316
+ }
317
+ if (col.onOpenPreview) {
318
+ return (_jsx("span", { className: "crisp-datatable-entity-actions", children: _jsx("button", { type: "button", className: "crisp-datatable-entity-action", "aria-label": openPreviewLabel, onClick: (ev) => {
319
+ ev.stopPropagation();
320
+ col.onOpenPreview?.(row);
321
+ }, children: _jsx(PanelRight, { "data-rtl-flip": "", size: 16, strokeWidth: 1.75, "aria-hidden": "true" }) }) }));
322
+ }
323
+ return null;
324
+ }
257
325
  /** Built-in typed-cell renderer (used when a column has no custom `render`). */
258
326
  // `value` is always the RAW cell value and `display` the caller's `format`
259
327
  // output, if any. They are separate arguments on purpose: the entity and tags
@@ -281,10 +349,7 @@ function renderTyped(col, value, avatarTone, row, display, locale = "en-US", ope
281
349
  // at a DIFFERENT record than the row itself (#501).
282
350
  ev.stopPropagation();
283
351
  col.onEntityClick?.(row);
284
- }, children: e.name })) : (_jsx("span", { className: "crisp-datatable-entity-name", children: e.name })), col.onOpenComments ? (_jsx("span", { className: "crisp-datatable-entity-actions", children: _jsx(CommentCountBadge, { count: col.commentCount?.(row) ?? 0, hovered: rowHovered, onOpen: () => col.onOpenComments?.(row), onOpenPreview: col.onOpenPreview ? () => col.onOpenPreview?.(row) : undefined, previewLabel: openPreviewLabel }) })) : (col.onOpenPreview && (_jsx("span", { className: "crisp-datatable-entity-actions", children: _jsx("button", { type: "button", className: "crisp-datatable-entity-action", "aria-label": openPreviewLabel, onClick: (ev) => {
285
- ev.stopPropagation();
286
- col.onOpenPreview?.(row);
287
- }, children: _jsx(PanelRight, { "data-rtl-flip": "", size: 16, strokeWidth: 1.75, "aria-hidden": "true" }) }) })))] }));
352
+ }, children: e.name })) : (_jsx("span", { className: "crisp-datatable-entity-name", children: e.name })), renderCellActions(col, row, rowHovered, openPreviewLabel)] }));
288
353
  }
289
354
  case "tags": {
290
355
  // asking for a format on a tag column means "render it your way" -- show
@@ -304,6 +369,14 @@ function renderTyped(col, value, avatarTone, row, display, locale = "en-US", ope
304
369
  // translation) can never silently repaint the chip
305
370
  return (_jsx("span", { className: "crisp-datatable-tag", "data-tone": col.tones?.[s] ?? toneFor(s), children: display || s }));
306
371
  }
372
+ case "rating": {
373
+ // Read-only display -- `editor: "rating"` (the always-live picker) is
374
+ // handled separately, the same split `switch` gets (#453).
375
+ const n = typeof value === "number" ? value : Number(value);
376
+ if (!n || Number.isNaN(n))
377
+ return empty;
378
+ return _jsx(RatingStars, { value: n, max: col.maxRating ?? 5 });
379
+ }
307
380
  case "url":
308
381
  case "email": {
309
382
  const text = display || asText(value);
@@ -995,8 +1068,9 @@ export function DataTable({ columns: columnsProp, data, selectable = false, sele
995
1068
  * `editor: "select"`/`"reference"` open their own picker instead of this
996
1069
  * native-input family, but through the SAME double-click gate (#453) --
997
1070
  * see the row-render loop's `isEditing` branch for those, this function
998
- * only covers the native-input-backed types. `editor: "switch"` is the
999
- * one editor with no double-click gate at all (always live).
1071
+ * only covers the native-input-backed types.
1072
+ * `editor: "switch"`/`"rating"`/`"checkbox"` are the editors with no
1073
+ * double-click gate at all (always live).
1000
1074
  */
1001
1075
  function editorFor(col) {
1002
1076
  switch (col.type) {
@@ -1249,8 +1323,13 @@ export function DataTable({ columns: columnsProp, data, selectable = false, sele
1249
1323
  // already its own live widget (#385); Enter there is a no-op, same
1250
1324
  // as any other non-editable cell that isn't wired to onRowClick.
1251
1325
  // `select`/`reference` DO have one now (like every other editor),
1252
- // so Enter opens them too.
1253
- if (columns[c]?.editable && columns[c]?.editor !== "switch") {
1326
+ // so Enter opens them too. `rating` (#760) and `checkbox` (#761)
1327
+ // join `switch` in the no-separate-editing-mode exception -- same
1328
+ // always-live shape.
1329
+ if (columns[c]?.editable &&
1330
+ columns[c]?.editor !== "switch" &&
1331
+ columns[c]?.editor !== "rating" &&
1332
+ columns[c]?.editor !== "checkbox") {
1254
1333
  e.preventDefault();
1255
1334
  openEditing(selCell.id, selCell.key);
1256
1335
  }
@@ -1787,6 +1866,22 @@ export function DataTable({ columns: columnsProp, data, selectable = false, sele
1787
1866
  if (col.editable && col.editor === "switch") {
1788
1867
  return (_jsxs(TableCell, { align: alignFor(col), "aria-colindex": colAriaIndex(i), ...sticky, className: "crisp-datatable-td--picker", "data-pending": isPending ? "" : undefined, children: [_jsxs("span", { className: "crisp-datatable-edit-picker", children: [_jsx(Switch, { "aria-label": `Edit ${col.key}`, checked: value === true || value === "true", disabled: isPending, onCheckedChange: (checked) => commitEdit(row.id, col.key, String(checked)) }), isPending && (_jsx(Spinner, { size: 14, className: "crisp-datatable-edit-pending" }))] }), errorHere && (_jsx("span", { className: "crisp-datatable-edit-error", children: errorHere }))] }, col.key));
1789
1868
  }
1869
+ // crisp/#760: `editor: "rating"` is the same always-
1870
+ // live shape as `switch` above -- clicking a star IS
1871
+ // the commit gesture, confirmed live.
1872
+ if (col.editable && col.editor === "rating") {
1873
+ const n = typeof value === "number" ? value : Number(value);
1874
+ return (_jsxs(TableCell, { align: alignFor(col), "aria-colindex": colAriaIndex(i), ...sticky, className: "crisp-datatable-td--picker", "data-pending": isPending ? "" : undefined, children: [_jsxs("span", { className: "crisp-datatable-edit-picker", children: [_jsx(RatingStars, { "aria-label": `Edit ${col.key}`, value: Number.isNaN(n) ? 0 : n, max: col.maxRating ?? 5, onChange: (next) => commitEdit(row.id, col.key, String(next)) }), isPending && (_jsx(Spinner, { size: 14, className: "crisp-datatable-edit-pending" }))] }), errorHere && (_jsx("span", { className: "crisp-datatable-edit-error", children: errorHere }))] }, col.key));
1875
+ }
1876
+ // crisp/#761: `editor: "checkbox"` is a THIRD always-
1877
+ // live boolean shape, distinct from `switch` --
1878
+ // confirmed live against Attio's own "Checkbox"
1879
+ // attribute type, which renders a plain square, not a
1880
+ // toggle. Reuses crisp's own `Checkbox` (already the
1881
+ // measured 16px/r6/brand-fill grid checkbox).
1882
+ if (col.editable && col.editor === "checkbox") {
1883
+ return (_jsxs(TableCell, { align: alignFor(col), "aria-colindex": colAriaIndex(i), ...sticky, className: "crisp-datatable-td--picker", "data-pending": isPending ? "" : undefined, children: [_jsxs("span", { className: "crisp-datatable-edit-picker", children: [_jsx(Checkbox, { "aria-label": `Edit ${col.key}`, checked: value === true || value === "true", disabled: isPending, onChange: (e) => commitEdit(row.id, col.key, String(e.currentTarget.checked)) }), isPending && (_jsx(Spinner, { size: 14, className: "crisp-datatable-edit-pending" }))] }), errorHere && (_jsx("span", { className: "crisp-datatable-edit-error", children: errorHere }))] }, col.key));
1884
+ }
1790
1885
  if (isEditing &&
1791
1886
  (col.editor === "select" ||
1792
1887
  col.editor === "reference" ||
@@ -1842,9 +1937,25 @@ export function DataTable({ columns: columnsProp, data, selectable = false, sele
1842
1937
  i >= range.c0 &&
1843
1938
  i <= range.c1);
1844
1939
  })();
1845
- const rendered = col.render
1940
+ const rowHoveredForCell = hoveredRowId === row.id;
1941
+ const baseRendered = col.render
1846
1942
  ? col.render(value, row)
1847
- : renderTyped(col, value, avatarTone, row, col.format?.(value, row), locale, openPreviewLabel, hoveredRowId === row.id);
1943
+ : renderTyped(col, value, avatarTone, row, col.format?.(value, row), locale, openPreviewLabel, rowHoveredForCell);
1944
+ // crisp/#601 supersession follow-up: `onOpenComments`/
1945
+ // `onOpenPreview`/`commentCount` used to only do
1946
+ // anything inside `renderTyped`'s `"entity"` case,
1947
+ // which already renders its own actions inline (see
1948
+ // `renderCellActions` there) -- silently dropping them
1949
+ // on any OTHER type, and on a custom `render` of any
1950
+ // type, since neither path went through that switch.
1951
+ // This composes the same actions alongside whatever
1952
+ // `rendered` is instead, decoupling the props from
1953
+ // requiring `type: "entity"`.
1954
+ const cellActions = col.type !== "entity" &&
1955
+ (col.onOpenComments || col.onOpenPreview)
1956
+ ? renderCellActions(col, row, rowHoveredForCell, openPreviewLabel)
1957
+ : null;
1958
+ const rendered = cellActions ? (_jsxs("span", { className: "crisp-datatable-cell-actions-row", children: [baseRendered, cellActions] })) : (baseRendered);
1848
1959
  const isExpanded = expandedCell?.id === row.id &&
1849
1960
  expandedCell?.key === col.key;
1850
1961
  const cellContent = (_jsxs(_Fragment, { children: [!isExpanded && rendered, errorHere && (_jsx("span", { className: "crisp-datatable-edit-error", children: errorHere })), isExpanded && (_jsx("div", { className: "crisp-datatable-cell-overlay", onClick: (e) => e.stopPropagation(), children: rendered }))] }));
@@ -224,6 +224,13 @@ const config = defineRecipe({
224
224
  & th[data-drop-after]::after { inset-inline-end: -1.5px; }
225
225
  /* typed cells — entity (avatar + name), colour-coded tags, numbers, dates */
226
226
  & .crisp-datatable-entity { display: flex; align-items: center; gap: ${c.space["1.5"]}; min-width: 0; color: ${c.fgPrimary}; font-weight: ${c.weight.medium}; }
227
+ /* crisp/#601 supersession follow-up -- the same flex-row shape
228
+ .crisp-datatable-entity gives its own avatar+name+actions, but
229
+ structural only (no entity-specific colour/weight): lets
230
+ onOpenComments/onOpenPreview compose alongside ANY column's own
231
+ content (a custom render, or a non-entity built-in type), not just
232
+ type: "entity"'s own inline rendering. */
233
+ & .crisp-datatable-cell-actions-row { display: flex; align-items: center; gap: ${c.space["1.5"]}; min-width: 0; width: 100%; }
227
234
  /* crisp/#601, G-050: measured LIVE (getComputedStyle on the name text,
228
235
  plain hover vs rest) that Attio's underline is unconditional, not a
229
236
  hover reveal -- identical at rest and on hover: text-decoration-color
@@ -265,6 +272,23 @@ const config = defineRecipe({
265
272
  link. */
266
273
  & .crisp-datatable-link { color: ${c.fgPrimary}; text-decoration: none; overflow: hidden; text-overflow: ellipsis; }
267
274
  & .crisp-datatable-link:hover { text-decoration: underline; }
275
+ /* rating cell type/editor (#760) -- measured live: 16px stars on an
276
+ 18px pitch (a 2px gap), an empty star at rgba(0,0,0,0.1), a
277
+ committed/filled star at brand600 (--crisp-bg-brand-solid, an exact
278
+ match to the measured #266df0), and a distinct hover-preview colour
279
+ -- rgb(33,91,196) -- that's close to but NOT brand700/bgBrandSolidPressed
280
+ (#1f5fd6); kept as its own literal rather than forced onto that token,
281
+ since the two were measured stable and different. The read-only svg
282
+ and the editable button's own inner svg both carry the
283
+ crisp-datatable-rating-star class on the svg itself (not the
284
+ button), so one set of fill rules covers both -- the button only
285
+ adds its own reset + cursor via a separate -btn class. */
286
+ & .crisp-datatable-rating { display: inline-flex; align-items: center; gap: 2px; }
287
+ & .crisp-datatable-rating-star { display: block; }
288
+ & .crisp-datatable-rating-star path { fill: rgba(0, 0, 0, 0.1); }
289
+ & .crisp-datatable-rating-star[data-filled] path { fill: ${c.bgBrandSolid}; }
290
+ & .crisp-datatable-rating-star[data-hovering] path { fill: #215bc4; }
291
+ & .crisp-datatable-rating-star-btn { display: inline-flex; padding: 0; margin: 0; border: none; background: none; font-family: inherit; cursor: pointer; }
268
292
  /* row drag-to-reorder -- a Settings > Objects > Attributes screen measures
269
293
  a leading 20px grip column, a 16px grip icon, and the row's own
270
294
  hairline doubling as the drop indicator (it tints toward the brand
@@ -50,12 +50,35 @@ export const toolbarChromeHead = (p) => ` & .${CLASS_PREFIX}${p}-toolbar { di
50
50
  & .${CLASS_PREFIX}${p}-search svg { flex-shrink: 0; }
51
51
  & .${CLASS_PREFIX}${p}-search input { border: none; outline: none; background: transparent; font-family: inherit; font-size: ${c.text.md}; color: ${c.fgPrimary}; width: 180px; }
52
52
  & .${CLASS_PREFIX}${p}-search input::placeholder { color: ${c.fgMuted}; }
53
- & .${CLASS_PREFIX}${p}-tool { display: inline-flex; align-items: center; gap: ${c.space["1"]}; height: ${c.size.row}; margin: 0; padding: 0 ${c.space["2"]}; border: none; border-radius: ${c.radius.md}; background: transparent; box-shadow: inset 0 0 0 1px ${c.borderDefault}; color: ${c.fgPrimary}; font-family: inherit; font-size: ${c.text.md}; font-weight: ${c.weight.medium}; white-space: nowrap; cursor: pointer; transition: background-color .12s; }
53
+ & .${CLASS_PREFIX}${p}-tool { display: inline-flex; align-items: center; gap: ${c.space["1"]}; height: ${c.size.row}; margin: 0; padding: 0 ${c.space["2"]}; border: none; border-radius: ${c.radius.md}; background: ${c.bgSurface}; box-shadow: inset 0 0 0 1px ${c.borderDivider}; color: ${c.fgPrimary}; font-family: inherit; font-size: ${c.text.md}; font-weight: ${c.weight.medium}; white-space: nowrap; cursor: pointer; transition: background-color .12s; }
54
54
  & .${CLASS_PREFIX}${p}-tool:hover { background: ${c.bgRaised}; }
55
55
  & .${CLASS_PREFIX}${p}-tool svg { flex-shrink: 0; color: ${c.fgMuted}; }
56
- & .${CLASS_PREFIX}${p}-tool--active { background: ${c.bgRaised}; }
56
+ /* crisp/#772: re-measured live on Sort with an actual sort applied --
57
+ an active trigger's own fill is white (bgSurface), the same as the
58
+ base .tool rule above now already sets; this override existed to
59
+ distinguish active from a base state that used to be transparent.
60
+ Kept explicit (not just deleted) so a future change to the base
61
+ rule's background can't silently change this one too. */
62
+ & .${CLASS_PREFIX}${p}-tool--active { background: ${c.bgSurface}; }
57
63
  & .${CLASS_PREFIX}${p}-tool--ghost { box-shadow: none; background: color-mix(in srgb, ${c.fgPrimary} 3%, transparent); color: ${c.fgMuted}; }
58
64
  & .${CLASS_PREFIX}${p}-tool--ghost:hover { background: color-mix(in srgb, ${c.fgPrimary} 6%, transparent); }
65
+ /* crisp/#772 follow-up, re-measured live 2026-09-25: an EMPTY Sort/Filter
66
+ trigger (no sort/condition set) isn't a solid-ring chip at all -- it's
67
+ a dashed ::before (1px dashed, border-radius: inherit, absolutely
68
+ positioned inset: 0) over a barely-there fill, confirmed on Attio's
69
+ own empty Filter chip (its ::before border computes to exactly
70
+ "1px dashed rgba(0, 0, 0, 0.06)"). Once a value IS set, the trigger
71
+ drops the dashed pseudo and picks up the base tool's own solid ring
72
+ instead (the --active modifier, unchanged) -- confirmed on Sort with
73
+ an actual sort applied. color-mix (not the raw rgba Attio computes
74
+ to) so both the border and the fill flip correctly in dark mode, the
75
+ same reasoning --ghost above already uses. Only wired into
76
+ TableToolbar itself (tabletoolbar.tsx) this pass -- RecordsTable's
77
+ own Sort/Filter triggers share this same chrome function but are
78
+ deprecated, not touched here. */
79
+ & .${CLASS_PREFIX}${p}-tool--dashed { position: relative; box-shadow: none; background: color-mix(in srgb, ${c.fgPrimary} 2%, transparent); }
80
+ & .${CLASS_PREFIX}${p}-tool--dashed::before { content: ""; position: absolute; inset: 0; border-radius: inherit; border: 1px dashed color-mix(in srgb, ${c.fgPrimary} 6%, transparent); pointer-events: none; }
81
+ & .${CLASS_PREFIX}${p}-tool--dashed:hover { background: color-mix(in srgb, ${c.fgPrimary} 4%, transparent); }
59
82
  & .${CLASS_PREFIX}${p}-toolgroup { position: relative; display: inline-flex; }
60
83
  & .${CLASS_PREFIX}${p}-divider { flex-shrink: 0; width: 1px; height: 20px; background: ${c.borderSubtle}; }
61
84
  & .${CLASS_PREFIX}${p}-menu { position: absolute; top: calc(100% + ${c.space["1"]}); left: 0; z-index: 30; min-width: 232px; padding: ${c.space["1"]}; border-radius: ${c.radius.xl}; background: ${c.bgPopover}; box-shadow: ${e.floating}; display: flex; flex-direction: column; gap: ${c.space.px}; transform-origin: top; opacity: 1; transform: none; transition: opacity .13s ease, transform .13s ease; }