@usetheo/ui 0.21.0 → 0.22.1

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
+ };
@@ -19,6 +19,8 @@ import type { ElementType, HTMLAttributes, ReactNode } from "react";
19
19
  export interface ActionBarProps extends Omit<HTMLAttributes<HTMLDivElement>, "children"> {
20
20
  search?: {
21
21
  placeholder: string;
22
+ /** Accessible name; defaults to the placeholder with trailing ellipsis stripped (issue #8). */
23
+ "aria-label"?: string;
22
24
  value: string;
23
25
  onChange: (value: string) => void;
24
26
  };
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({
@@ -3898,6 +3899,7 @@ var ActionBar = forwardRef(
3898
3899
  "input",
3899
3900
  {
3900
3901
  type: "search",
3902
+ "aria-label": search["aria-label"] ?? search.placeholder.replace(/(\.{3}|…)\s*$/, ""),
3901
3903
  placeholder: search.placeholder,
3902
3904
  value: search.value,
3903
3905
  onChange: (e) => search.onChange(e.target.value),
@@ -4094,6 +4096,74 @@ var PinInput = forwardRef(
4094
4096
  }
4095
4097
  );
4096
4098
  PinInput.displayName = "PinInput";
4099
+ function DataTableHeaderRow(props) {
4100
+ const { columns, sort, onSortClick, hasExpandColumn = false, hasActionsColumn = false } = props;
4101
+ return /* @__PURE__ */ jsxs(Table.Row, { children: [
4102
+ hasExpandColumn ? /* @__PURE__ */ jsx(Table.HeaderCell, { children: /* @__PURE__ */ jsx("span", { className: "sr-only", children: "Expand" }) }) : null,
4103
+ columns.map((col) => {
4104
+ const isSortable = col.sortable === true;
4105
+ const isActive = sort?.key === col.key;
4106
+ return /* @__PURE__ */ jsx(
4107
+ Table.HeaderCell,
4108
+ {
4109
+ align: col.align,
4110
+ onSort: isSortable ? () => onSortClick(col.key) : void 0,
4111
+ sortDirection: isSortable ? isActive ? sort?.direction : "none" : void 0,
4112
+ style: col.width ? { width: col.width } : void 0,
4113
+ children: col.label
4114
+ },
4115
+ col.key
4116
+ );
4117
+ }),
4118
+ hasActionsColumn ? /* @__PURE__ */ jsx(Table.HeaderCell, { children: /* @__PURE__ */ jsx("span", { className: "sr-only", children: "Actions" }) }) : null
4119
+ ] });
4120
+ }
4121
+ function DataTableRowActionsCell(props) {
4122
+ return /* @__PURE__ */ jsx(Table.Cell, { align: "right", children: /* @__PURE__ */ jsxs(DropdownMenu, { children: [
4123
+ /* @__PURE__ */ jsx(
4124
+ DropdownMenu.Trigger,
4125
+ {
4126
+ "aria-label": "Row actions",
4127
+ className: cn(
4128
+ "inline-flex size-7 items-center justify-center rounded-md",
4129
+ "text-muted-foreground hover:bg-muted hover:text-foreground",
4130
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
4131
+ ),
4132
+ children: /* @__PURE__ */ jsx(MoreHorizontal, { "aria-hidden": "true", className: "size-4" })
4133
+ }
4134
+ ),
4135
+ /* @__PURE__ */ jsx(DropdownMenu.Content, { align: "end", children: props.children })
4136
+ ] }) });
4137
+ }
4138
+ function renderCellContent(row, col) {
4139
+ return col.render ? col.render(row) : String(row[col.key] ?? "");
4140
+ }
4141
+ function DataTableLoading(props) {
4142
+ const { columns, hasExpandColumn, hasActionsColumn, stickyHeader, className } = props;
4143
+ const totalCols = columns.length + (hasExpandColumn ? 1 : 0) + (hasActionsColumn ? 1 : 0);
4144
+ return /* @__PURE__ */ jsx("div", { "data-slot": "data-table", className: cn("w-full", className), children: /* @__PURE__ */ jsxs(Table, { children: [
4145
+ /* @__PURE__ */ jsx(Table.Header, { className: stickyHeader ? "sticky top-0 bg-card" : void 0, children: /* @__PURE__ */ jsx(
4146
+ DataTableHeaderRow,
4147
+ {
4148
+ columns: columns.map((col) => ({ ...col, sortable: false })),
4149
+ sort: null,
4150
+ onSortClick: () => {
4151
+ },
4152
+ hasExpandColumn,
4153
+ hasActionsColumn
4154
+ }
4155
+ ) }),
4156
+ /* @__PURE__ */ jsx(Table.Body, { children: Array.from({ length: 5 }, (_, i) => (
4157
+ // biome-ignore lint/suspicious/noArrayIndexKey: skeleton rows are positional placeholders
4158
+ /* @__PURE__ */ jsx(Table.Row, { children: Array.from({ length: totalCols }, (_2, j) => (
4159
+ // biome-ignore lint/suspicious/noArrayIndexKey: skeleton cells are positional placeholders
4160
+ /* @__PURE__ */ jsx(Table.Cell, { children: /* @__PURE__ */ jsx(Skeleton, { className: "h-4 w-full" }) }, `s-${i}-${j}`)
4161
+ )) }, `skeleton-${i}`)
4162
+ )) })
4163
+ ] }) });
4164
+ }
4165
+
4166
+ // src/components/composites/data-table/compare-values.ts
4097
4167
  function compareValues(a, b) {
4098
4168
  if (a === b) return 0;
4099
4169
  if (a === null || a === void 0) return -1;
@@ -4101,7 +4171,184 @@ function compareValues(a, b) {
4101
4171
  if (typeof a === "number" && typeof b === "number") return a - b;
4102
4172
  return String(a).localeCompare(String(b));
4103
4173
  }
4174
+
4175
+ // src/components/composites/data-table/use-data-table-sort.ts
4176
+ function useDataTableSort(opts) {
4177
+ const { data, columns, defaultSort, sort: controlledSort, onSortChange } = opts;
4178
+ const isControlledSort = onSortChange !== void 0;
4179
+ const [uncontrolledSort, setUncontrolledSort] = useState(
4180
+ defaultSort ?? null
4181
+ );
4182
+ const sort = isControlledSort ? controlledSort ?? null : uncontrolledSort;
4183
+ function handleSort(columnKey) {
4184
+ let nextSort;
4185
+ if (sort?.key !== columnKey) {
4186
+ nextSort = { key: columnKey, direction: "asc" };
4187
+ } else if (sort.direction === "asc") {
4188
+ nextSort = { key: columnKey, direction: "desc" };
4189
+ } else {
4190
+ nextSort = null;
4191
+ }
4192
+ if (isControlledSort) {
4193
+ onSortChange?.(nextSort);
4194
+ } else {
4195
+ setUncontrolledSort(nextSort);
4196
+ opts.onUncontrolledSortChange?.();
4197
+ }
4198
+ }
4199
+ const sortedData = useMemo(() => {
4200
+ if (isControlledSort || sort === null) return data;
4201
+ const col = columns.find((c) => c.key === sort.key);
4202
+ if (!col) return data;
4203
+ const sorted = [...data].sort((a, b) => {
4204
+ const aVal = col.render ? null : a[sort.key];
4205
+ const bVal = col.render ? null : b[sort.key];
4206
+ const cmp = compareValues(aVal, bVal);
4207
+ return sort.direction === "asc" ? cmp : -cmp;
4208
+ });
4209
+ return sorted;
4210
+ }, [data, sort, isControlledSort, columns]);
4211
+ return { sort, handleSort, sortedData };
4212
+ }
4213
+ var EXCLUSIVE_PROPS = ["pagination", "expandable"];
4214
+ function SpacerRow(props) {
4215
+ if (props.height <= 0) return null;
4216
+ return (
4217
+ // 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
4218
+ /* @__PURE__ */ jsx(
4219
+ "tr",
4220
+ {
4221
+ "data-slot": `data-table-virtual-spacer-${props.slot}`,
4222
+ role: "presentation",
4223
+ style: { height: `${props.height}px` },
4224
+ children: /* @__PURE__ */ jsx("td", { colSpan: props.colSpan, style: { padding: 0 } })
4225
+ }
4226
+ )
4227
+ );
4228
+ }
4229
+ function DataTableVirtualizedBody(props) {
4230
+ const {
4231
+ data,
4232
+ columns,
4233
+ rowKey,
4234
+ virtualized,
4235
+ rowActions,
4236
+ defaultSort,
4237
+ sort: controlledSort,
4238
+ onSortChange,
4239
+ loading = false,
4240
+ emptyState,
4241
+ className
4242
+ } = props;
4243
+ if (isDev()) {
4244
+ for (const key of EXCLUSIVE_PROPS) {
4245
+ if (props[key]) {
4246
+ console.warn(
4247
+ `DataTable: \`${key}\` is ignored in virtualized mode \u2014 the modes are mutually exclusive.`
4248
+ );
4249
+ }
4250
+ }
4251
+ if (virtualized.rowHeight < 1) {
4252
+ console.warn("DataTable: virtualized.rowHeight must be >= 1px \u2014 falling back to 1.");
4253
+ }
4254
+ }
4255
+ const rowHeight = Math.max(1, virtualized.rowHeight);
4256
+ const parentRef = useRef(null);
4257
+ const { sort, handleSort, sortedData } = useDataTableSort({
4258
+ data,
4259
+ columns,
4260
+ defaultSort,
4261
+ sort: controlledSort,
4262
+ onSortChange
4263
+ });
4264
+ const virtualizer = useVirtualizer({
4265
+ // Injection first (review F-dom-6): the @internal test hook can add
4266
+ // observeElementRect but can never override the core window contract.
4267
+ ...virtualized.virtualizerOptions,
4268
+ count: sortedData.length,
4269
+ getScrollElement: () => parentRef.current,
4270
+ estimateSize: () => rowHeight,
4271
+ overscan: virtualized.overscan ?? 5
4272
+ });
4273
+ if (loading) {
4274
+ return /* @__PURE__ */ jsx(
4275
+ DataTableLoading,
4276
+ {
4277
+ columns,
4278
+ hasExpandColumn: false,
4279
+ hasActionsColumn: rowActions !== void 0,
4280
+ stickyHeader: true,
4281
+ className
4282
+ }
4283
+ );
4284
+ }
4285
+ if (sortedData.length === 0) {
4286
+ return /* @__PURE__ */ jsx("div", { className: cn("w-full", className), children: emptyState ?? /* @__PURE__ */ jsx(EmptyState, { title: "No data", description: "There's nothing here yet." }) });
4287
+ }
4288
+ const items = virtualizer.getVirtualItems();
4289
+ const totalSize = virtualizer.getTotalSize();
4290
+ const firstItem = items[0];
4291
+ const lastItem = items[items.length - 1];
4292
+ const paddingTop = firstItem ? firstItem.start : 0;
4293
+ const paddingBottom = lastItem ? totalSize - lastItem.end : totalSize;
4294
+ const colSpan = columns.length + (rowActions ? 1 : 0);
4295
+ return /* @__PURE__ */ jsx("div", { "data-slot": "data-table-virtual-body", className: cn("w-full", className), children: /* @__PURE__ */ jsx(
4296
+ "section",
4297
+ {
4298
+ ref: parentRef,
4299
+ "aria-label": "Table scroll area",
4300
+ tabIndex: 0,
4301
+ className: "overflow-auto focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
4302
+ style: { height: virtualized.height },
4303
+ "data-slot": "data-table-virtual-scroll",
4304
+ children: /* @__PURE__ */ jsxs(Table, { "aria-rowcount": sortedData.length + 1, children: [
4305
+ /* @__PURE__ */ jsx(Table.Header, { className: "sticky top-0 z-10 bg-card", children: /* @__PURE__ */ jsx(
4306
+ DataTableHeaderRow,
4307
+ {
4308
+ columns,
4309
+ sort,
4310
+ onSortClick: handleSort,
4311
+ hasActionsColumn: rowActions !== void 0
4312
+ }
4313
+ ) }),
4314
+ /* @__PURE__ */ jsxs(Table.Body, { children: [
4315
+ /* @__PURE__ */ jsx(SpacerRow, { slot: "top", height: paddingTop, colSpan }),
4316
+ items.map((vRow) => {
4317
+ const row = sortedData[vRow.index];
4318
+ return /* @__PURE__ */ jsxs(
4319
+ Table.Row,
4320
+ {
4321
+ "aria-rowindex": vRow.index + 2,
4322
+ style: { height: `${rowHeight}px` },
4323
+ children: [
4324
+ columns.map((col) => /* @__PURE__ */ jsx(
4325
+ Table.Cell,
4326
+ {
4327
+ align: col.align,
4328
+ className: cn("overflow-hidden py-1.5", col.className),
4329
+ children: renderCellContent(row, col)
4330
+ },
4331
+ col.key
4332
+ )),
4333
+ rowActions ? /* @__PURE__ */ jsx(DataTableRowActionsCell, { children: rowActions(row) }) : null
4334
+ ]
4335
+ },
4336
+ rowKey(row)
4337
+ );
4338
+ }),
4339
+ /* @__PURE__ */ jsx(SpacerRow, { slot: "bottom", height: paddingBottom, colSpan })
4340
+ ] })
4341
+ ] })
4342
+ }
4343
+ ) });
4344
+ }
4104
4345
  function DataTable(props) {
4346
+ if (props.virtualized) {
4347
+ return /* @__PURE__ */ jsx(DataTableVirtualizedBody, { ...props, virtualized: props.virtualized });
4348
+ }
4349
+ return /* @__PURE__ */ jsx(DataTableDefaultBody, { ...props });
4350
+ }
4351
+ function DataTableDefaultBody(props) {
4105
4352
  const {
4106
4353
  data,
4107
4354
  columns,
@@ -4118,32 +4365,22 @@ function DataTable(props) {
4118
4365
  emptyState,
4119
4366
  className
4120
4367
  } = props;
4121
- const isControlledSort = onSortChange !== void 0;
4122
- const [uncontrolledSort, setUncontrolledSort] = useState(
4123
- defaultSort ?? null
4124
- );
4125
- const sort = isControlledSort ? controlledSort ?? null : uncontrolledSort;
4126
4368
  const isControlledPage = pagination?.controlledPage !== void 0;
4127
4369
  const [uncontrolledPage, setUncontrolledPage] = useState(0);
4128
4370
  const currentPage = isControlledPage ? pagination?.controlledPage ?? 0 : uncontrolledPage;
4129
4371
  const effectivePageSize = Math.max(1, pagination?.pageSize ?? 10);
4130
4372
  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);
4373
+ const { sort, handleSort, sortedData } = useDataTableSort({
4374
+ data,
4375
+ columns,
4376
+ defaultSort,
4377
+ sort: controlledSort,
4378
+ onSortChange,
4379
+ // EC-8: sort change resets pagination to page 0
4380
+ onUncontrolledSortChange: () => {
4144
4381
  if (!isControlledPage) setUncontrolledPage(0);
4145
4382
  }
4146
- }
4383
+ });
4147
4384
  function handlePageChange(page) {
4148
4385
  const zeroIdx = page - 1;
4149
4386
  if (isControlledPage) {
@@ -4167,18 +4404,6 @@ function DataTable(props) {
4167
4404
  });
4168
4405
  }
4169
4406
  }
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
4407
  const visibleData = useMemo(() => {
4183
4408
  if (!pagination) return sortedData;
4184
4409
  if (isControlledPage) return sortedData;
@@ -4187,22 +4412,17 @@ function DataTable(props) {
4187
4412
  }, [sortedData, pagination, isControlledPage, currentPage, effectivePageSize]);
4188
4413
  const extraCols = (expandable ? 1 : 0) + (rowActions ? 1 : 0);
4189
4414
  const expandedColSpan = columns.length + extraCols;
4190
- const totalCols = columns.length + extraCols;
4191
4415
  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
- ] }) });
4416
+ return /* @__PURE__ */ jsx(
4417
+ DataTableLoading,
4418
+ {
4419
+ columns,
4420
+ hasExpandColumn: expandable !== void 0,
4421
+ hasActionsColumn: rowActions !== void 0,
4422
+ stickyHeader,
4423
+ className
4424
+ }
4425
+ );
4206
4426
  }
4207
4427
  if (sortedData.length === 0) {
4208
4428
  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 +4430,16 @@ function DataTable(props) {
4210
4430
  const totalPages = pagination ? Math.ceil(sortedData.length / effectivePageSize) : 1;
4211
4431
  return /* @__PURE__ */ jsxs("div", { className: cn("w-full", className), children: [
4212
4432
  /* @__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
- ] }) }),
4433
+ /* @__PURE__ */ jsx(Table.Header, { className: stickyHeader ? "sticky top-0 z-10 bg-card" : void 0, children: /* @__PURE__ */ jsx(
4434
+ DataTableHeaderRow,
4435
+ {
4436
+ columns,
4437
+ sort,
4438
+ onSortClick: handleSort,
4439
+ hasExpandColumn: expandable !== void 0,
4440
+ hasActionsColumn: rowActions !== void 0
4441
+ }
4442
+ ) }),
4232
4443
  /* @__PURE__ */ jsx(Table.Body, { children: visibleData.map((row) => {
4233
4444
  const key = rowKey(row);
4234
4445
  const expandedContent = expandable ? expandable(row) : null;
@@ -4248,22 +4459,8 @@ function DataTable(props) {
4248
4459
  children: isExpanded ? /* @__PURE__ */ jsx(ChevronDown, { "aria-hidden": "true", className: "size-4" }) : /* @__PURE__ */ jsx(ChevronRight, { "aria-hidden": "true", className: "size-4" })
4249
4460
  }
4250
4461
  ) : 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
4462
+ columns.map((col) => /* @__PURE__ */ jsx(Table.Cell, { align: col.align, className: col.className, children: renderCellContent(row, col) }, col.key)),
4463
+ rowActions ? /* @__PURE__ */ jsx(DataTableRowActionsCell, { children: rowActions(row) }) : null
4267
4464
  ] }),
4268
4465
  isExpanded && isExpandable ? /* @__PURE__ */ jsx("tr", { id: `expanded-${key}`, children: /* @__PURE__ */ jsx("td", { colSpan: expandedColSpan, className: "bg-muted/30 p-4", children: expandedContent }) }) : null
4269
4466
  ] }, 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.1",
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",