From d9367a8ce4aa8d31438aa88c895dd976e713db32 Mon Sep 17 00:00:00 2001 From: Maximilian Stubhan Date: Sat, 25 Jul 2026 13:10:52 +0200 Subject: [PATCH] Form primitives, keyboard-operable comboboxes, dialog focus, route states MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Accessibility work on the UI layer, all of it rooted in one structural gap: there were no form primitives, so every field was hand-assembled and every field got the same details wrong. Form primitives - components/ui/Field.tsx (Field/TextField/SelectField/TextareaField) and Button.tsx. Field generates the control id with useId and derives htmlFor from it, which is what makes the association impossible to omit rather than merely conventional. - 92 labels existed, 4 used htmlFor, and no input carried an id at all: a screen reader announced an unnamed edit box and clicking a label focused nothing. Now every label resolves to its control (0 unassociated), and the input class chain that appeared verbatim 85 times appears zero times. - Field also takes a render prop, so Lookup, CountryPicker and Picklist get the same wiring instead of a second, partial solution. - SearchInput replaces three hand-rolled copies of the icon-in-a-box search whose input had only a placeholder — not a label — and killed its own focus ring with outline-none and nothing in its place. - Toggle groups (workdays, reorg change type) became fieldsets with aria-pressed; colour alone was carrying the selected state. Comboboxes - Lookup and CountryPicker were text inputs with a div of clickable buttons underneath: typeable, but no keyboard path to a result and nothing telling a screen reader a list had appeared. Both now carry role=combobox, aria-expanded/controls/activedescendant and listbox semantics, with arrow keys, Enter and Escape. Escape stops propagation, or it would close the surrounding dialog along with the dropdown. Dialogs - useDialogFocus centralises what Modal and SlideOver each owed the keyboard and neither provided beyond Escape: focus into the dialog on open, Tab and Shift+Tab cycling within it, focus restored to the trigger on close. - SlideOver stays mounted for its transition, and aria-hidden does not remove anything from the tab order — so every closed panel was leaving invisible tab stops at the end of the page. `inert` fixes that. Route states - loading.tsx, error.tsx, not-found.tsx and global-error.tsx. Every page in the (app) group is server-rendered per request, so without loading.tsx a navigation showed nothing at all until the server answered, and a render error dropped the user on Next's own screen with no way back. Tests - 22 component tests (vitest jsdom project). Two of them found limits of the environment rather than of the code: jsdom implements neither `inert` nor scrollIntoView, so the inert test asserts the attribute and the missing scrollIntoView — which was taking the whole render down from inside an effect — is stubbed in the setup file. --- app/(app)/error.tsx | 47 +++ app/(app)/loading.tsx | 30 ++ app/(auth)/login/page.tsx | 13 +- app/global-error.tsx | 44 +++ app/not-found.tsx | 19 ++ components/audit/AuditFilters.tsx | 16 +- components/employees/AddDependentModal.tsx | 65 +--- components/employees/AngehoerigeSection.tsx | 21 +- components/employees/EmployeeDetail.tsx | 28 +- components/employees/EmployeeFilters.tsx | 25 +- components/employees/RoleEmploymentFields.tsx | 55 ++-- components/employees/SvNummerField.tsx | 53 ++- components/employees/TitleFields.tsx | 15 +- .../employees/panels/DatenAendernPanel.tsx | 182 ++++------- components/employees/panels/KarenzPanel.tsx | 97 +++--- components/employees/panels/PromotePanel.tsx | 51 +-- components/employees/panels/RehirePanel.tsx | 24 +- .../employees/panels/TerminatePanel.tsx | 51 +-- components/employees/panels/TransferPanel.tsx | 87 ++--- components/employees/tabs/NotizenTab.tsx | 54 ++- components/employees/tabs/OrganisationTab.tsx | 6 +- components/hire/HireWizard.tsx | 39 +-- components/hire/StepPerson.tsx | 66 ++-- components/hire/StepPosition.tsx | 88 ++--- components/hire/StepVertrag.tsx | 104 +++--- components/orgchart/AsOfPicker.tsx | 8 +- components/orgchart/EmployeeTree.tsx | 30 +- components/orgchart/ReorgWorkbench.tsx | 227 ++++++------- components/positions/CreatePositionModal.tsx | 95 +++--- components/positions/PositionsPageClient.tsx | 21 +- components/reports/ReportsPageClient.tsx | 307 +++++++++--------- components/shell/NewHireButton.tsx | 14 +- components/ui/Button.tsx | 74 +++++ components/ui/CountryPicker.tsx | 106 +++++- components/ui/Field.tsx | 182 +++++++++++ components/ui/Lookup.tsx | 123 +++++-- components/ui/Modal.tsx | 31 +- components/ui/Picklist.tsx | 10 +- components/ui/SearchInput.tsx | 45 +++ components/ui/SlideOver.tsx | 36 +- components/ui/useDialogFocus.ts | 78 +++++ tests/components/Lookup.test.tsx | 93 ++++++ tests/components/dialog-a11y.test.tsx | 130 ++++++++ tests/components/setup.ts | 7 + 44 files changed, 1770 insertions(+), 1127 deletions(-) create mode 100644 app/(app)/error.tsx create mode 100644 app/(app)/loading.tsx create mode 100644 app/global-error.tsx create mode 100644 app/not-found.tsx create mode 100644 components/ui/Button.tsx create mode 100644 components/ui/Field.tsx create mode 100644 components/ui/SearchInput.tsx create mode 100644 components/ui/useDialogFocus.ts create mode 100644 tests/components/Lookup.test.tsx create mode 100644 tests/components/dialog-a11y.test.tsx diff --git a/app/(app)/error.tsx b/app/(app)/error.tsx new file mode 100644 index 0000000..8de43ca --- /dev/null +++ b/app/(app)/error.tsx @@ -0,0 +1,47 @@ +"use client"; + +import { AlertTriangle, RotateCcw } from "lucide-react"; +import Link from "next/link"; +import { useEffect } from "react"; +import { Button, LINK_BUTTON_CLASS } from "@/components/ui/Button"; + +// Without this file a failed render drops the user on Next.js's own error +// screen — no navigation, no way back, and in production just "a client-side +// exception occurred". `reset()` re-renders the segment, which is enough for +// the common case of a transient Supabase timeout. +export default function AppError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) { + useEffect(() => { + console.error("Route error:", error); + }, [error]); + + return ( +
+ + + +
+

Diese Ansicht konnte nicht geladen werden

+

+ Die Daten wurden nicht verändert. Meist hilft ein erneuter Versuch; bleibt der Fehler, wenden Sie sich bitte an die + IT-Betreuung. +

+
+ {/* The digest is the only handle on the server-side stack trace, which + is deliberately not sent to the browser in production. */} + {error.digest && ( +

+ Fehlerkennung: {error.digest} +

+ )} +
+ + + Zur Übersicht + +
+
+ ); +} diff --git a/app/(app)/loading.tsx b/app/(app)/loading.tsx new file mode 100644 index 0000000..e71724a --- /dev/null +++ b/app/(app)/loading.tsx @@ -0,0 +1,30 @@ +// Every page in this group is server-rendered per request (they all read +// from Supabase), so without this the browser sits on the previous page with +// no feedback until the server answers — on the employee list, long enough +// to look broken. +export default function Loading() { + return ( +
+
+
+ {[240, 160, 160].map((w, i) => ( +
+ ))} +
+
+ {Array.from({ length: 8 }, (_, i) => ( +
+
+
+
+
+
+
+
+
+ ))} +
+ Daten werden geladen… +
+ ); +} diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx index e97ea68..58e84b7 100644 --- a/app/(auth)/login/page.tsx +++ b/app/(auth)/login/page.tsx @@ -1,4 +1,6 @@ import { login, logout } from "@/actions/auth"; +import { Button } from "@/components/ui/Button"; +import { CONTROL_CLASS } from "@/components/ui/Field"; // The query string is attacker-controlled, so the login page renders a message // looked up by code rather than whatever text ?error= carries. Reflecting the @@ -50,7 +52,7 @@ export default async function LoginPage({ searchParams }: LoginPageProps) { type="email" required autoComplete="username" - className="w-full rounded border border-border px-3 py-2 text-sm text-ink outline-none focus:border-brand-500" + className={CONTROL_CLASS} />
@@ -63,15 +65,12 @@ export default async function LoginPage({ searchParams }: LoginPageProps) { type="password" required autoComplete="current-password" - className="w-full rounded border border-border px-3 py-2 text-sm text-ink outline-none focus:border-brand-500" + className={CONTROL_CLASS} />
- +
diff --git a/app/global-error.tsx b/app/global-error.tsx new file mode 100644 index 0000000..0e7feaf --- /dev/null +++ b/app/global-error.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { useEffect } from "react"; + +// Last resort: catches errors thrown by the root layout itself, where +// (app)/error.tsx is not mounted yet. It replaces , so it cannot use +// the app's fonts, Tailwind layer or shared components — hence the inline +// styles. Kept deliberately plain; anything clever here can fail too. +export default function GlobalError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) { + useEffect(() => { + console.error("Global error:", error); + }, [error]); + + return ( + + +
+

Die Anwendung konnte nicht geladen werden

+

+ Es ist ein unerwarteter Fehler aufgetreten. Ihre Daten sind davon nicht betroffen. +

+ {error.digest && ( +

Fehlerkennung: {error.digest}

+ )} + +
+ + + ); +} diff --git a/app/not-found.tsx b/app/not-found.tsx new file mode 100644 index 0000000..83ef56d --- /dev/null +++ b/app/not-found.tsx @@ -0,0 +1,19 @@ +import Link from "next/link"; +import { LINK_BUTTON_CLASS } from "@/components/ui/Button"; + +export default function NotFound() { + return ( +
+
+

404

+

Seite nicht gefunden

+

+ Die aufgerufene Adresse existiert nicht. Möglicherweise wurde der Datensatz gelöscht oder der Link ist veraltet. +

+ + Zur Übersicht + +
+
+ ); +} diff --git a/components/audit/AuditFilters.tsx b/components/audit/AuditFilters.tsx index 2c3f1f4..96d147e 100644 --- a/components/audit/AuditFilters.tsx +++ b/components/audit/AuditFilters.tsx @@ -1,8 +1,9 @@ "use client"; -import { Search } from "lucide-react"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { useEffect, useState } from "react"; +import { FILTER_SELECT_CLASS } from "@/components/ui/Field"; +import { SearchInput } from "@/components/ui/SearchInput"; const ACTIONS = [ "Neueinstellung", @@ -50,19 +51,12 @@ export function AuditFilters() { return (
-
- - setQ(e.target.value)} - placeholder="Objekt, Details, Benutzer:in…" - className="w-full text-sm text-ink outline-none placeholder:text-ink-muted" - /> -
+ setEffectiveDate(e.target.value)} - className="w-full rounded border border-border px-3 py-2 text-sm" - /> -
+
-
- - setFirstName(e.target.value)} className="w-full rounded border border-border px-3 py-2 text-sm" /> -
-
- - setLastName(e.target.value)} className="w-full rounded border border-border px-3 py-2 text-sm" /> -
-
-
- - setSvNummer(e.target.value)} className="w-full rounded border border-border px-3 py-2 text-sm" /> -
-
- - setBirthDate(e.target.value)} className="w-full rounded border border-border px-3 py-2 text-sm" /> -
-
- - + +
+ + + setRelationship(v as RelationshipType)} + options={RELATIONSHIPS.map((r) => ({ value: r, label: r }))} + />
); diff --git a/components/employees/AngehoerigeSection.tsx b/components/employees/AngehoerigeSection.tsx index 140c4a6..1bfdccc 100644 --- a/components/employees/AngehoerigeSection.tsx +++ b/components/employees/AngehoerigeSection.tsx @@ -4,6 +4,7 @@ import { Plus, Trash2 } from "lucide-react"; import { useRouter } from "next/navigation"; import { useState } from "react"; import { deleteEmployeeDependent } from "@/actions/employees"; +import { Button } from "@/components/ui/Button"; import { useToast } from "@/components/ui/Toast"; import { fmtDate, todayIso } from "@/lib/format"; import type { Database } from "@/lib/supabase/types"; @@ -38,13 +39,9 @@ export function AngehoerigeSection({ employeeId, dependents, effectiveDate }: {

Angehörige

- + {dependents.length} Personen
@@ -73,15 +70,15 @@ export function AngehoerigeSection({ employeeId, dependents, effectiveDate }: { {d.sv_nummer ?? "–"} {fmtDate(d.birth_date)} - + ))} diff --git a/components/employees/EmployeeDetail.tsx b/components/employees/EmployeeDetail.tsx index db3d575..cad1578 100644 --- a/components/employees/EmployeeDetail.tsx +++ b/components/employees/EmployeeDetail.tsx @@ -4,6 +4,7 @@ import { ArrowLeft, ArrowRightLeft, Clock, Pencil, RotateCcw, TrendingUp, XCircl import Link from "next/link"; import { useState } from "react"; import { Avatar } from "@/components/ui/Avatar"; +import { Button } from "@/components/ui/Button"; import { StatusChip } from "@/components/ui/StatusChip"; import { fmtFullName, tenure } from "@/lib/format"; import type { Database } from "@/lib/supabase/types"; @@ -97,31 +98,34 @@ export function EmployeeDetail(props: EmployeeDetailProps) { )} {canEditData && setPanel("daten")} />} {isActive && ( - + )} {employee.status === "Ausgetreten" && ( - + )}
-
+ {/* Tabs, so the list gets the tablist role and each button says + whether it is the selected one. */} +
{TABS.map((t) => ( + ); } diff --git a/components/employees/EmployeeFilters.tsx b/components/employees/EmployeeFilters.tsx index 17ff347..78e78b8 100644 --- a/components/employees/EmployeeFilters.tsx +++ b/components/employees/EmployeeFilters.tsx @@ -1,8 +1,9 @@ "use client"; -import { Search } from "lucide-react"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { useEffect, useState } from "react"; +import { FILTER_SELECT_CLASS } from "@/components/ui/Field"; +import { SearchInput } from "@/components/ui/SearchInput"; type EmployeeFiltersProps = { divisions: { id: string; name: string }[]; @@ -40,19 +41,15 @@ export function EmployeeFilters({ divisions, locations }: EmployeeFiltersProps) return (
-
- - setQ(e.target.value)} - placeholder="Name, Pers.-Nr., Titel…" - className="w-full text-sm text-ink outline-none placeholder:text-ink-muted" - /> -
+ + {/* aria-label rather than a visible label: the filter bar is a single + horizontal row, and each select's first option already names it on + screen. */} onChange({ workerType: e.target.value as WorkerType })} - className="w-full rounded border border-border px-3 py-2 text-sm" - > - - - -
-
- - -
+ onChange({ workerType: v as WorkerType })} + options={[ + { value: "Angestellte:r", label: "Angestellte:r" }, + { value: "Arbeiter:in", label: "Arbeiter:in" }, + ]} + /> + onChange({ collectiveAgreement: v as CollectiveAgreement })} + options={[ + { value: "Handel", label: "Handel" }, + { value: "Süßwaren", label: "Süßwaren" }, + ]} + />
-
- + {/* Toggle group, not a set of fields: a fieldset names the group, and + aria-pressed is what tells a screen reader a day is selected — + colour alone does not. */} +
+ Arbeitstage
{WEEKDAYS.map((day) => ( ))}
-
+