chati-dev 4.3.0 → 4.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.
- package/README.md +18 -13
- package/bin/chati.js +32 -4
- package/framework/agents/build/dev.md +9 -5
- package/framework/agents/discover/brief.md +2 -0
- package/framework/agents/discover/brownfield-wu.md +2 -0
- package/framework/agents/discover/greenfield-wu.md +18 -1
- package/framework/agents/plan/detail.md +2 -0
- package/framework/agents/plan/tasks.md +31 -0
- package/framework/agents/plan/ux-brand-architect.md +21 -2
- package/framework/agents/plan/ux-component-engineer.md +10 -0
- package/framework/agents/quality/qa-implementation.md +1 -1
- package/framework/agents/quality/qa-planning.md +1 -1
- package/framework/agents/quality/qa-visual.md +33 -21
- package/framework/config.yaml +5 -4
- package/framework/constitution.md +58 -11
- package/framework/context/governance.md +12 -10
- package/framework/context/root.md +2 -2
- package/framework/data/entity-registry.yaml +12 -4
- package/framework/data/qa-rubrics.yaml +335 -0
- package/framework/domains/constitution.yaml +3 -3
- package/framework/executors/json-validate.js +80 -0
- package/framework/executors/npm-script.js +72 -0
- package/framework/executors/path-check.js +67 -0
- package/framework/executors/registry.yaml +36 -0
- package/framework/hooks/advance-trigger.js +47 -3
- package/framework/hooks/constitution-guard.js +9 -4
- package/framework/hooks/git-push-authority.js +113 -0
- package/framework/hooks/license-guard.js +59 -9
- package/framework/hooks/model-governance.js +40 -17
- package/framework/hooks/prism-engine.js +78 -11
- package/framework/hooks/session-digest.js +12 -3
- package/framework/hooks/settings.json +7 -3
- package/framework/hooks/style-guard.js +52 -3
- package/framework/hooks/team-quality-gate.js +43 -5
- package/framework/i18n/en.yaml +3 -3
- package/framework/i18n/es.yaml +3 -3
- package/framework/i18n/fr.yaml +3 -3
- package/framework/i18n/pt.yaml +3 -3
- package/framework/intelligence/confidence.yaml +85 -6
- package/framework/intelligence/context-engine.md +9 -5
- package/framework/intelligence/decision-engine.md +1 -1
- package/framework/orchestrator/chati-router.js +59 -5
- package/framework/orchestrator/chati-update.md +19 -3
- package/framework/orchestrator/chati.md +20 -1
- package/framework/quality-gates/planning-gate.md +2 -0
- package/framework/rules/human-writing-style.md +47 -0
- package/framework/scaffold/motion-premium/README.md +12 -2
- package/framework/scaffold/motion-premium/lib/animations/tokens.ts.template +1 -1
- package/framework/scaffold/motion-premium/scaffold.yaml +6 -1
- package/framework/scaffold/motion-premium-3d/README.md +10 -0
- package/framework/scaffold/motion-premium-3d/components/webgl/WebGLContext.tsx.template +1 -1
- package/framework/scaffold/motion-premium-3d/scaffold.yaml +3 -0
- package/framework/scaffold/saas-dashboard/README.md +58 -0
- package/framework/scaffold/saas-dashboard/app/dashboard/page.tsx.template +546 -0
- package/framework/scaffold/saas-dashboard/app/globals.css.template +746 -0
- package/framework/scaffold/saas-dashboard/app/layout.tsx.template +192 -0
- package/framework/scaffold/saas-dashboard/app/shell.css.template +1070 -0
- package/framework/scaffold/saas-dashboard/components/data/Chart.tsx.template +725 -0
- package/framework/scaffold/saas-dashboard/components/data/DataTable.tsx.template +623 -0
- package/framework/scaffold/saas-dashboard/components/data/DataTable.types.ts.template +104 -0
- package/framework/scaffold/saas-dashboard/components/data/DataTablePagination.tsx.template +234 -0
- package/framework/scaffold/saas-dashboard/components/data/SkeletonBlock.tsx.template +111 -0
- package/framework/scaffold/saas-dashboard/components/data/StatCard.tsx.template +393 -0
- package/framework/scaffold/saas-dashboard/components/data/StatCardGrid.tsx.template +121 -0
- package/framework/scaffold/saas-dashboard/components/feedback/EmptyState.tsx.template +281 -0
- package/framework/scaffold/saas-dashboard/components/interaction/CommandPalette.tsx.template +767 -0
- package/framework/scaffold/saas-dashboard/components/interaction/Field.tsx.template +351 -0
- package/framework/scaffold/saas-dashboard/components/interaction/Form.tsx.template +424 -0
- package/framework/scaffold/saas-dashboard/components/primitives/Badge.tsx.template +193 -0
- package/framework/scaffold/saas-dashboard/components/primitives/Button.tsx.template +261 -0
- package/framework/scaffold/saas-dashboard/components/primitives/Card.tsx.template +287 -0
- package/framework/scaffold/saas-dashboard/components/primitives/Input.tsx.template +310 -0
- package/framework/scaffold/saas-dashboard/components/primitives/Spinner.tsx.template +135 -0
- package/framework/scaffold/saas-dashboard/components/primitives/index.ts.template +60 -0
- package/framework/scaffold/saas-dashboard/components/shell/AppShell.tsx.template +174 -0
- package/framework/scaffold/saas-dashboard/components/shell/Breadcrumb.tsx.template +186 -0
- package/framework/scaffold/saas-dashboard/components/shell/Container.tsx.template +86 -0
- package/framework/scaffold/saas-dashboard/components/shell/NavItem.tsx.template +147 -0
- package/framework/scaffold/saas-dashboard/components/shell/Sidebar.tsx.template +378 -0
- package/framework/scaffold/saas-dashboard/components/shell/SidebarContext.tsx.template +169 -0
- package/framework/scaffold/saas-dashboard/components/shell/ThemeToggle.tsx.template +128 -0
- package/framework/scaffold/saas-dashboard/components/shell/Topbar.tsx.template +301 -0
- package/framework/scaffold/saas-dashboard/lib/brand.ts.template +324 -0
- package/framework/scaffold/saas-dashboard/scaffold.yaml +225 -0
- package/framework/schemas/session.schema.json +5 -0
- package/framework/schemas/task.schema.json +9 -0
- package/framework/scripts/visual-qa.js +101 -3
- package/framework/tasks/orchestrator-health.md +4 -4
- package/framework/tasks/qa-impl-verdict.md +11 -5
- package/framework/templates/brandbook-html-tmpl.md +1 -1
- package/framework/templates/qa-gate-tmpl.yaml +24 -11
- package/package.json +4 -3
- package/src/config/context-file-generator.js +0 -6
- package/src/dashboard/renderer.js +0 -36
- package/src/executors/runner.js +204 -0
- package/src/installer/core.js +13 -3
- package/src/installer/templates.js +5 -6
- package/src/intelligence/registry-manager.js +1 -1
- package/src/license/client.js +9 -3
- package/src/license/commands.js +12 -2
- package/src/license/machine-id.js +42 -1
- package/src/memory/gotchas.js +58 -40
- package/src/memory/magic-docs.js +1 -1
- package/src/memory/session-digest.js +9 -4
- package/src/orchestrator/cli.js +186 -15
- package/src/orchestrator/doctor.js +98 -16
- package/src/orchestrator/pipeline-manager.js +44 -22
- package/src/orchestrator/session-manager.js +64 -8
- package/src/telemetry/sender.js +7 -19
- package/src/terminal/run-team.js +3 -3
- package/src/upgrade/tracked-files-detector.js +34 -16
- package/src/utils/feature-flags.js +1 -1
- package/src/utils/flatten-entities.js +4 -40
- package/src/utils/schema-validator.js +0 -14
- package/src/wizard/i18n.js +3 -3
- package/src/api/index.js +0 -120
- package/src/autonomy/autonomous-gate.js +0 -294
- package/src/autonomy/build-loop.js +0 -281
- package/src/autonomy/build-state.js +0 -286
- package/src/autonomy/cause-analyzer.js +0 -177
- package/src/autonomy/escalation.js +0 -214
- package/src/autonomy/index.js +0 -51
- package/src/autonomy/mode-manager.js +0 -225
- package/src/autonomy/mode-suggester.js +0 -283
- package/src/autonomy/progress-reporter.js +0 -275
- package/src/autonomy/safety-net.js +0 -370
- package/src/config/agent-customizer.js +0 -231
- package/src/decision/analyzer.js +0 -291
- package/src/decision/engine.js +0 -250
- package/src/decision/index.js +0 -38
- package/src/decision/registry-healer.js +0 -468
- package/src/decision/registry-updater.js +0 -339
- package/src/extensions/loader.js +0 -145
- package/src/extensions/registry.js +0 -134
- package/src/gates/circuit-breaker.js +0 -151
- package/src/gates/g1-planning-complete.js +0 -154
- package/src/gates/g2-qa-planning.js +0 -156
- package/src/gates/g3-implementation.js +0 -215
- package/src/gates/g4-qa-implementation.js +0 -240
- package/src/gates/g5-deploy-ready.js +0 -181
- package/src/gates/gate-base.js +0 -185
- package/src/gates/index.js +0 -46
- package/src/health/auto-fix.js +0 -216
- package/src/health/engine.js +0 -246
- package/src/merger/semantic-merger.js +0 -292
- package/src/preview/detector.js +0 -238
- package/src/preview/index.js +0 -20
- package/src/preview/launcher.js +0 -235
- package/src/preview/log-buffer.js +0 -103
- package/src/quality/metrics-collector.js +0 -281
- package/src/quality/test-runner.js +0 -366
- package/src/tasks/executor.js +0 -195
- package/src/tasks/index.js +0 -4
- package/src/tasks/loader.js +0 -210
- package/src/tasks/router.js +0 -182
- package/src/utils/event-bus.js +0 -126
|
@@ -0,0 +1,623 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* components/data/DataTable.tsx
|
|
3
|
+
* ============================================================
|
|
4
|
+
* WHY THIS COMPONENT EXISTS
|
|
5
|
+
* -------------------------
|
|
6
|
+
* SaaS dashboards are fundamentally about tabular data. A reusable,
|
|
7
|
+
* premium data table component eliminates the most common source of
|
|
8
|
+
* quality inconsistency in dashboard UIs: each feature team
|
|
9
|
+
* hand-rolling its own table with slightly different row heights,
|
|
10
|
+
* hover states, loading patterns, and accessibility implementations.
|
|
11
|
+
*
|
|
12
|
+
* ARCHITECTURE
|
|
13
|
+
* ------------
|
|
14
|
+
* DataTable<T> is a generic React component where T is the row
|
|
15
|
+
* datum type. This eliminates `any` casts in cell renderers and
|
|
16
|
+
* gives callers full type inference on the `onRowClick` callback.
|
|
17
|
+
*
|
|
18
|
+
* The component is intentionally server-friendly: it receives
|
|
19
|
+
* already-sorted, already-paginated data via props. Sort state
|
|
20
|
+
* changes call `onSort` so the parent can decide whether to sort
|
|
21
|
+
* client-side or re-fetch. This matches the most common dashboard
|
|
22
|
+
* pattern (server-side pagination + sort) without forcing it.
|
|
23
|
+
*
|
|
24
|
+
* ACCESSIBILITY APPROACH
|
|
25
|
+
* ----------------------
|
|
26
|
+
* - <table> with a mandatory `caption` prop (not aria-label) so
|
|
27
|
+
* screen readers announce the table purpose before reading cells.
|
|
28
|
+
* - <th scope="col"> on all column headers (required for WCAG 1.3.1).
|
|
29
|
+
* - Sort buttons are real <button> elements with aria-sort on the
|
|
30
|
+
* <th>, not just styled divs.
|
|
31
|
+
* - Selection checkboxes have visually hidden labels via aria-label.
|
|
32
|
+
* - Row click targets are keyboard accessible via tabIndex + Enter/Space.
|
|
33
|
+
* - Loading skeleton rows use aria-hidden to prevent screen readers
|
|
34
|
+
* from announcing placeholder content.
|
|
35
|
+
*
|
|
36
|
+
* MOTION DESIGN
|
|
37
|
+
* -------------
|
|
38
|
+
* Row hover: background-color transition, duration-fast (120ms),
|
|
39
|
+
* ease-out. No transform on rows (layout-shift risk with variable
|
|
40
|
+
* row height and sticky header).
|
|
41
|
+
* Sort icon swap: opacity + transform (translateY 4px), duration-fast.
|
|
42
|
+
* Skeleton shimmer: translateX sweep on a pseudo-element, 1.4s
|
|
43
|
+
* ease-in-out infinite. Collapses to static bg on reduced-motion.
|
|
44
|
+
*
|
|
45
|
+
* SPACING DECISIONS
|
|
46
|
+
* -----------------
|
|
47
|
+
* Row height: min-height 44px (--space-11) via vertical cell padding
|
|
48
|
+
* of 12px (--space-3) top+bottom on a 14px font = ~40px + 4px border.
|
|
49
|
+
* Meets WCAG 2.5.5 target size guideline for interactive rows.
|
|
50
|
+
* Header: 40px (--space-10) via 10px v-padding, slightly denser than
|
|
51
|
+
* data rows to establish visual hierarchy without extra markup.
|
|
52
|
+
* ============================================================
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
'use client';
|
|
56
|
+
|
|
57
|
+
import React, { useState, useCallback, useId } from 'react';
|
|
58
|
+
import { ChevronUp, ChevronDown, ChevronsUpDown } from 'lucide-react';
|
|
59
|
+
import type { ColumnDef, SortState, SortDirection, PaginationState } from './DataTable.types';
|
|
60
|
+
import { DataTablePagination } from './DataTablePagination';
|
|
61
|
+
import { SkeletonBlock } from './SkeletonBlock';
|
|
62
|
+
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// Types
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
export interface DataTableProps<T> {
|
|
68
|
+
/**
|
|
69
|
+
* Column definitions. Each column controls header label, cell renderer,
|
|
70
|
+
* sort capability, and optional fixed width.
|
|
71
|
+
*/
|
|
72
|
+
columns: ColumnDef<T>[];
|
|
73
|
+
|
|
74
|
+
/** Row data. The component is display-only; sorting/paging is external. */
|
|
75
|
+
data: T[];
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Required for screen reader announcement. Describes the table content
|
|
79
|
+
* (e.g., "Monthly revenue by customer").
|
|
80
|
+
* NOTE: visually hidden by default (className="sr-only"). Pass
|
|
81
|
+
* captionVisible to show it above the table.
|
|
82
|
+
*/
|
|
83
|
+
caption: string;
|
|
84
|
+
|
|
85
|
+
/** When true, renders the caption as a visible heading above the table. */
|
|
86
|
+
captionVisible?: boolean;
|
|
87
|
+
|
|
88
|
+
/** When true, shows 6 skeleton rows instead of data rows. */
|
|
89
|
+
loading?: boolean;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Rendered when data is empty and loading is false.
|
|
93
|
+
* Defaults to a minimal "No results" message.
|
|
94
|
+
*/
|
|
95
|
+
emptyState?: React.ReactNode;
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Called when a sortable column header is clicked.
|
|
99
|
+
* The parent is responsible for sorting data and re-rendering.
|
|
100
|
+
*/
|
|
101
|
+
onSort?: (sort: SortState) => void;
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Current sort state. Controls the active sort indicator.
|
|
105
|
+
* When omitted, no sort indicator is shown.
|
|
106
|
+
*/
|
|
107
|
+
sortState?: SortState;
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Called when a data row is clicked. When provided, rows receive
|
|
111
|
+
* cursor-pointer and are keyboard-focusable (tabIndex=0).
|
|
112
|
+
*/
|
|
113
|
+
onRowClick?: (row: T) => void;
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* When true, the header row is sticky (position: sticky, top: 0).
|
|
117
|
+
* Requires the table's scroll container to have overflow-y: auto.
|
|
118
|
+
* The AppShell main content area provides this automatically.
|
|
119
|
+
*/
|
|
120
|
+
stickyHeader?: boolean;
|
|
121
|
+
|
|
122
|
+
/** Pagination props. When omitted, no pagination row is rendered. */
|
|
123
|
+
pagination?: PaginationState & {
|
|
124
|
+
onPageChange: (page: number) => void;
|
|
125
|
+
onPageSizeChange: (size: number) => void;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Row selection. When provided, adds a checkbox column as the first column.
|
|
130
|
+
* selectedKeys is a Set of string row keys.
|
|
131
|
+
*/
|
|
132
|
+
selection?: {
|
|
133
|
+
selectedKeys: Set<string>;
|
|
134
|
+
getRowKey: (row: T) => string;
|
|
135
|
+
onSelectionChange: (keys: Set<string>) => void;
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
/** Additional className applied to the <div> wrapper. */
|
|
139
|
+
className?: string;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
// Skeleton row count for loading state
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
const SKELETON_ROWS = 6;
|
|
146
|
+
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
// Sort icon helper
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
function SortIcon({
|
|
151
|
+
columnKey,
|
|
152
|
+
sortState,
|
|
153
|
+
}: {
|
|
154
|
+
columnKey: string;
|
|
155
|
+
sortState?: SortState;
|
|
156
|
+
}) {
|
|
157
|
+
const isActive = sortState?.key === columnKey;
|
|
158
|
+
const direction: SortDirection = isActive ? sortState!.direction : 'none';
|
|
159
|
+
|
|
160
|
+
return (
|
|
161
|
+
<span
|
|
162
|
+
aria-hidden="true"
|
|
163
|
+
style={{
|
|
164
|
+
display: 'inline-flex',
|
|
165
|
+
alignItems: 'center',
|
|
166
|
+
marginLeft: 'var(--space-1)',
|
|
167
|
+
opacity: isActive ? 1 : 0.35,
|
|
168
|
+
transition: `opacity var(--duration-fast) var(--ease-out),
|
|
169
|
+
transform var(--duration-fast) var(--ease-out)`,
|
|
170
|
+
transform: isActive && direction === 'desc' ? 'translateY(1px)' : 'translateY(0)',
|
|
171
|
+
}}
|
|
172
|
+
>
|
|
173
|
+
{direction === 'asc' ? (
|
|
174
|
+
<ChevronUp size={13} />
|
|
175
|
+
) : direction === 'desc' ? (
|
|
176
|
+
<ChevronDown size={13} />
|
|
177
|
+
) : (
|
|
178
|
+
<ChevronsUpDown size={13} />
|
|
179
|
+
)}
|
|
180
|
+
</span>
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ---------------------------------------------------------------------------
|
|
185
|
+
// Derive next sort direction in the 3-state cycle
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
function nextDirection(current: SortDirection): SortDirection {
|
|
188
|
+
if (current === 'none') return 'asc';
|
|
189
|
+
if (current === 'asc') return 'desc';
|
|
190
|
+
return 'none';
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ---------------------------------------------------------------------------
|
|
194
|
+
// Main component
|
|
195
|
+
// ---------------------------------------------------------------------------
|
|
196
|
+
export function DataTable<T>(props: DataTableProps<T>) {
|
|
197
|
+
const {
|
|
198
|
+
columns,
|
|
199
|
+
data,
|
|
200
|
+
caption,
|
|
201
|
+
captionVisible = false,
|
|
202
|
+
loading = false,
|
|
203
|
+
emptyState,
|
|
204
|
+
onSort,
|
|
205
|
+
sortState,
|
|
206
|
+
onRowClick,
|
|
207
|
+
stickyHeader = false,
|
|
208
|
+
pagination,
|
|
209
|
+
selection,
|
|
210
|
+
className,
|
|
211
|
+
} = props;
|
|
212
|
+
|
|
213
|
+
const tableId = useId();
|
|
214
|
+
const captionId = `${tableId}-caption`;
|
|
215
|
+
|
|
216
|
+
// ------------------------------------------------------------------
|
|
217
|
+
// Row selection logic
|
|
218
|
+
// ------------------------------------------------------------------
|
|
219
|
+
const allKeys = selection
|
|
220
|
+
? data.map((row) => selection.getRowKey(row))
|
|
221
|
+
: [];
|
|
222
|
+
const allSelected =
|
|
223
|
+
selection != null &&
|
|
224
|
+
allKeys.length > 0 &&
|
|
225
|
+
allKeys.every((k) => selection.selectedKeys.has(k));
|
|
226
|
+
const someSelected =
|
|
227
|
+
selection != null &&
|
|
228
|
+
allKeys.some((k) => selection.selectedKeys.has(k));
|
|
229
|
+
|
|
230
|
+
const handleSelectAll = useCallback(() => {
|
|
231
|
+
if (!selection) return;
|
|
232
|
+
if (allSelected) {
|
|
233
|
+
selection.onSelectionChange(new Set());
|
|
234
|
+
} else {
|
|
235
|
+
selection.onSelectionChange(new Set(allKeys));
|
|
236
|
+
}
|
|
237
|
+
}, [selection, allKeys, allSelected]);
|
|
238
|
+
|
|
239
|
+
const handleSelectRow = useCallback(
|
|
240
|
+
(key: string) => {
|
|
241
|
+
if (!selection) return;
|
|
242
|
+
const next = new Set(selection.selectedKeys);
|
|
243
|
+
if (next.has(key)) {
|
|
244
|
+
next.delete(key);
|
|
245
|
+
} else {
|
|
246
|
+
next.add(key);
|
|
247
|
+
}
|
|
248
|
+
selection.onSelectionChange(next);
|
|
249
|
+
},
|
|
250
|
+
[selection],
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
// ------------------------------------------------------------------
|
|
254
|
+
// Sort handler
|
|
255
|
+
// ------------------------------------------------------------------
|
|
256
|
+
const handleSort = useCallback(
|
|
257
|
+
(columnKey: string) => {
|
|
258
|
+
if (!onSort) return;
|
|
259
|
+
const currentDirection: SortDirection =
|
|
260
|
+
sortState?.key === columnKey ? sortState.direction : 'none';
|
|
261
|
+
onSort({ key: columnKey, direction: nextDirection(currentDirection) });
|
|
262
|
+
},
|
|
263
|
+
[onSort, sortState],
|
|
264
|
+
);
|
|
265
|
+
|
|
266
|
+
// ------------------------------------------------------------------
|
|
267
|
+
// Derived aria-sort value
|
|
268
|
+
// ------------------------------------------------------------------
|
|
269
|
+
function ariaSortFor(columnKey: string): React.AriaAttributes['aria-sort'] {
|
|
270
|
+
if (!sortState || sortState.key !== columnKey) return 'none';
|
|
271
|
+
if (sortState.direction === 'asc') return 'ascending';
|
|
272
|
+
if (sortState.direction === 'desc') return 'descending';
|
|
273
|
+
return 'none';
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ------------------------------------------------------------------
|
|
277
|
+
// Total columns count (including checkbox column)
|
|
278
|
+
// ------------------------------------------------------------------
|
|
279
|
+
const totalCols = columns.length + (selection ? 1 : 0);
|
|
280
|
+
|
|
281
|
+
// ------------------------------------------------------------------
|
|
282
|
+
// Styles (inline CSS custom properties -- no raw values)
|
|
283
|
+
// ------------------------------------------------------------------
|
|
284
|
+
const headerThStyle: React.CSSProperties = {
|
|
285
|
+
position: stickyHeader ? 'sticky' : undefined,
|
|
286
|
+
top: stickyHeader ? 0 : undefined,
|
|
287
|
+
zIndex: stickyHeader ? 1 : undefined,
|
|
288
|
+
backgroundColor: 'var(--color-bg-overlay)',
|
|
289
|
+
color: 'var(--color-text-secondary)',
|
|
290
|
+
fontSize: 'var(--type-caption)',
|
|
291
|
+
fontWeight: 'var(--font-weight-semibold)' as React.CSSProperties['fontWeight'],
|
|
292
|
+
letterSpacing: '0.04em',
|
|
293
|
+
textTransform: 'uppercase',
|
|
294
|
+
padding: 'var(--space-2) var(--space-4)',
|
|
295
|
+
borderBottom: '1px solid var(--color-border-default)',
|
|
296
|
+
whiteSpace: 'nowrap',
|
|
297
|
+
userSelect: 'none',
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
return (
|
|
301
|
+
<div
|
|
302
|
+
className={className}
|
|
303
|
+
style={{
|
|
304
|
+
borderRadius: 'var(--radius-default)',
|
|
305
|
+
border: '1px solid var(--color-border-default)',
|
|
306
|
+
overflow: 'hidden',
|
|
307
|
+
backgroundColor: 'var(--color-bg-raised)',
|
|
308
|
+
}}
|
|
309
|
+
>
|
|
310
|
+
{/* Table scroll container -- required for sticky header to work */}
|
|
311
|
+
<div style={{ overflowX: 'auto', overflowY: 'auto' }}>
|
|
312
|
+
<table
|
|
313
|
+
aria-labelledby={captionId}
|
|
314
|
+
style={{
|
|
315
|
+
width: '100%',
|
|
316
|
+
borderCollapse: 'collapse',
|
|
317
|
+
tableLayout: 'fixed',
|
|
318
|
+
fontFamily: 'var(--font-sans)',
|
|
319
|
+
fontSize: 'var(--type-small)',
|
|
320
|
+
color: 'var(--color-text-primary)',
|
|
321
|
+
}}
|
|
322
|
+
>
|
|
323
|
+
{/* Caption: always present for SR; optionally visible */}
|
|
324
|
+
<caption
|
|
325
|
+
id={captionId}
|
|
326
|
+
style={{
|
|
327
|
+
captionSide: 'top',
|
|
328
|
+
padding: captionVisible ? 'var(--space-4) var(--space-6)' : undefined,
|
|
329
|
+
fontSize: captionVisible ? 'var(--type-h4)' : undefined,
|
|
330
|
+
fontWeight: captionVisible ? 'var(--font-weight-semibold)' : undefined,
|
|
331
|
+
color: captionVisible ? 'var(--color-text-primary)' : undefined,
|
|
332
|
+
textAlign: captionVisible ? 'left' : undefined,
|
|
333
|
+
// visually hidden when captionVisible=false
|
|
334
|
+
...(captionVisible
|
|
335
|
+
? {}
|
|
336
|
+
: {
|
|
337
|
+
position: 'absolute',
|
|
338
|
+
width: '1px',
|
|
339
|
+
height: '1px',
|
|
340
|
+
padding: 0,
|
|
341
|
+
margin: '-1px',
|
|
342
|
+
overflow: 'hidden',
|
|
343
|
+
clip: 'rect(0,0,0,0)',
|
|
344
|
+
whiteSpace: 'nowrap',
|
|
345
|
+
border: 0,
|
|
346
|
+
}),
|
|
347
|
+
}}
|
|
348
|
+
>
|
|
349
|
+
{caption}
|
|
350
|
+
</caption>
|
|
351
|
+
|
|
352
|
+
{/* Column width hints */}
|
|
353
|
+
<colgroup>
|
|
354
|
+
{selection && (
|
|
355
|
+
<col style={{ width: '40px' }} />
|
|
356
|
+
)}
|
|
357
|
+
{columns.map((col) => (
|
|
358
|
+
<col
|
|
359
|
+
key={col.key}
|
|
360
|
+
style={{
|
|
361
|
+
width:
|
|
362
|
+
col.width != null
|
|
363
|
+
? typeof col.width === 'number'
|
|
364
|
+
? `${col.width}px`
|
|
365
|
+
: col.width
|
|
366
|
+
: undefined,
|
|
367
|
+
}}
|
|
368
|
+
/>
|
|
369
|
+
))}
|
|
370
|
+
</colgroup>
|
|
371
|
+
|
|
372
|
+
{/* Header */}
|
|
373
|
+
<thead>
|
|
374
|
+
<tr>
|
|
375
|
+
{/* Select-all checkbox */}
|
|
376
|
+
{selection && (
|
|
377
|
+
<th
|
|
378
|
+
scope="col"
|
|
379
|
+
style={{ ...headerThStyle, textAlign: 'center', padding: 'var(--space-2)' }}
|
|
380
|
+
>
|
|
381
|
+
<input
|
|
382
|
+
type="checkbox"
|
|
383
|
+
aria-label="Select all rows"
|
|
384
|
+
checked={allSelected}
|
|
385
|
+
ref={(el) => {
|
|
386
|
+
if (el) el.indeterminate = someSelected && !allSelected;
|
|
387
|
+
}}
|
|
388
|
+
onChange={handleSelectAll}
|
|
389
|
+
style={checkboxStyle}
|
|
390
|
+
/>
|
|
391
|
+
</th>
|
|
392
|
+
)}
|
|
393
|
+
|
|
394
|
+
{columns.map((col) => (
|
|
395
|
+
<th
|
|
396
|
+
key={col.key}
|
|
397
|
+
scope="col"
|
|
398
|
+
aria-sort={col.sortable ? ariaSortFor(col.key) : undefined}
|
|
399
|
+
style={{
|
|
400
|
+
...headerThStyle,
|
|
401
|
+
textAlign: col.align ?? 'left',
|
|
402
|
+
}}
|
|
403
|
+
>
|
|
404
|
+
{col.sortable ? (
|
|
405
|
+
<button
|
|
406
|
+
type="button"
|
|
407
|
+
onClick={() => handleSort(col.key)}
|
|
408
|
+
style={sortButtonStyle}
|
|
409
|
+
aria-label={`Sort by ${col.header}${
|
|
410
|
+
sortState?.key === col.key
|
|
411
|
+
? `, currently ${sortState.direction}ending`
|
|
412
|
+
: ''
|
|
413
|
+
}`}
|
|
414
|
+
>
|
|
415
|
+
{col.header}
|
|
416
|
+
{col.srDescription && (
|
|
417
|
+
<span
|
|
418
|
+
style={{
|
|
419
|
+
position: 'absolute',
|
|
420
|
+
width: '1px',
|
|
421
|
+
height: '1px',
|
|
422
|
+
overflow: 'hidden',
|
|
423
|
+
clip: 'rect(0,0,0,0)',
|
|
424
|
+
}}
|
|
425
|
+
>
|
|
426
|
+
{col.srDescription}
|
|
427
|
+
</span>
|
|
428
|
+
)}
|
|
429
|
+
<SortIcon columnKey={col.key} sortState={sortState} />
|
|
430
|
+
</button>
|
|
431
|
+
) : (
|
|
432
|
+
<>
|
|
433
|
+
{col.header}
|
|
434
|
+
{col.srDescription && (
|
|
435
|
+
<span
|
|
436
|
+
style={{
|
|
437
|
+
position: 'absolute',
|
|
438
|
+
width: '1px',
|
|
439
|
+
height: '1px',
|
|
440
|
+
overflow: 'hidden',
|
|
441
|
+
clip: 'rect(0,0,0,0)',
|
|
442
|
+
}}
|
|
443
|
+
>
|
|
444
|
+
{col.srDescription}
|
|
445
|
+
</span>
|
|
446
|
+
)}
|
|
447
|
+
</>
|
|
448
|
+
)}
|
|
449
|
+
</th>
|
|
450
|
+
))}
|
|
451
|
+
</tr>
|
|
452
|
+
</thead>
|
|
453
|
+
|
|
454
|
+
{/* Body */}
|
|
455
|
+
<tbody>
|
|
456
|
+
{/* Loading skeleton */}
|
|
457
|
+
{loading &&
|
|
458
|
+
Array.from({ length: SKELETON_ROWS }).map((_, rowIdx) => (
|
|
459
|
+
<tr key={`skel-${rowIdx}`} aria-hidden="true">
|
|
460
|
+
{selection && (
|
|
461
|
+
<td style={cellStyle('left')}>
|
|
462
|
+
<SkeletonBlock width={16} height={16} borderRadius="var(--radius-sm)" />
|
|
463
|
+
</td>
|
|
464
|
+
)}
|
|
465
|
+
{columns.map((col, colIdx) => (
|
|
466
|
+
<td key={col.key} style={cellStyle(col.align ?? 'left')}>
|
|
467
|
+
<SkeletonBlock
|
|
468
|
+
height={14}
|
|
469
|
+
width={colIdx === 0 ? '70%' : colIdx % 3 === 0 ? '50%' : '80%'}
|
|
470
|
+
borderRadius="var(--radius-sharp)"
|
|
471
|
+
/>
|
|
472
|
+
</td>
|
|
473
|
+
))}
|
|
474
|
+
</tr>
|
|
475
|
+
))}
|
|
476
|
+
|
|
477
|
+
{/* Empty state */}
|
|
478
|
+
{!loading && data.length === 0 && (
|
|
479
|
+
<tr>
|
|
480
|
+
<td
|
|
481
|
+
colSpan={totalCols}
|
|
482
|
+
style={{
|
|
483
|
+
padding: 'var(--space-16) var(--space-6)',
|
|
484
|
+
textAlign: 'center',
|
|
485
|
+
color: 'var(--color-text-secondary)',
|
|
486
|
+
}}
|
|
487
|
+
>
|
|
488
|
+
{emptyState ?? (
|
|
489
|
+
<span style={{ fontSize: 'var(--type-body)' }}>No results found.</span>
|
|
490
|
+
)}
|
|
491
|
+
</td>
|
|
492
|
+
</tr>
|
|
493
|
+
)}
|
|
494
|
+
|
|
495
|
+
{/* Data rows */}
|
|
496
|
+
{!loading &&
|
|
497
|
+
data.map((row, rowIdx) => {
|
|
498
|
+
const rowKey = selection?.getRowKey(row) ?? String(rowIdx);
|
|
499
|
+
const isSelected = selection?.selectedKeys.has(rowKey) ?? false;
|
|
500
|
+
const isInteractive = !!onRowClick;
|
|
501
|
+
|
|
502
|
+
return (
|
|
503
|
+
<tr
|
|
504
|
+
key={rowKey}
|
|
505
|
+
className="data-table-row"
|
|
506
|
+
onClick={isInteractive ? () => onRowClick!(row) : undefined}
|
|
507
|
+
onKeyDown={
|
|
508
|
+
isInteractive
|
|
509
|
+
? (e) => {
|
|
510
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
511
|
+
e.preventDefault();
|
|
512
|
+
onRowClick!(row);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
: undefined
|
|
516
|
+
}
|
|
517
|
+
tabIndex={isInteractive ? 0 : undefined}
|
|
518
|
+
role={isInteractive ? 'button' : undefined}
|
|
519
|
+
aria-selected={selection ? isSelected : undefined}
|
|
520
|
+
style={rowStyle(isSelected, isInteractive)}
|
|
521
|
+
>
|
|
522
|
+
{/* Selection checkbox */}
|
|
523
|
+
{selection && (
|
|
524
|
+
<td
|
|
525
|
+
style={{ ...cellStyle('center'), padding: 'var(--space-2)' }}
|
|
526
|
+
onClick={(e) => {
|
|
527
|
+
e.stopPropagation();
|
|
528
|
+
handleSelectRow(rowKey);
|
|
529
|
+
}}
|
|
530
|
+
>
|
|
531
|
+
<input
|
|
532
|
+
type="checkbox"
|
|
533
|
+
aria-label={`Select row ${rowIdx + 1}`}
|
|
534
|
+
checked={isSelected}
|
|
535
|
+
onChange={() => handleSelectRow(rowKey)}
|
|
536
|
+
style={checkboxStyle}
|
|
537
|
+
/>
|
|
538
|
+
</td>
|
|
539
|
+
)}
|
|
540
|
+
|
|
541
|
+
{/* Data cells */}
|
|
542
|
+
{columns.map((col) => {
|
|
543
|
+
const content = col.render
|
|
544
|
+
? col.render(row)
|
|
545
|
+
: String((row as Record<string, unknown>)[col.key] ?? '');
|
|
546
|
+
return (
|
|
547
|
+
<td key={col.key} style={cellStyle(col.align ?? 'left')}>
|
|
548
|
+
{content}
|
|
549
|
+
</td>
|
|
550
|
+
);
|
|
551
|
+
})}
|
|
552
|
+
</tr>
|
|
553
|
+
);
|
|
554
|
+
})}
|
|
555
|
+
</tbody>
|
|
556
|
+
</table>
|
|
557
|
+
</div>
|
|
558
|
+
|
|
559
|
+
{/* Pagination */}
|
|
560
|
+
{pagination && !loading && (
|
|
561
|
+
<DataTablePagination
|
|
562
|
+
page={pagination.page}
|
|
563
|
+
pageSize={pagination.pageSize}
|
|
564
|
+
total={pagination.total}
|
|
565
|
+
onPageChange={pagination.onPageChange}
|
|
566
|
+
onPageSizeChange={pagination.onPageSizeChange}
|
|
567
|
+
/>
|
|
568
|
+
)}
|
|
569
|
+
</div>
|
|
570
|
+
);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// ---------------------------------------------------------------------------
|
|
574
|
+
// Style helpers (all values reference CSS custom properties)
|
|
575
|
+
// ---------------------------------------------------------------------------
|
|
576
|
+
|
|
577
|
+
function cellStyle(align: 'left' | 'center' | 'right'): React.CSSProperties {
|
|
578
|
+
return {
|
|
579
|
+
padding: 'var(--space-3) var(--space-4)',
|
|
580
|
+
borderBottom: '1px solid var(--color-border-default)',
|
|
581
|
+
textAlign: align,
|
|
582
|
+
verticalAlign: 'middle',
|
|
583
|
+
lineHeight: 'var(--type-body-lh)',
|
|
584
|
+
color: 'var(--color-text-primary)',
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function rowStyle(isSelected: boolean, isInteractive: boolean): React.CSSProperties {
|
|
589
|
+
return {
|
|
590
|
+
backgroundColor: isSelected
|
|
591
|
+
? 'var(--color-bg-highlight)'
|
|
592
|
+
: 'transparent',
|
|
593
|
+
cursor: isInteractive ? 'pointer' : 'default',
|
|
594
|
+
transition: `background-color var(--duration-fast) var(--ease-out)`,
|
|
595
|
+
outline: 'none',
|
|
596
|
+
// Hover is handled via CSS class in a companion stylesheet or via
|
|
597
|
+
// onMouseEnter/Leave state. Using inline style for the base state
|
|
598
|
+
// keeps the component self-contained. A global .data-table-row:hover
|
|
599
|
+
// rule in globals.css handles the hover affordance.
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
const sortButtonStyle: React.CSSProperties = {
|
|
604
|
+
background: 'none',
|
|
605
|
+
border: 'none',
|
|
606
|
+
padding: 0,
|
|
607
|
+
cursor: 'pointer',
|
|
608
|
+
color: 'inherit',
|
|
609
|
+
font: 'inherit',
|
|
610
|
+
letterSpacing: 'inherit',
|
|
611
|
+
textTransform: 'inherit',
|
|
612
|
+
display: 'inline-flex',
|
|
613
|
+
alignItems: 'center',
|
|
614
|
+
gap: 0,
|
|
615
|
+
width: '100%',
|
|
616
|
+
};
|
|
617
|
+
|
|
618
|
+
const checkboxStyle: React.CSSProperties = {
|
|
619
|
+
width: '16px',
|
|
620
|
+
height: '16px',
|
|
621
|
+
accentColor: 'var(--color-accent)',
|
|
622
|
+
cursor: 'pointer',
|
|
623
|
+
};
|