@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 +1 -1
- package/dist/badge.layered.css +1 -1
- package/dist/cjs/components/badge/badge.recipe.js +10 -5
- package/dist/cjs/components/datatable/countries.js +73 -0
- package/dist/cjs/components/datatable/datatable.js +282 -12
- package/dist/cjs/components/datatable/datatable.recipe.js +51 -1
- package/dist/datatable.css +172 -102
- package/dist/datatable.layered.css +172 -102
- package/dist/esm/components/badge/badge.recipe.js +10 -5
- package/dist/esm/components/datatable/countries.js +68 -0
- package/dist/esm/components/datatable/datatable.js +283 -13
- package/dist/esm/components/datatable/datatable.recipe.js +51 -1
- package/dist/styles.css +173 -103
- package/dist/styles.layered.css +173 -103
- package/dist/types/components/badge/badge.recipe.d.ts +3 -1
- package/dist/types/components/datatable/countries.d.ts +12 -0
- package/dist/types/components/datatable/datatable.d.ts +47 -3
- package/package.json +1 -1
|
@@ -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
|
-
//
|
|
1289
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
1704
|
+
if (!keepEditing)
|
|
1705
|
+
setEditing(null);
|
|
1440
1706
|
}, (err) => {
|
|
1441
1707
|
setPending(null);
|
|
1442
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|