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.
Files changed (65) hide show
  1. package/README.md +2 -2
  2. package/apps/storybook/stories/catalog/app-frame.stories.tsx +43 -0
  3. package/apps/storybook/stories/catalog/button.stories.tsx +1 -0
  4. package/apps/storybook/stories/catalog/checkbox.stories.tsx +7 -2
  5. package/apps/storybook/stories/catalog/data-table.stories.tsx +226 -3
  6. package/apps/storybook/stories/catalog/sidebar-navigation.stories.tsx +22 -0
  7. package/docs/design-system/brand-theme.md +3 -2
  8. package/docs/design-system/components.md +45 -0
  9. package/docs/design-system/inventory.md +1 -0
  10. package/docs/design-system/surface-policy.md +11 -8
  11. package/docs/design-system/visual-qa.md +6 -0
  12. package/package.json +1 -1
  13. package/packages/data-ui/package.json +6 -1
  14. package/packages/data-ui/src/data-table.tsx +2 -99
  15. package/packages/data-ui/src/pagination.tsx +2 -2
  16. package/packages/data-ui/src/table/adapters/tanstack.test.ts +116 -0
  17. package/packages/data-ui/src/table/adapters/tanstack.ts +179 -0
  18. package/packages/data-ui/src/table/contracts.ts +133 -0
  19. package/packages/data-ui/src/table/data-table-visual.evidence.browser.test.tsx +157 -0
  20. package/packages/data-ui/src/table/data-table.browser.test.tsx +180 -0
  21. package/packages/data-ui/src/table/data-table.tsx +524 -0
  22. package/packages/design-tokens/src/tokens.css +16 -10
  23. package/packages/design-tokens/src/tokens.json +12 -8
  24. package/packages/design-tokens/src/tokens.mjs +12 -8
  25. package/packages/registry/catalog-index.json +2 -2
  26. package/packages/registry/manifest.json +60 -18
  27. package/packages/registry/r/activity-feed.json +1 -1
  28. package/packages/registry/r/agent-composer.json +7 -1
  29. package/packages/registry/r/agent-message.json +1 -1
  30. package/packages/registry/r/agent-plan.json +1 -1
  31. package/packages/registry/r/agent-workspace.json +1 -1
  32. package/packages/registry/r/app-frame.json +12 -4
  33. package/packages/registry/r/approval-request.json +7 -1
  34. package/packages/registry/r/button.json +1 -1
  35. package/packages/registry/r/calendar.json +6 -5
  36. package/packages/registry/r/chart-frame.json +1 -1
  37. package/packages/registry/r/chart.json +1 -1
  38. package/packages/registry/r/dashboard-01.json +22 -2
  39. package/packages/registry/r/dashboard-02.json +22 -2
  40. package/packages/registry/r/dashboard-shell.json +1 -1
  41. package/packages/registry/r/data-table.json +22 -2
  42. package/packages/registry/r/date-picker.json +6 -5
  43. package/packages/registry/r/filter-bar.json +1 -1
  44. package/packages/registry/r/header-navigation.json +3 -3
  45. package/packages/registry/r/inline-cta.json +6 -6
  46. package/packages/registry/r/metric-card.json +1 -1
  47. package/packages/registry/r/pagination.json +7 -5
  48. package/packages/registry/r/run-inspector.json +1 -1
  49. package/packages/registry/r/sidebar-navigation.json +8 -3
  50. package/packages/registry/r/tool-call.json +1 -1
  51. package/packages/registry/r/workflow-canvas.json +1 -1
  52. package/packages/registry/r/workflow-node.json +1 -1
  53. package/packages/registry/registry.json +12 -3
  54. package/packages/registry/search-index.json +23 -8
  55. package/packages/ui/package.json +5 -1
  56. package/packages/ui/src/app-frame.browser.test.tsx +47 -0
  57. package/packages/ui/src/app-frame.tsx +33 -6
  58. package/packages/ui/src/button.browser.test.tsx +8 -0
  59. package/packages/ui/src/button.tsx +1 -1
  60. package/packages/ui/src/checkbox.tsx +1 -1
  61. package/packages/ui/src/header-navigation.tsx +1 -1
  62. package/packages/ui/src/sidebar-navigation.tsx +13 -2
  63. package/scripts/generate-manifest.mjs +71 -0
  64. package/tests/tokens.test.mjs +7 -13
  65. package/vitest.config.ts +5 -0
@@ -0,0 +1,116 @@
1
+ import { describe, expect, test, vi } from 'vitest';
2
+
3
+ import type { FilterSpec } from '../contracts';
4
+ import { createMemoryTableStateAdapter, toDataQuery } from '../contracts';
5
+ import {
6
+ fromTanStackFilters,
7
+ fromTanStackSelection,
8
+ fromTanStackSorting,
9
+ toTanStackColumns,
10
+ toTanStackFilters,
11
+ toTanStackPagination,
12
+ toTanStackSelection,
13
+ toTanStackSorting,
14
+ } from './tanstack';
15
+
16
+ type Row = { id: string; name: string; updated: string };
17
+
18
+ describe('NOE <-> TanStack sorting', () => {
19
+ test('maps NOE DataSort to TanStack SortingState without leaking direction', () => {
20
+ expect(
21
+ toTanStackSorting([
22
+ { field: 'name', direction: 'asc' },
23
+ { field: 'updated', direction: 'desc' },
24
+ ]),
25
+ ).toEqual([
26
+ { id: 'name', desc: false },
27
+ { id: 'updated', desc: true },
28
+ ]);
29
+ });
30
+
31
+ test('round-trips back to NOE contracts', () => {
32
+ const sorts = [{ field: 'name', direction: 'desc' }] as const;
33
+ expect(fromTanStackSorting(toTanStackSorting(sorts))).toEqual([
34
+ { field: 'name', direction: 'desc' },
35
+ ]);
36
+ });
37
+ });
38
+
39
+ describe('NOE <-> TanStack filters', () => {
40
+ test('keeps the NOE operator inside the filter value and collapses per field', () => {
41
+ const filters: FilterSpec[] = [
42
+ { field: 'name', operator: 'contains', value: 'api' },
43
+ { field: 'name', operator: 'equals', value: 'api-2' },
44
+ { field: 'owner', operator: 'in', value: ['a', 'b'] },
45
+ ];
46
+ const tanstack = toTanStackFilters(filters);
47
+ expect(tanstack).toHaveLength(2);
48
+ expect(fromTanStackFilters(tanstack)).toEqual([
49
+ { field: 'name', operator: 'equals', value: 'api-2' },
50
+ { field: 'owner', operator: 'in', value: ['a', 'b'] },
51
+ ]);
52
+ });
53
+
54
+ test('evaluates NOE operators client-side', async () => {
55
+ const { noeTableFeatures } = await import('./tanstack');
56
+ const registered = (noeTableFeatures as unknown as { filterFns: Record<string, unknown> })
57
+ .filterFns;
58
+ expect(registered.noe).toBeTypeOf('function');
59
+ });
60
+ });
61
+
62
+ describe('NOE <-> TanStack pagination and selection', () => {
63
+ test('maps PageSpec to PaginationState', () => {
64
+ expect(toTanStackPagination({ index: 2, size: 25 })).toEqual({ pageIndex: 2, pageSize: 25 });
65
+ });
66
+
67
+ test('maps stable row ids to selection state and back', () => {
68
+ const ids = ['row-1', 'row-2'];
69
+ expect(fromTanStackSelection(toTanStackSelection(ids))).toEqual(ids);
70
+ });
71
+ });
72
+
73
+ describe('NOE column contract to engine columns', () => {
74
+ test('uses the row field as stable id and applies sortable opt-out', () => {
75
+ const columns = toTanStackColumns<Row>([
76
+ { key: 'name', header: 'Name' },
77
+ { key: 'updated', header: 'Updated', sortable: false, sortFn: 'alphanumeric' },
78
+ ]);
79
+ expect(columns[0]).toMatchObject({ id: 'name', accessorKey: 'name', enableSorting: true });
80
+ expect(columns[1]).toMatchObject({
81
+ id: 'updated',
82
+ enableSorting: false,
83
+ sortFn: 'alphanumeric',
84
+ });
85
+ });
86
+ });
87
+
88
+ describe('DataQuery and TableStateAdapter', () => {
89
+ test('builds a serializable query', () => {
90
+ expect(
91
+ toDataQuery({
92
+ search: 'api',
93
+ sorts: [{ field: 'name', direction: 'asc' }],
94
+ filters: [{ field: 'owner', operator: 'equals', value: 'Platform' }],
95
+ page: { index: 0, size: 10 },
96
+ }),
97
+ ).toEqual({
98
+ search: 'api',
99
+ sorts: [{ field: 'name', direction: 'asc' }],
100
+ filters: [{ field: 'owner', operator: 'equals', value: 'Platform' }],
101
+ page: { index: 0, size: 10 },
102
+ });
103
+ });
104
+
105
+ test('memory adapter notifies subscribers on set', () => {
106
+ const adapter = createMemoryTableStateAdapter<{ sorts: string[] }>({ sorts: [] });
107
+ const listener = vi.fn();
108
+ const unsubscribe = adapter.subscribe(listener);
109
+ adapter.set({ sorts: ['name'] });
110
+ expect(adapter.get()).toEqual({ sorts: ['name'] });
111
+ expect(listener).toHaveBeenCalledTimes(1);
112
+ unsubscribe();
113
+ adapter.set({ sorts: [] });
114
+ expect(listener).toHaveBeenCalledTimes(1);
115
+ });
116
+ });
@@ -0,0 +1,179 @@
1
+ import {
2
+ columnFilteringFeature,
3
+ createFilteredRowModel,
4
+ createPaginatedRowModel,
5
+ createSortedRowModel,
6
+ rowPaginationFeature,
7
+ rowSelectionFeature,
8
+ rowSortingFeature,
9
+ sortFn_alphanumeric,
10
+ sortFn_basic,
11
+ sortFn_datetime,
12
+ sortFn_text,
13
+ tableFeatures,
14
+ } from '@tanstack/react-table';
15
+ import type {
16
+ ColumnDef,
17
+ ColumnFiltersState,
18
+ PaginationState,
19
+ Row,
20
+ RowData,
21
+ RowSelectionState,
22
+ SortingState,
23
+ TableFeatures,
24
+ } from '@tanstack/react-table';
25
+
26
+ import type { DataColumn, DataSort, FilterSpec, PageSpec, RowId } from '../contracts';
27
+
28
+ /**
29
+ * Internal TanStack Table v9 adapter.
30
+ *
31
+ * This module is the only place in @noe/data-ui that knows about TanStack.
32
+ * Public NOE types (contracts.ts) never reference TanStack, and TanStack types
33
+ * must never be re-exported from the package entry point.
34
+ */
35
+
36
+ /**
37
+ * Client-side evaluator for NOE FilterSpec operators. Registered in the
38
+ * feature set as `noe` and attached to every filterable column.
39
+ */
40
+ function noeFilterFn<TFeatures extends TableFeatures, TData extends RowData>(
41
+ row: Row<TFeatures, TData>,
42
+ columnId: string,
43
+ filterValue: unknown,
44
+ ): boolean {
45
+ const spec = filterValue as { operator: FilterSpec['operator']; value: unknown };
46
+ const cell = row.getValue<unknown>(columnId);
47
+ const target = spec.value;
48
+
49
+ switch (spec.operator) {
50
+ case 'contains':
51
+ return String(cell ?? '')
52
+ .toLowerCase()
53
+ .includes(String(target ?? '').toLowerCase());
54
+ case 'equals':
55
+ return cell === target;
56
+ case 'startsWith':
57
+ return String(cell ?? '')
58
+ .toLowerCase()
59
+ .startsWith(String(target ?? '').toLowerCase());
60
+ case 'endsWith':
61
+ return String(cell ?? '')
62
+ .toLowerCase()
63
+ .endsWith(String(target ?? '').toLowerCase());
64
+ case 'gt':
65
+ return compareValues(cell, target) > 0;
66
+ case 'gte':
67
+ return compareValues(cell, target) >= 0;
68
+ case 'lt':
69
+ return compareValues(cell, target) < 0;
70
+ case 'lte':
71
+ return compareValues(cell, target) <= 0;
72
+ case 'in':
73
+ return Array.isArray(target) && target.includes(cell);
74
+ case 'between': {
75
+ const [min, max] = Array.isArray(target) ? target : [undefined, undefined];
76
+ return compareValues(cell, min) >= 0 && compareValues(cell, max) <= 0;
77
+ }
78
+ default:
79
+ return true;
80
+ }
81
+ }
82
+
83
+ function compareValues(a: unknown, b: unknown): number {
84
+ if (typeof a === 'number' && typeof b === 'number') return a - b;
85
+ return String(a ?? '').localeCompare(String(b ?? ''));
86
+ }
87
+
88
+ /**
89
+ * The NOE feature set: sorting, filtering, pagination and selection are
90
+ * registered explicitly (never `stockFeatures`), so unsupported behavior
91
+ * stays out of the bundle while DataTable keeps one static feature identity.
92
+ */
93
+ export const noeTableFeatures = tableFeatures({
94
+ columnFilteringFeature,
95
+ filteredRowModel: createFilteredRowModel(),
96
+ filterFns: { noe: noeFilterFn },
97
+ rowPaginationFeature,
98
+ paginatedRowModel: createPaginatedRowModel(),
99
+ rowSelectionFeature,
100
+ rowSortingFeature,
101
+ sortedRowModel: createSortedRowModel(),
102
+ sortFns: {
103
+ text: sortFn_text,
104
+ alphanumeric: sortFn_alphanumeric,
105
+ basic: sortFn_basic,
106
+ datetime: sortFn_datetime,
107
+ },
108
+ });
109
+
110
+ export type NoeTableFeatures = typeof noeTableFeatures;
111
+
112
+ // --- Sorting -----------------------------------------------------------------
113
+
114
+ export function toTanStackSorting(sorts: readonly DataSort[]): SortingState {
115
+ return sorts.map((sort) => ({ id: sort.field, desc: sort.direction === 'desc' }));
116
+ }
117
+
118
+ export function fromTanStackSorting(sorting: SortingState): DataSort[] {
119
+ return sorting.map((sort) => ({
120
+ field: sort.id,
121
+ direction: sort.desc ? 'desc' : 'asc',
122
+ }));
123
+ }
124
+
125
+ // --- Filtering ---------------------------------------------------------------
126
+ // One TanStack column filter per field; the NOE operator travels inside the
127
+ // filter value so `fromTanStackFilters` can reconstruct the NOE contract.
128
+
129
+ export function toTanStackFilters(filters: readonly FilterSpec[]): ColumnFiltersState {
130
+ const byField = new Map<string, FilterSpec>();
131
+ for (const filter of filters) byField.set(filter.field, filter);
132
+ return [...byField.values()].map((filter) => ({
133
+ id: filter.field,
134
+ value: { operator: filter.operator, value: filter.value },
135
+ }));
136
+ }
137
+
138
+ export function fromTanStackFilters(filters: ColumnFiltersState): FilterSpec[] {
139
+ return filters.map((filter) => {
140
+ const value = filter.value as { operator: FilterSpec['operator']; value: unknown };
141
+ return { field: filter.id, operator: value.operator, value: value.value };
142
+ });
143
+ }
144
+
145
+ // --- Pagination ---------------------------------------------------------------
146
+
147
+ export function toTanStackPagination(page: PageSpec | undefined): PaginationState {
148
+ return { pageIndex: page?.index ?? 0, pageSize: page?.size ?? Number.MAX_SAFE_INTEGER };
149
+ }
150
+
151
+ export function fromTanStackPagination(pagination: PaginationState): PageSpec {
152
+ return { index: pagination.pageIndex, size: pagination.pageSize };
153
+ }
154
+
155
+ // --- Selection -----------------------------------------------------------------
156
+
157
+ export function toTanStackSelection(ids: readonly RowId[]): RowSelectionState {
158
+ const state: RowSelectionState = {};
159
+ for (const id of ids) state[id] = true;
160
+ return state;
161
+ }
162
+
163
+ export function fromTanStackSelection(selection: RowSelectionState): RowId[] {
164
+ return Object.keys(selection);
165
+ }
166
+
167
+ // --- Columns --------------------------------------------------------------------
168
+
169
+ export function toTanStackColumns<Row extends { id: string }>(
170
+ columns: readonly DataColumn<Row>[],
171
+ ): ColumnDef<NoeTableFeatures, Row, unknown>[] {
172
+ return columns.map((column) => ({
173
+ id: column.key,
174
+ accessorKey: column.key,
175
+ enableSorting: column.sortable !== false,
176
+ ...(column.sortFn ? { sortFn: column.sortFn } : {}),
177
+ filterFn: 'noe' as const,
178
+ }));
179
+ }
@@ -0,0 +1,133 @@
1
+ import type * as React from 'react';
2
+
3
+ /**
4
+ * NOE data-table public contracts.
5
+ *
6
+ * These types are the stable public API of the NOE DataTable. Internal engines
7
+ * (currently TanStack Table v9) adapt to these contracts and must never leak
8
+ * through them. See ADR 0008 for the capability-library boundary.
9
+ */
10
+
11
+ /** Stable application-level row identifier. Never a positional index. */
12
+ export type RowId = string;
13
+
14
+ /** NOE sort direction. Logical, not visual: RTL rendering stays start/end based. */
15
+ export type DataSortDirection = 'asc' | 'desc';
16
+
17
+ /** A single sort entry over a data field. */
18
+ export interface DataSort {
19
+ field: string;
20
+ direction: DataSortDirection;
21
+ }
22
+
23
+ /**
24
+ * NOE filter operators. In server mode these travel to the backend verbatim;
25
+ * in client mode they are evaluated by the internal engine adapter.
26
+ */
27
+ export type FilterOperator =
28
+ | 'contains'
29
+ | 'equals'
30
+ | 'startsWith'
31
+ | 'endsWith'
32
+ | 'gt'
33
+ | 'gte'
34
+ | 'lt'
35
+ | 'lte'
36
+ | 'in'
37
+ | 'between';
38
+
39
+ /** A single filter entry over a data field. */
40
+ export interface FilterSpec {
41
+ field: string;
42
+ operator: FilterOperator;
43
+ value: unknown;
44
+ }
45
+
46
+ /** Zero-based page index plus page size. */
47
+ export interface PageSpec {
48
+ /** Zero-based page index. */
49
+ index: number;
50
+ /** Number of rows per page. */
51
+ size: number;
52
+ }
53
+
54
+ /** A serializable description of the current table query state. */
55
+ export interface DataQuery {
56
+ search?: string;
57
+ filters: FilterSpec[];
58
+ sorts: DataSort[];
59
+ page?: PageSpec;
60
+ }
61
+
62
+ export interface DataQueryInput {
63
+ search?: string;
64
+ filters?: readonly FilterSpec[];
65
+ sorts?: readonly DataSort[];
66
+ page?: PageSpec;
67
+ }
68
+
69
+ /** Builds a serializable DataQuery from table state pieces. */
70
+ export function toDataQuery(input: DataQueryInput): DataQuery {
71
+ return {
72
+ ...(input.search !== undefined ? { search: input.search } : {}),
73
+ filters: [...(input.filters ?? [])],
74
+ sorts: [...(input.sorts ?? [])],
75
+ ...(input.page !== undefined ? { page: { ...input.page } } : {}),
76
+ };
77
+ }
78
+
79
+ /** Row selection behavior: one row at a time, or many. */
80
+ export type SelectionMode = 'single' | 'multiple';
81
+
82
+ /** Selected row ids as stable application identifiers. */
83
+ export type SelectionState = readonly RowId[];
84
+
85
+ /**
86
+ * Store-agnostic state ownership contract (OpenStatus-inspired BYOS pattern).
87
+ * Products can back table state with URL, React state, a query cache, or any
88
+ * external store without coupling NOE to a specific transport or framework.
89
+ */
90
+ export interface TableStateAdapter<TState> {
91
+ get(): TState;
92
+ set(next: TState): void;
93
+ subscribe(listener: () => void): () => void;
94
+ }
95
+
96
+ /** Minimal in-memory TableStateAdapter for non-persisted table state. */
97
+ export function createMemoryTableStateAdapter<TState>(initial: TState): TableStateAdapter<TState> {
98
+ let value = initial;
99
+ const listeners = new Set<() => void>();
100
+ return {
101
+ get: () => value,
102
+ set: (next: TState) => {
103
+ value = next;
104
+ for (const listener of listeners) listener();
105
+ },
106
+ subscribe: (listener: () => void) => {
107
+ listeners.add(listener);
108
+ return () => listeners.delete(listener);
109
+ },
110
+ };
111
+ }
112
+
113
+ export type DataColumnSortFn = 'text' | 'alphanumeric' | 'basic' | 'datetime';
114
+
115
+ /**
116
+ * NOE column contract. `key` addresses a row field; presentation stays with
117
+ * NOE (semantic markup, alignment, bidi handling), while the internal engine
118
+ * uses the key as a stable accessor id for sorting and filtering.
119
+ */
120
+ export type DataColumn<Row> = {
121
+ /** Row field this column reads and sorts/filters by. */
122
+ key: keyof Row & string;
123
+ header: React.ReactNode;
124
+ /** Logical alignment: `start`/`end`, never left/right. */
125
+ align?: 'start' | 'end';
126
+ /** Render with LTR isolation for technical identifiers. */
127
+ technical?: boolean;
128
+ render?: (row: Row) => React.ReactNode;
129
+ /** Opt out of sorting for this column. Default: sortable. */
130
+ sortable?: boolean;
131
+ /** Client-side sort strategy. Default: engine auto-detection. */
132
+ sortFn?: DataColumnSortFn;
133
+ };
@@ -0,0 +1,157 @@
1
+ import type * as React from 'react';
2
+ import { expect, test } from 'vitest';
3
+ import { render } from 'vitest-browser-react';
4
+
5
+ import '../../../design-tokens/src/tailwind.css';
6
+
7
+ import type { DataColumn, DataSort } from './contracts';
8
+ import { DataTable } from './data-table';
9
+
10
+ /**
11
+ * Deterministic PNG evidence for the NOE DataTable (docs/design-system/visual-qa.md).
12
+ * Captures regular/compact density, RTL Persian, selection and loading states
13
+ * into evidence/data-table/. Pure layout evidence: no mock data leaves the repo.
14
+ *
15
+ * All variants render once; per-variant wrappers are captured individually
16
+ * because repeated render/unmount cycles invalidate the shared mount wrapper.
17
+ */
18
+
19
+ // Resolved relative to this test file by the browser screenshot driver.
20
+ const OUT = '../../../../evidence/data-table';
21
+
22
+ type Row = { id: string; service: string; owner: string; status: string; updated: string };
23
+
24
+ const columns: DataColumn<Row>[] = [
25
+ { key: 'service', header: 'Service', technical: true },
26
+ { key: 'owner', header: 'Owner' },
27
+ { key: 'status', header: 'Status' },
28
+ { key: 'updated', header: 'Updated', technical: true, align: 'end' },
29
+ ];
30
+
31
+ const rows: Row[] = [
32
+ { id: '1', service: 'catalog-api', owner: 'Platform', status: 'Healthy', updated: '2m ago' },
33
+ {
34
+ id: '2',
35
+ service: 'agent-runner',
36
+ owner: 'AI Platform',
37
+ status: 'Degraded',
38
+ updated: '11m ago',
39
+ },
40
+ {
41
+ id: '3',
42
+ service: 'registry-sync',
43
+ owner: 'Design Systems',
44
+ status: 'Healthy',
45
+ updated: '1h ago',
46
+ },
47
+ {
48
+ id: '4',
49
+ service: 'registry-api',
50
+ owner: 'Design Systems',
51
+ status: 'Healthy',
52
+ updated: '3h ago',
53
+ },
54
+ { id: '5', service: 'agent-gateway', owner: 'AI Platform', status: 'Healthy', updated: '5h ago' },
55
+ ];
56
+
57
+ const faColumns: DataColumn<Row>[] = [
58
+ { key: 'service', header: 'سرویس', technical: true },
59
+ { key: 'owner', header: 'مالک' },
60
+ { key: 'status', header: 'وضعیت' },
61
+ ];
62
+ const faSorting: DataSort[] = [{ field: 'service', direction: 'asc' }];
63
+
64
+ const captures: Array<{ name: string; ui: React.ReactElement }> = [
65
+ {
66
+ name: 'data-table-regular-en-light',
67
+ ui: (
68
+ <DataTable columns={columns} rows={rows} caption="Service health" empty="No services yet." />
69
+ ),
70
+ },
71
+ {
72
+ name: 'data-table-compact-fa-rtl',
73
+ ui: (
74
+ <div dir="rtl" lang="fa">
75
+ <DataTable
76
+ columns={faColumns}
77
+ rows={rows}
78
+ caption="وضعیت سرویس‌ها"
79
+ density="compact"
80
+ empty="هنوز سرویسی ثبت نشده است."
81
+ sorting={faSorting}
82
+ onSortingChange={() => {}}
83
+ />
84
+ </div>
85
+ ),
86
+ },
87
+ {
88
+ name: 'data-table-selection',
89
+ ui: (
90
+ <DataTable
91
+ columns={columns}
92
+ rows={rows}
93
+ caption="Service health"
94
+ empty="No services yet."
95
+ defaultSelection={['2']}
96
+ selectAllLabel="Select all services"
97
+ selectedCountLabel={(count: number) => `${count} selected`}
98
+ renderSelectionActions={() => <button type="button">Restart</button>}
99
+ />
100
+ ),
101
+ },
102
+ {
103
+ name: 'data-table-loading',
104
+ ui: (
105
+ <DataTable
106
+ columns={columns}
107
+ rows={[]}
108
+ caption="Service health"
109
+ empty="No services yet."
110
+ loading
111
+ loadingRows={4}
112
+ />
113
+ ),
114
+ },
115
+ {
116
+ name: 'data-table-selection-fa-dark-compact',
117
+ ui: (
118
+ <div
119
+ data-theme="dark"
120
+ dir="rtl"
121
+ lang="fa"
122
+ className="rounded-lg bg-background p-3 text-foreground"
123
+ >
124
+ <DataTable
125
+ columns={faColumns}
126
+ rows={rows}
127
+ caption="وضعیت سرویس‌ها"
128
+ density="compact"
129
+ empty="هنوز سرویسی ثبت نشده است."
130
+ defaultSelection={['2']}
131
+ selectAllLabel="انتخاب همه سرویس‌ها"
132
+ selectedCountLabel={(count: number) => `${count} مورد انتخاب شده`}
133
+ />
134
+ </div>
135
+ ),
136
+ },
137
+ ];
138
+
139
+ test('captures DataTable visual evidence', async () => {
140
+ const screen = await render(
141
+ <div className="flex flex-col">
142
+ {captures.map(({ name, ui }) => (
143
+ <div data-testid={`capture-${name}`} key={name}>
144
+ {ui}
145
+ </div>
146
+ ))}
147
+ </div>,
148
+ );
149
+
150
+ for (const { name } of captures) {
151
+ await screen
152
+ .getByTestId(`capture-${name}`)
153
+ .screenshot({ animations: 'disabled', caret: 'hide', path: `${OUT}/${name}.png` });
154
+ }
155
+
156
+ expect(captures).toHaveLength(5);
157
+ });