@usetheo/ui 0.21.0 → 0.22.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,2 @@
1
+ /** Shared sort comparator for DataTable modes (default + virtualized). */
2
+ export declare function compareValues(a: unknown, b: unknown): number;
@@ -0,0 +1,10 @@
1
+ import type { ReactNode } from "react";
2
+ import type { DataTableColumn } from "./data-table.js";
3
+ /** Skeleton state shared by both DataTable modes (extracted — 300-line hook floor). */
4
+ export declare function DataTableLoading<T>(props: {
5
+ columns: DataTableColumn<T>[];
6
+ hasExpandColumn: boolean;
7
+ hasActionsColumn: boolean;
8
+ stickyHeader: boolean;
9
+ className?: string;
10
+ }): ReactNode;
@@ -0,0 +1,16 @@
1
+ import type { ReactNode } from "react";
2
+ import type { DataTableColumn, DataTableSort } from "./data-table.js";
3
+ /** Header row shared by the default and virtualized DataTable bodies. */
4
+ export declare function DataTableHeaderRow<T>(props: {
5
+ columns: DataTableColumn<T>[];
6
+ sort: DataTableSort | null;
7
+ onSortClick: (columnKey: string) => void;
8
+ hasExpandColumn?: boolean;
9
+ hasActionsColumn?: boolean;
10
+ }): ReactNode;
11
+ /** Row-actions dropdown cell shared by both DataTable bodies. */
12
+ export declare function DataTableRowActionsCell(props: {
13
+ children: ReactNode;
14
+ }): ReactNode;
15
+ /** Renders a body cell's content per column definition (shared by both bodies). */
16
+ export declare function renderCellContent<T>(row: T, col: DataTableColumn<T>): ReactNode;
@@ -0,0 +1,32 @@
1
+ import type { VirtualizerOptions } from "@tanstack/react-virtual";
2
+ import type { ReactNode } from "react";
3
+ import type { DataTableBaseProps } from "./data-table.js";
4
+ /**
5
+ * Virtualized body of DataTable — renders 10K+ rows over @tanstack/react-virtual
6
+ * (blueprint M6, ADR D1/D2 + review fix F-dom-1). Semantic `<table>` preserved
7
+ * with the SPACER-ROW technique: two fixed-height spacer `<tr>`s grow the
8
+ * table's real layout box to the full dataset height, so the sticky `<thead>`
9
+ * keeps its containing block for the whole scroll and the last row stays
10
+ * reachable — per-row transforms (the official example's pattern) do NOT grow
11
+ * the layout box and un-stick the header after ~one window.
12
+ *
13
+ * Limitations (documented contract — mutually exclusive at the type level):
14
+ * no pagination, no expandable rows (fixed row heights are required by
15
+ * estimateSize). Cell content must fit `rowHeight` (cells are clipped with
16
+ * reduced padding; keep rowHeight >= ~32px). A row-action dropdown unmounts
17
+ * when its row leaves the overscan window.
18
+ */
19
+ export interface DataTableVirtualizedOptions {
20
+ /** Scroll container height (px or CSS length). */
21
+ height: number | string;
22
+ /** Fixed row height in px — required by the virtualizer's estimateSize. */
23
+ rowHeight: number;
24
+ /** Rows rendered beyond the viewport on each side. @default 5 */
25
+ overscan?: number;
26
+ /** @internal test-only — inject virtualizer options (e.g. observeElementRect). */
27
+ virtualizerOptions?: Partial<VirtualizerOptions<HTMLElement, Element>>;
28
+ }
29
+ export type DataTableVirtualizedBodyProps<T> = Omit<DataTableBaseProps<T>, "stickyHeader"> & {
30
+ virtualized: DataTableVirtualizedOptions;
31
+ };
32
+ export declare function DataTableVirtualizedBody<T>(props: DataTableVirtualizedBodyProps<T>): ReactNode;
@@ -1,4 +1,5 @@
1
1
  import type { ReactNode } from "react";
2
+ import type { DataTableVirtualizedOptions } from "./data-table-virtualized.js";
2
3
  /**
3
4
  * DataTable — generic, sortable, expandable composite over `<Table>`.
4
5
  *
@@ -9,6 +10,10 @@ import type { ReactNode } from "react";
9
10
  * pagination support controlled OR uncontrolled mode (consumer
10
11
  * passes onSortChange / onPageChange to take over state).
11
12
  *
13
+ * `virtualized` mode (M6) renders 10K+ rows over @tanstack/react-virtual.
14
+ * It is mutually exclusive with `pagination` and `expandable` at the type
15
+ * level — a virtualized list is one continuous scroll of fixed-height rows.
16
+ *
12
17
  * @example
13
18
  * <DataTable
14
19
  * columns={[
@@ -39,19 +44,12 @@ export interface DataTableSort {
39
44
  key: string;
40
45
  direction: "asc" | "desc";
41
46
  }
42
- export interface DataTableProps<T> {
47
+ export interface DataTableBaseProps<T> {
43
48
  data: T[];
44
49
  columns: DataTableColumn<T>[];
45
50
  rowKey: (row: T) => string;
46
51
  stickyHeader?: boolean;
47
- expandable?: (row: T) => ReactNode | null;
48
- expandMode?: "single" | "multiple";
49
52
  rowActions?: (row: T) => ReactNode;
50
- pagination?: {
51
- pageSize: number;
52
- controlledPage?: number;
53
- onPageChange?: (page: number) => void;
54
- } | null;
55
53
  defaultSort?: DataTableSort;
56
54
  sort?: DataTableSort | null;
57
55
  onSortChange?: (sort: DataTableSort | null) => void;
@@ -59,5 +57,26 @@ export interface DataTableProps<T> {
59
57
  emptyState?: ReactNode;
60
58
  className?: string;
61
59
  }
60
+ /**
61
+ * Discriminated union: the default mode keeps pagination/expandable; the
62
+ * virtualized mode excludes them at the type level (fixed-height single
63
+ * scroll — see DataTableVirtualizedOptions for the documented limitations).
64
+ */
65
+ export type DataTableProps<T> = DataTableBaseProps<T> & ({
66
+ virtualized?: never;
67
+ expandable?: (row: T) => ReactNode | null;
68
+ expandMode?: "single" | "multiple";
69
+ pagination?: {
70
+ pageSize: number;
71
+ controlledPage?: number;
72
+ onPageChange?: (page: number) => void;
73
+ } | null;
74
+ } | {
75
+ virtualized: DataTableVirtualizedOptions;
76
+ expandable?: never;
77
+ expandMode?: never;
78
+ pagination?: never;
79
+ stickyHeader?: never;
80
+ });
62
81
  declare function DataTable<T>(props: DataTableProps<T>): ReactNode;
63
82
  export { DataTable };
@@ -1 +1,2 @@
1
1
  export { DataTable, type DataTableColumn, type DataTableProps, type DataTableSort, } from "./data-table.js";
2
+ export type { DataTableVirtualizedOptions } from "./data-table-virtualized.js";
@@ -0,0 +1,19 @@
1
+ import type { DataTableColumn, DataTableSort } from "./data-table.js";
2
+ /**
3
+ * Shared controlled/uncontrolled sort state for both DataTable modes.
4
+ * Cycle: none → asc → desc → none. Client-side sorting applies only in
5
+ * uncontrolled mode (controlled consumers pre-sort their data).
6
+ */
7
+ export declare function useDataTableSort<T>(opts: {
8
+ data: T[];
9
+ columns: DataTableColumn<T>[];
10
+ defaultSort?: DataTableSort;
11
+ sort?: DataTableSort | null;
12
+ onSortChange?: (sort: DataTableSort | null) => void;
13
+ /** Fired on uncontrolled sort change (EC-8: page reset in paginated mode). */
14
+ onUncontrolledSortChange?: () => void;
15
+ }): {
16
+ sort: DataTableSort | null;
17
+ handleSort: (columnKey: string) => void;
18
+ sortedData: T[];
19
+ };
package/dist/index.d.ts CHANGED
@@ -45,7 +45,7 @@ export { Pagination, computePageRange, type PaginationProps, } from "./component
45
45
  export { DropdownMenu } from "./components/primitives/dropdown-menu/index.js";
46
46
  export { ActionBar, type ActionBarProps } from "./components/primitives/action-bar/index.js";
47
47
  export { PinInput, type PinInputProps } from "./components/primitives/pin-input/index.js";
48
- export { DataTable, type DataTableColumn, type DataTableProps, type DataTableSort, } from "./components/composites/data-table/index.js";
48
+ export { DataTable, type DataTableColumn, type DataTableProps, type DataTableSort, type DataTableVirtualizedOptions, } from "./components/composites/data-table/index.js";
49
49
  export { PageShell, type PageShellProps } from "./components/composites/page-shell/index.js";
50
50
  export { linScale, niceMax, seriesPath, TrendChart, type TrendChartProps, type TrendPoint, type TrendSeries, } from "./components/composites/trend-chart/index.js";
51
51
  export { Stepper, deriveSteps, type StepStatus, type StepperProps, type StepperStepData, } from "./components/composites/stepper/index.js";
package/dist/index.js CHANGED
@@ -19,6 +19,7 @@ import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
19
19
  import * as RadioGroupPrimitive from '@radix-ui/react-radio-group';
20
20
  import * as SwitchPrimitive from '@radix-ui/react-switch';
21
21
  import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
22
+ import { useVirtualizer } from '@tanstack/react-virtual';
22
23
 
23
24
  // src/lib/cn.ts
24
25
  var twMerge = extendTailwindMerge({
@@ -4094,6 +4095,74 @@ var PinInput = forwardRef(
4094
4095
  }
4095
4096
  );
4096
4097
  PinInput.displayName = "PinInput";
4098
+ function DataTableHeaderRow(props) {
4099
+ const { columns, sort, onSortClick, hasExpandColumn = false, hasActionsColumn = false } = props;
4100
+ return /* @__PURE__ */ jsxs(Table.Row, { children: [
4101
+ hasExpandColumn ? /* @__PURE__ */ jsx(Table.HeaderCell, { children: /* @__PURE__ */ jsx("span", { className: "sr-only", children: "Expand" }) }) : null,
4102
+ columns.map((col) => {
4103
+ const isSortable = col.sortable === true;
4104
+ const isActive = sort?.key === col.key;
4105
+ return /* @__PURE__ */ jsx(
4106
+ Table.HeaderCell,
4107
+ {
4108
+ align: col.align,
4109
+ onSort: isSortable ? () => onSortClick(col.key) : void 0,
4110
+ sortDirection: isSortable ? isActive ? sort?.direction : "none" : void 0,
4111
+ style: col.width ? { width: col.width } : void 0,
4112
+ children: col.label
4113
+ },
4114
+ col.key
4115
+ );
4116
+ }),
4117
+ hasActionsColumn ? /* @__PURE__ */ jsx(Table.HeaderCell, { children: /* @__PURE__ */ jsx("span", { className: "sr-only", children: "Actions" }) }) : null
4118
+ ] });
4119
+ }
4120
+ function DataTableRowActionsCell(props) {
4121
+ return /* @__PURE__ */ jsx(Table.Cell, { align: "right", children: /* @__PURE__ */ jsxs(DropdownMenu, { children: [
4122
+ /* @__PURE__ */ jsx(
4123
+ DropdownMenu.Trigger,
4124
+ {
4125
+ "aria-label": "Row actions",
4126
+ className: cn(
4127
+ "inline-flex size-7 items-center justify-center rounded-md",
4128
+ "text-muted-foreground hover:bg-muted hover:text-foreground",
4129
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
4130
+ ),
4131
+ children: /* @__PURE__ */ jsx(MoreHorizontal, { "aria-hidden": "true", className: "size-4" })
4132
+ }
4133
+ ),
4134
+ /* @__PURE__ */ jsx(DropdownMenu.Content, { align: "end", children: props.children })
4135
+ ] }) });
4136
+ }
4137
+ function renderCellContent(row, col) {
4138
+ return col.render ? col.render(row) : String(row[col.key] ?? "");
4139
+ }
4140
+ function DataTableLoading(props) {
4141
+ const { columns, hasExpandColumn, hasActionsColumn, stickyHeader, className } = props;
4142
+ const totalCols = columns.length + (hasExpandColumn ? 1 : 0) + (hasActionsColumn ? 1 : 0);
4143
+ return /* @__PURE__ */ jsx("div", { "data-slot": "data-table", className: cn("w-full", className), children: /* @__PURE__ */ jsxs(Table, { children: [
4144
+ /* @__PURE__ */ jsx(Table.Header, { className: stickyHeader ? "sticky top-0 bg-card" : void 0, children: /* @__PURE__ */ jsx(
4145
+ DataTableHeaderRow,
4146
+ {
4147
+ columns: columns.map((col) => ({ ...col, sortable: false })),
4148
+ sort: null,
4149
+ onSortClick: () => {
4150
+ },
4151
+ hasExpandColumn,
4152
+ hasActionsColumn
4153
+ }
4154
+ ) }),
4155
+ /* @__PURE__ */ jsx(Table.Body, { children: Array.from({ length: 5 }, (_, i) => (
4156
+ // biome-ignore lint/suspicious/noArrayIndexKey: skeleton rows are positional placeholders
4157
+ /* @__PURE__ */ jsx(Table.Row, { children: Array.from({ length: totalCols }, (_2, j) => (
4158
+ // biome-ignore lint/suspicious/noArrayIndexKey: skeleton cells are positional placeholders
4159
+ /* @__PURE__ */ jsx(Table.Cell, { children: /* @__PURE__ */ jsx(Skeleton, { className: "h-4 w-full" }) }, `s-${i}-${j}`)
4160
+ )) }, `skeleton-${i}`)
4161
+ )) })
4162
+ ] }) });
4163
+ }
4164
+
4165
+ // src/components/composites/data-table/compare-values.ts
4097
4166
  function compareValues(a, b) {
4098
4167
  if (a === b) return 0;
4099
4168
  if (a === null || a === void 0) return -1;
@@ -4101,7 +4170,184 @@ function compareValues(a, b) {
4101
4170
  if (typeof a === "number" && typeof b === "number") return a - b;
4102
4171
  return String(a).localeCompare(String(b));
4103
4172
  }
4173
+
4174
+ // src/components/composites/data-table/use-data-table-sort.ts
4175
+ function useDataTableSort(opts) {
4176
+ const { data, columns, defaultSort, sort: controlledSort, onSortChange } = opts;
4177
+ const isControlledSort = onSortChange !== void 0;
4178
+ const [uncontrolledSort, setUncontrolledSort] = useState(
4179
+ defaultSort ?? null
4180
+ );
4181
+ const sort = isControlledSort ? controlledSort ?? null : uncontrolledSort;
4182
+ function handleSort(columnKey) {
4183
+ let nextSort;
4184
+ if (sort?.key !== columnKey) {
4185
+ nextSort = { key: columnKey, direction: "asc" };
4186
+ } else if (sort.direction === "asc") {
4187
+ nextSort = { key: columnKey, direction: "desc" };
4188
+ } else {
4189
+ nextSort = null;
4190
+ }
4191
+ if (isControlledSort) {
4192
+ onSortChange?.(nextSort);
4193
+ } else {
4194
+ setUncontrolledSort(nextSort);
4195
+ opts.onUncontrolledSortChange?.();
4196
+ }
4197
+ }
4198
+ const sortedData = useMemo(() => {
4199
+ if (isControlledSort || sort === null) return data;
4200
+ const col = columns.find((c) => c.key === sort.key);
4201
+ if (!col) return data;
4202
+ const sorted = [...data].sort((a, b) => {
4203
+ const aVal = col.render ? null : a[sort.key];
4204
+ const bVal = col.render ? null : b[sort.key];
4205
+ const cmp = compareValues(aVal, bVal);
4206
+ return sort.direction === "asc" ? cmp : -cmp;
4207
+ });
4208
+ return sorted;
4209
+ }, [data, sort, isControlledSort, columns]);
4210
+ return { sort, handleSort, sortedData };
4211
+ }
4212
+ var EXCLUSIVE_PROPS = ["pagination", "expandable"];
4213
+ function SpacerRow(props) {
4214
+ if (props.height <= 0) return null;
4215
+ return (
4216
+ // biome-ignore lint/a11y/noInteractiveElementToNoninteractiveRole: spacer row is pure layout filler (no content, no interaction) inside a semantic table; presentation keeps SRs on the aria-rowcount contract
4217
+ /* @__PURE__ */ jsx(
4218
+ "tr",
4219
+ {
4220
+ "data-slot": `data-table-virtual-spacer-${props.slot}`,
4221
+ role: "presentation",
4222
+ style: { height: `${props.height}px` },
4223
+ children: /* @__PURE__ */ jsx("td", { colSpan: props.colSpan, style: { padding: 0 } })
4224
+ }
4225
+ )
4226
+ );
4227
+ }
4228
+ function DataTableVirtualizedBody(props) {
4229
+ const {
4230
+ data,
4231
+ columns,
4232
+ rowKey,
4233
+ virtualized,
4234
+ rowActions,
4235
+ defaultSort,
4236
+ sort: controlledSort,
4237
+ onSortChange,
4238
+ loading = false,
4239
+ emptyState,
4240
+ className
4241
+ } = props;
4242
+ if (isDev()) {
4243
+ for (const key of EXCLUSIVE_PROPS) {
4244
+ if (props[key]) {
4245
+ console.warn(
4246
+ `DataTable: \`${key}\` is ignored in virtualized mode \u2014 the modes are mutually exclusive.`
4247
+ );
4248
+ }
4249
+ }
4250
+ if (virtualized.rowHeight < 1) {
4251
+ console.warn("DataTable: virtualized.rowHeight must be >= 1px \u2014 falling back to 1.");
4252
+ }
4253
+ }
4254
+ const rowHeight = Math.max(1, virtualized.rowHeight);
4255
+ const parentRef = useRef(null);
4256
+ const { sort, handleSort, sortedData } = useDataTableSort({
4257
+ data,
4258
+ columns,
4259
+ defaultSort,
4260
+ sort: controlledSort,
4261
+ onSortChange
4262
+ });
4263
+ const virtualizer = useVirtualizer({
4264
+ // Injection first (review F-dom-6): the @internal test hook can add
4265
+ // observeElementRect but can never override the core window contract.
4266
+ ...virtualized.virtualizerOptions,
4267
+ count: sortedData.length,
4268
+ getScrollElement: () => parentRef.current,
4269
+ estimateSize: () => rowHeight,
4270
+ overscan: virtualized.overscan ?? 5
4271
+ });
4272
+ if (loading) {
4273
+ return /* @__PURE__ */ jsx(
4274
+ DataTableLoading,
4275
+ {
4276
+ columns,
4277
+ hasExpandColumn: false,
4278
+ hasActionsColumn: rowActions !== void 0,
4279
+ stickyHeader: true,
4280
+ className
4281
+ }
4282
+ );
4283
+ }
4284
+ if (sortedData.length === 0) {
4285
+ return /* @__PURE__ */ jsx("div", { className: cn("w-full", className), children: emptyState ?? /* @__PURE__ */ jsx(EmptyState, { title: "No data", description: "There's nothing here yet." }) });
4286
+ }
4287
+ const items = virtualizer.getVirtualItems();
4288
+ const totalSize = virtualizer.getTotalSize();
4289
+ const firstItem = items[0];
4290
+ const lastItem = items[items.length - 1];
4291
+ const paddingTop = firstItem ? firstItem.start : 0;
4292
+ const paddingBottom = lastItem ? totalSize - lastItem.end : totalSize;
4293
+ const colSpan = columns.length + (rowActions ? 1 : 0);
4294
+ return /* @__PURE__ */ jsx("div", { "data-slot": "data-table-virtual-body", className: cn("w-full", className), children: /* @__PURE__ */ jsx(
4295
+ "section",
4296
+ {
4297
+ ref: parentRef,
4298
+ "aria-label": "Table scroll area",
4299
+ tabIndex: 0,
4300
+ className: "overflow-auto focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
4301
+ style: { height: virtualized.height },
4302
+ "data-slot": "data-table-virtual-scroll",
4303
+ children: /* @__PURE__ */ jsxs(Table, { "aria-rowcount": sortedData.length + 1, children: [
4304
+ /* @__PURE__ */ jsx(Table.Header, { className: "sticky top-0 z-10 bg-card", children: /* @__PURE__ */ jsx(
4305
+ DataTableHeaderRow,
4306
+ {
4307
+ columns,
4308
+ sort,
4309
+ onSortClick: handleSort,
4310
+ hasActionsColumn: rowActions !== void 0
4311
+ }
4312
+ ) }),
4313
+ /* @__PURE__ */ jsxs(Table.Body, { children: [
4314
+ /* @__PURE__ */ jsx(SpacerRow, { slot: "top", height: paddingTop, colSpan }),
4315
+ items.map((vRow) => {
4316
+ const row = sortedData[vRow.index];
4317
+ return /* @__PURE__ */ jsxs(
4318
+ Table.Row,
4319
+ {
4320
+ "aria-rowindex": vRow.index + 2,
4321
+ style: { height: `${rowHeight}px` },
4322
+ children: [
4323
+ columns.map((col) => /* @__PURE__ */ jsx(
4324
+ Table.Cell,
4325
+ {
4326
+ align: col.align,
4327
+ className: cn("overflow-hidden py-1.5", col.className),
4328
+ children: renderCellContent(row, col)
4329
+ },
4330
+ col.key
4331
+ )),
4332
+ rowActions ? /* @__PURE__ */ jsx(DataTableRowActionsCell, { children: rowActions(row) }) : null
4333
+ ]
4334
+ },
4335
+ rowKey(row)
4336
+ );
4337
+ }),
4338
+ /* @__PURE__ */ jsx(SpacerRow, { slot: "bottom", height: paddingBottom, colSpan })
4339
+ ] })
4340
+ ] })
4341
+ }
4342
+ ) });
4343
+ }
4104
4344
  function DataTable(props) {
4345
+ if (props.virtualized) {
4346
+ return /* @__PURE__ */ jsx(DataTableVirtualizedBody, { ...props, virtualized: props.virtualized });
4347
+ }
4348
+ return /* @__PURE__ */ jsx(DataTableDefaultBody, { ...props });
4349
+ }
4350
+ function DataTableDefaultBody(props) {
4105
4351
  const {
4106
4352
  data,
4107
4353
  columns,
@@ -4118,32 +4364,22 @@ function DataTable(props) {
4118
4364
  emptyState,
4119
4365
  className
4120
4366
  } = props;
4121
- const isControlledSort = onSortChange !== void 0;
4122
- const [uncontrolledSort, setUncontrolledSort] = useState(
4123
- defaultSort ?? null
4124
- );
4125
- const sort = isControlledSort ? controlledSort ?? null : uncontrolledSort;
4126
4367
  const isControlledPage = pagination?.controlledPage !== void 0;
4127
4368
  const [uncontrolledPage, setUncontrolledPage] = useState(0);
4128
4369
  const currentPage = isControlledPage ? pagination?.controlledPage ?? 0 : uncontrolledPage;
4129
4370
  const effectivePageSize = Math.max(1, pagination?.pageSize ?? 10);
4130
4371
  const [expanded, setExpanded] = useState(/* @__PURE__ */ new Set());
4131
- function handleSort(columnKey) {
4132
- let nextSort;
4133
- if (sort?.key !== columnKey) {
4134
- nextSort = { key: columnKey, direction: "asc" };
4135
- } else if (sort.direction === "asc") {
4136
- nextSort = { key: columnKey, direction: "desc" };
4137
- } else {
4138
- nextSort = null;
4139
- }
4140
- if (isControlledSort) {
4141
- onSortChange?.(nextSort);
4142
- } else {
4143
- setUncontrolledSort(nextSort);
4372
+ const { sort, handleSort, sortedData } = useDataTableSort({
4373
+ data,
4374
+ columns,
4375
+ defaultSort,
4376
+ sort: controlledSort,
4377
+ onSortChange,
4378
+ // EC-8: sort change resets pagination to page 0
4379
+ onUncontrolledSortChange: () => {
4144
4380
  if (!isControlledPage) setUncontrolledPage(0);
4145
4381
  }
4146
- }
4382
+ });
4147
4383
  function handlePageChange(page) {
4148
4384
  const zeroIdx = page - 1;
4149
4385
  if (isControlledPage) {
@@ -4167,18 +4403,6 @@ function DataTable(props) {
4167
4403
  });
4168
4404
  }
4169
4405
  }
4170
- const sortedData = useMemo(() => {
4171
- if (isControlledSort || sort === null) return data;
4172
- const col = columns.find((c) => c.key === sort.key);
4173
- if (!col) return data;
4174
- const sorted = [...data].sort((a, b) => {
4175
- const aVal = col.render ? null : a[sort.key];
4176
- const bVal = col.render ? null : b[sort.key];
4177
- const cmp = compareValues(aVal, bVal);
4178
- return sort.direction === "asc" ? cmp : -cmp;
4179
- });
4180
- return sorted;
4181
- }, [data, sort, isControlledSort, columns]);
4182
4406
  const visibleData = useMemo(() => {
4183
4407
  if (!pagination) return sortedData;
4184
4408
  if (isControlledPage) return sortedData;
@@ -4187,22 +4411,17 @@ function DataTable(props) {
4187
4411
  }, [sortedData, pagination, isControlledPage, currentPage, effectivePageSize]);
4188
4412
  const extraCols = (expandable ? 1 : 0) + (rowActions ? 1 : 0);
4189
4413
  const expandedColSpan = columns.length + extraCols;
4190
- const totalCols = columns.length + extraCols;
4191
4414
  if (loading) {
4192
- return /* @__PURE__ */ jsx("div", { "data-slot": "data-table", className: cn("w-full", className), children: /* @__PURE__ */ jsxs(Table, { children: [
4193
- /* @__PURE__ */ jsx(Table.Header, { className: stickyHeader ? "sticky top-0 bg-card" : void 0, children: /* @__PURE__ */ jsxs(Table.Row, { children: [
4194
- expandable ? /* @__PURE__ */ jsx(Table.HeaderCell, { children: /* @__PURE__ */ jsx("span", { className: "sr-only", children: "Expand" }) }) : null,
4195
- columns.map((col) => /* @__PURE__ */ jsx(Table.HeaderCell, { align: col.align, children: col.label }, col.key)),
4196
- rowActions ? /* @__PURE__ */ jsx(Table.HeaderCell, { children: /* @__PURE__ */ jsx("span", { className: "sr-only", children: "Actions" }) }) : null
4197
- ] }) }),
4198
- /* @__PURE__ */ jsx(Table.Body, { children: Array.from({ length: 5 }, (_, i) => (
4199
- // biome-ignore lint/suspicious/noArrayIndexKey: skeleton rows are positional placeholders
4200
- /* @__PURE__ */ jsx(Table.Row, { children: Array.from({ length: totalCols }, (_2, j) => (
4201
- // biome-ignore lint/suspicious/noArrayIndexKey: skeleton cells are positional placeholders
4202
- /* @__PURE__ */ jsx(Table.Cell, { children: /* @__PURE__ */ jsx(Skeleton, { className: "h-4 w-full" }) }, `s-${i}-${j}`)
4203
- )) }, `skeleton-${i}`)
4204
- )) })
4205
- ] }) });
4415
+ return /* @__PURE__ */ jsx(
4416
+ DataTableLoading,
4417
+ {
4418
+ columns,
4419
+ hasExpandColumn: expandable !== void 0,
4420
+ hasActionsColumn: rowActions !== void 0,
4421
+ stickyHeader,
4422
+ className
4423
+ }
4424
+ );
4206
4425
  }
4207
4426
  if (sortedData.length === 0) {
4208
4427
  return /* @__PURE__ */ jsx("div", { className: cn("w-full", className), children: emptyState ?? /* @__PURE__ */ jsx(EmptyState, { title: "No data", description: "There's nothing here yet." }) });
@@ -4210,25 +4429,16 @@ function DataTable(props) {
4210
4429
  const totalPages = pagination ? Math.ceil(sortedData.length / effectivePageSize) : 1;
4211
4430
  return /* @__PURE__ */ jsxs("div", { className: cn("w-full", className), children: [
4212
4431
  /* @__PURE__ */ jsxs(Table, { children: [
4213
- /* @__PURE__ */ jsx(Table.Header, { className: stickyHeader ? "sticky top-0 z-10 bg-card" : void 0, children: /* @__PURE__ */ jsxs(Table.Row, { children: [
4214
- expandable ? /* @__PURE__ */ jsx(Table.HeaderCell, { children: /* @__PURE__ */ jsx("span", { className: "sr-only", children: "Expand" }) }) : null,
4215
- columns.map((col) => {
4216
- const isSortable = col.sortable === true;
4217
- const isActive = sort?.key === col.key;
4218
- return /* @__PURE__ */ jsx(
4219
- Table.HeaderCell,
4220
- {
4221
- align: col.align,
4222
- onSort: isSortable ? () => handleSort(col.key) : void 0,
4223
- sortDirection: isSortable ? isActive ? sort?.direction : "none" : void 0,
4224
- style: col.width ? { width: col.width } : void 0,
4225
- children: col.label
4226
- },
4227
- col.key
4228
- );
4229
- }),
4230
- rowActions ? /* @__PURE__ */ jsx(Table.HeaderCell, { children: /* @__PURE__ */ jsx("span", { className: "sr-only", children: "Actions" }) }) : null
4231
- ] }) }),
4432
+ /* @__PURE__ */ jsx(Table.Header, { className: stickyHeader ? "sticky top-0 z-10 bg-card" : void 0, children: /* @__PURE__ */ jsx(
4433
+ DataTableHeaderRow,
4434
+ {
4435
+ columns,
4436
+ sort,
4437
+ onSortClick: handleSort,
4438
+ hasExpandColumn: expandable !== void 0,
4439
+ hasActionsColumn: rowActions !== void 0
4440
+ }
4441
+ ) }),
4232
4442
  /* @__PURE__ */ jsx(Table.Body, { children: visibleData.map((row) => {
4233
4443
  const key = rowKey(row);
4234
4444
  const expandedContent = expandable ? expandable(row) : null;
@@ -4248,22 +4458,8 @@ function DataTable(props) {
4248
4458
  children: isExpanded ? /* @__PURE__ */ jsx(ChevronDown, { "aria-hidden": "true", className: "size-4" }) : /* @__PURE__ */ jsx(ChevronRight, { "aria-hidden": "true", className: "size-4" })
4249
4459
  }
4250
4460
  ) : null }) : null,
4251
- columns.map((col) => /* @__PURE__ */ jsx(Table.Cell, { align: col.align, className: col.className, children: col.render ? col.render(row) : String(row[col.key] ?? "") }, col.key)),
4252
- rowActions ? /* @__PURE__ */ jsx(Table.Cell, { align: "right", children: /* @__PURE__ */ jsxs(DropdownMenu, { children: [
4253
- /* @__PURE__ */ jsx(
4254
- DropdownMenu.Trigger,
4255
- {
4256
- "aria-label": "Row actions",
4257
- className: cn(
4258
- "inline-flex size-7 items-center justify-center rounded-md",
4259
- "text-muted-foreground hover:bg-muted hover:text-foreground",
4260
- "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
4261
- ),
4262
- children: /* @__PURE__ */ jsx(MoreHorizontal, { "aria-hidden": "true", className: "size-4" })
4263
- }
4264
- ),
4265
- /* @__PURE__ */ jsx(DropdownMenu.Content, { align: "end", children: rowActions(row) })
4266
- ] }) }) : null
4461
+ columns.map((col) => /* @__PURE__ */ jsx(Table.Cell, { align: col.align, className: col.className, children: renderCellContent(row, col) }, col.key)),
4462
+ rowActions ? /* @__PURE__ */ jsx(DataTableRowActionsCell, { children: rowActions(row) }) : null
4267
4463
  ] }),
4268
4464
  isExpanded && isExpandable ? /* @__PURE__ */ jsx("tr", { id: `expanded-${key}`, children: /* @__PURE__ */ jsx("td", { colSpan: expandedColSpan, className: "bg-muted/30 p-4", children: expandedContent }) }) : null
4269
4465
  ] }, key);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usetheo/ui",
3
- "version": "0.21.0",
3
+ "version": "0.22.0",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "description": "Violet Forge design system — generic React components + primitives (the non-AI foundation of @theokit/ui).",
@@ -40,6 +40,7 @@
40
40
  "@radix-ui/react-tabs": "^1.1.3",
41
41
  "@radix-ui/react-toast": "^1.2.15",
42
42
  "@radix-ui/react-tooltip": "^1.1.8",
43
+ "@tanstack/react-virtual": "^3.14.6",
43
44
  "class-variance-authority": "^0.7.1",
44
45
  "clsx": "^2.1.1",
45
46
  "cmdk": "^1.1.1",