simplify focus-visible behavior and remove keyboard nav detection (#127)

* refactor: simplify focus-visible behavior and remove keyboard nav detection

Remove the useKeyboardNavDetection hook that gated focus rings behind a
data-keyboard-nav attribute. Focus-visible rings are now always available
via the browser's native :focus-visible heuristic, which already
distinguishes keyboard from pointer interaction.

- Delete useKeyboardNavDetection hook and its App.tsx usage
- Make global focus-visible ring unconditional in globals.css
- Switch Dialog/Sheet close buttons from focus: to focus-visible:
- Opt Input/Textarea out of global ring (rely on border change)
- Remove input-group focus ring state (handled by global rule)

* style: enhance focus-visible styles for input components

- Added focus-visible border styles to InputGroup, Input, and Textarea components to improve accessibility and visual feedback.
- Updated class names to ensure consistent focus behavior across all input-related components.
This commit is contained in:
morgmart
2026-04-08 10:03:54 -07:00
committed by GitHub
parent bbdce16649
commit 84df964b05
8 changed files with 8 additions and 64 deletions
-2
View File
@@ -1,12 +1,10 @@
import { useEffect } from "react";
import { AppShell } from "@/app/AppShell";
import { useKeyboardNavDetection } from "@/shared/hooks/useKeyboardNavDetection";
import { useScrollFade } from "@/shared/hooks/useScrollFade";
import { useZoom } from "@/shared/hooks/useZoom";
export function App() {
useKeyboardNavDetection();
useScrollFade();
useZoom();
useEffect(() => {
@@ -1,53 +0,0 @@
import { useEffect } from "react";
const ATTR = "data-keyboard-nav";
const NAVIGATION_KEYS = new Set([
"Tab",
"ArrowUp",
"ArrowDown",
"ArrowLeft",
"ArrowRight",
"Enter",
" ",
"Escape",
"Home",
"End",
"PageUp",
"PageDown",
]);
function isKeyboardNavigationEvent(event: KeyboardEvent) {
if (event.key === "Shift" || event.key === "Meta") {
return false;
}
if (event.ctrlKey || event.metaKey) {
return true;
}
return NAVIGATION_KEYS.has(event.key);
}
export function useKeyboardNavDetection() {
useEffect(() => {
const root = document.documentElement;
function onKeyDown(e: KeyboardEvent) {
if (isKeyboardNavigationEvent(e)) {
root.setAttribute(ATTR, "");
}
}
function onPointerDown() {
root.removeAttribute(ATTR);
}
window.addEventListener("keydown", onKeyDown, true);
window.addEventListener("pointerdown", onPointerDown, true);
return () => {
window.removeEventListener("keydown", onKeyDown, true);
window.removeEventListener("pointerdown", onPointerDown, true);
};
}, []);
}
+3 -3
View File
@@ -746,9 +746,9 @@ body,
outline: none !important;
}
html[data-keyboard-nav] *:not(body):not(.focus-override) {
*:not(body):not(.focus-override) {
&:focus-visible {
@apply focus-visible:ring-ring focus-visible:ring-offset-background focus-visible:ring-2 focus-visible:ring-offset-1 focus-visible:outline-hidden;
@apply ring-ring ring-offset-background ring-2 ring-offset-1 outline-hidden;
}
}
@@ -756,7 +756,7 @@ body,
@apply hover:text-foreground/80 inline-flex items-center gap-1 rounded-xs font-medium underline underline-offset-4;
}
html[data-keyboard-nav] *:not(body):not(.focus-override).link {
*:not(body):not(.focus-override).link {
&:focus-visible {
@apply ring-ring ring-offset-background ring-2 ring-offset-2 outline-hidden;
}
+1 -1
View File
@@ -65,7 +65,7 @@ function DialogContent({
>
{children}
{showCloseButton && (
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-muted data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
<DialogPrimitive.Close className="ring-offset-background focus-visible:ring-ring data-[state=open]:bg-muted data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
+1 -2
View File
@@ -21,8 +21,7 @@ function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
"has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3",
"has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3",
// Focus state.
"has-[[data-slot=input-group-control]:focus-visible]:ring-ring has-[[data-slot=input-group-control]:focus-visible]:ring-1",
"has-[[data-slot=input-group-control]:focus-visible]:border-ring",
// Error state.
"has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40",
+1 -1
View File
@@ -8,7 +8,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground border-input hover:border-border-input-hover flex h-9 w-full min-w-0 rounded-input border bg-transparent px-3 py-1 text-base transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground border-input hover:border-border-input-hover focus-visible:border-ring focus-visible:ring-0 focus-visible:ring-offset-0 flex h-9 w-full min-w-0 rounded-input border bg-transparent px-3 py-1 text-base transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className,
+1 -1
View File
@@ -70,7 +70,7 @@ function SheetContent({
{...props}
>
{children}
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-muted absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<SheetPrimitive.Close className="ring-offset-background focus-visible:ring-ring data-[state=open]:bg-muted absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden disabled:pointer-events-none">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
+1 -1
View File
@@ -7,7 +7,7 @@ function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base transition-[color,box-shadow] outline-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-0 focus-visible:ring-offset-0 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base transition-[color,box-shadow] outline-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className,
)}
{...props}