@crisp-ui-kit/crisp 0.55.0 → 0.55.2

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
@@ -14,13 +14,16 @@ import { Avatar } from "../avatar/index.js";
14
14
  import { Checkbox } from "../checkbox/index.js";
15
15
  import { ColumnCalc } from "../columncalc/index.js";
16
16
  import { ContextMenu } from "../contextmenu/index.js";
17
+ import { DatePickerField } from "../datepickerfield/index.js";
17
18
  import { Menu, MenuItem } from "../menu/index.js";
18
19
  import { useRadioGroupKeyboard } from "../radiogroup/index.js";
19
20
  import { Select } from "../select/index.js";
20
21
  import { Spinner } from "../spinner/index.js";
21
22
  import { Switch } from "../switch/index.js";
22
23
  import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow, } from "../table/index.js";
24
+ import { TimePicker } from "../timepicker/index.js";
23
25
  import { Tooltip } from "../tooltip/index.js";
26
+ import { COUNTRIES, countryByIso2, flagEmoji } from "./countries.js";
24
27
  import { datatableRecipe } from "./datatable.recipe.js";
25
28
  // #534: whether an element's own content is being cut off by its own box --
26
29
  // the same fact `.crisp-datatable-th-label`'s CSS ellipsis is silently
@@ -158,6 +161,30 @@ function asText(v) {
158
161
  return v.join(", ");
159
162
  return String(v);
160
163
  }
164
+ // crisp/#765 -- a `type: "phone"`/`editor: "phone"` cell's raw value is
165
+ // "{iso2}|{digits}", not a bare E.164 string: see DataTableColumn's own
166
+ // "phone" doc comment for why the pipe separator earns its keep over a
167
+ // plain "+{dialCode}{digits}" concatenation.
168
+ function parsePhoneValue(v) {
169
+ const s = typeof v === "string" ? v : "";
170
+ const i = s.indexOf("|");
171
+ if (i < 0)
172
+ return null;
173
+ return { iso2: s.slice(0, i), digits: s.slice(i + 1) };
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
+ }
161
188
  // stable pastel tone per string value — colour-coded tags/statuses
162
189
  const TAG_TONES = [
163
190
  "brand",
@@ -377,6 +404,29 @@ function renderTyped(col, value, avatarTone, row, display, locale = "en-US", ope
377
404
  return empty;
378
405
  return _jsx(RatingStars, { value: n, max: col.maxRating ?? 5 });
379
406
  }
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
+ }
424
+ const parsed = parsePhoneValue(value);
425
+ if (!parsed?.digits)
426
+ return empty;
427
+ const country = countryByIso2(parsed.iso2);
428
+ return (_jsxs("span", { className: "crisp-datatable-phone", children: [country && _jsx("span", { "aria-hidden": "true", children: flagEmoji(country.iso2) }), _jsxs("span", { className: "crisp-datatable-phone-dial", children: ["+", country?.dialCode ?? "?"] }), parsed.digits] }));
429
+ }
380
430
  case "url":
381
431
  case "email": {
382
432
  const text = display || asText(value);
@@ -387,6 +437,16 @@ function renderTyped(col, value, avatarTone, row, display, locale = "en-US", ope
387
437
  // link's own navigation must not also fire the row's onRowClick.
388
438
  onClick: (ev) => ev.stopPropagation(), children: text }));
389
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
+ }
390
450
  default: {
391
451
  const text = display || formatCell(col.type, value, col.symbol, locale);
392
452
  const isNum = col.type === "number" || col.type === "currency";
@@ -530,6 +590,130 @@ function DataReferenceEditor({ col, value, isPending, onCommit, onCancel, }) {
530
590
  onCommit(v);
531
591
  } }) }));
532
592
  }
593
+ // crisp/#765, confirmed live -- Attio's own Phone Number editor: a
594
+ // country-code select (ghost trigger, "MN ▾"), then a static, non-editable
595
+ // "+{dialCode}" prefix, then a borderless digits field -- measured
596
+ // `rgba(0,0,0,0.63)` for the prefix text, matching every other muted-text
597
+ // value already measured elsewhere in this file (14px in both cases).
598
+ // Reuses crisp's own searchable `Select` for the country list -- the same
599
+ // popover chrome every other picker in this file already uses -- rather
600
+ // than inventing new country-picker chrome; the flag/name/dial-code ROW
601
+ // shape wasn't independently re-measured against Attio's own popover this
602
+ // pass (time-boxed), so it's built to the same shape crisp's other search
603
+ // pickers already carry.
604
+ function DataPhoneEditor({ col, value, isPending, onCommit, }) {
605
+ const parsed = parsePhoneValue(value);
606
+ const [iso2, setIso2] = React.useState(parsed?.iso2 ?? "US");
607
+ const [digits, setDigits] = React.useState(parsed?.digits ?? "");
608
+ const country = countryByIso2(iso2);
609
+ const countryOptions = COUNTRIES.map((c) => ({
610
+ value: c.iso2,
611
+ label: `${flagEmoji(c.iso2)} ${c.name} +${c.dialCode}`,
612
+ keywords: `${c.name} ${c.iso2} ${c.dialCode}`,
613
+ }));
614
+ const commit = (nextIso2, nextDigits) => onCommit(`${nextIso2}|${nextDigits}`);
615
+ 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) => {
616
+ setIso2(v);
617
+ commit(v, digits);
618
+ } }), _jsxs("span", { className: "crisp-datatable-phone-dial", "aria-hidden": "true", children: ["+", country?.dialCode ?? "?"] }), _jsx("input", { className: "crisp-datatable-edit", "aria-label": `Edit ${col.key} number`, value: digits, disabled: isPending, onChange: (e) => setDigits(e.currentTarget.value), onBlur: () => commit(iso2, digits), onKeyDown: (e) => {
619
+ if (e.key === "Enter")
620
+ commit(iso2, digits);
621
+ } })] }));
622
+ }
623
+ // crisp/#816, confirmed live -- Attio's own multi-value Phone Number
624
+ // editor (`col.multiValue`): a stack of already-committed values (a
625
+ // drag grip, the value as a real `tel:` link, and a hover-revealed
626
+ // copy/delete pair -- measured `32px` row, `5px 8px` padding, `8px` gap,
627
+ // `16×16` icons) plus one ALWAYS-blank trailing "country + digits" row in
628
+ // `DataPhoneEditor`'s own shape. The blank row itself is the only
629
+ // affordance for adding another value -- there is no separate "+ Add"
630
+ // button; committing it (Enter/blur with digits) appends and clears back
631
+ // to blank so the next value can be typed immediately. Drag-to-reorder
632
+ // (the grip icon) was measured present but isn't wired here -- reordering
633
+ // a committed array is a distinct interaction from add/remove/copy and is
634
+ // left as a follow-up rather than a partial, non-functional drag handle.
635
+ function DataPhoneMultiEditor({ col, value, isPending, onCommit, }) {
636
+ const committed = parsePhoneValues(value);
637
+ const [draftIso2, setDraftIso2] = React.useState("US");
638
+ const [draftDigits, setDraftDigits] = React.useState("");
639
+ const countryOptions = COUNTRIES.map((c) => ({
640
+ value: c.iso2,
641
+ label: `${flagEmoji(c.iso2)} ${c.name} +${c.dialCode}`,
642
+ keywords: `${c.name} ${c.iso2} ${c.dialCode}`,
643
+ }));
644
+ const join = (list) => list.map((p) => `${p.iso2}|${p.digits}`).join(";");
645
+ const removeAt = (i) => onCommit(join(committed.filter((_, idx) => idx !== i)));
646
+ const commitDraft = () => {
647
+ if (!draftDigits)
648
+ return;
649
+ onCommit(join([...committed, { iso2: draftIso2, digits: draftDigits }]));
650
+ setDraftDigits("");
651
+ };
652
+ return (_jsxs("span", { className: "crisp-datatable-phone-multi-editor", children: [committed.map((p, i) => {
653
+ const country = countryByIso2(p.iso2);
654
+ const tel = `+${country?.dialCode ?? "?"}${p.digits}`;
655
+ 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}`));
656
+ }), _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) => {
657
+ if (e.key === "Enter")
658
+ commitDraft();
659
+ } })] })] }));
660
+ }
661
+ // crisp/#762, confirmed live -- Attio's own Timestamp editor is a calendar +
662
+ // time + preset picker, not the native `datetime-local` input `type:
663
+ // "datetime"` gets by default (that stays the DEFAULT, unchanged, for
664
+ // backward compatibility -- this is a new, opt-in `editor: "datetime"`).
665
+ // Composes crisp's own `DatePickerField` (calendar + presets, already the
666
+ // measured shape: Today/Tomorrow/Next week/No date chips under the
667
+ // calendar) with `TimePicker` (hour/minute wheel) side by side. The exact
668
+ // PRESET WORDING/order and the time control's own wheel-vs-text-field
669
+ // shape weren't independently re-verified against Attio's pixels this
670
+ // pass (time-boxed) -- `TimePicker` is crisp's only time-input primitive,
671
+ // reused rather than inventing a new one from scratch.
672
+ function DataDateTimeEditor({ col, value, isPending, onCommit, }) {
673
+ const initial = value instanceof Date
674
+ ? value
675
+ : typeof value === "string" || typeof value === "number"
676
+ ? new Date(value)
677
+ : null;
678
+ const hasInitial = initial != null && !Number.isNaN(initial.getTime());
679
+ const [date, setDate] = React.useState(hasInitial ? initial : null);
680
+ const [time, setTime] = React.useState({
681
+ hour: hasInitial ? initial.getHours() : 0,
682
+ minute: hasInitial ? initial.getMinutes() : 0,
683
+ });
684
+ const commit = (nextDate, nextTime) => {
685
+ if (!nextDate) {
686
+ onCommit("");
687
+ return;
688
+ }
689
+ const pad = (n) => String(n).padStart(2, "0");
690
+ onCommit(`${nextDate.getFullYear()}-${pad(nextDate.getMonth() + 1)}-${pad(nextDate.getDate())}T${pad(nextTime.hour)}:${pad(nextTime.minute)}`);
691
+ };
692
+ const preset = (label, days) => ({
693
+ label,
694
+ date: () => {
695
+ const d = new Date();
696
+ d.setDate(d.getDate() + days);
697
+ return d;
698
+ },
699
+ });
700
+ return (_jsxs("span", { className: "crisp-datatable-datetime-editor", children: [_jsx(DatePickerField, { "aria-label": `Edit ${col.key} date`, variant: "ghost",
701
+ // "No date" chip in Attio's own popover maps to DatePickerField's
702
+ // existing Clear affordance -- its own default label ("Clear
703
+ // date") is already overridable via `useCrispMessage` internally,
704
+ // so no hardcoded copy of this component's own is added here.
705
+ value: date, presets: [
706
+ preset("Today", 0),
707
+ preset("Tomorrow", 1),
708
+ preset("Next week", 7),
709
+ ], onValueChange: (d) => {
710
+ setDate(d);
711
+ commit(d, time);
712
+ } }), _jsx(TimePicker, { size: "small", value: time, disabled: isPending, onValueChange: (t) => {
713
+ setTime(t);
714
+ commit(date, t);
715
+ } })] }));
716
+ }
533
717
  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 }) {
534
718
  // crisp/#471: same resolution order as TableToolbar's own — an explicit
535
719
  // prop wins, then the provider's translation of the built-in English
@@ -1285,12 +1469,8 @@ export function DataTable({ columns: columnsProp, data, selectable = false, sele
1285
1469
  // Cmd/Ctrl+ArrowDown to the last -- confirmed live (Attio's own help
1286
1470
  // drawer documents "Jump to top"/"Jump to bottom" on these exact
1287
1471
  // bindings). `!e.shiftKey` guards this from stealing Cmd+Shift+Arrow,
1288
- // a separate gesture (#773, extend-selection-to-first/last) that falls
1289
- // through to the Shift+Arrow branch below untouched. Placed before that
1290
- // branch for the same reason: without the guard, Cmd+Shift+Arrow would
1291
- // still reach it (that branch doesn't check metaKey/ctrlKey either),
1292
- // just not do the "jump" part -- the guard here is what keeps the two
1293
- // gestures from fighting over the same keys.
1472
+ // the separate #773 gesture (extend-selection-to-first/last) handled
1473
+ // in its own branch right below.
1294
1474
  if ((e.metaKey || e.ctrlKey) &&
1295
1475
  !e.shiftKey &&
1296
1476
  (e.key === "ArrowUp" || e.key === "ArrowDown")) {
@@ -1303,6 +1483,34 @@ export function DataTable({ columns: columnsProp, data, selectable = false, sele
1303
1483
  clipboardProxyRef.current?.select();
1304
1484
  return;
1305
1485
  }
1486
+ // crisp/#773: Cmd/Ctrl+Shift+Up/Down extends the row-checkbox selection
1487
+ // all the way to the first/last row in one keystroke -- confirmed live,
1488
+ // the "jump" sibling of #749's one-step Shift+Arrow below, sharing the
1489
+ // same anchor→destination-span recompute (only the destination differs:
1490
+ // 0 / rows.length-1 instead of r±1). Checked ahead of #749's own branch
1491
+ // so the modifier routes here instead of being swallowed as a one-step
1492
+ // Shift+Arrow.
1493
+ if (selectable &&
1494
+ (e.metaKey || e.ctrlKey) &&
1495
+ e.shiftKey &&
1496
+ (e.key === "ArrowDown" || e.key === "ArrowUp")) {
1497
+ const r0 = rows.findIndex((row) => row.id === selCell.id);
1498
+ if (r0 < 0)
1499
+ return;
1500
+ const nr0 = e.key === "ArrowDown" ? rows.length - 1 : 0;
1501
+ e.preventDefault();
1502
+ const anchorId = rowSelectAnchor ?? selCell.id;
1503
+ const anchorIdx = rows.findIndex((row) => row.id === anchorId);
1504
+ if (anchorIdx < 0)
1505
+ return;
1506
+ setRowSelectAnchor(anchorId);
1507
+ const lo = Math.min(anchorIdx, nr0);
1508
+ const hi = Math.max(anchorIdx, nr0);
1509
+ setSelected(new Set(rows.slice(lo, hi + 1).map((row) => row.id)));
1510
+ setSelCell({ id: rows[nr0].id, key: selCell.key });
1511
+ clipboardProxyRef.current?.select();
1512
+ return;
1513
+ }
1306
1514
  // crisp/#749: Shift+ArrowUp/Down extends the row-checkbox selection --
1307
1515
  // confirmed live, distinct from Shift+Click's cell-range gesture above
1308
1516
  // (that one is `selAnchor`; this is `rowSelectAnchor`, and the two
@@ -1333,12 +1541,38 @@ export function DataTable({ columns: columnsProp, data, selectable = false, sele
1333
1541
  clipboardProxyRef.current?.select();
1334
1542
  return;
1335
1543
  }
1544
+ // crisp/#770: Shift+Space toggles the current row's own checkbox without
1545
+ // moving the selected cell or touching any other row -- distinct from
1546
+ // #749/#773 above, whose Shift+Arrow gestures move the cursor as they
1547
+ // extend the range.
1548
+ if (selectable && e.shiftKey && e.key === " ") {
1549
+ e.preventDefault();
1550
+ toggleRow(selCell.id);
1551
+ return;
1552
+ }
1553
+ // crisp/#771: Cmd/Ctrl+Enter always opens the row's record page,
1554
+ // regardless of whether the current column is editable -- distinct from
1555
+ // plain Enter below, which opens the cell's own editor first when one
1556
+ // exists.
1557
+ if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
1558
+ if (onRowClick) {
1559
+ const r0 = rows.findIndex((row) => row.id === selCell.id);
1560
+ if (r0 < 0)
1561
+ return;
1562
+ e.preventDefault();
1563
+ onRowClick(rows[r0]);
1564
+ }
1565
+ return;
1566
+ }
1336
1567
  const r = rows.findIndex((row) => row.id === selCell.id);
1337
1568
  const c = columns.findIndex((col) => col.key === selCell.key);
1338
1569
  if (r < 0 || c < 0)
1339
1570
  return;
1340
1571
  let nr = r;
1341
1572
  let nc = c;
1573
+ // #772/#773 (Cmd/Ctrl±Shift+Up/Down) are both handled in their own
1574
+ // early-return branches above, before `r`/`c` are even computed -- a
1575
+ // plain arrow (no modifier) is the only thing that reaches here.
1342
1576
  if (e.key === "ArrowUp")
1343
1577
  nr = Math.max(0, r - 1);
1344
1578
  else if (e.key === "ArrowDown")
@@ -1350,6 +1584,33 @@ export function DataTable({ columns: columnsProp, data, selectable = false, sele
1350
1584
  const forward = e.key === (rtl ? "ArrowLeft" : "ArrowRight");
1351
1585
  nc = forward ? Math.min(columns.length - 1, c + 1) : Math.max(0, c - 1);
1352
1586
  }
1587
+ // crisp/#769: Tab/Shift+Tab move to the next/previous cell in reading
1588
+ // order, wrapping to the next (or previous) row at a row boundary. This
1589
+ // is pure logical/DOM order, not a visual direction, so unlike Left/Right
1590
+ // above it needs no RTL handling -- native tab order doesn't flip under
1591
+ // RTL either. At the grid's first/last cell there's nowhere left to go:
1592
+ // return without `preventDefault`, so Tab still falls through to the
1593
+ // browser's native focus order instead of getting trapped.
1594
+ else if (e.key === "Tab" && !e.shiftKey) {
1595
+ if (c + 1 < columns.length)
1596
+ nc = c + 1;
1597
+ else if (r + 1 < rows.length) {
1598
+ nr = r + 1;
1599
+ nc = 0;
1600
+ }
1601
+ else
1602
+ return;
1603
+ }
1604
+ else if (e.key === "Tab" && e.shiftKey) {
1605
+ if (c - 1 >= 0)
1606
+ nc = c - 1;
1607
+ else if (r - 1 >= 0) {
1608
+ nr = r - 1;
1609
+ nc = columns.length - 1;
1610
+ }
1611
+ else
1612
+ return;
1613
+ }
1353
1614
  // crisp/#607, G-066/G-068: End jumps to the last row (confirmed live --
1354
1615
  // it also triggers Attio's own lazy-load, which is a data-loading
1355
1616
  // concern outside this component; the #577 scroll-into-view effect
@@ -1429,17 +1690,23 @@ export function DataTable({ columns: columnsProp, data, selectable = false, sele
1429
1690
  // value while showing the rejection's message beside the cell (reject).
1430
1691
  // The revert needs no extra code: this component never mutates `data`
1431
1692
  // itself, so once `editing` clears, the cell simply shows `data` again.
1432
- function commitEdit(id, key, value) {
1693
+ // crisp/#816 -- `keepEditing` lets a multi-value editor stay open across
1694
+ // a commit (adding/removing one value must not close the whole cell, the
1695
+ // way it does for every other, single-value editor). Default false
1696
+ // preserves every existing editor's own close-on-commit behaviour.
1697
+ function commitEdit(id, key, value, keepEditing = false) {
1433
1698
  setEditError(null);
1434
1699
  const result = onEdit?.(id, key, value);
1435
1700
  if (result && typeof result.then === "function") {
1436
1701
  setPending({ id, key });
1437
1702
  result.then(() => {
1438
1703
  setPending(null);
1439
- setEditing(null);
1704
+ if (!keepEditing)
1705
+ setEditing(null);
1440
1706
  }, (err) => {
1441
1707
  setPending(null);
1442
- setEditing(null);
1708
+ if (!keepEditing)
1709
+ setEditing(null);
1443
1710
  setEditError({
1444
1711
  id,
1445
1712
  key,
@@ -1452,7 +1719,8 @@ export function DataTable({ columns: columnsProp, data, selectable = false, sele
1452
1719
  });
1453
1720
  return;
1454
1721
  }
1455
- setEditing(null);
1722
+ if (!keepEditing)
1723
+ setEditing(null);
1456
1724
  }
1457
1725
  const reduceMotion = usePrefersReducedMotion();
1458
1726
  // crisp/#577 -- keyboard nav to an off-screen (virtualized-out) cell
@@ -1977,7 +2245,9 @@ export function DataTable({ columns: columnsProp, data, selectable = false, sele
1977
2245
  if (isEditing &&
1978
2246
  (col.editor === "select" ||
1979
2247
  col.editor === "reference" ||
1980
- col.editor === "actor")) {
2248
+ col.editor === "actor" ||
2249
+ col.editor === "phone" ||
2250
+ col.editor === "datetime")) {
1981
2251
  return (_jsxs(TableCell, { align: alignFor(col), "aria-colindex": colAriaIndex(i), ...sticky, ...rowHeaderProps, className: "crisp-datatable-td--picker", "data-pending": isPending ? "" : undefined,
1982
2252
  // Same as the plain text editor's own cell:
1983
2253
  // opening/using the picker must not also fire
@@ -1989,7 +2259,7 @@ export function DataTable({ columns: columnsProp, data, selectable = false, sele
1989
2259
  // portaled popover, so a handler here can't
1990
2260
  // rely on the key event ever bubbling through
1991
2261
  // this element (#453).
1992
- 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) })) : (_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));
2262
+ 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));
1993
2263
  }
1994
2264
  if (isEditing) {
1995
2265
  return (_jsxs(TableCell, { align: alignFor(col), "aria-colindex": colAriaIndex(i), ...sticky, ...rowHeaderProps, onClick: (e) => e.stopPropagation(), children: [_jsx("input", { className: "crisp-datatable-edit",
@@ -7,7 +7,15 @@ import { contract as c, elevation as e } from "../../tokens/index.js";
7
7
  const config = defineRecipe({
8
8
  name: "datatable",
9
9
  base: `display: flex; flex-direction: column; gap: ${c.space["2.5"]}; font-family: ${c.font.sans};
10
- & .crisp-datatable-toolbar { display: flex; align-items: center; gap: ${c.space["2"]}; min-height: 49px; border-top: 1px solid ${c.borderSubtle}; }
10
+ /* The toolbar carried no horizontal inset of its own, so its content
11
+ (search box, sort/filter chips) sat flush at the table's outer edge
12
+ (x=0) while the header row's own th/td padding (table.recipe.ts,
13
+ #465) starts every column's text at 12px -- one row down, the SAME
14
+ grid's own header cell already begins at that inset. c.space["3"]
15
+ here just matches the row directly below it, measured live against
16
+ Attio (the sort/filter row's search chip lines up with the first
17
+ column header's text, not the grid's raw edge). */
18
+ & .crisp-datatable-toolbar { display: flex; align-items: center; gap: ${c.space["2"]}; min-height: 49px; border-top: 1px solid ${c.borderSubtle}; padding: 0 ${c.space["3"]}; }
11
19
  & .crisp-datatable-search { position: relative; flex: 1; max-width: 280px; display: inline-flex; align-items: center; }
12
20
  & .crisp-datatable-search svg { position: absolute; inset-inline-start: ${c.space["2"]}; color: ${c.fgMuted}; pointer-events: none; }
13
21
  & .crisp-datatable-search input { width: 100%; height: 32px; padding: 0 ${c.space["2"]} 0 ${c.space["6"]}; border: none; border-radius: ${c.radius.md}; background: ${c.bgSurface}; box-shadow: ${e.field}; font-family: inherit; font-size: ${c.text.lg}; color: ${c.fgPrimary}; outline: none; transition: box-shadow .15s; }
@@ -266,12 +274,54 @@ const config = defineRecipe({
266
274
  & .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}); }
267
275
  & .crisp-datatable-num { font-variant-numeric: tabular-nums; }
268
276
  & .crisp-datatable-muted { color: ${c.fgMuted}; }
277
+ /* crisp#822 -- a date/datetime cell's danger/overdue tone: same token as
278
+ TasksTable's own overdue due-date (crisp#462), plain text colour, no
279
+ fill/weight change. */
280
+ & .crisp-datatable-danger { color: ${c.fgDanger}; }
269
281
  /* url/email cell types (#654) -- same measured values as RecordsTable's
270
282
  own .crisp-recordstable-link, which this mirrors: no underline at
271
283
  rest, underline on hover, own-colour text rather than a browser-blue
272
284
  link. */
273
285
  & .crisp-datatable-link { color: ${c.fgPrimary}; text-decoration: none; overflow: hidden; text-overflow: ellipsis; }
274
286
  & .crisp-datatable-link:hover { text-decoration: underline; }
287
+ /* phone cell type/editor (#765) -- measured live: the dial-code prefix
288
+ is a muted rgba(0,0,0,0.63) at 14px, matching every other muted-text
289
+ value already measured elsewhere in this file. */
290
+ & .crisp-datatable-phone { display: inline-flex; align-items: center; gap: ${c.space["1"]}; }
291
+ & .crisp-datatable-phone-dial { color: color-mix(in srgb, ${c.fgPrimary} 63%, transparent); }
292
+ & .crisp-datatable-phone-editor { display: flex; align-items: center; gap: ${c.space["1"]}; height: ${c.size.tableRow}; padding: 0 ${c.space["2"]}; }
293
+ & .crisp-datatable-phone-editor input { flex: 1; min-width: 0; }
294
+ /* multi-value phone (#816) -- confirmed live (a real custom "Multiple
295
+ values" Phone Number attribute, and the built-in People "Phone
296
+ numbers" field): the collapsed cell is a 4px-gap flex row of real
297
+ tel: links in a measured link blue (~rgb(59,108,232) pixel-sampled;
298
+ reused as the closest already-tokened blue, brand600, rather than
299
+ adding a third blue token for a delta this small), underlined,
300
+ UNLIKE the single-value cell above (plain text, no link, no
301
+ underline) -- multi-value is the one shape Attio makes into a real
302
+ link. The cell hard-clips on overflow (no ellipsis -- confirmed
303
+ live, a second value can end mid-digit with no "..."). */
304
+ & .crisp-datatable-phone-multi { display: inline-flex; align-items: center; gap: ${c.space["1"]}; overflow: hidden; }
305
+ & .crisp-datatable-phone-link { color: ${c.bgBrandSolid}; text-decoration: underline; white-space: nowrap; }
306
+ /* multi-value editor (#816) -- confirmed live: a stack of committed-
307
+ value rows (32px = size.menuRow, 5px 8px padding, 8px gap between
308
+ the grip/value/actions, 16px icons) above one ALWAYS-blank trailing
309
+ DataPhoneEditor-shaped row -- that blank row is the only "add
310
+ another" affordance, there's no separate button for it. The
311
+ copy/delete pair reuses .crisp-datatable-entity-action's own
312
+ hover-reveal chrome (not independently re-measured as its own hit
313
+ target this pass, only the 16px icon glyphs were). */
314
+ & .crisp-datatable-phone-multi-editor { display: flex; flex-direction: column; }
315
+ & .crisp-datatable-phone-multi-row { display: flex; align-items: center; gap: ${c.space["2"]}; height: ${c.size.menuRow}; padding: 5px ${c.space["2"]}; }
316
+ & .crisp-datatable-phone-multi-row .crisp-datatable-phone-link { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
317
+ & .crisp-datatable-phone-multi-grip { color: ${c.fgMuted}; flex-shrink: 0; cursor: grab; }
318
+ & .crisp-datatable-phone-multi-actions { display: flex; align-items: center; gap: ${c.space["1"]}; margin-inline-start: auto; flex-shrink: 0; opacity: 0; }
319
+ & .crisp-datatable-phone-multi-row:hover .crisp-datatable-phone-multi-actions,
320
+ & .crisp-datatable-phone-multi-actions:focus-within { opacity: 1; }
321
+ & .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; }
322
+ & .crisp-datatable-phone-multi-action:hover { background: color-mix(in srgb, ${c.fgPrimary} 4%, transparent); color: ${c.fgPrimary}; }
323
+ /* datetime cell editor (#762) -- DatePickerField + TimePicker side by side. */
324
+ & .crisp-datatable-datetime-editor { display: flex; align-items: center; gap: ${c.space["1"]}; height: ${c.size.tableRow}; padding: 0 ${c.space["2"]}; }
275
325
  /* rating cell type/editor (#760) -- measured live: 16px stars on an
276
326
  18px pitch (a 2px gap), an empty star at rgba(0,0,0,0.1), a
277
327
  committed/filled star at brand600 (--crisp-bg-brand-solid, an exact