noecosystem-design 0.2.2 → 0.3.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/README.md +2 -2
- package/apps/storybook/stories/catalog/app-frame.stories.tsx +43 -0
- package/apps/storybook/stories/catalog/button.stories.tsx +1 -0
- package/apps/storybook/stories/catalog/checkbox.stories.tsx +7 -2
- package/apps/storybook/stories/catalog/data-table.stories.tsx +226 -3
- package/apps/storybook/stories/catalog/sidebar-navigation.stories.tsx +22 -0
- package/docs/design-system/brand-theme.md +3 -2
- package/docs/design-system/components.md +45 -0
- package/docs/design-system/inventory.md +1 -0
- package/docs/design-system/surface-policy.md +11 -8
- package/docs/design-system/visual-qa.md +6 -0
- package/package.json +1 -1
- package/packages/data-ui/package.json +6 -1
- package/packages/data-ui/src/data-table.tsx +2 -99
- package/packages/data-ui/src/pagination.tsx +2 -2
- package/packages/data-ui/src/table/adapters/tanstack.test.ts +116 -0
- package/packages/data-ui/src/table/adapters/tanstack.ts +179 -0
- package/packages/data-ui/src/table/contracts.ts +133 -0
- package/packages/data-ui/src/table/data-table-visual.evidence.browser.test.tsx +157 -0
- package/packages/data-ui/src/table/data-table.browser.test.tsx +180 -0
- package/packages/data-ui/src/table/data-table.tsx +524 -0
- package/packages/design-tokens/src/tokens.css +16 -10
- package/packages/design-tokens/src/tokens.json +12 -8
- package/packages/design-tokens/src/tokens.mjs +12 -8
- package/packages/registry/catalog-index.json +2 -2
- package/packages/registry/manifest.json +60 -18
- package/packages/registry/r/activity-feed.json +1 -1
- package/packages/registry/r/agent-composer.json +7 -1
- package/packages/registry/r/agent-message.json +1 -1
- package/packages/registry/r/agent-plan.json +1 -1
- package/packages/registry/r/agent-workspace.json +1 -1
- package/packages/registry/r/app-frame.json +12 -4
- package/packages/registry/r/approval-request.json +7 -1
- package/packages/registry/r/button.json +1 -1
- package/packages/registry/r/calendar.json +6 -5
- package/packages/registry/r/chart-frame.json +1 -1
- package/packages/registry/r/chart.json +1 -1
- package/packages/registry/r/dashboard-01.json +22 -2
- package/packages/registry/r/dashboard-02.json +22 -2
- package/packages/registry/r/dashboard-shell.json +1 -1
- package/packages/registry/r/data-table.json +22 -2
- package/packages/registry/r/date-picker.json +6 -5
- package/packages/registry/r/filter-bar.json +1 -1
- package/packages/registry/r/header-navigation.json +3 -3
- package/packages/registry/r/inline-cta.json +6 -6
- package/packages/registry/r/metric-card.json +1 -1
- package/packages/registry/r/pagination.json +7 -5
- package/packages/registry/r/run-inspector.json +1 -1
- package/packages/registry/r/sidebar-navigation.json +8 -3
- package/packages/registry/r/tool-call.json +1 -1
- package/packages/registry/r/workflow-canvas.json +1 -1
- package/packages/registry/r/workflow-node.json +1 -1
- package/packages/registry/registry.json +12 -3
- package/packages/registry/search-index.json +23 -8
- package/packages/ui/package.json +5 -1
- package/packages/ui/src/app-frame.browser.test.tsx +47 -0
- package/packages/ui/src/app-frame.tsx +33 -6
- package/packages/ui/src/button.browser.test.tsx +8 -0
- package/packages/ui/src/button.tsx +1 -1
- package/packages/ui/src/checkbox.tsx +1 -1
- package/packages/ui/src/header-navigation.tsx +1 -1
- package/packages/ui/src/sidebar-navigation.tsx +13 -2
- package/scripts/generate-manifest.mjs +71 -0
- package/tests/tokens.test.mjs +7 -13
- package/vitest.config.ts +5 -0
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { expect, test, vi } from 'vitest';
|
|
3
|
+
import { render } from 'vitest-browser-react';
|
|
4
|
+
|
|
5
|
+
import type { DataColumn } from './contracts';
|
|
6
|
+
import { DataTable } from './data-table';
|
|
7
|
+
|
|
8
|
+
type Row = { id: string; service: string; owner: string };
|
|
9
|
+
|
|
10
|
+
const columns: DataColumn<Row>[] = [
|
|
11
|
+
{ key: 'service', header: 'Service', technical: true },
|
|
12
|
+
{ key: 'owner', header: 'Owner' },
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
const rows: Row[] = [
|
|
16
|
+
{ id: '1', service: 'catalog-api', owner: 'Platform' },
|
|
17
|
+
{ id: '2', service: 'agent-runner', owner: 'AI Platform' },
|
|
18
|
+
{ id: '3', service: 'registry-sync', owner: 'Design Systems' },
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
interface ScreenLike {
|
|
22
|
+
getByRole(
|
|
23
|
+
role: 'table' | 'button' | 'checkbox' | 'columnheader',
|
|
24
|
+
options?: { name?: string },
|
|
25
|
+
): {
|
|
26
|
+
element(): Element;
|
|
27
|
+
click(): Promise<void>;
|
|
28
|
+
};
|
|
29
|
+
getByText(text: string): { element(): Element };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const bodyRows = (screen: ScreenLike) =>
|
|
33
|
+
(screen.getByRole('table').element() as HTMLTableElement).querySelectorAll('tbody tr');
|
|
34
|
+
|
|
35
|
+
test('renders a semantic table with logical alignment metadata', async () => {
|
|
36
|
+
const screen = await render(
|
|
37
|
+
<DataTable columns={columns} rows={rows} caption="Service health" empty="No services yet." />,
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
const table = screen.getByRole('table').element() as HTMLTableElement;
|
|
41
|
+
expect(table.querySelector('caption')?.textContent).toBe('Service health');
|
|
42
|
+
const header = screen
|
|
43
|
+
.getByRole('columnheader', { name: 'Service' })
|
|
44
|
+
.element() as HTMLTableCellElement;
|
|
45
|
+
expect(header.getAttribute('data-align')).toBe('start');
|
|
46
|
+
expect(header.getAttribute('aria-sort')).toBe('none');
|
|
47
|
+
expect(bodyRows(screen)).toHaveLength(3);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('sorting cycles ascending, descending, then unsorted with aria-sort state', async () => {
|
|
51
|
+
const onSortingChange = vi.fn();
|
|
52
|
+
const screen = await render(
|
|
53
|
+
<DataTable
|
|
54
|
+
columns={columns}
|
|
55
|
+
rows={rows}
|
|
56
|
+
caption="Service health"
|
|
57
|
+
empty="No services yet."
|
|
58
|
+
onSortingChange={onSortingChange}
|
|
59
|
+
/>,
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
const header = () =>
|
|
63
|
+
screen.getByRole('columnheader', { name: 'Service' }).element() as HTMLTableCellElement;
|
|
64
|
+
const firstRowService = () =>
|
|
65
|
+
(bodyRows(screen)[0] as HTMLTableRowElement).querySelector('td')?.textContent;
|
|
66
|
+
|
|
67
|
+
await screen.getByRole('button', { name: 'Service' }).click();
|
|
68
|
+
expect(header().getAttribute('aria-sort')).toBe('ascending');
|
|
69
|
+
expect(firstRowService()).toBe('agent-runner');
|
|
70
|
+
expect(onSortingChange).toHaveBeenLastCalledWith([{ field: 'service', direction: 'asc' }]);
|
|
71
|
+
|
|
72
|
+
await screen.getByRole('button', { name: 'Service' }).click();
|
|
73
|
+
expect(header().getAttribute('aria-sort')).toBe('descending');
|
|
74
|
+
expect(firstRowService()).toBe('registry-sync');
|
|
75
|
+
expect(onSortingChange).toHaveBeenLastCalledWith([{ field: 'service', direction: 'desc' }]);
|
|
76
|
+
|
|
77
|
+
await screen.getByRole('button', { name: 'Service' }).click();
|
|
78
|
+
expect(header().getAttribute('aria-sort')).toBe('none');
|
|
79
|
+
expect(firstRowService()).toBe('catalog-api');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('selection keeps stable row ids and reflects indeterminate state', async () => {
|
|
83
|
+
const onSelectionChange = vi.fn();
|
|
84
|
+
const screen = await render(
|
|
85
|
+
<DataTable
|
|
86
|
+
columns={columns}
|
|
87
|
+
rows={rows}
|
|
88
|
+
caption="Service health"
|
|
89
|
+
empty="No services yet."
|
|
90
|
+
defaultSelection={['2']}
|
|
91
|
+
onSelectionChange={onSelectionChange}
|
|
92
|
+
selectAllLabel="Select all rows"
|
|
93
|
+
rowSelectionLabel={(row: Row) => `Select ${row.service}`}
|
|
94
|
+
selectedCountLabel={(count: number) => `${count} selected`}
|
|
95
|
+
/>,
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
await screen.getByRole('checkbox', { name: 'Select catalog-api' }).click();
|
|
99
|
+
expect(onSelectionChange).toHaveBeenLastCalledWith(['1', '2']);
|
|
100
|
+
expect(
|
|
101
|
+
screen.getByRole('checkbox', { name: 'Select all rows' }).element() as HTMLInputElement,
|
|
102
|
+
).toBePartiallyChecked();
|
|
103
|
+
|
|
104
|
+
await screen.getByRole('checkbox', { name: 'Select all rows' }).click();
|
|
105
|
+
expect(onSelectionChange).toHaveBeenLastCalledWith(['1', '2', '3']);
|
|
106
|
+
expect(screen.getByText('3 selected').element()).toBeTruthy();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('client-side pagination slices rows and reports status', async () => {
|
|
110
|
+
function PaginationHost() {
|
|
111
|
+
const [page, setPage] = useState({ index: 0, size: 2 });
|
|
112
|
+
return (
|
|
113
|
+
<DataTable
|
|
114
|
+
columns={columns}
|
|
115
|
+
rows={rows}
|
|
116
|
+
caption="Service health"
|
|
117
|
+
empty="No services yet."
|
|
118
|
+
page={page}
|
|
119
|
+
onPageChange={setPage}
|
|
120
|
+
previousPageLabel="Previous page"
|
|
121
|
+
nextPageLabel="Next page"
|
|
122
|
+
/>
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
const screen = await render(<PaginationHost />);
|
|
126
|
+
|
|
127
|
+
expect(bodyRows(screen)).toHaveLength(2);
|
|
128
|
+
expect(screen.getByRole('button', { name: 'Previous page' })).toBeDisabled();
|
|
129
|
+
expect(screen.getByText('1–2 / 3').element()).toBeTruthy();
|
|
130
|
+
|
|
131
|
+
await screen.getByRole('button', { name: 'Next page' }).click();
|
|
132
|
+
expect(bodyRows(screen)).toHaveLength(1);
|
|
133
|
+
expect(screen.getByRole('button', { name: 'Next page' })).toBeDisabled();
|
|
134
|
+
expect(screen.getByText('3–3 / 3').element()).toBeTruthy();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('renders logical alignment inside an RTL container without mirroring hacks', async () => {
|
|
138
|
+
const screen = await render(
|
|
139
|
+
<div dir="rtl">
|
|
140
|
+
<DataTable columns={columns} rows={rows} caption="Service health" empty="No services yet." />
|
|
141
|
+
</div>,
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
const header = screen
|
|
145
|
+
.getByRole('columnheader', { name: 'Owner' })
|
|
146
|
+
.element() as HTMLTableCellElement;
|
|
147
|
+
expect(header.getAttribute('data-align')).toBe('start');
|
|
148
|
+
expect(header.className).toContain('text-start');
|
|
149
|
+
const technicalCell = screen
|
|
150
|
+
.getByRole('cell', { name: 'catalog-api' })
|
|
151
|
+
.element() as HTMLTableCellElement;
|
|
152
|
+
expect(technicalCell.querySelector('[dir="ltr"]')).toBeTruthy();
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test('loading renders skeleton rows instead of data', async () => {
|
|
156
|
+
const screen = await render(
|
|
157
|
+
<DataTable
|
|
158
|
+
columns={columns}
|
|
159
|
+
rows={rows}
|
|
160
|
+
caption="Service health"
|
|
161
|
+
empty="No services yet."
|
|
162
|
+
loading
|
|
163
|
+
loadingRows={2}
|
|
164
|
+
/>,
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
expect(
|
|
168
|
+
(screen.getByRole('table').element() as HTMLTableElement).querySelectorAll(
|
|
169
|
+
'[data-slot="data-table-loading-row"]',
|
|
170
|
+
),
|
|
171
|
+
).toHaveLength(2);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test('empty model renders the empty affordance', async () => {
|
|
175
|
+
const screen = await render(
|
|
176
|
+
<DataTable columns={columns} rows={[]} caption="Service health" empty="No services yet." />,
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
expect(screen.getByText('No services yet.').element()).toBeTruthy();
|
|
180
|
+
});
|
|
@@ -0,0 +1,524 @@
|
|
|
1
|
+
import { useId, useMemo, useState } from 'react';
|
|
2
|
+
import type * as React from 'react';
|
|
3
|
+
import { ArrowDown2, ArrowUp2 } from '@noe/icons';
|
|
4
|
+
import { TechnicalValue } from '@noe/ui/bidi';
|
|
5
|
+
import { Button } from '@noe/ui/button';
|
|
6
|
+
import { Checkbox } from '@noe/ui/checkbox';
|
|
7
|
+
import { cn } from '@noe/ui/lib/cn';
|
|
8
|
+
import { Skeleton } from '@noe/ui/skeleton';
|
|
9
|
+
import { functionalUpdate, useTable } from '@tanstack/react-table';
|
|
10
|
+
import type {
|
|
11
|
+
ColumnFiltersState,
|
|
12
|
+
OnChangeFn,
|
|
13
|
+
RowSelectionState,
|
|
14
|
+
SortingState,
|
|
15
|
+
} from '@tanstack/react-table';
|
|
16
|
+
|
|
17
|
+
import { Pagination } from '../pagination';
|
|
18
|
+
import {
|
|
19
|
+
fromTanStackFilters,
|
|
20
|
+
fromTanStackSelection,
|
|
21
|
+
fromTanStackSorting,
|
|
22
|
+
noeTableFeatures,
|
|
23
|
+
toTanStackColumns,
|
|
24
|
+
toTanStackFilters,
|
|
25
|
+
toTanStackSelection,
|
|
26
|
+
toTanStackSorting,
|
|
27
|
+
} from './adapters/tanstack';
|
|
28
|
+
import type { DataColumn, DataSort, FilterSpec, PageSpec, RowId, SelectionMode } from './contracts';
|
|
29
|
+
|
|
30
|
+
export type {
|
|
31
|
+
DataColumn,
|
|
32
|
+
DataColumnSortFn,
|
|
33
|
+
DataQuery,
|
|
34
|
+
DataQueryInput,
|
|
35
|
+
DataSort,
|
|
36
|
+
DataSortDirection,
|
|
37
|
+
FilterOperator,
|
|
38
|
+
FilterSpec,
|
|
39
|
+
PageSpec,
|
|
40
|
+
RowId,
|
|
41
|
+
SelectionMode,
|
|
42
|
+
SelectionState,
|
|
43
|
+
TableStateAdapter,
|
|
44
|
+
} from './contracts';
|
|
45
|
+
export { createMemoryTableStateAdapter, toDataQuery } from './contracts';
|
|
46
|
+
|
|
47
|
+
export interface DataTablePaginationStatusInfo {
|
|
48
|
+
/** One-based index of the first row on the page. */
|
|
49
|
+
startIndex: number;
|
|
50
|
+
/** One-based index of the last row on the page. */
|
|
51
|
+
endIndex: number;
|
|
52
|
+
/** Total row count across pages. */
|
|
53
|
+
total: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface DataTableSelectionInfo {
|
|
57
|
+
selectedIds: RowId[];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
type DataTableFeatureProps<Row extends { id: string }> = {
|
|
61
|
+
/** Controlled NOE sorting state. */
|
|
62
|
+
sorting?: readonly DataSort[];
|
|
63
|
+
/** Initial sorting state for uncontrolled usage. */
|
|
64
|
+
defaultSorting?: readonly DataSort[];
|
|
65
|
+
onSortingChange?: (sorting: DataSort[]) => void;
|
|
66
|
+
/** Server owns row order; the table only reflects sorting state. */
|
|
67
|
+
manualSorting?: boolean;
|
|
68
|
+
} & (
|
|
69
|
+
| {
|
|
70
|
+
filters?: undefined;
|
|
71
|
+
defaultFilters?: undefined;
|
|
72
|
+
onFiltersChange?: undefined;
|
|
73
|
+
manualFiltering?: undefined;
|
|
74
|
+
}
|
|
75
|
+
| {
|
|
76
|
+
/** Controlled NOE filter state. */
|
|
77
|
+
filters: readonly FilterSpec[];
|
|
78
|
+
defaultFilters?: undefined;
|
|
79
|
+
onFiltersChange: (filters: FilterSpec[]) => void;
|
|
80
|
+
manualFiltering?: boolean;
|
|
81
|
+
}
|
|
82
|
+
| {
|
|
83
|
+
filters?: undefined;
|
|
84
|
+
/** Initial filter state for uncontrolled usage. */
|
|
85
|
+
defaultFilters?: readonly FilterSpec[];
|
|
86
|
+
onFiltersChange?: (filters: FilterSpec[]) => void;
|
|
87
|
+
manualFiltering?: boolean;
|
|
88
|
+
}
|
|
89
|
+
) &
|
|
90
|
+
(
|
|
91
|
+
| {
|
|
92
|
+
page?: undefined;
|
|
93
|
+
onPageChange?: undefined;
|
|
94
|
+
previousPageLabel?: undefined;
|
|
95
|
+
nextPageLabel?: undefined;
|
|
96
|
+
paginationLabel?: undefined;
|
|
97
|
+
paginationStatus?: undefined;
|
|
98
|
+
totalRowCount?: undefined;
|
|
99
|
+
manualPagination?: undefined;
|
|
100
|
+
}
|
|
101
|
+
| {
|
|
102
|
+
/** Controlled zero-based page state. */
|
|
103
|
+
page: PageSpec;
|
|
104
|
+
onPageChange: (page: PageSpec) => void;
|
|
105
|
+
/** Accessible names for the icon-only pagination controls. */
|
|
106
|
+
previousPageLabel: string;
|
|
107
|
+
nextPageLabel: string;
|
|
108
|
+
paginationLabel?: string;
|
|
109
|
+
paginationStatus?: (info: DataTablePaginationStatusInfo) => React.ReactNode;
|
|
110
|
+
/** Server-side total; required for accurate status in manual mode. */
|
|
111
|
+
totalRowCount?: number;
|
|
112
|
+
/** Server owns row slicing; `rows` holds exactly the current page. */
|
|
113
|
+
manualPagination?: boolean;
|
|
114
|
+
}
|
|
115
|
+
) &
|
|
116
|
+
(
|
|
117
|
+
| {
|
|
118
|
+
selection?: undefined;
|
|
119
|
+
defaultSelection?: undefined;
|
|
120
|
+
onSelectionChange?: undefined;
|
|
121
|
+
selectionMode?: undefined;
|
|
122
|
+
selectAllLabel?: undefined;
|
|
123
|
+
rowSelectionLabel?: undefined;
|
|
124
|
+
selectedCountLabel?: undefined;
|
|
125
|
+
renderSelectionActions?: undefined;
|
|
126
|
+
}
|
|
127
|
+
| {
|
|
128
|
+
/** Controlled selected row ids. */
|
|
129
|
+
selection?: readonly RowId[];
|
|
130
|
+
/** Initial selection for uncontrolled usage. */
|
|
131
|
+
defaultSelection?: readonly RowId[];
|
|
132
|
+
onSelectionChange?: (ids: RowId[]) => void;
|
|
133
|
+
selectionMode?: SelectionMode;
|
|
134
|
+
/** Accessible name for the select-all header control. */
|
|
135
|
+
selectAllLabel: string;
|
|
136
|
+
/** Accessible name factory for row selection controls. */
|
|
137
|
+
rowSelectionLabel?: (row: Row) => string;
|
|
138
|
+
selectedCountLabel?: (count: number) => React.ReactNode;
|
|
139
|
+
renderSelectionActions?: (info: DataTableSelectionInfo) => React.ReactNode;
|
|
140
|
+
}
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
export type DataTableProps<Row extends { id: string }> = {
|
|
144
|
+
columns: DataColumn<Row>[];
|
|
145
|
+
rows: Row[];
|
|
146
|
+
caption: string;
|
|
147
|
+
empty: React.ReactNode;
|
|
148
|
+
className?: string;
|
|
149
|
+
loading?: boolean;
|
|
150
|
+
/** Skeleton row count while `loading` is true. Default: 5. */
|
|
151
|
+
loadingRows?: number;
|
|
152
|
+
/** Visual rhythm: `regular` (default) or `compact` for dense workspaces. */
|
|
153
|
+
density?: 'compact' | 'regular';
|
|
154
|
+
} & DataTableFeatureProps<Row>;
|
|
155
|
+
|
|
156
|
+
/** Pagination state that keeps every row visible when paging is disabled. */
|
|
157
|
+
const ALL_ROWS_PAGINATION = { pageIndex: 0, pageSize: Number.MAX_SAFE_INTEGER };
|
|
158
|
+
|
|
159
|
+
export function DataTable<Row extends { id: string }>({
|
|
160
|
+
columns,
|
|
161
|
+
rows,
|
|
162
|
+
caption,
|
|
163
|
+
empty,
|
|
164
|
+
className,
|
|
165
|
+
loading = false,
|
|
166
|
+
loadingRows = 5,
|
|
167
|
+
density = 'regular',
|
|
168
|
+
sorting,
|
|
169
|
+
defaultSorting,
|
|
170
|
+
onSortingChange,
|
|
171
|
+
manualSorting,
|
|
172
|
+
filters,
|
|
173
|
+
defaultFilters,
|
|
174
|
+
onFiltersChange,
|
|
175
|
+
manualFiltering,
|
|
176
|
+
page,
|
|
177
|
+
onPageChange,
|
|
178
|
+
previousPageLabel,
|
|
179
|
+
nextPageLabel,
|
|
180
|
+
paginationLabel,
|
|
181
|
+
paginationStatus,
|
|
182
|
+
totalRowCount,
|
|
183
|
+
manualPagination,
|
|
184
|
+
selection,
|
|
185
|
+
defaultSelection,
|
|
186
|
+
onSelectionChange,
|
|
187
|
+
selectionMode = 'multiple',
|
|
188
|
+
selectAllLabel,
|
|
189
|
+
rowSelectionLabel,
|
|
190
|
+
selectedCountLabel,
|
|
191
|
+
renderSelectionActions,
|
|
192
|
+
}: DataTableProps<Row>) {
|
|
193
|
+
const captionId = useId();
|
|
194
|
+
const selectionEnabled = selectAllLabel !== undefined;
|
|
195
|
+
const paginationEnabled = page !== undefined;
|
|
196
|
+
|
|
197
|
+
const [internalSorting, setInternalSorting] = useState<SortingState>(() =>
|
|
198
|
+
toTanStackSorting(defaultSorting ?? []),
|
|
199
|
+
);
|
|
200
|
+
const [internalFilters, setInternalFilters] = useState<ColumnFiltersState>(() =>
|
|
201
|
+
toTanStackFilters(defaultFilters ?? []),
|
|
202
|
+
);
|
|
203
|
+
const [internalSelection, setInternalSelection] = useState<RowSelectionState>(() =>
|
|
204
|
+
toTanStackSelection(defaultSelection ?? []),
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
const controlledSorting = useMemo(
|
|
208
|
+
() => (sorting !== undefined ? toTanStackSorting(sorting) : undefined),
|
|
209
|
+
[sorting],
|
|
210
|
+
);
|
|
211
|
+
const controlledFilters = useMemo(
|
|
212
|
+
() => (filters !== undefined ? toTanStackFilters(filters) : undefined),
|
|
213
|
+
[filters],
|
|
214
|
+
);
|
|
215
|
+
const controlledSelection = useMemo(
|
|
216
|
+
() => (selection !== undefined ? toTanStackSelection(selection) : undefined),
|
|
217
|
+
[selection],
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
const sortingState = controlledSorting ?? internalSorting;
|
|
221
|
+
const filtersState = controlledFilters ?? internalFilters;
|
|
222
|
+
const selectionState = controlledSelection ?? internalSelection;
|
|
223
|
+
const paginationState = useMemo(
|
|
224
|
+
() =>
|
|
225
|
+
page !== undefined ? { pageIndex: page.index, pageSize: page.size } : ALL_ROWS_PAGINATION,
|
|
226
|
+
[page],
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
const tanstackColumns = useMemo(() => toTanStackColumns(columns), [columns]);
|
|
230
|
+
|
|
231
|
+
const handleSortingChange: OnChangeFn<SortingState> = (updater) => {
|
|
232
|
+
const next = functionalUpdate(updater, sortingState);
|
|
233
|
+
setInternalSorting(next);
|
|
234
|
+
onSortingChange?.(fromTanStackSorting(next));
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
const handleFiltersChange: OnChangeFn<ColumnFiltersState> = (updater) => {
|
|
238
|
+
const next = functionalUpdate(updater, filtersState);
|
|
239
|
+
setInternalFilters(next);
|
|
240
|
+
onFiltersChange?.(fromTanStackFilters(next));
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
const handleSelectionChange: OnChangeFn<RowSelectionState> = (updater) => {
|
|
244
|
+
const next = functionalUpdate(updater, selectionState);
|
|
245
|
+
setInternalSelection(next);
|
|
246
|
+
onSelectionChange?.(fromTanStackSelection(next));
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
const table = useTable({
|
|
250
|
+
features: noeTableFeatures,
|
|
251
|
+
columns: tanstackColumns,
|
|
252
|
+
data: rows,
|
|
253
|
+
getRowId: (row: Row) => row.id,
|
|
254
|
+
state: {
|
|
255
|
+
sorting: sortingState,
|
|
256
|
+
columnFilters: filtersState,
|
|
257
|
+
rowSelection: selectionState,
|
|
258
|
+
pagination: paginationState,
|
|
259
|
+
},
|
|
260
|
+
onSortingChange: handleSortingChange,
|
|
261
|
+
onColumnFiltersChange: handleFiltersChange,
|
|
262
|
+
onRowSelectionChange: handleSelectionChange,
|
|
263
|
+
enableMultiSort: false,
|
|
264
|
+
autoResetPageIndex: false,
|
|
265
|
+
...(manualSorting !== undefined ? { manualSorting } : {}),
|
|
266
|
+
...(manualFiltering !== undefined ? { manualFiltering } : {}),
|
|
267
|
+
...(manualPagination !== undefined ? { manualPagination } : {}),
|
|
268
|
+
...(totalRowCount !== undefined ? { rowCount: totalRowCount } : {}),
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
const modelRows = table.getRowModel().rows;
|
|
272
|
+
const selectedIds =
|
|
273
|
+
selection !== undefined ? [...selection] : fromTanStackSelection(selectionState);
|
|
274
|
+
|
|
275
|
+
const toggleRowSelection = (rowId: RowId) => {
|
|
276
|
+
const isSelected = selectedIds.includes(rowId);
|
|
277
|
+
if (selectionMode === 'single') {
|
|
278
|
+
handleSelectionChange(isSelected ? {} : toTanStackSelection([rowId]));
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
handleSelectionChange(
|
|
282
|
+
toTanStackSelection(
|
|
283
|
+
isSelected ? selectedIds.filter((id) => id !== rowId) : [...selectedIds, rowId],
|
|
284
|
+
),
|
|
285
|
+
);
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
const allRowsSelected = table.getIsAllRowsSelected();
|
|
289
|
+
const someRowsSelected = table.getIsSomeRowsSelected();
|
|
290
|
+
|
|
291
|
+
const pageSize = page?.size ?? ALL_ROWS_PAGINATION.pageSize;
|
|
292
|
+
const pageIndex = page?.index ?? 0;
|
|
293
|
+
const totalRows =
|
|
294
|
+
manualPagination === true
|
|
295
|
+
? (totalRowCount ?? pageIndex * pageSize + modelRows.length)
|
|
296
|
+
: table.getSortedRowModel().rows.length;
|
|
297
|
+
const canPreviousPage = paginationEnabled && pageIndex > 0;
|
|
298
|
+
const canNextPage = paginationEnabled
|
|
299
|
+
? manualPagination === true
|
|
300
|
+
? totalRowCount !== undefined
|
|
301
|
+
? (pageIndex + 1) * pageSize < totalRowCount
|
|
302
|
+
: modelRows.length === pageSize
|
|
303
|
+
: pageIndex + 1 < table.getPageCount()
|
|
304
|
+
: false;
|
|
305
|
+
const paginationInfo: DataTablePaginationStatusInfo | undefined = paginationEnabled
|
|
306
|
+
? {
|
|
307
|
+
startIndex: totalRows === 0 ? 0 : pageIndex * pageSize + 1,
|
|
308
|
+
endIndex: Math.min(totalRows, (pageIndex + 1) * pageSize),
|
|
309
|
+
total: totalRows,
|
|
310
|
+
}
|
|
311
|
+
: undefined;
|
|
312
|
+
|
|
313
|
+
const cellPadding = density === 'compact' ? 'px-3 py-1.5' : 'px-4 py-2.5';
|
|
314
|
+
|
|
315
|
+
if (!loading && modelRows.length === 0) {
|
|
316
|
+
return (
|
|
317
|
+
<div
|
|
318
|
+
className={cn(
|
|
319
|
+
'flex min-h-32 items-center justify-center rounded-lg border border-dashed border-border bg-surface-sunken/30 p-6 text-center text-sm text-muted-foreground',
|
|
320
|
+
className,
|
|
321
|
+
)}
|
|
322
|
+
data-slot="data-table-empty"
|
|
323
|
+
>
|
|
324
|
+
{empty}
|
|
325
|
+
</div>
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const showSelectionBar =
|
|
330
|
+
selectionEnabled &&
|
|
331
|
+
selectedIds.length > 0 &&
|
|
332
|
+
(selectedCountLabel !== undefined || renderSelectionActions !== undefined);
|
|
333
|
+
|
|
334
|
+
return (
|
|
335
|
+
<div className={cn('flex flex-col gap-3', className)} data-slot="data-table-root">
|
|
336
|
+
{showSelectionBar ? (
|
|
337
|
+
<div
|
|
338
|
+
className="flex items-center gap-3 rounded-md border border-border bg-surface-sunken/40 px-4 py-2.5 text-sm"
|
|
339
|
+
data-slot="data-table-selection-bar"
|
|
340
|
+
>
|
|
341
|
+
{selectedCountLabel ? (
|
|
342
|
+
<span className="tabular-nums text-muted-foreground">
|
|
343
|
+
{selectedCountLabel(selectedIds.length)}
|
|
344
|
+
</span>
|
|
345
|
+
) : null}
|
|
346
|
+
{renderSelectionActions ? (
|
|
347
|
+
<div className="ms-auto flex items-center gap-2">
|
|
348
|
+
{renderSelectionActions({ selectedIds })}
|
|
349
|
+
</div>
|
|
350
|
+
) : null}
|
|
351
|
+
</div>
|
|
352
|
+
) : null}
|
|
353
|
+
{/* WCAG 2.1: keyboard-accessible scrollable container for overflow tables. */}
|
|
354
|
+
<section
|
|
355
|
+
aria-labelledby={captionId}
|
|
356
|
+
className="overflow-x-auto rounded-lg border border-border"
|
|
357
|
+
data-slot="data-table-scroll"
|
|
358
|
+
// biome-ignore lint/a11y/noNoninteractiveTabindex: scrollable region focus pattern
|
|
359
|
+
tabIndex={0}
|
|
360
|
+
>
|
|
361
|
+
<table
|
|
362
|
+
className="w-full min-w-full border-collapse text-sm"
|
|
363
|
+
data-density={density}
|
|
364
|
+
data-slot="data-table"
|
|
365
|
+
>
|
|
366
|
+
<caption className="sr-only" id={captionId}>
|
|
367
|
+
{caption}
|
|
368
|
+
</caption>
|
|
369
|
+
<thead>
|
|
370
|
+
<tr className="border-b border-border bg-surface-sunken/60">
|
|
371
|
+
{selectionEnabled && selectionMode === 'multiple' ? (
|
|
372
|
+
<th className={cn('w-10', cellPadding)} data-slot="data-table-select-header">
|
|
373
|
+
<Checkbox
|
|
374
|
+
aria-label={selectAllLabel}
|
|
375
|
+
checked={allRowsSelected}
|
|
376
|
+
onChange={() => table.toggleAllRowsSelected(!allRowsSelected)}
|
|
377
|
+
ref={(element) => {
|
|
378
|
+
if (element) element.indeterminate = someRowsSelected && !allRowsSelected;
|
|
379
|
+
}}
|
|
380
|
+
/>
|
|
381
|
+
</th>
|
|
382
|
+
) : null}
|
|
383
|
+
{columns.map((column) => {
|
|
384
|
+
const tanstackColumn = table.getColumn(column.key);
|
|
385
|
+
const canSort = tanstackColumn?.getCanSort() ?? false;
|
|
386
|
+
const sorted = tanstackColumn?.getIsSorted() ?? false;
|
|
387
|
+
return (
|
|
388
|
+
<th
|
|
389
|
+
aria-sort={
|
|
390
|
+
sorted === 'asc'
|
|
391
|
+
? 'ascending'
|
|
392
|
+
: sorted === 'desc'
|
|
393
|
+
? 'descending'
|
|
394
|
+
: canSort
|
|
395
|
+
? 'none'
|
|
396
|
+
: undefined
|
|
397
|
+
}
|
|
398
|
+
className={cn(
|
|
399
|
+
'whitespace-nowrap text-xs font-medium text-muted-foreground',
|
|
400
|
+
cellPadding,
|
|
401
|
+
column.align === 'end' ? 'text-end' : 'text-start',
|
|
402
|
+
)}
|
|
403
|
+
data-align={column.align ?? 'start'}
|
|
404
|
+
key={column.key}
|
|
405
|
+
scope="col"
|
|
406
|
+
>
|
|
407
|
+
{canSort && tanstackColumn ? (
|
|
408
|
+
<Button
|
|
409
|
+
className="-ms-2 text-muted-foreground hover:text-foreground"
|
|
410
|
+
data-slot="data-table-sort-button"
|
|
411
|
+
onClick={(event) => tanstackColumn.getToggleSortingHandler()?.(event)}
|
|
412
|
+
size="sm"
|
|
413
|
+
variant="ghost"
|
|
414
|
+
>
|
|
415
|
+
{column.header}
|
|
416
|
+
{sorted === 'desc' ? (
|
|
417
|
+
<ArrowDown2 className="size-3.5" />
|
|
418
|
+
) : (
|
|
419
|
+
<ArrowUp2 className={cn('size-3.5', sorted === false && 'opacity-40')} />
|
|
420
|
+
)}
|
|
421
|
+
</Button>
|
|
422
|
+
) : (
|
|
423
|
+
column.header
|
|
424
|
+
)}
|
|
425
|
+
</th>
|
|
426
|
+
);
|
|
427
|
+
})}
|
|
428
|
+
</tr>
|
|
429
|
+
</thead>
|
|
430
|
+
<tbody>
|
|
431
|
+
{loading
|
|
432
|
+
? Array.from(
|
|
433
|
+
{ length: loadingRows },
|
|
434
|
+
(_, index) => `data-table-loading-${index}`,
|
|
435
|
+
).map((rowKey) => (
|
|
436
|
+
<tr
|
|
437
|
+
className="border-b border-border last:border-b-0"
|
|
438
|
+
data-slot="data-table-loading-row"
|
|
439
|
+
key={rowKey}
|
|
440
|
+
>
|
|
441
|
+
{selectionEnabled ? (
|
|
442
|
+
<td className={cellPadding}>
|
|
443
|
+
<Skeleton className="size-4" />
|
|
444
|
+
</td>
|
|
445
|
+
) : null}
|
|
446
|
+
{columns.map((column) => (
|
|
447
|
+
<td className={cellPadding} key={column.key}>
|
|
448
|
+
<Skeleton className="h-4 w-full max-w-32" />
|
|
449
|
+
</td>
|
|
450
|
+
))}
|
|
451
|
+
</tr>
|
|
452
|
+
))
|
|
453
|
+
: modelRows.map((tanstackRow) => {
|
|
454
|
+
const row = tanstackRow.original;
|
|
455
|
+
const isSelected = tanstackRow.getIsSelected();
|
|
456
|
+
return (
|
|
457
|
+
<tr
|
|
458
|
+
className="border-b border-border last:border-b-0 hover:bg-muted/50"
|
|
459
|
+
data-selected={isSelected || undefined}
|
|
460
|
+
key={tanstackRow.id}
|
|
461
|
+
>
|
|
462
|
+
{selectionEnabled ? (
|
|
463
|
+
<td className={cellPadding} data-slot="data-table-select-cell">
|
|
464
|
+
<Checkbox
|
|
465
|
+
aria-label={rowSelectionLabel?.(row)}
|
|
466
|
+
checked={isSelected}
|
|
467
|
+
onChange={() => toggleRowSelection(tanstackRow.id)}
|
|
468
|
+
/>
|
|
469
|
+
</td>
|
|
470
|
+
) : null}
|
|
471
|
+
{columns.map((column) => {
|
|
472
|
+
const content = column.render
|
|
473
|
+
? column.render(row)
|
|
474
|
+
: String(row[column.key] ?? '');
|
|
475
|
+
const value = column.technical ? (
|
|
476
|
+
<TechnicalValue>{content}</TechnicalValue>
|
|
477
|
+
) : (
|
|
478
|
+
content
|
|
479
|
+
);
|
|
480
|
+
return (
|
|
481
|
+
<td
|
|
482
|
+
className={cn(
|
|
483
|
+
'whitespace-nowrap text-foreground',
|
|
484
|
+
cellPadding,
|
|
485
|
+
column.align === 'end' ? 'text-end' : 'text-start',
|
|
486
|
+
)}
|
|
487
|
+
data-align={column.align ?? 'start'}
|
|
488
|
+
key={column.key}
|
|
489
|
+
>
|
|
490
|
+
{value}
|
|
491
|
+
</td>
|
|
492
|
+
);
|
|
493
|
+
})}
|
|
494
|
+
</tr>
|
|
495
|
+
);
|
|
496
|
+
})}
|
|
497
|
+
</tbody>
|
|
498
|
+
</table>
|
|
499
|
+
</section>
|
|
500
|
+
{paginationEnabled && paginationInfo ? (
|
|
501
|
+
<Pagination
|
|
502
|
+
canNext={canNextPage}
|
|
503
|
+
canPrevious={canPreviousPage}
|
|
504
|
+
label={paginationLabel ?? caption}
|
|
505
|
+
nextLabel={nextPageLabel}
|
|
506
|
+
onNext={
|
|
507
|
+
canNextPage ? () => onPageChange({ index: pageIndex + 1, size: pageSize }) : undefined
|
|
508
|
+
}
|
|
509
|
+
onPrevious={
|
|
510
|
+
canPreviousPage
|
|
511
|
+
? () => onPageChange({ index: pageIndex - 1, size: pageSize })
|
|
512
|
+
: undefined
|
|
513
|
+
}
|
|
514
|
+
previousLabel={previousPageLabel}
|
|
515
|
+
status={
|
|
516
|
+
paginationStatus
|
|
517
|
+
? paginationStatus(paginationInfo)
|
|
518
|
+
: `${paginationInfo.startIndex}–${paginationInfo.endIndex} / ${paginationInfo.total}`
|
|
519
|
+
}
|
|
520
|
+
/>
|
|
521
|
+
) : null}
|
|
522
|
+
</div>
|
|
523
|
+
);
|
|
524
|
+
}
|