@mendylanda/ui 0.3.0-alpha.0 → 0.3.0-alpha.10

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.
Files changed (55) hide show
  1. package/README.md +20 -3
  2. package/dist/class-names.js +1 -1
  3. package/dist/filters/filter-bar.d.ts +1 -1
  4. package/dist/filters/filter-bar.js +53 -12
  5. package/dist/filters/filter-menu-focus.d.ts +1 -1
  6. package/dist/filters/filter-menu-panel.d.ts +1 -1
  7. package/dist/filters/use-menu-placement.d.ts +1 -1
  8. package/dist/primitives/popover.d.ts +5 -0
  9. package/dist/primitives/popover.js +10 -0
  10. package/dist/styles.css +153 -27
  11. package/dist/styles.tailwind3.css +153 -27
  12. package/dist/table/clipboard.d.ts +1 -1
  13. package/dist/table/clipboard.js +35 -22
  14. package/dist/table/column-widths.d.ts +9 -0
  15. package/dist/table/column-widths.js +23 -0
  16. package/dist/table/columns.d.ts +6 -0
  17. package/dist/table/columns.js +4 -0
  18. package/dist/table/data-table.d.ts +3 -0
  19. package/dist/table/data-table.js +6 -3
  20. package/dist/table/index.d.ts +1 -0
  21. package/dist/table/index.js +1 -0
  22. package/dist/table/table-column-settings.d.ts +6 -0
  23. package/dist/table/table-column-settings.js +55 -0
  24. package/dist/table/table-controls.d.ts +1 -3
  25. package/dist/table/table-controls.js +4 -41
  26. package/dist/table/table-feedback.d.ts +5 -1
  27. package/dist/table/table-feedback.js +5 -4
  28. package/dist/table/table-filters.d.ts +8 -0
  29. package/dist/table/table-filters.js +9 -0
  30. package/dist/table/table-frame.d.ts +10 -0
  31. package/dist/table/table-frame.js +10 -0
  32. package/dist/table/table-layout.d.ts +44 -0
  33. package/dist/table/table-layout.js +68 -0
  34. package/dist/table/table-loading.d.ts +12 -0
  35. package/dist/table/table-loading.js +6 -0
  36. package/dist/table/table-parts.d.ts +20 -2
  37. package/dist/table/table-parts.js +91 -37
  38. package/dist/table/table-view.d.ts +19 -2
  39. package/dist/table/table-view.js +60 -53
  40. package/dist/table/use-column-window.d.ts +42 -0
  41. package/dist/table/use-column-window.js +65 -0
  42. package/dist/table/use-data-table.d.ts +2 -2
  43. package/dist/table/use-data-table.js +15 -2
  44. package/dist/table/use-fill-layout.d.ts +2 -0
  45. package/dist/table/use-fill-layout.js +67 -0
  46. package/dist/table/use-inline-row-selection.d.ts +10 -0
  47. package/dist/table/use-inline-row-selection.js +33 -0
  48. package/dist/table/use-stable-array.d.ts +3 -0
  49. package/dist/table/use-stable-array.js +16 -0
  50. package/dist/table/use-table-interaction.d.ts +1 -1
  51. package/dist/table/use-table-interaction.js +32 -3
  52. package/dist/table/use-table-results.d.ts +8 -0
  53. package/dist/table/use-table-results.js +63 -0
  54. package/dist/utils.js +13 -2
  55. package/package.json +9 -8
@@ -1,14 +1,24 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { useCallback, useEffect, useLayoutEffect, useRef } from "react";
3
+ import { Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef } from "react";
4
4
  import { useVirtualizer } from "@tanstack/react-virtual";
5
5
  import { TableHeaderCell, TableBodyRow } from "./table-parts.js";
6
6
  import { Button } from "../primitives/button.js";
7
7
  import { cn } from "../utils.js";
8
+ import { useColumnWindow } from "./use-column-window.js";
8
9
  import { useViewportWidth } from "./use-viewport-width.js";
10
+ import { tableLayout } from "./table-layout.js";
11
+ import { TableLoadingRows } from "./table-loading.js";
9
12
  import { TableInitialState } from "./table-feedback.js";
10
13
  import { useTableInteraction } from "./use-table-interaction.js";
11
- export function TableView({ table, label = "Data table", height = "min(65dvh, 640px)", rowHeight = 44, className, contentClassName, emptyState, loadingState, scrollRef, status = "ready", refreshing, error, retry, loadMore, queryKey, renderHeader, onRowActivate, isRowHighlighted, onCopyError, }) {
14
+ import { useTableResults } from "./use-table-results.js";
15
+ import { useInlineRowSelection } from "./use-inline-row-selection.js";
16
+ import { TableFrame } from "./table-frame.js";
17
+ export function TableView({ table, label = "Data table", height = "auto", layout: layoutMode = "content", footer, rowHeight: rowHeightOption = 44, rowClassName, renderRowDetail, className, contentClassName, emptyState, loadingState, scrollRef, status = "ready", refreshing, error, retry, loadMore, queryKey, filters, renderHeader, onRowClick, onRowActivate, isRowHighlighted, rowSelectionControls = true, onCopyError, }) {
18
+ const fill = layoutMode === "fill";
19
+ const { resultKey, hasFilters, clearFilters } = useTableResults(table, queryKey, filters);
20
+ const autoRowHeight = rowHeightOption === "auto" || Boolean(renderRowDetail);
21
+ const rowHeight = rowHeightOption === "auto" ? 44 : rowHeightOption;
12
22
  const container = useRef(null);
13
23
  const setContainer = useCallback((node) => {
14
24
  container.current = node;
@@ -18,32 +28,33 @@ export function TableView({ table, label = "Data table", height = "min(65dvh, 64
18
28
  scrollRef.current = node;
19
29
  }, [scrollRef]);
20
30
  const rows = table.getRowModel().rows;
21
- const columns = [
22
- ...table.getStartVisibleLeafColumns(),
23
- ...table.getCenterVisibleLeafColumns(),
24
- ...table.getEndVisibleLeafColumns(),
25
- ];
31
+ const getScrollElement = useCallback(() => container.current, []);
32
+ const estimateSize = useCallback(() => rowHeight, [rowHeight]);
33
+ const getItemKey = useCallback((index) => rows[index]?.id ?? index, [rows]);
26
34
  const virtual = useVirtualizer({
27
35
  count: rows.length,
28
- getScrollElement: () => container.current,
29
- estimateSize: () => rowHeight,
30
- getItemKey: (index) => rows[index]?.id ?? index,
36
+ getScrollElement,
37
+ estimateSize,
38
+ getItemKey,
31
39
  overscan: 8,
32
40
  });
33
41
  const viewportWidth = useViewportWidth(container);
34
- const totalWidth = columns.reduce((sum, column) => sum + column.getSize(), 0);
35
- const pinnedWidth = [
36
- ...table.getStartVisibleLeafColumns(),
37
- ...table.getEndVisibleLeafColumns(),
38
- ].reduce((sum, column) => sum + column.getSize(), 0);
39
- // Preserve stored pins, but leave room to reach the middle columns on narrow screens.
40
- const pinningActive = (viewportWidth ?? Infinity) >= Math.min(totalWidth, pinnedWidth + 120);
42
+ const startColumns = table.getStartVisibleLeafColumns();
43
+ const centerColumns = table.getCenterVisibleLeafColumns();
44
+ const endColumns = table.getEndVisibleLeafColumns();
45
+ const sizing = table.state.columnSizing;
46
+ const layout = useMemo(() => tableLayout(table, viewportWidth, rowHeight), [table, startColumns, centerColumns, endColumns, sizing, viewportWidth, rowHeight]);
47
+ const { totalWidth, pinningActive } = layout;
48
+ const { inlineSelection, selectLoaded } = useInlineRowSelection(table, layout.columns, rowSelectionControls);
49
+ const focused = table.getFocusedCell();
50
+ const { columns, columnGaps, cellStyle } = useColumnWindow(layout, container, viewportWidth, focused?.column.id, !autoRowHeight);
51
+ const columnIndexes = useMemo(() => new Map(layout.columns.map((column, index) => [column.id, index])), [layout.columns]);
41
52
  const { announcement, copied, onKeyDown } = useTableInteraction({
42
53
  table,
43
54
  pinningActive,
44
55
  container,
45
- queryKey,
46
- onRowActivate,
56
+ queryKey: resultKey,
57
+ onRowActivate: onRowActivate ?? onRowClick,
47
58
  onCopyError,
48
59
  scrollToIndex: (index) => virtual.scrollToIndex(index, { align: "auto" }),
49
60
  });
@@ -54,57 +65,53 @@ export function TableView({ table, label = "Data table", height = "min(65dvh, 64
54
65
  useLayoutEffect(() => {
55
66
  load.current = loadMore;
56
67
  }, [loadMore]);
68
+ useEffect(() => {
69
+ requested.current = null;
70
+ }, [resultKey]);
57
71
  useEffect(() => {
58
72
  const data = load.current;
59
- const requestKey = `${queryKey}:${rows.length}`;
60
73
  if (!data?.available ||
61
74
  data.loading ||
62
75
  data.error ||
63
76
  last < rows.length - 10 ||
64
- requested.current === requestKey)
77
+ (requested.current?.key === resultKey && requested.current.count === rows.length))
65
78
  return;
66
- requested.current = requestKey;
79
+ const element = container.current;
80
+ // Only measure near the end when another page can actually be requested.
81
+ // Reading clientHeight on every scroll otherwise forces layout needlessly.
82
+ const nearEnd = element &&
83
+ element.scrollTop + element.clientHeight + rowHeight * 10 >=
84
+ virtual.getTotalSize() + rowHeight;
85
+ if (!nearEnd)
86
+ return;
87
+ requested.current = { key: resultKey, count: rows.length };
67
88
  Promise.resolve()
68
89
  .then(() => data.load())
69
90
  .catch(() => {
70
91
  requested.current = null;
71
92
  });
72
- }, [last, rows.length, queryKey, loadMore?.available, loadMore?.loading, loadMore?.error]);
73
- const positions = new Map();
74
- let offset = 0;
75
- for (const column of table.getStartVisibleLeafColumns()) {
76
- positions.set(column.id, { side: "left", offset });
77
- offset += column.getSize();
78
- }
79
- offset = 0;
80
- for (const column of [...table.getEndVisibleLeafColumns()].reverse()) {
81
- positions.set(column.id, { side: "right", offset });
82
- offset += column.getSize();
83
- }
84
- function cellStyle(column) {
85
- const pin = pinningActive ? positions.get(column.id) : undefined;
86
- return {
87
- width: column.getSize(),
88
- minWidth: column.getSize(),
89
- height: rowHeight,
90
- ...(column.id === table.getEndVisibleLeafColumns()[0]?.id
91
- ? { marginInlineStart: "auto" }
92
- : {}),
93
- ...(pin ? { position: "sticky", [pin.side]: pin.offset, zIndex: 2 } : {}),
94
- };
95
- }
96
- const focused = table.getFocusedCell();
93
+ }, [
94
+ last,
95
+ rows.length,
96
+ rowHeight,
97
+ resultKey,
98
+ loadMore?.available,
99
+ loadMore?.loading,
100
+ loadMore?.error,
101
+ virtual,
102
+ ]);
97
103
  const headersById = new Map(table.getFlatHeaders().map((header) => [header.column.id, header]));
98
- return (_jsxs("div", { "data-mendy-ui": "", className: "mui-184ddc11e5f9", children: [_jsx("div", { "aria-live": "polite", className: "mui-32fb090591d9", children: announcement }), _jsxs("div", { ref: setContainer, role: "grid", "aria-label": label, "aria-rowcount": rows.length + 1, "aria-colcount": columns.length, "aria-busy": status === "loading" || refreshing, style: { height }, className: cn("mui-d2d9e1f13413 mui-014aadadffad mui-1a26a0d28420 mui-3fa8c572949b mui-4f1a55de40bc mui-5b272f3c5076 mui-c74ab393b96d mui-b30fc56058b6", className), onKeyDown: onKeyDown, children: [_jsx("div", { role: "row", "aria-rowindex": 1, className: "mui-964a9431ff49 mui-98599e4ee250 mui-4e8f0a87a0dc mui-222f930b8752 mui-2bf6510f1330 mui-bbe39cfb5cc6 mui-5b272f3c5076", style: { width: totalWidth }, children: columns.map((column, index) => (_jsx(TableHeaderCell, { table: table, header: headersById.get(column.id), index: index, style: cellStyle(column), renderHeader: renderHeader, contentClassName: contentClassName }, column.id))) }), _jsx(TableInitialState, { status: status, hasRows: rows.length > 0, loadingState: loadingState, emptyState: emptyState, error: error, retry: retry }), _jsx("div", { role: "rowgroup", className: "mui-d2d9e1f13413 mui-2bf6510f1330", style: { height: virtual.getTotalSize(), width: totalWidth }, children: items.map((item) => (_jsx(TableBodyRow, { row: rows[item.index], rowIndex: item.index, start: item.start, rowHeight: rowHeight, cellStyle: cellStyle, focusedId: focused?.id, copied: copied, contentClassName: contentClassName, onRowActivate: onRowActivate, isRowHighlighted: isRowHighlighted }, item.key))) }), rows.length > 0 && status === "error" && (_jsxs("div", { role: "alert", className: "mui-964a9431ff49 mui-30150dd033ab mui-5b272f3c5076 mui-094f5333853b", children: [error ?? "Could not refresh rows.", retry && (_jsx(Button, { onClick: retry, variant: "outline", size: "sm", children: "Retry" }))] })), loadMore?.available && (_jsxs("div", { className: "mui-964a9431ff49 mui-635702706586 mui-222f930b8752 mui-a503dd374cca mui-b97db4a9f432", children: [_jsx(Button, { size: "sm", variant: "ghost", disabled: loadMore.loading, onClick: () => {
104
+ return (_jsxs(TableFrame, { layout: layoutMode, stretch: height === "100%", footer: footer, children: [_jsx("div", { "aria-live": "polite", className: "mui-32fb090591d9", children: announcement }), _jsxs("div", { ref: setContainer, role: "grid", tabIndex: -1, "aria-label": label, "aria-rowcount": renderRowDetail ? -1 : rows.length + 1, "aria-colcount": layout.columns.length, "aria-busy": status === "loading" || refreshing, style: {
105
+ height: fill || height === "auto" ? undefined : height,
106
+ maxHeight: !fill && height === "auto" ? "65dvh" : undefined,
107
+ }, className: cn("mui-d2d9e1f13413 mui-014aadadffad mui-1a26a0d28420 mui-3fa8c572949b mui-4f1a55de40bc mui-5b272f3c5076 mui-c74ab393b96d mui-b30fc56058b6", fill && "mui-410da8dfa8ac mui-7bd5bab6d7f4", className), onKeyDown: onKeyDown, children: [_jsx("div", { role: "row", "aria-rowindex": 1, className: "mui-964a9431ff49 mui-98599e4ee250 mui-4e8f0a87a0dc mui-222f930b8752 mui-2bf6510f1330 mui-bbe39cfb5cc6 mui-5b272f3c5076", style: { width: totalWidth }, children: columns.map((column) => (_jsxs(Fragment, { children: [columnGaps.has(column.id) && (_jsx("div", { "aria-hidden": "true", className: "mui-27ead27a81df", style: { width: columnGaps.get(column.id) } })), _jsx(TableHeaderCell, { table: table, header: headersById.get(column.id), index: columnIndexes.get(column.id), style: cellStyle(column), renderHeader: renderHeader, contentClassName: contentClassName, selectLoaded: selectLoaded }, column.id)] }, column.id))) }), _jsx(TableInitialState, { status: status, hasRows: rows.length > 0, loadingState: loadingState ?? (_jsx(TableLoadingRows, { columns: columns, columnGaps: columnGaps, width: totalWidth, rowHeight: rowHeight, cellStyle: cellStyle, count: !fill && height === "auto"
108
+ ? 8
109
+ : Math.max(1, Math.ceil(((virtual.scrollRect?.height ?? 400) - rowHeight) / rowHeight)) })), emptyState: emptyState, hasFilters: hasFilters, pageIndex: table.state.pagination.pageIndex, clearFilters: clearFilters, firstPage: () => table.setPageIndex(0), error: error, retry: retry }), _jsx("div", { role: "rowgroup", className: "mui-d2d9e1f13413 mui-2bf6510f1330", style: { height: virtual.getTotalSize(), width: totalWidth }, children: items.map((item) => (_jsx(TableBodyRow, { row: rows[item.index], rowIndex: item.index, start: item.start, rowHeight: rowHeight, autoRowHeight: autoRowHeight, measureElement: autoRowHeight ? virtual.measureElement : undefined, rowClassName: rowClassName, renderRowDetail: renderRowDetail, detailWidth: viewportWidth ?? totalWidth, cellStyle: cellStyle, columns: columns, columnGaps: columnGaps, columnIndexes: columnIndexes, contentVersion: table.options, contentState: table.state, focusedId: focused?.id, copied: copied, contentClassName: contentClassName, onRowClick: onRowClick, onRowActivate: onRowActivate, isRowHighlighted: isRowHighlighted, inlineSelection: inlineSelection }, item.key))) }), rows.length > 0 && status === "error" && (_jsxs("div", { role: "alert", className: "mui-964a9431ff49 mui-30150dd033ab mui-5b272f3c5076 mui-094f5333853b", children: [error ?? "Could not refresh rows.", retry && (_jsx(Button, { onClick: retry, variant: "outline", size: "sm", children: "Retry" }))] })), loadMore?.available && (_jsxs("div", { className: "mui-964a9431ff49 mui-635702706586 mui-222f930b8752 mui-71556df3b421 mui-a503dd374cca mui-074569488cca mui-96b92a971c95 mui-c74ab393b96d mui-35f35c41d134", children: [loadMore.loading ? (_jsxs("div", { role: "status", className: "mui-222f930b8752 mui-71556df3b421 mui-074569488cca", children: [_jsx("svg", { "aria-hidden": "true", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "1.5", strokeLinecap: "round", strokeLinejoin: "round", className: "mui-2bd43fb57913 mui-27ead27a81df mui-2c14b55d77e6 mui-237747164b60", children: _jsx("path", { d: "M12 3v3m6.366-.366-2.12 2.12M21 12h-3m.366 6.366-2.12-2.12M12 21v-3m-6.366.366 2.12-2.12M3 12h3m-.366-6.366 2.12 2.12" }) }), _jsx("span", { children: "Loading more\u2026" })] })) : (_jsx(Button, { size: "sm", variant: "ghost", onClick: () => {
99
110
  requested.current = null;
100
111
  void Promise.resolve()
101
112
  .then(() => loadMore.load())
102
113
  .catch(() => {
103
114
  requested.current = null;
104
115
  });
105
- }, children: loadMore.loading
106
- ? "Loading more…"
107
- : loadMore.error
108
- ? "Retry loading more"
109
- : "Load more" }), loadMore.error && _jsx("span", { role: "alert", children: loadMore.error })] }))] })] }));
116
+ }, children: loadMore.error ? "Retry loading more" : "Load more" })), loadMore.error && _jsx("span", { role: "alert", children: loadMore.error })] }))] })] }));
110
117
  }
@@ -0,0 +1,42 @@
1
+ import type { RefObject } from "react";
2
+ import type { Column } from "@tanstack/react-table";
3
+ import type { DataTableFeatures } from "./features.js";
4
+ import type { tableLayout } from "./table-layout.js";
5
+ /** Keep pinned and focused columns mounted, with two columns of overscan. */
6
+ export declare function useColumnWindow<T extends object>(layout: ReturnType<typeof tableLayout<T>>, container: RefObject<HTMLDivElement | null>, viewportWidth: number | null, focusedColumn?: string, enabled?: boolean): {
7
+ columns: Column<{
8
+ cellSelectionFeature: import("@tanstack/react-table").TableFeature;
9
+ columnResizingFeature: import("@tanstack/react-table").TableFeature;
10
+ columnOrderingFeature: import("@tanstack/react-table").TableFeature;
11
+ columnPinningFeature: import("@tanstack/react-table").TableFeature;
12
+ columnSizingFeature: import("@tanstack/react-table").TableFeature;
13
+ columnVisibilityFeature: import("@tanstack/react-table").TableFeature;
14
+ columnFilteringFeature: import("@tanstack/react-table").TableFeature;
15
+ columnFacetingFeature: import("@tanstack/react-table").TableFeature;
16
+ globalFilteringFeature: import("@tanstack/react-table").TableFeature;
17
+ rowPaginationFeature: import("@tanstack/react-table").TableFeature;
18
+ rowSelectionFeature: import("@tanstack/react-table").TableFeature;
19
+ rowSortingFeature: import("@tanstack/react-table").TableFeature;
20
+ filteredRowModel: (table: import("@tanstack/react-table").Table<any, any>) => () => import("@tanstack/react-table").RowModel<any, any>;
21
+ sortedRowModel: (table: import("@tanstack/react-table").Table<any, any>) => () => import("@tanstack/react-table").RowModel<any, any>;
22
+ paginatedRowModel: (table: import("@tanstack/react-table").Table<any, any>) => () => import("@tanstack/react-table").RowModel<any, any>;
23
+ facetedRowModel: (table: import("@tanstack/react-table").Table<any, any>, columnId: string) => () => import("@tanstack/react-table").RowModel<any, any>;
24
+ facetedUniqueValues: (table: import("@tanstack/react-table").Table<import("@tanstack/react-table").TableFeatures, any>, columnId: string) => () => Map<any, number>;
25
+ filterFns: {
26
+ includesString: import("@tanstack/react-table").CreatedFilterFn<any, any>;
27
+ inNumberRange: import("@tanstack/react-table").CreatedFilterFn<any, any>;
28
+ inDateRange: import("@tanstack/react-table").CreatedFilterFn<any, any>;
29
+ equals: import("@tanstack/react-table").CreatedFilterFn<any, any>;
30
+ arrIncludes: import("@tanstack/react-table").CreatedFilterFn<any, any>;
31
+ weakEquals: import("@tanstack/react-table").CreatedFilterFn<any, any>;
32
+ };
33
+ sortFns: {
34
+ alphanumeric: import("@tanstack/react-table").CreatedSortFn<any, any>;
35
+ basic: import("@tanstack/react-table").CreatedSortFn<any, any>;
36
+ datetime: import("@tanstack/react-table").CreatedSortFn<any, any>;
37
+ text: import("@tanstack/react-table").CreatedSortFn<any, any>;
38
+ };
39
+ }, T>[];
40
+ columnGaps: Map<string, number>;
41
+ cellStyle: (column: Column<DataTableFeatures, T>) => import("react").CSSProperties;
42
+ };
@@ -0,0 +1,65 @@
1
+ "use client";
2
+ import { useLayoutEffect, useMemo, useState } from "react";
3
+ /** Keep pinned and focused columns mounted, with two columns of overscan. */
4
+ export function useColumnWindow(layout, container, viewportWidth, focusedColumn, enabled = true) {
5
+ const [scrollLeft, setScrollLeft] = useState(0);
6
+ const [activeColumn, setActiveColumn] = useState();
7
+ useLayoutEffect(() => {
8
+ const element = container.current;
9
+ if (!element)
10
+ return;
11
+ const rememberFocus = (event) => {
12
+ setActiveColumn(event.target.closest("[data-column-id]")?.dataset.columnId);
13
+ };
14
+ element.addEventListener("focusin", rememberFocus);
15
+ return () => element.removeEventListener("focusin", rememberFocus);
16
+ }, [container]);
17
+ useLayoutEffect(() => {
18
+ const element = container.current;
19
+ if (!element)
20
+ return;
21
+ const measure = () => setScrollLeft(element.scrollLeft);
22
+ measure();
23
+ element.addEventListener("scroll", measure, { passive: true });
24
+ return () => element.removeEventListener("scroll", measure);
25
+ }, [container]);
26
+ const right = scrollLeft + (viewportWidth ?? 0);
27
+ let start = 0;
28
+ let end = layout.columns.length - 1;
29
+ while (start < end && layout.offsets[start + 1] < scrollLeft)
30
+ start++;
31
+ end = start;
32
+ while (end < layout.columns.length - 1 && layout.offsets[end] < right)
33
+ end++;
34
+ start = Math.max(0, start - 2);
35
+ end = Math.min(layout.columns.length - 1, end + 2);
36
+ return useMemo(() => {
37
+ const columns = [];
38
+ const columnGaps = new Map();
39
+ const styles = new Map();
40
+ let previousEnd = 0;
41
+ layout.columns.forEach((column, index) => {
42
+ if (!enabled ||
43
+ (index >= start && index <= end) ||
44
+ (layout.pinningActive && column.getIsPinned()) ||
45
+ column.id === focusedColumn ||
46
+ column.id === activeColumn) {
47
+ columns.push(column);
48
+ const gap = layout.offsets[index] - previousEnd;
49
+ const pinnedEnd = layout.pinningActive && column.getIsPinned() === "end";
50
+ if (gap > 0 && pinnedEnd)
51
+ columnGaps.set(column.id, gap);
52
+ styles.set(column.id, {
53
+ ...layout.cellStyle(column),
54
+ ...(gap > 0 && !pinnedEnd ? { marginInlineStart: gap } : {}),
55
+ });
56
+ previousEnd = layout.offsets[index + 1];
57
+ }
58
+ });
59
+ return {
60
+ columns,
61
+ columnGaps,
62
+ cellStyle: (column) => styles.get(column.id),
63
+ };
64
+ }, [layout, start, end, focusedColumn, activeColumn, enabled]);
65
+ }
@@ -4,7 +4,7 @@ import type { DataTableFeatures } from "./features.js";
4
4
  import type { PreferenceStorage, TablePreferences } from "./state.js";
5
5
  export type DataTableInstance<T extends object> = ReactTable<DataTableFeatures, T>;
6
6
  export interface UseDataTableOptions<T extends object> extends Omit<TableOptions<DataTableFeatures, T>, "features" | "data" | "columns" | "manualFiltering" | "manualSorting" | "manualPagination"> {
7
- rows: T[];
7
+ rows: readonly T[];
8
8
  columns: TableColumn<T>[];
9
9
  getRowId: (row: T, index: number) => string;
10
10
  processing?: {
@@ -21,4 +21,4 @@ export interface UseDataTableOptions<T extends object> extends Omit<TableOptions
21
21
  }
22
22
  export declare function captureTablePreferences<T extends object>(table: DataTableInstance<T>): TablePreferences;
23
23
  export declare function applyTablePreferences<T extends object>(table: DataTableInstance<T>, input: unknown): void;
24
- export declare function useDataTable<T extends object>({ rows, columns, processing, preferences, ...options }: UseDataTableOptions<T>): DataTableInstance<T>;
24
+ export declare function useDataTable<T extends object>({ rows: inputRows, columns: inputColumns, processing, preferences, ...options }: UseDataTableOptions<T>): DataTableInstance<T>;
@@ -2,6 +2,7 @@
2
2
  import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
3
3
  import { useTable } from "@tanstack/react-table";
4
4
  import { tableFeaturesDefault } from "./features.js";
5
+ import { useStableArray } from "./use-stable-array.js";
5
6
  import { reconcilePreferences } from "./state.js";
6
7
  const browserStorage = {
7
8
  read: (key) => {
@@ -26,7 +27,9 @@ export function applyTablePreferences(table, input) {
26
27
  table.setColumnSizing(value.columnSizing);
27
28
  table.setColumnPinning(value.columnPinning);
28
29
  }
29
- export function useDataTable({ rows, columns, processing, preferences, ...options }) {
30
+ export function useDataTable({ rows: inputRows, columns: inputColumns, processing, preferences, ...options }) {
31
+ const rows = useStableArray(inputRows);
32
+ const columns = useStableArray(inputColumns);
30
33
  const defaults = useMemo(() => reconcilePreferences(null, columns, options.initialState), [columns, options.initialState]);
31
34
  const table = useTable({
32
35
  features: tableFeaturesDefault,
@@ -36,6 +39,9 @@ export function useDataTable({ rows, columns, processing, preferences, ...option
36
39
  defaultColumn: { size: 180, minSize: 48, maxSize: 1200, ...options.defaultColumn },
37
40
  columnResizeMode: options.columnResizeMode ?? "onChange",
38
41
  enableCellSelection: options.enableCellSelection ?? true,
42
+ enableRowSelection: options.enableRowSelection ??
43
+ (Boolean(options.onRowSelectionChange) ||
44
+ columns.some((column) => column.id === "_selection")),
39
45
  enableColumnPinning: options.enableColumnPinning ?? true,
40
46
  autoResetCellSelection: options.autoResetCellSelection ?? false,
41
47
  manualFiltering: processing?.filtering === "external",
@@ -82,7 +88,14 @@ export function useDataTable({ rows, columns, processing, preferences, ...option
82
88
  active = false;
83
89
  };
84
90
  }, [key, storage]);
85
- const serialized = JSON.stringify(captureTablePreferences(table));
91
+ const { columnOrder, columnPinning, columnSizing, columnVisibility } = table.state;
92
+ const serialized = useMemo(() => JSON.stringify({
93
+ version: 1,
94
+ columnOrder,
95
+ columnPinning,
96
+ columnSizing,
97
+ columnVisibility,
98
+ }), [columnOrder, columnPinning, columnSizing, columnVisibility]);
86
99
  useEffect(() => {
87
100
  if (!key || restoredKey !== key)
88
101
  return;
@@ -0,0 +1,2 @@
1
+ /** Size the containing frame, not the grid, so controls share its available height. */
2
+ export declare function useFillLayout(enabled: boolean): (node: HTMLDivElement | null) => void;
@@ -0,0 +1,67 @@
1
+ "use client";
2
+ import { useCallback, useRef } from "react";
3
+ /** Size the containing frame, not the grid, so controls share its available height. */
4
+ export function useFillLayout(enabled) {
5
+ const dispose = useRef(undefined);
6
+ return useCallback((node) => {
7
+ dispose.current?.();
8
+ dispose.current = undefined;
9
+ if (!node || !enabled)
10
+ return;
11
+ let frame = 0;
12
+ let previous = -1;
13
+ const measure = () => {
14
+ frame = 0;
15
+ let inset = 0;
16
+ let ancestor = node.parentElement;
17
+ while (ancestor) {
18
+ const style = getComputedStyle(ancestor);
19
+ inset += parseFloat(style.paddingBottom) || 0;
20
+ inset += parseFloat(style.borderBottomWidth) || 0;
21
+ ancestor = ancestor.parentElement;
22
+ }
23
+ const height = Math.max(0, window.innerHeight - node.getBoundingClientRect().top - inset);
24
+ if (Math.abs(height - previous) < 0.5)
25
+ return;
26
+ previous = height;
27
+ node.style.height = `${height}px`;
28
+ };
29
+ const schedule = () => {
30
+ if (!frame)
31
+ frame = requestAnimationFrame(measure);
32
+ };
33
+ const observer = new ResizeObserver(schedule);
34
+ const observe = () => {
35
+ observer.disconnect();
36
+ let current = node;
37
+ while (current?.parentElement) {
38
+ observer.observe(current.parentElement);
39
+ let sibling = current.previousElementSibling;
40
+ while (sibling) {
41
+ observer.observe(sibling);
42
+ sibling = sibling.previousElementSibling;
43
+ }
44
+ current = current.parentElement;
45
+ }
46
+ schedule();
47
+ };
48
+ // Newly inserted preceding siblings can move a table inside a fixed-height parent.
49
+ const mutations = new MutationObserver(observe);
50
+ let parent = node.parentElement;
51
+ while (parent) {
52
+ mutations.observe(parent, { childList: true });
53
+ parent = parent.parentElement;
54
+ }
55
+ measure();
56
+ observe();
57
+ window.addEventListener("resize", schedule);
58
+ dispose.current = () => {
59
+ observer.disconnect();
60
+ mutations.disconnect();
61
+ window.removeEventListener("resize", schedule);
62
+ cancelAnimationFrame(frame);
63
+ if (node.style.height === `${previous}px`)
64
+ node.style.removeProperty("height");
65
+ };
66
+ }, [enabled]);
67
+ }
@@ -0,0 +1,10 @@
1
+ import type { Column } from "@tanstack/react-table";
2
+ import type { DataTableFeatures } from "./features.js";
3
+ import type { DataTableInstance } from "./use-data-table.js";
4
+ export declare function useInlineRowSelection<T extends object>(table: DataTableInstance<T>, columns: Column<DataTableFeatures, T>[], controls: boolean): {
5
+ inlineSelection: boolean;
6
+ selectLoaded: {
7
+ checked: boolean | "indeterminate";
8
+ disabled: boolean;
9
+ } | undefined;
10
+ };
@@ -0,0 +1,33 @@
1
+ import { useMemo } from "react";
2
+ export function useInlineRowSelection(table, columns, controls) {
3
+ const rows = table.getRowModel().rows;
4
+ const inlineSelection = Boolean(controls &&
5
+ table.options.enableRowSelection &&
6
+ !columns.some((column) => column.id === "_selection"));
7
+ const rowSelection = table.state.rowSelection;
8
+ const enableRowSelection = table.options.enableRowSelection;
9
+ const enableMultiRowSelection = table.options.enableMultiRowSelection;
10
+ const selectLoadedAvailability = useMemo(() => {
11
+ if (!inlineSelection || enableMultiRowSelection === false)
12
+ return undefined;
13
+ let hasSelectableRow = false;
14
+ for (const row of rows) {
15
+ if (!row.getCanSelect())
16
+ continue;
17
+ hasSelectableRow = true;
18
+ if (!row.getCanMultiSelect())
19
+ return undefined;
20
+ }
21
+ return { disabled: !hasSelectableRow };
22
+ }, [rows, inlineSelection, enableRowSelection, enableMultiRowSelection]);
23
+ const selectLoaded = useMemo(() => {
24
+ if (!selectLoadedAvailability)
25
+ return undefined;
26
+ return {
27
+ checked: table.getIsAllPageRowsSelected() ||
28
+ (table.getIsSomePageRowsSelected() ? "indeterminate" : false),
29
+ disabled: selectLoadedAvailability.disabled,
30
+ };
31
+ }, [table, rowSelection, selectLoadedAvailability]);
32
+ return { inlineSelection, selectLoaded };
33
+ }
@@ -0,0 +1,3 @@
1
+ /** Reuse equal array shells; never deep-compare records or ignore changed callbacks. */
2
+ export declare function useStableArray<T>(input: T[]): T[];
3
+ export declare function useStableArray<T>(input: readonly T[]): readonly T[];
@@ -0,0 +1,16 @@
1
+ "use client";
2
+ import { useLayoutEffect, useMemo, useRef } from "react";
3
+ export function useStableArray(input) {
4
+ const committed = useRef(input);
5
+ const value = useMemo(() => {
6
+ const previous = committed.current;
7
+ return input.length === previous.length &&
8
+ input.every((item, index) => item === previous[index])
9
+ ? previous
10
+ : input;
11
+ }, [input]);
12
+ useLayoutEffect(() => {
13
+ committed.current = value;
14
+ }, [value]);
15
+ return value;
16
+ }
@@ -4,7 +4,7 @@ export declare function useTableInteraction<T extends object>({ table, pinningAc
4
4
  table: DataTableInstance<T>;
5
5
  pinningActive: boolean;
6
6
  container: RefObject<HTMLDivElement | null>;
7
- queryKey?: string;
7
+ queryKey?: unknown;
8
8
  onRowActivate?: (row: T) => void;
9
9
  onCopyError?: (error: unknown) => void;
10
10
  scrollToIndex: (index: number) => void;
@@ -1,5 +1,5 @@
1
1
  "use client";
2
- import { useEffect, useRef, useState } from "react";
2
+ import { useEffect, useLayoutEffect, useRef, useState } from "react";
3
3
  import { interactiveSelector, selectedCellsText } from "./clipboard.js";
4
4
  export function useTableInteraction({ table, pinningActive, container, queryKey, onRowActivate, onCopyError, scrollToIndex, }) {
5
5
  const rows = table.getRowModel().rows;
@@ -13,6 +13,32 @@ export function useTableInteraction({ table, pinningActive, container, queryKey,
13
13
  const [copied, setCopied] = useState(false);
14
14
  const copyTimer = useRef(undefined);
15
15
  const mounted = useRef(true);
16
+ const ownedFocus = useRef(null);
17
+ useEffect(() => {
18
+ const element = container.current;
19
+ if (!element)
20
+ return;
21
+ const remember = (event) => {
22
+ const target = event.target;
23
+ ownedFocus.current =
24
+ target.closest('[role="grid"]') === element && target.getAttribute("role") === "gridcell"
25
+ ? target
26
+ : null;
27
+ };
28
+ document.addEventListener("focusin", remember);
29
+ return () => document.removeEventListener("focusin", remember);
30
+ }, [container]);
31
+ useLayoutEffect(() => {
32
+ // A virtual row can unmount the focused cell without a blur event. Keep
33
+ // keyboard commands on the grid rather than silently returning them to body.
34
+ if (ownedFocus.current &&
35
+ !ownedFocus.current.isConnected &&
36
+ document.activeElement === document.body &&
37
+ table.getSelectedCellCount()) {
38
+ ownedFocus.current = null;
39
+ container.current?.focus({ preventScroll: true });
40
+ }
41
+ });
16
42
  useEffect(() => {
17
43
  mounted.current = true;
18
44
  return () => {
@@ -28,7 +54,8 @@ export function useTableInteraction({ table, pinningActive, container, queryKey,
28
54
  }, [queryKey, resetCellSelection, container]);
29
55
  useEffect(() => {
30
56
  const dismiss = (event) => {
31
- if (container.current && !container.current.contains(event.target))
57
+ if (container.current &&
58
+ event.target.closest('[role="grid"]') !== container.current)
32
59
  resetCellSelection(true);
33
60
  };
34
61
  document.addEventListener("pointerdown", dismiss);
@@ -78,7 +105,9 @@ export function useTableInteraction({ table, pinningActive, container, queryKey,
78
105
  });
79
106
  }
80
107
  const onKeyDown = (event) => {
81
- if (event.target.closest(interactiveSelector))
108
+ if (event.defaultPrevented ||
109
+ event.target.closest('[role="grid"]') !== event.currentTarget ||
110
+ event.target.closest(interactiveSelector))
82
111
  return;
83
112
  if ((event.ctrlKey || event.metaKey) &&
84
113
  event.key.toLowerCase() === "c" &&
@@ -0,0 +1,8 @@
1
+ import type { FilterController } from "../filters/use-filters.js";
2
+ import type { DataTableInstance } from "./use-data-table.js";
3
+ /** Only query state contributes to this key. Row appends and UI state never do. */
4
+ export declare function useTableResults<T extends object>(table: DataTableInstance<T>, queryKey?: string, filters?: FilterController): {
5
+ resultKey: string | any[];
6
+ hasFilters: boolean;
7
+ clearFilters: (() => void) | undefined;
8
+ };
@@ -0,0 +1,63 @@
1
+ "use client";
2
+ import { useMemo } from "react";
3
+ function hasValue(value) {
4
+ if (value == null || value === "")
5
+ return false;
6
+ if (Array.isArray(value))
7
+ return value.some((item) => item != null && item !== "");
8
+ return true;
9
+ }
10
+ /** Only query state contributes to this key. Row appends and UI state never do. */
11
+ export function useTableResults(table, queryKey, filters) {
12
+ const { sorting, columnFilters, globalFilter, pagination } = table.state;
13
+ const entries = filters?.entries;
14
+ const search = filters?.search;
15
+ const resultKey = useMemo(() => {
16
+ const state = [
17
+ queryKey,
18
+ sorting,
19
+ columnFilters,
20
+ globalFilter,
21
+ pagination.pageIndex,
22
+ pagination.pageSize,
23
+ search,
24
+ ];
25
+ try {
26
+ return JSON.stringify([...state, entries?.map(({ id, field, value }) => [id, field.codec.serialize(value)])], (_key, value) => (typeof value === "bigint" ? { $bigint: String(value) } : value));
27
+ }
28
+ catch {
29
+ // Opaque application values may not be serializable. In that case their
30
+ // immutable references drive changes, without crashing table rendering.
31
+ return [...state, entries];
32
+ }
33
+ }, [
34
+ queryKey,
35
+ sorting,
36
+ columnFilters,
37
+ globalFilter,
38
+ pagination.pageIndex,
39
+ pagination.pageSize,
40
+ search,
41
+ entries,
42
+ ]);
43
+ const hasFilters = Boolean(columnFilters.some(({ value }) => hasValue(value)) ||
44
+ hasValue(globalFilter) ||
45
+ search?.trim() ||
46
+ filters?.active.length);
47
+ const clearFilters = () => {
48
+ if (filters)
49
+ filters.clear();
50
+ else {
51
+ if (columnFilters.length)
52
+ table.setColumnFilters([]);
53
+ if (hasValue(globalFilter))
54
+ table.setGlobalFilter(undefined);
55
+ if (pagination.pageIndex)
56
+ table.setPageIndex(0);
57
+ }
58
+ };
59
+ const canClear = !filters ||
60
+ Boolean(search?.trim()) ||
61
+ filters.entries.some(({ field, value }) => !field.hidden && !field.disabled && field.removable !== false && field.isActive(value));
62
+ return { resultKey, hasFilters, clearFilters: canClear ? clearFilters : undefined };
63
+ }