Fix a11y incomplete checks and glyph icons
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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{" "}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user