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:
@@ -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>
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user