From 797aa5e3502d475952ecd02cb0db7769a6bd0f94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Fri, 10 Apr 2026 23:06:25 +0200 Subject: [PATCH] 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 --- apps/web/src/app/global-error.tsx | 10 ++---- apps/web/src/components/ui/AnimatedModal.tsx | 9 +++--- apps/web/src/components/ui/EntityCombobox.tsx | 32 +++++++++++++------ apps/web/src/components/ui/FilterBar.tsx | 6 +++- .../components/ui/SortableColumnHeader.tsx | 17 ++++++++-- tooling/eslint/src/nextjs.js | 5 ++- 6 files changed, 53 insertions(+), 26 deletions(-) diff --git a/apps/web/src/app/global-error.tsx b/apps/web/src/app/global-error.tsx index 69c9f3f..fb7320c 100644 --- a/apps/web/src/app/global-error.tsx +++ b/apps/web/src/app/global-error.tsx @@ -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 ( - +

Something went wrong

diff --git a/apps/web/src/components/ui/AnimatedModal.tsx b/apps/web/src/components/ui/AnimatedModal.tsx index bcb7bc9..cdf9313 100644 --- a/apps/web/src/components/ui/AnimatedModal.tsx +++ b/apps/web/src/components/ui/AnimatedModal.tsx @@ -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(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 }} diff --git a/apps/web/src/components/ui/EntityCombobox.tsx b/apps/web/src/components/ui/EntityCombobox.tsx index 7bcd73f..a7d5aed 100644 --- a/apps/web/src/components/ui/EntityCombobox.tsx +++ b/apps/web/src/components/ui/EntityCombobox.tsx @@ -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({ const debouncedSearch = useDebounce(search, 300); const containerRef = useRef(null); const inputRef = useRef(null); + const listboxId = useId(); const closeDropdown = useCallback(() => { setOpen(false); setSearch(""); @@ -82,6 +83,10 @@ export function EntityCombobox({ setSearch(e.target.value)} onFocus={handleFocus} @@ -97,7 +102,11 @@ export function EntityCombobox({ {value && !disabled && !open && (
- {open && ( - typeof document !== "undefined" + {open && + (typeof document !== "undefined" ? createPortal(
({ width: position.minWidth, }} > -
    +
      {items.length === 0 ? ( -
    • No results
    • +
    • + No results +
    • ) : ( items.map((item) => ( -
    • +
, document.body, ) - : null - )} + : null)} ); } diff --git a/apps/web/src/components/ui/FilterBar.tsx b/apps/web/src/components/ui/FilterBar.tsx index 843e359..2022492 100644 --- a/apps/web/src/components/ui/FilterBar.tsx +++ b/apps/web/src/components/ui/FilterBar.tsx @@ -8,7 +8,11 @@ interface FilterBarProps { export function FilterBar({ children, hasActiveFilters, onClearFilters }: FilterBarProps) { return ( -
+
{children} {hasActiveFilters && onClearFilters && ( - {tooltip && } + {tooltip && ( + + )}
); diff --git a/tooling/eslint/src/nextjs.js b/tooling/eslint/src/nextjs.js index 0cc62d9..d9a908b 100644 --- a/tooling/eslint/src/nextjs.js +++ b/tooling/eslint/src/nextjs.js @@ -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",