merge: ui/a11y-polish — focus rings, ARIA labels, skip-link

This commit is contained in:
Tyler
2026-06-20 16:59:25 -06:00
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
+57 -1
View File
@@ -144,12 +144,28 @@
background-clip: content-box;
}
/* Focus ring — accent, 2px, with offset */
/* Focus ring — accent, 2px, with offset.
Components override this with Tailwind focus-visible:ring-* utilities
for a ring+offset combo that matches the rest of the design system.
This base rule covers anything that doesn't opt in (links in
prose, native checkboxes, etc.). */
:focus-visible {
outline: 2px solid hsl(var(--ring));
outline-offset: 2px;
border-radius: 6px;
}
/* Respect reduced-motion users globally. */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.001ms !important;
scroll-behavior: auto !important;
}
}
}
@layer components {
@@ -197,6 +213,46 @@
box-shadow: 0 0 12px hsl(var(--accent) / 0.5);
}
/* Skip link — invisible until focused. Sits above the scan element on
first paint so it is the first Tab stop in the app. */
.skip-link {
position: fixed;
top: 0.75rem;
left: 0.75rem;
z-index: 100;
display: inline-flex;
align-items: center;
height: 2.25rem;
padding: 0 0.875rem;
border-radius: 0.625rem;
background-color: hsl(var(--card));
color: hsl(var(--foreground));
border: 1px solid hsl(var(--border));
box-shadow:
inset 0 1px 0 0 hsl(0 0% 100% / 0.04),
0 8px 24px hsl(0 0% 0% / 0.45);
font-size: 12.5px;
font-weight: 600;
letter-spacing: 0.01em;
transform: translateY(-200%);
transition: transform 150ms ease-out;
}
.skip-link:focus-visible {
transform: translateY(0);
outline: none;
box-shadow:
inset 0 1px 0 0 hsl(0 0% 100% / 0.04),
0 0 0 2px hsl(var(--background)),
0 0 0 4px hsl(var(--ring)),
0 8px 24px hsl(0 0% 0% / 0.45);
}
/* Reduced motion: skip the slide. */
@media (prefers-reduced-motion: reduce) {
.skip-link {
transition: none;
}
}
/* Keyboard hint chip */
.kbd {
display: inline-flex;