a11y(frontend): focus rings, ARIA labels, skip-link across ui components

This commit is contained in:
Tyler
2026-06-20 16:51:36 -06:00
parent b980e77cf2
commit b37fb5a26c
8 changed files with 113 additions and 10 deletions
+9 -1
View File
@@ -1,6 +1,7 @@
import { Outlet } from "react-router-dom";
import { useIsFetching } from "@tanstack/react-query";
import { Sidebar } from "./Sidebar";
import { SkipLink } from "@/components/ui/skip-link";
export function Layout() {
// useIsFetching() returns the number of in-flight queries and is 0 on
@@ -11,6 +12,9 @@ export function Layout() {
return (
<div className="relative min-h-screen z-10">
{/* Skip link — first Tab stop, jumps past the sidebar.
Hidden until focused (see .skip-link in index.css). */}
<SkipLink />
{/*
Scan element — always in the DOM (no layout shift), 1px tall, full
width. pointer-events-none so it never blocks clicks. The inner
@@ -28,7 +32,11 @@ export function Layout() {
/>
</div>
<Sidebar />
<main className="md:pl-60">
<main
id="main-content"
tabIndex={-1}
className="md:pl-60 outline-none"
>
<div className="mx-auto max-w-[1400px] px-8 py-10">
<Outlet />
</div>
+1 -1
View File
@@ -3,7 +3,7 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-xs font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background",
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
{
variants: {
variant: {
+1 -1
View File
@@ -75,7 +75,7 @@ export function ClaimStateBadge({
className,
)}
>
<Icon className="h-3 w-3" strokeWidth={1.75} />
<Icon className="h-3 w-3" strokeWidth={1.75} aria-hidden />
{cfg.label}
</Badge>
);
+5 -2
View File
@@ -38,8 +38,11 @@ const DialogContent = React.forwardRef<
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-md p-1 text-muted-foreground opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring">
<X className="h-4 w-4" />
<DialogPrimitive.Close
aria-label="Close dialog"
className="absolute right-4 top-4 rounded-md p-1 text-muted-foreground opacity-70 transition-opacity hover:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
<X className="h-4 w-4" aria-hidden />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
+2 -2
View File
@@ -14,7 +14,7 @@ const SelectTrigger = React.forwardRef<
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-9 w-full items-center justify-between rounded-md border border-input bg-background/60 px-3 py-1 text-sm shadow-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
"flex h-9 w-full items-center justify-between rounded-md border border-input bg-background/60 px-3 py-1 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}
@@ -64,7 +64,7 @@ const SelectItem = React.forwardRef<
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-muted focus:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-muted focus:text-foreground data-[highlighted]:bg-muted data-[highlighted]:text-foreground data-[state=checked]:font-medium data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
+35
View File
@@ -0,0 +1,35 @@
import * as React from "react";
import { cn } from "@/lib/utils";
type SkipLinkProps = React.AnchorHTMLAttributes<HTMLAnchorElement> & {
/** id of the element the link should jump to (target of the focus). */
targetId?: string;
};
/**
* "Skip to main content" link.
*
* Renders off-screen by default so it does not intrude on the visual
* design, and slides into view when it receives keyboard focus. Used at
* the very top of <Layout> so keyboard users can bypass the sidebar and
* the global scan element on the first Tab.
*
* Voice: instrument-label eyebrow caps. Same hairline surface as the
* sidebar so it feels native when it appears.
*/
export function SkipLink({
targetId = "main-content",
className,
children = "Skip to main content",
...props
}: SkipLinkProps) {
return (
<a
href={`#${targetId}`}
className={cn("skip-link", className)}
{...props}
>
{children}
</a>
);
}
+3 -2
View File
@@ -45,7 +45,7 @@ const TableRow = React.forwardRef<
<tr
ref={ref}
className={cn(
"border-b border-border/40 transition-colors hover:bg-muted/30 data-[state=selected]:bg-muted",
"border-b border-border/40 transition-colors hover:bg-muted/30 focus-within:bg-muted/30 data-[state=selected]:bg-muted",
className
)}
{...props}
@@ -56,9 +56,10 @@ TableRow.displayName = "TableRow";
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
>(({ className, scope = "col", ...props }, ref) => (
<th
ref={ref}
scope={scope}
className={cn(
"h-10 px-4 text-left align-middle text-[11px] font-medium uppercase tracking-wider text-muted-foreground [&:has([role=checkbox])]:pr-0",
className