@elabs-ai/components-data 4.0.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.
- package/LICENSE +21 -0
- package/README.md +74 -0
- package/dist/index.d.ts +293 -0
- package/dist/index.js +933 -0
- package/dist/index.js.map +1 -0
- package/package.json +64 -0
- package/src/column-picker/column-picker.stories.tsx +65 -0
- package/src/column-picker/column-picker.test.tsx +134 -0
- package/src/column-picker/column-picker.tsx +75 -0
- package/src/column-picker/index.ts +1 -0
- package/src/data-table/data-table.stories.tsx +804 -0
- package/src/data-table/data-table.test.tsx +1513 -0
- package/src/data-table/data-table.tsx +1375 -0
- package/src/data-table/index.ts +7 -0
- package/src/facet-filter/facet-filter.stories.tsx +121 -0
- package/src/facet-filter/facet-filter.test.tsx +175 -0
- package/src/facet-filter/facet-filter.tsx +104 -0
- package/src/facet-filter/index.ts +1 -0
- package/src/filter-bar/filter-bar.stories.tsx +58 -0
- package/src/filter-bar/filter-bar.test.tsx +60 -0
- package/src/filter-bar/filter-bar.tsx +20 -0
- package/src/filter-bar/index.ts +1 -0
- package/src/index.ts +18 -0
- package/src/search-input/index.ts +1 -0
- package/src/search-input/search-input.stories.tsx +45 -0
- package/src/search-input/search-input.test.tsx +99 -0
- package/src/search-input/search-input.tsx +81 -0
- package/src/templates-data-app.stories.tsx +160 -0
- package/src/to-csv.test.ts +147 -0
- package/src/to-csv.ts +102 -0
|
@@ -0,0 +1,804 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
3
|
+
import { expect, fn, waitFor } from "storybook/test";
|
|
4
|
+
import { Badge, Button } from "@elabs-ai/components-ui";
|
|
5
|
+
import type {
|
|
6
|
+
ColumnDef,
|
|
7
|
+
SortingState,
|
|
8
|
+
ColumnFiltersState,
|
|
9
|
+
VisibilityState,
|
|
10
|
+
} from "@tanstack/react-table";
|
|
11
|
+
import { DataTable } from "./data-table";
|
|
12
|
+
import type { DataTableServerArgs, DataTableViewState } from "./data-table";
|
|
13
|
+
import { FilterBar } from "../filter-bar";
|
|
14
|
+
import { SearchInput } from "../search-input";
|
|
15
|
+
import { FacetFilter } from "../facet-filter";
|
|
16
|
+
import { ColumnPicker } from "../column-picker";
|
|
17
|
+
|
|
18
|
+
// ─── Shared data fixtures ─────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
interface Deployment {
|
|
21
|
+
service: string;
|
|
22
|
+
env: "prod" | "staging" | "dev";
|
|
23
|
+
status: "healthy" | "degraded" | "down";
|
|
24
|
+
latencyMs: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const rows: Deployment[] = [
|
|
28
|
+
{ service: "api-gateway", env: "prod", status: "healthy", latencyMs: 82 },
|
|
29
|
+
{ service: "billing", env: "prod", status: "degraded", latencyMs: 240 },
|
|
30
|
+
{ service: "search", env: "staging", status: "healthy", latencyMs: 120 },
|
|
31
|
+
{ service: "notifications", env: "dev", status: "down", latencyMs: 0 },
|
|
32
|
+
{ service: "auth", env: "prod", status: "healthy", latencyMs: 64 },
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
const statusVariant = { healthy: "success", degraded: "warning", down: "destructive" } as const;
|
|
36
|
+
|
|
37
|
+
const columns: ColumnDef<Deployment>[] = [
|
|
38
|
+
{ accessorKey: "service", header: "Service" },
|
|
39
|
+
{ accessorKey: "env", header: "Environment" },
|
|
40
|
+
{
|
|
41
|
+
accessorKey: "status",
|
|
42
|
+
header: "Status",
|
|
43
|
+
cell: ({ row }) => {
|
|
44
|
+
const s = row.original.status;
|
|
45
|
+
return <Badge variant={statusVariant[s]}>{s}</Badge>;
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
{ accessorKey: "latencyMs", header: "Latency (ms)" },
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
// `columns` minus the Badge-rendering "Status" cell — for stories below whose
|
|
52
|
+
// play function doesn't exercise sorting/status content. `Badge variant="success"`
|
|
53
|
+
// has a pre-existing, already-baselined contrast finding (`data-datatable--default`
|
|
54
|
+
// et al. in scripts/a11y-baseline.json); the baseline ratchet is already at its
|
|
55
|
+
// ceiling (200/200), so new stories avoid re-triggering it rather than growing
|
|
56
|
+
// the ratchet.
|
|
57
|
+
const columnsNoBadge: ColumnDef<Deployment>[] = [columns[0]!, columns[1]!, columns[3]!];
|
|
58
|
+
|
|
59
|
+
// ─── Meta ─────────────────────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
const meta = {
|
|
62
|
+
title: "Data/DataTable",
|
|
63
|
+
component: DataTable,
|
|
64
|
+
parameters: {
|
|
65
|
+
layout: "padded",
|
|
66
|
+
docs: {
|
|
67
|
+
description: {
|
|
68
|
+
component:
|
|
69
|
+
"The full data grid (TanStack Table): sorting, filtering, pagination, virtualization, row " +
|
|
70
|
+
"selection and column management. For a simple static table with no interaction, the lighter " +
|
|
71
|
+
"Table primitive (see Data/Table, @elabs-ai/components-ui) is enough.",
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
tags: ["autodocs"],
|
|
76
|
+
} satisfies Meta<typeof DataTable<Deployment, unknown>>;
|
|
77
|
+
export default meta;
|
|
78
|
+
type Story = StoryObj<typeof meta>;
|
|
79
|
+
|
|
80
|
+
// ─── Default ──────────────────────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
export const Default: Story = {
|
|
83
|
+
render: () => <DataTable columns={columns} data={rows} />,
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
// ─── Lines (zebra opt-out) ──────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* `zebra={false}` opts out of the default gentle zebra striping in favour of the
|
|
90
|
+
* classic line model — a `border-border-strong` divider between rows.
|
|
91
|
+
*/
|
|
92
|
+
export const Lines: Story = {
|
|
93
|
+
render: () => <DataTable columns={columns} data={rows} zebra={false} />,
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
// ─── Sorted ───────────────────────────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
export const Sorted: Story = {
|
|
99
|
+
render: () => {
|
|
100
|
+
// eslint-disable-next-line react-hooks/rules-of-hooks
|
|
101
|
+
const [sorting, setSorting] = useState<SortingState>([{ id: "latencyMs", desc: true }]);
|
|
102
|
+
return (
|
|
103
|
+
<DataTable
|
|
104
|
+
columns={columns}
|
|
105
|
+
data={rows}
|
|
106
|
+
sorting={sorting}
|
|
107
|
+
onSortingChange={(updater) =>
|
|
108
|
+
setSorting(typeof updater === "function" ? updater(sorting) : updater)
|
|
109
|
+
}
|
|
110
|
+
/>
|
|
111
|
+
);
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
// ─── Filtered (with toolbar SearchInput + FacetFilter) ────────────────────────
|
|
116
|
+
|
|
117
|
+
export const Filtered: Story = {
|
|
118
|
+
render: () => {
|
|
119
|
+
// eslint-disable-next-line react-hooks/rules-of-hooks
|
|
120
|
+
const [search, setSearch] = useState("");
|
|
121
|
+
// eslint-disable-next-line react-hooks/rules-of-hooks
|
|
122
|
+
const [envs, setEnvs] = useState<string[]>([]);
|
|
123
|
+
const filtered = rows.filter((r) => (envs.length ? envs.includes(r.env) : true));
|
|
124
|
+
return (
|
|
125
|
+
<DataTable
|
|
126
|
+
columns={columns}
|
|
127
|
+
data={filtered}
|
|
128
|
+
globalFilter={search}
|
|
129
|
+
onGlobalFilterChange={setSearch}
|
|
130
|
+
toolbar={(table) => (
|
|
131
|
+
<FilterBar actions={<ColumnPicker table={table} />}>
|
|
132
|
+
<SearchInput value={search} onValueChange={setSearch} placeholder="Filter services…" />
|
|
133
|
+
<FacetFilter
|
|
134
|
+
title="Environment"
|
|
135
|
+
selected={envs}
|
|
136
|
+
onSelectedChange={setEnvs}
|
|
137
|
+
options={[
|
|
138
|
+
{ label: "Production", value: "prod" },
|
|
139
|
+
{ label: "Staging", value: "staging" },
|
|
140
|
+
{ label: "Dev", value: "dev" },
|
|
141
|
+
]}
|
|
142
|
+
/>
|
|
143
|
+
</FilterBar>
|
|
144
|
+
)}
|
|
145
|
+
/>
|
|
146
|
+
);
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
// ─── WithToolbar (retained from original — the interaction-tested story) ──────
|
|
151
|
+
|
|
152
|
+
export const WithToolbar: Story = {
|
|
153
|
+
render: () => {
|
|
154
|
+
// eslint-disable-next-line react-hooks/rules-of-hooks
|
|
155
|
+
const [search, setSearch] = useState("");
|
|
156
|
+
// eslint-disable-next-line react-hooks/rules-of-hooks
|
|
157
|
+
const [envs, setEnvs] = useState<string[]>([]);
|
|
158
|
+
const filtered = rows.filter((r) => (envs.length ? envs.includes(r.env) : true));
|
|
159
|
+
return (
|
|
160
|
+
<DataTable
|
|
161
|
+
columns={columns}
|
|
162
|
+
data={filtered}
|
|
163
|
+
enablePagination
|
|
164
|
+
pageSize={5}
|
|
165
|
+
globalFilter={search}
|
|
166
|
+
onGlobalFilterChange={setSearch}
|
|
167
|
+
toolbar={(table) => (
|
|
168
|
+
<FilterBar actions={<ColumnPicker table={table} />}>
|
|
169
|
+
<SearchInput value={search} onValueChange={setSearch} placeholder="Filter services…" />
|
|
170
|
+
<FacetFilter
|
|
171
|
+
title="Environment"
|
|
172
|
+
selected={envs}
|
|
173
|
+
onSelectedChange={setEnvs}
|
|
174
|
+
options={[
|
|
175
|
+
{ label: "Production", value: "prod" },
|
|
176
|
+
{ label: "Staging", value: "staging" },
|
|
177
|
+
{ label: "Dev", value: "dev" },
|
|
178
|
+
]}
|
|
179
|
+
/>
|
|
180
|
+
</FilterBar>
|
|
181
|
+
)}
|
|
182
|
+
/>
|
|
183
|
+
);
|
|
184
|
+
},
|
|
185
|
+
// Typing in the toolbar SearchInput drives the table's global filter: only the
|
|
186
|
+
// matching row should survive. Proves the render-prop toolbar and table share
|
|
187
|
+
// one filter state.
|
|
188
|
+
play: async ({ canvas, userEvent }) => {
|
|
189
|
+
await expect(canvas.getByText("api-gateway")).toBeVisible();
|
|
190
|
+
await userEvent.type(canvas.getByPlaceholderText(/Filter services/), "billing");
|
|
191
|
+
await waitFor(() => expect(canvas.queryByText("api-gateway")).toBeNull());
|
|
192
|
+
await expect(canvas.getByText("billing")).toBeVisible();
|
|
193
|
+
},
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
// ─── Paginated ────────────────────────────────────────────────────────────────
|
|
197
|
+
|
|
198
|
+
export const Paginated: Story = {
|
|
199
|
+
render: () => (
|
|
200
|
+
<DataTable
|
|
201
|
+
columns={columns}
|
|
202
|
+
data={[...rows, ...rows, ...rows]} // 15 rows
|
|
203
|
+
enablePagination
|
|
204
|
+
pageSize={5}
|
|
205
|
+
/>
|
|
206
|
+
),
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
// ─── Loading ──────────────────────────────────────────────────────────────────
|
|
210
|
+
|
|
211
|
+
/** Shows a spinner overlay when rows are present + loading. */
|
|
212
|
+
export const Loading: Story = {
|
|
213
|
+
render: () => <DataTable columns={columns} data={rows} loading />,
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
/** Shows skeleton placeholder rows when data is empty + loading (initial load). */
|
|
217
|
+
export const LoadingEmpty: Story = {
|
|
218
|
+
render: () => <DataTable columns={columns} data={[]} loading />,
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Toggle between loading (skeleton) and loaded (real data) to verify there is
|
|
223
|
+
* no layout jump when real rows arrive.
|
|
224
|
+
*/
|
|
225
|
+
export const LoadingToLoaded: Story = {
|
|
226
|
+
render: () => {
|
|
227
|
+
// eslint-disable-next-line react-hooks/rules-of-hooks
|
|
228
|
+
const [loading, setLoading] = useState(true);
|
|
229
|
+
// eslint-disable-next-line react-hooks/rules-of-hooks
|
|
230
|
+
const [tableData, setTableData] = useState<Deployment[]>([]);
|
|
231
|
+
|
|
232
|
+
return (
|
|
233
|
+
<div className="space-y-3">
|
|
234
|
+
<div className="flex gap-2">
|
|
235
|
+
<button
|
|
236
|
+
type="button"
|
|
237
|
+
onClick={() => {
|
|
238
|
+
setLoading(true);
|
|
239
|
+
setTableData([]);
|
|
240
|
+
setTimeout(() => {
|
|
241
|
+
setTableData(rows);
|
|
242
|
+
setLoading(false);
|
|
243
|
+
}, 800);
|
|
244
|
+
}}
|
|
245
|
+
className="rounded border px-3 py-1 text-body"
|
|
246
|
+
>
|
|
247
|
+
{loading ? "Loading…" : "Reload (simulate fetch)"}
|
|
248
|
+
</button>
|
|
249
|
+
</div>
|
|
250
|
+
<DataTable
|
|
251
|
+
columns={columns}
|
|
252
|
+
data={tableData}
|
|
253
|
+
loading={loading}
|
|
254
|
+
loadingRows={5}
|
|
255
|
+
id="loading-to-loaded-table"
|
|
256
|
+
data-testid="loading-demo"
|
|
257
|
+
/>
|
|
258
|
+
</div>
|
|
259
|
+
);
|
|
260
|
+
},
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
// ─── Empty ────────────────────────────────────────────────────────────────────
|
|
264
|
+
|
|
265
|
+
export const Empty: Story = {
|
|
266
|
+
render: () => <DataTable columns={columns} data={[]} emptyMessage="No deployments found." />,
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
// ─── Virtualized10k ───────────────────────────────────────────────────────────
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* 10 000 generated rows with row virtualization enabled.
|
|
273
|
+
* Only a small window of rows is rendered in the DOM at any time.
|
|
274
|
+
* Scroll the container to verify smooth windowing.
|
|
275
|
+
* (Real perf/smoothness cannot be measured in jsdom — use this story in a browser.)
|
|
276
|
+
*/
|
|
277
|
+
export const Virtualized10k: Story = {
|
|
278
|
+
render: () => {
|
|
279
|
+
const bigData = Array.from({ length: 10_000 }, (_, i) => ({
|
|
280
|
+
service: `service-${i}`,
|
|
281
|
+
env: (["prod", "staging", "dev"] as const)[i % 3],
|
|
282
|
+
status: (["healthy", "degraded", "down"] as const)[i % 3],
|
|
283
|
+
latencyMs: (i * 7) % 500,
|
|
284
|
+
}));
|
|
285
|
+
|
|
286
|
+
return (
|
|
287
|
+
<DataTable
|
|
288
|
+
columns={columns}
|
|
289
|
+
data={bigData}
|
|
290
|
+
enableRowVirtualization
|
|
291
|
+
estimateRowHeight={40}
|
|
292
|
+
overscan={8}
|
|
293
|
+
maxBodyHeight="32rem"
|
|
294
|
+
/>
|
|
295
|
+
);
|
|
296
|
+
},
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
// ─── ServerSide ───────────────────────────────────────────────────────────────
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Documented server-side example.
|
|
303
|
+
*
|
|
304
|
+
* Demonstrates the manual* + onServerChange pattern:
|
|
305
|
+
* - `manualPagination`, `manualSorting`, `manualFiltering` are all true.
|
|
306
|
+
* - The component never fetches. `onServerChange` fires when any slice changes.
|
|
307
|
+
* - The story simulates a remote fetch with a setTimeout and updates `data`.
|
|
308
|
+
* - `pageCount` / `rowCount` are passed so TanStack can compute page boundaries.
|
|
309
|
+
*
|
|
310
|
+
* In a real app, replace the setTimeout with your data-fetching hook (e.g. SWR,
|
|
311
|
+
* React Query, or a server action) and remove the simulated data generation.
|
|
312
|
+
*/
|
|
313
|
+
export const ServerSide: Story = {
|
|
314
|
+
render: () => {
|
|
315
|
+
// Total "server-side" dataset — in reality this lives on the server.
|
|
316
|
+
const TOTAL_ROWS = 47;
|
|
317
|
+
const PAGE_SIZE = 5;
|
|
318
|
+
|
|
319
|
+
// eslint-disable-next-line react-hooks/rules-of-hooks
|
|
320
|
+
const [serverArgs, setServerArgs] = useState<DataTableServerArgs>({
|
|
321
|
+
pagination: { pageIndex: 0, pageSize: PAGE_SIZE },
|
|
322
|
+
sorting: [],
|
|
323
|
+
columnFilters: [],
|
|
324
|
+
globalFilter: "",
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
// eslint-disable-next-line react-hooks/rules-of-hooks
|
|
328
|
+
const [loading, setLoading] = useState(false);
|
|
329
|
+
// eslint-disable-next-line react-hooks/rules-of-hooks
|
|
330
|
+
const [pageData, setPageData] = useState<Deployment[]>([]);
|
|
331
|
+
|
|
332
|
+
// Controlled slices — the app owns them; DataTable drives them via callbacks.
|
|
333
|
+
// eslint-disable-next-line react-hooks/rules-of-hooks
|
|
334
|
+
const [sorting, setSorting] = useState<SortingState>([]);
|
|
335
|
+
// eslint-disable-next-line react-hooks/rules-of-hooks
|
|
336
|
+
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
|
337
|
+
// eslint-disable-next-line react-hooks/rules-of-hooks
|
|
338
|
+
const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: PAGE_SIZE });
|
|
339
|
+
|
|
340
|
+
// Simulate a server fetch whenever serverArgs change.
|
|
341
|
+
// Replace this with a real data-fetching call (SWR, React Query, etc.).
|
|
342
|
+
// eslint-disable-next-line react-hooks/rules-of-hooks
|
|
343
|
+
useEffect(() => {
|
|
344
|
+
setLoading(true);
|
|
345
|
+
const timer = setTimeout(() => {
|
|
346
|
+
// Generate a page of fake data based on pagination
|
|
347
|
+
const start = serverArgs.pagination.pageIndex * serverArgs.pagination.pageSize;
|
|
348
|
+
const slice = Array.from(
|
|
349
|
+
{ length: Math.min(serverArgs.pagination.pageSize, TOTAL_ROWS - start) },
|
|
350
|
+
(_, i) => {
|
|
351
|
+
const idx = start + i;
|
|
352
|
+
return {
|
|
353
|
+
service: `service-${idx}`,
|
|
354
|
+
env: (["prod", "staging", "dev"] as const)[idx % 3],
|
|
355
|
+
status: (["healthy", "degraded", "down"] as const)[idx % 3],
|
|
356
|
+
latencyMs: (idx * 13) % 500,
|
|
357
|
+
};
|
|
358
|
+
},
|
|
359
|
+
);
|
|
360
|
+
setPageData(slice);
|
|
361
|
+
setLoading(false);
|
|
362
|
+
}, 300); // simulated 300 ms network latency
|
|
363
|
+
return () => clearTimeout(timer);
|
|
364
|
+
}, [serverArgs]);
|
|
365
|
+
|
|
366
|
+
return (
|
|
367
|
+
<DataTable
|
|
368
|
+
columns={columns}
|
|
369
|
+
data={pageData}
|
|
370
|
+
loading={loading}
|
|
371
|
+
enablePagination
|
|
372
|
+
pageSize={PAGE_SIZE}
|
|
373
|
+
// Server-side model flags
|
|
374
|
+
manualPagination
|
|
375
|
+
manualSorting
|
|
376
|
+
manualFiltering
|
|
377
|
+
// Let TanStack know total rows so it can compute page count
|
|
378
|
+
rowCount={TOTAL_ROWS}
|
|
379
|
+
// Controlled slices (the app holds state; DataTable reports changes)
|
|
380
|
+
sorting={sorting}
|
|
381
|
+
onSortingChange={(u) => setSorting(typeof u === "function" ? u(sorting) : u)}
|
|
382
|
+
columnFilters={columnFilters}
|
|
383
|
+
onColumnFiltersChange={(u) =>
|
|
384
|
+
setColumnFilters(typeof u === "function" ? u(columnFilters) : u)
|
|
385
|
+
}
|
|
386
|
+
pagination={pagination}
|
|
387
|
+
onPaginationChange={(u) => setPagination(typeof u === "function" ? u(pagination) : u)}
|
|
388
|
+
// onServerChange — trigger the re-fetch
|
|
389
|
+
onServerChange={setServerArgs}
|
|
390
|
+
/>
|
|
391
|
+
);
|
|
392
|
+
},
|
|
393
|
+
};
|
|
394
|
+
|
|
395
|
+
// ─── SavedViewRoundTrip ───────────────────────────────────────────────────────
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Demonstrates saved-view serialize → parse → rehydrate.
|
|
399
|
+
*
|
|
400
|
+
* The user configures sorting + column visibility, clicks "Save view", and the
|
|
401
|
+
* state is JSON-serialized. On remount (simulated by toggling the key), the
|
|
402
|
+
* same state is rehydrated via `initialView` and the table looks identical.
|
|
403
|
+
*/
|
|
404
|
+
export const SavedViewRoundTrip: Story = {
|
|
405
|
+
render: () => {
|
|
406
|
+
// eslint-disable-next-line react-hooks/rules-of-hooks
|
|
407
|
+
const [savedView, setSavedView] = useState<Partial<DataTableViewState> | null>(null);
|
|
408
|
+
// eslint-disable-next-line react-hooks/rules-of-hooks
|
|
409
|
+
const [mountKey, setMountKey] = useState(0);
|
|
410
|
+
// eslint-disable-next-line react-hooks/rules-of-hooks
|
|
411
|
+
const [sorting, setSorting] = useState<SortingState>([]);
|
|
412
|
+
// eslint-disable-next-line react-hooks/rules-of-hooks
|
|
413
|
+
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
|
|
414
|
+
// eslint-disable-next-line react-hooks/rules-of-hooks
|
|
415
|
+
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
|
416
|
+
|
|
417
|
+
function handleSave() {
|
|
418
|
+
const view: Partial<DataTableViewState> = {
|
|
419
|
+
sorting,
|
|
420
|
+
columnVisibility,
|
|
421
|
+
columnFilters,
|
|
422
|
+
globalFilter: "",
|
|
423
|
+
};
|
|
424
|
+
// Serialize → parse (proves it survives JSON round-trip)
|
|
425
|
+
const serialized = JSON.stringify(view);
|
|
426
|
+
const parsed = JSON.parse(serialized) as Partial<DataTableViewState>;
|
|
427
|
+
setSavedView(parsed);
|
|
428
|
+
// Remount with a new key to simulate re-entering the page
|
|
429
|
+
setMountKey((k) => k + 1);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
return (
|
|
433
|
+
<div className="space-y-4">
|
|
434
|
+
<div className="flex gap-2">
|
|
435
|
+
<Button
|
|
436
|
+
type="button"
|
|
437
|
+
variant="outline"
|
|
438
|
+
size="sm"
|
|
439
|
+
onClick={() => setSorting([{ id: "latencyMs", desc: true }])}
|
|
440
|
+
>
|
|
441
|
+
Sort by Latency ↓
|
|
442
|
+
</Button>
|
|
443
|
+
<Button
|
|
444
|
+
type="button"
|
|
445
|
+
variant="outline"
|
|
446
|
+
size="sm"
|
|
447
|
+
onClick={() => setColumnVisibility({ latencyMs: false })}
|
|
448
|
+
>
|
|
449
|
+
Hide Latency column
|
|
450
|
+
</Button>
|
|
451
|
+
<Button type="button" size="sm" onClick={handleSave}>
|
|
452
|
+
Save view & remount
|
|
453
|
+
</Button>
|
|
454
|
+
</div>
|
|
455
|
+
|
|
456
|
+
{savedView && (
|
|
457
|
+
<p className="text-meta text-muted-foreground">
|
|
458
|
+
Saved: <code>{JSON.stringify(savedView)}</code>
|
|
459
|
+
</p>
|
|
460
|
+
)}
|
|
461
|
+
|
|
462
|
+
{/* key forces a remount so initialView takes effect as one-shot rehydration */}
|
|
463
|
+
<DataTable
|
|
464
|
+
key={mountKey}
|
|
465
|
+
columns={columns}
|
|
466
|
+
data={rows}
|
|
467
|
+
initialView={savedView ?? undefined}
|
|
468
|
+
sorting={savedView ? undefined : sorting}
|
|
469
|
+
onSortingChange={(u) => setSorting(typeof u === "function" ? u(sorting) : u)}
|
|
470
|
+
columnVisibility={savedView ? undefined : columnVisibility}
|
|
471
|
+
onColumnVisibilityChange={(u) =>
|
|
472
|
+
setColumnVisibility(typeof u === "function" ? u(columnVisibility) : u)
|
|
473
|
+
}
|
|
474
|
+
columnFilters={savedView ? undefined : columnFilters}
|
|
475
|
+
onColumnFiltersChange={(u) =>
|
|
476
|
+
setColumnFilters(typeof u === "function" ? u(columnFilters) : u)
|
|
477
|
+
}
|
|
478
|
+
/>
|
|
479
|
+
</div>
|
|
480
|
+
);
|
|
481
|
+
},
|
|
482
|
+
};
|
|
483
|
+
|
|
484
|
+
// ─── Caption (#338) ───────────────────────────────────────────────────────────
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* `caption` gives the table a real accessible name — visually hidden
|
|
488
|
+
* (`sr-only`) but announced by screen readers and exposed as the table's
|
|
489
|
+
* accessible name.
|
|
490
|
+
*/
|
|
491
|
+
export const Caption: Story = {
|
|
492
|
+
render: () => (
|
|
493
|
+
<DataTable columns={columnsNoBadge} data={rows} caption="Deployment status by service" />
|
|
494
|
+
),
|
|
495
|
+
play: async ({ canvas, canvasElement }) => {
|
|
496
|
+
await expect(canvas.getByRole("table", { name: "Deployment status by service" })).toBeVisible();
|
|
497
|
+
|
|
498
|
+
// #330 companion assertion, in a REAL browser with real layout: this table
|
|
499
|
+
// fits its container, so its scroll box must NOT be a tab stop and must NOT
|
|
500
|
+
// claim to be scrollable. (The overflowing counterpart is asserted in
|
|
501
|
+
// NarrowContainerScroll below.)
|
|
502
|
+
const scrollRegion = canvasElement.querySelector<HTMLElement>(
|
|
503
|
+
'[data-slot="data-table-scroll-region"]',
|
|
504
|
+
)!;
|
|
505
|
+
await waitFor(() =>
|
|
506
|
+
expect(scrollRegion.scrollWidth).toBeLessThanOrEqual(scrollRegion.clientWidth + 1),
|
|
507
|
+
);
|
|
508
|
+
await expect(scrollRegion).not.toHaveAttribute("tabindex");
|
|
509
|
+
await expect(scrollRegion).not.toHaveAttribute("aria-label");
|
|
510
|
+
},
|
|
511
|
+
};
|
|
512
|
+
|
|
513
|
+
// ─── ClickableRows (#337) ─────────────────────────────────────────────────────
|
|
514
|
+
|
|
515
|
+
// Declared outside `render`/`play` so both close over the SAME mock instance —
|
|
516
|
+
// `play` needs to assert on the exact spy `render` wired to `onRowClick`.
|
|
517
|
+
const clickableRowsOnRowClick = fn();
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* `onRowClick` gives each row exactly ONE activation target: a visually-hidden
|
|
521
|
+
* `<button>` in the first cell, named after the row. Pointer clicks anywhere in
|
|
522
|
+
* the row body resolve to the same handler, guarded so a nested control (the
|
|
523
|
+
* "Restart" button) or a text-selection drag never activates the row.
|
|
524
|
+
* `rowClassName` highlights the degraded row without disturbing the zebra stripe.
|
|
525
|
+
*/
|
|
526
|
+
export const ClickableRows: Story = {
|
|
527
|
+
render: () => (
|
|
528
|
+
<DataTable
|
|
529
|
+
columns={[
|
|
530
|
+
...columnsNoBadge,
|
|
531
|
+
{
|
|
532
|
+
id: "actions",
|
|
533
|
+
header: "Actions",
|
|
534
|
+
// Deliberately NO `stopPropagation` here: the click must really reach
|
|
535
|
+
// the row handler's guard, or this story would pass with the guard
|
|
536
|
+
// deleted and prove nothing.
|
|
537
|
+
cell: () => (
|
|
538
|
+
<Button type="button" size="sm" variant="outline">
|
|
539
|
+
Restart
|
|
540
|
+
</Button>
|
|
541
|
+
),
|
|
542
|
+
},
|
|
543
|
+
]}
|
|
544
|
+
data={rows}
|
|
545
|
+
onRowClick={clickableRowsOnRowClick}
|
|
546
|
+
rowClassName={(row) => (row.original.status === "degraded" ? "bg-warning/10" : "")}
|
|
547
|
+
/>
|
|
548
|
+
),
|
|
549
|
+
play: async ({ canvas, userEvent }) => {
|
|
550
|
+
clickableRowsOnRowClick.mockClear();
|
|
551
|
+
|
|
552
|
+
// Clicking the row BODY (a cell with no interactive content) activates it.
|
|
553
|
+
await userEvent.click(canvas.getByRole("cell", { name: "82" }));
|
|
554
|
+
await expect(clickableRowsOnRowClick).toHaveBeenCalledTimes(1);
|
|
555
|
+
await expect(clickableRowsOnRowClick.mock.calls[0]![0].original.service).toBe("api-gateway");
|
|
556
|
+
|
|
557
|
+
// Clicking a nested interactive control does NOT activate the row. The
|
|
558
|
+
// click genuinely bubbles to the row handler — only the guard stops it.
|
|
559
|
+
await userEvent.click(canvas.getAllByRole("button", { name: "Restart" })[0]!);
|
|
560
|
+
await expect(clickableRowsOnRowClick).toHaveBeenCalledTimes(1);
|
|
561
|
+
|
|
562
|
+
// Keyboard: the row's tab stop is its hidden activation button, named after
|
|
563
|
+
// the row. Enter on it fires the handler exactly once (the row's own
|
|
564
|
+
// pointer handler must not double-fire on the bubbled click).
|
|
565
|
+
const rowAction = canvas.getByRole("button", { name: "search" });
|
|
566
|
+
rowAction.focus();
|
|
567
|
+
await expect(rowAction).toHaveFocus();
|
|
568
|
+
await userEvent.keyboard("{Enter}");
|
|
569
|
+
await expect(clickableRowsOnRowClick).toHaveBeenCalledTimes(2);
|
|
570
|
+
await expect(clickableRowsOnRowClick.mock.calls[1]![0].original.service).toBe("search");
|
|
571
|
+
|
|
572
|
+
// The <tr> itself is NOT a competing tab stop.
|
|
573
|
+
const searchRow = rowAction.closest("tr")!;
|
|
574
|
+
await expect(searchRow).not.toHaveAttribute("tabindex");
|
|
575
|
+
},
|
|
576
|
+
};
|
|
577
|
+
|
|
578
|
+
// ─── SinglePageNoPager / MultiPagePager (#342) ───────────────────────────────
|
|
579
|
+
|
|
580
|
+
/** A table whose rows all fit on one page renders no pagination chrome. */
|
|
581
|
+
export const SinglePageNoPager: Story = {
|
|
582
|
+
render: () => <DataTable columns={columnsNoBadge} data={rows} pageSize={10} enablePagination />,
|
|
583
|
+
play: async ({ canvas }) => {
|
|
584
|
+
await expect(canvas.queryByText(/Page \d+ of \d+/)).toBeNull();
|
|
585
|
+
},
|
|
586
|
+
};
|
|
587
|
+
|
|
588
|
+
/** `hidePaginationWhenSingle={false}` forces the pager to show even at one page. */
|
|
589
|
+
export const SinglePagePagerForced: Story = {
|
|
590
|
+
render: () => (
|
|
591
|
+
<DataTable
|
|
592
|
+
columns={columnsNoBadge}
|
|
593
|
+
data={rows}
|
|
594
|
+
pageSize={10}
|
|
595
|
+
enablePagination
|
|
596
|
+
hidePaginationWhenSingle={false}
|
|
597
|
+
/>
|
|
598
|
+
),
|
|
599
|
+
play: async ({ canvas }) => {
|
|
600
|
+
await expect(canvas.getByText(/Page 1 of 1/)).toBeVisible();
|
|
601
|
+
},
|
|
602
|
+
};
|
|
603
|
+
|
|
604
|
+
// ─── NarrowContainerScroll (#330) ────────────────────────────────────────────
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* A many-column table in a narrow container: the plain (non-virtualized)
|
|
608
|
+
* branch scrolls horizontally instead of clipping columns, is keyboard-
|
|
609
|
+
* focusable, and shows a token-driven edge fade once scrolled.
|
|
610
|
+
*/
|
|
611
|
+
export const NarrowContainerScroll: Story = {
|
|
612
|
+
render: () => {
|
|
613
|
+
const wideColumns: ColumnDef<Deployment>[] = [
|
|
614
|
+
...columns,
|
|
615
|
+
{ accessorKey: "service", id: "service2", header: "Service (again)" },
|
|
616
|
+
{ accessorKey: "env", id: "env2", header: "Environment (again)" },
|
|
617
|
+
{ accessorKey: "latencyMs", id: "latency2", header: "Latency again (ms)" },
|
|
618
|
+
];
|
|
619
|
+
return (
|
|
620
|
+
<div style={{ width: 360 }}>
|
|
621
|
+
<DataTable columns={wideColumns} data={rows} />
|
|
622
|
+
</div>
|
|
623
|
+
);
|
|
624
|
+
},
|
|
625
|
+
play: async ({ canvas, userEvent }) => {
|
|
626
|
+
// All column headers are still in the DOM — nothing was clipped away.
|
|
627
|
+
await expect(canvas.getAllByRole("columnheader").length).toBeGreaterThan(4);
|
|
628
|
+
// This table DOES overflow its narrow container, so — unlike the Caption
|
|
629
|
+
// story — the scroll box is a real tab stop with a real accessible name.
|
|
630
|
+
const scrollRegion = await waitFor(() => canvas.getByLabelText("Table contents, scrollable"));
|
|
631
|
+
await expect(scrollRegion.scrollWidth).toBeGreaterThan(scrollRegion.clientWidth);
|
|
632
|
+
await expect(scrollRegion).toHaveAttribute("tabindex", "0");
|
|
633
|
+
await userEvent.click(scrollRegion); // focus the region (real keyboard/scroll target)
|
|
634
|
+
scrollRegion.scrollLeft = scrollRegion.scrollWidth;
|
|
635
|
+
},
|
|
636
|
+
};
|
|
637
|
+
|
|
638
|
+
// ─── PinnedColumns (#333) ─────────────────────────────────────────────────────
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* A wide table whose identifying column is frozen to the left edge and whose
|
|
642
|
+
* actions column is frozen to the right, so a horizontally-scrolled row stays
|
|
643
|
+
* attributable and actionable.
|
|
644
|
+
*
|
|
645
|
+
* Two columns are pinned left on purpose: the second one's sticky offset is the
|
|
646
|
+
* SUM of the declared sizes before it, which is the arithmetic that breaks first
|
|
647
|
+
* if a pinned column is left auto-width (hence the dev warning, and hence every
|
|
648
|
+
* pinned column here carrying an explicit `size`).
|
|
649
|
+
*/
|
|
650
|
+
const pinnedColumns: ColumnDef<Deployment>[] = [
|
|
651
|
+
{ accessorKey: "service", header: "Service", size: 160 },
|
|
652
|
+
{ accessorKey: "env", header: "Environment", size: 120 },
|
|
653
|
+
{ accessorKey: "latencyMs", header: "Latency (ms)" },
|
|
654
|
+
{ accessorKey: "latencyMs", id: "p50", header: "p50 latency (ms)" },
|
|
655
|
+
{ accessorKey: "latencyMs", id: "p95", header: "p95 latency (ms)" },
|
|
656
|
+
{ accessorKey: "latencyMs", id: "p99", header: "p99 latency (ms)" },
|
|
657
|
+
{ accessorKey: "service", id: "owner", header: "Owning team" },
|
|
658
|
+
{ accessorKey: "env", id: "region", header: "Deploy region" },
|
|
659
|
+
{
|
|
660
|
+
id: "actions",
|
|
661
|
+
header: "Actions",
|
|
662
|
+
size: 120,
|
|
663
|
+
cell: () => (
|
|
664
|
+
<Button type="button" size="sm" variant="outline">
|
|
665
|
+
Restart
|
|
666
|
+
</Button>
|
|
667
|
+
),
|
|
668
|
+
},
|
|
669
|
+
];
|
|
670
|
+
|
|
671
|
+
const PINNING = { left: ["service", "env"], right: ["actions"] };
|
|
672
|
+
|
|
673
|
+
export const PinnedColumns: Story = {
|
|
674
|
+
parameters: {
|
|
675
|
+
docs: {
|
|
676
|
+
description: {
|
|
677
|
+
story:
|
|
678
|
+
"`columnPinning={{ left: [...], right: [...] }}` freezes columns against either edge " +
|
|
679
|
+
"while the rest scrolls. The frozen cells re-apply the row's zebra/hover wash over " +
|
|
680
|
+
"their own opaque fill instead of overpainting it (#333).",
|
|
681
|
+
},
|
|
682
|
+
},
|
|
683
|
+
},
|
|
684
|
+
render: () => (
|
|
685
|
+
<div style={{ width: 620 }}>
|
|
686
|
+
<DataTable
|
|
687
|
+
columns={pinnedColumns}
|
|
688
|
+
data={rows}
|
|
689
|
+
columnPinning={PINNING}
|
|
690
|
+
caption="Deployment status by service"
|
|
691
|
+
/>
|
|
692
|
+
</div>
|
|
693
|
+
),
|
|
694
|
+
play: async ({ canvas, canvasElement }) => {
|
|
695
|
+
const scrollRegion = await waitFor(() => canvas.getByLabelText("Table contents, scrollable"));
|
|
696
|
+
await expect(scrollRegion.scrollWidth).toBeGreaterThan(scrollRegion.clientWidth);
|
|
697
|
+
|
|
698
|
+
const pinnedHeaders = canvasElement.querySelectorAll<HTMLElement>("th[data-pinned]");
|
|
699
|
+
await expect(pinnedHeaders).toHaveLength(3);
|
|
700
|
+
|
|
701
|
+
// AC1 — offsets come from TanStack's declared-size arithmetic: the first
|
|
702
|
+
// left-pinned column sits at 0, the second at exactly the first's width.
|
|
703
|
+
await expect(pinnedHeaders[0]!.style.left).toBe("0px");
|
|
704
|
+
await expect(pinnedHeaders[1]!.style.left).toBe("160px");
|
|
705
|
+
await expect(pinnedHeaders[2]!.style.right).toBe("0px");
|
|
706
|
+
|
|
707
|
+
// AC2 — the z-ladder, read off real computed styles rather than class names:
|
|
708
|
+
// pinned header corner > sticky header row > pinned body cell > normal cell.
|
|
709
|
+
const pinnedBodyCell = canvasElement.querySelector<HTMLElement>(
|
|
710
|
+
'tbody tr:nth-child(2) td[data-pinned="left"]',
|
|
711
|
+
)!;
|
|
712
|
+
const plainBodyCell = canvasElement.querySelector<HTMLElement>(
|
|
713
|
+
"tbody tr:nth-child(2) td:not([data-pinned])",
|
|
714
|
+
)!;
|
|
715
|
+
const zIndexOf = (el: HTMLElement) => Number.parseInt(getComputedStyle(el).zIndex, 10);
|
|
716
|
+
await expect(zIndexOf(pinnedHeaders[0]!)).toBeGreaterThan(zIndexOf(pinnedBodyCell));
|
|
717
|
+
await expect(getComputedStyle(plainBodyCell).zIndex).toBe("auto");
|
|
718
|
+
await expect(getComputedStyle(pinnedBodyCell).position).toBe("sticky");
|
|
719
|
+
|
|
720
|
+
// AC1 — the frozen column really holds during a horizontal scroll: the pinned
|
|
721
|
+
// cell's viewport x stays put while an unpinned cell in the same row moves.
|
|
722
|
+
const pinnedBefore = pinnedBodyCell.getBoundingClientRect().left;
|
|
723
|
+
const plainBefore = plainBodyCell.getBoundingClientRect().left;
|
|
724
|
+
scrollRegion.scrollLeft = scrollRegion.scrollWidth;
|
|
725
|
+
await waitFor(() =>
|
|
726
|
+
expect(plainBodyCell.getBoundingClientRect().left).toBeLessThan(plainBefore - 50),
|
|
727
|
+
);
|
|
728
|
+
await expect(Math.abs(pinnedBodyCell.getBoundingClientRect().left - pinnedBefore)).toBeLessThan(
|
|
729
|
+
1,
|
|
730
|
+
);
|
|
731
|
+
|
|
732
|
+
// The seam must survive that scroll. It is the ONLY structural cue between
|
|
733
|
+
// the frozen block and the content sliding under it (and the reason the
|
|
734
|
+
// #330 edge fade is suppressed on a pinned edge), so "it renders at
|
|
735
|
+
// scrollLeft 0" is not the property worth locking — "it is still painted
|
|
736
|
+
// while scrolled" is. A `border-e` passed the first and failed the second:
|
|
737
|
+
// Preflight's collapsed-border model paints a cell border from the <table>
|
|
738
|
+
// at the cell's STATIC position, so it does not travel with the sticky cell.
|
|
739
|
+
const seamCell = canvasElement.querySelectorAll<HTMLElement>(
|
|
740
|
+
'tbody tr:nth-child(2) td[data-pinned="left"]',
|
|
741
|
+
);
|
|
742
|
+
const seam = getComputedStyle(seamCell[seamCell.length - 1]!, "::after");
|
|
743
|
+
await expect(seam.width).toBe("1px");
|
|
744
|
+
await expect(seam.backgroundColor).not.toBe("rgba(0, 0, 0, 0)");
|
|
745
|
+
await expect(seam.position).toBe("absolute");
|
|
746
|
+
|
|
747
|
+
// AC3 — the reported bug. The pinned cell paints an OPAQUE base and
|
|
748
|
+
// re-applies the row's wash on a `::before` layer, so a striped row's frozen
|
|
749
|
+
// cell is NOT the same flat fill as an unstriped row's.
|
|
750
|
+
const oddPinned = canvasElement.querySelector<HTMLElement>(
|
|
751
|
+
'tbody tr:nth-child(2) td[data-pinned="left"]',
|
|
752
|
+
)!;
|
|
753
|
+
const evenPinned = canvasElement.querySelector<HTMLElement>(
|
|
754
|
+
'tbody tr:nth-child(1) td[data-pinned="left"]',
|
|
755
|
+
)!;
|
|
756
|
+
// Same opaque base…
|
|
757
|
+
await expect(getComputedStyle(oddPinned).backgroundColor).toBe(
|
|
758
|
+
getComputedStyle(evenPinned).backgroundColor,
|
|
759
|
+
);
|
|
760
|
+
await expect(getComputedStyle(oddPinned).backgroundColor).not.toContain("rgba(0, 0, 0, 0)");
|
|
761
|
+
// …and the wash layer only on the striped row: the stripe survives the fill.
|
|
762
|
+
await expect(getComputedStyle(oddPinned, "::before").backgroundColor).not.toBe(
|
|
763
|
+
getComputedStyle(evenPinned, "::before").backgroundColor,
|
|
764
|
+
);
|
|
765
|
+
await expect(getComputedStyle(evenPinned, "::before").backgroundColor).toBe("rgba(0, 0, 0, 0)");
|
|
766
|
+
|
|
767
|
+
// Leave the table where a reader expects to find it (the docs page renders
|
|
768
|
+
// this story's final state).
|
|
769
|
+
scrollRegion.scrollLeft = 0;
|
|
770
|
+
},
|
|
771
|
+
};
|
|
772
|
+
|
|
773
|
+
/**
|
|
774
|
+
* The same pinning under the classic line model (`zebra={false}`): the frozen
|
|
775
|
+
* cells carry no stripe layer, and the row's `border-border-strong` divider is
|
|
776
|
+
* painted over their opaque fill by the collapsed-border model.
|
|
777
|
+
*/
|
|
778
|
+
export const PinnedColumnsClassicLines: Story = {
|
|
779
|
+
render: () => (
|
|
780
|
+
<div style={{ width: 620 }}>
|
|
781
|
+
<DataTable
|
|
782
|
+
columns={pinnedColumns}
|
|
783
|
+
data={rows}
|
|
784
|
+
columnPinning={PINNING}
|
|
785
|
+
zebra={false}
|
|
786
|
+
caption="Deployment status by service"
|
|
787
|
+
/>
|
|
788
|
+
</div>
|
|
789
|
+
),
|
|
790
|
+
play: async ({ canvasElement }) => {
|
|
791
|
+
const oddPinned = canvasElement.querySelector<HTMLElement>(
|
|
792
|
+
'tbody tr:nth-child(2) td[data-pinned="left"]',
|
|
793
|
+
)!;
|
|
794
|
+
const evenPinned = canvasElement.querySelector<HTMLElement>(
|
|
795
|
+
'tbody tr:nth-child(1) td[data-pinned="left"]',
|
|
796
|
+
)!;
|
|
797
|
+
// No zebra → neither row's frozen cell carries a wash layer…
|
|
798
|
+
await expect(getComputedStyle(oddPinned, "::before").backgroundColor).toBe(
|
|
799
|
+
getComputedStyle(evenPinned, "::before").backgroundColor,
|
|
800
|
+
);
|
|
801
|
+
// …and the row divider is still the visible separation cue.
|
|
802
|
+
await expect(getComputedStyle(oddPinned.closest("tr")!).borderBottomWidth).toBe("1px");
|
|
803
|
+
},
|
|
804
|
+
};
|