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
+14 -1
View File
@@ -1,6 +1,19 @@
import { Button } from "@ai-ui/ui"; import { Button } from "@ai-ui/ui";
import type { Meta, StoryObj } from "@storybook/react"; import type { Meta, StoryObj } from "@storybook/react";
function FavoriteIcon() {
return (
<svg aria-hidden="true" className="size-4" fill="none" viewBox="0 0 16 16">
<path
d="m8 2.4 1.67 3.4 3.76.55-2.72 2.64.64 3.73L8 10.98l-3.35 1.74.64-3.73L2.57 6.35l3.76-.55L8 2.4Z"
stroke="currentColor"
strokeLinejoin="round"
strokeWidth="1.35"
/>
</svg>
);
}
function getButtonFromCanvas(canvasElement: HTMLElement, name: string) { function getButtonFromCanvas(canvasElement: HTMLElement, name: string) {
const buttons = canvasElement.querySelectorAll("button, a"); const buttons = canvasElement.querySelectorAll("button, a");
@@ -104,7 +117,7 @@ export const Sizes: Story = {
<Button size="md">Medium</Button> <Button size="md">Medium</Button>
<Button size="lg">Large</Button> <Button size="lg">Large</Button>
<Button aria-label="Favorite" size="icon"> <Button aria-label="Favorite" size="icon">
<FavoriteIcon />
</Button> </Button>
</div> </div>
) )
@@ -5,6 +5,7 @@ const meta = {
title: "Components/Checkbox", title: "Components/Checkbox",
component: Checkbox, component: Checkbox,
args: { args: {
"aria-label": "Checkbox example",
defaultChecked: true defaultChecked: true
}, },
argTypes: { argTypes: {
@@ -31,7 +32,9 @@ export default meta;
type Story = StoryObj<typeof meta>; type Story = StoryObj<typeof meta>;
export const Playground: Story = {}; export const Playground: Story = {
render: (args) => <Checkbox {...args} aria-label="Checkbox example" />
};
export const States: Story = { export const States: Story = {
render: () => ( render: () => (
+15 -1
View File
@@ -39,6 +39,20 @@ const motionAccessibilityModes = [
type MotionAccessibilityMode = (typeof motionAccessibilityModes)[number]["value"]; type MotionAccessibilityMode = (typeof motionAccessibilityModes)[number]["value"];
function ClosePreviewIcon() {
return (
<svg aria-hidden="true" className="size-4" fill="none" viewBox="0 0 16 16">
<path
d="m4.5 4.5 7 7m0-7-7 7"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.75"
/>
</svg>
);
}
function RuntimePill({ children }: { children: React.ReactNode }) { function RuntimePill({ children }: { children: React.ReactNode }) {
return ( return (
<span className="rounded-full border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-1 text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]"> <span className="rounded-full border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-1 text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
@@ -71,7 +85,7 @@ function PanelPreview() {
</p> </p>
</div> </div>
<Button aria-hidden="true" size="icon" tabIndex={-1} variant="ghost"> <Button aria-hidden="true" size="icon" tabIndex={-1} variant="ghost">
× <ClosePreviewIcon />
</Button> </Button>
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
+1 -1
View File
@@ -21,7 +21,7 @@ describe("Checkbox", () => {
expect(checkbox).toBeChecked(); expect(checkbox).toBeChecked();
expect(checkbox).toHaveAttribute("data-state", "checked"); expect(checkbox).toHaveAttribute("data-state", "checked");
expect(onCheckedChange).toHaveBeenCalledWith(true); expect(onCheckedChange).toHaveBeenCalledWith(true);
expect(screen.getByText("✓")).toHaveAttribute("data-slot", "icon"); expect(checkbox.querySelector('[data-slot="icon"] svg')).toBeInTheDocument();
}); });
it("supports keyboard interaction", async () => { 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 { checkboxIndicatorVariants, checkboxVariants } from "./checkbox.variants";
import { cn } from "../lib/cn"; import { cn } from "../lib/cn";
import { createDataAttributes, createSlot } from "../lib/contracts"; import { createDataAttributes, createSlot } from "../lib/contracts";
import { CheckIcon } from "../lib/icons";
export type CheckboxProps = ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root> & { export type CheckboxProps = ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root> & {
invalid?: boolean; invalid?: boolean;
@@ -30,7 +31,7 @@ export const Checkbox = forwardRef<
{...createSlot("icon")} {...createSlot("icon")}
className={checkboxIndicatorVariants()} className={checkboxIndicatorVariants()}
> >
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator> </CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root> </CheckboxPrimitive.Root>
); );
@@ -55,6 +55,7 @@ describe("Combobox", () => {
expect(trigger).toHaveTextContent("Design review"); expect(trigger).toHaveTextContent("Design review");
expect(trigger).toHaveAttribute("data-slot", "trigger"); expect(trigger).toHaveAttribute("data-slot", "trigger");
expect(trigger).not.toHaveAttribute("aria-controls");
await user.click(trigger); await user.click(trigger);
@@ -65,6 +66,7 @@ describe("Combobox", () => {
const option = within(listbox).getByRole("option", { name: /Legal review/i }); const option = within(listbox).getByRole("option", { name: /Legal review/i });
expect(listbox).toHaveAttribute("data-slot", "list"); expect(listbox).toHaveAttribute("data-slot", "list");
expect(trigger).toHaveAttribute("aria-controls", listbox.id);
expect(screen.getByText("Specialist lanes")).toHaveAttribute("data-slot", "label"); expect(screen.getByText("Specialist lanes")).toHaveAttribute("data-slot", "label");
await user.click(option); await user.click(option);
+7 -5
View File
@@ -22,6 +22,7 @@ import {
} from "./combobox.variants"; } from "./combobox.variants";
import { cn } from "../lib/cn"; import { cn } from "../lib/cn";
import { createDataAttributes, createSlot } from "../lib/contracts"; import { createDataAttributes, createSlot } from "../lib/contracts";
import { CheckIcon, ChevronDownIcon } from "../lib/icons";
import { useFieldContext } from "./field"; import { useFieldContext } from "./field";
function mergeIds(...ids: Array<string | undefined>) { function mergeIds(...ids: Array<string | undefined>) {
@@ -143,6 +144,7 @@ export const Combobox = forwardRef<HTMLButtonElement, ComboboxProps>(function Co
return haystack.includes(query); return haystack.includes(query);
}); });
}, [items, resolvedSearchValue]); }, [items, resolvedSearchValue]);
const listboxId = filteredItems.length > 0 ? `${controlId}-listbox` : undefined;
const groupedItems = useMemo(() => { const groupedItems = useMemo(() => {
const groups = new Map<string, ComboboxItem[]>(); const groups = new Map<string, ComboboxItem[]>();
@@ -305,7 +307,7 @@ export const Combobox = forwardRef<HTMLButtonElement, ComboboxProps>(function Co
invalid: resolvedInvalid, invalid: resolvedInvalid,
placeholder: selectedItem ? undefined : true placeholder: selectedItem ? undefined : true
})} })}
aria-controls={`${controlId}-listbox`} aria-controls={resolvedOpen ? listboxId : undefined}
aria-describedby={describedBy} aria-describedby={describedBy}
aria-expanded={resolvedOpen} aria-expanded={resolvedOpen}
aria-invalid={resolvedInvalid || undefined} aria-invalid={resolvedInvalid || undefined}
@@ -326,7 +328,7 @@ export const Combobox = forwardRef<HTMLButtonElement, ComboboxProps>(function Co
aria-hidden="true" aria-hidden="true"
className="shrink-0 text-[var(--color-muted-foreground)]" className="shrink-0 text-[var(--color-muted-foreground)]"
> >
<ChevronDownIcon className="size-3.5" />
</span> </span>
</button> </button>
</PopoverPrimitive.Trigger> </PopoverPrimitive.Trigger>
@@ -359,7 +361,7 @@ export const Combobox = forwardRef<HTMLButtonElement, ComboboxProps>(function Co
<div <div
{...createSlot("list")} {...createSlot("list")}
className={comboboxListVariants()} className={comboboxListVariants()}
id={`${controlId}-listbox`} id={listboxId}
role="listbox" role="listbox"
> >
{groupedItems.map(([group, groupItems]) => ( {groupedItems.map(([group, groupItems]) => (
@@ -405,11 +407,11 @@ export const Combobox = forwardRef<HTMLButtonElement, ComboboxProps>(function Co
{...createSlot("icon")} {...createSlot("icon")}
aria-hidden="true" aria-hidden="true"
className={cn( 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" !isSelected && "opacity-0"
)} )}
> >
<CheckIcon className="size-3.5" />
</span> </span>
<span className="min-w-0 flex-1"> <span className="min-w-0 flex-1">
<span className="block truncate">{item.label}</span> <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 { cn } from "../lib/cn";
import type { VariantProps } from "../lib/cva"; import type { VariantProps } from "../lib/cva";
import { createDataAttributes, createSlot } from "../lib/contracts"; import { createDataAttributes, createSlot } from "../lib/contracts";
import {
SortAscendingIcon,
SortDescendingIcon,
SortUnsortedIcon
} from "../lib/icons";
import { import {
dataTableBodyVariants, dataTableBodyVariants,
dataTableCellVariants, dataTableCellVariants,
@@ -546,9 +551,15 @@ function DataTableInner<TData>(
<span>{flexRender(header.column.columnDef.header, header.getContext())}</span> <span>{flexRender(header.column.columnDef.header, header.getContext())}</span>
<span <span
aria-hidden="true" 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>
<span className="sr-only"> <span className="sr-only">
Sort by{" "} Sort by{" "}
+2 -3
View File
@@ -5,6 +5,7 @@ import { dialogContentVariants, dialogFooterVariants, dialogHeaderVariants, dial
import { cn } from "../lib/cn"; import { cn } from "../lib/cn";
import type { VariantProps } from "../lib/cva"; import type { VariantProps } from "../lib/cva";
import { createDataAttributes, createSlot } from "../lib/contracts"; import { createDataAttributes, createSlot } from "../lib/contracts";
import { CloseIcon } from "../lib/icons";
export const Dialog = DialogPrimitive.Root; export const Dialog = DialogPrimitive.Root;
export const DialogTrigger = DialogPrimitive.Trigger; export const DialogTrigger = DialogPrimitive.Trigger;
@@ -47,9 +48,7 @@ export const DialogContent = forwardRef<
aria-label="Close dialog" 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)]" 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"> <CloseIcon className="size-4" />
×
</span>
</DialogPrimitive.Close> </DialogPrimitive.Close>
</DialogPrimitive.Content> </DialogPrimitive.Content>
</DialogPortal> </DialogPortal>
+10 -5
View File
@@ -15,6 +15,7 @@ import {
import { cn } from "../lib/cn"; import { cn } from "../lib/cn";
import type { VariantProps } from "../lib/cva"; import type { VariantProps } from "../lib/cva";
import { createDataAttributes, createSlot } from "../lib/contracts"; import { createDataAttributes, createSlot } from "../lib/contracts";
import { CheckIcon, ChevronRightIcon, DotIcon } from "../lib/icons";
export const DropdownMenu = DropdownMenuPrimitive.Root; export const DropdownMenu = DropdownMenuPrimitive.Root;
export const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; export const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
@@ -117,9 +118,11 @@ export const DropdownMenuCheckboxItem = forwardRef<
> >
<span <span
{...createSlot("icon")} {...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> </span>
{children} {children}
</DropdownMenuPrimitive.CheckboxItem> </DropdownMenuPrimitive.CheckboxItem>
@@ -147,9 +150,11 @@ export const DropdownMenuRadioItem = forwardRef<
> >
<span <span
{...createSlot("icon")} {...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> </span>
{children} {children}
</DropdownMenuPrimitive.RadioItem> </DropdownMenuPrimitive.RadioItem>
@@ -209,7 +214,7 @@ export const DropdownMenuSubTrigger = forwardRef<
ref={ref} ref={ref}
> >
{children} {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> </DropdownMenuPrimitive.SubTrigger>
); );
}); });
+6 -3
View File
@@ -11,6 +11,7 @@ import {
} from "./select.variants"; } from "./select.variants";
import { cn } from "../lib/cn"; import { cn } from "../lib/cn";
import { createDataAttributes, createSlot } from "../lib/contracts"; import { createDataAttributes, createSlot } from "../lib/contracts";
import { CheckIcon, ChevronDownIcon } from "../lib/icons";
import { useFieldContext } from "./field"; import { useFieldContext } from "./field";
function mergeIds(...ids: Array<string | undefined>) { function mergeIds(...ids: Array<string | undefined>) {
@@ -57,7 +58,7 @@ export const SelectTrigger = forwardRef<
{...createSlot("icon")} {...createSlot("icon")}
className="text-[var(--color-muted-foreground)]" className="text-[var(--color-muted-foreground)]"
> >
<ChevronDownIcon className="size-3.5" />
</SelectPrimitive.Icon> </SelectPrimitive.Icon>
</SelectPrimitive.Trigger> </SelectPrimitive.Trigger>
); );
@@ -117,9 +118,11 @@ export const SelectItem = forwardRef<
> >
<span <span
{...createSlot("icon")} {...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> </span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText> <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item> </SelectPrimitive.Item>
+2 -3
View File
@@ -10,6 +10,7 @@ import {
import { cn } from "../lib/cn"; import { cn } from "../lib/cn";
import type { VariantProps } from "../lib/cva"; import type { VariantProps } from "../lib/cva";
import { createDataAttributes, createSlot } from "../lib/contracts"; import { createDataAttributes, createSlot } from "../lib/contracts";
import { CloseIcon } from "../lib/icons";
export const Sheet = DialogPrimitive.Root; export const Sheet = DialogPrimitive.Root;
export const SheetTrigger = DialogPrimitive.Trigger; export const SheetTrigger = DialogPrimitive.Trigger;
@@ -64,9 +65,7 @@ export const SheetContent = forwardRef<
aria-label="Close sheet" 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)]" 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"> <CloseIcon className="size-4" />
×
</span>
</DialogPrimitive.Close> </DialogPrimitive.Close>
</DialogPrimitive.Content> </DialogPrimitive.Content>
</SheetPortal> </SheetPortal>
+2 -3
View File
@@ -5,6 +5,7 @@ import { toastActionVariants, toastCloseVariants, toastVariants, toastViewportVa
import { cn } from "../lib/cn"; import { cn } from "../lib/cn";
import type { VariantProps } from "../lib/cva"; import type { VariantProps } from "../lib/cva";
import { createDataAttributes, createSlot } from "../lib/contracts"; import { createDataAttributes, createSlot } from "../lib/contracts";
import { CloseIcon } from "../lib/icons";
export const ToastProvider = ToastPrimitive.Provider; export const ToastProvider = ToastPrimitive.Provider;
@@ -95,9 +96,7 @@ export const ToastClose = forwardRef<
ref={ref} ref={ref}
> >
{children ?? ( {children ?? (
<span aria-hidden="true" className="text-lg leading-none"> <CloseIcon className="size-4" />
×
</span>
)} )}
</ToastPrimitive.Close> </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>
);
}