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
|
||||
|
||||
Reference in New Issue
Block a user