@kinetixui/ui 0.16.0 → 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.
- package/dist/index.d.ts +4 -4
- package/dist/index.js +146 -36
- package/package.json +3 -2
- package/src/components/badge.tsx +1 -1
- package/src/components/banner.tsx +1 -1
- package/src/components/button.tsx +12 -8
- package/src/components/color-picker.tsx +2 -4
- package/src/components/data-grid.tsx +44 -7
- package/src/components/inform.tsx +1 -1
- package/src/components/input.tsx +1 -1
- package/src/components/kbd.tsx +1 -1
- package/src/components/modal.tsx +2 -2
- package/src/components/multi-select.tsx +14 -2
- package/src/components/native-select.tsx +1 -1
- package/src/components/select.tsx +1 -1
- package/src/components/slider.tsx +7 -2
- package/src/components/tag.tsx +1 -1
- package/src/components/textarea.tsx +1 -1
- package/src/components/tour.tsx +45 -2
- package/src/components-a11y.test.tsx +90 -0
- package/src/components-keyboard.test.tsx +571 -0
- package/src/lib/utils.test.ts +12 -0
- package/src/lib/utils.ts +22 -1
- package/src/stories/AppBar.stories.tsx +1 -1
- package/tailwind.config.ts +6 -1
|
@@ -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
|
|
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
|
-
|
|
177
|
-
|
|
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 &&
|
|
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
|
-
|
|
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")
|
|
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",
|
package/src/components/input.tsx
CHANGED
|
@@ -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-
|
|
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",
|
package/src/components/kbd.tsx
CHANGED
|
@@ -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-
|
|
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}
|
package/src/components/modal.tsx
CHANGED
|
@@ -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-
|
|
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-
|
|
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={
|
|
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
|
-
|
|
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-
|
|
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-
|
|
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
|
-
|
|
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;
|
package/src/components/tag.tsx
CHANGED
|
@@ -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-
|
|
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-
|
|
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",
|
package/src/components/tour.tsx
CHANGED
|
@@ -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
|
-
|
|
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">
|
|
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
|
+
});
|