From 6b160e199390c1a67c561f26ad86d8d4dc9249b1 Mon Sep 17 00:00:00 2001 From: kurihada Date: Fri, 20 Mar 2026 14:11:12 +0800 Subject: [PATCH] Fix a11y incomplete checks and glyph icons --- apps/docs/src/components/button.stories.tsx | 15 +- apps/docs/src/components/checkbox.stories.tsx | 5 +- apps/docs/src/style-matrix.stories.tsx | 16 ++- packages/ui/src/components/checkbox.test.tsx | 2 +- packages/ui/src/components/checkbox.tsx | 3 +- packages/ui/src/components/combobox.test.tsx | 2 + packages/ui/src/components/combobox.tsx | 12 +- packages/ui/src/components/data-table.tsx | 15 +- packages/ui/src/components/dialog.tsx | 5 +- packages/ui/src/components/dropdown-menu.tsx | 15 +- packages/ui/src/components/select.tsx | 9 +- packages/ui/src/components/sheet.tsx | 5 +- packages/ui/src/components/toast.tsx | 5 +- packages/ui/src/lib/icons.tsx | 133 ++++++++++++++++++ 14 files changed, 213 insertions(+), 29 deletions(-) create mode 100644 packages/ui/src/lib/icons.tsx diff --git a/apps/docs/src/components/button.stories.tsx b/apps/docs/src/components/button.stories.tsx index fb20f9a..ab7aa65 100644 --- a/apps/docs/src/components/button.stories.tsx +++ b/apps/docs/src/components/button.stories.tsx @@ -1,6 +1,19 @@ import { Button } from "@ai-ui/ui"; import type { Meta, StoryObj } from "@storybook/react"; +function FavoriteIcon() { + return ( + + ); +} + function getButtonFromCanvas(canvasElement: HTMLElement, name: string) { const buttons = canvasElement.querySelectorAll("button, a"); @@ -104,7 +117,7 @@ export const Sizes: Story = { ) diff --git a/apps/docs/src/components/checkbox.stories.tsx b/apps/docs/src/components/checkbox.stories.tsx index c9a4f65..d5b5e29 100644 --- a/apps/docs/src/components/checkbox.stories.tsx +++ b/apps/docs/src/components/checkbox.stories.tsx @@ -5,6 +5,7 @@ const meta = { title: "Components/Checkbox", component: Checkbox, args: { + "aria-label": "Checkbox example", defaultChecked: true }, argTypes: { @@ -31,7 +32,9 @@ export default meta; type Story = StoryObj; -export const Playground: Story = {}; +export const Playground: Story = { + render: (args) => +}; export const States: Story = { render: () => ( diff --git a/apps/docs/src/style-matrix.stories.tsx b/apps/docs/src/style-matrix.stories.tsx index 71c99fb..7975192 100644 --- a/apps/docs/src/style-matrix.stories.tsx +++ b/apps/docs/src/style-matrix.stories.tsx @@ -39,6 +39,20 @@ const motionAccessibilityModes = [ type MotionAccessibilityMode = (typeof motionAccessibilityModes)[number]["value"]; +function ClosePreviewIcon() { + return ( + + ); +} + function RuntimePill({ children }: { children: React.ReactNode }) { return ( @@ -71,7 +85,7 @@ function PanelPreview() {

diff --git a/packages/ui/src/components/checkbox.test.tsx b/packages/ui/src/components/checkbox.test.tsx index 74a62a1..98b8448 100644 --- a/packages/ui/src/components/checkbox.test.tsx +++ b/packages/ui/src/components/checkbox.test.tsx @@ -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 () => { diff --git a/packages/ui/src/components/checkbox.tsx b/packages/ui/src/components/checkbox.tsx index 763890f..67fc525 100644 --- a/packages/ui/src/components/checkbox.tsx +++ b/packages/ui/src/components/checkbox.tsx @@ -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 & { invalid?: boolean; @@ -30,7 +31,7 @@ export const Checkbox = forwardRef< {...createSlot("icon")} className={checkboxIndicatorVariants()} > - ✓ + ); diff --git a/packages/ui/src/components/combobox.test.tsx b/packages/ui/src/components/combobox.test.tsx index 64480ad..16099d7 100644 --- a/packages/ui/src/components/combobox.test.tsx +++ b/packages/ui/src/components/combobox.test.tsx @@ -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); diff --git a/packages/ui/src/components/combobox.tsx b/packages/ui/src/components/combobox.tsx index cfe17a3..0016c07 100644 --- a/packages/ui/src/components/combobox.tsx +++ b/packages/ui/src/components/combobox.tsx @@ -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) { @@ -143,6 +144,7 @@ export const Combobox = forwardRef(function Co return haystack.includes(query); }); }, [items, resolvedSearchValue]); + const listboxId = filteredItems.length > 0 ? `${controlId}-listbox` : undefined; const groupedItems = useMemo(() => { const groups = new Map(); @@ -305,7 +307,7 @@ export const Combobox = forwardRef(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(function Co aria-hidden="true" className="shrink-0 text-[var(--color-muted-foreground)]" > - ▾ + @@ -359,7 +361,7 @@ export const Combobox = forwardRef(function Co
{groupedItems.map(([group, groupItems]) => ( @@ -405,11 +407,11 @@ export const Combobox = forwardRef(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" )} > - ✓ + {item.label} diff --git a/packages/ui/src/components/data-table.tsx b/packages/ui/src/components/data-table.tsx index c3e63b7..1ede175 100644 --- a/packages/ui/src/components/data-table.tsx +++ b/packages/ui/src/components/data-table.tsx @@ -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( {flexRender(header.column.columnDef.header, header.getContext())} Sort by{" "} diff --git a/packages/ui/src/components/dialog.tsx b/packages/ui/src/components/dialog.tsx index e9a82c3..49ca0f5 100644 --- a/packages/ui/src/components/dialog.tsx +++ b/packages/ui/src/components/dialog.tsx @@ -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)]" > - + diff --git a/packages/ui/src/components/dropdown-menu.tsx b/packages/ui/src/components/dropdown-menu.tsx index 604e913..fe07a59 100644 --- a/packages/ui/src/components/dropdown-menu.tsx +++ b/packages/ui/src/components/dropdown-menu.tsx @@ -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< > - + + + {children} @@ -147,9 +150,11 @@ export const DropdownMenuRadioItem = forwardRef< > - + + + {children} @@ -209,7 +214,7 @@ export const DropdownMenuSubTrigger = forwardRef< ref={ref} > {children} - + ); }); diff --git a/packages/ui/src/components/select.tsx b/packages/ui/src/components/select.tsx index 431947f..81fc3e1 100644 --- a/packages/ui/src/components/select.tsx +++ b/packages/ui/src/components/select.tsx @@ -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) { @@ -57,7 +58,7 @@ export const SelectTrigger = forwardRef< {...createSlot("icon")} className="text-[var(--color-muted-foreground)]" > - ▾ + ); @@ -117,9 +118,11 @@ export const SelectItem = forwardRef< > - + + + {children} diff --git a/packages/ui/src/components/sheet.tsx b/packages/ui/src/components/sheet.tsx index 799b255..001c1a0 100644 --- a/packages/ui/src/components/sheet.tsx +++ b/packages/ui/src/components/sheet.tsx @@ -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)]" > - + diff --git a/packages/ui/src/components/toast.tsx b/packages/ui/src/components/toast.tsx index 105b61c..f55fd2c 100644 --- a/packages/ui/src/components/toast.tsx +++ b/packages/ui/src/components/toast.tsx @@ -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 ?? ( - + )} ); diff --git a/packages/ui/src/lib/icons.tsx b/packages/ui/src/lib/icons.tsx new file mode 100644 index 0000000..f91202b --- /dev/null +++ b/packages/ui/src/lib/icons.tsx @@ -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 ( + + ); +} + +export function CheckIcon({ className, ...props }: IconProps) { + return ( + + + + ); +} + +export function ChevronDownIcon({ className, ...props }: IconProps) { + return ( + + + + ); +} + +export function ChevronRightIcon({ className, ...props }: IconProps) { + return ( + + + + ); +} + +export function CloseIcon({ className, ...props }: IconProps) { + return ( + + + + ); +} + +export function DotIcon({ className, ...props }: IconProps) { + return ( + + + + ); +} + +export function SortAscendingIcon({ className, ...props }: IconProps) { + return ( + + + + ); +} + +export function SortDescendingIcon({ className, ...props }: IconProps) { + return ( + + + + ); +} + +export function SortUnsortedIcon({ className, ...props }: IconProps) { + return ( + + + + ); +}