fix(a11y): add ARIA attributes to core UI components

AnimatedModal: ariaLabelledBy prop, EntityCombobox: combobox/listbox
pattern, FilterBar: role="search", SortableColumnHeader: aria-sort,
global-error: html lang attr, eslint label rule depth config.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-10 23:06:25 +02:00
parent 09dcedb646
commit 797aa5e350
6 changed files with 53 additions and 26 deletions
+2 -8
View File
@@ -2,19 +2,13 @@
import { useEffect } from "react";
export default function GlobalError({
error,
reset,
}: {
error: Error;
reset: () => void;
}) {
export default function GlobalError({ error, reset }: { error: Error; reset: () => void }) {
useEffect(() => {
console.error(error);
}, [error]);
return (
<html>
<html lang="en">
<body>
<div style={{ padding: "2rem", textAlign: "center" }}>
<h2>Something went wrong</h2>
+5 -4
View File
@@ -12,6 +12,8 @@ interface AnimatedModalProps {
overlayClassName?: string;
maxWidth?: string;
disableBackdropClose?: boolean;
/** ID of the element that labels this dialog (for aria-labelledby). */
ariaLabelledBy?: string;
}
export function AnimatedModal({
@@ -22,6 +24,7 @@ export function AnimatedModal({
overlayClassName,
maxWidth = "max-w-xl",
disableBackdropClose = false,
ariaLabelledBy,
}: AnimatedModalProps) {
const panelRef = useRef<HTMLDivElement>(null);
@@ -50,10 +53,7 @@ export function AnimatedModal({
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className={
overlayClassName ??
"absolute inset-0 bg-black/40 backdrop-blur-sm"
}
className={overlayClassName ?? "absolute inset-0 bg-black/40 backdrop-blur-sm"}
onClick={disableBackdropClose ? undefined : onClose}
aria-hidden="true"
/>
@@ -63,6 +63,7 @@ export function AnimatedModal({
ref={panelRef}
role="dialog"
aria-modal="true"
aria-labelledby={ariaLabelledBy}
initial={{ opacity: 0, scale: 0.97 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.97 }}
+23 -9
View File
@@ -1,7 +1,7 @@
"use client";
import { createPortal } from "react-dom";
import { useState, useRef, useMemo, useCallback, type ReactNode } from "react";
import { useState, useRef, useMemo, useCallback, useId, type ReactNode } from "react";
import { useDebounce } from "~/hooks/useDebounce.js";
import { useAnchoredOverlay } from "~/hooks/useAnchoredOverlay.js";
@@ -37,6 +37,7 @@ export function EntityCombobox<T extends { id: string }>({
const debouncedSearch = useDebounce(search, 300);
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const listboxId = useId();
const closeDropdown = useCallback(() => {
setOpen(false);
setSearch("");
@@ -82,6 +83,10 @@ export function EntityCombobox<T extends { id: string }>({
<input
ref={inputRef}
type="text"
role="combobox"
aria-expanded={open}
aria-controls={open ? listboxId : undefined}
aria-autocomplete="list"
value={open ? search : selectedLabel}
onChange={(e) => setSearch(e.target.value)}
onFocus={handleFocus}
@@ -97,7 +102,11 @@ export function EntityCombobox<T extends { id: string }>({
{value && !disabled && !open && (
<button
type="button"
onMouseDown={(e) => { e.preventDefault(); e.stopPropagation(); select(null); }}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
select(null);
}}
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-lg leading-none"
aria-label="Clear"
tabIndex={-1}
@@ -107,8 +116,8 @@ export function EntityCombobox<T extends { id: string }>({
)}
</div>
{open && (
typeof document !== "undefined"
{open &&
(typeof document !== "undefined"
? createPortal(
<div
ref={panelRef}
@@ -120,12 +129,18 @@ export function EntityCombobox<T extends { id: string }>({
width: position.minWidth,
}}
>
<ul className="max-h-52 overflow-y-auto py-1">
<ul id={listboxId} role="listbox" className="max-h-52 overflow-y-auto py-1">
{items.length === 0 ? (
<li className="px-3 py-2 text-sm text-gray-400 dark:text-gray-500">No results</li>
<li
role="option"
aria-selected={false}
className="px-3 py-2 text-sm text-gray-400 dark:text-gray-500"
>
No results
</li>
) : (
items.map((item) => (
<li key={item.id}>
<li key={item.id} role="option" aria-selected={item.id === value}>
<button
type="button"
onMouseDown={() => select(item.id)}
@@ -144,8 +159,7 @@ export function EntityCombobox<T extends { id: string }>({
</div>,
document.body,
)
: null
)}
: null)}
</div>
);
}
+5 -1
View File
@@ -8,7 +8,11 @@ interface FilterBarProps {
export function FilterBar({ children, hasActiveFilters, onClearFilters }: FilterBarProps) {
return (
<div className="app-toolbar mb-4 flex flex-wrap items-center gap-3">
<div
role="search"
aria-label="Filters"
className="app-toolbar mb-4 flex flex-wrap items-center gap-3"
>
{children}
{hasActiveFilters && onClearFilters && (
<button
@@ -53,10 +53,16 @@ export function SortableColumnHeader({
tooltipWidth,
}: SortableColumnHeaderProps) {
const activeDir = sortField === field ? sortDir : null;
const alignClass = align === "right" ? "justify-end" : align === "center" ? "justify-center" : "justify-start";
const alignClass =
align === "right" ? "justify-end" : align === "center" ? "justify-center" : "justify-start";
const ariaSortValue =
activeDir === "asc" ? "ascending" : activeDir === "desc" ? "descending" : undefined;
return (
<th className={`px-3 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider ${className}`}>
<th
aria-sort={ariaSortValue}
className={`px-3 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider ${className}`}
>
<div className={`flex items-center gap-0.5 ${alignClass}`}>
<button
type="button"
@@ -66,7 +72,12 @@ export function SortableColumnHeader({
<span>{label}</span>
<SortIcon dir={activeDir} />
</button>
{tooltip && <InfoTooltip content={tooltip} {...(tooltipWidth !== undefined ? { width: tooltipWidth } : {})} />}
{tooltip && (
<InfoTooltip
content={tooltip}
{...(tooltipWidth !== undefined ? { width: tooltipWidth } : {})}
/>
)}
</div>
</th>
);
+4 -1
View File
@@ -30,7 +30,10 @@ export default [
"jsx-a11y/html-has-lang": "warn",
"jsx-a11y/img-redundant-alt": "warn",
"jsx-a11y/interactive-supports-focus": "warn",
"jsx-a11y/label-has-associated-control": "warn",
"jsx-a11y/label-has-associated-control": ["warn", {
assert: "either",
depth: 3,
}],
"jsx-a11y/no-access-key": "warn",
"jsx-a11y/no-autofocus": "warn",
"jsx-a11y/no-distracting-elements": "warn",