a11y(frontend): focus rings, ARIA labels, skip-link across ui components
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user