@crisp-ui-kit/crisp 0.55.0 → 0.55.1

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.
@@ -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,17 @@ 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
+ }
161
175
  // stable pastel tone per string value — colour-coded tags/statuses
162
176
  const TAG_TONES = [
163
177
  "brand",
@@ -377,6 +391,13 @@ function renderTyped(col, value, avatarTone, row, display, locale = "en-US", ope
377
391
  return empty;
378
392
  return _jsx(RatingStars, { value: n, max: col.maxRating ?? 5 });
379
393
  }
394
+ case "phone": {
395
+ const parsed = parsePhoneValue(value);
396
+ if (!parsed?.digits)
397
+ return empty;
398
+ const country = countryByIso2(parsed.iso2);
399
+ 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] }));
400
+ }
380
401
  case "url":
381
402
  case "email": {
382
403
  const text = display || asText(value);
@@ -530,6 +551,92 @@ function DataReferenceEditor({ col, value, isPending, onCommit, onCancel, }) {
530
551
  onCommit(v);
531
552
  } }) }));
532
553
  }
554
+ // crisp/#765, confirmed live -- Attio's own Phone Number editor: a
555
+ // country-code select (ghost trigger, "MN ▾"), then a static, non-editable
556
+ // "+{dialCode}" prefix, then a borderless digits field -- measured
557
+ // `rgba(0,0,0,0.63)` for the prefix text, matching every other muted-text
558
+ // value already measured elsewhere in this file (14px in both cases).
559
+ // Reuses crisp's own searchable `Select` for the country list -- the same
560
+ // 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.
565
+ function DataPhoneEditor({ col, value, isPending, onCommit, }) {
566
+ const parsed = parsePhoneValue(value);
567
+ const [iso2, setIso2] = React.useState(parsed?.iso2 ?? "US");
568
+ const [digits, setDigits] = React.useState(parsed?.digits ?? "");
569
+ 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
+ }));
575
+ const commit = (nextIso2, nextDigits) => onCommit(`${nextIso2}|${nextDigits}`);
576
+ 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
+ setIso2(v);
578
+ commit(v, digits);
579
+ } }), _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) => {
580
+ if (e.key === "Enter")
581
+ commit(iso2, digits);
582
+ } })] }));
583
+ }
584
+ // crisp/#762, confirmed live -- Attio's own Timestamp editor is a calendar +
585
+ // time + preset picker, not the native `datetime-local` input `type:
586
+ // "datetime"` gets by default (that stays the DEFAULT, unchanged, for
587
+ // backward compatibility -- this is a new, opt-in `editor: "datetime"`).
588
+ // Composes crisp's own `DatePickerField` (calendar + presets, already the
589
+ // measured shape: Today/Tomorrow/Next week/No date chips under the
590
+ // calendar) with `TimePicker` (hour/minute wheel) side by side. The exact
591
+ // PRESET WORDING/order and the time control's own wheel-vs-text-field
592
+ // shape weren't independently re-verified against Attio's pixels this
593
+ // pass (time-boxed) -- `TimePicker` is crisp's only time-input primitive,
594
+ // reused rather than inventing a new one from scratch.
595
+ function DataDateTimeEditor({ col, value, isPending, onCommit, }) {
596
+ const initial = value instanceof Date
597
+ ? value
598
+ : typeof value === "string" || typeof value === "number"
599
+ ? new Date(value)
600
+ : null;
601
+ const hasInitial = initial != null && !Number.isNaN(initial.getTime());
602
+ const [date, setDate] = React.useState(hasInitial ? initial : null);
603
+ const [time, setTime] = React.useState({
604
+ hour: hasInitial ? initial.getHours() : 0,
605
+ minute: hasInitial ? initial.getMinutes() : 0,
606
+ });
607
+ const commit = (nextDate, nextTime) => {
608
+ if (!nextDate) {
609
+ onCommit("");
610
+ return;
611
+ }
612
+ const pad = (n) => String(n).padStart(2, "0");
613
+ onCommit(`${nextDate.getFullYear()}-${pad(nextDate.getMonth() + 1)}-${pad(nextDate.getDate())}T${pad(nextTime.hour)}:${pad(nextTime.minute)}`);
614
+ };
615
+ const preset = (label, days) => ({
616
+ label,
617
+ date: () => {
618
+ const d = new Date();
619
+ d.setDate(d.getDate() + days);
620
+ return d;
621
+ },
622
+ });
623
+ return (_jsxs("span", { className: "crisp-datatable-datetime-editor", children: [_jsx(DatePickerField, { "aria-label": `Edit ${col.key} date`, variant: "ghost",
624
+ // "No date" chip in Attio's own popover maps to DatePickerField's
625
+ // existing Clear affordance -- its own default label ("Clear
626
+ // date") is already overridable via `useCrispMessage` internally,
627
+ // so no hardcoded copy of this component's own is added here.
628
+ value: date, presets: [
629
+ preset("Today", 0),
630
+ preset("Tomorrow", 1),
631
+ preset("Next week", 7),
632
+ ], onValueChange: (d) => {
633
+ setDate(d);
634
+ commit(d, time);
635
+ } }), _jsx(TimePicker, { size: "small", value: time, disabled: isPending, onValueChange: (t) => {
636
+ setTime(t);
637
+ commit(date, t);
638
+ } })] }));
639
+ }
533
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 }) {
534
641
  // crisp/#471: same resolution order as TableToolbar's own — an explicit
535
642
  // prop wins, then the provider's translation of the built-in English
@@ -1285,12 +1392,8 @@ export function DataTable({ columns: columnsProp, data, selectable = false, sele
1285
1392
  // Cmd/Ctrl+ArrowDown to the last -- confirmed live (Attio's own help
1286
1393
  // drawer documents "Jump to top"/"Jump to bottom" on these exact
1287
1394
  // 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.
1395
+ // the separate #773 gesture (extend-selection-to-first/last) handled
1396
+ // in its own branch right below.
1294
1397
  if ((e.metaKey || e.ctrlKey) &&
1295
1398
  !e.shiftKey &&
1296
1399
  (e.key === "ArrowUp" || e.key === "ArrowDown")) {
@@ -1303,6 +1406,34 @@ export function DataTable({ columns: columnsProp, data, selectable = false, sele
1303
1406
  clipboardProxyRef.current?.select();
1304
1407
  return;
1305
1408
  }
1409
+ // crisp/#773: Cmd/Ctrl+Shift+Up/Down extends the row-checkbox selection
1410
+ // all the way to the first/last row in one keystroke -- confirmed live,
1411
+ // the "jump" sibling of #749's one-step Shift+Arrow below, sharing the
1412
+ // same anchor→destination-span recompute (only the destination differs:
1413
+ // 0 / rows.length-1 instead of r±1). Checked ahead of #749's own branch
1414
+ // so the modifier routes here instead of being swallowed as a one-step
1415
+ // Shift+Arrow.
1416
+ if (selectable &&
1417
+ (e.metaKey || e.ctrlKey) &&
1418
+ e.shiftKey &&
1419
+ (e.key === "ArrowDown" || e.key === "ArrowUp")) {
1420
+ const r0 = rows.findIndex((row) => row.id === selCell.id);
1421
+ if (r0 < 0)
1422
+ return;
1423
+ const nr0 = e.key === "ArrowDown" ? rows.length - 1 : 0;
1424
+ e.preventDefault();
1425
+ const anchorId = rowSelectAnchor ?? selCell.id;
1426
+ const anchorIdx = rows.findIndex((row) => row.id === anchorId);
1427
+ if (anchorIdx < 0)
1428
+ return;
1429
+ setRowSelectAnchor(anchorId);
1430
+ const lo = Math.min(anchorIdx, nr0);
1431
+ const hi = Math.max(anchorIdx, nr0);
1432
+ setSelected(new Set(rows.slice(lo, hi + 1).map((row) => row.id)));
1433
+ setSelCell({ id: rows[nr0].id, key: selCell.key });
1434
+ clipboardProxyRef.current?.select();
1435
+ return;
1436
+ }
1306
1437
  // crisp/#749: Shift+ArrowUp/Down extends the row-checkbox selection --
1307
1438
  // confirmed live, distinct from Shift+Click's cell-range gesture above
1308
1439
  // (that one is `selAnchor`; this is `rowSelectAnchor`, and the two
@@ -1333,12 +1464,38 @@ export function DataTable({ columns: columnsProp, data, selectable = false, sele
1333
1464
  clipboardProxyRef.current?.select();
1334
1465
  return;
1335
1466
  }
1467
+ // crisp/#770: Shift+Space toggles the current row's own checkbox without
1468
+ // moving the selected cell or touching any other row -- distinct from
1469
+ // #749/#773 above, whose Shift+Arrow gestures move the cursor as they
1470
+ // extend the range.
1471
+ if (selectable && e.shiftKey && e.key === " ") {
1472
+ e.preventDefault();
1473
+ toggleRow(selCell.id);
1474
+ return;
1475
+ }
1476
+ // crisp/#771: Cmd/Ctrl+Enter always opens the row's record page,
1477
+ // regardless of whether the current column is editable -- distinct from
1478
+ // plain Enter below, which opens the cell's own editor first when one
1479
+ // exists.
1480
+ if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
1481
+ if (onRowClick) {
1482
+ const r0 = rows.findIndex((row) => row.id === selCell.id);
1483
+ if (r0 < 0)
1484
+ return;
1485
+ e.preventDefault();
1486
+ onRowClick(rows[r0]);
1487
+ }
1488
+ return;
1489
+ }
1336
1490
  const r = rows.findIndex((row) => row.id === selCell.id);
1337
1491
  const c = columns.findIndex((col) => col.key === selCell.key);
1338
1492
  if (r < 0 || c < 0)
1339
1493
  return;
1340
1494
  let nr = r;
1341
1495
  let nc = c;
1496
+ // #772/#773 (Cmd/Ctrl±Shift+Up/Down) are both handled in their own
1497
+ // early-return branches above, before `r`/`c` are even computed -- a
1498
+ // plain arrow (no modifier) is the only thing that reaches here.
1342
1499
  if (e.key === "ArrowUp")
1343
1500
  nr = Math.max(0, r - 1);
1344
1501
  else if (e.key === "ArrowDown")
@@ -1350,6 +1507,33 @@ export function DataTable({ columns: columnsProp, data, selectable = false, sele
1350
1507
  const forward = e.key === (rtl ? "ArrowLeft" : "ArrowRight");
1351
1508
  nc = forward ? Math.min(columns.length - 1, c + 1) : Math.max(0, c - 1);
1352
1509
  }
1510
+ // crisp/#769: Tab/Shift+Tab move to the next/previous cell in reading
1511
+ // order, wrapping to the next (or previous) row at a row boundary. This
1512
+ // is pure logical/DOM order, not a visual direction, so unlike Left/Right
1513
+ // above it needs no RTL handling -- native tab order doesn't flip under
1514
+ // RTL either. At the grid's first/last cell there's nowhere left to go:
1515
+ // return without `preventDefault`, so Tab still falls through to the
1516
+ // browser's native focus order instead of getting trapped.
1517
+ else if (e.key === "Tab" && !e.shiftKey) {
1518
+ if (c + 1 < columns.length)
1519
+ nc = c + 1;
1520
+ else if (r + 1 < rows.length) {
1521
+ nr = r + 1;
1522
+ nc = 0;
1523
+ }
1524
+ else
1525
+ return;
1526
+ }
1527
+ else if (e.key === "Tab" && e.shiftKey) {
1528
+ if (c - 1 >= 0)
1529
+ nc = c - 1;
1530
+ else if (r - 1 >= 0) {
1531
+ nr = r - 1;
1532
+ nc = columns.length - 1;
1533
+ }
1534
+ else
1535
+ return;
1536
+ }
1353
1537
  // crisp/#607, G-066/G-068: End jumps to the last row (confirmed live --
1354
1538
  // it also triggers Attio's own lazy-load, which is a data-loading
1355
1539
  // concern outside this component; the #577 scroll-into-view effect
@@ -1977,7 +2161,9 @@ export function DataTable({ columns: columnsProp, data, selectable = false, sele
1977
2161
  if (isEditing &&
1978
2162
  (col.editor === "select" ||
1979
2163
  col.editor === "reference" ||
1980
- col.editor === "actor")) {
2164
+ col.editor === "actor" ||
2165
+ col.editor === "phone" ||
2166
+ col.editor === "datetime")) {
1981
2167
  return (_jsxs(TableCell, { align: alignFor(col), "aria-colindex": colAriaIndex(i), ...sticky, ...rowHeaderProps, className: "crisp-datatable-td--picker", "data-pending": isPending ? "" : undefined,
1982
2168
  // Same as the plain text editor's own cell:
1983
2169
  // opening/using the picker must not also fire
@@ -1989,7 +2175,7 @@ export function DataTable({ columns: columnsProp, data, selectable = false, sele
1989
2175
  // portaled popover, so a handler here can't
1990
2176
  // rely on the key event ever bubbling through
1991
2177
  // 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));
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));
1993
2179
  }
1994
2180
  if (isEditing) {
1995
2181
  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; }
@@ -272,6 +280,15 @@ const config = defineRecipe({
272
280
  link. */
273
281
  & .crisp-datatable-link { color: ${c.fgPrimary}; text-decoration: none; overflow: hidden; text-overflow: ellipsis; }
274
282
  & .crisp-datatable-link:hover { text-decoration: underline; }
283
+ /* phone cell type/editor (#765) -- measured live: the dial-code prefix
284
+ is a muted rgba(0,0,0,0.63) at 14px, matching every other muted-text
285
+ value already measured elsewhere in this file. */
286
+ & .crisp-datatable-phone { display: inline-flex; align-items: center; gap: ${c.space["1"]}; }
287
+ & .crisp-datatable-phone-dial { color: color-mix(in srgb, ${c.fgPrimary} 63%, transparent); }
288
+ & .crisp-datatable-phone-editor { display: flex; align-items: center; gap: ${c.space["1"]}; height: ${c.size.tableRow}; padding: 0 ${c.space["2"]}; }
289
+ & .crisp-datatable-phone-editor input { flex: 1; min-width: 0; }
290
+ /* datetime cell editor (#762) -- DatePickerField + TimePicker side by side. */
291
+ & .crisp-datatable-datetime-editor { display: flex; align-items: center; gap: ${c.space["1"]}; height: ${c.size.tableRow}; padding: 0 ${c.space["2"]}; }
275
292
  /* rating cell type/editor (#760) -- measured live: 16px stars on an
276
293
  18px pitch (a 2px gap), an empty star at rgba(0,0,0,0.1), a
277
294
  committed/filled star at brand600 (--crisp-bg-brand-solid, an exact