@kinetixui/ui 0.16.1 → 0.17.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.
@@ -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,11 +173,26 @@ 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
+
181
196
  return (
182
197
  <div
183
198
  ref={containerRef}
@@ -224,9 +239,18 @@ function DataGrid<TData>({
224
239
  });
225
240
  }}
226
241
  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
+ }}
227
250
  className={cn(
228
251
  "relative flex h-10 shrink-0 select-none items-center gap-1 px-2 font-medium text-muted-foreground",
229
- column.sortable && "cursor-pointer hover:text-foreground",
252
+ column.sortable &&
253
+ "cursor-pointer outline-none hover:text-foreground focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring",
230
254
  column.pinned && "bg-background",
231
255
  )}
232
256
  style={{ width, ...pinnedStyle }}
@@ -271,8 +295,21 @@ function DataGrid<TData>({
271
295
  <div
272
296
  key={column.id}
273
297
  role="gridcell"
298
+ data-cell={`${rowIndex}:${column.id}`}
274
299
  onDoubleClick={() => column.editable && setEditing({ rowIndex, columnId: column.id })}
275
- className={cn("flex shrink-0 items-center px-2", column.pinned && "bg-background")}
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")) {
304
+ e.preventDefault();
305
+ setEditing({ rowIndex, columnId: column.id });
306
+ }
307
+ }}
308
+ 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",
311
+ column.pinned && "bg-background",
312
+ )}
276
313
  style={{ width, ...pinnedStyle }}
277
314
  >
278
315
  {isEditing ? (
@@ -281,8 +318,8 @@ function DataGrid<TData>({
281
318
  defaultValue={column.value ? String(column.value(row)) : ""}
282
319
  className="h-7 w-full rounded-sm border-0 bg-transparent px-1 text-sm outline-none ring-1 ring-inset ring-ring"
283
320
  onKeyDown={(e) => {
284
- if (e.key === "Enter") commitEdit(column, row, rowIndex, e.currentTarget.value);
285
- else if (e.key === "Escape") setEditing(null);
321
+ if (e.key === "Enter") commitEdit(column, row, rowIndex, e.currentTarget.value, true);
322
+ else if (e.key === "Escape") endEdit(true);
286
323
  }}
287
324
  onBlur={(e) => commitEdit(column, row, rowIndex, e.currentTarget.value)}
288
325
  />
@@ -16,7 +16,7 @@ const informVariants = cva(
16
16
  {
17
17
  variants: {
18
18
  variant: {
19
- information: "bg-info/10 text-info",
19
+ information: "bg-info/10 text-info-on-container",
20
20
  warning: "bg-warning/15 text-warning",
21
21
  success: "bg-success/15 text-success",
22
22
  error: "bg-destructive/10 text-destructive",
@@ -22,7 +22,7 @@ import { cn } from "../lib/utils";
22
22
  const inputVariants = cva(
23
23
  [
24
24
  "flex w-full border border-input bg-background px-3 py-3",
25
- "font-sans text-[14px] leading-5 tracking-[0.25px] text-foreground",
25
+ "font-sans text-body-md text-foreground",
26
26
  "placeholder:text-muted-foreground",
27
27
  "outline-none transition-colors",
28
28
  "focus-visible:border-primary focus-visible:shadow-focus",
@@ -20,7 +20,7 @@ const Kbd = React.forwardRef<HTMLElement, KbdProps>(({ className, asChild = fals
20
20
  <Comp
21
21
  ref={ref}
22
22
  className={cn(
23
- "inline-flex h-5 min-w-5 items-center justify-center rounded-sm border border-input bg-muted px-1.5 font-sans text-[11px] font-medium text-muted-foreground",
23
+ "inline-flex h-5 min-w-5 items-center justify-center rounded-sm border border-input bg-muted px-1.5 font-sans text-label-sm text-muted-foreground",
24
24
  className,
25
25
  )}
26
26
  {...props}
@@ -75,7 +75,7 @@ function Modal({
75
75
  )}
76
76
  >
77
77
  <header className="flex items-center justify-between px-6 pb-3 pt-5">
78
- <DialogPrimitive.Title className="min-w-0 flex-1 text-[18px] font-semibold leading-6 text-foreground">
78
+ <DialogPrimitive.Title className="min-w-0 flex-1 text-title-dialog text-foreground">
79
79
  {title}
80
80
  </DialogPrimitive.Title>
81
81
  <DialogPrimitive.Close
@@ -90,7 +90,7 @@ function Modal({
90
90
 
91
91
  <div className="px-6 py-5">
92
92
  {description && (
93
- <DialogPrimitive.Description className="text-[14px] leading-5 tracking-[0.25px] text-muted-foreground">
93
+ <DialogPrimitive.Description className="text-body-md text-muted-foreground">
94
94
  {description}
95
95
  </DialogPrimitive.Description>
96
96
  )}
@@ -43,6 +43,12 @@ const MultiSelect = React.forwardRef<HTMLDivElement, MultiSelectProps>(
43
43
  const current = value ?? internal;
44
44
  const [open, setOpen] = React.useState(false);
45
45
  const [query, setQuery] = React.useState("");
46
+ const anchorRef = React.useRef<HTMLDivElement | null>(null);
47
+ const setRefs = (node: HTMLDivElement | null) => {
48
+ anchorRef.current = node;
49
+ if (typeof ref === "function") ref(node);
50
+ else if (ref) (ref as React.MutableRefObject<HTMLDivElement | null>).current = node;
51
+ };
46
52
 
47
53
  const set = (next: string[]) => {
48
54
  setInternal(next);
@@ -62,7 +68,7 @@ const MultiSelect = React.forwardRef<HTMLDivElement, MultiSelectProps>(
62
68
  <Popover open={open} onOpenChange={setOpen}>
63
69
  <PopoverAnchor asChild>
64
70
  <div
65
- ref={ref}
71
+ ref={setRefs}
66
72
  role="combobox"
67
73
  aria-expanded={open}
68
74
  aria-haspopup="listbox"
@@ -93,7 +99,13 @@ const MultiSelect = React.forwardRef<HTMLDivElement, MultiSelectProps>(
93
99
  <PopoverContent
94
100
  align="start"
95
101
  className="w-[var(--radix-popover-trigger-width)] p-0"
96
- onOpenAutoFocus={(e) => e.preventDefault()}
102
+ // Radix focuses the search input on open, so keys reach the option list (cmdk only
103
+ // listens from inside its own root) — and on close focus goes back to the combobox,
104
+ // which is only an anchor here, so Radix has no trigger to return it to.
105
+ onCloseAutoFocus={(e) => {
106
+ e.preventDefault();
107
+ anchorRef.current?.focus();
108
+ }}
97
109
  >
98
110
  <Command shouldFilter={!creatable}>
99
111
  <CommandInput placeholder="Search…" value={query} onValueChange={setQuery} />
@@ -16,7 +16,7 @@ import { cn } from "../lib/utils";
16
16
  const nativeSelectVariants = cva(
17
17
  [
18
18
  "flex w-full appearance-none border border-input bg-background px-3 py-3 pe-9",
19
- "font-sans text-[14px] leading-5 tracking-[0.25px] text-foreground",
19
+ "font-sans text-body-md text-foreground",
20
20
  "outline-none transition-colors",
21
21
  "focus-visible:border-primary focus-visible:shadow-focus",
22
22
  "disabled:cursor-not-allowed disabled:opacity-disabled",
@@ -22,7 +22,7 @@ const SelectTrigger = React.forwardRef<
22
22
  ref={ref}
23
23
  className={cn(
24
24
  "flex min-h-[44px] w-full items-center justify-between rounded-sm border border-input bg-background px-3 py-3",
25
- "font-sans text-[14px] leading-5 tracking-[0.25px] text-foreground",
25
+ "font-sans text-body-md text-foreground",
26
26
  "data-[placeholder]:text-muted-foreground [&>span]:line-clamp-1",
27
27
  "outline-none transition-colors",
28
28
  "focus:border-primary focus:shadow-focus",
@@ -7,7 +7,7 @@ import { cn } from "../lib/utils";
7
7
  const Slider = React.forwardRef<
8
8
  React.ElementRef<typeof SliderPrimitive.Root>,
9
9
  React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
10
- >(({ className, ...props }, ref) => (
10
+ >(({ className, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, ...props }, ref) => (
11
11
  <SliderPrimitive.Root
12
12
  ref={ref}
13
13
  className={cn("relative flex w-full touch-none select-none items-center", className)}
@@ -16,7 +16,12 @@ const Slider = React.forwardRef<
16
16
  <SliderPrimitive.Track className="relative h-1.5 w-full grow overflow-hidden rounded-full bg-muted">
17
17
  <SliderPrimitive.Range className="absolute h-full bg-primary" />
18
18
  </SliderPrimitive.Track>
19
- <SliderPrimitive.Thumb className="block size-4 rounded-full border border-primary/50 bg-background shadow transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-disabled" />
19
+ {/* the accessible name belongs on the thumb: that is the element with role="slider" */}
20
+ <SliderPrimitive.Thumb
21
+ aria-label={ariaLabel}
22
+ aria-labelledby={ariaLabelledBy}
23
+ className="block size-4 rounded-full border border-primary/50 bg-background shadow transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-disabled"
24
+ />
20
25
  </SliderPrimitive.Root>
21
26
  ));
22
27
  Slider.displayName = SliderPrimitive.Root.displayName;
@@ -13,7 +13,7 @@ import { cn } from "../lib/utils";
13
13
  * optional close button. Variants: default | secondary | destructive | warning | outline.
14
14
  */
15
15
  const tagVariants = cva(
16
- "inline-flex items-center gap-1 rounded-sm px-2 py-1 text-xs font-medium font-sans tracking-[0.5px]",
16
+ "inline-flex items-center gap-1 rounded-sm px-2 py-1 text-label-md font-sans",
17
17
  {
18
18
  variants: {
19
19
  variant: {
@@ -14,7 +14,7 @@ import { cn } from "../lib/utils";
14
14
  const textareaVariants = cva(
15
15
  [
16
16
  "flex w-full min-h-[100px] resize-y rounded-sm border border-input bg-background px-3 py-3",
17
- "font-sans text-[14px] leading-5 tracking-[0.25px] text-foreground",
17
+ "font-sans text-body-md text-foreground",
18
18
  "placeholder:text-muted-foreground",
19
19
  "outline-none transition-colors",
20
20
  "focus-visible:border-primary focus-visible:shadow-focus",
@@ -55,6 +55,43 @@ const Tour: React.FC<TourProps> = ({
55
55
 
56
56
  React.useEffect(() => setMounted(true), []);
57
57
 
58
+ const titleId = React.useId();
59
+ const dialogRef = React.useRef<HTMLDivElement>(null);
60
+ const returnFocusRef = React.useRef<HTMLElement | null>(null);
61
+
62
+ // Remember what had focus when the tour opened and give it back when it closes.
63
+ React.useEffect(() => {
64
+ if (!open) return;
65
+ returnFocusRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
66
+ return () => returnFocusRef.current?.focus();
67
+ }, [open]);
68
+
69
+ // Move focus into the dialog on open and on every step, so a screen reader announces the new title.
70
+ React.useEffect(() => {
71
+ if (open && mounted) dialogRef.current?.focus();
72
+ }, [open, mounted, stepIndex]);
73
+
74
+ // aria-modal only *tells* assistive tech the page is inert; keep Tab inside the card ourselves.
75
+ const trapTab = (e: React.KeyboardEvent<HTMLDivElement>) => {
76
+ if (e.key !== "Tab") return;
77
+ const dialog = e.currentTarget;
78
+ const focusable = Array.from(dialog.querySelectorAll<HTMLElement>("button:not([disabled]), [href], input:not([disabled]), [tabindex]:not([tabindex='-1'])"));
79
+ if (focusable.length === 0) {
80
+ e.preventDefault();
81
+ return;
82
+ }
83
+ const first = focusable[0]!;
84
+ const last = focusable[focusable.length - 1]!;
85
+ const active = document.activeElement;
86
+ if (e.shiftKey && (active === first || active === dialog)) {
87
+ e.preventDefault();
88
+ last.focus();
89
+ } else if (!e.shiftKey && active === last) {
90
+ e.preventDefault();
91
+ first.focus();
92
+ }
93
+ };
94
+
58
95
  React.useEffect(() => {
59
96
  if (!open || !step) return;
60
97
  const update = () => {
@@ -114,12 +151,18 @@ const Tour: React.FC<TourProps> = ({
114
151
  style={spotlightStyle}
115
152
  />
116
153
  <div
154
+ ref={dialogRef}
117
155
  role="dialog"
118
156
  aria-modal="true"
119
- className="fixed z-overlay w-80 rounded-lg border border-border bg-popover p-4 font-sans text-popover-foreground shadow-lg transition-all duration-fast"
157
+ aria-labelledby={titleId}
158
+ tabIndex={-1}
159
+ onKeyDown={trapTab}
160
+ className="fixed z-overlay w-80 rounded-lg border border-border bg-popover p-4 font-sans text-popover-foreground shadow-lg outline-none transition-all duration-fast"
120
161
  style={cardStyle}
121
162
  >
122
- <p className="text-title-sm font-medium text-foreground">{step.title}</p>
163
+ <p id={titleId} className="text-title-sm font-medium text-foreground">
164
+ {step.title}
165
+ </p>
123
166
  <div className="mt-1.5 text-body-sm text-muted-foreground">{step.content}</div>
124
167
  <div className="mt-4 flex items-center justify-between">
125
168
  <span className="text-label-sm text-muted-foreground">
@@ -0,0 +1,90 @@
1
+ import * as React from "react";
2
+ import axe from "axe-core";
3
+ import { render, cleanup } from "@testing-library/react";
4
+ import { afterEach, describe, expect, it } from "vitest";
5
+
6
+ /**
7
+ * Rendered accessibility pass: mount every story in jsdom and run axe-core over
8
+ * the resulting DOM. This catches what the static contrast/token checks can't —
9
+ * missing accessible names, invalid ARIA, bad roles, duplicate ids, label
10
+ * association. jsdom has no layout or stylesheet, so `color-contrast` (covered
11
+ * by `pnpm check:contrast`) and page-level rules are off.
12
+ *
13
+ * KNOWN lists violations that already existed when this pass was added
14
+ * (mostly demo stories with unlabeled controls — fix the story or the component), keyed
15
+ * `story:rule`. A new violation fails; fixing a known one fails too (stale
16
+ * entry) until it is deleted from the list, so the baseline only shrinks.
17
+ */
18
+ const stories = import.meta.glob("./stories/*.stories.tsx", { eager: true }) as Record<
19
+ string,
20
+ Record<string, unknown>
21
+ >;
22
+
23
+ const KNOWN = new Set<string>([
24
+ "Banner:landmark-no-duplicate-banner",
25
+ "Banner:landmark-unique",
26
+ "CircularProgress:aria-progressbar-name",
27
+ "ColorPicker:label",
28
+ "DiffViewer:aria-required-children",
29
+ "FileUpload:label",
30
+ "FileUpload:nested-interactive",
31
+ "InputOTP:label",
32
+ "JsonViewer:aria-required-parent",
33
+ "List:aria-required-children",
34
+ "List:button-name",
35
+ "MarkdownEditor:label",
36
+ "MultiSelect:aria-input-field-name",
37
+ "NativeSelect:select-name",
38
+ "NumberInput:label",
39
+ "Progress:aria-progressbar-name",
40
+ "Select:button-name",
41
+ "Slider:aria-input-field-name",
42
+ "ToggleGroup:button-name",
43
+ ]);
44
+
45
+ afterEach(cleanup);
46
+
47
+ async function violationsFor(container: HTMLElement) {
48
+ const results = await axe.run(container, {
49
+ rules: {
50
+ "color-contrast": { enabled: false },
51
+ region: { enabled: false },
52
+ "landmark-one-main": { enabled: false },
53
+ "page-has-heading-one": { enabled: false },
54
+ },
55
+ });
56
+ return results.violations.map((v) => v.id);
57
+ }
58
+
59
+ describe("component a11y (axe)", () => {
60
+ const entries = Object.entries(stories).sort(([a], [b]) => a.localeCompare(b));
61
+
62
+ for (const [path, mod] of entries) {
63
+ const name = path.split("/").pop()!.replace(".stories.tsx", "");
64
+ const meta = mod.default as { component?: React.ComponentType<unknown>; args?: Record<string, unknown> };
65
+ const storyKey = ["Default", "Playground", "Variants", "States"].find(
66
+ (k) => mod[k] && typeof mod[k] === "object",
67
+ );
68
+ const story = storyKey
69
+ ? (mod[storyKey] as { render?: (...a: unknown[]) => React.ReactNode; args?: Record<string, unknown> })
70
+ : undefined;
71
+
72
+ it(`${name} has no new axe violations`, async () => {
73
+ const args = { ...meta?.args, ...story?.args };
74
+ const node =
75
+ typeof story?.render === "function"
76
+ ? story.render(args, { args } as never)
77
+ : meta?.component
78
+ ? React.createElement(meta.component, args)
79
+ : null;
80
+ if (node === null) return;
81
+
82
+ const { container } = render(node as React.ReactElement);
83
+ const found = new Set((await violationsFor(container)).map((id) => `${name}:${id}`));
84
+ const fresh = [...found].filter((k) => !KNOWN.has(k));
85
+ const stale = [...KNOWN].filter((k) => k.startsWith(`${name}:`) && !found.has(k));
86
+ expect(fresh, `new axe violations (story:rule)`).toEqual([]);
87
+ expect(stale, `fixed — remove from KNOWN`).toEqual([]);
88
+ });
89
+ }
90
+ });