@crisp-ui-kit/crisp 0.54.1 → 0.54.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/chip.css +7 -1
- package/dist/chip.d.ts +2 -1
- package/dist/chip.layered.css +7 -1
- package/dist/cjs/components/chip/chip.js +3 -2
- package/dist/cjs/components/chip/chip.recipe.js +37 -1
- package/dist/cjs/components/datatable/datatable.js +490 -111
- package/dist/cjs/components/datatable/datatable.recipe.js +4 -0
- package/dist/cjs/components/emptystate/emptystate.js +3 -2
- package/dist/cjs/components/emptystate/emptystate.recipe.js +26 -1
- package/dist/cjs/components/mentionchip/index.js +7 -0
- package/dist/cjs/components/mentionchip/mentionchip.js +57 -0
- package/dist/cjs/components/mentionchip/mentionchip.recipe.js +38 -0
- package/dist/cjs/components/personchip/index.js +17 -0
- package/dist/cjs/components/personchip/personchip.js +50 -0
- package/dist/cjs/components/table/table.js +2 -2
- package/dist/cjs/components/table/table.recipe.js +15 -3
- package/dist/cjs/components/tabs/tabs.js +2 -2
- package/dist/cjs/components/tabs/tabs.recipe.js +30 -3
- package/dist/cjs/components/text/index.js +7 -0
- package/dist/cjs/components/text/text.js +55 -0
- package/dist/cjs/components/text/text.recipe.js +34 -0
- package/dist/cjs/core/floating-stack.js +18 -0
- package/dist/cjs/core/index.js +2 -1
- package/dist/cjs/index.js +8 -0
- package/dist/cjs/product/formdialog/formdialog.js +171 -0
- package/dist/cjs/product/formdialog/formdialog.recipe.js +26 -0
- package/dist/cjs/product/formdialog/index.js +8 -0
- package/dist/cjs/product/headerbar/headerbar.recipe.js +9 -1
- package/dist/cjs/product/importdialog/importdialog.js +134 -0
- package/dist/cjs/product/importdialog/importdialog.recipe.js +92 -0
- package/dist/cjs/product/importdialog/index.js +18 -0
- package/dist/cjs/product/recordpage/recordpage.recipe.js +10 -1
- package/dist/cjs/product/recordstable/recordstable.js +89 -6
- package/dist/cjs/product/richtext/richtext.recipe.js +26 -1
- package/dist/cjs/product/savedviews/index.js +7 -0
- package/dist/cjs/product/savedviews/savedviews.js +207 -0
- package/dist/cjs/product/savedviews/savedviews.recipe.js +103 -0
- package/dist/cjs/product/taskstable/index.js +7 -0
- package/dist/cjs/product/taskstable/taskstable.js +27 -0
- package/dist/cjs/product/taskstable/taskstable.recipe.js +47 -0
- package/dist/cjs/product/themepicker/index.js +17 -0
- package/dist/cjs/product/themepicker/themepicker.js +32 -0
- package/dist/cjs/product/themepicker/themepicker.recipe.js +98 -0
- package/dist/cjs/tokens/palette.js +30 -13
- package/dist/cjs/tokens/semantic.js +6 -0
- package/dist/datatable.css +20 -21
- package/dist/datatable.layered.css +20 -21
- package/dist/emptystate.css +15 -1
- package/dist/emptystate.d.ts +2 -2
- package/dist/emptystate.layered.css +15 -1
- package/dist/esm/components/chip/chip.js +3 -2
- package/dist/esm/components/chip/chip.recipe.js +37 -1
- package/dist/esm/components/datatable/datatable.js +489 -111
- package/dist/esm/components/datatable/datatable.recipe.js +4 -0
- package/dist/esm/components/emptystate/emptystate.js +4 -3
- package/dist/esm/components/emptystate/emptystate.recipe.js +26 -1
- package/dist/esm/components/emptystate/index.js +1 -1
- package/dist/esm/components/mentionchip/index.js +2 -0
- package/dist/esm/components/mentionchip/mentionchip.js +21 -0
- package/dist/esm/components/mentionchip/mentionchip.recipe.js +35 -0
- package/dist/esm/components/personchip/index.js +1 -0
- package/dist/esm/components/personchip/personchip.js +47 -0
- package/dist/esm/components/table/table.js +2 -2
- package/dist/esm/components/table/table.recipe.js +15 -3
- package/dist/esm/components/tabs/tabs.js +2 -2
- package/dist/esm/components/tabs/tabs.recipe.js +30 -3
- package/dist/esm/components/text/index.js +2 -0
- package/dist/esm/components/text/text.js +19 -0
- package/dist/esm/components/text/text.recipe.js +31 -0
- package/dist/esm/core/floating-stack.js +17 -0
- package/dist/esm/core/index.js +1 -1
- package/dist/esm/index.js +8 -0
- package/dist/esm/product/formdialog/formdialog.js +134 -0
- package/dist/esm/product/formdialog/formdialog.recipe.js +23 -0
- package/dist/esm/product/formdialog/index.js +2 -0
- package/dist/esm/product/headerbar/headerbar.recipe.js +9 -1
- package/dist/esm/product/importdialog/importdialog.js +97 -0
- package/dist/esm/product/importdialog/importdialog.recipe.js +89 -0
- package/dist/esm/product/importdialog/index.js +2 -0
- package/dist/esm/product/recordpage/recordpage.recipe.js +10 -1
- package/dist/esm/product/recordstable/recordstable.js +90 -8
- package/dist/esm/product/richtext/richtext.recipe.js +26 -1
- package/dist/esm/product/savedviews/index.js +2 -0
- package/dist/esm/product/savedviews/savedviews.js +171 -0
- package/dist/esm/product/savedviews/savedviews.recipe.js +100 -0
- package/dist/esm/product/taskstable/index.js +2 -0
- package/dist/esm/product/taskstable/taskstable.js +24 -0
- package/dist/esm/product/taskstable/taskstable.recipe.js +44 -0
- package/dist/esm/product/themepicker/index.js +1 -0
- package/dist/esm/product/themepicker/themepicker.js +29 -0
- package/dist/esm/product/themepicker/themepicker.recipe.js +95 -0
- package/dist/esm/tokens/palette.js +30 -13
- package/dist/esm/tokens/semantic.js +6 -0
- package/dist/formdialog.css +6 -0
- package/dist/formdialog.d.ts +7 -0
- package/dist/formdialog.layered.css +8 -0
- package/dist/headerbar.css +1 -1
- package/dist/headerbar.layered.css +1 -1
- package/dist/importdialog.css +38 -0
- package/dist/importdialog.d.ts +7 -0
- package/dist/importdialog.layered.css +40 -0
- package/dist/mentionchip.css +3 -0
- package/dist/mentionchip.d.ts +7 -0
- package/dist/mentionchip.layered.css +5 -0
- package/dist/native/tokens.d.ts +1 -1
- package/dist/native/tokens.js +14 -6
- package/dist/recordpage.css +13 -2
- package/dist/recordpage.layered.css +13 -2
- package/dist/richtext.css +11 -1
- package/dist/richtext.layered.css +11 -1
- package/dist/savedviews.css +78 -0
- package/dist/savedviews.d.ts +7 -0
- package/dist/savedviews.layered.css +80 -0
- package/dist/styles.css +287 -37
- package/dist/styles.layered.css +287 -37
- package/dist/table.css +15 -6
- package/dist/table.layered.css +15 -6
- package/dist/tabs.css +7 -1
- package/dist/tabs.d.ts +2 -2
- package/dist/tabs.layered.css +7 -1
- package/dist/taskstable.css +23 -0
- package/dist/taskstable.d.ts +7 -0
- package/dist/taskstable.layered.css +25 -0
- package/dist/text.css +9 -0
- package/dist/text.d.ts +7 -0
- package/dist/text.layered.css +11 -0
- package/dist/themepicker.css +27 -0
- package/dist/themepicker.d.ts +7 -0
- package/dist/themepicker.layered.css +29 -0
- package/dist/tokens.css +7 -3
- package/dist/tokens.json +14 -6
- package/dist/types/components/chip/chip.d.ts +32 -0
- package/dist/types/components/chip/chip.recipe.d.ts +31 -0
- package/dist/types/components/datatable/datatable.d.ts +39 -4
- package/dist/types/components/emptystate/emptystate.d.ts +29 -4
- package/dist/types/components/emptystate/emptystate.recipe.d.ts +2 -0
- package/dist/types/components/emptystate/index.d.ts +1 -1
- package/dist/types/components/mentionchip/index.d.ts +2 -0
- package/dist/types/components/mentionchip/mentionchip.d.ts +20 -0
- package/dist/types/components/mentionchip/mentionchip.recipe.d.ts +18 -0
- package/dist/types/components/personchip/index.d.ts +1 -0
- package/dist/types/components/personchip/personchip.d.ts +37 -0
- package/dist/types/components/table/table.d.ts +36 -0
- package/dist/types/components/tabs/tabs.d.ts +4 -2
- package/dist/types/components/tabs/tabs.recipe.d.ts +18 -3
- package/dist/types/components/text/index.d.ts +2 -0
- package/dist/types/components/text/text.d.ts +32 -0
- package/dist/types/components/text/text.recipe.d.ts +40 -0
- package/dist/types/core/floating-stack.d.ts +14 -0
- package/dist/types/core/index.d.ts +1 -1
- package/dist/types/index.d.ts +8 -0
- package/dist/types/product/formdialog/formdialog.d.ts +57 -0
- package/dist/types/product/formdialog/formdialog.recipe.d.ts +3 -0
- package/dist/types/product/formdialog/index.d.ts +2 -0
- package/dist/types/product/importdialog/importdialog.d.ts +86 -0
- package/dist/types/product/importdialog/importdialog.recipe.d.ts +3 -0
- package/dist/types/product/importdialog/index.d.ts +2 -0
- package/dist/types/product/recordstable/recordstable.d.ts +24 -2
- package/dist/types/product/savedviews/index.d.ts +2 -0
- package/dist/types/product/savedviews/savedviews.d.ts +55 -0
- package/dist/types/product/savedviews/savedviews.recipe.d.ts +3 -0
- package/dist/types/product/taskstable/index.d.ts +2 -0
- package/dist/types/product/taskstable/taskstable.d.ts +54 -0
- package/dist/types/product/taskstable/taskstable.recipe.d.ts +3 -0
- package/dist/types/product/themepicker/index.d.ts +1 -0
- package/dist/types/product/themepicker/themepicker.d.ts +21 -0
- package/dist/types/product/themepicker/themepicker.recipe.d.ts +3 -0
- package/dist/types/tokens/contract.d.ts +2 -0
- package/dist/types/tokens/palette.d.ts +11 -3
- package/dist/types/tokens/semantic.d.ts +6 -0
- package/package.json +41 -1
|
@@ -9,7 +9,55 @@ import { Select } from "../select/index.js";
|
|
|
9
9
|
import { Spinner } from "../spinner/index.js";
|
|
10
10
|
import { Switch } from "../switch/index.js";
|
|
11
11
|
import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow, } from "../table/index.js";
|
|
12
|
+
import { Tooltip } from "../tooltip/index.js";
|
|
12
13
|
import { datatableRecipe } from "./datatable.recipe.js";
|
|
14
|
+
// #534: whether an element's own content is being cut off by its own box --
|
|
15
|
+
// the same fact `.crisp-datatable-th-label`'s CSS ellipsis is silently
|
|
16
|
+
// applying, exported so the comparison itself (not layout) can be
|
|
17
|
+
// mutation-tested without a real browser.
|
|
18
|
+
export function isLabelTruncated(el) {
|
|
19
|
+
return el.scrollWidth > el.clientWidth;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* The header label span: a `Tooltip` (full text, `asChild` so the wrapper
|
|
23
|
+
* never displaces the ellipsis CSS the span already carries -- see
|
|
24
|
+
* `Tooltip`'s own `asChild` doc) permanently closed until the label actually
|
|
25
|
+
* overflows its own box. Re-measures on mount and on resize (column drag,
|
|
26
|
+
* viewport change, header text change) via `ResizeObserver`; SSR-safe the
|
|
27
|
+
* same way `MiddleTruncate` is -- the DOM is only touched inside an effect,
|
|
28
|
+
* so the server (and the first client paint) render the closed tooltip.
|
|
29
|
+
*/
|
|
30
|
+
function ThLabel({ header }) {
|
|
31
|
+
const ref = React.useRef(null);
|
|
32
|
+
const [overflowing, setOverflowing] = React.useState(false);
|
|
33
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: `header` isn't read in the body, but a rename (col.header changing with the column's own width unchanged) needs a re-measure a ResizeObserver on `el` won't fire for -- same reasoning as Tabs' own re-measure effect.
|
|
34
|
+
React.useLayoutEffect(() => {
|
|
35
|
+
const el = ref.current;
|
|
36
|
+
if (!el)
|
|
37
|
+
return;
|
|
38
|
+
const measure = () => setOverflowing(isLabelTruncated(el));
|
|
39
|
+
measure();
|
|
40
|
+
if (typeof ResizeObserver === "undefined")
|
|
41
|
+
return;
|
|
42
|
+
const ro = new ResizeObserver(measure);
|
|
43
|
+
ro.observe(el);
|
|
44
|
+
return () => ro.disconnect();
|
|
45
|
+
}, [header]);
|
|
46
|
+
// `Tooltip` always wraps the SAME span (never swapped in/out of the tree
|
|
47
|
+
// based on `overflowing`) so the span's own DOM node identity survives
|
|
48
|
+
// every toggle -- a real-browser-only bug (happy-dom has no
|
|
49
|
+
// ResizeObserver, so the test suite never exercised this path): swapping
|
|
50
|
+
// between a bare `<span>` and `<Tooltip asChild><span/></Tooltip>` changes
|
|
51
|
+
// the element type at this position, so React unmounts the old span and
|
|
52
|
+
// mounts a new one; the effect above never re-runs (its deps are
|
|
53
|
+
// `[header]`, unchanged), so its ResizeObserver keeps watching the now
|
|
54
|
+
// -detached old node, whose deferred initial callback then reports 0x0 and
|
|
55
|
+
// flips `overflowing` straight back to false -- the tooltip would never
|
|
56
|
+
// actually show. `open={false}` when it fits keeps the Tooltip mounted
|
|
57
|
+
// (asChild costs no extra DOM either way) but pins it permanently closed,
|
|
58
|
+
// so hovering a non-overflowing header still opens nothing.
|
|
59
|
+
return (_jsx(Tooltip, { content: header, asChild: true, open: overflowing ? undefined : false, children: _jsx("span", { ref: ref, className: "crisp-datatable-th-label", children: header }) }));
|
|
60
|
+
}
|
|
13
61
|
/** A `date` input needs `yyyy-mm-dd`; cells may hold a Date, an ISO string or
|
|
14
62
|
* a display string. Returns "" when it cannot be parsed, so the field opens
|
|
15
63
|
* empty rather than showing an invalid value. */
|
|
@@ -214,6 +262,51 @@ function renderTyped(col, value, avatarTone, row, display, locale = "en-US") {
|
|
|
214
262
|
}
|
|
215
263
|
}
|
|
216
264
|
const CHECK_W = 40;
|
|
265
|
+
// crisp/#577 -- cell-level virtualization on both axes.
|
|
266
|
+
//
|
|
267
|
+
// ROW_HEIGHT/GROUP_ROW_HEIGHT must track table.recipe.ts's `c.size.tableRow`
|
|
268
|
+
// (36px) and datatable.recipe.ts's `.crisp-datatable-group` height, which
|
|
269
|
+
// reads `c.size.menuRow` (32px) -- there is no way to read a recipe's CSS
|
|
270
|
+
// value back out in JS, so these are a second, deliberately-commented copy.
|
|
271
|
+
// A drift between the two would only show up as visual jitter (a spacer's
|
|
272
|
+
// height not quite matching the rows it stands in for), never a crash, which
|
|
273
|
+
// is exactly the kind of silent drift AUTHORING.md Lesson 31 warns about.
|
|
274
|
+
const ROW_HEIGHT = 36;
|
|
275
|
+
const GROUP_ROW_HEIGHT = 32;
|
|
276
|
+
// Overscan is a performance cushion, not a measured value -- Attio's own
|
|
277
|
+
// Companies grid (measured live, #577's own issue body) rendered 24 rows for
|
|
278
|
+
// what a 715px-tall viewport fits at 36px/row (~20), i.e. a handful either
|
|
279
|
+
// side; these are the same shape of cushion, not a parity target.
|
|
280
|
+
const ROW_OVERSCAN = 6;
|
|
281
|
+
const COL_OVERSCAN = 2;
|
|
282
|
+
// The row window a server render (or a client's very first paint, before a
|
|
283
|
+
// real measurement lands) assumes when `height` is a CSS string rather than
|
|
284
|
+
// a plain pixel number -- see the `height` prop doc on why a string can't be
|
|
285
|
+
// turned into an exact row count up front.
|
|
286
|
+
const DEFAULT_VIRTUAL_VIEWPORT_PX = 480;
|
|
287
|
+
/**
|
|
288
|
+
* Cumulative `offsets` has length n+1 (offsets[i] is where item i STARTS,
|
|
289
|
+
* offsets[n] is the total). Returns the index i (clamped to [0, n-1]) whose
|
|
290
|
+
* span contains `target` -- i.e. the largest i with offsets[i] <= target.
|
|
291
|
+
* Shared by the row and column windowing below; a plain linear scan would
|
|
292
|
+
* also be correct but is the wrong complexity for a 10,000-row table
|
|
293
|
+
* recomputed on every scroll event.
|
|
294
|
+
*/
|
|
295
|
+
function offsetIndex(offsets, target) {
|
|
296
|
+
const n = offsets.length - 1;
|
|
297
|
+
if (n <= 0)
|
|
298
|
+
return 0;
|
|
299
|
+
let lo = 0;
|
|
300
|
+
let hi = n - 1;
|
|
301
|
+
while (lo < hi) {
|
|
302
|
+
const mid = (lo + hi + 1) >> 1;
|
|
303
|
+
if (offsets[mid] <= target)
|
|
304
|
+
lo = mid;
|
|
305
|
+
else
|
|
306
|
+
hi = mid - 1;
|
|
307
|
+
}
|
|
308
|
+
return lo;
|
|
309
|
+
}
|
|
217
310
|
// A sentinel that can't collide with a real record id (crisp never generates
|
|
218
311
|
// one, and a consumer's own ids come from their own data store) -- picking
|
|
219
312
|
// this row calls `onCreateReference`, not the cell's own commit (#449).
|
|
@@ -301,7 +394,7 @@ function DataReferenceEditor({ col, value, isPending, onCommit, onCancel, }) {
|
|
|
301
394
|
onCommit(v);
|
|
302
395
|
} }) }));
|
|
303
396
|
}
|
|
304
|
-
export function DataTable({ columns: columnsProp, data, selectable = false, selectedIds, onSelectionChange, selectAllLabel: selectAllLabelProp, selectRowLabel = (row) => `Select row ${row.id}`, selectionAnnouncement, columnMenu = false, onColumnsChange, groupBy, onRowsReorder, error, sort: sortProp, defaultSort, onSortChange, filterable = false, searchPlaceholder: searchPlaceholderProp, searchLabel: searchLabelProp, onEdit, stickyFirst = true, stickyLast = false, locale: localeProp, avatarTone = "brand", surface, interactive = false, onRowClick, onAddRow, addRowLabel: addRowLabelProp, empty: emptyProp, showCount = true, countLabel, toolbar, className, reorderRowLabel: reorderRowLabelProp, sortAscendingLabel: sortAscendingLabelProp, sortDescendingLabel: sortDescendingLabelProp, moveColumnLeftLabel: moveColumnLeftLabelProp, moveColumnRightLabel: moveColumnRightLabelProp, editColumnLabel: editColumnLabelProp, hideColumnLabel: hideColumnLabelProp, renameColumnLabel: renameColumnLabelProp, viewSettingsLabel: viewSettingsLabelProp, columnsGroupLabel: columnsGroupLabelProp, ...rest }) {
|
|
397
|
+
export function DataTable({ columns: columnsProp, data, selectable = false, selectedIds, onSelectionChange, selectAllLabel: selectAllLabelProp, selectRowLabel = (row) => `Select row ${row.id}`, selectionAnnouncement, columnMenu = false, onColumnsChange, groupBy, onRowsReorder, error, sort: sortProp, defaultSort, onSortChange, filterable = false, searchPlaceholder: searchPlaceholderProp, searchLabel: searchLabelProp, onEdit, 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, sortAscendingLabel: sortAscendingLabelProp, sortDescendingLabel: sortDescendingLabelProp, moveColumnLeftLabel: moveColumnLeftLabelProp, moveColumnRightLabel: moveColumnRightLabelProp, editColumnLabel: editColumnLabelProp, hideColumnLabel: hideColumnLabelProp, renameColumnLabel: renameColumnLabelProp, viewSettingsLabel: viewSettingsLabelProp, columnsGroupLabel: columnsGroupLabelProp, ...rest }) {
|
|
305
398
|
// crisp/#471: same resolution order as TableToolbar's own — an explicit
|
|
306
399
|
// prop wins, then the provider's translation of the built-in English
|
|
307
400
|
// default, then that default itself with no provider in the tree.
|
|
@@ -443,21 +536,52 @@ export function DataTable({ columns: columnsProp, data, selectable = false, sele
|
|
|
443
536
|
const [pending, setPending] = React.useState(null);
|
|
444
537
|
const [editError, setEditError] = React.useState(null);
|
|
445
538
|
const [scrolledX, setScrolledX] = React.useState(false);
|
|
446
|
-
// Mirrors `scrolledX` for the trailing edge: true while there is still
|
|
447
|
-
// unrevealed content sliding under the sticky trailing column, i.e. while
|
|
448
|
-
// NOT scrolled all the way to the end. Recomputed after every render (see
|
|
449
|
-
// the effect below), not just on scroll, because a column resize or a data
|
|
450
|
-
// change can move `scrollWidth` without firing a scroll event.
|
|
451
|
-
const [scrolledXEnd, setScrolledXEnd] = React.useState(false);
|
|
452
539
|
const scrollRef = React.useRef(null);
|
|
540
|
+
// crisp/#577 -- the scroll container's own position/size, read on scroll
|
|
541
|
+
// and on mount/resize. `w` (viewport width) is ALWAYS tracked, since column
|
|
542
|
+
// virtualization doesn't need `height` -- the grid already has a
|
|
543
|
+
// determinate horizontal scroll container. `h` only matters once `height`
|
|
544
|
+
// is set; its initial value is the "first window" SSR/first-paint renders
|
|
545
|
+
// (see the `height` prop doc): a plain-number `height` gets an exact
|
|
546
|
+
// viewport up front, a CSS-string one gets `DEFAULT_VIRTUAL_VIEWPORT_PX`
|
|
547
|
+
// until the real size is measured after mount. `w` starts at `Infinity` --
|
|
548
|
+
// "not yet measured" reads as "render every column", the same as today,
|
|
549
|
+
// rather than guessing a width and risking a visible column pop after
|
|
550
|
+
// hydration.
|
|
551
|
+
const [viewport, setViewport] = React.useState(() => ({
|
|
552
|
+
top: 0,
|
|
553
|
+
left: 0,
|
|
554
|
+
h: typeof height === "number"
|
|
555
|
+
? height
|
|
556
|
+
: height != null
|
|
557
|
+
? DEFAULT_VIRTUAL_VIEWPORT_PX
|
|
558
|
+
: Number.POSITIVE_INFINITY,
|
|
559
|
+
w: Number.POSITIVE_INFINITY,
|
|
560
|
+
}));
|
|
561
|
+
// Client-only measurement: re-reads the scroll container's real box on
|
|
562
|
+
// mount and whenever it resizes (a window resize, a sidebar toggling, a
|
|
563
|
+
// consumer's own layout change) -- none of which fire a `scroll` event.
|
|
564
|
+
// Never runs during SSR (effects don't run there at all), which is what
|
|
565
|
+
// keeps the "first window" guess above the only thing a server ever
|
|
566
|
+
// renders.
|
|
453
567
|
React.useEffect(() => {
|
|
454
|
-
if (!stickyLast)
|
|
455
|
-
return;
|
|
456
568
|
const el = scrollRef.current;
|
|
457
569
|
if (!el)
|
|
458
570
|
return;
|
|
459
|
-
|
|
460
|
-
|
|
571
|
+
const measure = () => {
|
|
572
|
+
setViewport((v) => ({
|
|
573
|
+
...v,
|
|
574
|
+
h: height != null ? el.clientHeight : v.h,
|
|
575
|
+
w: el.clientWidth,
|
|
576
|
+
}));
|
|
577
|
+
};
|
|
578
|
+
measure();
|
|
579
|
+
if (typeof ResizeObserver === "undefined")
|
|
580
|
+
return;
|
|
581
|
+
const ro = new ResizeObserver(measure);
|
|
582
|
+
ro.observe(el);
|
|
583
|
+
return () => ro.disconnect();
|
|
584
|
+
}, [height]);
|
|
461
585
|
// column widths (drag-to-resize) + fixed-layout sizing
|
|
462
586
|
const [widths, setWidths] = React.useState({});
|
|
463
587
|
const [resizingKey, setResizingKey] = React.useState(null);
|
|
@@ -466,6 +590,33 @@ export function DataTable({ columns: columnsProp, data, selectable = false, sele
|
|
|
466
590
|
const minWidth = (showGrip ? GRIP_W : 0) +
|
|
467
591
|
(selectable ? CHECK_W : 0) +
|
|
468
592
|
columns.reduce((sum, col) => sum + colWidth(col), 0);
|
|
593
|
+
// Mirrors `scrolledX` for the trailing edge: true while there is still
|
|
594
|
+
// unrevealed content sliding under the sticky trailing column, i.e. while
|
|
595
|
+
// NOT scrolled all the way to the end. Recomputed whenever `minWidth`
|
|
596
|
+
// changes (a column resize or the column set changing), not just on
|
|
597
|
+
// scroll, since either can move `scrollWidth` without firing a scroll
|
|
598
|
+
// event. This used to run after EVERY render (no dependency array at
|
|
599
|
+
// all) -- harmless on its own, but crisp/#577's own `viewport` effects
|
|
600
|
+
// (below) also read layout and call `setViewport` on their own schedule,
|
|
601
|
+
// and the two together resonated: each render's `setScrolledXEnd`
|
|
602
|
+
// triggered another render, which reran this effect, which could
|
|
603
|
+
// observe a still-settling layout and set a DIFFERENT value, which
|
|
604
|
+
// triggered yet another render -- "Maximum update depth exceeded",
|
|
605
|
+
// caught reviewing the virtualized docs example (the first place this
|
|
606
|
+
// repo ever exercises `stickyLast` in a real browser; the existing
|
|
607
|
+
// jsdom tests never hit real layout, so this never surfaced there).
|
|
608
|
+
// `minWidth` is exactly what this comparison's `scrollWidth` side
|
|
609
|
+
// tracks, so it's the one dependency that actually matters.
|
|
610
|
+
const [scrolledXEnd, setScrolledXEnd] = React.useState(false);
|
|
611
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: minWidth is what scrollWidth tracks, see comment above -- not read directly in the body
|
|
612
|
+
React.useEffect(() => {
|
|
613
|
+
if (!stickyLast)
|
|
614
|
+
return;
|
|
615
|
+
const el = scrollRef.current;
|
|
616
|
+
if (!el)
|
|
617
|
+
return;
|
|
618
|
+
setScrolledXEnd(el.scrollLeft + el.clientWidth < el.scrollWidth - 1);
|
|
619
|
+
}, [stickyLast, minWidth]);
|
|
469
620
|
const colByKey = React.useMemo(() => {
|
|
470
621
|
const m = new Map();
|
|
471
622
|
for (const c of columns)
|
|
@@ -526,6 +677,110 @@ export function DataTable({ columns: columnsProp, data, selectable = false, sele
|
|
|
526
677
|
rows: groupRows,
|
|
527
678
|
}));
|
|
528
679
|
}, [rows, groupBy]);
|
|
680
|
+
const renderItems = React.useMemo(() => {
|
|
681
|
+
const items = [];
|
|
682
|
+
for (const { group, rows: groupRows } of groupedRows) {
|
|
683
|
+
if (group !== null) {
|
|
684
|
+
items.push({
|
|
685
|
+
kind: "group",
|
|
686
|
+
key: `__group_${group}`,
|
|
687
|
+
label: group,
|
|
688
|
+
count: groupRows.length,
|
|
689
|
+
height: GROUP_ROW_HEIGHT,
|
|
690
|
+
});
|
|
691
|
+
}
|
|
692
|
+
for (const row of groupRows)
|
|
693
|
+
items.push({ kind: "row", key: row.id, row, height: ROW_HEIGHT });
|
|
694
|
+
}
|
|
695
|
+
return items;
|
|
696
|
+
}, [groupedRows]);
|
|
697
|
+
const rowOffsets = React.useMemo(() => {
|
|
698
|
+
const offsets = new Array(renderItems.length + 1);
|
|
699
|
+
offsets[0] = 0;
|
|
700
|
+
for (let i = 0; i < renderItems.length; i++)
|
|
701
|
+
offsets[i + 1] = offsets[i] + renderItems[i].height;
|
|
702
|
+
return offsets;
|
|
703
|
+
}, [renderItems]);
|
|
704
|
+
const totalRowsHeight = rowOffsets[rowOffsets.length - 1] ?? 0;
|
|
705
|
+
// O(1) lookup from a row's id to its position in `renderItems` -- used by
|
|
706
|
+
// the keyboard-nav scroll-into-view effect below, which knows a row by id
|
|
707
|
+
// (from `selCell`), not by its index in the flattened item list.
|
|
708
|
+
const rowIdToItemIndex = React.useMemo(() => {
|
|
709
|
+
const m = new Map();
|
|
710
|
+
renderItems.forEach((it, i) => {
|
|
711
|
+
if (it.kind === "row")
|
|
712
|
+
m.set(it.row.id, i);
|
|
713
|
+
});
|
|
714
|
+
return m;
|
|
715
|
+
}, [renderItems]);
|
|
716
|
+
// `height == null` (the default) makes this a no-op: `viewport.h` starts at
|
|
717
|
+
// `Infinity`, so the visible range below covers every item, both spacers
|
|
718
|
+
// come out to 0px, and neither renders (see the `> 0` guards further down)
|
|
719
|
+
// -- the DOM is byte-identical to before virtualization existed.
|
|
720
|
+
const rowFirstVisible = renderItems.length === 0 ? 0 : offsetIndex(rowOffsets, viewport.top);
|
|
721
|
+
const rowLastVisible = renderItems.length === 0
|
|
722
|
+
? 0
|
|
723
|
+
: offsetIndex(rowOffsets, Math.max(viewport.top, viewport.top + viewport.h - 1));
|
|
724
|
+
const rowStart = Math.max(0, rowFirstVisible - ROW_OVERSCAN);
|
|
725
|
+
const rowEnd = Math.min(renderItems.length, rowLastVisible + 1 + ROW_OVERSCAN);
|
|
726
|
+
const topSpacerHeight = rowOffsets[rowStart] ?? 0;
|
|
727
|
+
const bottomSpacerHeight = totalRowsHeight - (rowOffsets[rowEnd] ?? 0);
|
|
728
|
+
const visibleItems = renderItems.slice(rowStart, rowEnd);
|
|
729
|
+
// crisp/#577 -- COLUMN virtualization. Always on (no `height` needed): the
|
|
730
|
+
// sticky-pinned columns (the leading `columns[0]` when `stickyFirst`, the
|
|
731
|
+
// trailing column when `stickyLast`) are excluded from the window and
|
|
732
|
+
// always render, exactly like Attio's own checkbox + entity columns while
|
|
733
|
+
// its own grid scrolls under them. Coordinates below are in the SAME
|
|
734
|
+
// horizontal axis `scrollLeft` uses: a sticky column doesn't leave layout
|
|
735
|
+
// flow (only its paint position pins), so its width still occupies real
|
|
736
|
+
// space in that axis.
|
|
737
|
+
const colAlwaysFirst = stickyFirst && columns.length > 0;
|
|
738
|
+
const colAlwaysLastIdx = stickyLast &&
|
|
739
|
+
columns.length > 0 &&
|
|
740
|
+
!(colAlwaysFirst && columns.length === 1)
|
|
741
|
+
? columns.length - 1
|
|
742
|
+
: -1;
|
|
743
|
+
const colWindowStart = colAlwaysFirst ? 1 : 0;
|
|
744
|
+
const colWindowEnd = colAlwaysLastIdx >= 0 ? colAlwaysLastIdx : columns.length;
|
|
745
|
+
const colLeadingFixedWidth = (showGrip ? GRIP_W : 0) +
|
|
746
|
+
(selectable ? CHECK_W : 0) +
|
|
747
|
+
(colAlwaysFirst ? colWidth(columns[0]) : 0);
|
|
748
|
+
// Cumulative widths of just the WINDOWABLE columns, relative to their own
|
|
749
|
+
// start (not the table's) -- kept separate from the absolute scroll axis
|
|
750
|
+
// so `leftSpacerWidth + rightSpacerWidth + (visible widths)` provably sums
|
|
751
|
+
// to the windowable total no matter how many leading fixed columns there are.
|
|
752
|
+
const colRelOffsets = [0];
|
|
753
|
+
for (let i = colWindowStart; i < colWindowEnd; i++)
|
|
754
|
+
colRelOffsets.push(colRelOffsets[colRelOffsets.length - 1] + colWidth(columns[i]));
|
|
755
|
+
const colWindowableCount = colWindowEnd - colWindowStart;
|
|
756
|
+
const colTargetLeft = Math.max(0, Math.min(colRelOffsets[colWindowableCount] ?? 0, viewport.left - colLeadingFixedWidth));
|
|
757
|
+
const colTargetRight = Math.max(0, Math.min(colRelOffsets[colWindowableCount] ?? 0, viewport.left - colLeadingFixedWidth + viewport.w - 1));
|
|
758
|
+
const colFirstVisibleK = colWindowableCount === 0 ? 0 : offsetIndex(colRelOffsets, colTargetLeft);
|
|
759
|
+
const colLastVisibleK = colWindowableCount === 0 ? 0 : offsetIndex(colRelOffsets, colTargetRight);
|
|
760
|
+
const colStartK = Math.max(0, colFirstVisibleK - COL_OVERSCAN);
|
|
761
|
+
const colEndK = Math.min(colWindowableCount, colLastVisibleK + 1 + COL_OVERSCAN);
|
|
762
|
+
const leftSpacerWidth = colRelOffsets[colStartK] ?? 0;
|
|
763
|
+
const rightSpacerWidth = (colRelOffsets[colWindowableCount] ?? 0) - (colRelOffsets[colEndK] ?? 0);
|
|
764
|
+
const colSlots = [];
|
|
765
|
+
if (colAlwaysFirst)
|
|
766
|
+
colSlots.push({ type: "col", i: 0 });
|
|
767
|
+
if (leftSpacerWidth > 0)
|
|
768
|
+
colSlots.push({ type: "spacer", side: "left", width: leftSpacerWidth });
|
|
769
|
+
for (let i = colWindowStart + colStartK; i < colWindowStart + colEndK; i++)
|
|
770
|
+
colSlots.push({ type: "col", i });
|
|
771
|
+
if (rightSpacerWidth > 0)
|
|
772
|
+
colSlots.push({ type: "spacer", side: "right", width: rightSpacerWidth });
|
|
773
|
+
if (colAlwaysLastIdx >= 0)
|
|
774
|
+
colSlots.push({ type: "col", i: colAlwaysLastIdx });
|
|
775
|
+
// aria-rowcount/-rowindex, aria-colcount/-colindex (#577): a virtualized
|
|
776
|
+
// native `<table>` still exposes its TRUE size and each real cell's TRUE
|
|
777
|
+
// position to assistive tech, even though most rows/columns aren't in the
|
|
778
|
+
// DOM at any moment -- the spacer/placeholder cells are `aria-hidden`
|
|
779
|
+
// instead of numbered. `+1` header row, 1-based indices per the ARIA spec.
|
|
780
|
+
const ariaRowCount = 1 + renderItems.length;
|
|
781
|
+
const ariaColCount = (showGrip ? 1 : 0) + (selectable ? 1 : 0) + columns.length;
|
|
782
|
+
const ariaColIndexBase = (showGrip ? 1 : 0) + (selectable ? 1 : 0);
|
|
783
|
+
const colAriaIndex = (i) => ariaColIndexBase + i + 1;
|
|
529
784
|
const allSelected = rows.length > 0 && rows.every((r) => selected.has(r.id));
|
|
530
785
|
const someSelected = selected.size > 0 && !allSelected;
|
|
531
786
|
function toggleAll() {
|
|
@@ -677,6 +932,72 @@ export function DataTable({ columns: columnsProp, data, selectable = false, sele
|
|
|
677
932
|
}
|
|
678
933
|
setEditing(null);
|
|
679
934
|
}
|
|
935
|
+
// crisp/#577 -- keyboard nav to an off-screen (virtualized-out) cell
|
|
936
|
+
// scrolls it into the rendered window, on both axes. Column scroll-into-
|
|
937
|
+
// view runs regardless of `height` -- the horizontal scroll container (and
|
|
938
|
+
// its own virtualization) exists either way; row scroll-into-view only
|
|
939
|
+
// makes sense once `height` gives the grid an internal vertical scroll
|
|
940
|
+
// position to move. `widths` is read only indirectly, through `colWidth`'s
|
|
941
|
+
// closure over it -- biome can't see that, but a column resize while a
|
|
942
|
+
// cell is selected genuinely needs to re-run this to keep it in view, so
|
|
943
|
+
// the dependency is real even though the lint rule can't prove it.
|
|
944
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: widths is read via colWidth's closure, see comment above
|
|
945
|
+
React.useEffect(() => {
|
|
946
|
+
if (!selCell)
|
|
947
|
+
return;
|
|
948
|
+
const el = scrollRef.current;
|
|
949
|
+
if (!el)
|
|
950
|
+
return;
|
|
951
|
+
// Sets `viewport` directly from the SAME value just assigned to the
|
|
952
|
+
// real DOM property, rather than waiting for a follow-up `scroll` event
|
|
953
|
+
// to round-trip through the `onScroll` handler above -- a real browser
|
|
954
|
+
// does fire one when `scrollTop`/`scrollLeft` is set programmatically,
|
|
955
|
+
// but relying on that would make this effect's own visible result (the
|
|
956
|
+
// newly-selected cell actually rendering) depend on a second render
|
|
957
|
+
// triggered from outside React's own update, which is both slower and
|
|
958
|
+
// one more thing that has to be true for this to work.
|
|
959
|
+
if (height != null) {
|
|
960
|
+
const itemIndex = rowIdToItemIndex.get(selCell.id);
|
|
961
|
+
if (itemIndex != null) {
|
|
962
|
+
const itemTop = rowOffsets[itemIndex];
|
|
963
|
+
const itemBottom = itemTop + renderItems[itemIndex].height;
|
|
964
|
+
if (itemTop < el.scrollTop) {
|
|
965
|
+
el.scrollTop = itemTop;
|
|
966
|
+
setViewport((v) => ({ ...v, top: itemTop }));
|
|
967
|
+
}
|
|
968
|
+
else if (itemBottom > el.scrollTop + el.clientHeight) {
|
|
969
|
+
const newTop = itemBottom - el.clientHeight;
|
|
970
|
+
el.scrollTop = newTop;
|
|
971
|
+
setViewport((v) => ({ ...v, top: newTop }));
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
const ci = columns.findIndex((c) => c.key === selCell.key);
|
|
976
|
+
if (ci >= 0) {
|
|
977
|
+
let left = (showGrip ? GRIP_W : 0) + (selectable ? CHECK_W : 0);
|
|
978
|
+
for (let i = 0; i < ci; i++)
|
|
979
|
+
left += colWidth(columns[i]);
|
|
980
|
+
const right = left + colWidth(columns[ci]);
|
|
981
|
+
if (left < el.scrollLeft) {
|
|
982
|
+
el.scrollLeft = left;
|
|
983
|
+
setViewport((v) => ({ ...v, left }));
|
|
984
|
+
}
|
|
985
|
+
else if (right > el.scrollLeft + el.clientWidth) {
|
|
986
|
+
const newLeft = right - el.clientWidth;
|
|
987
|
+
el.scrollLeft = newLeft;
|
|
988
|
+
setViewport((v) => ({ ...v, left: newLeft }));
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
}, [
|
|
992
|
+
selCell,
|
|
993
|
+
height,
|
|
994
|
+
columns,
|
|
995
|
+
widths,
|
|
996
|
+
selectable,
|
|
997
|
+
rowIdToItemIndex,
|
|
998
|
+
rowOffsets,
|
|
999
|
+
renderItems,
|
|
1000
|
+
]);
|
|
680
1001
|
const totalCols = columns.length + (selectable ? 1 : 0) + (showGrip ? 1 : 0);
|
|
681
1002
|
const hasSummary = columns.some((c) => c.summary);
|
|
682
1003
|
// sticky attrs for the leading column(s): grip col (index -2) + selection
|
|
@@ -781,18 +1102,51 @@ export function DataTable({ columns: columnsProp, data, selectable = false, sele
|
|
|
781
1102
|
return dir === "asc" ? (_jsx(ArrowUp, { className: "crisp-datatable-chevron", size: 13, strokeWidth: 2 })) : (_jsx(ArrowDown, { className: "crisp-datatable-chevron", size: 13, strokeWidth: 2 }));
|
|
782
1103
|
return (_jsx(ChevronsUpDown, { className: "crisp-datatable-chevron", size: 13, strokeWidth: 2 }));
|
|
783
1104
|
}
|
|
784
|
-
return (_jsxs("div", { ref: gridRef, className: datatableRecipe({ className }), tabIndex: -1, onKeyDown: onGridKeyDown, ...rest, children: [_jsx("div", { className: "crisp-datatable-live", role: "status", "aria-live": "polite", children: selectionText }), (filterable || showCount || toolbar || columnMenu) && (_jsxs("div", { className: "crisp-datatable-toolbar", children: [columnMenu && (_jsxs(Menu, { align: "start", trigger: _jsxs("button", { type: "button", className: "crisp-datatable-viewsettings", children: [_jsx(SlidersHorizontal, { size: 14, strokeWidth: 1.75 }), viewSettingsLabel] }), children: [_jsx("div", { className: "crisp-datatable-group-label", children: columnsGroupLabel }), allColumns.map((c) => (_jsxs(MenuItem, { onSelect: () => c.hidden ? showColumn(c.key) : hideColumn(c.key), children: [_jsx(Check, { size: 14, strokeWidth: 2, style: { opacity: c.hidden ? 0 : 1 } }), typeof c.header === "string" ? c.header : c.key] }, c.key)))] })), filterable && (_jsxs("span", { className: "crisp-datatable-search", children: [_jsx(Search, { size: 15, strokeWidth: 1.75, "aria-hidden": "true" }), _jsx("input", { type: "search", placeholder: searchPlaceholder, value: query, onChange: (e) => setQuery(e.target.value), "aria-label": searchLabel })] })), showCount && (_jsx("span", { className: "crisp-datatable-count", children: resolvedCountLabel(rows.length, data.length) })), toolbar] })), _jsxs(Table, { interactive: interactive, surface: surface,
|
|
1105
|
+
return (_jsxs("div", { ref: gridRef, className: datatableRecipe({ className }), tabIndex: -1, onKeyDown: onGridKeyDown, ...rest, children: [_jsx("div", { className: "crisp-datatable-live", role: "status", "aria-live": "polite", children: selectionText }), (filterable || showCount || toolbar || columnMenu) && (_jsxs("div", { className: "crisp-datatable-toolbar", children: [columnMenu && (_jsxs(Menu, { align: "start", trigger: _jsxs("button", { type: "button", className: "crisp-datatable-viewsettings", children: [_jsx(SlidersHorizontal, { size: 14, strokeWidth: 1.75 }), viewSettingsLabel] }), children: [_jsx("div", { className: "crisp-datatable-group-label", children: columnsGroupLabel }), allColumns.map((c) => (_jsxs(MenuItem, { onSelect: () => c.hidden ? showColumn(c.key) : hideColumn(c.key), children: [_jsx(Check, { size: 14, strokeWidth: 2, style: { opacity: c.hidden ? 0 : 1 } }), typeof c.header === "string" ? c.header : c.key] }, c.key)))] })), filterable && (_jsxs("span", { className: "crisp-datatable-search", children: [_jsx(Search, { size: 15, strokeWidth: 1.75, "aria-hidden": "true" }), _jsx("input", { type: "search", placeholder: searchPlaceholder, value: query, onChange: (e) => setQuery(e.target.value), "aria-label": searchLabel })] })), showCount && (_jsx("span", { className: "crisp-datatable-count", children: resolvedCountLabel(rows.length, data.length) })), toolbar] })), _jsxs(Table, { interactive: interactive, surface: surface,
|
|
1106
|
+
// crisp/#581: on the <table> itself (`tableStyle`), not the outer
|
|
1107
|
+
// wrapper (`style`) -- see `Table`'s own doc comment on why. This is
|
|
1108
|
+
// what lets the grid actually scroll horizontally INSIDE its own
|
|
1109
|
+
// box instead of widening its consumer's page.
|
|
1110
|
+
tableStyle: { minWidth }, "aria-rowcount": ariaRowCount, "aria-colcount": ariaColCount, scrollProps: {
|
|
1111
|
+
...scrollProps,
|
|
785
1112
|
ref: scrollRef,
|
|
1113
|
+
// crisp/#577: `height` opts into an internally-scrolling viewport
|
|
1114
|
+
// (and so into row virtualization, see the prop's own doc) by
|
|
1115
|
+
// bounding + capping overflow here -- merged onto, not replacing,
|
|
1116
|
+
// whatever style a consumer already passed for #554's sticky
|
|
1117
|
+
// header/footer bounding.
|
|
1118
|
+
style: {
|
|
1119
|
+
...scrollProps?.style,
|
|
1120
|
+
...(height != null
|
|
1121
|
+
? { overflowY: "auto", height, maxHeight: height }
|
|
1122
|
+
: {}),
|
|
1123
|
+
},
|
|
786
1124
|
onScroll: (e) => {
|
|
787
1125
|
const el = e.currentTarget;
|
|
788
1126
|
setScrolledX(el.scrollLeft > 0);
|
|
789
1127
|
if (stickyLast) {
|
|
790
1128
|
setScrolledXEnd(el.scrollLeft + el.clientWidth < el.scrollWidth - 1);
|
|
791
1129
|
}
|
|
1130
|
+
// Re-reads clientHeight/clientWidth here too, not just
|
|
1131
|
+
// scrollTop/scrollLeft -- a defensive fallback for whenever
|
|
1132
|
+
// ResizeObserver isn't available (or hasn't fired yet): any
|
|
1133
|
+
// scroll re-syncs the viewport size along with its position,
|
|
1134
|
+
// rather than trusting a possibly-stale mount-time measurement.
|
|
1135
|
+
setViewport((v) => ({
|
|
1136
|
+
top: el.scrollTop,
|
|
1137
|
+
left: el.scrollLeft,
|
|
1138
|
+
h: height != null ? el.clientHeight : v.h,
|
|
1139
|
+
w: el.clientWidth,
|
|
1140
|
+
}));
|
|
792
1141
|
},
|
|
793
1142
|
...(scrolledX ? { "data-scrolled-x": "" } : {}),
|
|
794
1143
|
...(scrolledXEnd ? { "data-scrolled-x-end": "" } : {}),
|
|
795
|
-
}, children: [_jsxs("colgroup", { children: [showGrip && _jsx("col", { style: { width: GRIP_W } }), selectable && _jsx("col", { style: { width: CHECK_W } }),
|
|
1144
|
+
}, children: [_jsxs("colgroup", { children: [showGrip && _jsx("col", { style: { width: GRIP_W } }), selectable && _jsx("col", { style: { width: CHECK_W } }), colSlots.map((slot) => slot.type === "spacer" ? (_jsx("col", { style: { width: slot.width } }, `__spacer_${slot.side}`)) : (_jsx("col", { style: { width: colWidth(columns[slot.i]) } }, columns[slot.i].key)))] }), _jsx(TableHeader, { children: _jsxs(TableRow, { "aria-rowindex": 1, children: [showGrip && (_jsx(TableHead, { className: "crisp-datatable-col-grip", "aria-colindex": showGrip ? 1 : undefined, ...stickyCol(-2) })), selectable && (_jsx(TableHead, { className: "crisp-datatable-col-check", "aria-colindex": showGrip ? 2 : 1, ...stickyCol(-1), children: _jsx("span", { className: "crisp-datatable-check", children: _jsx(Checkbox, { "aria-label": selectAllLabel, checked: allSelected, indeterminate: someSelected, onChange: toggleAll }) }) })), colSlots.map((slot) => {
|
|
1145
|
+
if (slot.type === "spacer") {
|
|
1146
|
+
return (_jsx("th", { role: "presentation", "aria-hidden": "true", style: { padding: 0, border: "none" } }, `__spacer_${slot.side}`));
|
|
1147
|
+
}
|
|
1148
|
+
const col = columns[slot.i];
|
|
1149
|
+
const i = slot.i;
|
|
796
1150
|
const Icon = col.icon;
|
|
797
1151
|
// The sort direction was painted (`data-sorted`) and never
|
|
798
1152
|
// announced: a screen reader heard the same header before and
|
|
@@ -808,8 +1162,8 @@ export function DataTable({ columns: columnsProp, data, selectable = false, sele
|
|
|
808
1162
|
? "ascending"
|
|
809
1163
|
: "descending"
|
|
810
1164
|
: "none";
|
|
811
|
-
const inner = (_jsxs("span", { className: "crisp-datatable-th-inner", children: [Icon ? _jsx(Icon, { size: 16, strokeWidth: 1.75 }) : null, _jsx(
|
|
812
|
-
return (_jsxs(TableHead, { align: alignFor(col), "aria-sort": ariaSort, ...dataStickyCol(i), children: [renaming === col.key ? (_jsx("input", {
|
|
1165
|
+
const inner = (_jsxs("span", { className: "crisp-datatable-th-inner", children: [Icon ? _jsx(Icon, { size: 16, strokeWidth: 1.75 }) : null, _jsx(ThLabel, { header: col.header }), col.sortable ? _jsx(ChevronFor, { colKey: col.key }) : null] }));
|
|
1166
|
+
return (_jsxs(TableHead, { align: alignFor(col), "aria-sort": ariaSort, "aria-colindex": colAriaIndex(i), ...dataStickyCol(i), children: [renaming === col.key ? (_jsx("input", {
|
|
813
1167
|
// biome-ignore lint/a11y/noAutofocus: the rename item opens this field
|
|
814
1168
|
autoFocus: true, className: "crisp-datatable-th-rename", defaultValue: typeof col.header === "string" ? col.header : "", "aria-label": renameColumnLabel, onBlur: (e) => {
|
|
815
1169
|
renameColumn(col.key, e.currentTarget.value);
|
|
@@ -824,104 +1178,128 @@ export function DataTable({ columns: columnsProp, data, selectable = false, sele
|
|
|
824
1178
|
} })) : columnMenu ? (_jsxs(Menu, { align: "start", trigger: _jsx("button", { type: "button", className: "crisp-datatable-sort", "data-sorted": sortDirByKey.get(col.key), "aria-label": `${typeof col.header === "string"
|
|
825
1179
|
? col.header
|
|
826
1180
|
: col.key} ${crispMessages["column options"] ?? "column options"}`, children: inner }), children: [_jsx(MenuItem, { onSelect: () => setSort({ key: col.key, dir: "asc" }), children: sortAscendingLabel }), _jsx(MenuItem, { onSelect: () => setSort({ key: col.key, dir: "desc" }), children: sortDescendingLabel }), col.managed === false ? null : (_jsxs(_Fragment, { children: [_jsx(MenuItem, { onSelect: () => moveColumn(col.key, -1), children: moveColumnLeftLabel }), _jsx(MenuItem, { onSelect: () => moveColumn(col.key, 1), children: moveColumnRightLabel }), _jsx(MenuItem, { onSelect: () => setRenaming(col.key), children: editColumnLabel }), _jsx(MenuItem, { onSelect: () => hideColumn(col.key), children: hideColumnLabel })] }))] })) : col.sortable ? (_jsx("button", { type: "button", className: "crisp-datatable-sort", "data-sorted": sortDirByKey.get(col.key), onClick: () => cycleSort(col.key), children: inner })) : (inner), col.resizable !== false && (_jsx("span", { className: "crisp-datatable-resize", "data-resizing": resizingKey === col.key ? "" : undefined, onPointerDown: (e) => startResize(col, e), onPointerMove: moveResize, onPointerUp: endResize }))] }, col.key));
|
|
827
|
-
})] }) }), _jsx(TableBody, { children: error ? (_jsx(TableRow, { children: _jsx(TableCell, { colSpan: totalCols, children: _jsx("div", { className: "crisp-datatable-empty", children: error }) }) })) : rows.length === 0 ? (_jsx(TableRow, { children: _jsx(TableCell, { colSpan: totalCols, children: _jsx("div", { className: "crisp-datatable-empty", children: empty }) }) })) : (
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
setDropRowId(
|
|
849
|
-
}, children: _jsx(GripVertical, { size: 16, strokeWidth: 1.75, "aria-hidden": "true" }) }) })), selectable && (_jsx(TableCell, { className: "crisp-datatable-col-check", ...stickyCol(-1), onClick: (e) => e.stopPropagation(), children: _jsx("span", { className: "crisp-datatable-check", children: _jsx(Checkbox, { "aria-label": selectRowLabel(row), checked: selected.has(row.id), onChange: () => toggleRow(row.id) }) }) })), columns.map((col, i) => {
|
|
850
|
-
const isEditing = editing?.id === row.id && editing?.key === col.key;
|
|
851
|
-
const value = cellValue(col, row);
|
|
852
|
-
const sticky = dataStickyCol(i);
|
|
853
|
-
const isPending = pending?.id === row.id && pending?.key === col.key;
|
|
854
|
-
const errorHere = editError?.id === row.id && editError?.key === col.key
|
|
855
|
-
? editError.message
|
|
856
|
-
: null;
|
|
857
|
-
// #453: a `switch` editable column IS always its own
|
|
858
|
-
// live widget -- flipping the toggle IS the commit
|
|
859
|
-
// gesture, there's no meaningfully different "rest"
|
|
860
|
-
// display for a boolean. `select`/`reference` are
|
|
861
|
-
// NOT this: at rest they must show the column's own
|
|
862
|
-
// render/format like every other editable type
|
|
863
|
-
// (#412 empty handling included), and only open on
|
|
864
|
-
// double-click (#411) -- this branch previously
|
|
865
|
-
// rendered them unconditionally, replacing every
|
|
866
|
-
// row's own render with a picker trigger, which is
|
|
867
|
-
// the regression #453 reported (also #432/#449,
|
|
868
|
-
// which never had a "rest" state of their own to get
|
|
869
|
-
// right). See the `isEditing` block below for their
|
|
870
|
-
// actual editor.
|
|
871
|
-
if (col.editable && col.editor === "switch") {
|
|
872
|
-
return (_jsxs(TableCell, { align: alignFor(col), ...sticky, className: "crisp-datatable-td--picker", "data-pending": isPending ? "" : undefined, children: [_jsxs("span", { className: "crisp-datatable-edit-picker", children: [_jsx(Switch, { "aria-label": `Edit ${col.key}`, checked: value === true || value === "true", disabled: isPending, onCheckedChange: (checked) => commitEdit(row.id, col.key, String(checked)) }), isPending && (_jsx(Spinner, { size: 14, className: "crisp-datatable-edit-pending" }))] }), errorHere && (_jsx("span", { className: "crisp-datatable-edit-error", children: errorHere }))] }, col.key));
|
|
1181
|
+
})] }) }), _jsx(TableBody, { children: error ? (_jsx(TableRow, { children: _jsx(TableCell, { colSpan: totalCols, children: _jsx("div", { className: "crisp-datatable-empty", children: error }) }) })) : rows.length === 0 ? (_jsx(TableRow, { children: _jsx(TableCell, { colSpan: totalCols, children: _jsx("div", { className: "crisp-datatable-empty", children: empty }) }) })) : (_jsxs(_Fragment, { children: [topSpacerHeight > 0 && (_jsx("tr", { "aria-hidden": "true", children: _jsx("td", { colSpan: totalCols, style: {
|
|
1182
|
+
height: topSpacerHeight,
|
|
1183
|
+
padding: 0,
|
|
1184
|
+
border: "none",
|
|
1185
|
+
} }) })), visibleItems.map((item, localIndex) => {
|
|
1186
|
+
const itemIndex = rowStart + localIndex;
|
|
1187
|
+
// header row is aria-rowindex 1, so the first body item is 2
|
|
1188
|
+
const ariaRowIndex = itemIndex + 2;
|
|
1189
|
+
if (item.kind === "group") {
|
|
1190
|
+
return (_jsx(TableRow, { "aria-rowindex": ariaRowIndex, children: _jsxs(TableCell, { colSpan: totalCols, className: "crisp-datatable-group", children: [_jsx("span", { className: "crisp-datatable-group-label", children: item.label }), _jsx("span", { className: "crisp-datatable-group-count", children: item.count })] }) }, item.key));
|
|
1191
|
+
}
|
|
1192
|
+
const row = item.row;
|
|
1193
|
+
return (_jsxs(TableRow, { "aria-rowindex": ariaRowIndex, "data-selected": selected.has(row.id) ? "" : undefined, "data-drop-target": showGrip &&
|
|
1194
|
+
dragRowId &&
|
|
1195
|
+
dragRowId !== row.id &&
|
|
1196
|
+
dropRowId === row.id
|
|
1197
|
+
? ""
|
|
1198
|
+
: undefined, onClick: onRowClick ? () => onRowClick(row) : undefined, style: onRowClick ? { cursor: "pointer" } : undefined, onDragOver: showGrip && dragRowId
|
|
1199
|
+
? (e) => {
|
|
1200
|
+
e.preventDefault();
|
|
1201
|
+
if (dropRowId !== row.id)
|
|
1202
|
+
setDropRowId(row.id);
|
|
873
1203
|
}
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
// opening/using the picker must not also fire
|
|
879
|
-
// the row's onRowClick. Escape is handled by
|
|
880
|
-
// `DataSelectEditor`/`DataReferenceEditor`
|
|
881
|
-
// themselves (a document-level listener, not a
|
|
882
|
-
// `TableCell`-level `onKeyDown`) -- opening the
|
|
883
|
-
// picker moves DOM focus into a floating,
|
|
884
|
-
// portaled popover, so a handler here can't
|
|
885
|
-
// rely on the key event ever bubbling through
|
|
886
|
-
// this element (#453).
|
|
887
|
-
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));
|
|
1204
|
+
: undefined, onDrop: showGrip && dragRowId
|
|
1205
|
+
? (e) => {
|
|
1206
|
+
e.preventDefault();
|
|
1207
|
+
reorderRows(dragRowId, row.id);
|
|
888
1208
|
}
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
1209
|
+
: undefined, children: [showGrip && (_jsx(TableCell, { className: "crisp-datatable-col-grip", "aria-colindex": showGrip ? 1 : undefined, ...stickyCol(-2), onClick: (e) => e.stopPropagation(), children: _jsx("button", { type: "button", className: "crisp-datatable-grip", "aria-label": reorderRowLabel, draggable: true, onDragStart: (e) => {
|
|
1210
|
+
setDragRowId(row.id);
|
|
1211
|
+
e.dataTransfer.effectAllowed = "move";
|
|
1212
|
+
}, onDragEnd: () => {
|
|
1213
|
+
setDragRowId(null);
|
|
1214
|
+
setDropRowId(null);
|
|
1215
|
+
}, children: _jsx(GripVertical, { size: 16, strokeWidth: 1.75, "aria-hidden": "true" }) }) })), selectable && (_jsx(TableCell, { className: "crisp-datatable-col-check", "aria-colindex": showGrip ? 2 : 1, ...stickyCol(-1), onClick: (e) => e.stopPropagation(), children: _jsx("span", { className: "crisp-datatable-check", children: _jsx(Checkbox, { "aria-label": selectRowLabel(row), checked: selected.has(row.id), onChange: () => toggleRow(row.id) }) }) })), colSlots.map((slot) => {
|
|
1216
|
+
if (slot.type === "spacer") {
|
|
1217
|
+
return (_jsx("td", { role: "presentation", "aria-hidden": "true", style: { padding: 0, border: "none" } }, `__spacer_${slot.side}`));
|
|
1218
|
+
}
|
|
1219
|
+
const col = columns[slot.i];
|
|
1220
|
+
const i = slot.i;
|
|
1221
|
+
const isEditing = editing?.id === row.id && editing?.key === col.key;
|
|
1222
|
+
const value = cellValue(col, row);
|
|
1223
|
+
const sticky = dataStickyCol(i);
|
|
1224
|
+
const isPending = pending?.id === row.id && pending?.key === col.key;
|
|
1225
|
+
const errorHere = editError?.id === row.id && editError?.key === col.key
|
|
1226
|
+
? editError.message
|
|
1227
|
+
: null;
|
|
1228
|
+
// #453: a `switch` editable column IS always its own
|
|
1229
|
+
// live widget -- flipping the toggle IS the commit
|
|
1230
|
+
// gesture, there's no meaningfully different "rest"
|
|
1231
|
+
// display for a boolean. `select`/`reference` are
|
|
1232
|
+
// NOT this: at rest they must show the column's own
|
|
1233
|
+
// render/format like every other editable type
|
|
1234
|
+
// (#412 empty handling included), and only open on
|
|
1235
|
+
// double-click (#411) -- this branch previously
|
|
1236
|
+
// rendered them unconditionally, replacing every
|
|
1237
|
+
// row's own render with a picker trigger, which is
|
|
1238
|
+
// the regression #453 reported (also #432/#449,
|
|
1239
|
+
// which never had a "rest" state of their own to get
|
|
1240
|
+
// right). See the `isEditing` block below for their
|
|
1241
|
+
// actual editor.
|
|
1242
|
+
if (col.editable && col.editor === "switch") {
|
|
1243
|
+
return (_jsxs(TableCell, { align: alignFor(col), "aria-colindex": colAriaIndex(i), ...sticky, className: "crisp-datatable-td--picker", "data-pending": isPending ? "" : undefined, children: [_jsxs("span", { className: "crisp-datatable-edit-picker", children: [_jsx(Switch, { "aria-label": `Edit ${col.key}`, checked: value === true || value === "true", disabled: isPending, onCheckedChange: (checked) => commitEdit(row.id, col.key, String(checked)) }), isPending && (_jsx(Spinner, { size: 14, className: "crisp-datatable-edit-pending" }))] }), errorHere && (_jsx("span", { className: "crisp-datatable-edit-error", children: errorHere }))] }, col.key));
|
|
1244
|
+
}
|
|
1245
|
+
if (isEditing &&
|
|
1246
|
+
(col.editor === "select" || col.editor === "reference")) {
|
|
1247
|
+
return (_jsxs(TableCell, { align: alignFor(col), "aria-colindex": colAriaIndex(i), ...sticky, className: "crisp-datatable-td--picker", "data-pending": isPending ? "" : undefined,
|
|
1248
|
+
// Same as the plain text editor's own cell:
|
|
1249
|
+
// opening/using the picker must not also fire
|
|
1250
|
+
// the row's onRowClick. Escape is handled by
|
|
1251
|
+
// `DataSelectEditor`/`DataReferenceEditor`
|
|
1252
|
+
// themselves (a document-level listener, not a
|
|
1253
|
+
// `TableCell`-level `onKeyDown`) -- opening the
|
|
1254
|
+
// picker moves DOM focus into a floating,
|
|
1255
|
+
// portaled popover, so a handler here can't
|
|
1256
|
+
// rely on the key event ever bubbling through
|
|
1257
|
+
// this element (#453).
|
|
1258
|
+
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));
|
|
1259
|
+
}
|
|
1260
|
+
if (isEditing) {
|
|
1261
|
+
return (_jsxs(TableCell, { align: alignFor(col), "aria-colindex": colAriaIndex(i), ...sticky, onClick: (e) => e.stopPropagation(), children: [_jsx("input", { className: "crisp-datatable-edit",
|
|
1262
|
+
// biome-ignore lint/a11y/noAutofocus: focus the field the moment it opens
|
|
1263
|
+
autoFocus: true, ...editorFor(col), "data-editor": editorFor(col).type, disabled: isPending, defaultValue: col.type === "date"
|
|
1264
|
+
? toDateInputValue(value)
|
|
1265
|
+
: col.type === "datetime"
|
|
1266
|
+
? toDateTimeInputValue(value)
|
|
1267
|
+
: asText(value), "aria-label": `Edit ${col.key}`, onKeyDown: (e) => {
|
|
1268
|
+
if (isPending)
|
|
1269
|
+
return;
|
|
1270
|
+
if (e.key === "Enter")
|
|
1271
|
+
commitEdit(row.id, col.key, e.currentTarget.value);
|
|
1272
|
+
else if (e.key === "Escape")
|
|
1273
|
+
setEditing(null);
|
|
1274
|
+
}, onBlur: (e) => {
|
|
1275
|
+
if (isPending)
|
|
1276
|
+
return;
|
|
900
1277
|
commitEdit(row.id, col.key, e.currentTarget.value);
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
1278
|
+
} }), isPending && (_jsx(Spinner, { size: 14, className: "crisp-datatable-edit-pending" }))] }, col.key));
|
|
1279
|
+
}
|
|
1280
|
+
return (_jsxs(TableCell, { align: alignFor(col), "aria-colindex": colAriaIndex(i), ...sticky, className: col.editable
|
|
1281
|
+
? "crisp-datatable-editable"
|
|
1282
|
+
: undefined, "data-selected": selCell?.id === row.id && selCell?.key === col.key
|
|
1283
|
+
? ""
|
|
1284
|
+
: undefined, onClick: () => {
|
|
1285
|
+
// the keyboard model's anchor -- a single click
|
|
1286
|
+
// only selects. A genuine double-click (or Enter
|
|
1287
|
+
// on the selected cell) opens the editor (#411),
|
|
1288
|
+
// matching RecordsTable's own onDoubleClick.
|
|
1289
|
+
setSelCell({ id: row.id, key: col.key });
|
|
1290
|
+
gridRef.current?.focus({ preventScroll: true });
|
|
1291
|
+
}, onDoubleClick: col.editable
|
|
1292
|
+
? () => openEditing(row.id, col.key)
|
|
1293
|
+
: undefined, children: [col.render
|
|
1294
|
+
? col.render(value, row)
|
|
1295
|
+
: renderTyped(col, value, avatarTone, row, col.format?.(value, row), locale), errorHere && (_jsx("span", { className: "crisp-datatable-edit-error", children: errorHere }))] }, col.key));
|
|
1296
|
+
})] }, row.id));
|
|
1297
|
+
}), bottomSpacerHeight > 0 && (_jsx("tr", { "aria-hidden": "true", children: _jsx("td", { colSpan: totalCols, style: {
|
|
1298
|
+
height: bottomSpacerHeight,
|
|
1299
|
+
padding: 0,
|
|
1300
|
+
border: "none",
|
|
1301
|
+
} }) }))] })) }), (hasSummary || onAddRow) && (_jsxs(TableFooter, { children: [hasSummary && (_jsxs(TableRow, { children: [showGrip && _jsx(TableCell, { ...stickyCol(-2) }), selectable && _jsx(TableCell, { ...stickyCol(-1) }), colSlots.map((slot) => slot.type === "spacer" ? (_jsx("td", { role: "presentation", "aria-hidden": "true", style: { padding: 0, border: "none" } }, `__spacer_${slot.side}`)) : (_jsx(TableCell, { align: alignFor(columns[slot.i]), "aria-colindex": colAriaIndex(slot.i), ...dataStickyCol(slot.i), children: columns[slot.i].summary
|
|
1302
|
+
? columns[slot.i].summary?.(rows)
|
|
1303
|
+
: null }, columns[slot.i].key)))] })), onAddRow && (_jsx(TableRow, { children: _jsx(TableCell, { className: "crisp-datatable-addcell", colSpan: totalCols, children: _jsxs("button", { type: "button", className: "crisp-datatable-addrow", onClick: onAddRow, children: [_jsx(Plus, { size: 15, strokeWidth: 1.75, "aria-hidden": "true" }), addRowLabel] }) }) }))] }))] })] }));
|
|
926
1304
|
}
|
|
927
1305
|
DataTable.displayName = "DataTable";
|