noecosystem-design 0.2.3 → 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 (49) hide show
  1. package/README.md +2 -2
  2. package/apps/storybook/stories/catalog/checkbox.stories.tsx +7 -2
  3. package/apps/storybook/stories/catalog/data-table.stories.tsx +226 -3
  4. package/docs/design-system/components.md +32 -0
  5. package/docs/design-system/inventory.md +1 -0
  6. package/docs/design-system/visual-qa.md +6 -0
  7. package/package.json +1 -1
  8. package/packages/data-ui/package.json +6 -1
  9. package/packages/data-ui/src/data-table.tsx +2 -99
  10. package/packages/data-ui/src/pagination.tsx +2 -2
  11. package/packages/data-ui/src/table/adapters/tanstack.test.ts +116 -0
  12. package/packages/data-ui/src/table/adapters/tanstack.ts +179 -0
  13. package/packages/data-ui/src/table/contracts.ts +133 -0
  14. package/packages/data-ui/src/table/data-table-visual.evidence.browser.test.tsx +157 -0
  15. package/packages/data-ui/src/table/data-table.browser.test.tsx +180 -0
  16. package/packages/data-ui/src/table/data-table.tsx +524 -0
  17. package/packages/design-tokens/src/tokens.css +10 -4
  18. package/packages/design-tokens/src/tokens.json +8 -4
  19. package/packages/design-tokens/src/tokens.mjs +8 -4
  20. package/packages/registry/catalog-index.json +2 -2
  21. package/packages/registry/manifest.json +60 -18
  22. package/packages/registry/r/activity-feed.json +1 -1
  23. package/packages/registry/r/agent-composer.json +7 -1
  24. package/packages/registry/r/agent-message.json +1 -1
  25. package/packages/registry/r/agent-plan.json +1 -1
  26. package/packages/registry/r/agent-workspace.json +1 -1
  27. package/packages/registry/r/approval-request.json +7 -1
  28. package/packages/registry/r/calendar.json +6 -5
  29. package/packages/registry/r/chart-frame.json +1 -1
  30. package/packages/registry/r/chart.json +1 -1
  31. package/packages/registry/r/dashboard-01.json +22 -2
  32. package/packages/registry/r/dashboard-02.json +22 -2
  33. package/packages/registry/r/dashboard-shell.json +1 -1
  34. package/packages/registry/r/data-table.json +22 -2
  35. package/packages/registry/r/date-picker.json +6 -5
  36. package/packages/registry/r/filter-bar.json +1 -1
  37. package/packages/registry/r/inline-cta.json +6 -6
  38. package/packages/registry/r/metric-card.json +1 -1
  39. package/packages/registry/r/pagination.json +7 -5
  40. package/packages/registry/r/run-inspector.json +1 -1
  41. package/packages/registry/r/tool-call.json +1 -1
  42. package/packages/registry/r/workflow-canvas.json +1 -1
  43. package/packages/registry/r/workflow-node.json +1 -1
  44. package/packages/registry/registry.json +12 -3
  45. package/packages/registry/search-index.json +23 -8
  46. package/packages/ui/package.json +5 -1
  47. package/packages/ui/src/checkbox.tsx +1 -1
  48. package/scripts/generate-manifest.mjs +71 -0
  49. package/vitest.config.ts +5 -0
package/README.md CHANGED
@@ -100,8 +100,8 @@ Advanced Security are enabled in repository settings.
100
100
  CodeQL is similarly opt-in through `ENABLE_CODEQL=true`: first enable **Code
101
101
  Security** for this private repository in GitHub’s security settings, then set the
102
102
  variable. This avoids a misleading failed workflow when GitHub cannot accept SARIF
103
- uploads. The workflows use Node 24; the runner’s Node 20 deprecation notice does not
104
- require a compatibility override.
103
+ uploads. The workflows configure Node 24 and use `actions/setup-node@v6`, whose
104
+ action runtime is also Node 24.
105
105
 
106
106
  ## Architecture
107
107
 
@@ -8,11 +8,16 @@ const meta = {
8
8
  } satisfies Meta<typeof Checkbox>;
9
9
  export default meta;
10
10
  type Story = StoryObj<typeof meta>;
11
- export const Default: Story = {};
11
+ export const Default: Story = {
12
+ render: () => <Checkbox label="Enable notifications" />,
13
+ };
14
+ export const Bare: Story = {
15
+ render: () => <Checkbox aria-label="Enable notifications" />,
16
+ };
12
17
  export const RTL: Story = {
13
18
  render: () => (
14
19
  <div dir="rtl">
15
- <Checkbox />
20
+ <Checkbox label="فعال‌سازی اعلان‌ها" />
16
21
  </div>
17
22
  ),
18
23
  };
@@ -1,11 +1,23 @@
1
+ import { useState } from 'react';
1
2
  import type { Meta, StoryObj } from '@storybook/react-vite';
3
+ import { Button } from '@noe/ui/button';
4
+ import { EmptyState } from '@noe/ui/empty-state';
5
+ import { Input } from '@noe/ui/input';
2
6
 
3
- import { type DataColumn, DataTable } from '../../../../packages/data-ui/src/data-table';
7
+ import {
8
+ type DataColumn,
9
+ DataTable,
10
+ type DataSort,
11
+ type FilterSpec,
12
+ type PageSpec,
13
+ type RowId,
14
+ } from '../../../../packages/data-ui/src/data-table';
15
+ import { FilterBar } from '../../../../packages/data-ui/src/filter-bar';
4
16
 
5
17
  type ServiceRow = { id: string; service: string; owner: string; status: string; updated: string };
6
18
 
7
19
  const columns: DataColumn<ServiceRow>[] = [
8
- { key: 'service', header: 'Service' },
20
+ { key: 'service', header: 'Service', technical: true },
9
21
  { key: 'owner', header: 'Owner' },
10
22
  { key: 'status', header: 'Status' },
11
23
  { key: 'updated', header: 'Updated', technical: true, align: 'end' },
@@ -29,6 +41,18 @@ const rows: ServiceRow[] = [
29
41
  },
30
42
  ];
31
43
 
44
+ const moreRows: ServiceRow[] = [
45
+ ...rows,
46
+ {
47
+ id: '4',
48
+ service: 'registry-api',
49
+ owner: 'Design Systems',
50
+ status: 'Healthy',
51
+ updated: '3h ago',
52
+ },
53
+ { id: '5', service: 'agent-gateway', owner: 'AI Platform', status: 'Healthy', updated: '5h ago' },
54
+ ];
55
+
32
56
  function DataTableDemo() {
33
57
  return (
34
58
  <DataTable columns={columns} rows={rows} caption="Service health" empty="No services yet." />
@@ -44,8 +68,207 @@ export default meta;
44
68
  type Story = StoryObj<typeof meta>;
45
69
 
46
70
  export const Default: Story = {};
71
+
47
72
  export const Empty: Story = {
48
73
  render: () => (
49
- <DataTable columns={columns} rows={[]} caption="Service health" empty="No services yet." />
74
+ <DataTable
75
+ columns={columns}
76
+ rows={[]}
77
+ caption="Service health"
78
+ empty={
79
+ <EmptyState
80
+ title="No services yet"
81
+ description="Services appear here once they report health to the platform."
82
+ />
83
+ }
84
+ />
85
+ ),
86
+ };
87
+
88
+ function SortingDemo() {
89
+ const [sorting, setSorting] = useState<DataSort[]>([{ field: 'service', direction: 'asc' }]);
90
+ return (
91
+ <DataTable
92
+ columns={columns}
93
+ rows={[...moreRows]}
94
+ caption="Service health"
95
+ empty="No services yet."
96
+ sorting={sorting}
97
+ onSortingChange={setSorting}
98
+ />
99
+ );
100
+ }
101
+ export const Sorting: Story = { render: () => <SortingDemo /> };
102
+
103
+ function SelectionDemo() {
104
+ const [selected, setSelected] = useState<RowId[]>(['2']);
105
+ return (
106
+ <DataTable
107
+ columns={columns}
108
+ rows={rows}
109
+ caption="Service health"
110
+ empty="No services yet."
111
+ selection={selected}
112
+ onSelectionChange={setSelected}
113
+ selectAllLabel="Select all services"
114
+ rowSelectionLabel={(row: ServiceRow) => `Select ${row.service}`}
115
+ selectedCountLabel={(count: number) => `${count} selected`}
116
+ renderSelectionActions={({ selectedIds }) => (
117
+ <Button size="sm" variant="outline">
118
+ Restart {selectedIds.length > 0 ? `(${selectedIds.length})` : ''}
119
+ </Button>
120
+ )}
121
+ />
122
+ );
123
+ }
124
+ export const Selection: Story = { render: () => <SelectionDemo /> };
125
+
126
+ function PaginationDemo() {
127
+ const [page, setPage] = useState<PageSpec>({ index: 0, size: 2 });
128
+ return (
129
+ <DataTable
130
+ columns={columns}
131
+ rows={[...moreRows]}
132
+ caption="Service health"
133
+ empty="No services yet."
134
+ page={page}
135
+ onPageChange={setPage}
136
+ previousPageLabel="Previous page"
137
+ nextPageLabel="Next page"
138
+ paginationLabel="Service health pagination"
139
+ />
140
+ );
141
+ }
142
+ export const Pagination: Story = { render: () => <PaginationDemo /> };
143
+
144
+ function WithFiltersDemo() {
145
+ const [search, setSearch] = useState('');
146
+ const [filters, setFilters] = useState<FilterSpec[]>([]);
147
+ return (
148
+ <div className="flex flex-col gap-3">
149
+ <FilterBar
150
+ label="Filter services"
151
+ search={
152
+ <Input
153
+ aria-label="Search services"
154
+ onChange={(event) => {
155
+ setSearch(event.target.value);
156
+ setFilters(
157
+ event.target.value
158
+ ? [{ field: 'service', operator: 'contains', value: event.target.value }]
159
+ : [],
160
+ );
161
+ }}
162
+ placeholder="Search services"
163
+ size="sm"
164
+ value={search}
165
+ />
166
+ }
167
+ />
168
+ <DataTable
169
+ columns={columns}
170
+ rows={[...moreRows]}
171
+ caption="Service health"
172
+ empty="No services match the current filter."
173
+ filters={filters}
174
+ onFiltersChange={setFilters}
175
+ />
176
+ </div>
177
+ );
178
+ }
179
+ export const WithFilters: Story = { render: () => <WithFiltersDemo /> };
180
+
181
+ function CompactDemo() {
182
+ return (
183
+ <DataTable
184
+ columns={columns}
185
+ rows={[...moreRows]}
186
+ caption="Service health"
187
+ density="compact"
188
+ empty="No services yet."
189
+ page={{ index: 0, size: 3 }}
190
+ onPageChange={() => {}}
191
+ previousPageLabel="Previous page"
192
+ nextPageLabel="Next page"
193
+ paginationLabel="Service health pagination"
194
+ />
195
+ );
196
+ }
197
+ export const Compact: Story = { render: () => <CompactDemo /> };
198
+
199
+ function RtlDemo() {
200
+ const [sorting, setSorting] = useState<DataSort[]>([{ field: 'service', direction: 'asc' }]);
201
+ const faColumns: DataColumn<ServiceRow>[] = [
202
+ { key: 'service', header: 'سرویس', technical: true },
203
+ { key: 'owner', header: 'مالک' },
204
+ { key: 'status', header: 'وضعیت' },
205
+ { key: 'updated', header: 'آخرین به‌روزرسانی', align: 'end' },
206
+ ];
207
+ return (
208
+ <div dir="rtl" lang="fa">
209
+ <DataTable
210
+ columns={faColumns}
211
+ rows={rows}
212
+ caption="وضعیت سرویس‌ها"
213
+ density="compact"
214
+ empty="هنوز سرویسی ثبت نشده است."
215
+ sorting={sorting}
216
+ onSortingChange={setSorting}
217
+ selectAllLabel="انتخاب همه سرویس‌ها"
218
+ rowSelectionLabel={(row: ServiceRow) => `انتخاب ${row.service}`}
219
+ selectedCountLabel={(count: number) => `${count} مورد انتخاب شده`}
220
+ page={{ index: 0, size: 2 }}
221
+ onPageChange={() => {}}
222
+ previousPageLabel="صفحه قبلی"
223
+ nextPageLabel="صفحه بعدی"
224
+ paginationLabel="صفحه‌بندی وضعیت سرویس‌ها"
225
+ />
226
+ </div>
227
+ );
228
+ }
229
+ export const RTL: Story = { render: () => <RtlDemo /> };
230
+
231
+ function ServerSideDemo() {
232
+ const [query, setQuery] = useState<{ sorts: DataSort[]; filters: FilterSpec[]; page: PageSpec }>({
233
+ sorts: [],
234
+ filters: [],
235
+ page: { index: 0, size: 2 },
236
+ });
237
+ // A real product sends `toDataQuery(query)` to Django and renders the page.
238
+ const serverPage = moreRows.slice(
239
+ query.page.index * query.page.size,
240
+ (query.page.index + 1) * query.page.size,
241
+ );
242
+ return (
243
+ <DataTable
244
+ columns={columns}
245
+ rows={serverPage}
246
+ caption="Service health"
247
+ empty="No services yet."
248
+ sorting={query.sorts}
249
+ onSortingChange={(sorts) => setQuery((q) => ({ ...q, sorts }))}
250
+ manualSorting
251
+ page={query.page}
252
+ onPageChange={(next) => setQuery((q) => ({ ...q, page: next }))}
253
+ manualPagination
254
+ totalRowCount={moreRows.length}
255
+ previousPageLabel="Previous page"
256
+ nextPageLabel="Next page"
257
+ paginationLabel="Service health pagination"
258
+ />
259
+ );
260
+ }
261
+ export const ServerSide: Story = { render: () => <ServerSideDemo /> };
262
+
263
+ export const Loading: Story = {
264
+ render: () => (
265
+ <DataTable
266
+ columns={columns}
267
+ rows={[]}
268
+ caption="Service health"
269
+ empty="No services yet."
270
+ loading
271
+ loadingRows={3}
272
+ />
50
273
  ),
51
274
  };
@@ -101,6 +101,38 @@ Implemented source includes Button, Input, Field, Dialog, Drawer, Tabs, auto-dir
101
101
  - Tab arrow behavior resolves against active direction.
102
102
  - Email, URL, telephone, numeric, code and identifier content preserve technical direction.
103
103
 
104
+ ## Data tables
105
+
106
+ `DataTable` in `@noe/data-ui` owns the semantic `<table>` markup, NOE tokens,
107
+ RTL behavior and accessibility, while TanStack Table v9 provides the headless
108
+ state engine behind an internal adapter
109
+ (`packages/data-ui/src/table/adapters/tanstack.ts`, per ADR 0008). TanStack
110
+ types never appear in the public API: products program against NOE contracts
111
+ (`DataColumn`, `DataSort`, `FilterSpec`, `PageSpec`, `RowId`, `SelectionState`,
112
+ `DataQuery`) exported from `@noe/data-ui`.
113
+
114
+ - Sorting headers cycle ascending → descending → unsorted and expose `aria-sort`;
115
+ icons are direction-neutral so RTL needs no mirroring.
116
+ - Filtering uses NOE `FilterSpec` operators (`contains`, `equals`, `in`,
117
+ `between`, …). `FilterBar` composes the controls; `DataTable` owns state.
118
+ - Pagination is controlled via `PageSpec` with caller-provided accessible
119
+ labels, matching the `Pagination` primitive contract.
120
+ - Selection keeps stable row ids (`RowId`), supports single/multiple modes and
121
+ never selects by positional index.
122
+ - Server mode (`manualSorting`/`manualFiltering`/`manualPagination` +
123
+ `totalRowCount`) trusts incoming data order and slicing; pair it with
124
+ `toDataQuery` to serialize state into a Django query.
125
+ - `density="compact"` tightens row rhythm for dense workspaces using the
126
+ spacing scale; `regular` is the default.
127
+ - `empty` accepts composed content — pass an `EmptyState` for full empty
128
+ affordances. The `WithFilters` catalog story demonstrates the FilterBar +
129
+ Input + DataTable composition.
130
+
131
+ Deterministic visual evidence (light, dark, RTL, selection, loading) is
132
+ captured by `packages/data-ui/src/table/data-table-visual.evidence.browser.test.tsx`
133
+ into `evidence/data-table/`; the browser test project renders with the real
134
+ Tailwind-expanded NOE tokens.
135
+
104
136
  ## Product adapters
105
137
 
106
138
  Data, chart, agent and workflow packages own the NOE composition. Product screens should never import an unnormalized marketplace component directly.
@@ -386,3 +386,4 @@ Total catalog entries: **129** · registry items: **67**
386
386
  - `fab`: Mobile-only floating action button anchored to the logical end edge above the bottom navigation, with safe-area padding and required accessible label.
387
387
  - `mobile-screen`: Single-column mobile page scaffold composing an app bar, scrollable content region, optional floating action button and bottom navigation.
388
388
  - `bottom-navigation`: Mobile-only bottom tab bar with current-page state, badges, icon slots and safe-area padding; hidden from the medium breakpoint upward.
389
+
@@ -7,4 +7,10 @@ The capture script generates deterministic PNG evidence for:
7
7
  - Consumer, dashboard, agent and workflow surfaces at representative desktop conditions.
8
8
  - Pseudo-LTR and pseudo-RTL stress views.
9
9
 
10
+ The NOE DataTable has its own deterministic component-level evidence:
11
+ `packages/data-ui/src/table/data-table-visual.evidence.browser.test.tsx`
12
+ renders regular and compact density, Persian RTL, selection (light and dark)
13
+ and loading states with the real Tailwind-expanded tokens into
14
+ `evidence/data-table/`.
15
+
10
16
  Review checks include overflow, clipped Persian, bidi corruption, arrow direction, sidebar collapse, technical-value isolation, table scrolling, excessive cards, random shadows, coral overuse, mirrored brand marks, focus order and mobile composition.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "noecosystem-design",
3
- "version": "0.2.3",
3
+ "version": "0.3.0",
4
4
  "private": false,
5
5
  "description": "Agent-readable NOE design-system source, registry metadata, and verification contracts.",
6
6
  "keywords": [
@@ -5,11 +5,16 @@
5
5
  "type": "module",
6
6
  "dependencies": {
7
7
  "@noe/icons": "workspace:*",
8
- "@noe/ui": "workspace:*"
8
+ "@noe/ui": "workspace:*",
9
+ "@tanstack/react-table": "^9.2.4"
9
10
  },
10
11
  "peerDependencies": {
11
12
  "react": "^19.2.0"
12
13
  },
14
+ "devDependencies": {
15
+ "vitest": "4.1.11",
16
+ "vitest-browser-react": "2.2.0"
17
+ },
13
18
  "exports": {
14
19
  ".": "./src/index.ts"
15
20
  }
@@ -1,99 +1,2 @@
1
- import { useId } from 'react';
2
- import type * as React from 'react';
3
- import { TechnicalValue } from '@noe/ui/bidi';
4
- import { cn } from '@noe/ui/lib/cn';
5
-
6
- export type DataColumn<Row> = {
7
- key: keyof Row & string;
8
- header: React.ReactNode;
9
- align?: 'start' | 'end';
10
- technical?: boolean;
11
- render?: (row: Row) => React.ReactNode;
12
- };
13
-
14
- export function DataTable<Row extends { id: string }>({
15
- columns,
16
- rows,
17
- caption,
18
- empty,
19
- className,
20
- }: {
21
- columns: DataColumn<Row>[];
22
- rows: Row[];
23
- caption: string;
24
- empty: React.ReactNode;
25
- className?: string;
26
- }) {
27
- const captionId = useId();
28
- if (!rows.length)
29
- return (
30
- <div
31
- className={cn(
32
- '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',
33
- className,
34
- )}
35
- data-slot="data-table-empty"
36
- >
37
- {empty}
38
- </div>
39
- );
40
-
41
- return (
42
- // WCAG 2.1: keyboard-accessible scrollable container for overflow tables.
43
- <section
44
- aria-labelledby={captionId}
45
- className={cn('overflow-x-auto rounded-lg border border-border', className)}
46
- data-slot="data-table-scroll"
47
- // biome-ignore lint/a11y/noNoninteractiveTabindex: scrollable region focus pattern
48
- tabIndex={0}
49
- >
50
- <table className="w-full min-w-full border-collapse text-sm" data-slot="data-table">
51
- <caption className="sr-only" id={captionId}>
52
- {caption}
53
- </caption>
54
- <thead>
55
- <tr className="border-b border-border bg-surface-sunken/60">
56
- {columns.map((column) => (
57
- <th
58
- className={cn(
59
- 'whitespace-nowrap px-4 py-2.5 text-xs font-medium text-muted-foreground',
60
- column.align === 'end' ? 'text-end' : 'text-start',
61
- )}
62
- data-align={column.align ?? 'start'}
63
- key={column.key}
64
- scope="col"
65
- >
66
- {column.header}
67
- </th>
68
- ))}
69
- </tr>
70
- </thead>
71
- <tbody>
72
- {rows.map((row) => {
73
- const cells = columns.map((column) => {
74
- const content = column.render ? column.render(row) : String(row[column.key] ?? '');
75
- const value = column.technical ? <TechnicalValue>{content}</TechnicalValue> : content;
76
- return (
77
- <td
78
- className={cn(
79
- 'whitespace-nowrap px-4 py-2.5 text-foreground',
80
- column.align === 'end' ? 'text-end' : 'text-start',
81
- )}
82
- data-align={column.align ?? 'start'}
83
- key={column.key}
84
- >
85
- {value}
86
- </td>
87
- );
88
- });
89
- return (
90
- <tr className="border-b border-border last:border-b-0 hover:bg-muted/50" key={row.id}>
91
- {cells}
92
- </tr>
93
- );
94
- })}
95
- </tbody>
96
- </table>
97
- </section>
98
- );
99
- }
1
+ export * from './table/contracts';
2
+ export * from './table/data-table';
@@ -8,8 +8,8 @@ export interface PaginationProps extends React.ComponentProps<'nav'> {
8
8
  status: React.ReactNode;
9
9
  previousLabel: string;
10
10
  nextLabel: string;
11
- onPrevious?: () => void;
12
- onNext?: () => void;
11
+ onPrevious?: (() => void) | undefined;
12
+ onNext?: (() => void) | undefined;
13
13
  canPrevious?: boolean;
14
14
  canNext?: boolean;
15
15
  }
@@ -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
+ });