@ciridae/ui 0.3.7 → 0.5.0

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.
@@ -0,0 +1,23 @@
1
+ import * as React from "react";
2
+ import type { RowData } from "@tanstack/react-table";
3
+ declare module "@tanstack/react-table" {
4
+ interface ColumnMeta<TData extends RowData, TValue> {
5
+ /** What to call this column where its header cannot be read as text. */
6
+ label?: string;
7
+ }
8
+ }
9
+ /**
10
+ * Choose which columns appear and in what order.
11
+ *
12
+ * A draft the reader commits rather than a live edit. Rearranging a table is
13
+ * several changes that only mean something together, and applying each one as
14
+ * it is made redraws the table under the hand still working on it. Cancel has
15
+ * to be able to mean "none of that", so nothing here touches the table until
16
+ * Apply.
17
+ */
18
+ declare function DataTableColumnSettings({ description, title, triggerLabel, }: {
19
+ description?: string;
20
+ title?: string;
21
+ triggerLabel?: string;
22
+ }): React.JSX.Element | null;
23
+ export { DataTableColumnSettings };
@@ -0,0 +1,170 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import * as React from "react";
3
+ import { closestCenter, DndContext, KeyboardSensor, PointerSensor, useSensor, useSensors, } from "@dnd-kit/core";
4
+ import { arrayMove, SortableContext, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy, } from "@dnd-kit/sortable";
5
+ import { CSS } from "@dnd-kit/utilities";
6
+ import { GripVerticalIcon, SlidersHorizontalIcon } from "lucide-react";
7
+ import { cn } from "../../lib/utils";
8
+ import { useDataTable } from "./data-table-context";
9
+ import { Button } from "./button";
10
+ import { Checkbox } from "./checkbox";
11
+ import { Sheet, SheetBody, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle, SheetTrigger, } from "./sheet";
12
+ /**
13
+ * Choose which columns appear and in what order.
14
+ *
15
+ * A draft the reader commits rather than a live edit. Rearranging a table is
16
+ * several changes that only mean something together, and applying each one as
17
+ * it is made redraws the table under the hand still working on it. Cancel has
18
+ * to be able to mean "none of that", so nothing here touches the table until
19
+ * Apply.
20
+ */
21
+ function DataTableColumnSettings({ description = "Choose which columns appear and drag them into the order you prefer.", title = "Column settings", triggerLabel = "Settings", }) {
22
+ const table = useDataTable();
23
+ const [open, setOpen] = React.useState(false);
24
+ // `null` means "whatever the table is showing"; anything else is an edit in
25
+ // progress. Cleared on every open and close, so a cancelled edit cannot come
26
+ // back the next time the sheet is opened.
27
+ const [draft, setDraft] = React.useState(null);
28
+ const columns = table.getAllLeafColumns();
29
+ // Reordering stands on its own, so a table where nothing may be hidden still
30
+ // has something to offer here.
31
+ if (columns.length === 0)
32
+ return null;
33
+ const current = draft ?? readColumns(table);
34
+ return (_jsxs(Sheet, { onOpenChange: (next) => {
35
+ setDraft(null);
36
+ setOpen(next);
37
+ }, open: open, children: [_jsx(SheetTrigger, { asChild: true, children: _jsxs(Button, { size: "sm", type: "button", variant: "ghost", children: [_jsx(SlidersHorizontalIcon, {}), _jsx("span", { children: triggerLabel })] }) }), _jsxs(SheetContent, { size: "md", children: [_jsxs(SheetHeader, { children: [_jsx(SheetTitle, { children: title }), _jsx(SheetDescription, { children: description })] }), _jsx(SheetBody, { className: "pb-(--panel-padding)", children: _jsx(ColumnDraftList, { columns: columns, draft: current, onDraftChange: setDraft }) }), _jsxs(SheetFooter, { children: [_jsx(Button, { className: "mr-auto", onClick: () => {
38
+ setDraft(readDefaultColumns(table));
39
+ }, type: "button", variant: "ghost", children: "Reset" }), _jsx(Button, { onClick: () => {
40
+ setOpen(false);
41
+ }, type: "button", variant: "outline", children: "Cancel" }), _jsx(Button, { onClick: () => {
42
+ applyColumns(table, current);
43
+ setOpen(false);
44
+ }, type: "button", children: "Apply" })] })] })] }));
45
+ }
46
+ function ColumnDraftList({ columns, draft, onDraftChange, }) {
47
+ const sensors = useSensors(useSensor(PointerSensor), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }));
48
+ const order = draft.order;
49
+ const byId = new Map(columns.map((column) => [column.id, column]));
50
+ const reorder = (event) => {
51
+ const { active, over } = event;
52
+ if (!over || active.id === over.id)
53
+ return;
54
+ const from = order.indexOf(String(active.id));
55
+ const to = order.indexOf(String(over.id));
56
+ if (from === -1 || to === -1)
57
+ return;
58
+ onDraftChange({ ...draft, order: arrayMove(order, from, to) });
59
+ };
60
+ return (_jsx(DndContext, { collisionDetection: closestCenter, onDragEnd: reorder, sensors: sensors, children: _jsx(SortableContext, { items: order, strategy: verticalListSortingStrategy, children: _jsx("ul", { className: "grid gap-0.5", children: order.map((id) => {
61
+ const column = byId.get(id);
62
+ if (column === undefined)
63
+ return null;
64
+ return (_jsx(ColumnSettingsRow, { column: column, hidden: draft.hidden.includes(id), onToggle: (visible) => {
65
+ onDraftChange({
66
+ hidden: visible
67
+ ? draft.hidden.filter((entry) => entry !== id)
68
+ : [...draft.hidden, id],
69
+ order,
70
+ });
71
+ } }, id));
72
+ }) }) }) }));
73
+ }
74
+ function ColumnSettingsRow({ column, hidden, onToggle, }) {
75
+ const label = getColumnLabel(column);
76
+ const { attributes, isDragging, listeners, setNodeRef, transform, transition } = useSortable({
77
+ id: column.id,
78
+ });
79
+ return (_jsxs("li", { className: cn("flex items-center gap-3 rounded-sm px-1 py-2 text-sm", isDragging && "relative z-10 bg-accent"), ref: setNodeRef, style: { transform: CSS.Translate.toString(transform), transition }, children: [_jsx("button", { "aria-label": `Reorder ${label}`, className: "cursor-grab text-muted-foreground outline-none focus-visible:ring-[var(--focus-ring-width)] focus-visible:ring-ring/50", type: "button", ...attributes, ...listeners, children: _jsx(GripVerticalIcon, { className: "size-4" }) }), _jsx(Checkbox, { checked: !hidden, disabled: !column.getCanHide(), id: `column-setting-${column.id}`, onCheckedChange: (checked) => {
80
+ onToggle(checked === true);
81
+ } }), _jsx("label", { className: "min-w-0 flex-1 truncate", htmlFor: `column-setting-${column.id}`, children: label })] }));
82
+ }
83
+ /**
84
+ * What the table is showing now, in the shape the draft edits.
85
+ *
86
+ * Both fields are spelled out rather than left empty to mean "unchanged": the
87
+ * list has to draw an order either way, and an empty one would have to be
88
+ * filled in from somewhere -- from the current order, which would make Reset
89
+ * write back the very arrangement it was asked to undo.
90
+ */
91
+ function readColumns(table) {
92
+ const columns = table.getAllLeafColumns();
93
+ return {
94
+ hidden: columns.filter((column) => !column.getIsVisible()).map((column) => column.id),
95
+ order: columns.map((column) => column.id),
96
+ };
97
+ }
98
+ /**
99
+ * What Reset goes back to: the caller's own defaults, which `TanStackDataTable`
100
+ * keeps in `initialState` for exactly this.
101
+ *
102
+ * Not "every column, as declared" -- a table given a `defaultColumnVisibility`
103
+ * starts with something hidden on purpose, and a Reset that revealed it would
104
+ * be showing a view the caller never offered.
105
+ *
106
+ * A `columnOrder` is a partial list in TanStack: it names the columns it cares
107
+ * about and everything else keeps its declared position after them. The draft
108
+ * cannot be partial, because the list draws exactly what the draft names -- so
109
+ * a default naming two of five columns would make the other three vanish from
110
+ * the sheet, unreorderable and unhideable, and Apply would then write that
111
+ * two-column order back. Reconciling against the live leaf ids keeps the
112
+ * caller's prefix and restores the rest behind it.
113
+ */
114
+ function readDefaultColumns(table) {
115
+ const { columnOrder, columnVisibility } = table.initialState;
116
+ // Declared order, not drawn order: `getAllLeafColumns` has already had the
117
+ // current order applied to it, so resetting through it would return the
118
+ // arrangement Reset was asked to undo.
119
+ const declared = table
120
+ .getAllFlatColumns()
121
+ .filter((column) => column.columns.length === 0)
122
+ .map((column) => column.id);
123
+ const named = (columnOrder ?? []).filter((id) => declared.includes(id));
124
+ return {
125
+ hidden: Object.entries(columnVisibility ?? {})
126
+ .filter(([, visible]) => !visible)
127
+ .map(([id]) => id),
128
+ order: [...named, ...declared.filter((id) => !named.includes(id))],
129
+ };
130
+ }
131
+ /**
132
+ * Commit a draft to the table.
133
+ *
134
+ * Back to the defaults goes through the table's own reset rather than through
135
+ * an equivalent-looking explicit order. Writing the full list would leave a
136
+ * caller mirroring this into a URL with an order parameter saying "arranged"
137
+ * for a view that is not, and would pin today's columns so a later release
138
+ * appended its new one at the end rather than where it was declared.
139
+ */
140
+ function applyColumns(table, draft) {
141
+ if (isDefaultColumns(draft, readDefaultColumns(table))) {
142
+ table.resetColumnOrder();
143
+ table.resetColumnVisibility();
144
+ return;
145
+ }
146
+ table.setColumnOrder(draft.order);
147
+ table.setColumnVisibility(Object.fromEntries(draft.hidden.map((id) => [id, false])));
148
+ }
149
+ /** Whether a draft is the default arrangement, in whatever order it names it. */
150
+ function isDefaultColumns(draft, defaults) {
151
+ return (draft.order.length === defaults.order.length &&
152
+ draft.order.every((id, index) => defaults.order[index] === id) &&
153
+ draft.hidden.length === defaults.hidden.length &&
154
+ defaults.hidden.every((id) => draft.hidden.includes(id)));
155
+ }
156
+ /**
157
+ * What to call a column in a list of columns.
158
+ *
159
+ * A header rendered as a component -- a select-all checkbox, an icon -- has no
160
+ * text to borrow, and the column id behind it is a name for code rather than
161
+ * for a reader. `meta.label` is where a caller says what to call it instead.
162
+ */
163
+ function getColumnLabel(column) {
164
+ const label = column.columnDef.meta?.label;
165
+ if (typeof label === "string")
166
+ return label;
167
+ const header = column.columnDef.header;
168
+ return typeof header === "string" ? header : column.id;
169
+ }
170
+ export { DataTableColumnSettings };
@@ -0,0 +1,8 @@
1
+ import * as React from "react";
2
+ import type { Table as TanStackTable } from "@tanstack/react-table";
3
+ declare function DataTableProvider<TData>({ children, table, }: {
4
+ children: React.ReactNode;
5
+ table: TanStackTable<TData>;
6
+ }): React.JSX.Element;
7
+ declare function useDataTable<TData>(): TanStackTable<TData>;
8
+ export { DataTableProvider, useDataTable };
@@ -0,0 +1,54 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import * as React from "react";
3
+ /**
4
+ * The table instance `TanStackDataTable` builds, published so the controls
5
+ * placed in its toolbar can read it.
6
+ *
7
+ * The alternative is a function-valued `toolbar` prop, which reads to lint as a
8
+ * component defined during render and would put that error in front of every
9
+ * consumer of a facet filter.
10
+ *
11
+ * Held as `unknown` because a context cannot be generic. Nothing is lost: the
12
+ * only value ever put in is the table `TanStackDataTable` just created, and the
13
+ * only readers are inside that provider, so the row type `useDataTable`
14
+ * restores is the one the caller's own columns were built from.
15
+ */
16
+ const DataTableContext = React.createContext(null);
17
+ function DataTableProvider({ children, table, }) {
18
+ // A fresh object every render, deliberately, and the one thing here that must
19
+ // not be memoised.
20
+ //
21
+ // A control like `DataTableFacetFilter` takes only static props -- a column
22
+ // id, a title, a list of options -- and reads everything that changes from
23
+ // this context. In an app compiled with React Compiler that makes its element
24
+ // cacheable: no reactive props, so the element is built once and React skips
25
+ // re-rendering it. Publishing the table object itself would close the other
26
+ // door too, because its identity never changes however much its state does,
27
+ // so context propagation would never fire either and the control would render
28
+ // once and then show the table as it was at mount forever.
29
+ //
30
+ // Wrapping restores propagation: the provider re-renders whenever the table's
31
+ // state changes, the value is new each time, and React re-renders every
32
+ // consumer regardless of what the compiler did to their elements.
33
+ //
34
+ // `state` is carried so a reader can see what the value is meant to track,
35
+ // and because TanStack rebuilds its state object each render where the table
36
+ // object is the same one forever.
37
+ //
38
+ // It is NOT a guard against this package one day being built with React
39
+ // Compiler. The compiler takes its dependencies from the sources of an
40
+ // expression, and the only source of `table.getState()` is `table` -- so it
41
+ // would cache the call on `[table]`, cache this literal on the result, and
42
+ // freeze the whole thing exactly as before. If that day comes, this
43
+ // component needs a real bailout, not another field.
44
+ return (_jsx(DataTableContext.Provider, { value: { state: table.getState(), table }, children: children }));
45
+ }
46
+ function useDataTable() {
47
+ const context = React.useContext(DataTableContext);
48
+ if (context === null) {
49
+ throw new Error("This control must be rendered inside a <TanStackDataTable />.");
50
+ }
51
+ // eslint-disable-next-line no-restricted-syntax -- restores the row type erased by the context above; the provider only ever stores a TanStackTable.
52
+ return context.table;
53
+ }
54
+ export { DataTableProvider, useDataTable };
@@ -0,0 +1,23 @@
1
+ import * as React from "react";
2
+ type DataTableFacetOption = {
3
+ icon?: React.ComponentType<{
4
+ className?: string;
5
+ }>;
6
+ label: string;
7
+ value: string;
8
+ };
9
+ /**
10
+ * Narrow a column to a set of its own values.
11
+ *
12
+ * Counts come from the table's faceted row model, which `TanStackDataTable`
13
+ * already builds -- so an option can say how many rows it would leave without
14
+ * the caller counting anything itself. Pair with the `arrIncludesSome` filter
15
+ * function, which is what a multi-select selection means.
16
+ */
17
+ declare function DataTableFacetFilter({ columnId, options, title, }: {
18
+ columnId: string;
19
+ options: DataTableFacetOption[];
20
+ title: string;
21
+ }): React.JSX.Element;
22
+ export { DataTableFacetFilter };
23
+ export type { DataTableFacetOption };
@@ -0,0 +1,53 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { CheckIcon, PlusCircleIcon } from "lucide-react";
3
+ import { cn } from "../../lib/utils";
4
+ import { useDataTable } from "./data-table-context";
5
+ import { Badge } from "./badge";
6
+ import { Button } from "./button";
7
+ import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandSeparator, } from "./command";
8
+ import { Popover, PopoverContent, PopoverTrigger } from "./popover";
9
+ import { Separator } from "./separator";
10
+ /**
11
+ * Narrow a column to a set of its own values.
12
+ *
13
+ * Counts come from the table's faceted row model, which `TanStackDataTable`
14
+ * already builds -- so an option can say how many rows it would leave without
15
+ * the caller counting anything itself. Pair with the `arrIncludesSome` filter
16
+ * function, which is what a multi-select selection means.
17
+ */
18
+ function DataTableFacetFilter({ columnId, options, title, }) {
19
+ const column = useDataTable().getColumn(columnId);
20
+ const counts = column?.getFacetedUniqueValues();
21
+ const selected = new Set(readSelection(column?.getFilterValue()));
22
+ const select = (value) => {
23
+ const next = new Set(selected);
24
+ if (next.has(value))
25
+ next.delete(value);
26
+ else
27
+ next.add(value);
28
+ // An empty selection is no filter at all, not a filter matching nothing.
29
+ column?.setFilterValue(next.size ? [...next] : undefined);
30
+ };
31
+ return (_jsxs(Popover, { children: [_jsx(PopoverTrigger, { asChild: true, children: _jsxs(Button, { size: "sm", type: "button", variant: "outline", children: [_jsx(PlusCircleIcon, {}), title, selected.size > 0 ? (_jsxs(_Fragment, { children: [_jsx(Separator, { className: "mx-0.5 data-[orientation=vertical]:h-4", orientation: "vertical" }), _jsx(FacetSelectionSummary, { labels: options.filter((option) => selected.has(option.value)) })] })) : null] }) }), _jsx(PopoverContent, { align: "start", className: "w-52 p-0", children: _jsxs(Command, { children: [_jsx(CommandInput, { placeholder: title }), _jsxs(CommandList, { children: [_jsx(CommandEmpty, { children: "No matches." }), _jsx(CommandGroup, { children: options.map((option) => (_jsx(FacetOptionItem, { count: counts?.get(option.value), onSelect: () => {
32
+ select(option.value);
33
+ }, option: option, selected: selected.has(option.value) }, option.value))) }), selected.size > 0 ? (_jsxs(_Fragment, { children: [_jsx(CommandSeparator, {}), _jsx(CommandGroup, { children: _jsx(CommandItem, { className: "justify-center", onSelect: () => {
34
+ column?.setFilterValue(undefined);
35
+ }, children: "Clear" }) })] })) : null] })] }) })] }));
36
+ }
37
+ /** Names the choices while they still fit, then counts them. */
38
+ function FacetSelectionSummary({ labels }) {
39
+ if (labels.length > 2)
40
+ return _jsxs(Badge, { variant: "secondary", children: [labels.length, " selected"] });
41
+ return (_jsx(_Fragment, { children: labels.map((option) => (_jsx(Badge, { variant: "secondary", children: option.label }, option.value))) }));
42
+ }
43
+ function FacetOptionItem({ count, onSelect, option, selected, }) {
44
+ const Icon = option.icon;
45
+ return (_jsxs(CommandItem, { onSelect: onSelect, children: [_jsx("span", { "aria-hidden": true, className: cn("grid size-4 place-content-center rounded-[4px] border border-input", selected ? "border-primary bg-primary text-primary-foreground" : "opacity-50"), children: selected ? _jsx(CheckIcon, { className: "size-3.5" }) : null }), Icon ? _jsx(Icon, { className: "size-4 text-muted-foreground" }) : null, _jsx("span", { className: "truncate", children: option.label }), count === undefined ? null : (_jsx("span", { className: "ml-auto font-mono text-xs tabular-nums text-muted-foreground", children: count }))] }));
46
+ }
47
+ /** A column filter value is `unknown`; only an array of strings is a selection. */
48
+ function readSelection(value) {
49
+ if (!Array.isArray(value))
50
+ return [];
51
+ return value.filter((entry) => typeof entry === "string");
52
+ }
53
+ export { DataTableFacetFilter };
@@ -1,20 +1,29 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { ArrowDownIcon, ArrowUpDownIcon, ArrowUpIcon } from "lucide-react";
2
+ import { ArrowDownIcon, ArrowUpIcon, ChevronsUpDownIcon } from "lucide-react";
3
3
  import { flexRender, } from "@tanstack/react-table";
4
4
  import { cn } from "../../lib/utils";
5
5
  import { Button } from "./button";
6
6
  import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "./table";
7
7
  function DataTableGrid({ empty, getRowClassName, onRowClick, rows, table, }) {
8
- return (_jsxs(Table, { children: [_jsx(TableHeader, { children: table.getHeaderGroups().map((group) => (_jsx(TableRow, { children: group.headers.map((header) => (_jsx(TableHead, { style: { width: header.getSize() }, children: header.isPlaceholder ? null : _jsx(DataTableHeaderCell, { header: header }) }, header.id))) }, group.id))) }), _jsx(TableBody, { children: rows.length ? (rows.map((row) => (_jsx(DataTableDataRow, { getRowClassName: getRowClassName, onRowClick: onRowClick, row: row }, row.id)))) : (_jsx(TableRow, { children: _jsx(TableCell, { className: "h-32 text-center text-sm text-muted-foreground", colSpan: table.getVisibleLeafColumns().length, children: empty }) })) })] }));
8
+ // One scroller, owned by `data-table-content` outside this: nesting an
9
+ // `overflow-x-auto` inside it would give the sticky header a scrollport that
10
+ // only ever scrolls sideways, so the header would ride away with the rows.
11
+ return (_jsxs(Table, { scrollable: false, children: [_jsx(TableHeader, { className: "sticky top-0 z-10 bg-background", children: table.getHeaderGroups().map((group) => (_jsx(TableRow, { children: group.headers.map((header) => (_jsx(TableHead, { style: { width: header.getSize() }, children: header.isPlaceholder ? null : _jsx(DataTableHeaderCell, { header: header }) }, header.id))) }, group.id))) }), _jsx(TableBody, { children: rows.length ? (rows.map((row) => (_jsx(DataTableDataRow, { getRowClassName: getRowClassName, onRowClick: onRowClick, row: row }, row.id)))) : (_jsx(TableRow, { children: _jsx(TableCell, { className: "h-32 text-center text-sm text-muted-foreground", colSpan: table.getVisibleLeafColumns().length, children: empty }) })) })] }));
9
12
  }
10
13
  function DataTableDataRow({ getRowClassName, onRowClick, row, }) {
11
14
  return (_jsx(TableRow, { className: cn(onRowClick && "cursor-pointer", getRowClassName?.(row)), "data-state": row.getIsSelected() ? "selected" : undefined, onClick: onRowClick ? () => onRowClick(row) : undefined, children: row.getVisibleCells().map((cell) => (_jsx(TableCell, { children: flexRender(cell.column.columnDef.cell, cell.getContext()) }, cell.id))) }));
12
15
  }
13
16
  function DataTableHeaderCell({ header }) {
14
17
  const sorted = header.column.getIsSorted();
15
- const Icon = sorted === "asc" ? ArrowUpIcon : sorted === "desc" ? ArrowDownIcon : ArrowUpDownIcon;
18
+ // Paired chevrons while unsorted, a single arrow once sorted: the pair reads
19
+ // as "this can go either way", the arrow as "it went this way".
20
+ const Icon = sorted === "asc" ? ArrowUpIcon : sorted === "desc" ? ArrowDownIcon : ChevronsUpDownIcon;
16
21
  if (!header.column.getCanSort())
17
22
  return flexRender(header.column.columnDef.header, header.getContext());
18
- return (_jsxs(Button, { "aria-pressed": Boolean(sorted), className: "text-meta -ml-2 h-auto px-2 py-0 text-muted-foreground hover:bg-transparent hover:text-foreground", onClick: header.column.getToggleSortingHandler(), size: "sm", type: "button", variant: "ghost", children: [_jsx("span", { children: flexRender(header.column.columnDef.header, header.getContext()) }), _jsx(Icon, {})] }));
23
+ return (_jsxs(Button, { "aria-pressed": Boolean(sorted),
24
+ // `TableHead`'s size and weight, restated rather than inherited --
25
+ // Tailwind has no font-size or font-weight `inherit` utility -- so a
26
+ // sortable column and a plain one read the same.
27
+ className: "-ml-2 h-auto px-2 py-0 text-sm font-normal text-muted-foreground hover:bg-transparent hover:text-foreground", onClick: header.column.getToggleSortingHandler(), size: "sm", type: "button", variant: "ghost", children: [_jsx("span", { children: flexRender(header.column.columnDef.header, header.getContext()) }), _jsx(Icon, {})] }));
19
28
  }
20
29
  export { DataTableGrid };
@@ -1,10 +1,17 @@
1
1
  import type { Table as TanStackTable } from "@tanstack/react-table";
2
- import type { ResolvedDataTableLabels } from "./data-table-tanstack";
3
- declare function DataTablePagination<TData>({ labels, pageSizeOptions, selectedRows, table, totalRows, }: {
2
+ import type { DataTablePaginationVariant, ResolvedDataTableLabels } from "./data-table-tanstack";
3
+ declare function DataTablePagination<TData>({ footerControls, labels, pageSizeOptions, selectedRows, table, totalRows, variant, }: {
4
+ /**
5
+ * Controls that belong beside the row count rather than above the table --
6
+ * column settings, an export. Whatever governs the whole result set reads
7
+ * better next to the number saying how big it is.
8
+ */
9
+ footerControls?: React.ReactNode;
4
10
  labels: ResolvedDataTableLabels;
5
11
  pageSizeOptions: number[];
6
12
  selectedRows: number;
7
13
  table: TanStackTable<TData>;
8
14
  totalRows: number;
15
+ variant?: DataTablePaginationVariant;
9
16
  }): import("@types/react").JSX.Element;
10
17
  export { DataTablePagination };
@@ -1,19 +1,89 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { ChevronLeftIcon, ChevronRightIcon, ChevronsLeftIcon, ChevronsRightIcon, } from "lucide-react";
3
3
  import { Button } from "./button";
4
4
  import { NativeSelect, NativeSelectOption } from "./native-select";
5
- function DataTablePagination({ labels, pageSizeOptions, selectedRows, table, totalRows, }) {
5
+ import { Pagination, PaginationContent, PaginationEllipsis, PaginationItem } from "./pagination";
6
+ function DataTablePagination({ footerControls, labels, pageSizeOptions, selectedRows, table, totalRows, variant = "compact", }) {
6
7
  const pagination = table.getState().pagination;
7
- const pageCount = Math.max(table.getPageCount(), 1);
8
+ // `-1` is TanStack's "unknown", which a server-driven table reports until it
9
+ // is told a total. Clamping is right for the labels -- a reader is on page 1
10
+ // of at least 1 -- but a control that jumps to the end needs the difference.
11
+ const totalPages = table.getPageCount();
12
+ const pageCount = Math.max(totalPages, 1);
8
13
  const page = Math.min(pagination.pageIndex + 1, pageCount);
9
14
  const rowsShown = table.getRowModel().rows.length;
10
- return (_jsxs("div", { "data-slot": "data-table-pagination", className: "flex flex-col gap-2 text-sm text-muted-foreground sm:flex-row sm:items-center sm:justify-between", children: [_jsx("div", { className: "min-w-0", children: selectedRows > 0
11
- ? (labels.rowsSelected?.(selectedRows, totalRows) ??
12
- `${selectedRows} of ${totalRows} row(s) selected.`)
13
- : (labels.rowsShown?.(rowsShown, totalRows) ??
14
- `${rowsShown} of ${totalRows} row(s) shown.`) }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(NativeSelect, { "aria-label": "Rows per page", onChange: (event) => table.setPageSize(Number(event.target.value)), size: "sm", value: pagination.pageSize, children: pageSizeOptions.map((pageSize) => (_jsxs(NativeSelectOption, { value: pageSize, children: [pageSize, " rows"] }, pageSize))) }), _jsx("div", { className: "min-w-20 text-center tabular-nums", children: labels.page?.(page, pageCount) ?? `Page ${page} of ${pageCount}` }), _jsx(DataTablePaginationButtons, { labels: labels, table: table })] })] }));
15
+ return (_jsxs("div", { "data-slot": "data-table-pagination", className: "flex flex-col gap-2 text-sm text-muted-foreground sm:flex-row sm:items-center sm:justify-between", children: [_jsxs("div", { className: "flex min-w-0 items-center gap-2", children: [_jsx("span", { className: "min-w-0 truncate", children: selectedRows > 0
16
+ ? (labels.rowsSelected?.(selectedRows, totalRows) ??
17
+ `${selectedRows} of ${totalRows} row(s) selected.`)
18
+ : (labels.rowsShown?.(rowsShown, totalRows) ??
19
+ `${rowsShown} of ${totalRows} row(s) shown.`) }), footerControls] }), _jsxs("div", { className: "flex shrink-0 items-center gap-2", children: [_jsx("span", { className: "shrink-0", children: labels.rowsPerPage }), _jsx(NativeSelect, { "aria-label": labels.rowsPerPageName, className: "min-w-20 shrink-0", onChange: (event) => table.setPageSize(Number(event.target.value)), size: "sm", value: pagination.pageSize, children: pageSizeOptions.map((pageSize) => (_jsx(NativeSelectOption, { value: pageSize, children: pageSize }, pageSize))) }), variant === "numbered" ? (_jsx(DataTablePager, { labels: labels, page: page, pageCount: pageCount, pageCountKnown: totalPages >= 0, table: table })) : (_jsxs(_Fragment, { children: [_jsx("div", { className: "min-w-20 text-center tabular-nums", children: labels.page?.(page, pageCount) ?? `Page ${page} of ${pageCount}` }), _jsx(DataTablePaginationButtons, { labels: labels, pageCountKnown: totalPages >= 0, table: table })] }))] })] }));
15
20
  }
16
- function DataTablePaginationButtons({ labels, table, }) {
17
- return (_jsxs("div", { className: "flex items-center gap-1", children: [_jsx(Button, { "aria-label": "First page", disabled: !table.getCanPreviousPage(), onClick: () => table.firstPage(), size: "icon-sm", type: "button", variant: "outline", children: _jsx(ChevronsLeftIcon, {}) }), _jsx(Button, { "aria-label": labels.previousPage, disabled: !table.getCanPreviousPage(), onClick: () => table.previousPage(), size: "icon-sm", type: "button", variant: "outline", children: _jsx(ChevronLeftIcon, {}) }), _jsx(Button, { "aria-label": labels.nextPage, disabled: !table.getCanNextPage(), onClick: () => table.nextPage(), size: "icon-sm", type: "button", variant: "outline", children: _jsx(ChevronRightIcon, {}) }), _jsx(Button, { "aria-label": "Last page", disabled: !table.getCanNextPage(), onClick: () => table.lastPage(), size: "icon-sm", type: "button", variant: "outline", children: _jsx(ChevronsRightIcon, {}) })] }));
21
+ /**
22
+ * Pages as a numbered row rather than a step counter.
23
+ *
24
+ * Worth the extra width when a reader has a page in mind -- page 7 of a member
25
+ * directory is one click, not six. `pageCount` is the clamped count the label
26
+ * uses, so an out-of-range index highlights nothing rather than inventing a
27
+ * page that is not there.
28
+ */
29
+ function DataTablePager({ labels, page, pageCount, pageCountKnown, table, }) {
30
+ const back = !table.getCanPreviousPage();
31
+ const forward = !table.getCanNextPage();
32
+ return (_jsx(Pagination, { className: "mx-0 w-auto justify-end", children: _jsxs(PaginationContent, { children: [_jsx(PagerStepButton, { disabled: back, icon: ChevronsLeftIcon, label: labels.firstPage, onClick: () => {
33
+ table.firstPage();
34
+ } }), _jsx(PagerStepButton, { disabled: back, icon: ChevronLeftIcon, label: labels.previousPage, onClick: () => {
35
+ table.previousPage();
36
+ } }), _jsx(PagerNumbers, { page: page, pageCount: pageCount, table: table }), _jsx(PagerStepButton, { disabled: forward, icon: ChevronRightIcon, label: labels.nextPage, onClick: () => {
37
+ table.nextPage();
38
+ } }), pageCountKnown ? (_jsx(PagerStepButton, { disabled: forward, icon: ChevronsRightIcon, label: labels.lastPage, onClick: () => {
39
+ table.lastPage();
40
+ } })) : null] }) }));
41
+ }
42
+ /** The numbers themselves, with an ellipsis wherever the row elides a run. */
43
+ function PagerNumbers({ page, pageCount, table, }) {
44
+ return (_jsx(_Fragment, { children: getPagerSteps(page, pageCount).map((step, index) => (_jsx(PaginationItem, { children: step === "gap" ? (_jsx(PaginationEllipsis, { className: "size-8" })) : (_jsx(Button, { "aria-current": step === page ? "page" : undefined, "aria-label": `Page ${String(step)}`, className: "tabular-nums", onClick: () => {
45
+ table.setPageIndex(step - 1);
46
+ }, size: "icon-sm", type: "button", variant: step === page ? "outline" : "ghost", children: step })) }, step === "gap" ? `gap-${String(index)}` : step))) }));
47
+ }
48
+ /**
49
+ * One of the four steps either side of the numbers, which differ only in these.
50
+ *
51
+ * Ghost rather than outlined: a row of six bordered boxes reads as six equal
52
+ * things, and the only one worth pointing at is the page you are on.
53
+ */
54
+ function PagerStepButton({ disabled, icon: Icon, label, onClick, }) {
55
+ return (_jsx(PaginationItem, { children: _jsx(Button, { "aria-label": label, disabled: disabled, onClick: onClick, size: "icon-sm", type: "button", variant: "ghost", children: _jsx(Icon, {}) }) }));
56
+ }
57
+ /**
58
+ * The page numbers to draw: always the first and last, always the current and
59
+ * its neighbours, and a gap wherever that skips more than one page.
60
+ *
61
+ * The row is always seven slots wide or fewer, however many pages there are.
62
+ */
63
+ function getPagerSteps(page, pageCount) {
64
+ if (pageCount <= 7)
65
+ return countUp(1, pageCount);
66
+ let from = Math.min(Math.max(page - 1, 2), pageCount - 4);
67
+ let to = Math.max(Math.min(page + 1, pageCount - 1), 5);
68
+ // A gap hiding a single page is wider than the page it hides, so widen the
69
+ // window over it instead. Only one side can be one short of the end at a
70
+ // time above seven pages, so this cannot push the row past seven slots.
71
+ if (from === 3)
72
+ from = 2;
73
+ if (to === pageCount - 2)
74
+ to = pageCount - 1;
75
+ return [
76
+ 1,
77
+ ...(from > 2 ? ["gap"] : []),
78
+ ...countUp(from, to),
79
+ ...(to < pageCount - 1 ? ["gap"] : []),
80
+ pageCount,
81
+ ];
82
+ }
83
+ function countUp(from, to) {
84
+ return Array.from({ length: to - from + 1 }, (_, index) => from + index);
85
+ }
86
+ function DataTablePaginationButtons({ labels, pageCountKnown, table, }) {
87
+ return (_jsxs("div", { className: "flex items-center gap-1", children: [_jsx(Button, { "aria-label": labels.firstPage, disabled: !table.getCanPreviousPage(), onClick: () => table.firstPage(), size: "icon-sm", type: "button", variant: "outline", children: _jsx(ChevronsLeftIcon, {}) }), _jsx(Button, { "aria-label": labels.previousPage, disabled: !table.getCanPreviousPage(), onClick: () => table.previousPage(), size: "icon-sm", type: "button", variant: "outline", children: _jsx(ChevronLeftIcon, {}) }), _jsx(Button, { "aria-label": labels.nextPage, disabled: !table.getCanNextPage(), onClick: () => table.nextPage(), size: "icon-sm", type: "button", variant: "outline", children: _jsx(ChevronRightIcon, {}) }), pageCountKnown ? (_jsx(Button, { "aria-label": labels.lastPage, disabled: !table.getCanNextPage(), onClick: () => table.lastPage(), size: "icon-sm", type: "button", variant: "outline", children: _jsx(ChevronsRightIcon, {}) })) : null] }));
18
88
  }
19
89
  export { DataTablePagination };
@@ -0,0 +1,19 @@
1
+ /**
2
+ * The vocabulary a controlled `TanStackDataTable` is driven in.
3
+ *
4
+ * `TanStackDataTableProps` already exposes these types publicly -- a consumer
5
+ * cannot write `onSortingChange` or a `ColumnDef` without naming them. Without
6
+ * this module the only way to name them is to depend on `@tanstack/react-table`
7
+ * directly, which makes every consumer responsible for keeping a version in
8
+ * step with this package's. Re-exporting them keeps the table library an
9
+ * implementation detail on this side of the boundary.
10
+ */
11
+ export type { Cell, Column, ColumnDef, ColumnFiltersState, ColumnOrderState, Header, OnChangeFn, PaginationState, Row, RowSelectionState, SortingState, Table, TableOptions, Updater, VisibilityState, } from "@tanstack/react-table";
12
+ /**
13
+ * Resolve an `Updater` to the value it stands for.
14
+ *
15
+ * Every controlled handler receives either the next state or a function
16
+ * producing it from the current one, so a consumer that stores state elsewhere
17
+ * -- a URL, a store -- has to collapse that fork before it can write anywhere.
18
+ */
19
+ export { functionalUpdate } from "@tanstack/react-table";
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Resolve an `Updater` to the value it stands for.
3
+ *
4
+ * Every controlled handler receives either the next state or a function
5
+ * producing it from the current one, so a consumer that stores state elsewhere
6
+ * -- a URL, a store -- has to collapse that fork before it can write anywhere.
7
+ */
8
+ export { functionalUpdate } from "@tanstack/react-table";
@@ -1,7 +1,8 @@
1
1
  import * as React from "react";
2
- import { type ColumnDef, type ColumnFiltersState, type OnChangeFn, type PaginationState, type Row, type RowSelectionState, type SortingState, type TableOptions, type VisibilityState } from "@tanstack/react-table";
2
+ import { type ColumnDef, type ColumnFiltersState, type ColumnOrderState, type OnChangeFn, type PaginationState, type Row, type RowSelectionState, type SortingState, type TableOptions, type VisibilityState } from "@tanstack/react-table";
3
3
  type DataTableState = {
4
4
  columnFilters?: ColumnFiltersState;
5
+ columnOrder?: ColumnOrderState;
5
6
  columnVisibility?: VisibilityState;
6
7
  globalFilter?: string;
7
8
  pagination?: PaginationState;
@@ -12,28 +13,57 @@ type DataTableLabels = {
12
13
  columns?: string;
13
14
  empty?: React.ReactNode;
14
15
  filterPlaceholder?: string;
16
+ firstPage?: string;
17
+ lastPage?: string;
15
18
  nextPage?: string;
16
19
  page?: (page: number, pageCount: number) => string;
17
20
  previousPage?: string;
21
+ /** Sits before the page-size control, which reads "Show 25" rather than "25 rows". */
22
+ rowsPerPage?: string;
23
+ /**
24
+ * The page-size control's accessible name. Separate from `rowsPerPage`
25
+ * because that one is terse by design: "Show" beside a number says enough
26
+ * with the footer in view and nothing at all read out on its own.
27
+ */
28
+ rowsPerPageName?: string;
18
29
  rowsSelected?: (selected: number, total: number) => string;
19
30
  rowsShown?: (shown: number, total: number) => string;
20
31
  };
21
32
  type DataTableFilterMode = "global" | "column" | false;
33
+ type DataTablePaginationVariant = "compact" | "numbered";
22
34
  type TanStackDataTableProps<TData, TValue = unknown> = Omit<React.ComponentProps<"div">, "children"> & {
23
35
  actions?: React.ReactNode;
36
+ clampPageIndex?: boolean;
24
37
  columns: ColumnDef<TData, TValue>[];
25
38
  data: TData[];
39
+ defaultColumnOrder?: ColumnOrderState;
26
40
  defaultColumnVisibility?: VisibilityState;
27
41
  defaultPageSize?: number;
28
42
  empty?: React.ReactNode;
29
43
  enableColumnFilters?: boolean;
30
44
  enableColumnVisibility?: boolean;
31
45
  enableGlobalFilter?: boolean;
46
+ /**
47
+ * Whether columns may be hidden at all, separately from whether the built-in
48
+ * columns menu is offered. They were the same switch while the menu was the
49
+ * only way to hide a column; a caller supplying its own control needs to turn
50
+ * the menu off without also making every column permanent.
51
+ */
52
+ enableHiding?: boolean;
32
53
  enablePagination?: boolean;
33
54
  enableRowSelection?: TableOptions<TData>["enableRowSelection"];
34
55
  enableSorting?: boolean;
35
56
  filterMode?: DataTableFilterMode;
36
57
  filterPlaceholder?: string;
58
+ /**
59
+ * Controls for the footer, beside the row count. Whatever governs the whole
60
+ * result set -- column settings, an export -- reads better next to the number
61
+ * saying how big it is than stacked above the table with the filters.
62
+ *
63
+ * The footer is the pagination row, so `enablePagination={false}` removes
64
+ * these along with it; put them in `actions` for a table that does not page.
65
+ */
66
+ footerControls?: React.ReactNode;
37
67
  getRowClassName?: (row: Row<TData>) => string | undefined;
38
68
  getRowId?: TableOptions<TData>["getRowId"];
39
69
  labels?: DataTableLabels;
@@ -42,20 +72,28 @@ type TanStackDataTableProps<TData, TValue = unknown> = Omit<React.ComponentProps
42
72
  manualPagination?: boolean;
43
73
  manualSorting?: boolean;
44
74
  onColumnFiltersChange?: OnChangeFn<ColumnFiltersState>;
75
+ onColumnOrderChange?: OnChangeFn<ColumnOrderState>;
45
76
  onColumnVisibilityChange?: OnChangeFn<VisibilityState>;
46
77
  onGlobalFilterChange?: OnChangeFn<string>;
78
+ /**
79
+ * Called instead of `onPaginationChange` when `clampPageIndex` moves a page
80
+ * index that pointed past the end, so a caller storing the page can replace
81
+ * the address that was wrong rather than pushing the correction on top of it.
82
+ */
83
+ onPageIndexCorrected?: (pageIndex: number) => void;
47
84
  onPaginationChange?: OnChangeFn<PaginationState>;
48
85
  onRowClick?: (row: Row<TData>) => void;
49
86
  onRowSelectionChange?: OnChangeFn<RowSelectionState>;
50
87
  onSortingChange?: OnChangeFn<SortingState>;
51
88
  pageCount?: number;
52
89
  pageSizeOptions?: number[];
90
+ paginationVariant?: DataTablePaginationVariant;
53
91
  rowCount?: number;
54
92
  state?: DataTableState;
55
93
  tableOptions?: Partial<TableOptions<TData>>;
56
94
  toolbar?: React.ReactNode;
57
95
  };
58
- type ResolvedDataTableLabels = Required<Pick<DataTableLabels, "columns" | "filterPlaceholder" | "nextPage" | "previousPage">> & DataTableLabels;
96
+ type ResolvedDataTableLabels = Required<Pick<DataTableLabels, "columns" | "filterPlaceholder" | "firstPage" | "lastPage" | "nextPage" | "previousPage" | "rowsPerPage" | "rowsPerPageName">> & DataTableLabels;
59
97
  declare function TanStackDataTable<TData, TValue = unknown>(props: TanStackDataTableProps<TData, TValue>): React.JSX.Element;
60
98
  export { TanStackDataTable };
61
- export type { DataTableFilterMode, DataTableLabels, ResolvedDataTableLabels, TanStackDataTableProps, };
99
+ export type { DataTableFilterMode, DataTableLabels, DataTablePaginationVariant, DataTableState, ResolvedDataTableLabels, TanStackDataTableProps, };
@@ -4,17 +4,23 @@ import { getCoreRowModel, getFacetedRowModel, getFacetedUniqueValues, getFiltere
4
4
  import { cn } from "../../lib/utils";
5
5
  import { TableSkeleton } from "./loading-state";
6
6
  import { DataTableTools } from "./data-table-controls";
7
+ import { DataTableProvider } from "./data-table-context";
7
8
  import { DataTableGrid } from "./data-table-grid";
8
9
  import { DataTablePagination } from "./data-table-pagination";
9
10
  const defaultLabels = {
10
11
  columns: "Columns",
11
12
  empty: "No results.",
12
13
  filterPlaceholder: "Filter rows...",
14
+ firstPage: "First page",
15
+ lastPage: "Last page",
13
16
  nextPage: "Next page",
14
17
  previousPage: "Previous page",
18
+ rowsPerPage: "Show",
19
+ rowsPerPageName: "Rows per page",
15
20
  };
16
21
  function TanStackDataTable(props) {
17
22
  const table = useDataTableInstance(props);
23
+ useClampedPageIndex(table, props.clampPageIndex, props.loading, props.onPageIndexCorrected);
18
24
  const labels = { ...defaultLabels, ...props.labels };
19
25
  const rows = table.getRowModel().rows;
20
26
  const pagination = table.getState().pagination;
@@ -23,12 +29,52 @@ function TanStackDataTable(props) {
23
29
  props.actions ||
24
30
  props.filterMode !== false ||
25
31
  props.enableColumnVisibility !== false);
26
- return (_jsxs("div", { "data-slot": "data-table", className: cn("grid gap-(--panel-gap)", props.className), children: [hasToolbar ? _jsx(DataTableTools, { labels: labels, props: props, table: table }) : null, _jsx("div", { "data-slot": "data-table-content", className: "overflow-hidden border-t border-border", children: props.loading ? (_jsx(TableSkeleton, { className: "rounded-none border-x-0 border-b-0", columns: table.getVisibleLeafColumns().length, rows: pagination.pageSize })) : (_jsx(DataTableGrid, { empty: props.empty ?? labels.empty, getRowClassName: props.getRowClassName, onRowClick: props.onRowClick, rows: rows, table: table })) }), props.enablePagination !== false ? (_jsx(DataTablePagination, { labels: labels, pageSizeOptions: props.pageSizeOptions ?? [10, 25, 50, 100], selectedRows: table.getFilteredSelectedRowModel().rows.length, table: table, totalRows: totalRows })) : null] }));
32
+ return (_jsx(DataTableProvider, { table: table, children: _jsxs("div", { "data-slot": "data-table", className: cn("flex flex-col gap-(--panel-gap)", props.className), children: [hasToolbar ? _jsx(DataTableTools, { labels: labels, props: props, table: table }) : null, _jsx("div", { "data-slot": "data-table-content", className: "min-h-0 flex-1 overflow-auto border-t border-border", children: props.loading ? (_jsx(TableSkeleton, { className: "rounded-none border-x-0 border-b-0", columns: table.getVisibleLeafColumns().length, rows: pagination.pageSize })) : (_jsx(DataTableGrid, { empty: props.empty ?? labels.empty, getRowClassName: props.getRowClassName, onRowClick: props.onRowClick, rows: rows, table: table })) }), props.enablePagination !== false ? (_jsx(DataTablePagination, { footerControls: props.footerControls, labels: labels, pageSizeOptions: props.pageSizeOptions ?? [10, 25, 50, 100], selectedRows: table.getFilteredSelectedRowModel().rows.length, table: table, totalRows: totalRows, variant: props.paginationVariant })) : null] }) }));
33
+ }
34
+ /**
35
+ * Move a page index that points past the last page back onto a page that
36
+ * exists, reporting the correction through `onPaginationChange`.
37
+ *
38
+ * A caller that keeps the page in the URL can be handed any number at all --
39
+ * `?page=99` in a bookmark, or a page that was valid until a filter narrowed
40
+ * the rows under it. Without this the grid renders an empty body while the
41
+ * footer still counts rows the reader cannot reach, and nothing puts the
42
+ * address right. Correcting through the caller's own handler rather than
43
+ * quietly inside the table keeps the URL and the table saying one thing.
44
+ *
45
+ * Off by default, and skipped while `loading`: it reaches out through the
46
+ * caller's handler, which for a caller that navigates on pagination is a
47
+ * navigation -- not something to start doing to an existing table uninvited,
48
+ * or to do at all while an empty result still means "not yet".
49
+ *
50
+ * `onPageIndexCorrected` exists so that handler can tell a correction from a
51
+ * page turn. A caller writing the page into the URL wants a turn to be a place
52
+ * to come back to and a correction to replace the address that was wrong --
53
+ * and without this they arrive as the same call.
54
+ */
55
+ function useClampedPageIndex(table, enabled, loading, onCorrected) {
56
+ const { pageIndex } = table.getState().pagination;
57
+ const lastPageIndex = Math.max(table.getPageCount() - 1, 0);
58
+ const outOfRange = enabled === true && loading !== true && pageIndex > lastPageIndex;
59
+ const correctedRef = React.useRef(onCorrected);
60
+ React.useEffect(() => {
61
+ correctedRef.current = onCorrected;
62
+ }, [onCorrected]);
63
+ React.useEffect(() => {
64
+ if (!outOfRange)
65
+ return;
66
+ const corrected = correctedRef.current;
67
+ if (corrected)
68
+ corrected(lastPageIndex);
69
+ else
70
+ table.setPageIndex(lastPageIndex);
71
+ }, [lastPageIndex, outOfRange, table]);
27
72
  }
28
73
  // eslint-disable-next-line complexity -- TanStack table setup is declarative option wiring.
29
74
  function useDataTableInstance(props) {
30
75
  const [sorting, setSorting] = React.useState(props.state?.sorting ?? []);
31
76
  const [columnFilters, setColumnFilters] = React.useState(props.state?.columnFilters ?? []);
77
+ const [columnOrder, setColumnOrder] = React.useState(props.state?.columnOrder ?? props.defaultColumnOrder ?? []);
32
78
  const [columnVisibility, setColumnVisibility] = React.useState(props.state?.columnVisibility ?? props.defaultColumnVisibility ?? {});
33
79
  const [rowSelection, setRowSelection] = React.useState(props.state?.rowSelection ?? {});
34
80
  const [globalFilter, setGlobalFilter] = React.useState(props.state?.globalFilter ?? "");
@@ -37,12 +83,20 @@ function useDataTableInstance(props) {
37
83
  return useReactTable({
38
84
  data: props.data,
39
85
  columns: props.columns,
86
+ // What `table.resetColumnOrder()` and `resetColumnVisibility()` restore.
87
+ // Seeding the hooks above is not enough: reset reads `initialState`, so
88
+ // without this a Reset control would clear a caller's defaults rather
89
+ // than return to them.
90
+ initialState: {
91
+ columnOrder: props.defaultColumnOrder ?? [],
92
+ columnVisibility: props.defaultColumnVisibility ?? {},
93
+ },
40
94
  getRowId: props.getRowId,
41
95
  pageCount: props.pageCount,
42
96
  rowCount: props.rowCount,
43
97
  enableColumnFilters: props.enableColumnFilters ?? true,
44
98
  enableGlobalFilter: props.enableGlobalFilter ?? true,
45
- enableHiding: props.enableColumnVisibility ?? true,
99
+ enableHiding: props.enableHiding ?? props.enableColumnVisibility ?? true,
46
100
  enableRowSelection: props.enableRowSelection ?? false,
47
101
  enableSorting: props.enableSorting ?? true,
48
102
  manualFiltering: props.manualFiltering ?? false,
@@ -50,6 +104,7 @@ function useDataTableInstance(props) {
50
104
  manualSorting: props.manualSorting ?? false,
51
105
  state: {
52
106
  columnFilters: props.state?.columnFilters ?? columnFilters,
107
+ columnOrder: props.state?.columnOrder ?? columnOrder,
53
108
  columnVisibility: props.state?.columnVisibility ?? columnVisibility,
54
109
  globalFilter: props.state?.globalFilter ?? globalFilter,
55
110
  pagination: props.state?.pagination ?? pagination,
@@ -57,6 +112,7 @@ function useDataTableInstance(props) {
57
112
  sorting: props.state?.sorting ?? sorting,
58
113
  },
59
114
  onColumnFiltersChange: props.onColumnFiltersChange ?? setColumnFilters,
115
+ onColumnOrderChange: props.onColumnOrderChange ?? setColumnOrder,
60
116
  onColumnVisibilityChange: props.onColumnVisibilityChange ?? setColumnVisibility,
61
117
  onGlobalFilterChange: props.onGlobalFilterChange ?? setGlobalFilter,
62
118
  onPaginationChange: props.onPaginationChange ?? setPagination,
@@ -22,6 +22,10 @@ function DataTableSkeleton({ className, ...props }) {
22
22
  }
23
23
  function SortableTableHead({ active = false, children, className, direction = "asc", onSort, align = "left", ...props }) {
24
24
  const Icon = active ? (direction === "asc" ? ArrowUpIcon : ArrowDownIcon) : ArrowUpDownIcon;
25
- return (_jsx(TableHead, { "aria-sort": active ? (direction === "asc" ? "ascending" : "descending") : "none", className: cn(align === "right" && "text-right", className), ...props, children: _jsxs(Button, { "aria-pressed": active, className: cn("text-meta h-auto px-0 py-0 text-muted-foreground hover:bg-transparent hover:text-foreground", align === "right" ? "ml-auto justify-end" : "justify-start"), onClick: onSort, size: "sm", type: "button", variant: "ghost", children: [_jsx("span", { children: children }), _jsx(Icon, {})] }) }));
25
+ return (_jsx(TableHead, { "aria-sort": active ? (direction === "asc" ? "ascending" : "descending") : "none", className: cn(align === "right" && "text-right", className), ...props, children: _jsxs(Button, { "aria-pressed": active, className: cn(
26
+ // `TableHead`'s size and weight, restated rather than inherited --
27
+ // Tailwind has no font-size or font-weight `inherit` utility -- so the
28
+ // two data tables in this package draw their header rows the same way.
29
+ "h-auto px-0 py-0 text-sm font-normal text-muted-foreground hover:bg-transparent hover:text-foreground", align === "right" ? "ml-auto justify-end" : "justify-start"), onClick: onSort, size: "sm", type: "button", variant: "ghost", children: [_jsx("span", { children: children }), _jsx(Icon, {})] }) }));
26
30
  }
27
31
  export { DataTable, DataTableToolbar, DataTableContent, DataTableEmpty, DataTableSkeleton, TanStackDataTable, SortableTableHead, };
@@ -53,7 +53,12 @@ function SidebarGap({ variant }) {
53
53
  : "group-data-[collapsible=icon]:w-(--sidebar-width-icon)") }));
54
54
  }
55
55
  function SidebarInset({ className, ...props }) {
56
- return (_jsx("main", { "data-slot": "sidebar-inset", className: cn("relative flex w-full flex-1 flex-col bg-background", "md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-lg md:peer-data-[variant=inset]:shadow-xs md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2", className), ...props }));
56
+ return (_jsx("main", { "data-slot": "sidebar-inset", className: cn(
57
+ // `min-w-0` because this is a flex item beside the sidebar, and a flex
58
+ // item will not shrink below its content: without it a page holding
59
+ // anything wide -- a table with sized columns -- pushes the whole app
60
+ // sideways instead of scrolling inside its own box.
61
+ "relative flex w-full min-w-0 flex-1 flex-col bg-background", "md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-lg md:peer-data-[variant=inset]:shadow-xs md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2", className), ...props }));
57
62
  }
58
63
  function SidebarInput({ className, ...props }) {
59
64
  return (_jsx(Input, { "data-slot": "sidebar-input", "data-sidebar": "input", className: cn("h-8 w-full bg-background shadow-none", className), ...props }));
@@ -1,5 +1,16 @@
1
1
  import * as React from "react";
2
- declare function Table({ className, ...props }: React.ComponentProps<"table">): React.JSX.Element;
2
+ declare function Table({ className, scrollable, ...props }: React.ComponentProps<"table"> & {
3
+ /**
4
+ * Whether the wrapper scrolls a too-wide table sideways. A caller that
5
+ * already scrolls the table from outside passes `false`: an `overflow` of
6
+ * anything but `visible` makes this wrapper a scrollport on *both* axes, so
7
+ * leaving it on would anchor a sticky header here rather than to the box
8
+ * that actually scrolls. Two overflow utilities cannot settle this between
9
+ * them -- `cn` does not dedupe them, and the axis-specific one wins -- so it
10
+ * is a switch rather than a class.
11
+ */
12
+ scrollable?: boolean;
13
+ }): React.JSX.Element;
3
14
  declare function TableHeader({ className, ...props }: React.ComponentProps<"thead">): React.JSX.Element;
4
15
  declare function TableBody({ className, ...props }: React.ComponentProps<"tbody">): React.JSX.Element;
5
16
  declare function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">): React.JSX.Element;
@@ -1,8 +1,8 @@
1
1
  "use client";
2
2
  import { jsx as _jsx } from "react/jsx-runtime";
3
3
  import { cn } from "../../lib/utils";
4
- function Table({ className, ...props }) {
5
- return (_jsx("div", { "data-slot": "table-container", className: "relative w-full overflow-x-auto", children: _jsx("table", { "data-slot": "table", className: cn("w-full caption-bottom text-sm", className), ...props }) }));
4
+ function Table({ className, scrollable = true, ...props }) {
5
+ return (_jsx("div", { "data-slot": "table-container", className: cn("relative w-full", scrollable && "overflow-x-auto"), children: _jsx("table", { "data-slot": "table", className: cn("w-full caption-bottom text-sm", className), ...props }) }));
6
6
  }
7
7
  function TableHeader({ className, ...props }) {
8
8
  return _jsx("thead", { "data-slot": "table-header", className: cn("[&_tr]:border-b", className), ...props });
@@ -17,7 +17,12 @@ function TableRow({ className, ...props }) {
17
17
  return (_jsx("tr", { "data-slot": "table-row", className: cn("h-(--table-row-height) border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted", className), ...props }));
18
18
  }
19
19
  function TableHead({ className, ...props }) {
20
- return (_jsx("th", { "data-slot": "table-head", className: cn("h-(--table-row-height) px-(--table-cell-padding-x) py-(--table-cell-padding-y) text-left align-middle text-xs font-medium tracking-normal whitespace-nowrap text-table-header-foreground normal-case [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]", className), ...props }));
20
+ return (_jsx("th", { "data-slot": "table-head", className: cn(
21
+ // A column name is a name, not metadata, so it is set at the table's
22
+ // own size. It lives here rather than on the sort button so that a
23
+ // table mixing sortable and plain columns draws one header row and
24
+ // not two typefaces side by side.
25
+ "h-(--table-row-height) px-(--table-cell-padding-x) py-(--table-cell-padding-y) text-left align-middle text-sm font-normal tracking-normal whitespace-nowrap text-table-header-foreground normal-case [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]", className), ...props }));
21
26
  }
22
27
  function TableCell({ className, ...props }) {
23
28
  return (_jsx("td", { "data-slot": "table-cell", className: cn("px-(--table-cell-padding-x) py-(--table-cell-padding-y) align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]", className), ...props }));
@@ -0,0 +1 @@
1
+ export * from "../components/ui/data-table-column-settings";
@@ -0,0 +1 @@
1
+ export * from "../components/ui/data-table-column-settings";
@@ -0,0 +1 @@
1
+ export * from "../components/ui/data-table-context";
@@ -0,0 +1 @@
1
+ export * from "../components/ui/data-table-context";
@@ -0,0 +1 @@
1
+ export * from "../components/ui/data-table-facet-filter";
@@ -0,0 +1 @@
1
+ export * from "../components/ui/data-table-facet-filter";
@@ -0,0 +1 @@
1
+ export * from "../components/ui/data-table-state";
@@ -0,0 +1 @@
1
+ export * from "../components/ui/data-table-state";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ciridae/ui",
3
- "version": "0.3.7",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "private": false,
6
6
  "repository": {