@kinetixui/ui 0.17.0 → 0.19.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 (62) hide show
  1. package/dist/index.d.ts +16 -1
  2. package/dist/index.js +455 -238
  3. package/package.json +2 -2
  4. package/src/components/audio-player.tsx +2 -2
  5. package/src/components/badge.tsx +1 -1
  6. package/src/components/banner.tsx +1 -1
  7. package/src/components/button.tsx +8 -8
  8. package/src/components/calendar.tsx +1 -1
  9. package/src/components/chart.tsx +2 -2
  10. package/src/components/checkbox.tsx +2 -2
  11. package/src/components/circular-progress.tsx +1 -1
  12. package/src/components/code-block.tsx +1 -1
  13. package/src/components/color-picker.tsx +4 -0
  14. package/src/components/data-grid.tsx +276 -24
  15. package/src/components/diff-viewer.tsx +9 -8
  16. package/src/components/fab.tsx +1 -1
  17. package/src/components/file-upload.tsx +13 -15
  18. package/src/components/input-group.tsx +1 -1
  19. package/src/components/input-otp.tsx +2 -1
  20. package/src/components/input.tsx +2 -2
  21. package/src/components/json-viewer.tsx +12 -10
  22. package/src/components/list.tsx +41 -27
  23. package/src/components/markdown-editor.tsx +3 -2
  24. package/src/components/message-bubble.tsx +3 -3
  25. package/src/components/multi-select.tsx +14 -3
  26. package/src/components/native-select.tsx +2 -2
  27. package/src/components/notification-center.tsx +2 -2
  28. package/src/components/progress.tsx +1 -1
  29. package/src/components/radio-group.tsx +3 -3
  30. package/src/components/scroll-area.tsx +1 -1
  31. package/src/components/select.tsx +2 -2
  32. package/src/components/skeleton.tsx +1 -1
  33. package/src/components/slider.tsx +2 -2
  34. package/src/components/sonner.tsx +1 -1
  35. package/src/components/spinner.tsx +3 -3
  36. package/src/components/stepper.tsx +3 -3
  37. package/src/components/switch.tsx +1 -1
  38. package/src/components/tab-bar.tsx +1 -1
  39. package/src/components/table-of-contents.tsx +1 -1
  40. package/src/components/textarea.tsx +2 -2
  41. package/src/components/timeline.tsx +1 -1
  42. package/src/components/tooltip.tsx +1 -1
  43. package/src/components/virtual-list.tsx +2 -1
  44. package/src/components-a11y.test.tsx +4 -25
  45. package/src/components-behavior.test.tsx +1 -1
  46. package/src/components-keyboard.test.tsx +388 -34
  47. package/src/stories/AppBar.stories.tsx +1 -1
  48. package/src/stories/Checkbox.stories.tsx +1 -1
  49. package/src/stories/CircularProgress.stories.tsx +4 -4
  50. package/src/stories/DataGrid.stories.tsx +2 -1
  51. package/src/stories/InputOTP.stories.tsx +2 -2
  52. package/src/stories/List.stories.tsx +2 -2
  53. package/src/stories/MarkdownEditor.stories.tsx +1 -1
  54. package/src/stories/MultiSelect.stories.tsx +1 -0
  55. package/src/stories/NativeSelect.stories.tsx +1 -1
  56. package/src/stories/NumberInput.stories.tsx +3 -3
  57. package/src/stories/Progress.stories.tsx +3 -3
  58. package/src/stories/Select.stories.tsx +2 -2
  59. package/src/stories/Slider.stories.tsx +3 -3
  60. package/src/stories/Switch.stories.tsx +1 -1
  61. package/src/stories/ToggleGroup.stories.tsx +6 -6
  62. package/tailwind.config.ts +14 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kinetixui/ui",
3
- "version": "0.17.0",
3
+ "version": "0.19.0",
4
4
  "private": false,
5
5
  "description": "KinetixUI React components — CVA + Radix + Tailwind on the KinetixUI token contract. Also distributed via the kinetixui CLI as a component registry.",
6
6
  "keywords": [
@@ -44,7 +44,7 @@
44
44
  "@dnd-kit/sortable": "^10.0.0",
45
45
  "@dnd-kit/utilities": "^3.2.2",
46
46
  "@hookform/resolvers": "^5.9.1",
47
- "@kinetixui/tokens": "0.17.0",
47
+ "@kinetixui/tokens": "0.19.0",
48
48
  "@radix-ui/react-accordion": "^1.2.20",
49
49
  "@radix-ui/react-alert-dialog": "^1.1.23",
50
50
  "@radix-ui/react-aspect-ratio": "^1.1.15",
@@ -66,7 +66,7 @@ const AudioPlayer = React.forwardRef<HTMLDivElement, AudioPlayerProps>(
66
66
  type="button"
67
67
  onClick={toggle}
68
68
  aria-label={playing ? "Pause" : "Play"}
69
- className="grid shrink-0 place-items-center rounded-full bg-primary text-primary-foreground outline-none transition-transform hover:scale-105 focus-visible:shadow-focus"
69
+ className="grid shrink-0 place-items-center rounded-full bg-action text-action-foreground outline-none transition-transform hover:scale-105 focus-visible:shadow-focus"
70
70
  style={{ width: size, height: size }}
71
71
  >
72
72
  {playing ? <Pause className="size-1/3 fill-current" /> : <Play className="size-1/3 translate-x-px fill-current" />}
@@ -105,7 +105,7 @@ const AudioPlayer = React.forwardRef<HTMLDivElement, AudioPlayerProps>(
105
105
  </button>
106
106
  <PlayPause size={36} />
107
107
  <div className="absolute inset-x-0 bottom-0 h-1 bg-muted">
108
- <div className="h-full bg-primary transition-[width]" style={{ width: `${pct}%` }} />
108
+ <div className="h-full bg-action transition-[width]" style={{ width: `${pct}%` }} />
109
109
  </div>
110
110
  </div>
111
111
  );
@@ -19,7 +19,7 @@ const badgeVariants = cva(
19
19
  {
20
20
  variants: {
21
21
  variant: {
22
- default: "bg-primary text-primary-foreground",
22
+ default: "bg-action text-action-foreground",
23
23
  secondary: "bg-secondary-foreground text-secondary",
24
24
  destructive: "bg-destructive text-destructive-foreground",
25
25
  outline: "border border-input text-foreground",
@@ -56,7 +56,7 @@ const Banner = React.forwardRef<HTMLDivElement, BannerProps>(
56
56
  ({ className, variant = "information", sticky, hideIcon, onDismiss, action, children, ...props }, ref) => {
57
57
  const Icon = ICON[variant ?? "information"];
58
58
  return (
59
- <div ref={ref} role="banner" className={cn(bannerVariants({ variant, sticky }), className)} {...props}>
59
+ <div ref={ref} className={cn(bannerVariants({ variant, sticky }), className)} {...props}>
60
60
  {!hideIcon && <Icon />}
61
61
  <div className="mx-auto flex min-w-0 max-w-4xl flex-1 flex-wrap items-center justify-center gap-x-3 gap-y-1 text-center">
62
62
  <span>{children}</span>
@@ -41,7 +41,7 @@ const buttonVariants = cva(
41
41
  // (the old hover:bg-[--color-blue-600] was a hard-coded dark navy — on dark mode,
42
42
  // where --primary is light, it inverted the button into the near-black background.)
43
43
  Primary:
44
- "bg-primary text-primary-foreground hover:bg-primary/90 active:bg-primary/85 disabled:bg-border disabled:text-muted-foreground",
44
+ "bg-action text-action-foreground hover:bg-action/90 active:bg-action/85 disabled:bg-border disabled:text-muted-foreground",
45
45
  // rest = --secondary; hover/active promote to the --secondary-foreground fill with
46
46
  // inverted text — theme-aware, unlike the previous hard-coded green primitives.
47
47
  // Active is solid like hover: dimming it (90% was 3.97:1) drops the text under AA,
@@ -51,14 +51,14 @@ const buttonVariants = cva(
51
51
  // 1px outline; hover fills with accent + strengthens the edge to --ring so the
52
52
  // state stays visible on the darkest surfaces (WCAG 2.2 SC 1.4.11); active pins primary
53
53
  Outline:
54
- "border border-input bg-transparent text-foreground hover:border-ring hover:bg-accent active:border-primary active:bg-accent active:text-primary disabled:border-input disabled:text-muted-foreground disabled:bg-transparent",
54
+ "border border-input bg-transparent text-foreground hover:border-ring hover:bg-accent active:border-action active:bg-accent active:text-action disabled:border-input disabled:text-muted-foreground disabled:bg-transparent",
55
55
  Destructive:
56
56
  "bg-destructive text-destructive-foreground hover:brightness-95 active:brightness-90 disabled:bg-border disabled:text-muted-foreground",
57
57
  // transparent rest; hover adds an inset --ring outline (the accent fill alone is
58
58
  // <3:1 against the page on dark) plus the accent fill; active pins primary text
59
59
  Ghost:
60
- "bg-transparent text-foreground hover:bg-accent hover:ring-1 hover:ring-inset hover:ring-ring active:bg-accent active:text-primary active:ring-0 disabled:text-muted-foreground disabled:bg-transparent",
61
- Link: "bg-transparent text-primary underline-offset-4 hover:underline hover:text-foreground rounded-none px-0 disabled:text-muted-foreground disabled:no-underline",
60
+ "bg-transparent text-foreground hover:bg-accent hover:ring-1 hover:ring-inset hover:ring-ring active:bg-accent active:text-action active:ring-0 disabled:text-muted-foreground disabled:bg-transparent",
61
+ Link: "bg-transparent text-link underline-offset-4 hover:underline hover:text-foreground rounded-none px-0 disabled:text-muted-foreground disabled:no-underline",
62
62
  },
63
63
  size: {
64
64
  // padding = Figma spacing/3 + spacing/2 ; type = Label Small (11/16, +0.5 tracking)
@@ -87,14 +87,14 @@ const buttonVariants = cva(
87
87
  },
88
88
  compoundVariants: [
89
89
  { variant: "Destructive", class: "focus-visible:shadow-focus-destructive" },
90
- { variant: "Primary", state: "Hover", class: "bg-primary/90" },
91
- { variant: "Primary", state: "Active", class: "bg-primary/85" },
90
+ { variant: "Primary", state: "Hover", class: "bg-action/90" },
91
+ { variant: "Primary", state: "Active", class: "bg-action/85" },
92
92
  { variant: "Secondary", state: "Hover", class: "bg-secondary-foreground text-secondary" },
93
93
  { variant: "Secondary", state: "Active", class: "bg-secondary-foreground text-secondary" },
94
94
  { variant: "Outline", state: "Hover", class: "border-ring bg-accent" },
95
- { variant: "Outline", state: "Active", class: "border-primary bg-accent text-primary" },
95
+ { variant: "Outline", state: "Active", class: "border-action bg-accent text-action" },
96
96
  { variant: "Ghost", state: "Hover", class: "bg-accent ring-1 ring-inset ring-ring" },
97
- { variant: "Ghost", state: "Active", class: "bg-accent text-primary" },
97
+ { variant: "Ghost", state: "Active", class: "bg-accent text-action" },
98
98
  { variant: "Link", state: "Hover", class: "underline text-foreground" },
99
99
  { variant: "Link", size: ["sm", "md", "lg"], class: "py-2 px-0 h-auto" },
100
100
  ],
@@ -45,7 +45,7 @@ function Calendar({ className, classNames, showOutsideDays = true, ...props }: C
45
45
  ),
46
46
  day_button: cn(
47
47
  buttonVariants({ variant: "Ghost", size: "icon" }),
48
- "size-8 p-0 font-normal group-aria-selected:opacity-visible group-data-[selected=true]:bg-primary group-data-[selected=true]:text-primary-foreground group-data-[selected=true]:hover:bg-primary group-data-[selected=true]:hover:text-primary-foreground group-data-[selected=true]:focus:bg-primary group-data-[selected=true]:focus:text-primary-foreground",
48
+ "size-8 p-0 font-normal group-aria-selected:opacity-visible group-data-[selected=true]:bg-action group-data-[selected=true]:text-action-foreground group-data-[selected=true]:hover:bg-action group-data-[selected=true]:hover:text-action-foreground group-data-[selected=true]:focus:bg-action group-data-[selected=true]:focus:text-action-foreground",
49
49
  ),
50
50
  range_start: "day-range-start rounded-l-md",
51
51
  range_end: "day-range-end rounded-r-md",
@@ -100,7 +100,7 @@ const ChartContainer = React.forwardRef<
100
100
  role={role ?? "img"}
101
101
  aria-label={name}
102
102
  className={cn(
103
- "flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
103
+ "flex aspect-video justify-center text-xs [&_.recharts-surface:focus-visible]:[outline:2px_solid_hsl(var(--ring))] [&_.recharts-surface:focus-visible]:rounded-sm [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
104
104
  className,
105
105
  )}
106
106
  {...props}
@@ -144,7 +144,7 @@ function ChartState({
144
144
  {[40, 72, 56, 88, 48, 64, 80].map((h, i) => (
145
145
  <span
146
146
  key={i}
147
- className="w-full animate-pulse rounded-sm bg-muted"
147
+ className="w-full animate-pulse rounded-sm bg-muted motion-reduce:animate-none"
148
148
  style={{ height: `${h}px`, animationDelay: `${i * 90}ms` }}
149
149
  />
150
150
  ))}
@@ -21,8 +21,8 @@ const Checkbox = React.forwardRef<
21
21
  "peer size-[18px] shrink-0 rounded-sm border-2 border-input outline-none transition-colors",
22
22
  "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
23
23
  "disabled:cursor-not-allowed disabled:opacity-disabled",
24
- "data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
25
- "data-[state=indeterminate]:border-primary data-[state=indeterminate]:bg-primary data-[state=indeterminate]:text-primary-foreground",
24
+ "data-[state=checked]:border-action data-[state=checked]:bg-action data-[state=checked]:text-action-foreground",
25
+ "data-[state=indeterminate]:border-action data-[state=indeterminate]:bg-action data-[state=indeterminate]:text-action-foreground",
26
26
  "aria-invalid:border-destructive aria-invalid:data-[state=checked]:border-destructive aria-invalid:data-[state=checked]:bg-destructive",
27
27
  className,
28
28
  )}
@@ -52,7 +52,7 @@ const CircularProgress = React.forwardRef<HTMLDivElement, CircularProgressProps>
52
52
  strokeLinecap="round"
53
53
  strokeDasharray={c}
54
54
  strokeDashoffset={c - (v / 100) * c}
55
- className="stroke-primary transition-[stroke-dashoffset] duration-base"
55
+ className="stroke-action transition-[stroke-dashoffset] duration-base"
56
56
  />
57
57
  </svg>
58
58
  {(showValue || label != null) && (
@@ -53,7 +53,7 @@ const CodeBlock = React.forwardRef<HTMLDivElement, CodeBlockProps>(
53
53
  onClick={() => setActive(i)}
54
54
  className={cn(
55
55
  "border-b-2 px-3 py-2 text-body-sm transition-colors outline-none",
56
- i === active ? "border-primary text-foreground" : "border-transparent text-muted-foreground hover:text-foreground",
56
+ i === active ? "border-action text-foreground" : "border-transparent text-muted-foreground hover:text-foreground",
57
57
  )}
58
58
  >
59
59
  {t.name || "code"}
@@ -197,6 +197,9 @@ const ColorPicker = React.forwardRef<HTMLDivElement, ColorPickerProps>(
197
197
  role="slider"
198
198
  tabIndex={0}
199
199
  aria-label="Saturation and value"
200
+ aria-valuemin={0}
201
+ aria-valuemax={100}
202
+ aria-valuenow={Math.round(s)}
200
203
  aria-valuetext={`saturation ${Math.round(s)}%, value ${Math.round(v)}%`}
201
204
  onPointerDown={handleSquarePointerDown}
202
205
  onPointerMove={handleSquarePointerMove}
@@ -333,6 +336,7 @@ function HexField({
333
336
 
334
337
  return (
335
338
  <Input
339
+ aria-label="Hex color"
336
340
  value={draft}
337
341
  onChange={(e) => setDraft(e.target.value.replace(/[^0-9a-fA-F]/g, "").slice(0, alpha ? 8 : 6))}
338
342
  onBlur={commit}
@@ -24,6 +24,14 @@ export interface DataGridColumn<TData> {
24
24
  onCellEdit?: (row: TData, rowIndex: number, value: string) => void;
25
25
  }
26
26
 
27
+ /** A rectangular range of cells, in display order (after sorting and column reordering). */
28
+ export interface DataGridSelection {
29
+ /** first and last selected row index, inclusive, into the *displayed* (sorted) rows */
30
+ rows: [number, number];
31
+ /** ids of the selected columns, in display order */
32
+ columns: string[];
33
+ }
34
+
27
35
  export interface DataGridProps<TData> {
28
36
  columns: DataGridColumn<TData>[];
29
37
  data: TData[];
@@ -33,6 +41,14 @@ export interface DataGridProps<TData> {
33
41
  overscan?: number;
34
42
  getRowId?: (row: TData, index: number) => React.Key;
35
43
  className?: string;
44
+ /**
45
+ * Opt in to range selection: Shift+arrows / Shift+click extend a rectangle from the anchor cell,
46
+ * Ctrl/Cmd+A selects everything, Ctrl/Cmd+C copies the range as tab-separated text (each
47
+ * column's `value()`; empty where a column has none), Esc clears. Sets aria-multiselectable.
48
+ */
49
+ selectable?: boolean;
50
+ /** fires when the selected range changes; `null` when it is cleared */
51
+ onSelectionChange?: (selection: DataGridSelection | null) => void;
36
52
  }
37
53
 
38
54
  type SortState = { columnId: string; direction: "asc" | "desc" } | null;
@@ -61,6 +77,8 @@ function DataGrid<TData>({
61
77
  overscan = 6,
62
78
  getRowId,
63
79
  className,
80
+ selectable = false,
81
+ onSelectionChange,
64
82
  }: DataGridProps<TData>) {
65
83
  const columnById = React.useMemo(() => new Map(columns.map((c) => [c.id, c])), [columns]);
66
84
  const [order, setOrder] = React.useState(() => columns.map((c) => c.id));
@@ -193,17 +211,239 @@ function DataGrid<TData>({
193
211
  endEdit(restoreFocus);
194
212
  }
195
213
 
214
+ // ── keyboard model (ARIA grid pattern) ─────────────────────────────────────────────────────
215
+ // The grid is ONE tab stop (roving tabindex). Arrows, Home/End (Ctrl = grid corners) and
216
+ // PageUp/PageDown move the active cell; the header row is row -1. Rows are virtualized, so a move
217
+ // first scrolls the target row into the rendered window and focus lands after the next render.
218
+ const HEADER_HEIGHT = 40; // h-10
219
+ type Pos = { row: number; columnId: string };
220
+ const [active, setActive] = React.useState<Pos>({ row: -1, columnId: columns[0]?.id ?? "" });
221
+ const pendingFocusRef = React.useRef<Pos | null>(null);
222
+ const [announcement, setAnnouncement] = React.useState("");
223
+
224
+ // ── range selection (opt-in) ──────────────────────────────────────────────────────────────
225
+ type Anchor = { row: number; columnId: string };
226
+ const [sel, setSel] = React.useState<{ anchor: Anchor; end: Anchor } | null>(null);
227
+ const onSelectionChangeRef = React.useRef(onSelectionChange);
228
+ onSelectionChangeRef.current = onSelectionChange;
229
+ const lastEmittedRef = React.useRef("null");
230
+
231
+ const rowCount = sortedData.length;
232
+ const colIds = renderedColumns.map((c) => c.id);
233
+ const activeCol = colIds.includes(active.columnId) ? active.columnId : (colIds[0] ?? "");
234
+ const activeRow = Math.min(active.row, rowCount - 1);
235
+ // if the active row has scrolled out of the rendered window, the header takes the tab stop so
236
+ // the grid stays reachable by Tab
237
+ const activeRendered = activeRow < 0 || (activeRow >= startIndex && activeRow < endIndex);
238
+ const tabStop: Pos = { row: activeRendered ? activeRow : -1, columnId: activeCol };
239
+ const colKey = colIds.join("|");
240
+ const range = React.useMemo(() => {
241
+ if (!selectable || !sel) return null;
242
+ const a = colIds.indexOf(sel.anchor.columnId);
243
+ const b = colIds.indexOf(sel.end.columnId);
244
+ if (a < 0 || b < 0) return null;
245
+ return { r0: Math.min(sel.anchor.row, sel.end.row), r1: Math.max(sel.anchor.row, sel.end.row), c0: Math.min(a, b), c1: Math.max(a, b) };
246
+ // eslint-disable-next-line react-hooks/exhaustive-deps
247
+ }, [selectable, sel, colKey]);
248
+
249
+ // Selection refers to displayed rows, so a re-sort or a different row count invalidates it.
250
+ React.useEffect(() => setSel(null), [sort, rowCount]);
251
+
252
+ React.useEffect(() => {
253
+ if (!selectable) return;
254
+ const value = range ? { rows: [range.r0, range.r1] as [number, number], columns: colIds.slice(range.c0, range.c1 + 1) } : null;
255
+ const key = JSON.stringify(value);
256
+ if (key === lastEmittedRef.current) return;
257
+ lastEmittedRef.current = key;
258
+ onSelectionChangeRef.current?.(value);
259
+ setAnnouncement(value ? `${(range!.r1 - range!.r0 + 1) * (range!.c1 - range!.c0 + 1)} cells selected` : "Selection cleared");
260
+ // eslint-disable-next-line react-hooks/exhaustive-deps
261
+ }, [selectable, range]);
262
+
263
+ function extendSelection(fromRow: number, fromCol: string, toRow: number, toCol: string) {
264
+ const anchor = sel?.anchor ?? { row: fromRow, columnId: fromCol };
265
+ const end = { row: Math.max(0, Math.min(toRow, rowCount - 1)), columnId: toCol };
266
+ setSel({ anchor, end });
267
+ moveTo(end.row, end.columnId);
268
+ }
269
+
270
+ function selectAll() {
271
+ if (rowCount === 0 || colIds.length === 0) return;
272
+ setSel({ anchor: { row: 0, columnId: colIds[0]! }, end: { row: rowCount - 1, columnId: colIds[colIds.length - 1]! } });
273
+ }
274
+
275
+ function copySelection() {
276
+ if (!range) return;
277
+ const ids = colIds.slice(range.c0, range.c1 + 1);
278
+ const text = sortedData
279
+ .slice(range.r0, range.r1 + 1)
280
+ .map((r) => ids.map((id) => { const c = columnById.get(id); return c?.value ? String(c.value(r)) : ""; }).join("\t"))
281
+ .join("\n");
282
+ try {
283
+ void navigator.clipboard?.writeText(text);
284
+ setAnnouncement(`Copied ${(range.r1 - range.r0 + 1) * ids.length} cells`);
285
+ } catch {
286
+ /* clipboard unavailable (insecure context / permission) — nothing to announce */
287
+ }
288
+ }
289
+
290
+ const pageRows = Math.max(1, Math.floor((viewportHeight - HEADER_HEIGHT) / rowHeight) - 1);
291
+ const labelOf = (c: DataGridColumn<TData>) => (typeof c.header === "string" ? c.header : c.id);
292
+
293
+ function ensureRowVisible(row: number) {
294
+ const el = containerRef.current;
295
+ if (!el) return;
296
+ if (row < 0) {
297
+ // the header is sticky and always visible, but moving to it means "back to the top"
298
+ if (scrollTop !== 0) {
299
+ el.scrollTop = 0;
300
+ setScrollTop(0);
301
+ }
302
+ return;
303
+ }
304
+ const top = HEADER_HEIGHT + row * rowHeight;
305
+ const bottom = top + rowHeight;
306
+ let next = scrollTop;
307
+ if (top < scrollTop + HEADER_HEIGHT) next = row * rowHeight; // under the sticky header
308
+ else if (bottom > scrollTop + viewportHeight) next = bottom - viewportHeight;
309
+ if (next !== scrollTop) {
310
+ el.scrollTop = next;
311
+ setScrollTop(next);
312
+ }
313
+ }
314
+
315
+ function moveTo(row: number, columnId: string) {
316
+ const r = Math.max(-1, Math.min(row, rowCount - 1));
317
+ ensureRowVisible(r);
318
+ pendingFocusRef.current = { row: r, columnId };
319
+ setActive({ row: r, columnId });
320
+ }
321
+
322
+ // Runs after every render until the pending target exists in the DOM (it may need a scroll first).
323
+ React.useEffect(() => {
324
+ const p = pendingFocusRef.current;
325
+ if (!p) return;
326
+ const selector = p.row < 0 ? `[data-header="${p.columnId}"]` : `[data-cell="${p.row}:${p.columnId}"]`;
327
+ const el = containerRef.current?.querySelector<HTMLElement>(selector);
328
+ if (el) {
329
+ pendingFocusRef.current = null;
330
+ el.focus();
331
+ }
332
+ });
333
+
334
+ function moveColumn(column: DataGridColumn<TData>, dir: 1 | -1) {
335
+ if (column.pinned) return;
336
+ const ids = unpinnedOrdered.map((c) => c.id);
337
+ const from = ids.indexOf(column.id);
338
+ const to = from + dir;
339
+ if (to < 0 || to >= ids.length) {
340
+ setAnnouncement(`${labelOf(column)} is already the ${dir < 0 ? "first" : "last"} movable column`);
341
+ return;
342
+ }
343
+ const targetId = ids[to]!;
344
+ setOrder((prev) => {
345
+ const next = prev.filter((id) => id !== column.id);
346
+ next.splice(next.indexOf(targetId) + (dir > 0 ? 1 : 0), 0, column.id);
347
+ return next;
348
+ });
349
+ pendingFocusRef.current = { row: -1, columnId: column.id }; // a reordered node loses focus; give it back
350
+ setAnnouncement(`${labelOf(column)} moved to position ${leftPinned.length + to + 1} of ${colIds.length}`);
351
+ }
352
+
353
+ function resizeColumn(column: DataGridColumn<TData>, delta: number) {
354
+ const current = widths[column.id] ?? column.width ?? DEFAULT_WIDTH;
355
+ const next = Math.max(column.minWidth ?? DEFAULT_MIN_WIDTH, current + delta);
356
+ setWidths((prev) => ({ ...prev, [column.id]: next }));
357
+ setAnnouncement(`${labelOf(column)} column width ${next} pixels`);
358
+ }
359
+
360
+ function onNavKeyDown(e: React.KeyboardEvent<HTMLElement>, row: number, column: DataGridColumn<TData>) {
361
+ if (e.target !== e.currentTarget) return; // keys inside an editing input are the input's own
362
+ const rtl = getComputedStyle(e.currentTarget).direction === "rtl";
363
+ const idx = colIds.indexOf(column.id);
364
+ const horizontal = e.key === "ArrowRight" ? (rtl ? -1 : 1) : e.key === "ArrowLeft" ? (rtl ? 1 : -1) : 0;
365
+
366
+ // header chords: Alt+Left/Right reorders the column, Shift+Left/Right resizes it (10px)
367
+ if (row < 0 && horizontal !== 0 && (e.altKey || e.shiftKey)) {
368
+ e.preventDefault();
369
+ if (e.altKey) moveColumn(column, horizontal as 1 | -1);
370
+ else resizeColumn(column, horizontal * 10);
371
+ return;
372
+ }
373
+
374
+ const mod = e.ctrlKey || e.metaKey;
375
+ if (selectable && row >= 0) {
376
+ if (mod && e.key.toLowerCase() === "a") {
377
+ e.preventDefault();
378
+ return selectAll();
379
+ }
380
+ if (mod && e.key.toLowerCase() === "c" && range) {
381
+ e.preventDefault();
382
+ return copySelection();
383
+ }
384
+ if (e.key === "Escape" && sel) {
385
+ e.preventDefault();
386
+ return setSel(null);
387
+ }
388
+ }
389
+ const extend = selectable && e.shiftKey && row >= 0;
390
+ const go = (r: number, i: number) => {
391
+ e.preventDefault();
392
+ const target = colIds[Math.max(0, Math.min(i, colIds.length - 1))]!;
393
+ if (extend) return extendSelection(row, column.id, r, target);
394
+ if (sel) setSel(null); // a plain move collapses the range
395
+ moveTo(r, target);
396
+ };
397
+ switch (e.key) {
398
+ case "ArrowRight":
399
+ case "ArrowLeft":
400
+ return go(row, idx + horizontal);
401
+ case "ArrowDown":
402
+ return go(row + 1, idx);
403
+ case "ArrowUp":
404
+ return go(row - 1, idx);
405
+ case "Home":
406
+ return go(e.ctrlKey ? -1 : row, 0);
407
+ case "End":
408
+ return go(e.ctrlKey ? rowCount - 1 : row, colIds.length - 1);
409
+ case "PageDown":
410
+ return go(row + pageRows, idx);
411
+ case "PageUp":
412
+ return go(Math.max(row - pageRows, row >= 0 ? 0 : -1), idx);
413
+ case "Enter":
414
+ case " ":
415
+ if (row < 0 && column.sortable) {
416
+ e.preventDefault();
417
+ toggleSort(column.id);
418
+ } else if (row >= 0 && column.editable && e.key === "Enter") {
419
+ e.preventDefault();
420
+ setEditing({ rowIndex: row, columnId: column.id });
421
+ }
422
+ return;
423
+ case "F2":
424
+ if (row >= 0 && column.editable) {
425
+ e.preventDefault();
426
+ setEditing({ rowIndex: row, columnId: column.id });
427
+ }
428
+ return;
429
+ }
430
+ }
431
+
196
432
  return (
433
+ <>
197
434
  <div
198
435
  ref={containerRef}
199
436
  role="grid"
437
+ aria-rowcount={rowCount + 1}
438
+ aria-colcount={colIds.length}
439
+ aria-multiselectable={selectable || undefined}
200
440
  className={cn("relative overflow-auto rounded-md border font-sans text-sm", className)}
201
441
  style={{ height }}
202
442
  onScroll={(e) => setScrollTop(e.currentTarget.scrollTop)}
203
443
  >
204
444
  <div style={{ width: totalWidth, minWidth: "100%" }}>
205
- <div role="row" className="sticky top-0 z-[2] flex border-b bg-background">
206
- {renderedColumns.map((column) => {
445
+ <div role="row" aria-rowindex={1} className="sticky top-0 z-[2] flex border-b bg-background">
446
+ {renderedColumns.map((column, colIdx) => {
207
447
  const width = widths[column.id] ?? column.width ?? DEFAULT_WIDTH;
208
448
  const pinnedStyle: React.CSSProperties =
209
449
  column.pinned === "left"
@@ -216,6 +456,9 @@ function DataGrid<TData>({
216
456
  <div
217
457
  key={column.id}
218
458
  role="columnheader"
459
+ data-header={column.id}
460
+ aria-colindex={colIdx + 1}
461
+ aria-keyshortcuts={`Shift+ArrowLeft Shift+ArrowRight${column.pinned ? "" : " Alt+ArrowLeft Alt+ArrowRight"}`}
219
462
  {...(column.sortable
220
463
  ? { "aria-sort": isSorted ? (sort!.direction === "asc" ? "ascending" : "descending") : "none" }
221
464
  : {})}
@@ -239,18 +482,13 @@ function DataGrid<TData>({
239
482
  });
240
483
  }}
241
484
  onClick={() => column.sortable && toggleSort(column.id)}
242
- // a sortable header is a control: reachable with Tab, operated with Enter / Space
243
- tabIndex={column.sortable ? 0 : undefined}
244
- onKeyDown={(e) => {
245
- if (column.sortable && e.target === e.currentTarget && (e.key === "Enter" || e.key === " ")) {
246
- e.preventDefault();
247
- toggleSort(column.id);
248
- }
249
- }}
485
+ // roving tabindex: the grid is one tab stop; arrows move between cells (see onNavKeyDown)
486
+ tabIndex={tabStop.row === -1 && tabStop.columnId === column.id ? 0 : -1}
487
+ onFocus={() => setActive({ row: -1, columnId: column.id })}
488
+ onKeyDown={(e) => onNavKeyDown(e, -1, column)}
250
489
  className={cn(
251
- "relative flex h-10 shrink-0 select-none items-center gap-1 px-2 font-medium text-muted-foreground",
252
- column.sortable &&
253
- "cursor-pointer outline-none hover:text-foreground focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring",
490
+ "relative flex h-10 shrink-0 select-none items-center gap-1 px-2 font-medium text-muted-foreground outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring",
491
+ column.sortable && "cursor-pointer hover:text-foreground",
254
492
  column.pinned && "bg-background",
255
493
  )}
256
494
  style={{ width, ...pinnedStyle }}
@@ -266,7 +504,7 @@ function DataGrid<TData>({
266
504
  e.stopPropagation();
267
505
  beginResize(column.id, column.minWidth ?? DEFAULT_MIN_WIDTH, e.clientX, width);
268
506
  }}
269
- className="absolute right-0 top-0 h-full w-1 touch-none cursor-col-resize hover:bg-primary/50"
507
+ className="absolute right-0 top-0 h-full w-1 touch-none cursor-col-resize hover:bg-action/50"
270
508
  />
271
509
  </div>
272
510
  );
@@ -279,10 +517,11 @@ function DataGrid<TData>({
279
517
  <div
280
518
  key={getRowId ? getRowId(row, rowIndex) : rowIndex}
281
519
  role="row"
520
+ aria-rowindex={rowIndex + 2}
282
521
  className="absolute flex w-full border-b hover:bg-muted/50"
283
522
  style={{ top: rowIndex * rowHeight, height: rowHeight }}
284
523
  >
285
- {renderedColumns.map((column) => {
524
+ {renderedColumns.map((column, colIdx) => {
286
525
  const width = widths[column.id] ?? column.width ?? DEFAULT_WIDTH;
287
526
  const pinnedStyle: React.CSSProperties =
288
527
  column.pinned === "left"
@@ -291,24 +530,32 @@ function DataGrid<TData>({
291
530
  ? { position: "sticky", right: rightOffsets[column.id], zIndex: 1 }
292
531
  : {};
293
532
  const isEditing = editing?.rowIndex === rowIndex && editing.columnId === column.id;
533
+ const selected = selectable && !!range && rowIndex >= range.r0 && rowIndex <= range.r1 && colIdx >= range.c0 && colIdx <= range.c1;
294
534
  return (
295
535
  <div
296
536
  key={column.id}
297
537
  role="gridcell"
538
+ aria-colindex={colIdx + 1}
539
+ aria-selected={selectable ? selected : undefined}
298
540
  data-cell={`${rowIndex}:${column.id}`}
299
- onDoubleClick={() => column.editable && setEditing({ rowIndex, columnId: column.id })}
300
- // an editable cell is reachable with Tab and entered with Enter or F2 (the spreadsheet convention)
301
- tabIndex={column.editable ? 0 : undefined}
302
- onKeyDown={(e) => {
303
- if (column.editable && e.target === e.currentTarget && (e.key === "Enter" || e.key === "F2")) {
541
+ onMouseDown={(e) => {
542
+ if (!selectable || e.button !== 0) return;
543
+ if (e.shiftKey) {
544
+ // extend from the previously active cell (focus has not moved yet on mousedown) — even when it has
545
+ // since scrolled out of the rendered window
304
546
  e.preventDefault();
305
- setEditing({ rowIndex, columnId: column.id });
306
- }
547
+ extendSelection(activeRow >= 0 ? activeRow : rowIndex, activeCol, rowIndex, column.id);
548
+ } else if (sel) setSel(null);
307
549
  }}
550
+ onDoubleClick={() => column.editable && setEditing({ rowIndex, columnId: column.id })}
551
+ // roving tabindex; Enter or F2 edits an editable cell (the spreadsheet convention)
552
+ tabIndex={tabStop.row === rowIndex && tabStop.columnId === column.id ? 0 : -1}
553
+ onFocus={() => setActive({ row: rowIndex, columnId: column.id })}
554
+ onKeyDown={(e) => onNavKeyDown(e, rowIndex, column)}
308
555
  className={cn(
309
- "flex shrink-0 items-center px-2",
310
- column.editable && "outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring",
556
+ "flex shrink-0 items-center px-2 outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring",
311
557
  column.pinned && "bg-background",
558
+ selected && "bg-accent",
312
559
  )}
313
560
  style={{ width, ...pinnedStyle }}
314
561
  >
@@ -335,6 +582,11 @@ function DataGrid<TData>({
335
582
  </div>
336
583
  </div>
337
584
  </div>
585
+ {/* reorder / resize results, spoken politely; outside the grid because a grid's children must be rows */}
586
+ <div role="status" aria-live="polite" className="sr-only">
587
+ {announcement}
588
+ </div>
589
+ </>
338
590
  );
339
591
  }
340
592
 
@@ -103,13 +103,14 @@ function UnifiedDiff({ ops }: { ops: DiffOp[] }) {
103
103
  role="row"
104
104
  className={cn("flex", op.type === "add" && "bg-success/10", op.type === "remove" && "bg-destructive/10")}
105
105
  >
106
- <span className="w-9 shrink-0 select-none border-r px-1.5 text-right text-muted-foreground">
106
+ <span role="cell" className="w-9 shrink-0 select-none border-r px-1.5 text-right text-muted-foreground">
107
107
  {op.type !== "add" ? op.oldLine : ""}
108
108
  </span>
109
- <span className="w-9 shrink-0 select-none border-r px-1.5 text-right text-muted-foreground">
109
+ <span role="cell" className="w-9 shrink-0 select-none border-r px-1.5 text-right text-muted-foreground">
110
110
  {op.type !== "remove" ? op.newLine : ""}
111
111
  </span>
112
112
  <span
113
+ role="cell"
113
114
  className={cn(
114
115
  "w-4 shrink-0 select-none text-center",
115
116
  op.type === "add" && "text-success",
@@ -118,7 +119,7 @@ function UnifiedDiff({ ops }: { ops: DiffOp[] }) {
118
119
  >
119
120
  {op.type === "add" ? "+" : op.type === "remove" ? "−" : ""}
120
121
  </span>
121
- <span className="min-w-0 flex-1 whitespace-pre px-1">{op.text}</span>
122
+ <span role="cell" className="min-w-0 flex-1 whitespace-pre px-1">{op.text}</span>
122
123
  </div>
123
124
  ))}
124
125
  </div>
@@ -128,19 +129,19 @@ function UnifiedDiff({ ops }: { ops: DiffOp[] }) {
128
129
  function SplitDiff({ ops, oldLabel, newLabel }: { ops: DiffOp[]; oldLabel: string; newLabel: string }) {
129
130
  return (
130
131
  <div role="table">
131
- <div className="flex border-b bg-muted/40 text-muted-foreground">
132
- <div className="flex-1 px-2 py-1">{oldLabel}</div>
133
- <div className="flex-1 border-l px-2 py-1">{newLabel}</div>
132
+ <div role="row" className="flex border-b bg-muted/40 text-muted-foreground">
133
+ <div role="columnheader" className="flex-1 px-2 py-1">{oldLabel}</div>
134
+ <div role="columnheader" className="flex-1 border-l px-2 py-1">{newLabel}</div>
134
135
  </div>
135
136
  {ops.map((op, i) => (
136
137
  <div key={i} role="row" className="flex">
137
- <div className={cn("flex flex-1 items-start", op.type === "remove" && "bg-destructive/10")}>
138
+ <div role="cell" className={cn("flex flex-1 items-start", op.type === "remove" && "bg-destructive/10")}>
138
139
  <span className="w-9 shrink-0 select-none px-1.5 text-right text-muted-foreground">
139
140
  {op.type !== "add" ? op.oldLine : ""}
140
141
  </span>
141
142
  <span className="min-w-0 flex-1 whitespace-pre px-1">{op.type !== "add" ? op.text : ""}</span>
142
143
  </div>
143
- <div className={cn("flex flex-1 items-start border-l", op.type === "add" && "bg-success/10")}>
144
+ <div role="cell" className={cn("flex flex-1 items-start border-l", op.type === "add" && "bg-success/10")}>
144
145
  <span className="w-9 shrink-0 select-none px-1.5 text-right text-muted-foreground">
145
146
  {op.type !== "remove" ? op.newLine : ""}
146
147
  </span>
@@ -19,7 +19,7 @@ const fabVariants = cva(
19
19
  {
20
20
  variants: {
21
21
  variant: {
22
- Primary: "bg-primary text-primary-foreground hover:bg-[--color-blue-600] active:bg-[--color-blue-700]",
22
+ Primary: "bg-action text-action-foreground hover:bg-action/90 active:bg-action/85",
23
23
  Secondary:
24
24
  "bg-secondary text-secondary-foreground hover:bg-[--color-green-500] hover:text-[--color-green-50] active:bg-[--color-green-600] active:text-[--color-green-50]",
25
25
  },