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";
|
import { useEffect } from "react";
|
||||||
|
|
||||||
export default function GlobalError({
|
export default function GlobalError({ error, reset }: { error: Error; reset: () => void }) {
|
||||||
error,
|
|
||||||
reset,
|
|
||||||
}: {
|
|
||||||
error: Error;
|
|
||||||
reset: () => void;
|
|
||||||
}) {
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
}, [error]);
|
}, [error]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html>
|
<html lang="en">
|
||||||
<body>
|
<body>
|
||||||
<div style={{ padding: "2rem", textAlign: "center" }}>
|
<div style={{ padding: "2rem", textAlign: "center" }}>
|
||||||
<h2>Something went wrong</h2>
|
<h2>Something went wrong</h2>
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ interface AnimatedModalProps {
|
|||||||
overlayClassName?: string;
|
overlayClassName?: string;
|
||||||
maxWidth?: string;
|
maxWidth?: string;
|
||||||
disableBackdropClose?: boolean;
|
disableBackdropClose?: boolean;
|
||||||
|
/** ID of the element that labels this dialog (for aria-labelledby). */
|
||||||
|
ariaLabelledBy?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AnimatedModal({
|
export function AnimatedModal({
|
||||||
@@ -22,6 +24,7 @@ export function AnimatedModal({
|
|||||||
overlayClassName,
|
overlayClassName,
|
||||||
maxWidth = "max-w-xl",
|
maxWidth = "max-w-xl",
|
||||||
disableBackdropClose = false,
|
disableBackdropClose = false,
|
||||||
|
ariaLabelledBy,
|
||||||
}: AnimatedModalProps) {
|
}: AnimatedModalProps) {
|
||||||
const panelRef = useRef<HTMLDivElement>(null);
|
const panelRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
@@ -50,10 +53,7 @@ export function AnimatedModal({
|
|||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
transition={{ duration: 0.2 }}
|
transition={{ duration: 0.2 }}
|
||||||
className={
|
className={overlayClassName ?? "absolute inset-0 bg-black/40 backdrop-blur-sm"}
|
||||||
overlayClassName ??
|
|
||||||
"absolute inset-0 bg-black/40 backdrop-blur-sm"
|
|
||||||
}
|
|
||||||
onClick={disableBackdropClose ? undefined : onClose}
|
onClick={disableBackdropClose ? undefined : onClose}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
@@ -63,6 +63,7 @@ export function AnimatedModal({
|
|||||||
ref={panelRef}
|
ref={panelRef}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
|
aria-labelledby={ariaLabelledBy}
|
||||||
initial={{ opacity: 0, scale: 0.97 }}
|
initial={{ opacity: 0, scale: 0.97 }}
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
exit={{ opacity: 0, scale: 0.97 }}
|
exit={{ opacity: 0, scale: 0.97 }}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { createPortal } from "react-dom";
|
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 { useDebounce } from "~/hooks/useDebounce.js";
|
||||||
import { useAnchoredOverlay } from "~/hooks/useAnchoredOverlay.js";
|
import { useAnchoredOverlay } from "~/hooks/useAnchoredOverlay.js";
|
||||||
|
|
||||||
@@ -37,6 +37,7 @@ export function EntityCombobox<T extends { id: string }>({
|
|||||||
const debouncedSearch = useDebounce(search, 300);
|
const debouncedSearch = useDebounce(search, 300);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const listboxId = useId();
|
||||||
const closeDropdown = useCallback(() => {
|
const closeDropdown = useCallback(() => {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
setSearch("");
|
setSearch("");
|
||||||
@@ -82,6 +83,10 @@ export function EntityCombobox<T extends { id: string }>({
|
|||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
type="text"
|
type="text"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-controls={open ? listboxId : undefined}
|
||||||
|
aria-autocomplete="list"
|
||||||
value={open ? search : selectedLabel}
|
value={open ? search : selectedLabel}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
onFocus={handleFocus}
|
onFocus={handleFocus}
|
||||||
@@ -97,7 +102,11 @@ export function EntityCombobox<T extends { id: string }>({
|
|||||||
{value && !disabled && !open && (
|
{value && !disabled && !open && (
|
||||||
<button
|
<button
|
||||||
type="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"
|
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"
|
aria-label="Clear"
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
@@ -107,8 +116,8 @@ export function EntityCombobox<T extends { id: string }>({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{open && (
|
{open &&
|
||||||
typeof document !== "undefined"
|
(typeof document !== "undefined"
|
||||||
? createPortal(
|
? createPortal(
|
||||||
<div
|
<div
|
||||||
ref={panelRef}
|
ref={panelRef}
|
||||||
@@ -120,12 +129,18 @@ export function EntityCombobox<T extends { id: string }>({
|
|||||||
width: position.minWidth,
|
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 ? (
|
{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) => (
|
items.map((item) => (
|
||||||
<li key={item.id}>
|
<li key={item.id} role="option" aria-selected={item.id === value}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onMouseDown={() => select(item.id)}
|
onMouseDown={() => select(item.id)}
|
||||||
@@ -144,8 +159,7 @@ export function EntityCombobox<T extends { id: string }>({
|
|||||||
</div>,
|
</div>,
|
||||||
document.body,
|
document.body,
|
||||||
)
|
)
|
||||||
: null
|
: null)}
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,11 @@ interface FilterBarProps {
|
|||||||
|
|
||||||
export function FilterBar({ children, hasActiveFilters, onClearFilters }: FilterBarProps) {
|
export function FilterBar({ children, hasActiveFilters, onClearFilters }: FilterBarProps) {
|
||||||
return (
|
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}
|
{children}
|
||||||
{hasActiveFilters && onClearFilters && (
|
{hasActiveFilters && onClearFilters && (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -53,10 +53,16 @@ export function SortableColumnHeader({
|
|||||||
tooltipWidth,
|
tooltipWidth,
|
||||||
}: SortableColumnHeaderProps) {
|
}: SortableColumnHeaderProps) {
|
||||||
const activeDir = sortField === field ? sortDir : null;
|
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 (
|
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}`}>
|
<div className={`flex items-center gap-0.5 ${alignClass}`}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -66,7 +72,12 @@ export function SortableColumnHeader({
|
|||||||
<span>{label}</span>
|
<span>{label}</span>
|
||||||
<SortIcon dir={activeDir} />
|
<SortIcon dir={activeDir} />
|
||||||
</button>
|
</button>
|
||||||
{tooltip && <InfoTooltip content={tooltip} {...(tooltipWidth !== undefined ? { width: tooltipWidth } : {})} />}
|
{tooltip && (
|
||||||
|
<InfoTooltip
|
||||||
|
content={tooltip}
|
||||||
|
{...(tooltipWidth !== undefined ? { width: tooltipWidth } : {})}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</th>
|
</th>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -30,7 +30,10 @@ export default [
|
|||||||
"jsx-a11y/html-has-lang": "warn",
|
"jsx-a11y/html-has-lang": "warn",
|
||||||
"jsx-a11y/img-redundant-alt": "warn",
|
"jsx-a11y/img-redundant-alt": "warn",
|
||||||
"jsx-a11y/interactive-supports-focus": "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-access-key": "warn",
|
||||||
"jsx-a11y/no-autofocus": "warn",
|
"jsx-a11y/no-autofocus": "warn",
|
||||||
"jsx-a11y/no-distracting-elements": "warn",
|
"jsx-a11y/no-distracting-elements": "warn",
|
||||||
|
|||||||
Reference in New Issue
Block a user