Fix a11y incomplete checks and glyph icons

This commit is contained in:
2026-03-20 14:11:12 +08:00
parent 91a0bac8dd
commit 6b160e1993
14 changed files with 213 additions and 29 deletions
+1 -1
View File
@@ -21,7 +21,7 @@ describe("Checkbox", () => {
expect(checkbox).toBeChecked();
expect(checkbox).toHaveAttribute("data-state", "checked");
expect(onCheckedChange).toHaveBeenCalledWith(true);
expect(screen.getByText("✓")).toHaveAttribute("data-slot", "icon");
expect(checkbox.querySelector('[data-slot="icon"] svg')).toBeInTheDocument();
});
it("supports keyboard interaction", async () => {
+2 -1
View File
@@ -4,6 +4,7 @@ import { forwardRef, type ComponentPropsWithoutRef, type ElementRef } from "reac
import { checkboxIndicatorVariants, checkboxVariants } from "./checkbox.variants";
import { cn } from "../lib/cn";
import { createDataAttributes, createSlot } from "../lib/contracts";
import { CheckIcon } from "../lib/icons";
export type CheckboxProps = ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root> & {
invalid?: boolean;
@@ -30,7 +31,7 @@ export const Checkbox = forwardRef<
{...createSlot("icon")}
className={checkboxIndicatorVariants()}
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
);
@@ -55,6 +55,7 @@ describe("Combobox", () => {
expect(trigger).toHaveTextContent("Design review");
expect(trigger).toHaveAttribute("data-slot", "trigger");
expect(trigger).not.toHaveAttribute("aria-controls");
await user.click(trigger);
@@ -65,6 +66,7 @@ describe("Combobox", () => {
const option = within(listbox).getByRole("option", { name: /Legal review/i });
expect(listbox).toHaveAttribute("data-slot", "list");
expect(trigger).toHaveAttribute("aria-controls", listbox.id);
expect(screen.getByText("Specialist lanes")).toHaveAttribute("data-slot", "label");
await user.click(option);
+7 -5
View File
@@ -22,6 +22,7 @@ import {
} from "./combobox.variants";
import { cn } from "../lib/cn";
import { createDataAttributes, createSlot } from "../lib/contracts";
import { CheckIcon, ChevronDownIcon } from "../lib/icons";
import { useFieldContext } from "./field";
function mergeIds(...ids: Array<string | undefined>) {
@@ -143,6 +144,7 @@ export const Combobox = forwardRef<HTMLButtonElement, ComboboxProps>(function Co
return haystack.includes(query);
});
}, [items, resolvedSearchValue]);
const listboxId = filteredItems.length > 0 ? `${controlId}-listbox` : undefined;
const groupedItems = useMemo(() => {
const groups = new Map<string, ComboboxItem[]>();
@@ -305,7 +307,7 @@ export const Combobox = forwardRef<HTMLButtonElement, ComboboxProps>(function Co
invalid: resolvedInvalid,
placeholder: selectedItem ? undefined : true
})}
aria-controls={`${controlId}-listbox`}
aria-controls={resolvedOpen ? listboxId : undefined}
aria-describedby={describedBy}
aria-expanded={resolvedOpen}
aria-invalid={resolvedInvalid || undefined}
@@ -326,7 +328,7 @@ export const Combobox = forwardRef<HTMLButtonElement, ComboboxProps>(function Co
aria-hidden="true"
className="shrink-0 text-[var(--color-muted-foreground)]"
>
<ChevronDownIcon className="size-3.5" />
</span>
</button>
</PopoverPrimitive.Trigger>
@@ -359,7 +361,7 @@ export const Combobox = forwardRef<HTMLButtonElement, ComboboxProps>(function Co
<div
{...createSlot("list")}
className={comboboxListVariants()}
id={`${controlId}-listbox`}
id={listboxId}
role="listbox"
>
{groupedItems.map(([group, groupItems]) => (
@@ -405,11 +407,11 @@ export const Combobox = forwardRef<HTMLButtonElement, ComboboxProps>(function Co
{...createSlot("icon")}
aria-hidden="true"
className={cn(
"mt-0.5 inline-flex size-4 shrink-0 items-center justify-center text-xs text-[var(--color-primary)]",
"mt-0.5 inline-flex size-4 shrink-0 items-center justify-center text-[var(--color-primary)]",
!isSelected && "opacity-0"
)}
>
<CheckIcon className="size-3.5" />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate">{item.label}</span>
+13 -2
View File
@@ -40,6 +40,11 @@ import { Spinner } from "./spinner";
import { cn } from "../lib/cn";
import type { VariantProps } from "../lib/cva";
import { createDataAttributes, createSlot } from "../lib/contracts";
import {
SortAscendingIcon,
SortDescendingIcon,
SortUnsortedIcon
} from "../lib/icons";
import {
dataTableBodyVariants,
dataTableCellVariants,
@@ -546,9 +551,15 @@ function DataTableInner<TData>(
<span>{flexRender(header.column.columnDef.header, header.getContext())}</span>
<span
aria-hidden="true"
className="text-xs text-[var(--color-muted-foreground)]"
className="inline-flex items-center justify-center text-[var(--color-muted-foreground)]"
>
{sortState === "asc" ? "↑" : sortState === "desc" ? "↓" : "↕"}
{sortState === "asc" ? (
<SortAscendingIcon className="size-3.5" />
) : sortState === "desc" ? (
<SortDescendingIcon className="size-3.5" />
) : (
<SortUnsortedIcon className="size-3.5" />
)}
</span>
<span className="sr-only">
Sort by{" "}
+2 -3
View File
@@ -5,6 +5,7 @@ import { dialogContentVariants, dialogFooterVariants, dialogHeaderVariants, dial
import { cn } from "../lib/cn";
import type { VariantProps } from "../lib/cva";
import { createDataAttributes, createSlot } from "../lib/contracts";
import { CloseIcon } from "../lib/icons";
export const Dialog = DialogPrimitive.Root;
export const DialogTrigger = DialogPrimitive.Trigger;
@@ -47,9 +48,7 @@ export const DialogContent = forwardRef<
aria-label="Close dialog"
className="absolute right-4 top-4 inline-flex size-9 items-center justify-center rounded-[var(--ui-control-radius)] border border-transparent text-[var(--color-muted-foreground)] outline-none transition-colors duration-[var(--dur-fast)] ease-[var(--ease-standard)] hover:border-[var(--ui-control-border)] hover:bg-[var(--ui-control-bg)] hover:text-[var(--color-foreground)] hover:shadow-[var(--ui-control-shadow)] focus-visible:ring-2 focus-visible:ring-[var(--color-ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--ui-panel-bg)]"
>
<span aria-hidden="true" className="text-lg leading-none">
×
</span>
<CloseIcon className="size-4" />
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
+10 -5
View File
@@ -15,6 +15,7 @@ import {
import { cn } from "../lib/cn";
import type { VariantProps } from "../lib/cva";
import { createDataAttributes, createSlot } from "../lib/contracts";
import { CheckIcon, ChevronRightIcon, DotIcon } from "../lib/icons";
export const DropdownMenu = DropdownMenuPrimitive.Root;
export const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
@@ -117,9 +118,11 @@ export const DropdownMenuCheckboxItem = forwardRef<
>
<span
{...createSlot("icon")}
className="absolute left-2.5 inline-flex size-4 items-center justify-center text-xs"
className="absolute left-2.5 inline-flex size-4 items-center justify-center text-[var(--color-primary)]"
>
<DropdownMenuPrimitive.ItemIndicator></DropdownMenuPrimitive.ItemIndicator>
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-3" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
@@ -147,9 +150,11 @@ export const DropdownMenuRadioItem = forwardRef<
>
<span
{...createSlot("icon")}
className="absolute left-2.5 inline-flex size-4 items-center justify-center text-xs"
className="absolute left-2.5 inline-flex size-4 items-center justify-center text-[var(--color-primary)]"
>
<DropdownMenuPrimitive.ItemIndicator></DropdownMenuPrimitive.ItemIndicator>
<DropdownMenuPrimitive.ItemIndicator>
<DotIcon className="size-2.5" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
@@ -209,7 +214,7 @@ export const DropdownMenuSubTrigger = forwardRef<
ref={ref}
>
{children}
<span className="ml-auto text-xs text-[var(--color-muted-foreground)]"></span>
<ChevronRightIcon className="ml-auto size-3 text-[var(--color-muted-foreground)]" />
</DropdownMenuPrimitive.SubTrigger>
);
});
+6 -3
View File
@@ -11,6 +11,7 @@ import {
} from "./select.variants";
import { cn } from "../lib/cn";
import { createDataAttributes, createSlot } from "../lib/contracts";
import { CheckIcon, ChevronDownIcon } from "../lib/icons";
import { useFieldContext } from "./field";
function mergeIds(...ids: Array<string | undefined>) {
@@ -57,7 +58,7 @@ export const SelectTrigger = forwardRef<
{...createSlot("icon")}
className="text-[var(--color-muted-foreground)]"
>
<ChevronDownIcon className="size-3.5" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
@@ -117,9 +118,11 @@ export const SelectItem = forwardRef<
>
<span
{...createSlot("icon")}
className="absolute left-2.5 inline-flex size-4 items-center justify-center text-xs"
className="absolute left-2.5 inline-flex size-4 items-center justify-center text-[var(--color-primary)]"
>
<SelectPrimitive.ItemIndicator></SelectPrimitive.ItemIndicator>
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-3" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
+2 -3
View File
@@ -10,6 +10,7 @@ import {
import { cn } from "../lib/cn";
import type { VariantProps } from "../lib/cva";
import { createDataAttributes, createSlot } from "../lib/contracts";
import { CloseIcon } from "../lib/icons";
export const Sheet = DialogPrimitive.Root;
export const SheetTrigger = DialogPrimitive.Trigger;
@@ -64,9 +65,7 @@ export const SheetContent = forwardRef<
aria-label="Close sheet"
className="absolute right-4 top-4 inline-flex size-9 items-center justify-center rounded-[var(--ui-control-radius)] border border-transparent text-[var(--color-muted-foreground)] outline-none transition-colors duration-[var(--dur-fast)] ease-[var(--ease-standard)] hover:border-[var(--ui-control-border)] hover:bg-[var(--ui-control-bg)] hover:text-[var(--color-foreground)] hover:shadow-[var(--ui-control-shadow)] focus-visible:ring-2 focus-visible:ring-[var(--color-ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--ui-panel-bg)]"
>
<span aria-hidden="true" className="text-lg leading-none">
×
</span>
<CloseIcon className="size-4" />
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</SheetPortal>
+2 -3
View File
@@ -5,6 +5,7 @@ import { toastActionVariants, toastCloseVariants, toastVariants, toastViewportVa
import { cn } from "../lib/cn";
import type { VariantProps } from "../lib/cva";
import { createDataAttributes, createSlot } from "../lib/contracts";
import { CloseIcon } from "../lib/icons";
export const ToastProvider = ToastPrimitive.Provider;
@@ -95,9 +96,7 @@ export const ToastClose = forwardRef<
ref={ref}
>
{children ?? (
<span aria-hidden="true" className="text-lg leading-none">
×
</span>
<CloseIcon className="size-4" />
)}
</ToastPrimitive.Close>
);
+133
View File
@@ -0,0 +1,133 @@
import type { ComponentPropsWithoutRef, ReactNode } from "react";
import { cn } from "./cn";
type IconProps = ComponentPropsWithoutRef<"svg">;
function IconFrame({
children,
className,
viewBox = "0 0 16 16",
...props
}: IconProps & {
children: ReactNode;
viewBox?: string;
}) {
return (
<svg
aria-hidden="true"
className={cn("size-4 shrink-0", className)}
fill="none"
viewBox={viewBox}
{...props}
>
{children}
</svg>
);
}
export function CheckIcon({ className, ...props }: IconProps) {
return (
<IconFrame className={className} {...props}>
<path
d="M3.5 8.5 6.5 11.5 12.5 5.5"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.75"
/>
</IconFrame>
);
}
export function ChevronDownIcon({ className, ...props }: IconProps) {
return (
<IconFrame className={className} {...props}>
<path
d="m4.5 6.5 3.5 3 3.5-3"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.75"
/>
</IconFrame>
);
}
export function ChevronRightIcon({ className, ...props }: IconProps) {
return (
<IconFrame className={className} {...props}>
<path
d="m6 4.5 3.5 3.5L6 11.5"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.75"
/>
</IconFrame>
);
}
export function CloseIcon({ className, ...props }: IconProps) {
return (
<IconFrame className={className} {...props}>
<path
d="m4.5 4.5 7 7m0-7-7 7"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.75"
/>
</IconFrame>
);
}
export function DotIcon({ className, ...props }: IconProps) {
return (
<IconFrame className={className} viewBox="0 0 8 8" {...props}>
<circle cx="4" cy="4" fill="currentColor" r="2.25" />
</IconFrame>
);
}
export function SortAscendingIcon({ className, ...props }: IconProps) {
return (
<IconFrame className={className} {...props}>
<path
d="M8 12.5v-9m0 0L5.5 6m2.5-2.5L10.5 6"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
/>
</IconFrame>
);
}
export function SortDescendingIcon({ className, ...props }: IconProps) {
return (
<IconFrame className={className} {...props}>
<path
d="M8 3.5v9m0 0-2.5-2.5M8 12.5l2.5-2.5"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
/>
</IconFrame>
);
}
export function SortUnsortedIcon({ className, ...props }: IconProps) {
return (
<IconFrame className={className} {...props}>
<path
d="m5 6 3-3 3 3M11 10l-3 3-3-3"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
/>
</IconFrame>
);
}