@kinetixui/ui 0.16.1 → 0.18.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 (67) hide show
  1. package/dist/index.d.ts +4 -4
  2. package/dist/index.js +498 -248
  3. package/package.json +3 -2
  4. package/src/components/audio-player.tsx +2 -2
  5. package/src/components/badge.tsx +2 -2
  6. package/src/components/banner.tsx +2 -2
  7. package/src/components/button.tsx +18 -14
  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 +6 -4
  14. package/src/components/data-grid.tsx +192 -11
  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/inform.tsx +1 -1
  19. package/src/components/input-group.tsx +1 -1
  20. package/src/components/input-otp.tsx +2 -1
  21. package/src/components/input.tsx +3 -3
  22. package/src/components/json-viewer.tsx +12 -10
  23. package/src/components/kbd.tsx +1 -1
  24. package/src/components/list.tsx +41 -27
  25. package/src/components/markdown-editor.tsx +3 -2
  26. package/src/components/message-bubble.tsx +3 -3
  27. package/src/components/modal.tsx +2 -2
  28. package/src/components/multi-select.tsx +28 -5
  29. package/src/components/native-select.tsx +3 -3
  30. package/src/components/notification-center.tsx +2 -2
  31. package/src/components/progress.tsx +1 -1
  32. package/src/components/radio-group.tsx +3 -3
  33. package/src/components/scroll-area.tsx +1 -1
  34. package/src/components/select.tsx +3 -3
  35. package/src/components/skeleton.tsx +1 -1
  36. package/src/components/slider.tsx +8 -3
  37. package/src/components/sonner.tsx +1 -1
  38. package/src/components/spinner.tsx +3 -3
  39. package/src/components/stepper.tsx +3 -3
  40. package/src/components/switch.tsx +1 -1
  41. package/src/components/tab-bar.tsx +1 -1
  42. package/src/components/table-of-contents.tsx +1 -1
  43. package/src/components/tag.tsx +1 -1
  44. package/src/components/textarea.tsx +3 -3
  45. package/src/components/timeline.tsx +1 -1
  46. package/src/components/tooltip.tsx +1 -1
  47. package/src/components/tour.tsx +45 -2
  48. package/src/components/virtual-list.tsx +2 -1
  49. package/src/components-a11y.test.tsx +69 -0
  50. package/src/components-behavior.test.tsx +1 -1
  51. package/src/components-keyboard.test.tsx +774 -0
  52. package/src/lib/utils.test.ts +12 -0
  53. package/src/lib/utils.ts +22 -1
  54. package/src/stories/Checkbox.stories.tsx +1 -1
  55. package/src/stories/CircularProgress.stories.tsx +4 -4
  56. package/src/stories/InputOTP.stories.tsx +2 -2
  57. package/src/stories/List.stories.tsx +2 -2
  58. package/src/stories/MarkdownEditor.stories.tsx +1 -1
  59. package/src/stories/MultiSelect.stories.tsx +1 -0
  60. package/src/stories/NativeSelect.stories.tsx +1 -1
  61. package/src/stories/NumberInput.stories.tsx +3 -3
  62. package/src/stories/Progress.stories.tsx +3 -3
  63. package/src/stories/Select.stories.tsx +2 -2
  64. package/src/stories/Slider.stories.tsx +3 -3
  65. package/src/stories/Switch.stories.tsx +1 -1
  66. package/src/stories/ToggleGroup.stories.tsx +6 -6
  67. package/tailwind.config.ts +20 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kinetixui/ui",
3
- "version": "0.16.1",
3
+ "version": "0.18.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.16.1",
47
+ "@kinetixui/tokens": "0.18.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",
@@ -101,6 +101,7 @@
101
101
  "@types/react": "^19.3.0",
102
102
  "@types/react-dom": "^19.3.0",
103
103
  "@vitejs/plugin-react": "^6.1.1",
104
+ "axe-core": "4.13.0",
104
105
  "jsdom": "^30.0.1",
105
106
  "react": "^19.3.0",
106
107
  "react-dom": "^19.3.0",
@@ -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
  );
@@ -15,11 +15,11 @@ import { cn } from "../lib/utils";
15
15
  * container. Hence the swapped bg/text on that one variant.
16
16
  */
17
17
  const badgeVariants = cva(
18
- "inline-flex items-center rounded-full px-2.5 py-1 text-xs font-medium font-sans tracking-[0.5px] transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
18
+ "inline-flex items-center rounded-full px-2.5 py-1 text-label-md font-sans transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
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",
@@ -20,7 +20,7 @@ const bannerVariants = cva(
20
20
  {
21
21
  variants: {
22
22
  variant: {
23
- information: "border-info/20 bg-info/10 text-info",
23
+ information: "border-info/20 bg-info/10 text-info-on-container",
24
24
  warning: "border-warning/25 bg-warning/15 text-warning",
25
25
  success: "border-success/25 bg-success/15 text-success",
26
26
  error: "border-destructive/20 bg-destructive/10 text-destructive",
@@ -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>
@@ -36,33 +36,37 @@ const buttonVariants = cva(
36
36
  variant: {
37
37
  // bg primary / text on-primary; hover+active step down the blue ramp; disabled -> border grey
38
38
  // rest = --primary; hover/active dim it via opacity so it works in BOTH themes.
39
+ // Alphas are bounded by contrast, not taste: text on the dimmed fill must stay >= 4.5:1
40
+ // (light: 90% = 5.05, 85% = 4.54, 80% = 4.11 fails) — enforced by check:contrast.
39
41
  // (the old hover:bg-[--color-blue-600] was a hard-coded dark navy — on dark mode,
40
42
  // where --primary is light, it inverted the button into the near-black background.)
41
43
  Primary:
42
- "bg-primary text-primary-foreground hover:bg-primary/90 active:bg-primary/80 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",
43
45
  // rest = --secondary; hover/active promote to the --secondary-foreground fill with
44
- // inverted text — theme-aware, unlike the previous hard-coded green primitives
46
+ // inverted text — theme-aware, unlike the previous hard-coded green primitives.
47
+ // Active is solid like hover: dimming it (90% was 3.97:1) drops the text under AA,
48
+ // and anything above ~97% is imperceptible. Enforced by check:contrast.
45
49
  Secondary:
46
- "bg-secondary text-secondary-foreground hover:bg-secondary-foreground hover:text-secondary active:bg-secondary-foreground/90 active:text-secondary focus-visible:bg-secondary disabled:bg-border disabled:text-muted-foreground",
50
+ "bg-secondary text-secondary-foreground hover:bg-secondary-foreground hover:text-secondary active:bg-secondary-foreground active:text-secondary focus-visible:bg-secondary disabled:bg-border disabled:text-muted-foreground",
47
51
  // 1px outline; hover fills with accent + strengthens the edge to --ring so the
48
52
  // state stays visible on the darkest surfaces (WCAG 2.2 SC 1.4.11); active pins primary
49
53
  Outline:
50
- "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",
51
55
  Destructive:
52
56
  "bg-destructive text-destructive-foreground hover:brightness-95 active:brightness-90 disabled:bg-border disabled:text-muted-foreground",
53
57
  // transparent rest; hover adds an inset --ring outline (the accent fill alone is
54
58
  // <3:1 against the page on dark) plus the accent fill; active pins primary text
55
59
  Ghost:
56
- "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",
57
- 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",
58
62
  },
59
63
  size: {
60
64
  // padding = Figma spacing/3 + spacing/2 ; type = Label Small (11/16, +0.5 tracking)
61
- sm: "px-3 py-2 text-[11px] leading-4 tracking-[0.5px]",
65
+ sm: "px-3 py-2 text-label-sm",
62
66
  // spacing/4 + spacing/3 ; Label Medium (12/16, +0.5)
63
- md: "px-4 py-3 text-[12px] leading-4 tracking-[0.5px]",
67
+ md: "px-4 py-3 text-label-md",
64
68
  // spacing/6 + spacing/3 ; Label Large (14/20, +0.1)
65
- lg: "px-6 py-3 text-[14px] leading-5 tracking-[0.1px]",
69
+ lg: "px-6 py-3 text-label-lg",
66
70
  // spacing/3 all round, square
67
71
  icon: "p-3 [&>*:not(svg)]:sr-only",
68
72
  },
@@ -83,14 +87,14 @@ const buttonVariants = cva(
83
87
  },
84
88
  compoundVariants: [
85
89
  { variant: "Destructive", class: "focus-visible:shadow-focus-destructive" },
86
- { variant: "Primary", state: "Hover", class: "bg-primary/90" },
87
- { variant: "Primary", state: "Active", class: "bg-primary/80" },
90
+ { variant: "Primary", state: "Hover", class: "bg-action/90" },
91
+ { variant: "Primary", state: "Active", class: "bg-action/85" },
88
92
  { variant: "Secondary", state: "Hover", class: "bg-secondary-foreground text-secondary" },
89
- { variant: "Secondary", state: "Active", class: "bg-secondary-foreground/90 text-secondary" },
93
+ { variant: "Secondary", state: "Active", class: "bg-secondary-foreground text-secondary" },
90
94
  { variant: "Outline", state: "Hover", class: "border-ring bg-accent" },
91
- { variant: "Outline", state: "Active", class: "border-primary bg-accent text-primary" },
95
+ { variant: "Outline", state: "Active", class: "border-action bg-accent text-action" },
92
96
  { variant: "Ghost", state: "Hover", class: "bg-accent ring-1 ring-inset ring-ring" },
93
- { variant: "Ghost", state: "Active", class: "bg-accent text-primary" },
97
+ { variant: "Ghost", state: "Active", class: "bg-accent text-action" },
94
98
  { variant: "Link", state: "Hover", class: "underline text-foreground" },
95
99
  { variant: "Link", size: ["sm", "md", "lg"], class: "py-2 px-0 h-auto" },
96
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}
@@ -220,7 +223,6 @@ const ColorPicker = React.forwardRef<HTMLDivElement, ColorPickerProps>(
220
223
  max={360}
221
224
  step={1}
222
225
  onValueChange={([nh]) => commit(nh ?? h, s, v, a)}
223
- aria-label="Hue"
224
226
  className="relative flex h-4 w-full touch-none select-none items-center"
225
227
  >
226
228
  <SliderPrimitive.Track
@@ -230,7 +232,7 @@ const ColorPicker = React.forwardRef<HTMLDivElement, ColorPickerProps>(
230
232
  "linear-gradient(to right, hsl(0 100% 50%), hsl(60 100% 50%), hsl(120 100% 50%), hsl(180 100% 50%), hsl(240 100% 50%), hsl(300 100% 50%), hsl(360 100% 50%))",
231
233
  }}
232
234
  />
233
- <SliderPrimitive.Thumb className="block size-4 rounded-full border-2 border-white bg-background shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" />
235
+ <SliderPrimitive.Thumb aria-label="Hue" className="block size-4 rounded-full border-2 border-white bg-background shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" />
234
236
  </SliderPrimitive.Root>
235
237
 
236
238
  {alpha && (
@@ -240,7 +242,6 @@ const ColorPicker = React.forwardRef<HTMLDivElement, ColorPickerProps>(
240
242
  max={100}
241
243
  step={1}
242
244
  onValueChange={([na]) => commit(h, s, v, (na ?? a * 100) / 100)}
243
- aria-label="Alpha"
244
245
  className="relative flex h-4 w-full touch-none select-none items-center"
245
246
  >
246
247
  <SliderPrimitive.Track
@@ -256,7 +257,7 @@ const ColorPicker = React.forwardRef<HTMLDivElement, ColorPickerProps>(
256
257
  style={{ backgroundImage: `linear-gradient(to right, transparent, hsl(${h} ${s}% ${v}%))` }}
257
258
  />
258
259
  </SliderPrimitive.Track>
259
- <SliderPrimitive.Thumb className="block size-4 rounded-full border-2 border-white bg-background shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" />
260
+ <SliderPrimitive.Thumb aria-label="Alpha" className="block size-4 rounded-full border-2 border-white bg-background shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" />
260
261
  </SliderPrimitive.Root>
261
262
  )}
262
263
 
@@ -335,6 +336,7 @@ function HexField({
335
336
 
336
337
  return (
337
338
  <Input
339
+ aria-label="Hex color"
338
340
  value={draft}
339
341
  onChange={(e) => setDraft(e.target.value.replace(/[^0-9a-fA-F]/g, "").slice(0, alpha ? 8 : 6))}
340
342
  onBlur={commit}
@@ -19,7 +19,7 @@ export interface DataGridColumn<TData> {
19
19
  /** fixed to this side, not draggable, and excluded from the reorder sequence */
20
20
  pinned?: "left" | "right";
21
21
  sortable?: boolean;
22
- /** double-click swaps the cell for a text input; commit fires on Enter/blur, Escape cancels */
22
+ /** double-click — or Enter / F2 on the focused cell — swaps it for a text input; commit fires on Enter/blur, Escape cancels */
23
23
  editable?: boolean;
24
24
  onCellEdit?: (row: TData, rowIndex: number, value: string) => void;
25
25
  }
@@ -173,22 +173,181 @@ function DataGrid<TData>({
173
173
  window.addEventListener("pointerup", handleUp);
174
174
  }
175
175
 
176
- function commitEdit(column: DataGridColumn<TData>, row: TData, rowIndex: number, value: string) {
177
- column.onCellEdit?.(row, rowIndex, value);
176
+ // After a keyboard commit/cancel, hand focus back to the cell that was being edited (a blur
177
+ // commit — the user clicked or tabbed elsewhere — must not pull focus back).
178
+ const restoreFocusRef = React.useRef<{ rowIndex: number; columnId: string } | null>(null);
179
+ React.useEffect(() => {
180
+ const target = restoreFocusRef.current;
181
+ if (editing || !target) return;
182
+ restoreFocusRef.current = null;
183
+ containerRef.current?.querySelector<HTMLElement>(`[data-cell="${target.rowIndex}:${target.columnId}"]`)?.focus();
184
+ }, [editing]);
185
+
186
+ function endEdit(restoreFocus: boolean) {
187
+ if (restoreFocus && editing) restoreFocusRef.current = { rowIndex: editing.rowIndex, columnId: editing.columnId };
178
188
  setEditing(null);
179
189
  }
180
190
 
191
+ function commitEdit(column: DataGridColumn<TData>, row: TData, rowIndex: number, value: string, restoreFocus = false) {
192
+ column.onCellEdit?.(row, rowIndex, value);
193
+ endEdit(restoreFocus);
194
+ }
195
+
196
+ // ── keyboard model (ARIA grid pattern) ─────────────────────────────────────────────────────
197
+ // The grid is ONE tab stop (roving tabindex). Arrows, Home/End (Ctrl = grid corners) and
198
+ // PageUp/PageDown move the active cell; the header row is row -1. Rows are virtualized, so a move
199
+ // first scrolls the target row into the rendered window and focus lands after the next render.
200
+ const HEADER_HEIGHT = 40; // h-10
201
+ type Pos = { row: number; columnId: string };
202
+ const [active, setActive] = React.useState<Pos>({ row: -1, columnId: columns[0]?.id ?? "" });
203
+ const pendingFocusRef = React.useRef<Pos | null>(null);
204
+ const [announcement, setAnnouncement] = React.useState("");
205
+
206
+ const rowCount = sortedData.length;
207
+ const colIds = renderedColumns.map((c) => c.id);
208
+ const activeCol = colIds.includes(active.columnId) ? active.columnId : (colIds[0] ?? "");
209
+ const activeRow = Math.min(active.row, rowCount - 1);
210
+ // if the active row has scrolled out of the rendered window, the header takes the tab stop so
211
+ // the grid stays reachable by Tab
212
+ const activeRendered = activeRow < 0 || (activeRow >= startIndex && activeRow < endIndex);
213
+ const tabStop: Pos = { row: activeRendered ? activeRow : -1, columnId: activeCol };
214
+ const pageRows = Math.max(1, Math.floor((viewportHeight - HEADER_HEIGHT) / rowHeight) - 1);
215
+ const labelOf = (c: DataGridColumn<TData>) => (typeof c.header === "string" ? c.header : c.id);
216
+
217
+ function ensureRowVisible(row: number) {
218
+ const el = containerRef.current;
219
+ if (!el) return;
220
+ if (row < 0) {
221
+ // the header is sticky and always visible, but moving to it means "back to the top"
222
+ if (scrollTop !== 0) {
223
+ el.scrollTop = 0;
224
+ setScrollTop(0);
225
+ }
226
+ return;
227
+ }
228
+ const top = HEADER_HEIGHT + row * rowHeight;
229
+ const bottom = top + rowHeight;
230
+ let next = scrollTop;
231
+ if (top < scrollTop + HEADER_HEIGHT) next = row * rowHeight; // under the sticky header
232
+ else if (bottom > scrollTop + viewportHeight) next = bottom - viewportHeight;
233
+ if (next !== scrollTop) {
234
+ el.scrollTop = next;
235
+ setScrollTop(next);
236
+ }
237
+ }
238
+
239
+ function moveTo(row: number, columnId: string) {
240
+ const r = Math.max(-1, Math.min(row, rowCount - 1));
241
+ ensureRowVisible(r);
242
+ pendingFocusRef.current = { row: r, columnId };
243
+ setActive({ row: r, columnId });
244
+ }
245
+
246
+ // Runs after every render until the pending target exists in the DOM (it may need a scroll first).
247
+ React.useEffect(() => {
248
+ const p = pendingFocusRef.current;
249
+ if (!p) return;
250
+ const selector = p.row < 0 ? `[data-header="${p.columnId}"]` : `[data-cell="${p.row}:${p.columnId}"]`;
251
+ const el = containerRef.current?.querySelector<HTMLElement>(selector);
252
+ if (el) {
253
+ pendingFocusRef.current = null;
254
+ el.focus();
255
+ }
256
+ });
257
+
258
+ function moveColumn(column: DataGridColumn<TData>, dir: 1 | -1) {
259
+ if (column.pinned) return;
260
+ const ids = unpinnedOrdered.map((c) => c.id);
261
+ const from = ids.indexOf(column.id);
262
+ const to = from + dir;
263
+ if (to < 0 || to >= ids.length) {
264
+ setAnnouncement(`${labelOf(column)} is already the ${dir < 0 ? "first" : "last"} movable column`);
265
+ return;
266
+ }
267
+ const targetId = ids[to]!;
268
+ setOrder((prev) => {
269
+ const next = prev.filter((id) => id !== column.id);
270
+ next.splice(next.indexOf(targetId) + (dir > 0 ? 1 : 0), 0, column.id);
271
+ return next;
272
+ });
273
+ pendingFocusRef.current = { row: -1, columnId: column.id }; // a reordered node loses focus; give it back
274
+ setAnnouncement(`${labelOf(column)} moved to position ${leftPinned.length + to + 1} of ${colIds.length}`);
275
+ }
276
+
277
+ function resizeColumn(column: DataGridColumn<TData>, delta: number) {
278
+ const current = widths[column.id] ?? column.width ?? DEFAULT_WIDTH;
279
+ const next = Math.max(column.minWidth ?? DEFAULT_MIN_WIDTH, current + delta);
280
+ setWidths((prev) => ({ ...prev, [column.id]: next }));
281
+ setAnnouncement(`${labelOf(column)} column width ${next} pixels`);
282
+ }
283
+
284
+ function onNavKeyDown(e: React.KeyboardEvent<HTMLElement>, row: number, column: DataGridColumn<TData>) {
285
+ if (e.target !== e.currentTarget) return; // keys inside an editing input are the input's own
286
+ const rtl = getComputedStyle(e.currentTarget).direction === "rtl";
287
+ const idx = colIds.indexOf(column.id);
288
+ const horizontal = e.key === "ArrowRight" ? (rtl ? -1 : 1) : e.key === "ArrowLeft" ? (rtl ? 1 : -1) : 0;
289
+
290
+ // header chords: Alt+Left/Right reorders the column, Shift+Left/Right resizes it (10px)
291
+ if (row < 0 && horizontal !== 0 && (e.altKey || e.shiftKey)) {
292
+ e.preventDefault();
293
+ if (e.altKey) moveColumn(column, horizontal as 1 | -1);
294
+ else resizeColumn(column, horizontal * 10);
295
+ return;
296
+ }
297
+
298
+ const go = (r: number, i: number) => {
299
+ e.preventDefault();
300
+ moveTo(r, colIds[Math.max(0, Math.min(i, colIds.length - 1))]!);
301
+ };
302
+ switch (e.key) {
303
+ case "ArrowRight":
304
+ case "ArrowLeft":
305
+ return go(row, idx + horizontal);
306
+ case "ArrowDown":
307
+ return go(row + 1, idx);
308
+ case "ArrowUp":
309
+ return go(row - 1, idx);
310
+ case "Home":
311
+ return go(e.ctrlKey ? -1 : row, 0);
312
+ case "End":
313
+ return go(e.ctrlKey ? rowCount - 1 : row, colIds.length - 1);
314
+ case "PageDown":
315
+ return go(row + pageRows, idx);
316
+ case "PageUp":
317
+ return go(Math.max(row - pageRows, row >= 0 ? 0 : -1), idx);
318
+ case "Enter":
319
+ case " ":
320
+ if (row < 0 && column.sortable) {
321
+ e.preventDefault();
322
+ toggleSort(column.id);
323
+ } else if (row >= 0 && column.editable && e.key === "Enter") {
324
+ e.preventDefault();
325
+ setEditing({ rowIndex: row, columnId: column.id });
326
+ }
327
+ return;
328
+ case "F2":
329
+ if (row >= 0 && column.editable) {
330
+ e.preventDefault();
331
+ setEditing({ rowIndex: row, columnId: column.id });
332
+ }
333
+ return;
334
+ }
335
+ }
336
+
181
337
  return (
338
+ <>
182
339
  <div
183
340
  ref={containerRef}
184
341
  role="grid"
342
+ aria-rowcount={rowCount + 1}
343
+ aria-colcount={colIds.length}
185
344
  className={cn("relative overflow-auto rounded-md border font-sans text-sm", className)}
186
345
  style={{ height }}
187
346
  onScroll={(e) => setScrollTop(e.currentTarget.scrollTop)}
188
347
  >
189
348
  <div style={{ width: totalWidth, minWidth: "100%" }}>
190
- <div role="row" className="sticky top-0 z-[2] flex border-b bg-background">
191
- {renderedColumns.map((column) => {
349
+ <div role="row" aria-rowindex={1} className="sticky top-0 z-[2] flex border-b bg-background">
350
+ {renderedColumns.map((column, colIdx) => {
192
351
  const width = widths[column.id] ?? column.width ?? DEFAULT_WIDTH;
193
352
  const pinnedStyle: React.CSSProperties =
194
353
  column.pinned === "left"
@@ -201,6 +360,9 @@ function DataGrid<TData>({
201
360
  <div
202
361
  key={column.id}
203
362
  role="columnheader"
363
+ data-header={column.id}
364
+ aria-colindex={colIdx + 1}
365
+ aria-keyshortcuts={`Shift+ArrowLeft Shift+ArrowRight${column.pinned ? "" : " Alt+ArrowLeft Alt+ArrowRight"}`}
204
366
  {...(column.sortable
205
367
  ? { "aria-sort": isSorted ? (sort!.direction === "asc" ? "ascending" : "descending") : "none" }
206
368
  : {})}
@@ -224,8 +386,12 @@ function DataGrid<TData>({
224
386
  });
225
387
  }}
226
388
  onClick={() => column.sortable && toggleSort(column.id)}
389
+ // roving tabindex: the grid is one tab stop; arrows move between cells (see onNavKeyDown)
390
+ tabIndex={tabStop.row === -1 && tabStop.columnId === column.id ? 0 : -1}
391
+ onFocus={() => setActive({ row: -1, columnId: column.id })}
392
+ onKeyDown={(e) => onNavKeyDown(e, -1, column)}
227
393
  className={cn(
228
- "relative flex h-10 shrink-0 select-none items-center gap-1 px-2 font-medium text-muted-foreground",
394
+ "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",
229
395
  column.sortable && "cursor-pointer hover:text-foreground",
230
396
  column.pinned && "bg-background",
231
397
  )}
@@ -242,7 +408,7 @@ function DataGrid<TData>({
242
408
  e.stopPropagation();
243
409
  beginResize(column.id, column.minWidth ?? DEFAULT_MIN_WIDTH, e.clientX, width);
244
410
  }}
245
- className="absolute right-0 top-0 h-full w-1 touch-none cursor-col-resize hover:bg-primary/50"
411
+ className="absolute right-0 top-0 h-full w-1 touch-none cursor-col-resize hover:bg-action/50"
246
412
  />
247
413
  </div>
248
414
  );
@@ -255,10 +421,11 @@ function DataGrid<TData>({
255
421
  <div
256
422
  key={getRowId ? getRowId(row, rowIndex) : rowIndex}
257
423
  role="row"
424
+ aria-rowindex={rowIndex + 2}
258
425
  className="absolute flex w-full border-b hover:bg-muted/50"
259
426
  style={{ top: rowIndex * rowHeight, height: rowHeight }}
260
427
  >
261
- {renderedColumns.map((column) => {
428
+ {renderedColumns.map((column, colIdx) => {
262
429
  const width = widths[column.id] ?? column.width ?? DEFAULT_WIDTH;
263
430
  const pinnedStyle: React.CSSProperties =
264
431
  column.pinned === "left"
@@ -271,8 +438,17 @@ function DataGrid<TData>({
271
438
  <div
272
439
  key={column.id}
273
440
  role="gridcell"
441
+ aria-colindex={colIdx + 1}
442
+ data-cell={`${rowIndex}:${column.id}`}
274
443
  onDoubleClick={() => column.editable && setEditing({ rowIndex, columnId: column.id })}
275
- className={cn("flex shrink-0 items-center px-2", column.pinned && "bg-background")}
444
+ // roving tabindex; Enter or F2 edits an editable cell (the spreadsheet convention)
445
+ tabIndex={tabStop.row === rowIndex && tabStop.columnId === column.id ? 0 : -1}
446
+ onFocus={() => setActive({ row: rowIndex, columnId: column.id })}
447
+ onKeyDown={(e) => onNavKeyDown(e, rowIndex, column)}
448
+ className={cn(
449
+ "flex shrink-0 items-center px-2 outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring",
450
+ column.pinned && "bg-background",
451
+ )}
276
452
  style={{ width, ...pinnedStyle }}
277
453
  >
278
454
  {isEditing ? (
@@ -281,8 +457,8 @@ function DataGrid<TData>({
281
457
  defaultValue={column.value ? String(column.value(row)) : ""}
282
458
  className="h-7 w-full rounded-sm border-0 bg-transparent px-1 text-sm outline-none ring-1 ring-inset ring-ring"
283
459
  onKeyDown={(e) => {
284
- if (e.key === "Enter") commitEdit(column, row, rowIndex, e.currentTarget.value);
285
- else if (e.key === "Escape") setEditing(null);
460
+ if (e.key === "Enter") commitEdit(column, row, rowIndex, e.currentTarget.value, true);
461
+ else if (e.key === "Escape") endEdit(true);
286
462
  }}
287
463
  onBlur={(e) => commitEdit(column, row, rowIndex, e.currentTarget.value)}
288
464
  />
@@ -298,6 +474,11 @@ function DataGrid<TData>({
298
474
  </div>
299
475
  </div>
300
476
  </div>
477
+ {/* reorder / resize results, spoken politely; outside the grid because a grid's children must be rows */}
478
+ <div role="status" aria-live="polite" className="sr-only">
479
+ {announcement}
480
+ </div>
481
+ </>
301
482
  );
302
483
  }
303
484
 
@@ -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
  },