@izumisy-tailor/tailor-data-viewer 0.2.19 → 0.2.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@izumisy-tailor/tailor-data-viewer",
3
3
  "private": false,
4
- "version": "0.2.19",
4
+ "version": "0.2.21",
5
5
  "type": "module",
6
6
  "description": "Flexible data viewer component for Tailor Platform",
7
7
  "files": [
@@ -15,7 +15,7 @@ const CollectionContext = createContext<UseCollectionReturn<
15
15
  *
16
16
  * <CollectionProvider value={collection}>
17
17
  * <FilterPanel />
18
- * <DataTable.Root {...table.rootProps}>...</DataTable.Root>
18
+ * <DataTable.Root>...</DataTable.Root>
19
19
  * <Pagination {...table} />
20
20
  * </CollectionProvider>
21
21
  * ```
@@ -1,7 +1,9 @@
1
1
  import { render, screen } from "@testing-library/react";
2
2
  import { describe, it, expect } from "vitest";
3
3
  import { Table } from "./table";
4
- import { DataTable } from "./data-table/index";
4
+ import { DataTable } from "./data-table/data-table";
5
+ import { DataTableContext } from "./data-table/data-table-context";
6
+ import type { DataTableContextValue } from "./data-table/data-table-context";
5
7
  import type { Column } from "./types";
6
8
 
7
9
  describe("Table (static)", () => {
@@ -65,13 +67,51 @@ const testRows: TestRow[] = [
65
67
  { id: "2", name: "Bob", status: "Inactive" },
66
68
  ];
67
69
 
70
+ const noopRowOps = {
71
+ updateRow: () => ({ rollback: () => {} }),
72
+ deleteRow: () => ({
73
+ rollback: () => {},
74
+ deletedRow: {} as TestRow,
75
+ }),
76
+ insertRow: () => ({ rollback: () => {} }),
77
+ };
78
+
79
+ const defaultPageInfo = {
80
+ hasNextPage: false,
81
+ endCursor: null,
82
+ hasPreviousPage: false,
83
+ startCursor: null,
84
+ };
85
+
86
+ function createCtx(
87
+ overrides: Partial<DataTableContextValue<TestRow>> = {},
88
+ ): DataTableContextValue<TestRow> {
89
+ return {
90
+ columns: testColumns,
91
+ rows: testRows,
92
+ loading: false,
93
+ error: null,
94
+ sortStates: [],
95
+ visibleColumns: testColumns,
96
+ isColumnVisible: () => true,
97
+ toggleColumn: () => {},
98
+ showAllColumns: () => {},
99
+ hideAllColumns: () => {},
100
+ pageInfo: defaultPageInfo,
101
+ ...noopRowOps,
102
+ ...overrides,
103
+ };
104
+ }
105
+
68
106
  describe("DataTable", () => {
69
107
  it("renders data-bound table with auto-generated rows", () => {
70
108
  render(
71
- <DataTable.Root columns={testColumns} rows={testRows}>
72
- <DataTable.Headers />
73
- <DataTable.Body />
74
- </DataTable.Root>,
109
+ <DataTableContext.Provider value={createCtx()}>
110
+ <DataTable.Root>
111
+ <DataTable.Headers />
112
+ <DataTable.Body />
113
+ </DataTable.Root>
114
+ </DataTableContext.Provider>,
75
115
  );
76
116
 
77
117
  expect(screen.getByText("Name")).toBeInTheDocument();
@@ -83,10 +123,12 @@ describe("DataTable", () => {
83
123
 
84
124
  it("renders display columns via render function", () => {
85
125
  render(
86
- <DataTable.Root columns={testColumns} rows={testRows}>
87
- <DataTable.Headers />
88
- <DataTable.Body />
89
- </DataTable.Root>,
126
+ <DataTableContext.Provider value={createCtx()}>
127
+ <DataTable.Root>
128
+ <DataTable.Headers />
129
+ <DataTable.Body />
130
+ </DataTable.Root>
131
+ </DataTableContext.Provider>,
90
132
  );
91
133
 
92
134
  expect(screen.getByText("Edit Alice")).toBeInTheDocument();
@@ -95,10 +137,12 @@ describe("DataTable", () => {
95
137
 
96
138
  it("shows loading state", () => {
97
139
  render(
98
- <DataTable.Root columns={testColumns} rows={[]} loading>
99
- <DataTable.Headers />
100
- <DataTable.Body />
101
- </DataTable.Root>,
140
+ <DataTableContext.Provider value={createCtx({ rows: [], loading: true })}>
141
+ <DataTable.Root>
142
+ <DataTable.Headers />
143
+ <DataTable.Body />
144
+ </DataTable.Root>
145
+ </DataTableContext.Provider>,
102
146
  );
103
147
 
104
148
  expect(screen.getByText("Loading...")).toBeInTheDocument();
@@ -107,10 +151,12 @@ describe("DataTable", () => {
107
151
  it("shows error state", () => {
108
152
  const err = new Error("Something went wrong");
109
153
  render(
110
- <DataTable.Root columns={testColumns} rows={[]} error={err}>
111
- <DataTable.Headers />
112
- <DataTable.Body />
113
- </DataTable.Root>,
154
+ <DataTableContext.Provider value={createCtx({ rows: [], error: err })}>
155
+ <DataTable.Root>
156
+ <DataTable.Headers />
157
+ <DataTable.Body />
158
+ </DataTable.Root>
159
+ </DataTableContext.Provider>,
114
160
  );
115
161
 
116
162
  expect(screen.getByText("Error: Something went wrong")).toBeInTheDocument();
@@ -118,10 +164,12 @@ describe("DataTable", () => {
118
164
 
119
165
  it("shows empty state", () => {
120
166
  render(
121
- <DataTable.Root columns={testColumns} rows={[]}>
122
- <DataTable.Headers />
123
- <DataTable.Body />
124
- </DataTable.Root>,
167
+ <DataTableContext.Provider value={createCtx({ rows: [] })}>
168
+ <DataTable.Root>
169
+ <DataTable.Headers />
170
+ <DataTable.Body />
171
+ </DataTable.Root>
172
+ </DataTableContext.Provider>,
125
173
  );
126
174
 
127
175
  expect(screen.getByText("No data")).toBeInTheDocument();
@@ -129,14 +177,16 @@ describe("DataTable", () => {
129
177
 
130
178
  it("renders sort indicator on sorted column", () => {
131
179
  render(
132
- <DataTable.Root
133
- columns={testColumns}
134
- rows={testRows}
135
- sortStates={[{ field: "name", direction: "Asc" }]}
180
+ <DataTableContext.Provider
181
+ value={createCtx({
182
+ sortStates: [{ field: "name", direction: "Asc" }],
183
+ })}
136
184
  >
137
- <DataTable.Headers />
138
- <DataTable.Body />
139
- </DataTable.Root>,
185
+ <DataTable.Root>
186
+ <DataTable.Headers />
187
+ <DataTable.Body />
188
+ </DataTable.Root>
189
+ </DataTableContext.Provider>,
140
190
  );
141
191
 
142
192
  expect(screen.getByText("▲")).toBeInTheDocument();
@@ -144,14 +194,16 @@ describe("DataTable", () => {
144
194
 
145
195
  it("supports custom rendering with children", () => {
146
196
  render(
147
- <DataTable.Root columns={testColumns} rows={testRows}>
148
- <DataTable.Headers />
149
- <DataTable.Body>
150
- <DataTable.Row>
151
- <DataTable.Cell>Custom Cell</DataTable.Cell>
152
- </DataTable.Row>
153
- </DataTable.Body>
154
- </DataTable.Root>,
197
+ <DataTableContext.Provider value={createCtx()}>
198
+ <DataTable.Root>
199
+ <DataTable.Headers />
200
+ <DataTable.Body>
201
+ <DataTable.Row>
202
+ <DataTable.Cell>Custom Cell</DataTable.Cell>
203
+ </DataTable.Row>
204
+ </DataTable.Body>
205
+ </DataTable.Root>
206
+ </DataTableContext.Provider>,
155
207
  );
156
208
 
157
209
  expect(screen.getByText("Custom Cell")).toBeInTheDocument();
@@ -1,8 +1,8 @@
1
1
  import { createContext, useContext } from "react";
2
- import type { Column, PageInfo, RowOperations, SortState } from "../types";
2
+ import type { Column, PageInfo, RowAction, RowOperations, SortState } from "../types";
3
3
 
4
4
  /**
5
- * Context value provided by `DataTable.Root` or `DataTable.Provider`.
5
+ * Context value provided by `DataTable.Provider`.
6
6
  *
7
7
  * Exposes row operations for optimistic updates, table state, column
8
8
  * visibility, and page info so that `DataTable.Headers` / `DataTable.Body`
@@ -14,7 +14,7 @@ export interface DataTableContextValue<TRow extends Record<string, unknown>> {
14
14
  deleteRow: RowOperations<TRow>["deleteRow"];
15
15
  insertRow: RowOperations<TRow>["insertRow"];
16
16
 
17
- // Table state propagated from DataTable.Root / DataTable.Provider
17
+ // Table state propagated from DataTable.Provider
18
18
  columns: Column<TRow>[];
19
19
  rows: TRow[];
20
20
  loading: boolean;
@@ -31,6 +31,12 @@ export interface DataTableContextValue<TRow extends Record<string, unknown>> {
31
31
 
32
32
  // Page info from GraphQL response (populated by DataTable.Provider)
33
33
  pageInfo: PageInfo;
34
+
35
+ // Row interaction (populated by DataTable.Root)
36
+ /** Handler called when a row is clicked */
37
+ onClickRow?: (row: TRow) => void;
38
+ /** Row action definitions for the actions column */
39
+ rowActions?: RowAction<TRow>[];
34
40
  }
35
41
 
36
42
  // Using `any` for the context default since generic contexts need a base type.
@@ -40,9 +46,9 @@ const DataTableContext = createContext<DataTableContextValue<any> | null>(null);
40
46
  export { DataTableContext };
41
47
 
42
48
  /**
43
- * Hook to access row operations from the nearest `DataTable.Root`.
49
+ * Hook to access row operations from the nearest `DataTable.Provider`.
44
50
  *
45
- * @throws Error if used outside of `DataTable.Root`.
51
+ * @throws Error if used outside of `DataTable.Provider`.
46
52
  *
47
53
  * @example
48
54
  * ```tsx
@@ -57,7 +63,9 @@ export function useDataTableContext<
57
63
  >(): DataTableContextValue<TRow> {
58
64
  const ctx = useContext(DataTableContext);
59
65
  if (!ctx) {
60
- throw new Error("useDataTableContext must be used within <DataTable.Root>");
66
+ throw new Error(
67
+ "useDataTableContext must be used within <DataTable.Provider>",
68
+ );
61
69
  }
62
70
  return ctx as DataTableContextValue<TRow>;
63
71
  }
@@ -1,18 +1,17 @@
1
1
  import {
2
2
  createElement,
3
3
  useContext,
4
+ useCallback,
5
+ useEffect,
6
+ useRef,
7
+ useState,
4
8
  type ComponentProps,
5
9
  type ReactNode,
6
10
  } from "react";
7
11
  import { cn } from "../lib/utils";
8
12
  import { CollectionProvider } from "../collection/collection-provider";
9
13
  import { Table } from "../table";
10
- import type {
11
- Column,
12
- DataTableRootProps,
13
- PageInfo,
14
- UseDataTableReturn,
15
- } from "../types";
14
+ import type { Column, RowAction, UseDataTableReturn } from "../types";
16
15
  import {
17
16
  DataTableContext,
18
17
  type DataTableContextValue,
@@ -23,66 +22,28 @@ import {
23
22
  // =============================================================================
24
23
 
25
24
  /**
26
- * Internal noop row operations used when none are provided.
25
+ * Root container for the DataTable compound component.
26
+ *
27
+ * Must be used within `DataTable.Provider`.
28
+ * Renders a `<Table.Root>` wrapper; all data (including `onClickRow` and
29
+ * `rowActions`) is read from context via `useDataTable()` options.
30
+ *
31
+ * @example
32
+ * ```tsx
33
+ * <DataTable.Root>
34
+ * <DataTable.Headers />
35
+ * <DataTable.Body />
36
+ * </DataTable.Root>
37
+ * ```
27
38
  */
28
- const noopRowOps = {
29
- updateRow: () => ({ rollback: () => {} }),
30
- deleteRow: () => ({
31
- rollback: () => {},
32
- deletedRow: {} as Record<string, unknown>,
33
- }),
34
- insertRow: () => ({ rollback: () => {} }),
35
- };
36
-
37
- const defaultPageInfo: PageInfo = {
38
- hasNextPage: false,
39
- endCursor: null,
40
- hasPreviousPage: false,
41
- startCursor: null,
42
- };
43
-
44
- function DataTableRoot<TRow extends Record<string, unknown>>({
45
- columns = [] as Column<TRow>[],
46
- rows = [] as TRow[],
47
- loading = false,
48
- error = null,
49
- sortStates = [],
50
- onSort,
51
- rowOperations,
39
+ function DataTableRoot({
52
40
  children,
53
- }: DataTableRootProps<TRow>) {
54
- // When rendered inside DataTable.Provider, inherit missing fields from context
55
- const parentCtx = useContext(
56
- DataTableContext,
57
- ) as DataTableContextValue<TRow> | null;
58
-
59
- const contextValue: DataTableContextValue<TRow> = {
60
- updateRow: (rowOperations?.updateRow ??
61
- noopRowOps.updateRow) as DataTableContextValue<TRow>["updateRow"],
62
- deleteRow: (rowOperations?.deleteRow ??
63
- noopRowOps.deleteRow) as DataTableContextValue<TRow>["deleteRow"],
64
- insertRow: (rowOperations?.insertRow ??
65
- noopRowOps.insertRow) as DataTableContextValue<TRow>["insertRow"],
66
- columns,
67
- rows,
68
- loading,
69
- error,
70
- sortStates,
71
- onSort,
72
- // Inherit column visibility & pageInfo from parent context (DataTable.Provider)
73
- visibleColumns: parentCtx?.visibleColumns ?? columns,
74
- isColumnVisible: parentCtx?.isColumnVisible ?? (() => true),
75
- toggleColumn: parentCtx?.toggleColumn ?? (() => {}),
76
- showAllColumns: parentCtx?.showAllColumns ?? (() => {}),
77
- hideAllColumns: parentCtx?.hideAllColumns ?? (() => {}),
78
- pageInfo: parentCtx?.pageInfo ?? defaultPageInfo,
79
- };
80
-
81
- return (
82
- <DataTableContext.Provider value={contextValue}>
83
- <Table.Root>{children}</Table.Root>
84
- </DataTableContext.Provider>
85
- );
41
+ className,
42
+ }: {
43
+ children: ReactNode;
44
+ className?: string;
45
+ }) {
46
+ return <Table.Root className={className}>{children}</Table.Root>;
86
47
  }
87
48
 
88
49
  // =============================================================================
@@ -125,8 +86,8 @@ function DataTableProviderComponent<TRow extends Record<string, unknown>>({
125
86
  rows: value.rows,
126
87
  loading: value.loading,
127
88
  error: value.error,
128
- sortStates: value.rootProps.sortStates ?? [],
129
- onSort: value.rootProps.onSort,
89
+ sortStates: value.sortStates ?? [],
90
+ onSort: value.onSort,
130
91
  updateRow: value.updateRow,
131
92
  deleteRow: value.deleteRow,
132
93
  insertRow: value.insertRow,
@@ -136,6 +97,8 @@ function DataTableProviderComponent<TRow extends Record<string, unknown>>({
136
97
  showAllColumns: value.showAllColumns,
137
98
  hideAllColumns: value.hideAllColumns,
138
99
  pageInfo: value.pageInfo,
100
+ onClickRow: value.onClickRow,
101
+ rowActions: value.rowActions,
139
102
  };
140
103
 
141
104
  const collectionValue = value.collection ?? null;
@@ -160,16 +123,14 @@ function DataTableProviderComponent<TRow extends Record<string, unknown>>({
160
123
  // DataTable.Headers
161
124
  // =============================================================================
162
125
 
163
- interface DataTableHeadersProps {
164
- className?: string;
165
- }
166
-
167
- function DataTableHeaders({ className }: DataTableHeadersProps) {
126
+ function DataTableHeaders({ className }: { className?: string }) {
168
127
  const ctx = useContext(DataTableContext);
169
128
  if (!ctx) {
170
- throw new Error("<DataTable.Headers> must be used within <DataTable.Root>");
129
+ throw new Error(
130
+ "<DataTable.Headers> must be used within <DataTable.Provider>",
131
+ );
171
132
  }
172
- const { columns, sortStates, onSort } = ctx;
133
+ const { columns, sortStates, onSort, rowActions } = ctx;
173
134
 
174
135
  return (
175
136
  <Table.Headers className={className}>
@@ -208,6 +169,11 @@ function DataTableHeaders({ className }: DataTableHeadersProps) {
208
169
  </Table.HeaderCell>
209
170
  );
210
171
  })}
172
+ {rowActions && rowActions.length > 0 && (
173
+ <Table.HeaderCell style={{ width: 50 }}>
174
+ <span className="sr-only">操作</span>
175
+ </Table.HeaderCell>
176
+ )}
211
177
  </Table.HeaderRow>
212
178
  </Table.Headers>
213
179
  );
@@ -225,17 +191,22 @@ function SortIndicator({ direction }: { direction: "Asc" | "Desc" }) {
225
191
  // DataTable.Body
226
192
  // =============================================================================
227
193
 
228
- interface DataTableBodyProps {
194
+ function DataTableBody({
195
+ children,
196
+ className,
197
+ }: {
229
198
  children?: ReactNode;
230
199
  className?: string;
231
- }
232
-
233
- function DataTableBody({ children, className }: DataTableBodyProps) {
200
+ }) {
234
201
  const ctx = useContext(DataTableContext);
235
202
  if (!ctx) {
236
- throw new Error("<DataTable.Body> must be used within <DataTable.Root>");
203
+ throw new Error(
204
+ "<DataTable.Body> must be used within <DataTable.Provider>",
205
+ );
237
206
  }
238
- const { columns, rows, loading, error } = ctx;
207
+ const { columns, rows, loading, error, onClickRow, rowActions } = ctx;
208
+ const hasRowActions = rowActions && rowActions.length > 0;
209
+ const totalColSpan = (columns?.length ?? 1) + (hasRowActions ? 1 : 0);
239
210
 
240
211
  // If children are provided, render them directly (custom rendering)
241
212
  if (children) {
@@ -248,7 +219,7 @@ function DataTableBody({ children, className }: DataTableBodyProps) {
248
219
  {loading && (!rows || rows.length === 0) && (
249
220
  <Table.Row>
250
221
  <Table.Cell
251
- colSpan={columns?.length ?? 1}
222
+ colSpan={totalColSpan}
252
223
  className="h-24 text-center"
253
224
  >
254
225
  <span className="text-muted-foreground">Loading...</span>
@@ -258,7 +229,7 @@ function DataTableBody({ children, className }: DataTableBodyProps) {
258
229
  {error && (
259
230
  <Table.Row>
260
231
  <Table.Cell
261
- colSpan={columns?.length ?? 1}
232
+ colSpan={totalColSpan}
262
233
  className="h-24 text-center"
263
234
  >
264
235
  <span className="text-destructive">Error: {error.message}</span>
@@ -268,7 +239,7 @@ function DataTableBody({ children, className }: DataTableBodyProps) {
268
239
  {!loading && !error && (!rows || rows.length === 0) && (
269
240
  <Table.Row>
270
241
  <Table.Cell
271
- colSpan={columns?.length ?? 1}
242
+ colSpan={totalColSpan}
272
243
  className="h-24 text-center"
273
244
  >
274
245
  <span className="text-muted-foreground">No data</span>
@@ -276,19 +247,31 @@ function DataTableBody({ children, className }: DataTableBodyProps) {
276
247
  </Table.Row>
277
248
  )}
278
249
  {rows?.map((row, rowIndex) => (
279
- <DataTableRow key={rowIndex} row={row}>
250
+ <Table.Row
251
+ key={rowIndex}
252
+ className={cn(onClickRow && "cursor-pointer")}
253
+ onClick={onClickRow ? () => onClickRow(row) : undefined}
254
+ >
280
255
  {columns?.map((col) => {
281
256
  const key = col.kind === "field" ? col.dataKey : col.id;
282
257
  return (
283
- <DataTableCell
258
+ <Table.Cell
284
259
  key={key}
285
- row={row}
286
- column={col}
287
- rowIndex={rowIndex}
288
- />
260
+ style={col.width ? { width: col.width } : undefined}
261
+ >
262
+ {resolveContent(row, col, rowIndex)}
263
+ </Table.Cell>
289
264
  );
290
265
  })}
291
- </DataTableRow>
266
+ {hasRowActions && (
267
+ <Table.Cell
268
+ style={{ width: 50 }}
269
+ onClick={(e) => e.stopPropagation()}
270
+ >
271
+ <RowActionsMenu actions={rowActions} row={row} />
272
+ </Table.Cell>
273
+ )}
274
+ </Table.Row>
292
275
  ))}
293
276
  </Table.Body>
294
277
  );
@@ -298,82 +281,54 @@ function DataTableBody({ children, className }: DataTableBodyProps) {
298
281
  // DataTable.Row
299
282
  // =============================================================================
300
283
 
301
- interface DataTableRowComponentProps<
302
- TRow extends Record<string, unknown>,
303
- > extends Omit<ComponentProps<"tr">, "children"> {
304
- row?: TRow;
305
- children?: ReactNode;
306
- }
307
-
308
- function DataTableRow<TRow extends Record<string, unknown>>({
309
- children,
310
- ...restProps
311
- }: DataTableRowComponentProps<TRow>) {
312
- return <Table.Row {...restProps}>{children}</Table.Row>;
284
+ /**
285
+ * Thin wrapper around `Table.Row` for use in custom rendering within
286
+ * `DataTable.Body`.
287
+ */
288
+ function DataTableRow(props: ComponentProps<"tr">) {
289
+ return <Table.Row {...props} />;
313
290
  }
314
291
 
315
292
  // =============================================================================
316
293
  // DataTable.Cell
317
294
  // =============================================================================
318
295
 
319
- interface DataTableCellComponentProps<
320
- TRow extends Record<string, unknown>,
321
- > extends ComponentProps<"td"> {
322
- row?: TRow;
323
- column?: Column<TRow>;
324
- rowIndex?: number;
296
+ /**
297
+ * Thin wrapper around `Table.Cell` for use in custom rendering within
298
+ * `DataTable.Body`.
299
+ */
300
+ function DataTableCell(props: ComponentProps<"td">) {
301
+ return <Table.Cell {...props} />;
325
302
  }
326
303
 
327
- function DataTableCell<TRow extends Record<string, unknown>>({
328
- row,
329
- column,
330
- rowIndex = 0,
331
- children,
332
- className,
333
- ...restProps
334
- }: DataTableCellComponentProps<TRow>) {
335
- const content = resolveContent(children, row, column, rowIndex);
336
-
337
- return (
338
- <Table.Cell
339
- className={className}
340
- style={column?.width ? { width: column.width } : undefined}
341
- {...restProps}
342
- >
343
- {content}
344
- </Table.Cell>
345
- );
346
- }
304
+ // =============================================================================
305
+ // Helpers
306
+ // =============================================================================
347
307
 
348
308
  /**
349
- * Resolve cell content from children, row data, and column definition.
309
+ * Resolve cell content from row data and column definition.
350
310
  */
351
311
  function resolveContent<TRow extends Record<string, unknown>>(
352
- children: ReactNode,
353
- row: TRow | undefined,
354
- column: Column<TRow> | undefined,
312
+ row: TRow,
313
+ column: Column<TRow>,
355
314
  rowIndex: number,
356
315
  ): ReactNode {
357
- if (!children && row && column) {
358
- switch (column.kind) {
359
- case "field": {
360
- const value = row[column.dataKey];
361
- if (column.renderer) {
362
- return createElement(column.renderer, {
363
- value,
364
- row,
365
- rowIndex,
366
- column,
367
- });
368
- }
369
- return formatValue(value);
316
+ switch (column.kind) {
317
+ case "field": {
318
+ const value = row[column.dataKey];
319
+ if (column.renderer) {
320
+ return createElement(column.renderer, {
321
+ value,
322
+ row,
323
+ rowIndex,
324
+ column,
325
+ });
370
326
  }
371
- case "display":
372
- return column.render(row);
327
+ return formatValue(value);
373
328
  }
329
+ case "display":
330
+ return column.render(row);
374
331
  }
375
-
376
- return children;
377
332
  }
378
333
 
379
334
  /**
@@ -387,6 +342,95 @@ function formatValue(value: unknown): ReactNode {
387
342
  return String(value);
388
343
  }
389
344
 
345
+ // =============================================================================
346
+ // RowActionsMenu
347
+ // =============================================================================
348
+
349
+ /**
350
+ * Dropdown menu for row-level actions.
351
+ *
352
+ * Uses a simple toggle-based dropdown without external dependencies.
353
+ */
354
+ function RowActionsMenu<TRow extends Record<string, unknown>>({
355
+ actions,
356
+ row,
357
+ }: {
358
+ actions: RowAction<TRow>[];
359
+ row: TRow;
360
+ }) {
361
+ const [open, setOpen] = useState(false);
362
+ const menuRef = useRef<HTMLDivElement>(null);
363
+
364
+ // Close on outside click
365
+ useEffect(() => {
366
+ if (!open) return;
367
+ const handleClickOutside = (e: MouseEvent) => {
368
+ if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
369
+ setOpen(false);
370
+ }
371
+ };
372
+ document.addEventListener("mousedown", handleClickOutside);
373
+ return () => document.removeEventListener("mousedown", handleClickOutside);
374
+ }, [open]);
375
+
376
+ // Close on Escape
377
+ const handleKeyDown = useCallback(
378
+ (e: React.KeyboardEvent) => {
379
+ if (e.key === "Escape") setOpen(false);
380
+ },
381
+ [],
382
+ );
383
+
384
+ return (
385
+ <div className="relative inline-block" ref={menuRef} onKeyDown={handleKeyDown}>
386
+ <button
387
+ type="button"
388
+ className="inline-flex h-8 w-8 items-center justify-center rounded-md text-sm hover:bg-accent"
389
+ onClick={() => setOpen((prev) => !prev)}
390
+ aria-label="Row actions"
391
+ aria-haspopup="true"
392
+ aria-expanded={open}
393
+ >
394
+ <span aria-hidden>⋯</span>
395
+ </button>
396
+ {open && (
397
+ <div
398
+ className="absolute right-0 z-50 mt-1 min-w-[140px] rounded-md border bg-popover p-1 text-popover-foreground shadow-md"
399
+ role="menu"
400
+ >
401
+ {actions.map((action) => {
402
+ const disabled = action.isDisabled?.(row) ?? false;
403
+ return (
404
+ <button
405
+ key={action.id}
406
+ type="button"
407
+ role="menuitem"
408
+ disabled={disabled}
409
+ className={cn(
410
+ "flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm",
411
+ disabled
412
+ ? "cursor-not-allowed opacity-50"
413
+ : "cursor-pointer hover:bg-accent",
414
+ action.variant === "destructive" && "text-destructive",
415
+ )}
416
+ onClick={() => {
417
+ if (!disabled) {
418
+ action.onClick(row);
419
+ setOpen(false);
420
+ }
421
+ }}
422
+ >
423
+ {action.icon}
424
+ {action.label}
425
+ </button>
426
+ );
427
+ })}
428
+ </div>
429
+ )}
430
+ </div>
431
+ );
432
+ }
433
+
390
434
  // =============================================================================
391
435
  // DataTable namespace
392
436
  // =============================================================================
@@ -394,12 +438,11 @@ function formatValue(value: unknown): ReactNode {
394
438
  /**
395
439
  * Data-bound table compound component.
396
440
  *
397
- * Use with `useDataTable()` hook which provides `rootProps` to spread,
398
- * or wrap with `DataTable.Provider` for context-based usage.
441
+ * Use with `useDataTable()` hook and wrap with `DataTable.Provider`
442
+ * for context-based data flow.
399
443
  *
400
444
  * @example
401
445
  * ```tsx
402
- * // Context-based (recommended)
403
446
  * const table = useDataTable({ columns, data, loading, collection });
404
447
  *
405
448
  * <DataTable.Provider value={table}>
@@ -411,12 +454,6 @@ function formatValue(value: unknown): ReactNode {
411
454
  * </DataTable.Root>
412
455
  * <Pagination />
413
456
  * </DataTable.Provider>
414
- *
415
- * // Props-based (still supported)
416
- * <DataTable.Root {...table.rootProps}>
417
- * <DataTable.Headers />
418
- * <DataTable.Body />
419
- * </DataTable.Root>
420
457
  * ```
421
458
  */
422
459
  export const DataTable = {
@@ -234,46 +234,23 @@ describe("useDataTable", () => {
234
234
  });
235
235
 
236
236
  // ---------------------------------------------------------------------------
237
- // Props generators
237
+ // Sort state
238
238
  // ---------------------------------------------------------------------------
239
- describe("props generators", () => {
240
- it("rootProps contains columns, rows, loading, error", () => {
241
- const { result } = renderHook(() =>
242
- useDataTable<TestRow>({
243
- columns: testColumns,
244
- data: testData,
245
- loading: true,
246
- }),
247
- );
248
-
249
- const { rootProps } = result.current;
250
- expect(rootProps.columns).toHaveLength(4);
251
- expect(rootProps.rows).toHaveLength(3);
252
- expect(rootProps.loading).toBe(true);
253
- });
254
-
255
- it("getRowProps returns row", () => {
239
+ describe("sort state", () => {
240
+ it("sortStates is empty when no collection is provided", () => {
256
241
  const { result } = renderHook(() =>
257
242
  useDataTable<TestRow>({ columns: testColumns, data: testData }),
258
243
  );
259
244
 
260
- const rowProps = result.current.getRowProps(result.current.rows[0]);
261
- expect(rowProps.row.name).toBe("Alice");
245
+ expect(result.current.sortStates).toEqual([]);
262
246
  });
263
247
 
264
- it("getCellProps returns row, column, rowIndex", () => {
248
+ it("onSort is undefined when no collection is provided", () => {
265
249
  const { result } = renderHook(() =>
266
250
  useDataTable<TestRow>({ columns: testColumns, data: testData }),
267
251
  );
268
252
 
269
- const cellProps = result.current.getCellProps(
270
- result.current.rows[0],
271
- testColumns[0],
272
- 0,
273
- );
274
- expect(cellProps.row.name).toBe("Alice");
275
- expect(cellProps.column).toBe(testColumns[0]);
276
- expect(cellProps.rowIndex).toBe(0);
253
+ expect(result.current.onSort).toBeUndefined();
277
254
  });
278
255
  });
279
256
  });
@@ -1,17 +1,15 @@
1
1
  import { useCallback, useEffect, useMemo, useState } from "react";
2
2
  import type {
3
3
  Column,
4
- DataTableCellProps,
5
- DataTableRootProps,
6
- DataTableRowProps,
7
4
  PageInfo,
5
+ SortState,
8
6
  UseDataTableOptions,
9
7
  UseDataTableReturn,
10
8
  } from "../types";
11
9
 
12
10
  /**
13
11
  * Hook that integrates data management, column visibility, row operations, and
14
- * props generators for the `DataTable.*` compound component.
12
+ * sort/pagination state for the `DataTable.*` compound component.
15
13
  *
16
14
  * @example
17
15
  * ```tsx
@@ -25,10 +23,12 @@ import type {
25
23
  * collection,
26
24
  * });
27
25
  *
28
- * <DataTable.Root {...table.rootProps}>
29
- * <DataTable.Headers />
30
- * <DataTable.Body />
31
- * </DataTable.Root>
26
+ * <DataTable.Provider value={table}>
27
+ * <DataTable.Root>
28
+ * <DataTable.Headers />
29
+ * <DataTable.Body />
30
+ * </DataTable.Root>
31
+ * </DataTable.Provider>
32
32
  * ```
33
33
  */
34
34
  export function useDataTable<TRow extends Record<string, unknown>>(
@@ -40,6 +40,8 @@ export function useDataTable<TRow extends Record<string, unknown>>(
40
40
  loading = false,
41
41
  error = null,
42
42
  collection,
43
+ onClickRow,
44
+ rowActions,
43
45
  } = options;
44
46
 
45
47
  // ---------------------------------------------------------------------------
@@ -194,64 +196,30 @@ export function useDataTable<TRow extends Record<string, unknown>>(
194
196
  );
195
197
 
196
198
  // ---------------------------------------------------------------------------
197
- // Props generators
199
+ // Sort (delegated from collection)
198
200
  // ---------------------------------------------------------------------------
199
- const rootProps = useMemo<DataTableRootProps<TRow>>(() => {
200
- return {
201
- columns: visibleColumns,
202
- rows,
203
- loading,
204
- error,
205
- onSort: collection
206
- ? (field: string, direction?: "Asc" | "Desc") =>
207
- collection.setSort(field, direction)
208
- : undefined,
209
- sortStates: collection?.sortStates,
210
- rowOperations: { updateRow, deleteRow, insertRow },
211
- children: null,
212
- };
213
- }, [
214
- visibleColumns,
215
- rows,
216
- loading,
217
- error,
218
- collection,
219
- updateRow,
220
- deleteRow,
221
- insertRow,
222
- ]);
223
-
224
- const getRowProps = useCallback(
225
- (row: TRow): DataTableRowProps<TRow> => ({ row }),
226
- [],
227
- );
228
-
229
- const getCellProps = useCallback(
230
- (
231
- row: TRow,
232
- column: Column<TRow>,
233
- rowIndex: number,
234
- ): DataTableCellProps<TRow> => ({
235
- row,
236
- column,
237
- rowIndex,
238
- }),
239
- [],
240
- );
201
+ const sortStates = useMemo<SortState[]>(() => {
202
+ return collection?.sortStates ?? [];
203
+ }, [collection?.sortStates]);
204
+
205
+ const onSort = useMemo<
206
+ ((field: string, direction?: "Asc" | "Desc") => void) | undefined
207
+ >(() => {
208
+ if (!collection) return undefined;
209
+ return (field: string, direction?: "Asc" | "Desc") =>
210
+ collection.setSort(field, direction);
211
+ }, [collection]);
241
212
 
242
213
  // ---------------------------------------------------------------------------
243
214
  // Return
244
215
  // ---------------------------------------------------------------------------
245
216
  return {
246
- // Props generators
247
- rootProps,
248
- getRowProps,
249
- getCellProps,
250
-
251
217
  // Data
252
218
  rows,
253
219
  loading,
254
220
  error,
221
+ sortStates,
222
+ onSort,
255
223
 
256
224
  // Pagination
257
225
  pageInfo,
@@ -275,5 +243,9 @@ export function useDataTable<TRow extends Record<string, unknown>>(
275
243
 
276
244
  // Collection (passthrough for DataTable.Provider)
277
245
  collection,
246
+
247
+ // Row interaction (passthrough for DataTable.Provider)
248
+ onClickRow,
249
+ rowActions,
278
250
  };
279
251
  }
@@ -22,10 +22,8 @@ export type {
22
22
  UseCollectionReturn,
23
23
  UseDataTableOptions,
24
24
  UseDataTableReturn,
25
+ RowAction,
25
26
  RowOperations,
26
- DataTableRootProps,
27
- DataTableRowProps,
28
- DataTableCellProps,
29
27
  FieldName,
30
28
  TableFieldName,
31
29
  OrderableFieldName,
@@ -62,7 +60,7 @@ export {
62
60
  export { Table } from "./table";
63
61
 
64
62
  // DataTable (data-bound)
65
- export { DataTable } from "./data-table/index";
63
+ export { DataTable } from "./data-table/data-table";
66
64
  export { useDataTable } from "./data-table/use-data-table";
67
65
  export { useDataTableContext } from "./data-table/data-table-context";
68
66
 
@@ -497,6 +497,10 @@ export interface UseDataTableOptions<TRow extends Record<string, unknown>> {
497
497
  error?: Error | null;
498
498
  /** Collection state for sort/pagination integration */
499
499
  collection?: UseCollectionReturn<string, unknown>;
500
+ /** Handler called when a row is clicked */
501
+ onClickRow?: (row: TRow) => void;
502
+ /** Row action definitions for the actions column */
503
+ rowActions?: RowAction<TRow>[];
500
504
  }
501
505
 
502
506
  /**
@@ -511,63 +515,45 @@ export interface RowOperations<TRow extends Record<string, unknown>> {
511
515
  insertRow: (row: TRow) => { rollback: () => void };
512
516
  }
513
517
 
514
- /**
515
- * Props for `DataTable.Root` component (generated by `useDataTable`).
516
- */
517
- export interface DataTableRootProps<TRow extends Record<string, unknown>> {
518
- /** Visible column definitions */
519
- columns: Column<TRow>[];
520
- /** Row data */
521
- rows: TRow[];
522
- /** Loading state */
523
- loading?: boolean;
524
- /** Error */
525
- error?: Error | null;
526
- /** Sort handler (connected to collection) */
527
- onSort?: (field: string, direction?: "Asc" | "Desc") => void;
528
- /** Current sort states */
529
- sortStates?: SortState[];
530
- /** Row operations (provided to children via Context) */
531
- rowOperations?: RowOperations<TRow>;
532
- children: ReactNode;
533
- }
534
-
535
- /**
536
- * Props for `DataTable.Row` component.
537
- */
538
- export interface DataTableRowProps<TRow extends Record<string, unknown>> {
539
- /** The row data */
540
- row: TRow;
541
- }
518
+ // =============================================================================
519
+ // Row Actions
520
+ // =============================================================================
542
521
 
543
522
  /**
544
- * Props for `DataTable.Cell` component.
523
+ * A single row action definition for the actions column.
524
+ *
525
+ * @example
526
+ * ```tsx
527
+ * const actions: RowAction<Order>[] = [
528
+ * {
529
+ * id: "delete",
530
+ * label: "削除",
531
+ * icon: <Trash2 className="h-4 w-4" />,
532
+ * variant: "destructive",
533
+ * onClick: (row) => handleDelete(row.id),
534
+ * },
535
+ * ];
536
+ * ```
545
537
  */
546
- export interface DataTableCellProps<TRow extends Record<string, unknown>> {
547
- /** The row data */
548
- row: TRow;
549
- /** The column definition */
550
- column: Column<TRow>;
551
- /** The row index */
552
- rowIndex: number;
538
+ export interface RowAction<TRow extends Record<string, unknown>> {
539
+ /** Unique action identifier */
540
+ id: string;
541
+ /** Action label text */
542
+ label: string;
543
+ /** Optional icon element */
544
+ icon?: ReactNode;
545
+ /** Visual variant */
546
+ variant?: "default" | "destructive";
547
+ /** Whether the action is disabled for a given row */
548
+ isDisabled?: (row: TRow) => boolean;
549
+ /** Click handler receiving the row data */
550
+ onClick: (row: TRow) => void;
553
551
  }
554
552
 
555
553
  /**
556
554
  * Return type of `useDataTable` hook.
557
555
  */
558
556
  export interface UseDataTableReturn<TRow extends Record<string, unknown>> {
559
- // Props generators (for spreading)
560
- /** Props for DataTable.Root */
561
- rootProps: DataTableRootProps<TRow>;
562
- /** Get props for a DataTable.Row */
563
- getRowProps: (row: TRow) => DataTableRowProps<TRow>;
564
- /** Get props for a DataTable.Cell */
565
- getCellProps: (
566
- row: TRow,
567
- column: Column<TRow>,
568
- rowIndex: number,
569
- ) => DataTableCellProps<TRow>;
570
-
571
557
  // Data
572
558
  /** Row data extracted from collection result */
573
559
  rows: TRow[];
@@ -575,6 +561,10 @@ export interface UseDataTableReturn<TRow extends Record<string, unknown>> {
575
561
  loading: boolean;
576
562
  /** Error */
577
563
  error: Error | null;
564
+ /** Current sort states */
565
+ sortStates: SortState[];
566
+ /** Sort handler (connected to collection) */
567
+ onSort?: (field: string, direction?: "Asc" | "Desc") => void;
578
568
 
579
569
  // Pagination (delegated from collection)
580
570
  /** Page info from GraphQL response */
@@ -613,6 +603,12 @@ export interface UseDataTableReturn<TRow extends Record<string, unknown>> {
613
603
  // Collection (passthrough for DataTable.Provider)
614
604
  /** Collection state passed through from options */
615
605
  collection: UseCollectionReturn<string, unknown> | undefined;
606
+
607
+ // Row interaction (passthrough for DataTable.Provider)
608
+ /** Handler called when a row is clicked */
609
+ onClickRow?: (row: TRow) => void;
610
+ /** Row action definitions for the actions column */
611
+ rowActions?: RowAction<TRow>[];
616
612
  }
617
613
 
618
614
  // =============================================================================