merge: ui/a11y-polish — focus rings, ARIA labels, skip-link
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import { Outlet } from "react-router-dom";
|
import { Outlet } from "react-router-dom";
|
||||||
import { useIsFetching } from "@tanstack/react-query";
|
import { useIsFetching } from "@tanstack/react-query";
|
||||||
import { Sidebar } from "./Sidebar";
|
import { Sidebar } from "./Sidebar";
|
||||||
|
import { SkipLink } from "@/components/ui/skip-link";
|
||||||
|
|
||||||
export function Layout() {
|
export function Layout() {
|
||||||
// useIsFetching() returns the number of in-flight queries and is 0 on
|
// useIsFetching() returns the number of in-flight queries and is 0 on
|
||||||
@@ -11,6 +12,9 @@ export function Layout() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative min-h-screen z-10">
|
<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
|
Scan element — always in the DOM (no layout shift), 1px tall, full
|
||||||
width. pointer-events-none so it never blocks clicks. The inner
|
width. pointer-events-none so it never blocks clicks. The inner
|
||||||
@@ -28,7 +32,11 @@ export function Layout() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Sidebar />
|
<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">
|
<div className="mx-auto max-w-[1400px] px-8 py-10">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { cva, type VariantProps } from "class-variance-authority";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const badgeVariants = cva(
|
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: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ export function ClaimStateBadge({
|
|||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icon className="h-3 w-3" strokeWidth={1.75} />
|
<Icon className="h-3 w-3" strokeWidth={1.75} aria-hidden />
|
||||||
{cfg.label}
|
{cfg.label}
|
||||||
</Badge>
|
</Badge>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -38,8 +38,11 @@ const DialogContent = React.forwardRef<
|
|||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{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">
|
<DialogPrimitive.Close
|
||||||
<X className="h-4 w-4" />
|
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>
|
<span className="sr-only">Close</span>
|
||||||
</DialogPrimitive.Close>
|
</DialogPrimitive.Close>
|
||||||
</DialogPrimitive.Content>
|
</DialogPrimitive.Content>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ const SelectTrigger = React.forwardRef<
|
|||||||
<SelectPrimitive.Trigger
|
<SelectPrimitive.Trigger
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -64,7 +64,7 @@ const SelectItem = React.forwardRef<
|
|||||||
<SelectPrimitive.Item
|
<SelectPrimitive.Item
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...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
|
<tr
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -56,9 +56,10 @@ TableRow.displayName = "TableRow";
|
|||||||
const TableHead = React.forwardRef<
|
const TableHead = React.forwardRef<
|
||||||
HTMLTableCellElement,
|
HTMLTableCellElement,
|
||||||
React.ThHTMLAttributes<HTMLTableCellElement>
|
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, scope = "col", ...props }, ref) => (
|
||||||
<th
|
<th
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
scope={scope}
|
||||||
className={cn(
|
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",
|
"h-10 px-4 text-left align-middle text-[11px] font-medium uppercase tracking-wider text-muted-foreground [&:has([role=checkbox])]:pr-0",
|
||||||
className
|
className
|
||||||
|
|||||||
+57
-1
@@ -144,12 +144,28 @@
|
|||||||
background-clip: content-box;
|
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 {
|
:focus-visible {
|
||||||
outline: 2px solid hsl(var(--ring));
|
outline: 2px solid hsl(var(--ring));
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
border-radius: 6px;
|
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 {
|
@layer components {
|
||||||
@@ -197,6 +213,46 @@
|
|||||||
box-shadow: 0 0 12px hsl(var(--accent) / 0.5);
|
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 */
|
/* Keyboard hint chip */
|
||||||
.kbd {
|
.kbd {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
|||||||
Reference in New Issue
Block a user