@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.
package/dist/badge.css CHANGED
@@ -27,7 +27,7 @@
27
27
  height: 20px; padding-inline: var(--crisp-space-1); font-size: var(--crisp-text-sm); line-height: 16px; gap: 3px; border-radius: var(--crisp-radius-sm);
28
28
  }
29
29
  .crisp-badge--size_large {
30
- height: var(--crisp-size-chip); padding-inline: 9px; font-size: var(--crisp-text-lg); letter-spacing: -0.1px; border-radius: var(--crisp-radius-chip);
30
+ height: var(--crisp-size-chip); padding-block: var(--crisp-space-px); padding-inline-start: var(--crisp-space-1); padding-inline-end: var(--crisp-space-1-5); font-size: var(--crisp-text-lg); letter-spacing: -0.1px; border-radius: var(--crisp-radius-lg);
31
31
  }
32
32
  .crisp-badge--variant_soft {
33
33
 
@@ -28,7 +28,7 @@
28
28
  height: 20px; padding-inline: var(--crisp-space-1); font-size: var(--crisp-text-sm); line-height: 16px; gap: 3px; border-radius: var(--crisp-radius-sm);
29
29
  }
30
30
  .crisp-badge--size_large {
31
- height: var(--crisp-size-chip); padding-inline: 9px; font-size: var(--crisp-text-lg); letter-spacing: -0.1px; border-radius: var(--crisp-radius-chip);
31
+ height: var(--crisp-size-chip); padding-block: var(--crisp-space-px); padding-inline-start: var(--crisp-space-1); padding-inline-end: var(--crisp-space-1-5); font-size: var(--crisp-text-lg); letter-spacing: -0.1px; border-radius: var(--crisp-radius-lg);
32
32
  }
33
33
  .crisp-badge--variant_soft {
34
34
 
@@ -106,15 +106,20 @@ const spec = (0, core_1.defineSpec)({
106
106
  gap: "3px",
107
107
  borderRadius: tokens_1.contract.radius.sm,
108
108
  },
109
- // crisp#696: not re-measured this round -- kept exactly as it rendered
110
- // before the base tracking changed to `normal`, by pinning the old
111
- // `-0.1px` here instead. Gap is untouched (base's 4px).
109
+ // crisp#805: measured live against Attio's real status/stage chip
110
+ // (Deals "Deal stage" column, All Deals table view) -- radius 9px, not
111
+ // `radius.chip`'s 7px, and asymmetric padding (room for a leading dot/
112
+ // icon: 1px top/bottom, 6px trailing, 4px leading), not a symmetric
113
+ // 9px. Height/font/tracking are unchanged (still not independently
114
+ // re-measured this round, per #696's own note).
112
115
  large: {
113
116
  height: tokens_1.contract.size.chip,
114
- paddingInline: "9px",
117
+ paddingBlock: tokens_1.contract.space.px,
118
+ paddingInlineStart: tokens_1.contract.space["1"],
119
+ paddingInlineEnd: tokens_1.contract.space["1.5"],
115
120
  fontSize: tokens_1.contract.text.lg,
116
121
  letterSpacing: "-0.1px",
117
- borderRadius: tokens_1.contract.radius.chip,
122
+ borderRadius: tokens_1.contract.radius.lg,
118
123
  },
119
124
  },
120
125
  variant: { soft: {}, solid: {} },
@@ -0,0 +1,73 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.COUNTRIES = void 0;
4
+ exports.flagEmoji = flagEmoji;
5
+ exports.countryByIso2 = countryByIso2;
6
+ exports.COUNTRIES = [
7
+ { iso2: "US", name: "United States", dialCode: "1" },
8
+ { iso2: "CA", name: "Canada", dialCode: "1" },
9
+ { iso2: "GB", name: "United Kingdom", dialCode: "44" },
10
+ { iso2: "IE", name: "Ireland", dialCode: "353" },
11
+ { iso2: "FR", name: "France", dialCode: "33" },
12
+ { iso2: "DE", name: "Germany", dialCode: "49" },
13
+ { iso2: "ES", name: "Spain", dialCode: "34" },
14
+ { iso2: "PT", name: "Portugal", dialCode: "351" },
15
+ { iso2: "IT", name: "Italy", dialCode: "39" },
16
+ { iso2: "NL", name: "Netherlands", dialCode: "31" },
17
+ { iso2: "BE", name: "Belgium", dialCode: "32" },
18
+ { iso2: "CH", name: "Switzerland", dialCode: "41" },
19
+ { iso2: "AT", name: "Austria", dialCode: "43" },
20
+ { iso2: "SE", name: "Sweden", dialCode: "46" },
21
+ { iso2: "NO", name: "Norway", dialCode: "47" },
22
+ { iso2: "DK", name: "Denmark", dialCode: "45" },
23
+ { iso2: "FI", name: "Finland", dialCode: "358" },
24
+ { iso2: "PL", name: "Poland", dialCode: "48" },
25
+ { iso2: "CZ", name: "Czechia", dialCode: "420" },
26
+ { iso2: "GR", name: "Greece", dialCode: "30" },
27
+ { iso2: "RO", name: "Romania", dialCode: "40" },
28
+ { iso2: "HU", name: "Hungary", dialCode: "36" },
29
+ { iso2: "UA", name: "Ukraine", dialCode: "380" },
30
+ { iso2: "RU", name: "Russia", dialCode: "7" },
31
+ { iso2: "TR", name: "Turkey", dialCode: "90" },
32
+ { iso2: "IL", name: "Israel", dialCode: "972" },
33
+ { iso2: "AE", name: "United Arab Emirates", dialCode: "971" },
34
+ { iso2: "SA", name: "Saudi Arabia", dialCode: "966" },
35
+ { iso2: "EG", name: "Egypt", dialCode: "20" },
36
+ { iso2: "ZA", name: "South Africa", dialCode: "27" },
37
+ { iso2: "NG", name: "Nigeria", dialCode: "234" },
38
+ { iso2: "KE", name: "Kenya", dialCode: "254" },
39
+ { iso2: "IN", name: "India", dialCode: "91" },
40
+ { iso2: "PK", name: "Pakistan", dialCode: "92" },
41
+ { iso2: "BD", name: "Bangladesh", dialCode: "880" },
42
+ { iso2: "LK", name: "Sri Lanka", dialCode: "94" },
43
+ { iso2: "NP", name: "Nepal", dialCode: "977" },
44
+ { iso2: "CN", name: "China", dialCode: "86" },
45
+ { iso2: "HK", name: "Hong Kong", dialCode: "852" },
46
+ { iso2: "TW", name: "Taiwan", dialCode: "886" },
47
+ { iso2: "JP", name: "Japan", dialCode: "81" },
48
+ { iso2: "KR", name: "South Korea", dialCode: "82" },
49
+ { iso2: "MN", name: "Mongolia", dialCode: "976" },
50
+ { iso2: "VN", name: "Vietnam", dialCode: "84" },
51
+ { iso2: "TH", name: "Thailand", dialCode: "66" },
52
+ { iso2: "SG", name: "Singapore", dialCode: "65" },
53
+ { iso2: "MY", name: "Malaysia", dialCode: "60" },
54
+ { iso2: "ID", name: "Indonesia", dialCode: "62" },
55
+ { iso2: "PH", name: "Philippines", dialCode: "63" },
56
+ { iso2: "AU", name: "Australia", dialCode: "61" },
57
+ { iso2: "NZ", name: "New Zealand", dialCode: "64" },
58
+ { iso2: "MX", name: "Mexico", dialCode: "52" },
59
+ { iso2: "BR", name: "Brazil", dialCode: "55" },
60
+ { iso2: "AR", name: "Argentina", dialCode: "54" },
61
+ { iso2: "CL", name: "Chile", dialCode: "56" },
62
+ { iso2: "CO", name: "Colombia", dialCode: "57" },
63
+ { iso2: "PE", name: "Peru", dialCode: "51" },
64
+ ];
65
+ /** Two Unicode Regional Indicator Symbols spell the flag emoji for `iso2`. */
66
+ function flagEmoji(iso2) {
67
+ return [...iso2.toUpperCase()]
68
+ .map((ch) => String.fromCodePoint(0x1f1e6 - 65 + ch.charCodeAt(0)))
69
+ .join("");
70
+ }
71
+ function countryByIso2(iso2) {
72
+ return exports.COUNTRIES.find((c) => c.iso2 === iso2);
73
+ }
@@ -52,13 +52,16 @@ const avatar_1 = require("../avatar");
52
52
  const checkbox_1 = require("../checkbox");
53
53
  const columncalc_1 = require("../columncalc");
54
54
  const contextmenu_1 = require("../contextmenu");
55
+ const datepickerfield_1 = require("../datepickerfield");
55
56
  const menu_1 = require("../menu");
56
57
  const radiogroup_1 = require("../radiogroup");
57
58
  const select_1 = require("../select");
58
59
  const spinner_1 = require("../spinner");
59
60
  const switch_1 = require("../switch");
60
61
  const table_1 = require("../table");
62
+ const timepicker_1 = require("../timepicker");
61
63
  const tooltip_1 = require("../tooltip");
64
+ const countries_1 = require("./countries");
62
65
  const datatable_recipe_1 = require("./datatable.recipe");
63
66
  // #534: whether an element's own content is being cut off by its own box --
64
67
  // the same fact `.crisp-datatable-th-label`'s CSS ellipsis is silently
@@ -196,6 +199,30 @@ function asText(v) {
196
199
  return v.join(", ");
197
200
  return String(v);
198
201
  }
202
+ // crisp/#765 -- a `type: "phone"`/`editor: "phone"` cell's raw value is
203
+ // "{iso2}|{digits}", not a bare E.164 string: see DataTableColumn's own
204
+ // "phone" doc comment for why the pipe separator earns its keep over a
205
+ // plain "+{dialCode}{digits}" concatenation.
206
+ function parsePhoneValue(v) {
207
+ const s = typeof v === "string" ? v : "";
208
+ const i = s.indexOf("|");
209
+ if (i < 0)
210
+ return null;
211
+ return { iso2: s.slice(0, i), digits: s.slice(i + 1) };
212
+ }
213
+ // crisp/#816 -- a `multiValue` phone column's raw value is several
214
+ // `parsePhoneValue`-shaped entries joined with `;`. Empty entries (a blank
215
+ // trailing slot that was never filled) are dropped rather than rendered as
216
+ // a stray chip.
217
+ function parsePhoneValues(v) {
218
+ const s = typeof v === "string" ? v : "";
219
+ if (!s)
220
+ return [];
221
+ return s
222
+ .split(";")
223
+ .map((entry) => parsePhoneValue(entry))
224
+ .filter((p) => !!p?.digits);
225
+ }
199
226
  // stable pastel tone per string value — colour-coded tags/statuses
200
227
  const TAG_TONES = [
201
228
  "brand",
@@ -415,6 +442,29 @@ function renderTyped(col, value, avatarTone, row, display, locale = "en-US", ope
415
442
  return empty;
416
443
  return (0, jsx_runtime_1.jsx)(RatingStars, { value: n, max: col.maxRating ?? 5 });
417
444
  }
445
+ case "phone": {
446
+ // crisp/#816, confirmed live -- a `multiValue` phone cell renders
447
+ // each value as its own real `tel:` link (Attio: a measured link
448
+ // blue, underlined, no flag/dial-code decoration, `4px` flex gap
449
+ // between values), unlike the single-value cell above/below (a plain
450
+ // flag+dial+digits span, never a link). The whole cell hard-clips
451
+ // when it overflows the column -- confirmed live, Attio does not
452
+ // ellipsis a multi-value phone cell.
453
+ if (col.multiValue) {
454
+ const values = parsePhoneValues(value);
455
+ if (!values.length)
456
+ return empty;
457
+ return ((0, jsx_runtime_1.jsx)("span", { className: "crisp-datatable-phone-multi", children: values.map((p) => {
458
+ const country = (0, countries_1.countryByIso2)(p.iso2);
459
+ return ((0, jsx_runtime_1.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}`));
460
+ }) }));
461
+ }
462
+ const parsed = parsePhoneValue(value);
463
+ if (!parsed?.digits)
464
+ return empty;
465
+ const country = (0, countries_1.countryByIso2)(parsed.iso2);
466
+ return ((0, jsx_runtime_1.jsxs)("span", { className: "crisp-datatable-phone", children: [country && (0, jsx_runtime_1.jsx)("span", { "aria-hidden": "true", children: (0, countries_1.flagEmoji)(country.iso2) }), (0, jsx_runtime_1.jsxs)("span", { className: "crisp-datatable-phone-dial", children: ["+", country?.dialCode ?? "?"] }), parsed.digits] }));
467
+ }
418
468
  case "url":
419
469
  case "email": {
420
470
  const text = display || asText(value);
@@ -425,6 +475,16 @@ function renderTyped(col, value, avatarTone, row, display, locale = "en-US", ope
425
475
  // link's own navigation must not also fire the row's onRowClick.
426
476
  onClick: (ev) => ev.stopPropagation(), children: text }));
427
477
  }
478
+ case "date":
479
+ case "datetime": {
480
+ const text = display || formatCell(col.type, value, col.symbol, locale);
481
+ if (!text)
482
+ return empty;
483
+ // crisp#822 -- a column-level predicate, not `tones` (see its doc
484
+ // comment): the danger state is computed over the row, not read back
485
+ // off this formatted string.
486
+ return col.danger?.(value, row) ? ((0, jsx_runtime_1.jsx)("span", { className: "crisp-datatable-danger", children: text })) : (text);
487
+ }
428
488
  default: {
429
489
  const text = display || formatCell(col.type, value, col.symbol, locale);
430
490
  const isNum = col.type === "number" || col.type === "currency";
@@ -568,6 +628,130 @@ function DataReferenceEditor({ col, value, isPending, onCommit, onCancel, }) {
568
628
  onCommit(v);
569
629
  } }) }));
570
630
  }
631
+ // crisp/#765, confirmed live -- Attio's own Phone Number editor: a
632
+ // country-code select (ghost trigger, "MN ▾"), then a static, non-editable
633
+ // "+{dialCode}" prefix, then a borderless digits field -- measured
634
+ // `rgba(0,0,0,0.63)` for the prefix text, matching every other muted-text
635
+ // value already measured elsewhere in this file (14px in both cases).
636
+ // Reuses crisp's own searchable `Select` for the country list -- the same
637
+ // popover chrome every other picker in this file already uses -- rather
638
+ // than inventing new country-picker chrome; the flag/name/dial-code ROW
639
+ // shape wasn't independently re-measured against Attio's own popover this
640
+ // pass (time-boxed), so it's built to the same shape crisp's other search
641
+ // pickers already carry.
642
+ function DataPhoneEditor({ col, value, isPending, onCommit, }) {
643
+ const parsed = parsePhoneValue(value);
644
+ const [iso2, setIso2] = React.useState(parsed?.iso2 ?? "US");
645
+ const [digits, setDigits] = React.useState(parsed?.digits ?? "");
646
+ const country = (0, countries_1.countryByIso2)(iso2);
647
+ const countryOptions = countries_1.COUNTRIES.map((c) => ({
648
+ value: c.iso2,
649
+ label: `${(0, countries_1.flagEmoji)(c.iso2)} ${c.name} +${c.dialCode}`,
650
+ keywords: `${c.name} ${c.iso2} ${c.dialCode}`,
651
+ }));
652
+ const commit = (nextIso2, nextDigits) => onCommit(`${nextIso2}|${nextDigits}`);
653
+ return ((0, jsx_runtime_1.jsxs)("span", { className: "crisp-datatable-phone-editor", children: [(0, jsx_runtime_1.jsx)(select_1.Select, { variant: "ghost", searchable: true, "aria-label": `Edit ${col.key} country`, value: iso2, disabled: isPending, options: countryOptions, onValueChange: (v) => {
654
+ setIso2(v);
655
+ commit(v, digits);
656
+ } }), (0, jsx_runtime_1.jsxs)("span", { className: "crisp-datatable-phone-dial", "aria-hidden": "true", children: ["+", country?.dialCode ?? "?"] }), (0, jsx_runtime_1.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) => {
657
+ if (e.key === "Enter")
658
+ commit(iso2, digits);
659
+ } })] }));
660
+ }
661
+ // crisp/#816, confirmed live -- Attio's own multi-value Phone Number
662
+ // editor (`col.multiValue`): a stack of already-committed values (a
663
+ // drag grip, the value as a real `tel:` link, and a hover-revealed
664
+ // copy/delete pair -- measured `32px` row, `5px 8px` padding, `8px` gap,
665
+ // `16×16` icons) plus one ALWAYS-blank trailing "country + digits" row in
666
+ // `DataPhoneEditor`'s own shape. The blank row itself is the only
667
+ // affordance for adding another value -- there is no separate "+ Add"
668
+ // button; committing it (Enter/blur with digits) appends and clears back
669
+ // to blank so the next value can be typed immediately. Drag-to-reorder
670
+ // (the grip icon) was measured present but isn't wired here -- reordering
671
+ // a committed array is a distinct interaction from add/remove/copy and is
672
+ // left as a follow-up rather than a partial, non-functional drag handle.
673
+ function DataPhoneMultiEditor({ col, value, isPending, onCommit, }) {
674
+ const committed = parsePhoneValues(value);
675
+ const [draftIso2, setDraftIso2] = React.useState("US");
676
+ const [draftDigits, setDraftDigits] = React.useState("");
677
+ const countryOptions = countries_1.COUNTRIES.map((c) => ({
678
+ value: c.iso2,
679
+ label: `${(0, countries_1.flagEmoji)(c.iso2)} ${c.name} +${c.dialCode}`,
680
+ keywords: `${c.name} ${c.iso2} ${c.dialCode}`,
681
+ }));
682
+ const join = (list) => list.map((p) => `${p.iso2}|${p.digits}`).join(";");
683
+ const removeAt = (i) => onCommit(join(committed.filter((_, idx) => idx !== i)));
684
+ const commitDraft = () => {
685
+ if (!draftDigits)
686
+ return;
687
+ onCommit(join([...committed, { iso2: draftIso2, digits: draftDigits }]));
688
+ setDraftDigits("");
689
+ };
690
+ return ((0, jsx_runtime_1.jsxs)("span", { className: "crisp-datatable-phone-multi-editor", children: [committed.map((p, i) => {
691
+ const country = (0, countries_1.countryByIso2)(p.iso2);
692
+ const tel = `+${country?.dialCode ?? "?"}${p.digits}`;
693
+ return ((0, jsx_runtime_1.jsxs)("span", { className: "crisp-datatable-phone-multi-row", children: [(0, jsx_runtime_1.jsx)(lucide_react_1.GripVertical, { size: 16, strokeWidth: 1.75, className: "crisp-datatable-phone-multi-grip", "aria-hidden": "true" }), (0, jsx_runtime_1.jsx)("a", { className: "crisp-datatable-phone-link", href: `tel:${tel}`, onClick: (ev) => ev.stopPropagation(), children: tel }), (0, jsx_runtime_1.jsxs)("span", { className: "crisp-datatable-phone-multi-actions", children: [(0, jsx_runtime_1.jsx)("button", { type: "button", className: "crisp-datatable-phone-multi-action", "aria-label": `Copy ${col.key} value`, onClick: () => navigator.clipboard?.writeText(tel), children: (0, jsx_runtime_1.jsx)(lucide_react_1.Copy, { size: 16, strokeWidth: 1.75 }) }), (0, jsx_runtime_1.jsx)("button", { type: "button", className: "crisp-datatable-phone-multi-action", "aria-label": `Remove ${col.key} value`, disabled: isPending, onClick: () => removeAt(i), children: (0, jsx_runtime_1.jsx)(lucide_react_1.Trash2, { size: 16, strokeWidth: 1.75 }) })] })] }, `${p.iso2}|${p.digits}`));
694
+ }), (0, jsx_runtime_1.jsxs)("span", { className: "crisp-datatable-phone-editor", children: [(0, jsx_runtime_1.jsx)(select_1.Select, { variant: "ghost", searchable: true, "aria-label": `Edit ${col.key} country`, value: draftIso2, disabled: isPending, options: countryOptions, onValueChange: setDraftIso2 }), (0, jsx_runtime_1.jsxs)("span", { className: "crisp-datatable-phone-dial", "aria-hidden": "true", children: ["+", (0, countries_1.countryByIso2)(draftIso2)?.dialCode ?? "?"] }), (0, jsx_runtime_1.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) => {
695
+ if (e.key === "Enter")
696
+ commitDraft();
697
+ } })] })] }));
698
+ }
699
+ // crisp/#762, confirmed live -- Attio's own Timestamp editor is a calendar +
700
+ // time + preset picker, not the native `datetime-local` input `type:
701
+ // "datetime"` gets by default (that stays the DEFAULT, unchanged, for
702
+ // backward compatibility -- this is a new, opt-in `editor: "datetime"`).
703
+ // Composes crisp's own `DatePickerField` (calendar + presets, already the
704
+ // measured shape: Today/Tomorrow/Next week/No date chips under the
705
+ // calendar) with `TimePicker` (hour/minute wheel) side by side. The exact
706
+ // PRESET WORDING/order and the time control's own wheel-vs-text-field
707
+ // shape weren't independently re-verified against Attio's pixels this
708
+ // pass (time-boxed) -- `TimePicker` is crisp's only time-input primitive,
709
+ // reused rather than inventing a new one from scratch.
710
+ function DataDateTimeEditor({ col, value, isPending, onCommit, }) {
711
+ const initial = value instanceof Date
712
+ ? value
713
+ : typeof value === "string" || typeof value === "number"
714
+ ? new Date(value)
715
+ : null;
716
+ const hasInitial = initial != null && !Number.isNaN(initial.getTime());
717
+ const [date, setDate] = React.useState(hasInitial ? initial : null);
718
+ const [time, setTime] = React.useState({
719
+ hour: hasInitial ? initial.getHours() : 0,
720
+ minute: hasInitial ? initial.getMinutes() : 0,
721
+ });
722
+ const commit = (nextDate, nextTime) => {
723
+ if (!nextDate) {
724
+ onCommit("");
725
+ return;
726
+ }
727
+ const pad = (n) => String(n).padStart(2, "0");
728
+ onCommit(`${nextDate.getFullYear()}-${pad(nextDate.getMonth() + 1)}-${pad(nextDate.getDate())}T${pad(nextTime.hour)}:${pad(nextTime.minute)}`);
729
+ };
730
+ const preset = (label, days) => ({
731
+ label,
732
+ date: () => {
733
+ const d = new Date();
734
+ d.setDate(d.getDate() + days);
735
+ return d;
736
+ },
737
+ });
738
+ return ((0, jsx_runtime_1.jsxs)("span", { className: "crisp-datatable-datetime-editor", children: [(0, jsx_runtime_1.jsx)(datepickerfield_1.DatePickerField, { "aria-label": `Edit ${col.key} date`, variant: "ghost",
739
+ // "No date" chip in Attio's own popover maps to DatePickerField's
740
+ // existing Clear affordance -- its own default label ("Clear
741
+ // date") is already overridable via `useCrispMessage` internally,
742
+ // so no hardcoded copy of this component's own is added here.
743
+ value: date, presets: [
744
+ preset("Today", 0),
745
+ preset("Tomorrow", 1),
746
+ preset("Next week", 7),
747
+ ], onValueChange: (d) => {
748
+ setDate(d);
749
+ commit(d, time);
750
+ } }), (0, jsx_runtime_1.jsx)(timepicker_1.TimePicker, { size: "small", value: time, disabled: isPending, onValueChange: (t) => {
751
+ setTime(t);
752
+ commit(date, t);
753
+ } })] }));
754
+ }
571
755
  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 }) {
572
756
  // crisp/#471: same resolution order as TableToolbar's own — an explicit
573
757
  // prop wins, then the provider's translation of the built-in English
@@ -1323,12 +1507,8 @@ function DataTable({ columns: columnsProp, data, selectable = false, selectedIds
1323
1507
  // Cmd/Ctrl+ArrowDown to the last -- confirmed live (Attio's own help
1324
1508
  // drawer documents "Jump to top"/"Jump to bottom" on these exact
1325
1509
  // bindings). `!e.shiftKey` guards this from stealing Cmd+Shift+Arrow,
1326
- // a separate gesture (#773, extend-selection-to-first/last) that falls
1327
- // through to the Shift+Arrow branch below untouched. Placed before that
1328
- // branch for the same reason: without the guard, Cmd+Shift+Arrow would
1329
- // still reach it (that branch doesn't check metaKey/ctrlKey either),
1330
- // just not do the "jump" part -- the guard here is what keeps the two
1331
- // gestures from fighting over the same keys.
1510
+ // the separate #773 gesture (extend-selection-to-first/last) handled
1511
+ // in its own branch right below.
1332
1512
  if ((e.metaKey || e.ctrlKey) &&
1333
1513
  !e.shiftKey &&
1334
1514
  (e.key === "ArrowUp" || e.key === "ArrowDown")) {
@@ -1341,6 +1521,34 @@ function DataTable({ columns: columnsProp, data, selectable = false, selectedIds
1341
1521
  clipboardProxyRef.current?.select();
1342
1522
  return;
1343
1523
  }
1524
+ // crisp/#773: Cmd/Ctrl+Shift+Up/Down extends the row-checkbox selection
1525
+ // all the way to the first/last row in one keystroke -- confirmed live,
1526
+ // the "jump" sibling of #749's one-step Shift+Arrow below, sharing the
1527
+ // same anchor→destination-span recompute (only the destination differs:
1528
+ // 0 / rows.length-1 instead of r±1). Checked ahead of #749's own branch
1529
+ // so the modifier routes here instead of being swallowed as a one-step
1530
+ // Shift+Arrow.
1531
+ if (selectable &&
1532
+ (e.metaKey || e.ctrlKey) &&
1533
+ e.shiftKey &&
1534
+ (e.key === "ArrowDown" || e.key === "ArrowUp")) {
1535
+ const r0 = rows.findIndex((row) => row.id === selCell.id);
1536
+ if (r0 < 0)
1537
+ return;
1538
+ const nr0 = e.key === "ArrowDown" ? rows.length - 1 : 0;
1539
+ e.preventDefault();
1540
+ const anchorId = rowSelectAnchor ?? selCell.id;
1541
+ const anchorIdx = rows.findIndex((row) => row.id === anchorId);
1542
+ if (anchorIdx < 0)
1543
+ return;
1544
+ setRowSelectAnchor(anchorId);
1545
+ const lo = Math.min(anchorIdx, nr0);
1546
+ const hi = Math.max(anchorIdx, nr0);
1547
+ setSelected(new Set(rows.slice(lo, hi + 1).map((row) => row.id)));
1548
+ setSelCell({ id: rows[nr0].id, key: selCell.key });
1549
+ clipboardProxyRef.current?.select();
1550
+ return;
1551
+ }
1344
1552
  // crisp/#749: Shift+ArrowUp/Down extends the row-checkbox selection --
1345
1553
  // confirmed live, distinct from Shift+Click's cell-range gesture above
1346
1554
  // (that one is `selAnchor`; this is `rowSelectAnchor`, and the two
@@ -1371,12 +1579,38 @@ function DataTable({ columns: columnsProp, data, selectable = false, selectedIds
1371
1579
  clipboardProxyRef.current?.select();
1372
1580
  return;
1373
1581
  }
1582
+ // crisp/#770: Shift+Space toggles the current row's own checkbox without
1583
+ // moving the selected cell or touching any other row -- distinct from
1584
+ // #749/#773 above, whose Shift+Arrow gestures move the cursor as they
1585
+ // extend the range.
1586
+ if (selectable && e.shiftKey && e.key === " ") {
1587
+ e.preventDefault();
1588
+ toggleRow(selCell.id);
1589
+ return;
1590
+ }
1591
+ // crisp/#771: Cmd/Ctrl+Enter always opens the row's record page,
1592
+ // regardless of whether the current column is editable -- distinct from
1593
+ // plain Enter below, which opens the cell's own editor first when one
1594
+ // exists.
1595
+ if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
1596
+ if (onRowClick) {
1597
+ const r0 = rows.findIndex((row) => row.id === selCell.id);
1598
+ if (r0 < 0)
1599
+ return;
1600
+ e.preventDefault();
1601
+ onRowClick(rows[r0]);
1602
+ }
1603
+ return;
1604
+ }
1374
1605
  const r = rows.findIndex((row) => row.id === selCell.id);
1375
1606
  const c = columns.findIndex((col) => col.key === selCell.key);
1376
1607
  if (r < 0 || c < 0)
1377
1608
  return;
1378
1609
  let nr = r;
1379
1610
  let nc = c;
1611
+ // #772/#773 (Cmd/Ctrl±Shift+Up/Down) are both handled in their own
1612
+ // early-return branches above, before `r`/`c` are even computed -- a
1613
+ // plain arrow (no modifier) is the only thing that reaches here.
1380
1614
  if (e.key === "ArrowUp")
1381
1615
  nr = Math.max(0, r - 1);
1382
1616
  else if (e.key === "ArrowDown")
@@ -1388,6 +1622,33 @@ function DataTable({ columns: columnsProp, data, selectable = false, selectedIds
1388
1622
  const forward = e.key === (rtl ? "ArrowLeft" : "ArrowRight");
1389
1623
  nc = forward ? Math.min(columns.length - 1, c + 1) : Math.max(0, c - 1);
1390
1624
  }
1625
+ // crisp/#769: Tab/Shift+Tab move to the next/previous cell in reading
1626
+ // order, wrapping to the next (or previous) row at a row boundary. This
1627
+ // is pure logical/DOM order, not a visual direction, so unlike Left/Right
1628
+ // above it needs no RTL handling -- native tab order doesn't flip under
1629
+ // RTL either. At the grid's first/last cell there's nowhere left to go:
1630
+ // return without `preventDefault`, so Tab still falls through to the
1631
+ // browser's native focus order instead of getting trapped.
1632
+ else if (e.key === "Tab" && !e.shiftKey) {
1633
+ if (c + 1 < columns.length)
1634
+ nc = c + 1;
1635
+ else if (r + 1 < rows.length) {
1636
+ nr = r + 1;
1637
+ nc = 0;
1638
+ }
1639
+ else
1640
+ return;
1641
+ }
1642
+ else if (e.key === "Tab" && e.shiftKey) {
1643
+ if (c - 1 >= 0)
1644
+ nc = c - 1;
1645
+ else if (r - 1 >= 0) {
1646
+ nr = r - 1;
1647
+ nc = columns.length - 1;
1648
+ }
1649
+ else
1650
+ return;
1651
+ }
1391
1652
  // crisp/#607, G-066/G-068: End jumps to the last row (confirmed live --
1392
1653
  // it also triggers Attio's own lazy-load, which is a data-loading
1393
1654
  // concern outside this component; the #577 scroll-into-view effect
@@ -1467,17 +1728,23 @@ function DataTable({ columns: columnsProp, data, selectable = false, selectedIds
1467
1728
  // value while showing the rejection's message beside the cell (reject).
1468
1729
  // The revert needs no extra code: this component never mutates `data`
1469
1730
  // itself, so once `editing` clears, the cell simply shows `data` again.
1470
- function commitEdit(id, key, value) {
1731
+ // crisp/#816 -- `keepEditing` lets a multi-value editor stay open across
1732
+ // a commit (adding/removing one value must not close the whole cell, the
1733
+ // way it does for every other, single-value editor). Default false
1734
+ // preserves every existing editor's own close-on-commit behaviour.
1735
+ function commitEdit(id, key, value, keepEditing = false) {
1471
1736
  setEditError(null);
1472
1737
  const result = onEdit?.(id, key, value);
1473
1738
  if (result && typeof result.then === "function") {
1474
1739
  setPending({ id, key });
1475
1740
  result.then(() => {
1476
1741
  setPending(null);
1477
- setEditing(null);
1742
+ if (!keepEditing)
1743
+ setEditing(null);
1478
1744
  }, (err) => {
1479
1745
  setPending(null);
1480
- setEditing(null);
1746
+ if (!keepEditing)
1747
+ setEditing(null);
1481
1748
  setEditError({
1482
1749
  id,
1483
1750
  key,
@@ -1490,7 +1757,8 @@ function DataTable({ columns: columnsProp, data, selectable = false, selectedIds
1490
1757
  });
1491
1758
  return;
1492
1759
  }
1493
- setEditing(null);
1760
+ if (!keepEditing)
1761
+ setEditing(null);
1494
1762
  }
1495
1763
  const reduceMotion = (0, core_1.usePrefersReducedMotion)();
1496
1764
  // crisp/#577 -- keyboard nav to an off-screen (virtualized-out) cell
@@ -2015,7 +2283,9 @@ function DataTable({ columns: columnsProp, data, selectable = false, selectedIds
2015
2283
  if (isEditing &&
2016
2284
  (col.editor === "select" ||
2017
2285
  col.editor === "reference" ||
2018
- col.editor === "actor")) {
2286
+ col.editor === "actor" ||
2287
+ col.editor === "phone" ||
2288
+ col.editor === "datetime")) {
2019
2289
  return ((0, jsx_runtime_1.jsxs)(table_1.TableCell, { align: alignFor(col), "aria-colindex": colAriaIndex(i), ...sticky, ...rowHeaderProps, className: "crisp-datatable-td--picker", "data-pending": isPending ? "" : undefined,
2020
2290
  // Same as the plain text editor's own cell:
2021
2291
  // opening/using the picker must not also fire
@@ -2027,7 +2297,7 @@ function DataTable({ columns: columnsProp, data, selectable = false, selectedIds
2027
2297
  // portaled popover, so a handler here can't
2028
2298
  // rely on the key event ever bubbling through
2029
2299
  // this element (#453).
2030
- onClick: (e) => e.stopPropagation(), children: [(0, jsx_runtime_1.jsxs)("span", { className: "crisp-datatable-edit-picker", children: [col.editor === "select" ? ((0, jsx_runtime_1.jsx)(DataSelectEditor, { col: col, value: value, isPending: isPending, onCommit: (v) => commitEdit(row.id, col.key, v), onCancel: () => setEditing(null) })) : ((0, jsx_runtime_1.jsx)(DataReferenceEditor, { col: col, value: value, isPending: isPending, onCommit: (v) => commitEdit(row.id, col.key, v), onCancel: () => setEditing(null) })), isPending && ((0, jsx_runtime_1.jsx)(spinner_1.Spinner, { size: 14, className: "crisp-datatable-edit-pending" }))] }), errorHere && ((0, jsx_runtime_1.jsx)("span", { className: "crisp-datatable-edit-error", children: errorHere }))] }, col.key));
2300
+ onClick: (e) => e.stopPropagation(), children: [(0, jsx_runtime_1.jsxs)("span", { className: "crisp-datatable-edit-picker", children: [col.editor === "select" ? ((0, jsx_runtime_1.jsx)(DataSelectEditor, { col: col, value: value, isPending: isPending, onCommit: (v) => commitEdit(row.id, col.key, v), onCancel: () => setEditing(null) })) : col.editor === "phone" && col.multiValue ? ((0, jsx_runtime_1.jsx)(DataPhoneMultiEditor, { col: col, value: value, isPending: isPending, onCommit: (v) => commitEdit(row.id, col.key, v, true) })) : col.editor === "phone" ? ((0, jsx_runtime_1.jsx)(DataPhoneEditor, { col: col, value: value, isPending: isPending, onCommit: (v) => commitEdit(row.id, col.key, v) })) : col.editor === "datetime" ? ((0, jsx_runtime_1.jsx)(DataDateTimeEditor, { col: col, value: value, isPending: isPending, onCommit: (v) => commitEdit(row.id, col.key, v) })) : ((0, jsx_runtime_1.jsx)(DataReferenceEditor, { col: col, value: value, isPending: isPending, onCommit: (v) => commitEdit(row.id, col.key, v), onCancel: () => setEditing(null) })), isPending && ((0, jsx_runtime_1.jsx)(spinner_1.Spinner, { size: 14, className: "crisp-datatable-edit-pending" }))] }), errorHere && ((0, jsx_runtime_1.jsx)("span", { className: "crisp-datatable-edit-error", children: errorHere }))] }, col.key));
2031
2301
  }
2032
2302
  if (isEditing) {
2033
2303
  return ((0, jsx_runtime_1.jsxs)(table_1.TableCell, { align: alignFor(col), "aria-colindex": colAriaIndex(i), ...sticky, ...rowHeaderProps, onClick: (e) => e.stopPropagation(), children: [(0, jsx_runtime_1.jsx)("input", { className: "crisp-datatable-edit",
@@ -10,7 +10,15 @@ const tokens_1 = require("../../tokens");
10
10
  const config = (0, core_1.defineRecipe)({
11
11
  name: "datatable",
12
12
  base: `display: flex; flex-direction: column; gap: ${tokens_1.contract.space["2.5"]}; font-family: ${tokens_1.contract.font.sans};
13
- & .crisp-datatable-toolbar { display: flex; align-items: center; gap: ${tokens_1.contract.space["2"]}; min-height: 49px; border-top: 1px solid ${tokens_1.contract.borderSubtle}; }
13
+ /* The toolbar carried no horizontal inset of its own, so its content
14
+ (search box, sort/filter chips) sat flush at the table's outer edge
15
+ (x=0) while the header row's own th/td padding (table.recipe.ts,
16
+ #465) starts every column's text at 12px -- one row down, the SAME
17
+ grid's own header cell already begins at that inset. c.space["3"]
18
+ here just matches the row directly below it, measured live against
19
+ Attio (the sort/filter row's search chip lines up with the first
20
+ column header's text, not the grid's raw edge). */
21
+ & .crisp-datatable-toolbar { display: flex; align-items: center; gap: ${tokens_1.contract.space["2"]}; min-height: 49px; border-top: 1px solid ${tokens_1.contract.borderSubtle}; padding: 0 ${tokens_1.contract.space["3"]}; }
14
22
  & .crisp-datatable-search { position: relative; flex: 1; max-width: 280px; display: inline-flex; align-items: center; }
15
23
  & .crisp-datatable-search svg { position: absolute; inset-inline-start: ${tokens_1.contract.space["2"]}; color: ${tokens_1.contract.fgMuted}; pointer-events: none; }
16
24
  & .crisp-datatable-search input { width: 100%; height: 32px; padding: 0 ${tokens_1.contract.space["2"]} 0 ${tokens_1.contract.space["6"]}; border: none; border-radius: ${tokens_1.contract.radius.md}; background: ${tokens_1.contract.bgSurface}; box-shadow: ${tokens_1.elevation.field}; font-family: inherit; font-size: ${tokens_1.contract.text.lg}; color: ${tokens_1.contract.fgPrimary}; outline: none; transition: box-shadow .15s; }
@@ -269,12 +277,54 @@ const config = (0, core_1.defineRecipe)({
269
277
  & .crisp-datatable-tag[data-tone="orange"] { background: color-mix(in srgb, ${tokens_1.contract.bgOrange} 16%, transparent); color: color-mix(in srgb, ${tokens_1.contract.bgOrange} 82%, ${tokens_1.contract.fgPrimary}); }
270
278
  & .crisp-datatable-num { font-variant-numeric: tabular-nums; }
271
279
  & .crisp-datatable-muted { color: ${tokens_1.contract.fgMuted}; }
280
+ /* crisp#822 -- a date/datetime cell's danger/overdue tone: same token as
281
+ TasksTable's own overdue due-date (crisp#462), plain text colour, no
282
+ fill/weight change. */
283
+ & .crisp-datatable-danger { color: ${tokens_1.contract.fgDanger}; }
272
284
  /* url/email cell types (#654) -- same measured values as RecordsTable's
273
285
  own .crisp-recordstable-link, which this mirrors: no underline at
274
286
  rest, underline on hover, own-colour text rather than a browser-blue
275
287
  link. */
276
288
  & .crisp-datatable-link { color: ${tokens_1.contract.fgPrimary}; text-decoration: none; overflow: hidden; text-overflow: ellipsis; }
277
289
  & .crisp-datatable-link:hover { text-decoration: underline; }
290
+ /* phone cell type/editor (#765) -- measured live: the dial-code prefix
291
+ is a muted rgba(0,0,0,0.63) at 14px, matching every other muted-text
292
+ value already measured elsewhere in this file. */
293
+ & .crisp-datatable-phone { display: inline-flex; align-items: center; gap: ${tokens_1.contract.space["1"]}; }
294
+ & .crisp-datatable-phone-dial { color: color-mix(in srgb, ${tokens_1.contract.fgPrimary} 63%, transparent); }
295
+ & .crisp-datatable-phone-editor { display: flex; align-items: center; gap: ${tokens_1.contract.space["1"]}; height: ${tokens_1.contract.size.tableRow}; padding: 0 ${tokens_1.contract.space["2"]}; }
296
+ & .crisp-datatable-phone-editor input { flex: 1; min-width: 0; }
297
+ /* multi-value phone (#816) -- confirmed live (a real custom "Multiple
298
+ values" Phone Number attribute, and the built-in People "Phone
299
+ numbers" field): the collapsed cell is a 4px-gap flex row of real
300
+ tel: links in a measured link blue (~rgb(59,108,232) pixel-sampled;
301
+ reused as the closest already-tokened blue, brand600, rather than
302
+ adding a third blue token for a delta this small), underlined,
303
+ UNLIKE the single-value cell above (plain text, no link, no
304
+ underline) -- multi-value is the one shape Attio makes into a real
305
+ link. The cell hard-clips on overflow (no ellipsis -- confirmed
306
+ live, a second value can end mid-digit with no "..."). */
307
+ & .crisp-datatable-phone-multi { display: inline-flex; align-items: center; gap: ${tokens_1.contract.space["1"]}; overflow: hidden; }
308
+ & .crisp-datatable-phone-link { color: ${tokens_1.contract.bgBrandSolid}; text-decoration: underline; white-space: nowrap; }
309
+ /* multi-value editor (#816) -- confirmed live: a stack of committed-
310
+ value rows (32px = size.menuRow, 5px 8px padding, 8px gap between
311
+ the grip/value/actions, 16px icons) above one ALWAYS-blank trailing
312
+ DataPhoneEditor-shaped row -- that blank row is the only "add
313
+ another" affordance, there's no separate button for it. The
314
+ copy/delete pair reuses .crisp-datatable-entity-action's own
315
+ hover-reveal chrome (not independently re-measured as its own hit
316
+ target this pass, only the 16px icon glyphs were). */
317
+ & .crisp-datatable-phone-multi-editor { display: flex; flex-direction: column; }
318
+ & .crisp-datatable-phone-multi-row { display: flex; align-items: center; gap: ${tokens_1.contract.space["2"]}; height: ${tokens_1.contract.size.menuRow}; padding: 5px ${tokens_1.contract.space["2"]}; }
319
+ & .crisp-datatable-phone-multi-row .crisp-datatable-phone-link { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
320
+ & .crisp-datatable-phone-multi-grip { color: ${tokens_1.contract.fgMuted}; flex-shrink: 0; cursor: grab; }
321
+ & .crisp-datatable-phone-multi-actions { display: flex; align-items: center; gap: ${tokens_1.contract.space["1"]}; margin-inline-start: auto; flex-shrink: 0; opacity: 0; }
322
+ & .crisp-datatable-phone-multi-row:hover .crisp-datatable-phone-multi-actions,
323
+ & .crisp-datatable-phone-multi-actions:focus-within { opacity: 1; }
324
+ & .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: ${tokens_1.contract.radius.sm}; background: none; font-family: inherit; color: color-mix(in srgb, ${tokens_1.contract.fgPrimary} 55%, transparent); cursor: pointer; flex-shrink: 0; }
325
+ & .crisp-datatable-phone-multi-action:hover { background: color-mix(in srgb, ${tokens_1.contract.fgPrimary} 4%, transparent); color: ${tokens_1.contract.fgPrimary}; }
326
+ /* datetime cell editor (#762) -- DatePickerField + TimePicker side by side. */
327
+ & .crisp-datatable-datetime-editor { display: flex; align-items: center; gap: ${tokens_1.contract.space["1"]}; height: ${tokens_1.contract.size.tableRow}; padding: 0 ${tokens_1.contract.space["2"]}; }
278
328
  /* rating cell type/editor (#760) -- measured live: 16px stars on an
279
329
  18px pitch (a 2px gap), an empty star at rgba(0,0,0,0.1), a
280
330
  committed/filled star at brand600 (--crisp-bg-brand-solid, an exact