@izumisy-tailor/tailor-data-viewer 0.2.24 → 0.2.26

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.24",
4
+ "version": "0.2.26",
5
5
  "type": "module",
6
6
  "description": "Flexible data viewer component for Tailor Platform",
7
7
  "files": [
@@ -325,22 +325,30 @@ describe("useCollection", () => {
325
325
  expect(result.current.paginationDirection).toBe("forward");
326
326
  });
327
327
 
328
- it("tracks hasPrevPage and hasNextPage from setPageInfo", () => {
328
+ it("tracks hasPrevPage from currentPage and hasNextPage from setPageInfo", () => {
329
329
  const { result } = renderHook(() => useCollection({ query: FAKE_QUERY }));
330
330
 
331
+ // Initially on page 1: no prev, no next
331
332
  expect(result.current.hasPrevPage).toBe(false);
332
333
  expect(result.current.hasNextPage).toBe(false);
333
334
 
335
+ // After navigating to next page, hasPrevPage becomes true
336
+ act(() => {
337
+ result.current.nextPage("cursor-end");
338
+ });
339
+
340
+ expect(result.current.hasPrevPage).toBe(true);
341
+
342
+ // hasNextPage comes from setPageInfo when totalPages is null
334
343
  act(() => {
335
344
  result.current.setPageInfo({
336
345
  hasNextPage: true,
337
346
  endCursor: "end",
338
- hasPreviousPage: true,
347
+ hasPreviousPage: false,
339
348
  startCursor: "start",
340
349
  });
341
350
  });
342
351
 
343
- expect(result.current.hasPrevPage).toBe(true);
344
352
  expect(result.current.hasNextPage).toBe(true);
345
353
  });
346
354
  });
@@ -132,6 +132,8 @@ export function useCollection(
132
132
  hasPreviousPage: false,
133
133
  startCursor: null,
134
134
  });
135
+ const [currentPage, setCurrentPage] = useState(1);
136
+ const [total, setTotalState] = useState<number | null>(null);
135
137
 
136
138
  // ---------------------------------------------------------------------------
137
139
  // Filter operations
@@ -155,6 +157,7 @@ export function useCollection(
155
157
  // Reset pagination when filters change
156
158
  setCursor(null);
157
159
  setPaginationDirection("forward");
160
+ setCurrentPage(1);
158
161
  },
159
162
  [],
160
163
  );
@@ -164,6 +167,7 @@ export function useCollection(
164
167
  // Reset pagination when filters change
165
168
  setCursor(null);
166
169
  setPaginationDirection("forward");
170
+ setCurrentPage(1);
167
171
  }, []);
168
172
 
169
173
  const removeFilter = useCallback((field: string) => {
@@ -171,12 +175,14 @@ export function useCollection(
171
175
  // Reset pagination when filters change
172
176
  setCursor(null);
173
177
  setPaginationDirection("forward");
178
+ setCurrentPage(1);
174
179
  }, []);
175
180
 
176
181
  const clearFilters = useCallback(() => {
177
182
  setFiltersState([]);
178
183
  setCursor(null);
179
184
  setPaginationDirection("forward");
185
+ setCurrentPage(1);
180
186
  }, []);
181
187
 
182
188
  // ---------------------------------------------------------------------------
@@ -196,12 +202,14 @@ export function useCollection(
196
202
  // Reset pagination when sort changes
197
203
  setCursor(null);
198
204
  setPaginationDirection("forward");
205
+ setCurrentPage(1);
199
206
  }, []);
200
207
 
201
208
  const clearSort = useCallback(() => {
202
209
  setSortStates([]);
203
210
  setCursor(null);
204
211
  setPaginationDirection("forward");
212
+ setCurrentPage(1);
205
213
  }, []);
206
214
 
207
215
  // ---------------------------------------------------------------------------
@@ -210,24 +218,49 @@ export function useCollection(
210
218
  const nextPage = useCallback((endCursor: string) => {
211
219
  setCursor(endCursor);
212
220
  setPaginationDirection("forward");
221
+ setCurrentPage((p) => p + 1);
213
222
  }, []);
214
223
 
215
224
  const prevPage = useCallback((startCursor: string) => {
216
225
  setCursor(startCursor);
217
226
  setPaginationDirection("backward");
227
+ setCurrentPage((p) => Math.max(1, p - 1));
218
228
  }, []);
219
229
 
220
230
  const resetPage = useCallback(() => {
221
231
  setCursor(null);
222
232
  setPaginationDirection("forward");
233
+ setCurrentPage(1);
223
234
  }, []);
224
235
 
225
236
  const setPageInfo = useCallback((pageInfo: PageInfo) => {
226
237
  setCurrentPageInfo(pageInfo);
227
238
  }, []);
228
239
 
229
- const hasPrevPage = currentPageInfo.hasPreviousPage;
230
- const hasNextPage = currentPageInfo.hasNextPage;
240
+ const setTotal = useCallback((value: number) => {
241
+ setTotalState(value);
242
+ }, []);
243
+
244
+ const totalPages =
245
+ total !== null ? Math.max(1, Math.ceil(total / pageSize)) : null;
246
+
247
+ const goToFirstPage = useCallback(() => {
248
+ setCursor(null);
249
+ setPaginationDirection("forward");
250
+ setCurrentPage(1);
251
+ }, []);
252
+
253
+ const goToLastPage = useCallback(() => {
254
+ setCursor(null);
255
+ setPaginationDirection("backward");
256
+ setCurrentPage(totalPages ?? 1);
257
+ }, [totalPages]);
258
+
259
+ const hasPrevPage = currentPage > 1;
260
+ const hasNextPage =
261
+ totalPages !== null
262
+ ? currentPage < totalPages
263
+ : currentPageInfo.hasNextPage;
231
264
 
232
265
  // ---------------------------------------------------------------------------
233
266
  // Build query variables (Tailor Platform format)
@@ -299,5 +332,10 @@ export function useCollection(
299
332
  hasPrevPage,
300
333
  hasNextPage,
301
334
  setPageInfo,
335
+ currentPage,
336
+ totalPages,
337
+ goToFirstPage,
338
+ goToLastPage,
339
+ setTotal,
302
340
  };
303
341
  }
@@ -76,7 +76,15 @@ export function useDataTable<TRow extends Record<string, unknown>>(
76
76
  if (data?.pageInfo) {
77
77
  collection?.setPageInfo(data.pageInfo);
78
78
  }
79
- }, [data?.pageInfo, collection?.setPageInfo]);
79
+ if (data?.total != null) {
80
+ collection?.setTotal(data.total);
81
+ }
82
+ }, [
83
+ data?.pageInfo,
84
+ data?.total,
85
+ collection?.setPageInfo,
86
+ collection?.setTotal,
87
+ ]);
80
88
 
81
89
  // ---------------------------------------------------------------------------
82
90
  // Column visibility management
@@ -69,6 +69,7 @@ export { createColumnHelper } from "./field-helpers";
69
69
 
70
70
  // Utility components
71
71
  export { Pagination } from "./pagination";
72
+ export type { PaginationProps, PaginationLabels } from "./pagination";
72
73
  export { ColumnSelector } from "./column-selector";
73
74
  export { CsvButton } from "./csv-button";
74
75
  export { SearchFilterForm } from "./search-filter-form";
@@ -34,6 +34,8 @@ const TestProviders = createTestProviders({
34
34
  collectionDefaults: {
35
35
  hasPrevPage: true,
36
36
  hasNextPage: true,
37
+ currentPage: 2,
38
+ totalPages: 5,
37
39
  },
38
40
  });
39
41
 
@@ -42,37 +44,75 @@ const TestProviders = createTestProviders({
42
44
  // =============================================================================
43
45
 
44
46
  describe("Pagination", () => {
45
- it("renders Previous and Next buttons", () => {
47
+ it("renders four navigation buttons", () => {
46
48
  render(
47
49
  <TestProviders>
48
50
  <Pagination />
49
51
  </TestProviders>,
50
52
  );
51
53
 
52
- expect(screen.getByText("Previous")).toBeInTheDocument();
53
- expect(screen.getByText("Next")).toBeInTheDocument();
54
+ expect(screen.getByLabelText("First page")).toBeInTheDocument();
55
+ expect(screen.getByLabelText("Previous page")).toBeInTheDocument();
56
+ expect(screen.getByLabelText("Next page")).toBeInTheDocument();
57
+ expect(screen.getByLabelText("Last page")).toBeInTheDocument();
54
58
  });
55
59
 
56
- it("disables Previous when hasPrevPage is false", () => {
60
+ it("displays current page and total pages as N / M", () => {
57
61
  render(
58
- <TestProviders collection={{ hasPrevPage: false }}>
62
+ <TestProviders>
63
+ <Pagination />
64
+ </TestProviders>,
65
+ );
66
+
67
+ expect(screen.getByText("2 / 5")).toBeInTheDocument();
68
+ });
69
+
70
+ it("hides page indicator when totalPages is null", () => {
71
+ render(
72
+ <TestProviders collection={{ totalPages: null, currentPage: 3 }}>
73
+ <Pagination />
74
+ </TestProviders>,
75
+ );
76
+
77
+ expect(screen.queryByText("3")).not.toBeInTheDocument();
78
+ expect(screen.queryByText("/")).not.toBeInTheDocument();
79
+ });
80
+
81
+ it("disables First and Previous when hasPrevPage is false", () => {
82
+ render(
83
+ <TestProviders collection={{ hasPrevPage: false, currentPage: 1 }}>
59
84
  <Pagination />
60
85
  </TestProviders>,
61
86
  );
62
87
 
63
- expect(screen.getByText("Previous")).toBeDisabled();
64
- expect(screen.getByText("Next")).toBeEnabled();
88
+ expect(screen.getByLabelText("First page")).toBeDisabled();
89
+ expect(screen.getByLabelText("Previous page")).toBeDisabled();
90
+ expect(screen.getByLabelText("Next page")).toBeEnabled();
91
+ expect(screen.getByLabelText("Last page")).toBeEnabled();
65
92
  });
66
93
 
67
- it("disables Next when hasNextPage is false", () => {
94
+ it("disables Next and Last when hasNextPage is false", () => {
68
95
  render(
69
96
  <TestProviders collection={{ hasNextPage: false }}>
70
97
  <Pagination />
71
98
  </TestProviders>,
72
99
  );
73
100
 
74
- expect(screen.getByText("Previous")).toBeEnabled();
75
- expect(screen.getByText("Next")).toBeDisabled();
101
+ expect(screen.getByLabelText("First page")).toBeEnabled();
102
+ expect(screen.getByLabelText("Previous page")).toBeEnabled();
103
+ expect(screen.getByLabelText("Next page")).toBeDisabled();
104
+ expect(screen.getByLabelText("Last page")).toBeDisabled();
105
+ });
106
+
107
+ it("disables Last when totalPages is null even if hasNextPage", () => {
108
+ render(
109
+ <TestProviders collection={{ hasNextPage: true, totalPages: null }}>
110
+ <Pagination />
111
+ </TestProviders>,
112
+ );
113
+
114
+ expect(screen.getByLabelText("Next page")).toBeEnabled();
115
+ expect(screen.getByLabelText("Last page")).toBeDisabled();
76
116
  });
77
117
 
78
118
  it("calls nextPage with endCursor when Next is clicked", () => {
@@ -83,7 +123,7 @@ describe("Pagination", () => {
83
123
  </TestProviders>,
84
124
  );
85
125
 
86
- fireEvent.click(screen.getByText("Next"));
126
+ fireEvent.click(screen.getByLabelText("Next page"));
87
127
  expect(nextPage).toHaveBeenCalledWith("cursor-end");
88
128
  });
89
129
 
@@ -95,10 +135,34 @@ describe("Pagination", () => {
95
135
  </TestProviders>,
96
136
  );
97
137
 
98
- fireEvent.click(screen.getByText("Previous"));
138
+ fireEvent.click(screen.getByLabelText("Previous page"));
99
139
  expect(prevPage).toHaveBeenCalledWith("cursor-start");
100
140
  });
101
141
 
142
+ it("calls goToFirstPage when First is clicked", () => {
143
+ const goToFirstPage = vi.fn();
144
+ render(
145
+ <TestProviders collection={{ goToFirstPage }}>
146
+ <Pagination />
147
+ </TestProviders>,
148
+ );
149
+
150
+ fireEvent.click(screen.getByLabelText("First page"));
151
+ expect(goToFirstPage).toHaveBeenCalledOnce();
152
+ });
153
+
154
+ it("calls goToLastPage when Last is clicked", () => {
155
+ const goToLastPage = vi.fn();
156
+ render(
157
+ <TestProviders collection={{ goToLastPage }}>
158
+ <Pagination />
159
+ </TestProviders>,
160
+ );
161
+
162
+ fireEvent.click(screen.getByLabelText("Last page"));
163
+ expect(goToLastPage).toHaveBeenCalledOnce();
164
+ });
165
+
102
166
  it("does not call nextPage when endCursor is null", () => {
103
167
  const nextPage = vi.fn();
104
168
  render(
@@ -117,10 +181,30 @@ describe("Pagination", () => {
117
181
  </TestProviders>,
118
182
  );
119
183
 
120
- fireEvent.click(screen.getByText("Next"));
184
+ fireEvent.click(screen.getByLabelText("Next page"));
121
185
  expect(nextPage).not.toHaveBeenCalled();
122
186
  });
123
187
 
188
+ it("supports custom aria-labels via labels prop", () => {
189
+ render(
190
+ <TestProviders>
191
+ <Pagination
192
+ labels={{
193
+ first: "最初のページ",
194
+ previous: "前のページ",
195
+ next: "次のページ",
196
+ last: "最後のページ",
197
+ }}
198
+ />
199
+ </TestProviders>,
200
+ );
201
+
202
+ expect(screen.getByLabelText("最初のページ")).toBeInTheDocument();
203
+ expect(screen.getByLabelText("前のページ")).toBeInTheDocument();
204
+ expect(screen.getByLabelText("次のページ")).toBeInTheDocument();
205
+ expect(screen.getByLabelText("最後のページ")).toBeInTheDocument();
206
+ });
207
+
124
208
  it("throws when rendered outside provider", () => {
125
209
  console.error = vi.fn();
126
210
  expect(() => render(<Pagination />)).toThrow();
@@ -1,49 +1,186 @@
1
1
  import { useDataTableContext } from "./data-table/data-table-context";
2
2
  import { useCollectionContext } from "./collection/collection-provider";
3
3
 
4
+ // =============================================================================
5
+ // Inline SVG Icons (lucide-style, no external dependency)
6
+ // =============================================================================
7
+
8
+ const iconClass = "size-4";
9
+
10
+ function ChevronsLeftIcon() {
11
+ return (
12
+ <svg
13
+ xmlns="http://www.w3.org/2000/svg"
14
+ viewBox="0 0 24 24"
15
+ fill="none"
16
+ stroke="currentColor"
17
+ strokeWidth={2}
18
+ strokeLinecap="round"
19
+ strokeLinejoin="round"
20
+ className={iconClass}
21
+ >
22
+ <path d="m11 17-5-5 5-5" />
23
+ <path d="m18 17-5-5 5-5" />
24
+ </svg>
25
+ );
26
+ }
27
+
28
+ function ChevronLeftIcon() {
29
+ return (
30
+ <svg
31
+ xmlns="http://www.w3.org/2000/svg"
32
+ viewBox="0 0 24 24"
33
+ fill="none"
34
+ stroke="currentColor"
35
+ strokeWidth={2}
36
+ strokeLinecap="round"
37
+ strokeLinejoin="round"
38
+ className={iconClass}
39
+ >
40
+ <path d="m15 18-6-6 6-6" />
41
+ </svg>
42
+ );
43
+ }
44
+
45
+ function ChevronRightIcon() {
46
+ return (
47
+ <svg
48
+ xmlns="http://www.w3.org/2000/svg"
49
+ viewBox="0 0 24 24"
50
+ fill="none"
51
+ stroke="currentColor"
52
+ strokeWidth={2}
53
+ strokeLinecap="round"
54
+ strokeLinejoin="round"
55
+ className={iconClass}
56
+ >
57
+ <path d="m9 18 6-6-6-6" />
58
+ </svg>
59
+ );
60
+ }
61
+
62
+ function ChevronsRightIcon() {
63
+ return (
64
+ <svg
65
+ xmlns="http://www.w3.org/2000/svg"
66
+ viewBox="0 0 24 24"
67
+ fill="none"
68
+ stroke="currentColor"
69
+ strokeWidth={2}
70
+ strokeLinecap="round"
71
+ strokeLinejoin="round"
72
+ className={iconClass}
73
+ >
74
+ <path d="m13 17 5-5-5-5" />
75
+ <path d="m6 17 5-5-5-5" />
76
+ </svg>
77
+ );
78
+ }
79
+
80
+ // =============================================================================
81
+ // Pagination Component
82
+ // =============================================================================
83
+
84
+ export interface PaginationLabels {
85
+ first?: string;
86
+ previous?: string;
87
+ next?: string;
88
+ last?: string;
89
+ }
90
+
91
+ export interface PaginationProps {
92
+ /** Aria-labels for pagination buttons (defaults to English). */
93
+ labels?: PaginationLabels;
94
+ }
95
+
96
+ const btnClass =
97
+ "inline-flex items-center justify-center rounded-md border p-1.5 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground disabled:pointer-events-none disabled:opacity-50";
98
+
4
99
  /**
5
- * Pagination controls for cursor-based navigation.
100
+ * Pagination controls with first/prev/next/last navigation and page indicator.
6
101
  *
7
- * Reads `pageInfo` from `DataTableContext` and pagination actions
8
- * from `CollectionContext`. Must be rendered inside `DataTable.Provider`.
102
+ * Reads pagination state from `DataTableContext` and `CollectionContext`.
103
+ * Must be rendered inside `DataTable.Provider` but **outside** `DataTable.Root`.
9
104
  *
10
105
  * @example
11
106
  * ```tsx
12
107
  * <DataTable.Provider value={table}>
13
- * <Pagination />
108
+ * <DataTable.Root>
109
+ * <DataTable.Headers />
110
+ * <DataTable.Body />
111
+ * </DataTable.Root>
112
+ * <Pagination labels={{ first: "最初のページ", previous: "前のページ", next: "次のページ", last: "最後のページ" }} />
14
113
  * </DataTable.Provider>
15
114
  * ```
16
115
  */
17
- export function Pagination() {
116
+ export function Pagination({ labels }: PaginationProps = {}) {
18
117
  const { pageInfo } = useDataTableContext();
19
- const { nextPage, prevPage, hasPrevPage, hasNextPage } =
20
- useCollectionContext();
118
+ const {
119
+ nextPage,
120
+ prevPage,
121
+ hasPrevPage,
122
+ hasNextPage,
123
+ currentPage,
124
+ totalPages,
125
+ goToFirstPage,
126
+ goToLastPage,
127
+ } = useCollectionContext();
128
+
129
+ const firstLabel = labels?.first ?? "First page";
130
+ const previousLabel = labels?.previous ?? "Previous page";
131
+ const nextLabel = labels?.next ?? "Next page";
132
+ const lastLabel = labels?.last ?? "Last page";
21
133
 
22
134
  return (
23
135
  <div className="flex items-center justify-end gap-2 py-2">
136
+ {totalPages !== null && (
137
+ <span className="text-sm text-muted-foreground tabular-nums">
138
+ {currentPage} / {totalPages}
139
+ </span>
140
+ )}
24
141
  <button
25
142
  type="button"
26
- className="inline-flex items-center justify-center rounded-md border px-3 py-1.5 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground disabled:pointer-events-none disabled:opacity-50"
143
+ className={btnClass}
144
+ onClick={goToFirstPage}
145
+ disabled={!hasPrevPage}
146
+ aria-label={firstLabel}
147
+ >
148
+ <ChevronsLeftIcon />
149
+ </button>
150
+ <button
151
+ type="button"
152
+ className={btnClass}
27
153
  onClick={() => {
28
154
  if (pageInfo.startCursor) {
29
155
  prevPage(pageInfo.startCursor);
30
156
  }
31
157
  }}
32
158
  disabled={!hasPrevPage}
159
+ aria-label={previousLabel}
33
160
  >
34
- Previous
161
+ <ChevronLeftIcon />
35
162
  </button>
36
163
  <button
37
164
  type="button"
38
- className="inline-flex items-center justify-center rounded-md border px-3 py-1.5 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground disabled:pointer-events-none disabled:opacity-50"
165
+ className={btnClass}
39
166
  onClick={() => {
40
167
  if (pageInfo.endCursor) {
41
168
  nextPage(pageInfo.endCursor);
42
169
  }
43
170
  }}
44
171
  disabled={!hasNextPage}
172
+ aria-label={nextLabel}
173
+ >
174
+ <ChevronRightIcon />
175
+ </button>
176
+ <button
177
+ type="button"
178
+ className={btnClass}
179
+ onClick={goToLastPage}
180
+ disabled={!hasNextPage || totalPages === null}
181
+ aria-label={lastLabel}
45
182
  >
46
- Next
183
+ <ChevronsRightIcon />
47
184
  </button>
48
185
  </div>
49
186
  );
@@ -250,6 +250,7 @@ export interface QueryVariables {
250
250
  export interface CollectionResult<T> {
251
251
  edges: { node: T }[];
252
252
  pageInfo: PageInfo;
253
+ total?: number | null;
253
254
  }
254
255
 
255
256
  /**
@@ -278,9 +279,12 @@ export type NodeType<
278
279
  /**
279
280
  * Props passed to a custom cell renderer.
280
281
  */
281
- export interface CellRendererProps<TRow extends Record<string, unknown>> {
282
+ export interface CellRendererProps<
283
+ TRow extends Record<string, unknown>,
284
+ TKey extends keyof TRow = never,
285
+ > {
282
286
  /** The value of the cell */
283
- value: unknown;
287
+ value: [TKey] extends [never] ? unknown : TRow[TKey];
284
288
  /** The entire row data */
285
289
  row: TRow;
286
290
  /** The row index (0-based) */
@@ -469,12 +473,22 @@ export interface UseCollectionReturn<
469
473
  prevPage: (startCursor: string) => void;
470
474
  /** Reset to first page */
471
475
  resetPage: () => void;
472
- /** Whether there is a previous page (from GraphQL pageInfo) */
476
+ /** Whether there is a previous page */
473
477
  hasPrevPage: boolean;
474
- /** Whether there is a next page (from GraphQL pageInfo) */
478
+ /** Whether there is a next page */
475
479
  hasNextPage: boolean;
476
480
  /** Set pageInfo from graphql result to track hasPrevPage/hasNextPage */
477
481
  setPageInfo: (pageInfo: PageInfo) => void;
482
+ /** Current page number (1-based) */
483
+ currentPage: number;
484
+ /** Total number of pages, or null if total count is unknown */
485
+ totalPages: number | null;
486
+ /** Navigate to the first page */
487
+ goToFirstPage: () => void;
488
+ /** Navigate to the last page (requires totalPages) */
489
+ goToLastPage: () => void;
490
+ /** Set total item count from GraphQL result */
491
+ setTotal: (total: number) => void;
478
492
  }
479
493
 
480
494
  // =============================================================================
@@ -67,6 +67,11 @@ export function createMockCollectionContext(
67
67
  hasPrevPage: false,
68
68
  hasNextPage: false,
69
69
  setPageInfo: vi.fn(),
70
+ currentPage: 1,
71
+ totalPages: null,
72
+ goToFirstPage: vi.fn(),
73
+ goToLastPage: vi.fn(),
74
+ setTotal: vi.fn(),
70
75
  ...overrides,
71
76
  };
72
77
  }