@crisp-ui-kit/crisp 0.55.1 → 0.55.3

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.
@@ -1,5 +1,5 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { ArrowDown, ArrowUp, Check, ChevronsUpDown, CircleUserRound, ClipboardPaste, Copy, GripVertical, History, Lock, PanelRight, Plus, Search, SlidersHorizontal, X, } from "lucide-react";
2
+ import { ArrowDown, ArrowUp, Check, ChevronsUpDown, CircleUserRound, ClipboardPaste, Copy, GripVertical, History, Lock, PanelRight, Plus, Search, SlidersHorizontal, Trash2, X, } from "lucide-react";
3
3
  import * as React from "react";
4
4
  import { cellDatePrecision, formatCellDate, isRtlElement, parseCellDate, useCrispMessage, useCrispMessages, usePrefersReducedMotion, } from "../../core/index.js";
5
5
  // crisp/#601 supersession: a deliberate exception to "DS atoms stay
@@ -172,6 +172,19 @@ function parsePhoneValue(v) {
172
172
  return null;
173
173
  return { iso2: s.slice(0, i), digits: s.slice(i + 1) };
174
174
  }
175
+ // crisp/#816 -- a `multiValue` phone column's raw value is several
176
+ // `parsePhoneValue`-shaped entries joined with `;`. Empty entries (a blank
177
+ // trailing slot that was never filled) are dropped rather than rendered as
178
+ // a stray chip.
179
+ function parsePhoneValues(v) {
180
+ const s = typeof v === "string" ? v : "";
181
+ if (!s)
182
+ return [];
183
+ return s
184
+ .split(";")
185
+ .map((entry) => parsePhoneValue(entry))
186
+ .filter((p) => !!p?.digits);
187
+ }
175
188
  // stable pastel tone per string value — colour-coded tags/statuses
176
189
  const TAG_TONES = [
177
190
  "brand",
@@ -392,6 +405,22 @@ function renderTyped(col, value, avatarTone, row, display, locale = "en-US", ope
392
405
  return _jsx(RatingStars, { value: n, max: col.maxRating ?? 5 });
393
406
  }
394
407
  case "phone": {
408
+ // crisp/#816, confirmed live -- a `multiValue` phone cell renders
409
+ // each value as its own real `tel:` link (Attio: a measured link
410
+ // blue, underlined, no flag/dial-code decoration, `4px` flex gap
411
+ // between values), unlike the single-value cell above/below (a plain
412
+ // flag+dial+digits span, never a link). The whole cell hard-clips
413
+ // when it overflows the column -- confirmed live, Attio does not
414
+ // ellipsis a multi-value phone cell.
415
+ if (col.multiValue) {
416
+ const values = parsePhoneValues(value);
417
+ if (!values.length)
418
+ return empty;
419
+ return (_jsx("span", { className: "crisp-datatable-phone-multi", children: values.map((p) => {
420
+ const country = countryByIso2(p.iso2);
421
+ return (_jsxs("a", { className: "crisp-datatable-phone-link", href: `tel:+${country?.dialCode ?? ""}${p.digits}`, onClick: (ev) => ev.stopPropagation(), children: ["+", country?.dialCode ?? "?", p.digits] }, `${p.iso2}|${p.digits}`));
422
+ }) }));
423
+ }
395
424
  const parsed = parsePhoneValue(value);
396
425
  if (!parsed?.digits)
397
426
  return empty;
@@ -408,6 +437,16 @@ function renderTyped(col, value, avatarTone, row, display, locale = "en-US", ope
408
437
  // link's own navigation must not also fire the row's onRowClick.
409
438
  onClick: (ev) => ev.stopPropagation(), children: text }));
410
439
  }
440
+ case "date":
441
+ case "datetime": {
442
+ const text = display || formatCell(col.type, value, col.symbol, locale);
443
+ if (!text)
444
+ return empty;
445
+ // crisp#822 -- a column-level predicate, not `tones` (see its doc
446
+ // comment): the danger state is computed over the row, not read back
447
+ // off this formatted string.
448
+ return col.danger?.(value, row) ? (_jsx("span", { className: "crisp-datatable-danger", children: text })) : (text);
449
+ }
411
450
  default: {
412
451
  const text = display || formatCell(col.type, value, col.symbol, locale);
413
452
  const isNum = col.type === "number" || col.type === "currency";
@@ -551,6 +590,23 @@ function DataReferenceEditor({ col, value, isPending, onCommit, onCancel, }) {
551
590
  onCommit(v);
552
591
  } }) }));
553
592
  }
593
+ // crisp/#842, confirmed live -- Attio's own country-picker popover: each
594
+ // option row is plain text, name (opaque) + dial code (muted 63%), NO flag
595
+ // anywhere -- not in the option row, and not in the CLOSED trigger either,
596
+ // which shows only the bare 2-letter ISO code ("MN") + a chevron. That's a
597
+ // different, more compact string than the option row's own "{name}
598
+ // +{dialCode}", so this builds `label` (the option row) and `triggerLabel`
599
+ // (the closed trigger) as two different values on the same `SelectOption`
600
+ // rather than one flag+name+dialcode string doing both jobs -- the shape
601
+ // #765 originally shipped, unmeasured, and #842 corrected.
602
+ function buildCountryOptions() {
603
+ return COUNTRIES.map((c) => ({
604
+ value: c.iso2,
605
+ label: (_jsxs("span", { className: "crisp-datatable-country-option", children: [_jsx("span", { className: "crisp-datatable-country-name", children: c.name }), _jsxs("span", { className: "crisp-datatable-country-dial", children: ["+", c.dialCode] })] })),
606
+ triggerLabel: c.iso2,
607
+ keywords: `${c.name} ${c.iso2} ${c.dialCode}`,
608
+ }));
609
+ }
554
610
  // crisp/#765, confirmed live -- Attio's own Phone Number editor: a
555
611
  // country-code select (ghost trigger, "MN ▾"), then a static, non-editable
556
612
  // "+{dialCode}" prefix, then a borderless digits field -- measured
@@ -558,20 +614,16 @@ function DataReferenceEditor({ col, value, isPending, onCommit, onCancel, }) {
558
614
  // value already measured elsewhere in this file (14px in both cases).
559
615
  // Reuses crisp's own searchable `Select` for the country list -- the same
560
616
  // popover chrome every other picker in this file already uses -- rather
561
- // than inventing new country-picker chrome; the flag/name/dial-code ROW
562
- // shape wasn't independently re-measured against Attio's own popover this
563
- // pass (time-boxed), so it's built to the same shape crisp's other search
564
- // pickers already carry.
617
+ // than inventing new country-picker chrome; the panel/row chrome (radius,
618
+ // padding, row height) already lines up with `Select`'s own measured
619
+ // tokens (#842 re-confirmed this), so only the option content itself
620
+ // (`buildCountryOptions` above) needed a real fix.
565
621
  function DataPhoneEditor({ col, value, isPending, onCommit, }) {
566
622
  const parsed = parsePhoneValue(value);
567
623
  const [iso2, setIso2] = React.useState(parsed?.iso2 ?? "US");
568
624
  const [digits, setDigits] = React.useState(parsed?.digits ?? "");
569
625
  const country = countryByIso2(iso2);
570
- const countryOptions = COUNTRIES.map((c) => ({
571
- value: c.iso2,
572
- label: `${flagEmoji(c.iso2)} ${c.name} +${c.dialCode}`,
573
- keywords: `${c.name} ${c.iso2} ${c.dialCode}`,
574
- }));
626
+ const countryOptions = buildCountryOptions();
575
627
  const commit = (nextIso2, nextDigits) => onCommit(`${nextIso2}|${nextDigits}`);
576
628
  return (_jsxs("span", { className: "crisp-datatable-phone-editor", children: [_jsx(Select, { variant: "ghost", searchable: true, "aria-label": `Edit ${col.key} country`, value: iso2, disabled: isPending, options: countryOptions, onValueChange: (v) => {
577
629
  setIso2(v);
@@ -581,6 +633,40 @@ function DataPhoneEditor({ col, value, isPending, onCommit, }) {
581
633
  commit(iso2, digits);
582
634
  } })] }));
583
635
  }
636
+ // crisp/#816, confirmed live -- Attio's own multi-value Phone Number
637
+ // editor (`col.multiValue`): a stack of already-committed values (a
638
+ // drag grip, the value as a real `tel:` link, and a hover-revealed
639
+ // copy/delete pair -- measured `32px` row, `5px 8px` padding, `8px` gap,
640
+ // `16×16` icons) plus one ALWAYS-blank trailing "country + digits" row in
641
+ // `DataPhoneEditor`'s own shape. The blank row itself is the only
642
+ // affordance for adding another value -- there is no separate "+ Add"
643
+ // button; committing it (Enter/blur with digits) appends and clears back
644
+ // to blank so the next value can be typed immediately. Drag-to-reorder
645
+ // (the grip icon) was measured present but isn't wired here -- reordering
646
+ // a committed array is a distinct interaction from add/remove/copy and is
647
+ // left as a follow-up rather than a partial, non-functional drag handle.
648
+ function DataPhoneMultiEditor({ col, value, isPending, onCommit, }) {
649
+ const committed = parsePhoneValues(value);
650
+ const [draftIso2, setDraftIso2] = React.useState("US");
651
+ const [draftDigits, setDraftDigits] = React.useState("");
652
+ const countryOptions = buildCountryOptions();
653
+ const join = (list) => list.map((p) => `${p.iso2}|${p.digits}`).join(";");
654
+ const removeAt = (i) => onCommit(join(committed.filter((_, idx) => idx !== i)));
655
+ const commitDraft = () => {
656
+ if (!draftDigits)
657
+ return;
658
+ onCommit(join([...committed, { iso2: draftIso2, digits: draftDigits }]));
659
+ setDraftDigits("");
660
+ };
661
+ return (_jsxs("span", { className: "crisp-datatable-phone-multi-editor", children: [committed.map((p, i) => {
662
+ const country = countryByIso2(p.iso2);
663
+ const tel = `+${country?.dialCode ?? "?"}${p.digits}`;
664
+ return (_jsxs("span", { className: "crisp-datatable-phone-multi-row", children: [_jsx(GripVertical, { size: 16, strokeWidth: 1.75, className: "crisp-datatable-phone-multi-grip", "aria-hidden": "true" }), _jsx("a", { className: "crisp-datatable-phone-link", href: `tel:${tel}`, onClick: (ev) => ev.stopPropagation(), children: tel }), _jsxs("span", { className: "crisp-datatable-phone-multi-actions", children: [_jsx("button", { type: "button", className: "crisp-datatable-phone-multi-action", "aria-label": `Copy ${col.key} value`, onClick: () => navigator.clipboard?.writeText(tel), children: _jsx(Copy, { size: 16, strokeWidth: 1.75 }) }), _jsx("button", { type: "button", className: "crisp-datatable-phone-multi-action", "aria-label": `Remove ${col.key} value`, disabled: isPending, onClick: () => removeAt(i), children: _jsx(Trash2, { size: 16, strokeWidth: 1.75 }) })] })] }, `${p.iso2}|${p.digits}`));
665
+ }), _jsxs("span", { className: "crisp-datatable-phone-editor", children: [_jsx(Select, { variant: "ghost", searchable: true, "aria-label": `Edit ${col.key} country`, value: draftIso2, disabled: isPending, options: countryOptions, onValueChange: setDraftIso2 }), _jsxs("span", { className: "crisp-datatable-phone-dial", "aria-hidden": "true", children: ["+", countryByIso2(draftIso2)?.dialCode ?? "?"] }), _jsx("input", { className: "crisp-datatable-edit", "aria-label": `Edit ${col.key} number (add another)`, value: draftDigits, disabled: isPending, onChange: (e) => setDraftDigits(e.currentTarget.value), onBlur: commitDraft, onKeyDown: (e) => {
666
+ if (e.key === "Enter")
667
+ commitDraft();
668
+ } })] })] }));
669
+ }
584
670
  // crisp/#762, confirmed live -- Attio's own Timestamp editor is a calendar +
585
671
  // time + preset picker, not the native `datetime-local` input `type:
586
672
  // "datetime"` gets by default (that stays the DEFAULT, unchanged, for
@@ -637,7 +723,7 @@ function DataDateTimeEditor({ col, value, isPending, onCommit, }) {
637
723
  commit(date, t);
638
724
  } })] }));
639
725
  }
640
- export function DataTable({ columns: columnsProp, data, selectable = false, selectedIds, onSelectionChange, selectAllLabel: selectAllLabelProp, selectRowLabel = (row) => `Select row ${row.id}`, selectionAnnouncement, columnMenu = false, onColumnsChange, columnOrder, onColumnOrderChange, groupBy, onRowsReorder, error, sort: sortProp, defaultSort, onSortChange, filterable = false, searchPlaceholder: searchPlaceholderProp, searchLabel: searchLabelProp, onEdit, onViewEditHistory, stickyFirst = true, stickyLast = false, scrollProps, locale: localeProp, avatarTone = "brand", surface, interactive = false, onRowClick, onAddRow, addRowLabel: addRowLabelProp, empty: emptyProp, showCount = true, countLabel, toolbar, height, className, reorderRowLabel: reorderRowLabelProp, openPreviewLabel: openPreviewLabelProp, sortAscendingLabel: sortAscendingLabelProp, sortDescendingLabel: sortDescendingLabelProp, moveColumnLeftLabel: moveColumnLeftLabelProp, moveColumnRightLabel: moveColumnRightLabelProp, editColumnLabel: editColumnLabelProp, hideColumnLabel: hideColumnLabelProp, renameColumnLabel: renameColumnLabelProp, viewSettingsLabel: viewSettingsLabelProp, columnsGroupLabel: columnsGroupLabelProp, cellMenuLabel: cellMenuLabelProp, copyLabel: copyLabelProp, pasteLabel: pasteLabelProp, viewEditHistoryLabel: viewEditHistoryLabelProp, clearValueLabel: clearValueLabelProp, ...rest }) {
726
+ export function DataTable({ columns: columnsProp, data, selectable = false, selectedIds, onSelectionChange, selectAllLabel: selectAllLabelProp, selectRowLabel = (row) => `Select row ${row.id}`, selectionAnnouncement, columnMenu = false, onColumnsChange, columnOrder, onColumnOrderChange, groupBy, onRowsReorder, error, loading = false, loadingLabel = "Loading...", sort: sortProp, defaultSort, onSortChange, filterable = false, searchPlaceholder: searchPlaceholderProp, searchLabel: searchLabelProp, onEdit, onViewEditHistory, stickyFirst = true, stickyLast = false, scrollProps, locale: localeProp, avatarTone = "brand", surface, interactive = false, onRowClick, onAddRow, addRowLabel: addRowLabelProp, empty: emptyProp, showCount = true, countLabel, toolbar, height, className, reorderRowLabel: reorderRowLabelProp, openPreviewLabel: openPreviewLabelProp, sortAscendingLabel: sortAscendingLabelProp, sortDescendingLabel: sortDescendingLabelProp, moveColumnLeftLabel: moveColumnLeftLabelProp, moveColumnRightLabel: moveColumnRightLabelProp, editColumnLabel: editColumnLabelProp, hideColumnLabel: hideColumnLabelProp, renameColumnLabel: renameColumnLabelProp, viewSettingsLabel: viewSettingsLabelProp, columnsGroupLabel: columnsGroupLabelProp, cellMenuLabel: cellMenuLabelProp, copyLabel: copyLabelProp, pasteLabel: pasteLabelProp, viewEditHistoryLabel: viewEditHistoryLabelProp, clearValueLabel: clearValueLabelProp, ...rest }) {
641
727
  // crisp/#471: same resolution order as TableToolbar's own — an explicit
642
728
  // prop wins, then the provider's translation of the built-in English
643
729
  // default, then that default itself with no provider in the tree.
@@ -1613,17 +1699,23 @@ export function DataTable({ columns: columnsProp, data, selectable = false, sele
1613
1699
  // value while showing the rejection's message beside the cell (reject).
1614
1700
  // The revert needs no extra code: this component never mutates `data`
1615
1701
  // itself, so once `editing` clears, the cell simply shows `data` again.
1616
- function commitEdit(id, key, value) {
1702
+ // crisp/#816 -- `keepEditing` lets a multi-value editor stay open across
1703
+ // a commit (adding/removing one value must not close the whole cell, the
1704
+ // way it does for every other, single-value editor). Default false
1705
+ // preserves every existing editor's own close-on-commit behaviour.
1706
+ function commitEdit(id, key, value, keepEditing = false) {
1617
1707
  setEditError(null);
1618
1708
  const result = onEdit?.(id, key, value);
1619
1709
  if (result && typeof result.then === "function") {
1620
1710
  setPending({ id, key });
1621
1711
  result.then(() => {
1622
1712
  setPending(null);
1623
- setEditing(null);
1713
+ if (!keepEditing)
1714
+ setEditing(null);
1624
1715
  }, (err) => {
1625
1716
  setPending(null);
1626
- setEditing(null);
1717
+ if (!keepEditing)
1718
+ setEditing(null);
1627
1719
  setEditError({
1628
1720
  id,
1629
1721
  key,
@@ -1636,7 +1728,8 @@ export function DataTable({ columns: columnsProp, data, selectable = false, sele
1636
1728
  });
1637
1729
  return;
1638
1730
  }
1639
- setEditing(null);
1731
+ if (!keepEditing)
1732
+ setEditing(null);
1640
1733
  }
1641
1734
  const reduceMotion = usePrefersReducedMotion();
1642
1735
  // crisp/#577 -- keyboard nav to an off-screen (virtualized-out) cell
@@ -2055,7 +2148,7 @@ export function DataTable({ columns: columnsProp, data, selectable = false, sele
2055
2148
  } })) : columnMenu && !isPrimary ? (_jsxs(Menu, { align: "start", trigger: _jsx("button", { type: "button", className: "crisp-datatable-sort", "data-sorted": sortDirByKey.get(col.key), "aria-label": `${typeof col.header === "string"
2056
2149
  ? col.header
2057
2150
  : col.key} ${crispMessages["column options"] ?? "column options"}`, children: inner }), children: [_jsx(MenuItem, { onSelect: () => setSort({ key: col.key, dir: "asc" }), children: sortAscendingLabel }), _jsx(MenuItem, { onSelect: () => setSort({ key: col.key, dir: "desc" }), children: sortDescendingLabel }), col.managed === false ? null : (_jsxs(_Fragment, { children: [_jsx(MenuItem, { onSelect: () => moveColumn(col.key, -1), children: moveColumnLeftLabel }), _jsx(MenuItem, { onSelect: () => moveColumn(col.key, 1), children: moveColumnRightLabel }), _jsx(MenuItem, { onSelect: () => setRenaming(col.key), children: editColumnLabel }), _jsx(MenuItem, { onSelect: () => hideColumn(col.key), children: hideColumnLabel })] }))] })) : col.sortable && !isPrimary ? (_jsx("button", { type: "button", className: "crisp-datatable-sort", "data-sorted": sortDirByKey.get(col.key), onClick: () => cycleSort(col.key), children: inner })) : (inner), col.readOnly && (_jsx(Lock, { className: "crisp-datatable-readonly", size: 12, strokeWidth: 1.75, "aria-hidden": "true" })), col.resizable !== false && (_jsx("span", { className: "crisp-datatable-resize", "data-resizing": resizingKey === col.key ? "" : undefined, onPointerDown: (e) => startResize(col, e), onPointerMove: moveResize, onPointerUp: endResize }))] }, col.key));
2058
- })] }) }), _jsx(TableBody, { children: error ? (_jsx(TableRow, { children: _jsx(TableCell, { colSpan: totalCols, children: _jsx("div", { className: "crisp-datatable-empty", children: error }) }) })) : rows.length === 0 ? (_jsx(TableRow, { children: _jsx(TableCell, { colSpan: totalCols, children: _jsx("div", { className: "crisp-datatable-empty", children: empty }) }) })) : (_jsxs(_Fragment, { children: [topSpacerHeight > 0 && (_jsx("tr", { "aria-hidden": "true", children: _jsx("td", { colSpan: totalCols, style: {
2151
+ })] }) }), _jsx(TableBody, { children: loading ? (_jsx(TableRow, { children: _jsx(TableCell, { colSpan: totalCols, children: _jsxs("div", { className: "crisp-datatable-loading", children: [_jsx(Spinner, { size: 16 }), loadingLabel] }) }) })) : error ? (_jsx(TableRow, { children: _jsx(TableCell, { colSpan: totalCols, children: _jsx("div", { className: "crisp-datatable-empty", children: error }) }) })) : rows.length === 0 ? (_jsx(TableRow, { children: _jsx(TableCell, { colSpan: totalCols, children: _jsx("div", { className: "crisp-datatable-empty", children: empty }) }) })) : (_jsxs(_Fragment, { children: [topSpacerHeight > 0 && (_jsx("tr", { "aria-hidden": "true", children: _jsx("td", { colSpan: totalCols, style: {
2059
2152
  height: topSpacerHeight,
2060
2153
  padding: 0,
2061
2154
  border: "none",
@@ -2175,7 +2268,7 @@ export function DataTable({ columns: columnsProp, data, selectable = false, sele
2175
2268
  // portaled popover, so a handler here can't
2176
2269
  // rely on the key event ever bubbling through
2177
2270
  // this element (#453).
2178
- onClick: (e) => e.stopPropagation(), children: [_jsxs("span", { className: "crisp-datatable-edit-picker", children: [col.editor === "select" ? (_jsx(DataSelectEditor, { col: col, value: value, isPending: isPending, onCommit: (v) => commitEdit(row.id, col.key, v), onCancel: () => setEditing(null) })) : col.editor === "phone" ? (_jsx(DataPhoneEditor, { col: col, value: value, isPending: isPending, onCommit: (v) => commitEdit(row.id, col.key, v) })) : col.editor === "datetime" ? (_jsx(DataDateTimeEditor, { col: col, value: value, isPending: isPending, onCommit: (v) => commitEdit(row.id, col.key, v) })) : (_jsx(DataReferenceEditor, { col: col, value: value, isPending: isPending, onCommit: (v) => commitEdit(row.id, col.key, v), onCancel: () => setEditing(null) })), isPending && (_jsx(Spinner, { size: 14, className: "crisp-datatable-edit-pending" }))] }), errorHere && (_jsx("span", { className: "crisp-datatable-edit-error", children: errorHere }))] }, col.key));
2271
+ onClick: (e) => e.stopPropagation(), children: [_jsxs("span", { className: "crisp-datatable-edit-picker", children: [col.editor === "select" ? (_jsx(DataSelectEditor, { col: col, value: value, isPending: isPending, onCommit: (v) => commitEdit(row.id, col.key, v), onCancel: () => setEditing(null) })) : col.editor === "phone" && col.multiValue ? (_jsx(DataPhoneMultiEditor, { col: col, value: value, isPending: isPending, onCommit: (v) => commitEdit(row.id, col.key, v, true) })) : col.editor === "phone" ? (_jsx(DataPhoneEditor, { col: col, value: value, isPending: isPending, onCommit: (v) => commitEdit(row.id, col.key, v) })) : col.editor === "datetime" ? (_jsx(DataDateTimeEditor, { col: col, value: value, isPending: isPending, onCommit: (v) => commitEdit(row.id, col.key, v) })) : (_jsx(DataReferenceEditor, { col: col, value: value, isPending: isPending, onCommit: (v) => commitEdit(row.id, col.key, v), onCancel: () => setEditing(null) })), isPending && (_jsx(Spinner, { size: 14, className: "crisp-datatable-edit-pending" }))] }), errorHere && (_jsx("span", { className: "crisp-datatable-edit-error", children: errorHere }))] }, col.key));
2179
2272
  }
2180
2273
  if (isEditing) {
2181
2274
  return (_jsxs(TableCell, { align: alignFor(col), "aria-colindex": colAriaIndex(i), ...sticky, ...rowHeaderProps, onClick: (e) => e.stopPropagation(), children: [_jsx("input", { className: "crisp-datatable-edit",
@@ -65,8 +65,17 @@ const config = defineRecipe({
65
65
  in the whole product and making every interactive column's button
66
66
  double it to 24px. Scoping through \`.crisp-table\` adds a second
67
67
  class to the selector (0,2,1 beats 0,1,1) so this can never lose a
68
- specificity tie to source order again. */
69
- & .crisp-table th { padding: 0; }
68
+ specificity tie to source order again.
69
+ \`thead th\`, not a bare \`th\` (crisp#829): #779 made the primary
70
+ column's BODY cell a real \`<th scope="row">\` for a11y, and a bare
71
+ \`th\` here matches that one too, zeroing an inset nothing else
72
+ supplies -- the primary column's text sat flush left, one column's
73
+ cell no longer matching its own header or any sibling \`<td>\`. Only
74
+ the header row's own \`<th>\` needs zero padding (its sort BUTTON
75
+ owns the inset); the body row's \`<th scope="row">\` has no button,
76
+ and needs the SAME base 12px (table.recipe.ts, #465) every other
77
+ cell already gets. */
78
+ & .crisp-table thead th { padding: 0; }
70
79
  /* Same wrapper trap as AUTHORING.md Lesson 40 (ColumnCalc's summary trigger):
71
80
  Menu wraps its trigger in a flex div that sizes to its content, so a
72
81
  long header grew past the column instead of filling it -- with nothing
@@ -180,6 +189,16 @@ const config = defineRecipe({
180
189
  & .crisp-datatable-refoption-avatar { flex-shrink: 0; }
181
190
  & .crisp-datatable-refoption--create { color: ${c.fgMuted}; }
182
191
  & .crisp-datatable-empty { padding: ${c.space["8"]} ${c.space["3"]}; text-align: center; color: ${c.fgMuted}; font-size: ${c.text.lg}; }
192
+ /* #826 -- the full-body loading state (a filter/sort re-query, or a
193
+ fresh page boot), measured live on Attio's Companies grid on both
194
+ triggers: icon+text centred in the row-body area, 10px gap, 14px/500
195
+ weight/20px line-height, both icon and text in the SAME muted
196
+ rgba(0,0,0,0.63) -- the same color-mix figure this file already uses
197
+ for other muted text (e.g. -phone-dial). Distinct from the cold
198
+ in-place row load (blank, no indicator) and the warm-navigation
199
+ per-cell shimmer (#599) -- neither of those swaps the whole body. */
200
+ & .crisp-datatable-loading { display: flex; align-items: center; justify-content: center; gap: 10px; padding: ${c.space["8"]} ${c.space["3"]}; color: color-mix(in srgb, ${c.fgPrimary} 63%, transparent); font-size: ${c.text.lg}; font-weight: ${c.weight.medium}; line-height: 20px; }
201
+ & .crisp-datatable-loading .crisp-spinner { border-top-color: color-mix(in srgb, ${c.fgPrimary} 63%, transparent); }
183
202
  /* #579: click-to-expand overlay for a truncated cell -- a read-only,
184
203
  full-value card, settled live as click (not Attio's usual
185
204
  hover-expand) and measured position: absolute, unlike this file's
@@ -274,6 +293,10 @@ const config = defineRecipe({
274
293
  & .crisp-datatable-tag[data-tone="orange"] { background: color-mix(in srgb, ${c.bgOrange} 16%, transparent); color: color-mix(in srgb, ${c.bgOrange} 82%, ${c.fgPrimary}); }
275
294
  & .crisp-datatable-num { font-variant-numeric: tabular-nums; }
276
295
  & .crisp-datatable-muted { color: ${c.fgMuted}; }
296
+ /* crisp#822 -- a date/datetime cell's danger/overdue tone: same token as
297
+ TasksTable's own overdue due-date (crisp#462), plain text colour, no
298
+ fill/weight change. */
299
+ & .crisp-datatable-danger { color: ${c.fgDanger}; }
277
300
  /* url/email cell types (#654) -- same measured values as RecordsTable's
278
301
  own .crisp-recordstable-link, which this mirrors: no underline at
279
302
  rest, underline on hover, own-colour text rather than a browser-blue
@@ -287,6 +310,44 @@ const config = defineRecipe({
287
310
  & .crisp-datatable-phone-dial { color: color-mix(in srgb, ${c.fgPrimary} 63%, transparent); }
288
311
  & .crisp-datatable-phone-editor { display: flex; align-items: center; gap: ${c.space["1"]}; height: ${c.size.tableRow}; padding: 0 ${c.space["2"]}; }
289
312
  & .crisp-datatable-phone-editor input { flex: 1; min-width: 0; }
313
+ /* country-picker option row (#842) -- confirmed live: Attio's own popover
314
+ is plain text, no flag anywhere (not the option row, not the closed
315
+ trigger), name opaque + dial code muted rgba(0,0,0,0.63) -- the SAME
316
+ color-mix figure as -phone-dial above. The internal name/dial gap
317
+ wasn't independently measured this pass (Attio's own two <div>s gave
318
+ no exact number); reused space["1"], the same gap -phone already uses
319
+ for its own flag/dial/digits row. */
320
+ & .crisp-datatable-country-option { display: flex; align-items: center; gap: ${c.space["1"]}; }
321
+ & .crisp-datatable-country-dial { color: color-mix(in srgb, ${c.fgPrimary} 63%, transparent); }
322
+ /* multi-value phone (#816) -- confirmed live (a real custom "Multiple
323
+ values" Phone Number attribute, and the built-in People "Phone
324
+ numbers" field): the collapsed cell is a 4px-gap flex row of real
325
+ tel: links in a measured link blue (~rgb(59,108,232) pixel-sampled;
326
+ reused as the closest already-tokened blue, brand600, rather than
327
+ adding a third blue token for a delta this small), underlined,
328
+ UNLIKE the single-value cell above (plain text, no link, no
329
+ underline) -- multi-value is the one shape Attio makes into a real
330
+ link. The cell hard-clips on overflow (no ellipsis -- confirmed
331
+ live, a second value can end mid-digit with no "..."). */
332
+ & .crisp-datatable-phone-multi { display: inline-flex; align-items: center; gap: ${c.space["1"]}; overflow: hidden; }
333
+ & .crisp-datatable-phone-link { color: ${c.bgBrandSolid}; text-decoration: underline; white-space: nowrap; }
334
+ /* multi-value editor (#816) -- confirmed live: a stack of committed-
335
+ value rows (32px = size.menuRow, 5px 8px padding, 8px gap between
336
+ the grip/value/actions, 16px icons) above one ALWAYS-blank trailing
337
+ DataPhoneEditor-shaped row -- that blank row is the only "add
338
+ another" affordance, there's no separate button for it. The
339
+ copy/delete pair reuses .crisp-datatable-entity-action's own
340
+ hover-reveal chrome (not independently re-measured as its own hit
341
+ target this pass, only the 16px icon glyphs were). */
342
+ & .crisp-datatable-phone-multi-editor { display: flex; flex-direction: column; }
343
+ & .crisp-datatable-phone-multi-row { display: flex; align-items: center; gap: ${c.space["2"]}; height: ${c.size.menuRow}; padding: 5px ${c.space["2"]}; }
344
+ & .crisp-datatable-phone-multi-row .crisp-datatable-phone-link { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
345
+ & .crisp-datatable-phone-multi-grip { color: ${c.fgMuted}; flex-shrink: 0; cursor: grab; }
346
+ & .crisp-datatable-phone-multi-actions { display: flex; align-items: center; gap: ${c.space["1"]}; margin-inline-start: auto; flex-shrink: 0; opacity: 0; }
347
+ & .crisp-datatable-phone-multi-row:hover .crisp-datatable-phone-multi-actions,
348
+ & .crisp-datatable-phone-multi-actions:focus-within { opacity: 1; }
349
+ & .crisp-datatable-phone-multi-action { display: inline-flex; align-items: center; justify-content: center; width: 20px; height: 20px; margin: 0; padding: 0; border: none; border-radius: ${c.radius.sm}; background: none; font-family: inherit; color: color-mix(in srgb, ${c.fgPrimary} 55%, transparent); cursor: pointer; flex-shrink: 0; }
350
+ & .crisp-datatable-phone-multi-action:hover { background: color-mix(in srgb, ${c.fgPrimary} 4%, transparent); color: ${c.fgPrimary}; }
290
351
  /* datetime cell editor (#762) -- DatePickerField + TimePicker side by side. */
291
352
  & .crisp-datatable-datetime-editor { display: flex; align-items: center; gap: ${c.space["1"]}; height: ${c.size.tableRow}; padding: 0 ${c.space["2"]}; }
292
353
  /* rating cell type/editor (#760) -- measured live: 16px stars on an
@@ -168,7 +168,7 @@ export function Select({ options, value, defaultValue, onValueChange, name, requ
168
168
  return (_jsxs("div", { ref: rootRef, className: selectRecipe({ variant, width, invalid, className }), ...rest, children: [name && (selected || required) && (_jsx("input", { className: "crisp-formvalue", style: formValueStyle, name: name, value: selected ?? "", required: required, tabIndex: -1, "aria-hidden": "true",
169
169
  // controlled by the listbox above; `readOnly` would bar it from
170
170
  // validation, which is the only reason this input is not hidden
171
- onChange: () => { } })), _jsxs("button", { ref: triggerRef, type: "button", id: id, role: "combobox", "aria-haspopup": "listbox", "aria-expanded": open, "aria-controls": open ? listboxId : undefined, "aria-activedescendant": searchable ? undefined : activeId, "aria-label": ariaLabel, "aria-invalid": invalid === true || undefined, disabled: disabled, "data-open": open ? "" : undefined, className: "crisp-select-trigger", onClick: () => (open ? setOpen(false) : openMenu()), onKeyDown: onTriggerKeyDown, children: [_jsx("span", { className: "crisp-select-value", children: selectedOption ? (_jsxs(_Fragment, { children: [selectedOption.color && (_jsx("span", { className: "crisp-select-option-dot", style: { background: selectedOption.color } })), selectedOption.label] })) : (_jsx("span", { className: "crisp-select-placeholder", children: placeholder })) }), _jsx(ChevronDown, { className: "crisp-select-chevron", size: 16, strokeWidth: 1.75 })] }), open && (_jsx(FloatingPortal, { sourceRef: rootRef, children: _jsxs("div", { ref: listboxRef, id: listboxId, className: "crisp-select-listbox", role: "listbox", "aria-label": ariaLabel, "data-up": up ? "" : undefined, style: pos, children: [searchable && (_jsxs("div", { className: "crisp-select-search", children: [_jsx(Search, { size: 15, strokeWidth: 1.75 }), _jsx("input", { ref: searchRef, type: "text", placeholder: searchPlaceholder, "aria-label": searchPlaceholder, "aria-controls": listboxId, "aria-activedescendant": activeId, value: query, onChange: (e) => {
171
+ onChange: () => { } })), _jsxs("button", { ref: triggerRef, type: "button", id: id, role: "combobox", "aria-haspopup": "listbox", "aria-expanded": open, "aria-controls": open ? listboxId : undefined, "aria-activedescendant": searchable ? undefined : activeId, "aria-label": ariaLabel, "aria-invalid": invalid === true || undefined, disabled: disabled, "data-open": open ? "" : undefined, className: "crisp-select-trigger", onClick: () => (open ? setOpen(false) : openMenu()), onKeyDown: onTriggerKeyDown, children: [_jsx("span", { className: "crisp-select-value", children: selectedOption ? (_jsxs(_Fragment, { children: [selectedOption.color && (_jsx("span", { className: "crisp-select-option-dot", style: { background: selectedOption.color } })), selectedOption.triggerLabel ?? selectedOption.label] })) : (_jsx("span", { className: "crisp-select-placeholder", children: placeholder })) }), _jsx(ChevronDown, { className: "crisp-select-chevron", size: 16, strokeWidth: 1.75 })] }), open && (_jsx(FloatingPortal, { sourceRef: rootRef, children: _jsxs("div", { ref: listboxRef, id: listboxId, className: "crisp-select-listbox", role: "listbox", "aria-label": ariaLabel, "data-up": up ? "" : undefined, style: pos, children: [searchable && (_jsxs("div", { className: "crisp-select-search", children: [_jsx(Search, { size: 15, strokeWidth: 1.75 }), _jsx("input", { ref: searchRef, type: "text", placeholder: searchPlaceholder, "aria-label": searchPlaceholder, "aria-controls": listboxId, "aria-activedescendant": activeId, value: query, onChange: (e) => {
172
172
  setQuery(e.target.value);
173
173
  setHighlight(0);
174
174
  onSearchChange?.(e.target.value);
@@ -25,8 +25,25 @@ const config = defineRecipe({
25
25
  /* #554: sticky on the vertical axis, mirroring RecordsTable's own
26
26
  \`.crisp-recordstable-th\`. z-index 2 sits above a plain body cell (auto)
27
27
  and below a sticky-column corner cell (DataTable's \`th[data-sticky]\`,
28
- z-index 3), so the corner always wins. */
29
- & th { position: sticky; top: 0; z-index: 2; height: ${c.size.tableHeader}; background: ${c.bgSurface}; border-top: 1px solid ${c.borderSubtle}; font-size: ${c.text.lg}; font-weight: ${c.weight.medium}; letter-spacing: -0.14px; color: color-mix(in srgb, ${c.fgPrimary} 63%, transparent); user-select: none; }
28
+ z-index 3), so the corner always wins.
29
+ \`thead th\`, not a bare \`th\` (crisp#833/#835): DataTable's primary
30
+ column renders its BODY cell as a real \`<th scope="row">\` (#779, for
31
+ a11y), and a bare \`th\` here matched that one too -- every property in
32
+ this rule leaked onto it: \`height: tableHeader\` (40px) stretched the
33
+ whole body row to 40px instead of 36 (#833, confirmed live on the
34
+ showcase, every td in the row equalized to the one th); \`color:
35
+ color-mix(...63%...)\` muted the primary column's own text below every
36
+ sibling \`<td>\`'s full-opacity color (#835, re-measured live against
37
+ Attio's own header labels -- including a genuinely computed one,
38
+ "Twitter follower count" -- all fully opaque \`rgb(16,17,18)\`, no
39
+ muting at all, so this drops the color-mix entirely rather than
40
+ narrowing it); \`position:sticky; top:0\` tried to stick a BODY row
41
+ to the scroll container's top; the extra \`border-top\` doubled that
42
+ row's own grid line. \`thead th\` simply never matches
43
+ \`tbody th[scope=row]\` at all, so none of this reaches it; the base
44
+ \`& th, & td\` rule above (36px, one border-bottom, full opacity via
45
+ \`& table\`'s own color) applies to it like every other cell. */
46
+ & thead th { position: sticky; top: 0; z-index: 2; height: ${c.size.tableHeader}; background: ${c.bgSurface}; border-top: 1px solid ${c.borderSubtle}; font-size: ${c.text.lg}; font-weight: ${c.weight.medium}; letter-spacing: -0.14px; color: ${c.fgPrimary}; user-select: none; }
30
47
  & td { color: ${c.fgPrimary}; background: ${c.bgSurface}; }
31
48
  & th:last-child, & td:last-child { border-inline-end: none; }
32
49
  & tbody tr:last-child td { border-bottom: none; }
@@ -53,7 +70,7 @@ const config = defineRecipe({
53
70
  // bottom one: the container's edge wins, cells never double it.
54
71
  surface: {
55
72
  card: `border: 1px solid ${c.borderSubtle}; border-radius: ${c.radius.xl}; overflow: hidden;
56
- & th { border-top: none; }
73
+ & thead th { border-top: none; }
57
74
  & tfoot td { border-bottom: none; }`,
58
75
  bleed: `border: none; border-radius: 0;`,
59
76
  },
@@ -50,7 +50,14 @@ 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: ${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; }
53
+ /* crisp/#830: every trigger label here (Sort, Filter, View settings,
54
+ Import/Export -- all compose this one shared .tool rule) read the
55
+ "md" text size, 13px. Re-measured on Attio's own trigger LEAF node
56
+ (the button root itself inherits Attio's own 10px rem base and is a
57
+ red herring, the same trap the original "10px font" layout-audit
58
+ finding fell into): every label leaf is 14px/500/lh20, the "lg" text
59
+ size. */
60
+ & .${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.lg}; font-weight: ${c.weight.medium}; white-space: nowrap; cursor: pointer; transition: background-color .12s; }
54
61
  & .${CLASS_PREFIX}${p}-tool:hover { background: ${c.bgRaised}; }
55
62
  & .${CLASS_PREFIX}${p}-tool svg { flex-shrink: 0; color: ${c.fgMuted}; }
56
63
  /* crisp/#772: re-measured live on Sort with an actual sort applied --
@@ -75,8 +82,22 @@ export const toolbarChromeHead = (p) => ` & .${CLASS_PREFIX}${p}-toolbar { di
75
82
  same reasoning --ghost above already uses. Only wired into
76
83
  TableToolbar itself (tabletoolbar.tsx) this pass -- RecordsTable's
77
84
  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); }
85
+ deprecated, not touched here.
86
+ crisp/#844, correcting #831: the label colour was added here later,
87
+ muting only the FILTER trigger (via a "--filter" modifier), on the
88
+ assumption that was a Filter-specific tone distinct from Sort's.
89
+ Re-measured live and that premise was wrong -- Sort's own resting
90
+ label ("Sort", no condition set) is the exact SAME muted rgba(0,0,0,.5)
91
+ as Filter's, not full-strength. (The rgba(0,0,0,.63) that looked like
92
+ "Sort's colour" in an earlier pass is a different thing entirely: the
93
+ "Sorted by" PREFIX word inside an ACTIVE sort chip, #788 -- a
94
+ compound-label treatment that only exists once a sort is applied, not
95
+ a resting-state colour.) --dashed is currently only ever applied to
96
+ Sort's and Filter's own empty triggers (never View settings/
97
+ Import-Export, which stay full-strength always, confirmed live), so
98
+ the mute belongs on --dashed itself, not gated behind a Filter-only
99
+ modifier. */
100
+ & .${CLASS_PREFIX}${p}-tool--dashed { position: relative; box-shadow: none; background: color-mix(in srgb, ${c.fgPrimary} 2%, transparent); color: color-mix(in srgb, ${c.fgPrimary} 50%, transparent); }
80
101
  & .${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
102
  & .${CLASS_PREFIX}${p}-tool--dashed:hover { background: color-mix(in srgb, ${c.fgPrimary} 4%, transparent); }
82
103
  & .${CLASS_PREFIX}${p}-toolgroup { position: relative; display: inline-flex; }