noecosystem-design 0.2.3 → 0.4.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 (73) hide show
  1. package/README.md +2 -2
  2. package/apps/storybook/.storybook/preview.tsx +1 -1
  3. package/apps/storybook/stories/catalog/app-frame.stories.tsx +213 -1
  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/dialog.stories.tsx +2 -2
  7. package/apps/storybook/stories/catalog/icon-system.stories.tsx +11 -0
  8. package/apps/storybook/stories/catalog/menu.stories.tsx +3 -3
  9. package/apps/web/styles.css +0 -1
  10. package/docs/design-system/brand-theme.md +8 -0
  11. package/docs/design-system/components.md +38 -0
  12. package/docs/design-system/inventory.md +1 -0
  13. package/docs/design-system/visual-qa.md +6 -0
  14. package/package.json +1 -1
  15. package/packages/data-ui/package.json +6 -1
  16. package/packages/data-ui/src/data-table.tsx +2 -99
  17. package/packages/data-ui/src/pagination.tsx +2 -2
  18. package/packages/data-ui/src/table/adapters/tanstack.test.ts +116 -0
  19. package/packages/data-ui/src/table/adapters/tanstack.ts +179 -0
  20. package/packages/data-ui/src/table/contracts.ts +133 -0
  21. package/packages/data-ui/src/table/data-table-visual.evidence.browser.test.tsx +157 -0
  22. package/packages/data-ui/src/table/data-table.browser.test.tsx +180 -0
  23. package/packages/data-ui/src/table/data-table.tsx +524 -0
  24. package/packages/design-tokens/src/tailwind.css +5 -0
  25. package/packages/design-tokens/src/tokens.css +10 -4
  26. package/packages/design-tokens/src/tokens.json +8 -4
  27. package/packages/design-tokens/src/tokens.mjs +8 -4
  28. package/packages/icons/package.json +6 -1
  29. package/packages/icons/src/brands/nocfo.svg +8 -0
  30. package/packages/icons/src/nocfo-logo.browser.test.tsx +14 -0
  31. package/packages/icons/src/nocfo-logo.tsx +23 -0
  32. package/packages/registry/catalog-index.json +2 -2
  33. package/packages/registry/manifest.json +60 -18
  34. package/packages/registry/r/activity-feed.json +1 -1
  35. package/packages/registry/r/agent-composer.json +7 -1
  36. package/packages/registry/r/agent-message.json +1 -1
  37. package/packages/registry/r/agent-plan.json +1 -1
  38. package/packages/registry/r/agent-workspace.json +1 -1
  39. package/packages/registry/r/app-frame.json +9 -2
  40. package/packages/registry/r/approval-request.json +7 -1
  41. package/packages/registry/r/calendar.json +6 -5
  42. package/packages/registry/r/chart-frame.json +1 -1
  43. package/packages/registry/r/chart.json +1 -1
  44. package/packages/registry/r/dashboard-01.json +22 -2
  45. package/packages/registry/r/dashboard-02.json +22 -2
  46. package/packages/registry/r/dashboard-shell.json +1 -1
  47. package/packages/registry/r/data-table.json +22 -2
  48. package/packages/registry/r/date-picker.json +6 -5
  49. package/packages/registry/r/filter-bar.json +1 -1
  50. package/packages/registry/r/icon-system.json +18 -5
  51. package/packages/registry/r/inline-cta.json +6 -6
  52. package/packages/registry/r/metric-card.json +1 -1
  53. package/packages/registry/r/pagination.json +7 -5
  54. package/packages/registry/r/run-inspector.json +1 -1
  55. package/packages/registry/r/sidebar-navigation.json +5 -2
  56. package/packages/registry/r/tool-call.json +1 -1
  57. package/packages/registry/r/workflow-canvas.json +1 -1
  58. package/packages/registry/r/workflow-node.json +1 -1
  59. package/packages/registry/registry.json +12 -3
  60. package/packages/registry/search-index.json +23 -8
  61. package/packages/ui/package.json +5 -1
  62. package/packages/ui/src/app-frame.browser.test.tsx +6 -0
  63. package/packages/ui/src/app-frame.tsx +9 -2
  64. package/packages/ui/src/badge.tsx +4 -4
  65. package/packages/ui/src/calendar.tsx +1 -1
  66. package/packages/ui/src/checkbox.tsx +1 -1
  67. package/packages/ui/src/code.tsx +3 -1
  68. package/packages/ui/src/field.tsx +1 -1
  69. package/packages/ui/src/file-uploader.tsx +1 -1
  70. package/packages/ui/src/progress-steps.tsx +1 -1
  71. package/packages/ui/src/sidebar-navigation.tsx +11 -1
  72. package/scripts/generate-manifest.mjs +71 -0
  73. package/vitest.config.ts +5 -0
@@ -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.4.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
+ });
@@ -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
+ };