@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
@@ -59,17 +59,11 @@ const FileUpload = React.forwardRef<HTMLDivElement, FileUploadProps>(
59
59
 
60
60
  return (
61
61
  <div ref={ref} className={cn("flex w-full flex-col gap-3 font-sans", className)} {...props}>
62
+ {/* A drag-and-drop surface, not a control: the Button inside is the one keyboard/AT target
63
+ (a role="button" containing a Button is nested-interactive). Clicking the surface stays a
64
+ mouse convenience. */}
62
65
  <div
63
- role="button"
64
- tabIndex={disabled ? -1 : 0}
65
- aria-disabled={disabled}
66
66
  onClick={() => !disabled && inputRef.current?.click()}
67
- onKeyDown={(e) => {
68
- if (!disabled && (e.key === "Enter" || e.key === " ")) {
69
- e.preventDefault();
70
- inputRef.current?.click();
71
- }
72
- }}
73
67
  onDragOver={(e) => {
74
68
  e.preventDefault();
75
69
  if (!disabled) setDragging(true);
@@ -81,9 +75,9 @@ const FileUpload = React.forwardRef<HTMLDivElement, FileUploadProps>(
81
75
  if (!disabled) pick(e.dataTransfer.files);
82
76
  }}
83
77
  className={cn(
84
- "flex flex-col items-center gap-2 rounded-md border border-dashed border-input p-6 text-center outline-none transition-colors",
85
- !disabled && "cursor-pointer hover:bg-accent/50 focus-visible:shadow-focus",
86
- dragging && "border-primary bg-accent",
78
+ "flex flex-col items-center gap-2 rounded-md border border-dashed border-input p-6 text-center transition-colors",
79
+ !disabled && "cursor-pointer hover:bg-accent/50",
80
+ dragging && "border-action bg-accent",
87
81
  disabled && "pointer-events-none opacity-disabled",
88
82
  )}
89
83
  >
@@ -108,6 +102,10 @@ const FileUpload = React.forwardRef<HTMLDivElement, FileUploadProps>(
108
102
  multiple={multiple}
109
103
  accept={accept}
110
104
  disabled={disabled}
105
+ // driven by the dropzone button above; a second focusable control inside a role=button
106
+ // (and an unlabeled one) is invalid, so keep it out of the tab order and the a11y tree
107
+ tabIndex={-1}
108
+ aria-hidden="true"
111
109
  className="sr-only"
112
110
  onChange={(e) => {
113
111
  pick(e.target.files);
@@ -141,7 +139,7 @@ function FileUploadItem({ file, onRemove, onRetry }: FileUploadItemProps) {
141
139
  return (
142
140
  <li className={cn("flex items-center gap-2.5 rounded-md border p-2.5 text-body-sm", isError ? "border-destructive bg-destructive/5" : "border-input")}>
143
141
  {isLoading ? (
144
- <Loader2 className="size-4 shrink-0 animate-spin text-muted-foreground" aria-hidden />
142
+ <Loader2 className="size-4 shrink-0 animate-spin motion-reduce:animate-[spin_3s_linear_infinite] text-muted-foreground" aria-hidden />
145
143
  ) : isError ? (
146
144
  <TriangleAlert className="size-4 shrink-0 text-destructive" aria-hidden />
147
145
  ) : (
@@ -152,7 +150,7 @@ function FileUploadItem({ file, onRemove, onRetry }: FileUploadItemProps) {
152
150
  {isError && file.error ? <p className="text-destructive">{file.error}</p> : <p className="text-muted-foreground">{formatBytes(file.size)}</p>}
153
151
  {isLoading && typeof file.progress === "number" && (
154
152
  <div className="mt-1 h-1 w-full overflow-hidden rounded-full bg-muted">
155
- <div className="h-full bg-primary transition-[width]" style={{ width: `${Math.max(0, Math.min(100, file.progress))}%` }} />
153
+ <div className="h-full bg-action transition-[width]" style={{ width: `${Math.max(0, Math.min(100, file.progress))}%` }} />
156
154
  </div>
157
155
  )}
158
156
  </div>
@@ -160,7 +158,7 @@ function FileUploadItem({ file, onRemove, onRetry }: FileUploadItemProps) {
160
158
  <button
161
159
  type="button"
162
160
  onClick={() => onRetry(file.id)}
163
- className="shrink-0 text-label-md font-medium text-primary underline-offset-2 outline-none hover:underline focus-visible:underline"
161
+ className="shrink-0 text-label-md font-medium text-link underline-offset-2 outline-none hover:underline focus-visible:underline"
164
162
  >
165
163
  Retry
166
164
  </button>
@@ -22,7 +22,7 @@ const InputGroup = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDiv
22
22
  data-slot="input-group"
23
23
  className={cn(
24
24
  "flex w-full items-center overflow-hidden rounded-sm border border-input bg-background font-sans transition-colors",
25
- "focus-within:border-primary focus-within:shadow-focus",
25
+ "focus-within:border-action focus-within:shadow-focus",
26
26
  "has-[[aria-invalid=true]]:border-destructive has-[[aria-invalid=true]]:focus-within:shadow-focus-destructive",
27
27
  "has-[:disabled]:cursor-not-allowed has-[:disabled]:opacity-disabled",
28
28
  className,
@@ -35,7 +35,8 @@ const InputOTPSlot = React.forwardRef<
35
35
  ref={ref}
36
36
  className={cn(
37
37
  "relative flex size-9 items-center justify-center border-y border-r border-input text-sm shadow-sm transition-all first:rounded-l-md first:border-l last:rounded-r-md",
38
- isActive && "z-docked ring-1 ring-ring",
38
+ // ring is a box-shadow, which forced-colors mode strips; the outline survives it
39
+ isActive && "z-docked ring-1 ring-ring forced-colors:[outline:2px_solid] forced-colors:[outline-offset:-2px]",
39
40
  className,
40
41
  )}
41
42
  {...props}
@@ -25,7 +25,7 @@ const inputVariants = cva(
25
25
  "font-sans text-body-md text-foreground",
26
26
  "placeholder:text-muted-foreground",
27
27
  "outline-none transition-colors",
28
- "focus-visible:border-primary focus-visible:shadow-focus",
28
+ "focus-visible:border-action focus-visible:shadow-focus",
29
29
  "disabled:cursor-not-allowed disabled:opacity-disabled",
30
30
  "aria-[invalid=true]:border-destructive aria-[invalid=true]:focus-visible:border-destructive aria-[invalid=true]:focus-visible:shadow-focus-destructive",
31
31
  ],
@@ -33,7 +33,7 @@ const inputVariants = cva(
33
33
  variants: {
34
34
  state: {
35
35
  Default: "",
36
- Focus: "border-primary shadow-focus",
36
+ Focus: "border-action shadow-focus",
37
37
  Error: "border-destructive",
38
38
  Disabled: "opacity-disabled pointer-events-none",
39
39
  },
@@ -136,16 +136,18 @@ function JsonNode({
136
136
  </div>
137
137
  {expanded && (
138
138
  <>
139
- {entries.map(([key, val], i) => (
140
- <JsonNode
141
- key={key}
142
- name={isArray ? undefined : key}
143
- value={val}
144
- depth={depth + 1}
145
- expandDepth={expandDepth}
146
- isLast={i === entries.length - 1}
147
- />
148
- ))}
139
+ <div role="group">
140
+ {entries.map(([key, val], i) => (
141
+ <JsonNode
142
+ key={key}
143
+ name={isArray ? undefined : key}
144
+ value={val}
145
+ depth={depth + 1}
146
+ expandDepth={expandDepth}
147
+ isLast={i === entries.length - 1}
148
+ />
149
+ ))}
150
+ </div>
149
151
  <div style={{ paddingLeft: depth * 16 }}>
150
152
  <span>{closeBracket}</span>
151
153
  {!isLast && <span className="text-muted-foreground">,</span>}
@@ -29,39 +29,53 @@ export interface ListItemProps extends Omit<React.HTMLAttributes<HTMLDivElement>
29
29
  const ListItem = React.forwardRef<HTMLDivElement, ListItemProps>(
30
30
  ({ className, leading, title, description, trailing, disabled, onSelect, ...props }, ref) => {
31
31
  const interactive = !!onSelect && !disabled;
32
- return (
33
- <div
34
- ref={ref}
35
- role={interactive ? "button" : "listitem"}
36
- tabIndex={interactive ? 0 : undefined}
37
- aria-disabled={disabled}
38
- onClick={disabled ? undefined : onSelect}
39
- onKeyDown={
40
- interactive
41
- ? (e) => {
42
- if (e.key === "Enter" || e.key === " ") {
43
- e.preventDefault();
44
- onSelect?.();
45
- }
46
- }
47
- : undefined
48
- }
49
- className={cn(
50
- "flex items-center gap-3 px-3 py-3 font-sans outline-none",
51
- // ring keeps the highlighted row perceptible where the accent fill is <3:1 (dark)
52
- interactive &&
53
- "cursor-pointer hover:bg-accent hover:ring-1 hover:ring-inset hover:ring-ring focus-visible:bg-accent focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring",
54
- disabled && "pointer-events-none opacity-disabled",
55
- className,
56
- )}
57
- {...props}
58
- >
32
+ const rowClass = cn(
33
+ "flex items-center gap-3 px-3 py-3 font-sans outline-none",
34
+ // ring keeps the highlighted row perceptible where the accent fill is <3:1 (dark)
35
+ interactive &&
36
+ "cursor-pointer hover:bg-accent hover:ring-1 hover:ring-inset hover:ring-ring focus-visible:bg-accent focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring",
37
+ disabled && "pointer-events-none opacity-disabled",
38
+ className,
39
+ );
40
+ const content = (
41
+ <>
59
42
  {leading && <div className="flex shrink-0 items-center justify-center">{leading}</div>}
60
43
  <div className="min-w-0 flex-1">
61
44
  <div className="truncate text-body-md text-foreground">{title}</div>
62
45
  {description && <div className="truncate text-body-sm text-muted-foreground">{description}</div>}
63
46
  </div>
64
47
  {trailing && <div className="flex shrink-0 items-center gap-2">{trailing}</div>}
48
+ </>
49
+ );
50
+
51
+ if (!interactive) {
52
+ return (
53
+ <div ref={ref} role="listitem" aria-disabled={disabled} className={rowClass} {...props}>
54
+ {content}
55
+ </div>
56
+ );
57
+ }
58
+
59
+ // A list's only valid children are listitems, so a pressable row is a listitem that
60
+ // contains the button — not a button standing in for the listitem.
61
+ return (
62
+ <div role="listitem">
63
+ <div
64
+ ref={ref}
65
+ role="button"
66
+ tabIndex={0}
67
+ onClick={onSelect}
68
+ onKeyDown={(e) => {
69
+ if (e.key === "Enter" || e.key === " ") {
70
+ e.preventDefault();
71
+ onSelect?.();
72
+ }
73
+ }}
74
+ className={rowClass}
75
+ {...props}
76
+ >
77
+ {content}
78
+ </div>
65
79
  </div>
66
80
  );
67
81
  },
@@ -163,7 +163,7 @@ function renderMarkdown(source: string): string {
163
163
  * this ports cleanly to all four platforms instead.
164
164
  */
165
165
  const MarkdownEditor = React.forwardRef<HTMLDivElement, MarkdownEditorProps>(
166
- ({ value, onChange, placeholder, rows = 10, className, ...props }, ref) => {
166
+ ({ value, onChange, placeholder, rows = 10, className, "aria-label": ariaLabel = "Markdown", ...props }, ref) => {
167
167
  const textareaRef = React.useRef<HTMLTextAreaElement>(null);
168
168
  const [showPreview, setShowPreview] = React.useState(false);
169
169
  const pendingSelection = React.useRef<{ start: number; end: number } | null>(null);
@@ -236,11 +236,12 @@ const MarkdownEditor = React.forwardRef<HTMLDivElement, MarkdownEditorProps>(
236
236
  onChange={(e) => onChange(e.target.value)}
237
237
  placeholder={placeholder}
238
238
  rows={rows}
239
+ aria-label={ariaLabel}
239
240
  className="resize-none rounded-none border-0 font-mono text-[13px] focus-visible:border-transparent focus-visible:shadow-none"
240
241
  />
241
242
  {showPreview && (
242
243
  <div
243
- className="overflow-auto p-3 text-sm [&_a]:text-primary [&_a]:underline [&_blockquote]:border-l-2 [&_blockquote]:border-border [&_blockquote]:pl-3 [&_blockquote]:text-muted-foreground [&_code]:rounded-sm [&_code]:bg-muted [&_code]:px-1 [&_code]:py-0.5 [&_code]:font-mono [&_code]:text-[0.9em] [&_h1]:text-xl [&_h1]:font-semibold [&_h2]:text-lg [&_h2]:font-semibold [&_h3]:font-semibold [&_li]:ml-5 [&_ol]:list-decimal [&_p]:mb-2 [&_pre]:overflow-auto [&_pre]:rounded-sm [&_pre]:bg-muted [&_pre]:p-2 [&_ul]:list-disc"
244
+ className="overflow-auto p-3 text-sm [&_a]:text-link [&_a]:underline [&_blockquote]:border-l-2 [&_blockquote]:border-border [&_blockquote]:pl-3 [&_blockquote]:text-muted-foreground [&_code]:rounded-sm [&_code]:bg-muted [&_code]:px-1 [&_code]:py-0.5 [&_code]:font-mono [&_code]:text-[0.9em] [&_h1]:text-xl [&_h1]:font-semibold [&_h2]:text-lg [&_h2]:font-semibold [&_h3]:font-semibold [&_li]:ml-5 [&_ol]:list-decimal [&_p]:mb-2 [&_pre]:overflow-auto [&_pre]:rounded-sm [&_pre]:bg-muted [&_pre]:p-2 [&_ul]:list-disc"
244
245
  // Rendered from renderMarkdown above, which HTML-escapes all
245
246
  // source text before inserting any markup — safe from
246
247
  // injection, same trust boundary as any other self-generated
@@ -34,7 +34,7 @@ const MessageBubble = React.forwardRef<HTMLDivElement, MessageBubbleProps>(
34
34
  <div
35
35
  className={cn(
36
36
  "min-w-0 rounded-2xl px-3.5 py-2 text-body-sm",
37
- sent ? "bg-primary text-primary-foreground" : "bg-muted text-foreground",
37
+ sent ? "bg-action text-action-foreground" : "bg-muted text-foreground",
38
38
  sent && grouped && "rounded-tr-md",
39
39
  !sent && grouped && "rounded-tl-md",
40
40
  )}
@@ -45,7 +45,7 @@ const MessageBubble = React.forwardRef<HTMLDivElement, MessageBubbleProps>(
45
45
  <div className="flex items-center gap-1 px-1 text-label-sm text-muted-foreground">
46
46
  {timestamp}
47
47
  {sent && status && (
48
- <span className={cn(status === "read" && "text-primary")}>
48
+ <span className={cn(status === "read" && "text-action")}>
49
49
  {status === "sent" ? <Check className="size-3.5" /> : <CheckCheck className="size-3.5" />}
50
50
  </span>
51
51
  )}
@@ -71,7 +71,7 @@ const TypingIndicator = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HT
71
71
  <span
72
72
  key={i}
73
73
  aria-hidden
74
- className="size-1.5 animate-typing-dot rounded-full bg-muted-foreground"
74
+ className="size-1.5 animate-typing-dot motion-reduce:animate-none motion-reduce:opacity-70 rounded-full bg-muted-foreground"
75
75
  style={{ animationDelay: `${i * 150}ms` }}
76
76
  />
77
77
  ))}
@@ -82,8 +82,8 @@ const MultiSelect = React.forwardRef<HTMLDivElement, MultiSelectProps>(
82
82
  }}
83
83
  className={cn(
84
84
  "flex min-h-11 w-full flex-wrap items-center gap-1.5 border border-input bg-background px-3 py-2 font-sans",
85
- "outline-none focus-visible:border-primary focus-visible:shadow-focus",
86
- "aria-expanded:border-primary aria-expanded:shadow-focus",
85
+ "outline-none focus-visible:border-action focus-visible:shadow-focus",
86
+ "aria-expanded:border-action aria-expanded:shadow-focus",
87
87
  className,
88
88
  )}
89
89
  {...props}
@@ -108,7 +108,18 @@ const MultiSelect = React.forwardRef<HTMLDivElement, MultiSelectProps>(
108
108
  }}
109
109
  >
110
110
  <Command shouldFilter={!creatable}>
111
- <CommandInput placeholder="Search…" value={query} onValueChange={setQuery} />
111
+ <CommandInput
112
+ placeholder="Search…"
113
+ value={query}
114
+ onValueChange={setQuery}
115
+ onKeyDown={(e) => {
116
+ // Backspace in an empty search field removes the last chip, as in every tag input
117
+ if (e.key === "Backspace" && query === "" && current.length > 0) {
118
+ e.preventDefault();
119
+ remove(current[current.length - 1]!);
120
+ }
121
+ }}
122
+ />
112
123
  <CommandList>
113
124
  <CommandEmpty>
114
125
  {canCreate ? (
@@ -18,7 +18,7 @@ const nativeSelectVariants = cva(
18
18
  "flex w-full appearance-none border border-input bg-background px-3 py-3 pe-9",
19
19
  "font-sans text-body-md text-foreground",
20
20
  "outline-none transition-colors",
21
- "focus-visible:border-primary focus-visible:shadow-focus",
21
+ "focus-visible:border-action focus-visible:shadow-focus",
22
22
  "disabled:cursor-not-allowed disabled:opacity-disabled",
23
23
  "aria-[invalid=true]:border-destructive aria-[invalid=true]:focus-visible:border-destructive aria-[invalid=true]:focus-visible:shadow-focus-destructive",
24
24
  ],
@@ -26,7 +26,7 @@ const nativeSelectVariants = cva(
26
26
  variants: {
27
27
  state: {
28
28
  Default: "",
29
- Focus: "border-primary shadow-focus",
29
+ Focus: "border-action shadow-focus",
30
30
  Error: "border-destructive",
31
31
  Disabled: "opacity-disabled pointer-events-none",
32
32
  },
@@ -56,7 +56,7 @@ const NotificationCenterContent = React.forwardRef<HTMLDivElement, NotificationC
56
56
  <button
57
57
  type="button"
58
58
  onClick={onMarkAllRead}
59
- className="text-label-md font-medium text-primary outline-none hover:underline focus-visible:underline"
59
+ className="text-label-md font-medium text-link outline-none hover:underline focus-visible:underline"
60
60
  >
61
61
  Mark all read
62
62
  </button>
@@ -102,7 +102,7 @@ const NotificationItem = React.forwardRef<HTMLDivElement, NotificationItemProps>
102
102
  )}
103
103
  {...props}
104
104
  >
105
- <span className={cn("mt-1.5 size-2 shrink-0 rounded-full", unread ? "bg-primary" : "bg-transparent")} />
105
+ <span className={cn("mt-1.5 size-2 shrink-0 rounded-full", unread ? "bg-action" : "bg-transparent")} />
106
106
  <div className="min-w-0 flex-1">
107
107
  <p className={cn("truncate text-body-sm", unread ? "font-medium text-foreground" : "text-muted-foreground")}>
108
108
  {title}
@@ -14,7 +14,7 @@ const Progress = React.forwardRef<
14
14
  {...props}
15
15
  >
16
16
  <ProgressPrimitive.Indicator
17
- className="size-full flex-1 bg-primary transition-transform"
17
+ className="size-full flex-1 bg-action transition-transform"
18
18
  style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
19
19
  />
20
20
  </ProgressPrimitive.Root>
@@ -25,17 +25,17 @@ const RadioGroupItem = React.forwardRef<
25
25
  <RadioGroupPrimitive.Item
26
26
  ref={ref}
27
27
  className={cn(
28
- "aspect-square size-[18px] rounded-full border-2 border-input text-primary outline-none transition-colors",
28
+ "aspect-square size-[18px] rounded-full border-2 border-input text-action outline-none transition-colors",
29
29
  "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
30
30
  "disabled:cursor-not-allowed disabled:opacity-disabled",
31
- "data-[state=checked]:border-primary",
31
+ "data-[state=checked]:border-action",
32
32
  "aria-invalid:border-destructive",
33
33
  className,
34
34
  )}
35
35
  {...props}
36
36
  >
37
37
  <RadioGroupPrimitive.Indicator className="flex items-center justify-center">
38
- <Circle className="size-2.5 fill-primary text-primary" />
38
+ <Circle className="size-2.5 fill-action text-action" />
39
39
  </RadioGroupPrimitive.Indicator>
40
40
  </RadioGroupPrimitive.Item>
41
41
  ));
@@ -9,7 +9,7 @@ const ScrollArea = React.forwardRef<
9
9
  React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
10
10
  >(({ className, children, ...props }, ref) => (
11
11
  <ScrollAreaPrimitive.Root ref={ref} className={cn("relative overflow-hidden", className)} {...props}>
12
- <ScrollAreaPrimitive.Viewport className="size-full rounded-[inherit]">{children}</ScrollAreaPrimitive.Viewport>
12
+ <ScrollAreaPrimitive.Viewport tabIndex={0} className="size-full rounded-[inherit] outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring">{children}</ScrollAreaPrimitive.Viewport>
13
13
  <ScrollBar />
14
14
  <ScrollAreaPrimitive.Corner />
15
15
  </ScrollAreaPrimitive.Root>
@@ -25,8 +25,8 @@ const SelectTrigger = React.forwardRef<
25
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
- "focus:border-primary focus:shadow-focus",
29
- "data-[state=open]:border-primary",
28
+ "focus:border-action focus:shadow-focus",
29
+ "data-[state=open]:border-action",
30
30
  "aria-[invalid=true]:border-destructive aria-[invalid=true]:focus:border-destructive aria-[invalid=true]:focus:shadow-focus-destructive",
31
31
  "disabled:cursor-not-allowed disabled:opacity-disabled",
32
32
  className,
@@ -3,7 +3,7 @@
3
3
  import { cn } from "../lib/utils";
4
4
 
5
5
  function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
6
- return <div className={cn("animate-pulse rounded-md bg-muted", className)} {...props} />;
6
+ return <div className={cn("animate-pulse rounded-md bg-muted motion-reduce:animate-none", className)} {...props} />;
7
7
  }
8
8
 
9
9
  export { Skeleton };
@@ -14,13 +14,13 @@ const Slider = React.forwardRef<
14
14
  {...props}
15
15
  >
16
16
  <SliderPrimitive.Track className="relative h-1.5 w-full grow overflow-hidden rounded-full bg-muted">
17
- <SliderPrimitive.Range className="absolute h-full bg-primary" />
17
+ <SliderPrimitive.Range className="absolute h-full bg-action" />
18
18
  </SliderPrimitive.Track>
19
19
  {/* the accessible name belongs on the thumb: that is the element with role="slider" */}
20
20
  <SliderPrimitive.Thumb
21
21
  aria-label={ariaLabel}
22
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"
23
+ className="block size-4 rounded-full border border-action/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
24
  />
25
25
  </SliderPrimitive.Root>
26
26
  ));
@@ -18,7 +18,7 @@ function Toaster({ theme = "system", ...props }: ToasterProps) {
18
18
  toast:
19
19
  "group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
20
20
  description: "group-[.toast]:text-muted-foreground",
21
- actionButton: "group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
21
+ actionButton: "group-[.toast]:bg-action group-[.toast]:text-action-foreground",
22
22
  cancelButton: "group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
23
23
  },
24
24
  }}
@@ -9,7 +9,7 @@ import { cn } from "../lib/utils";
9
9
  * dependency; `size` sets the diameter, `variant` swaps the indicator
10
10
  * colour for on-color surfaces.
11
11
  */
12
- const spinnerVariants = cva("inline-block animate-spin rounded-full border-2 border-current border-t-transparent", {
12
+ const spinnerVariants = cva("inline-block animate-spin motion-reduce:animate-[spin_3s_linear_infinite] rounded-full border-2 border-current border-t-transparent", {
13
13
  variants: {
14
14
  size: {
15
15
  sm: "size-4",
@@ -17,9 +17,9 @@ const spinnerVariants = cva("inline-block animate-spin rounded-full border-2 bor
17
17
  lg: "size-8",
18
18
  },
19
19
  variant: {
20
- default: "text-primary",
20
+ default: "text-action",
21
21
  muted: "text-muted-foreground",
22
- onColor: "text-primary-foreground",
22
+ onColor: "text-action-foreground",
23
23
  },
24
24
  },
25
25
  defaultVariants: { size: "md", variant: "default" },
@@ -39,8 +39,8 @@ const Stepper = React.forwardRef<HTMLOListElement, StepperProps>(
39
39
  <span
40
40
  className={cn(
41
41
  "flex size-7 shrink-0 items-center justify-center rounded-full text-label-md font-medium",
42
- status === "complete" && "bg-primary text-primary-foreground",
43
- status === "current" && "border-2 border-primary text-primary",
42
+ status === "complete" && "bg-action text-action-foreground",
43
+ status === "current" && "border-2 border-action text-action",
44
44
  status === "upcoming" && "border border-input text-muted-foreground",
45
45
  )}
46
46
  >
@@ -52,7 +52,7 @@ const Stepper = React.forwardRef<HTMLOListElement, StepperProps>(
52
52
  className={cn(
53
53
  "bg-border",
54
54
  vertical ? "my-1 min-h-6 w-px flex-1 self-stretch" : "mt-3.5 h-px flex-1",
55
- status === "complete" && "bg-primary",
55
+ status === "complete" && "bg-action",
56
56
  )}
57
57
  />
58
58
  )}
@@ -19,7 +19,7 @@ const Switch = React.forwardRef<
19
19
  "peer inline-flex h-6 w-12 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors",
20
20
  "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
21
21
  "disabled:cursor-not-allowed disabled:opacity-disabled",
22
- "data-[state=checked]:bg-primary data-[state=unchecked]:bg-tertiary",
22
+ "data-[state=checked]:bg-action data-[state=unchecked]:bg-tertiary",
23
23
  className,
24
24
  )}
25
25
  {...props}
@@ -35,7 +35,7 @@ const TabBarItem = React.forwardRef<HTMLButtonElement, TabBarItemProps>(
35
35
  aria-current={active ? "page" : undefined}
36
36
  className={cn(
37
37
  "relative flex flex-1 flex-col items-center justify-center gap-1 py-2 text-label-sm outline-none transition-colors",
38
- active ? "text-primary" : "text-muted-foreground hover:text-foreground",
38
+ active ? "text-action" : "text-muted-foreground hover:text-foreground",
39
39
  "focus-visible:bg-accent",
40
40
  className,
41
41
  )}
@@ -31,7 +31,7 @@ const TableOfContents = React.forwardRef<HTMLElement, TableOfContentsProps>(
31
31
  style={{ paddingInlineStart: `${((item.level ?? 1) - 1) * 12 + 12}px` }}
32
32
  className={cn(
33
33
  "-ms-px border-s py-1.5 text-body-sm outline-none transition-colors",
34
- isActive ? "border-primary font-medium text-primary" : "border-transparent text-muted-foreground hover:text-foreground",
34
+ isActive ? "border-action font-medium text-action" : "border-transparent text-muted-foreground hover:text-foreground",
35
35
  )}
36
36
  >
37
37
  {item.label}
@@ -17,7 +17,7 @@ const textareaVariants = cva(
17
17
  "font-sans text-body-md text-foreground",
18
18
  "placeholder:text-muted-foreground",
19
19
  "outline-none transition-colors",
20
- "focus-visible:border-primary focus-visible:shadow-focus",
20
+ "focus-visible:border-action focus-visible:shadow-focus",
21
21
  "disabled:cursor-not-allowed disabled:opacity-disabled",
22
22
  "aria-[invalid=true]:border-destructive aria-[invalid=true]:focus-visible:border-destructive aria-[invalid=true]:focus-visible:shadow-focus-destructive",
23
23
  ],
@@ -25,7 +25,7 @@ const textareaVariants = cva(
25
25
  variants: {
26
26
  state: {
27
27
  Default: "",
28
- Focus: "border-primary shadow-focus",
28
+ Focus: "border-action shadow-focus",
29
29
  Error: "border-destructive",
30
30
  Disabled: "opacity-disabled pointer-events-none resize-none",
31
31
  },
@@ -35,7 +35,7 @@ const Body = ({ item, align }: { item: TimelineItem; align: "left" | "right" })
35
35
 
36
36
  const Rail = ({ item, isLast }: { item: TimelineItem; isLast: boolean }) => (
37
37
  <div className="flex flex-col items-center">
38
- <span className="flex size-2.5 shrink-0 items-center justify-center rounded-full bg-primary">{item.icon}</span>
38
+ <span className="flex size-2.5 shrink-0 items-center justify-center rounded-full bg-action">{item.icon}</span>
39
39
  {!isLast && <span aria-hidden className="my-1 w-px flex-1 bg-border" />}
40
40
  </div>
41
41
  );
@@ -17,7 +17,7 @@ const TooltipContent = React.forwardRef<
17
17
  ref={ref}
18
18
  sideOffset={sideOffset}
19
19
  className={cn(
20
- "z-overlay overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground font-sans",
20
+ "z-overlay overflow-hidden rounded-md bg-action px-3 py-1.5 text-xs text-action-foreground font-sans",
21
21
  "animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
22
22
  "data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
23
23
  className,
@@ -63,7 +63,8 @@ function VirtualList<T>({
63
63
  <div
64
64
  ref={containerRef}
65
65
  role="list"
66
- className={cn("overflow-y-auto font-sans", className)}
66
+ tabIndex={0}
67
+ className={cn("overflow-y-auto font-sans outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring", className)}
67
68
  style={{ height, ...style }}
68
69
  onScroll={(e) => {
69
70
  onScroll?.(e);
@@ -10,37 +10,16 @@ import { afterEach, describe, expect, it } from "vitest";
10
10
  * association. jsdom has no layout or stylesheet, so `color-contrast` (covered
11
11
  * by `pnpm check:contrast`) and page-level rules are off.
12
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.
13
+ * KNOWN lists violations that are tolerated, keyed `story:rule`. It is empty: every story
14
+ * is clean. A new violation fails; if you must baseline one, a later fix fails until the
15
+ * entry is deleted, so the list can only shrink.
17
16
  */
18
17
  const stories = import.meta.glob("./stories/*.stories.tsx", { eager: true }) as Record<
19
18
  string,
20
19
  Record<string, unknown>
21
20
  >;
22
21
 
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
- ]);
22
+ const KNOWN = new Set<string>([]);
44
23
 
45
24
  afterEach(cleanup);
46
25
 
@@ -65,7 +65,7 @@ describe("Badge", () => {
65
65
  );
66
66
  const link = screen.getByRole("link", { name: "New" });
67
67
  expect(link).toHaveAttribute("href", "/new");
68
- expect(link).toHaveClass("bg-primary"); // badge classes merged onto the <a>
68
+ expect(link).toHaveClass("bg-action"); // badge classes merged onto the <a>
69
69
  });
70
70
  });
71
71