@kinetixui/ui 0.17.0 → 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.
- package/dist/index.js +377 -237
- package/package.json +2 -2
- package/src/components/audio-player.tsx +2 -2
- package/src/components/badge.tsx +1 -1
- package/src/components/banner.tsx +1 -1
- package/src/components/button.tsx +8 -8
- package/src/components/calendar.tsx +1 -1
- package/src/components/chart.tsx +2 -2
- package/src/components/checkbox.tsx +2 -2
- package/src/components/circular-progress.tsx +1 -1
- package/src/components/code-block.tsx +1 -1
- package/src/components/color-picker.tsx +4 -0
- package/src/components/data-grid.tsx +169 -25
- package/src/components/diff-viewer.tsx +9 -8
- package/src/components/fab.tsx +1 -1
- package/src/components/file-upload.tsx +13 -15
- package/src/components/input-group.tsx +1 -1
- package/src/components/input-otp.tsx +2 -1
- package/src/components/input.tsx +2 -2
- package/src/components/json-viewer.tsx +12 -10
- package/src/components/list.tsx +41 -27
- package/src/components/markdown-editor.tsx +3 -2
- package/src/components/message-bubble.tsx +3 -3
- package/src/components/multi-select.tsx +14 -3
- package/src/components/native-select.tsx +2 -2
- package/src/components/notification-center.tsx +2 -2
- package/src/components/progress.tsx +1 -1
- package/src/components/radio-group.tsx +3 -3
- package/src/components/scroll-area.tsx +1 -1
- package/src/components/select.tsx +2 -2
- package/src/components/skeleton.tsx +1 -1
- package/src/components/slider.tsx +2 -2
- package/src/components/sonner.tsx +1 -1
- package/src/components/spinner.tsx +3 -3
- package/src/components/stepper.tsx +3 -3
- package/src/components/switch.tsx +1 -1
- package/src/components/tab-bar.tsx +1 -1
- package/src/components/table-of-contents.tsx +1 -1
- package/src/components/textarea.tsx +2 -2
- package/src/components/timeline.tsx +1 -1
- package/src/components/tooltip.tsx +1 -1
- package/src/components/virtual-list.tsx +2 -1
- package/src/components-a11y.test.tsx +4 -25
- package/src/components-behavior.test.tsx +1 -1
- package/src/components-keyboard.test.tsx +236 -33
- package/src/stories/AppBar.stories.tsx +1 -1
- package/src/stories/Checkbox.stories.tsx +1 -1
- package/src/stories/CircularProgress.stories.tsx +4 -4
- package/src/stories/InputOTP.stories.tsx +2 -2
- package/src/stories/List.stories.tsx +2 -2
- package/src/stories/MarkdownEditor.stories.tsx +1 -1
- package/src/stories/MultiSelect.stories.tsx +1 -0
- package/src/stories/NativeSelect.stories.tsx +1 -1
- package/src/stories/NumberInput.stories.tsx +3 -3
- package/src/stories/Progress.stories.tsx +3 -3
- package/src/stories/Select.stories.tsx +2 -2
- package/src/stories/Slider.stories.tsx +3 -3
- package/src/stories/Switch.stories.tsx +1 -1
- package/src/stories/ToggleGroup.stories.tsx +6 -6
- package/tailwind.config.ts +14 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kinetixui/ui",
|
|
3
|
-
"version": "0.
|
|
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.
|
|
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",
|
|
@@ -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-
|
|
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-
|
|
108
|
+
<div className="h-full bg-action transition-[width]" style={{ width: `${pct}%` }} />
|
|
109
109
|
</div>
|
|
110
110
|
</div>
|
|
111
111
|
);
|
package/src/components/badge.tsx
CHANGED
|
@@ -19,7 +19,7 @@ const badgeVariants = cva(
|
|
|
19
19
|
{
|
|
20
20
|
variants: {
|
|
21
21
|
variant: {
|
|
22
|
-
default: "bg-
|
|
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}
|
|
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-
|
|
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-
|
|
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-
|
|
61
|
-
Link: "bg-transparent text-
|
|
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-
|
|
91
|
-
{ variant: "Primary", state: "Active", class: "bg-
|
|
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-
|
|
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-
|
|
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-
|
|
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",
|
package/src/components/chart.tsx
CHANGED
|
@@ -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-
|
|
25
|
-
"data-[state=indeterminate]:border-
|
|
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-
|
|
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-
|
|
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}
|
|
@@ -193,17 +193,161 @@ function DataGrid<TData>({
|
|
|
193
193
|
endEdit(restoreFocus);
|
|
194
194
|
}
|
|
195
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
|
+
|
|
196
337
|
return (
|
|
338
|
+
<>
|
|
197
339
|
<div
|
|
198
340
|
ref={containerRef}
|
|
199
341
|
role="grid"
|
|
342
|
+
aria-rowcount={rowCount + 1}
|
|
343
|
+
aria-colcount={colIds.length}
|
|
200
344
|
className={cn("relative overflow-auto rounded-md border font-sans text-sm", className)}
|
|
201
345
|
style={{ height }}
|
|
202
346
|
onScroll={(e) => setScrollTop(e.currentTarget.scrollTop)}
|
|
203
347
|
>
|
|
204
348
|
<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) => {
|
|
349
|
+
<div role="row" aria-rowindex={1} className="sticky top-0 z-[2] flex border-b bg-background">
|
|
350
|
+
{renderedColumns.map((column, colIdx) => {
|
|
207
351
|
const width = widths[column.id] ?? column.width ?? DEFAULT_WIDTH;
|
|
208
352
|
const pinnedStyle: React.CSSProperties =
|
|
209
353
|
column.pinned === "left"
|
|
@@ -216,6 +360,9 @@ function DataGrid<TData>({
|
|
|
216
360
|
<div
|
|
217
361
|
key={column.id}
|
|
218
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"}`}
|
|
219
366
|
{...(column.sortable
|
|
220
367
|
? { "aria-sort": isSorted ? (sort!.direction === "asc" ? "ascending" : "descending") : "none" }
|
|
221
368
|
: {})}
|
|
@@ -239,18 +386,13 @@ function DataGrid<TData>({
|
|
|
239
386
|
});
|
|
240
387
|
}}
|
|
241
388
|
onClick={() => column.sortable && toggleSort(column.id)}
|
|
242
|
-
//
|
|
243
|
-
tabIndex={column.
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
e.preventDefault();
|
|
247
|
-
toggleSort(column.id);
|
|
248
|
-
}
|
|
249
|
-
}}
|
|
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)}
|
|
250
393
|
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",
|
|
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",
|
|
395
|
+
column.sortable && "cursor-pointer hover:text-foreground",
|
|
254
396
|
column.pinned && "bg-background",
|
|
255
397
|
)}
|
|
256
398
|
style={{ width, ...pinnedStyle }}
|
|
@@ -266,7 +408,7 @@ function DataGrid<TData>({
|
|
|
266
408
|
e.stopPropagation();
|
|
267
409
|
beginResize(column.id, column.minWidth ?? DEFAULT_MIN_WIDTH, e.clientX, width);
|
|
268
410
|
}}
|
|
269
|
-
className="absolute right-0 top-0 h-full w-1 touch-none cursor-col-resize hover:bg-
|
|
411
|
+
className="absolute right-0 top-0 h-full w-1 touch-none cursor-col-resize hover:bg-action/50"
|
|
270
412
|
/>
|
|
271
413
|
</div>
|
|
272
414
|
);
|
|
@@ -279,10 +421,11 @@ function DataGrid<TData>({
|
|
|
279
421
|
<div
|
|
280
422
|
key={getRowId ? getRowId(row, rowIndex) : rowIndex}
|
|
281
423
|
role="row"
|
|
424
|
+
aria-rowindex={rowIndex + 2}
|
|
282
425
|
className="absolute flex w-full border-b hover:bg-muted/50"
|
|
283
426
|
style={{ top: rowIndex * rowHeight, height: rowHeight }}
|
|
284
427
|
>
|
|
285
|
-
{renderedColumns.map((column) => {
|
|
428
|
+
{renderedColumns.map((column, colIdx) => {
|
|
286
429
|
const width = widths[column.id] ?? column.width ?? DEFAULT_WIDTH;
|
|
287
430
|
const pinnedStyle: React.CSSProperties =
|
|
288
431
|
column.pinned === "left"
|
|
@@ -295,19 +438,15 @@ function DataGrid<TData>({
|
|
|
295
438
|
<div
|
|
296
439
|
key={column.id}
|
|
297
440
|
role="gridcell"
|
|
441
|
+
aria-colindex={colIdx + 1}
|
|
298
442
|
data-cell={`${rowIndex}:${column.id}`}
|
|
299
443
|
onDoubleClick={() => column.editable && setEditing({ rowIndex, columnId: column.id })}
|
|
300
|
-
//
|
|
301
|
-
tabIndex={column.
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
e.preventDefault();
|
|
305
|
-
setEditing({ rowIndex, columnId: column.id });
|
|
306
|
-
}
|
|
307
|
-
}}
|
|
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)}
|
|
308
448
|
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",
|
|
449
|
+
"flex shrink-0 items-center px-2 outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring",
|
|
311
450
|
column.pinned && "bg-background",
|
|
312
451
|
)}
|
|
313
452
|
style={{ width, ...pinnedStyle }}
|
|
@@ -335,6 +474,11 @@ function DataGrid<TData>({
|
|
|
335
474
|
</div>
|
|
336
475
|
</div>
|
|
337
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
|
+
</>
|
|
338
482
|
);
|
|
339
483
|
}
|
|
340
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>
|
package/src/components/fab.tsx
CHANGED
|
@@ -19,7 +19,7 @@ const fabVariants = cva(
|
|
|
19
19
|
{
|
|
20
20
|
variants: {
|
|
21
21
|
variant: {
|
|
22
|
-
Primary: "bg-
|
|
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
|
},
|
|
@@ -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
|
|
85
|
-
!disabled && "cursor-pointer hover:bg-accent/50
|
|
86
|
-
dragging && "border-
|
|
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-
|
|
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-
|
|
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-
|
|
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
|
-
|
|
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}
|
package/src/components/input.tsx
CHANGED
|
@@ -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-
|
|
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-
|
|
36
|
+
Focus: "border-action shadow-focus",
|
|
37
37
|
Error: "border-destructive",
|
|
38
38
|
Disabled: "opacity-disabled pointer-events-none",
|
|
39
39
|
},
|