Form primitives, keyboard-operable comboboxes, dialog focus, route states
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.
This commit is contained in:
@@ -2,6 +2,8 @@
|
||||
|
||||
import { CalendarClock } from "lucide-react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { FILTER_SELECT_CLASS } from "@/components/ui/Field";
|
||||
import { fmtDate } from "@/lib/format";
|
||||
|
||||
type AsOfPickerProps = {
|
||||
@@ -44,12 +46,12 @@ export function AsOfPicker({ asOf, today, projectedCount, historyStartsAt }: AsO
|
||||
type="date"
|
||||
value={asOf}
|
||||
onChange={(e) => setAsOf(e.target.value || undefined)}
|
||||
className="rounded border border-border px-3 py-2 text-sm"
|
||||
className={FILTER_SELECT_CLASS}
|
||||
/>
|
||||
{!isToday && (
|
||||
<button type="button" onClick={() => setAsOf(undefined)} className="text-xs font-semibold text-brand-700 hover:underline">
|
||||
<Button variant="ghost" size="sm" onClick={() => setAsOf(undefined)} className="!px-1 text-brand-700 hover:!bg-transparent hover:underline">
|
||||
Heute
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
<span className="text-xs text-ink-muted">
|
||||
{isToday ? "Aktuelle Organisationsstruktur." : `Struktur zum ${fmtDate(asOf)}.`}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronDown, ChevronRight, Search } from "lucide-react";
|
||||
import { ChevronDown, ChevronRight } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Avatar } from "@/components/ui/Avatar";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { SearchInput } from "@/components/ui/SearchInput";
|
||||
import { SegmentedControl } from "@/components/ui/SegmentedControl";
|
||||
import { LazyGraphOrgChart } from "./LazyGraphOrgChart";
|
||||
import type { ChartNode, OrgEmployee } from "./types";
|
||||
@@ -167,29 +169,13 @@ export function EmployeeTree({ employees, focusId = null }: { employees: OrgEmpl
|
||||
return (
|
||||
<div className="rounded border border-border bg-white p-4">
|
||||
<div className="mb-4 flex flex-wrap items-center gap-3">
|
||||
<div className="flex min-w-[240px] flex-1 items-center gap-2 rounded border border-border px-3 py-2">
|
||||
<Search className="h-4 w-4 shrink-0 text-ink-muted" />
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Name, Pers.-Nr., Titel…"
|
||||
className="w-full text-sm text-ink outline-none placeholder:text-ink-muted"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded(new Set(root.map((r) => r.id)))}
|
||||
className="rounded border border-border px-3 py-1.5 text-sm font-semibold text-ink-body hover:bg-surface"
|
||||
>
|
||||
<SearchInput label="Organigramm durchsuchen" placeholder="Name, Pers.-Nr., Titel…" value={query} onChange={setQuery} />
|
||||
<Button variant="secondary" size="sm" onClick={() => setExpanded(new Set(root.map((r) => r.id)))}>
|
||||
Bereiche anzeigen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded(new Set())}
|
||||
className="rounded border border-border px-3 py-1.5 text-sm font-semibold text-ink-body hover:bg-surface"
|
||||
>
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" onClick={() => setExpanded(new Set())}>
|
||||
Alles einklappen
|
||||
</button>
|
||||
</Button>
|
||||
<SegmentedControl<ViewMode> value={mode} onChange={setMode} options={[{ value: "list", label: "Liste" }, { value: "graph", label: "Grafisch" }]} />
|
||||
</div>
|
||||
{mode === "list" ? (
|
||||
|
||||
@@ -4,6 +4,8 @@ import { RotateCcw, X } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMemo, useState } from "react";
|
||||
import { applyReorg, undoReorg, type ReorgMovePayload } from "@/actions/reorg";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Field, SelectField, TextField } from "@/components/ui/Field";
|
||||
import { Lookup } from "@/components/ui/Lookup";
|
||||
import { useToast } from "@/components/ui/Toast";
|
||||
import { fmtDate } from "@/lib/format";
|
||||
@@ -190,60 +192,62 @@ export function ReorgWorkbench({ employees, divisions, departments, teams, reorg
|
||||
<div className="rounded border border-border bg-white p-4">
|
||||
<h2 className="mb-3 text-sm font-bold text-ink">Neue Reorganisation</h2>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-semibold text-ink">Name der Reorganisation*</label>
|
||||
<input value={name} onChange={(e) => setName(e.target.value)} className="w-full rounded border border-border px-3 py-2 text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-semibold text-ink">Wirksam ab*</label>
|
||||
<input
|
||||
type="date"
|
||||
value={effectiveDate}
|
||||
onChange={(e) => setEffectiveDate(e.target.value)}
|
||||
className="w-full rounded border border-border px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<TextField label="Name der Reorganisation" required value={name} onChange={setName} />
|
||||
<TextField label="Wirksam ab" required type="date" value={effectiveDate} onChange={setEffectiveDate} />
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex gap-2">
|
||||
{(Object.keys(KIND_LABELS) as ChangeKind[]).map((kind) => (
|
||||
<button
|
||||
key={kind}
|
||||
type="button"
|
||||
onClick={() => setChangeType(kind)}
|
||||
className={`rounded px-3 py-1.5 text-sm font-semibold ${
|
||||
changeType === kind ? "bg-brand-500 text-white" : "border border-border text-ink-body hover:bg-surface"
|
||||
}`}
|
||||
>
|
||||
{KIND_LABELS[kind]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<fieldset className="mt-4">
|
||||
<legend className="sr-only">Art der Änderung</legend>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(Object.keys(KIND_LABELS) as ChangeKind[]).map((kind) => (
|
||||
<button
|
||||
key={kind}
|
||||
type="button"
|
||||
aria-pressed={changeType === kind}
|
||||
onClick={() => setChangeType(kind)}
|
||||
className={`rounded px-3 py-1.5 text-sm font-semibold focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500 ${
|
||||
changeType === kind ? "bg-brand-500 text-white" : "border border-border text-ink-body hover:bg-surface"
|
||||
}`}
|
||||
>
|
||||
{KIND_LABELS[kind]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div className="mt-4 grid grid-cols-2 gap-3">
|
||||
<div className="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-semibold text-ink">Quelle</label>
|
||||
{changeType === "emp" && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Lookup<OrgEmployee>
|
||||
placeholder="Mitarbeiter:in suchen…"
|
||||
onSearch={searchLocalEmployees}
|
||||
onSelect={(e) => setSelectedEmployees((prev) => [...prev, e])}
|
||||
renderResult={(e) => (
|
||||
<div>
|
||||
<div className="font-semibold text-ink">
|
||||
{e.first_name} {e.last_name}
|
||||
</div>
|
||||
<div className="text-xs text-ink-muted">{e.job_title}</div>
|
||||
</div>
|
||||
<Field label="Quelle: Mitarbeiter:innen">
|
||||
{(p) => (
|
||||
<Lookup<OrgEmployee>
|
||||
{...p}
|
||||
placeholder="Mitarbeiter:in suchen…"
|
||||
onSearch={searchLocalEmployees}
|
||||
onSelect={(e) => setSelectedEmployees((prev) => [...prev, e])}
|
||||
renderResult={(e) => (
|
||||
<div>
|
||||
<div className="font-semibold text-ink">
|
||||
{e.first_name} {e.last_name}
|
||||
</div>
|
||||
<div className="text-xs text-ink-muted">{e.job_title}</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Field>
|
||||
{selectedEmployees.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{selectedEmployees.map((e) => (
|
||||
<span key={e.id} className="flex items-center gap-1 rounded-full bg-brand-100 px-2.5 py-1 text-xs font-semibold text-brand-700">
|
||||
{e.first_name} {e.last_name}
|
||||
<button type="button" onClick={() => setSelectedEmployees((prev) => prev.filter((s) => s.id !== e.id))}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${e.first_name} ${e.last_name} aus der Auswahl entfernen`}
|
||||
onClick={() => setSelectedEmployees((prev) => prev.filter((s) => s.id !== e.id))}
|
||||
className="rounded focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-brand-500"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
@@ -253,78 +257,60 @@ export function ReorgWorkbench({ employees, divisions, departments, teams, reorg
|
||||
</div>
|
||||
)}
|
||||
{changeType === "team" && (
|
||||
<select value={sourceTeamId} onChange={(e) => setSourceTeamId(e.target.value)} className="w-full rounded border border-border px-3 py-2 text-sm">
|
||||
<option value="">Team wählen…</option>
|
||||
{teams.map((t) => (
|
||||
<option key={t.id} value={t.id}>
|
||||
{t.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<SelectField
|
||||
label="Quelle: Team"
|
||||
value={sourceTeamId}
|
||||
onChange={setSourceTeamId}
|
||||
placeholder="Team wählen…"
|
||||
options={teams.map((t) => ({ value: t.id, label: t.name }))}
|
||||
/>
|
||||
)}
|
||||
{changeType === "abt" && (
|
||||
<select value={sourceDeptId} onChange={(e) => setSourceDeptId(e.target.value)} className="w-full rounded border border-border px-3 py-2 text-sm">
|
||||
<option value="">Abteilung wählen…</option>
|
||||
{departments.map((d) => (
|
||||
<option key={d.id} value={d.id}>
|
||||
{d.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<SelectField
|
||||
label="Quelle: Abteilung"
|
||||
value={sourceDeptId}
|
||||
onChange={setSourceDeptId}
|
||||
placeholder="Abteilung wählen…"
|
||||
options={departments.map((d) => ({ value: d.id, label: d.name }))}
|
||||
/>
|
||||
)}
|
||||
{changeType === "dept" && (
|
||||
<select
|
||||
<SelectField
|
||||
label="Quelle: Bereich"
|
||||
value={sourceDivisionId}
|
||||
onChange={(e) => setSourceDivisionId(e.target.value)}
|
||||
className="w-full rounded border border-border px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">Bereich wählen…</option>
|
||||
{divisions.map((d) => (
|
||||
<option key={d.id} value={d.id}>
|
||||
{d.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
onChange={setSourceDivisionId}
|
||||
placeholder="Bereich wählen…"
|
||||
options={divisions.map((d) => ({ value: d.id, label: d.name }))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-semibold text-ink">Ziel-Bereich* / Ziel-Team*</label>
|
||||
<div className="flex flex-col gap-2">
|
||||
<select
|
||||
value={targetDivisionId}
|
||||
onChange={(e) => {
|
||||
setTargetDivisionId(e.target.value);
|
||||
setTargetTeamId("");
|
||||
}}
|
||||
className="w-full rounded border border-border px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">Ziel-Bereich wählen…</option>
|
||||
{divisions.map((d) => (
|
||||
<option key={d.id} value={d.id}>
|
||||
{d.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select value={targetTeamId} onChange={(e) => setTargetTeamId(e.target.value)} className="w-full rounded border border-border px-3 py-2 text-sm">
|
||||
<option value="">Ziel-Team wählen…</option>
|
||||
{teamsInTargetDivision.map((t) => (
|
||||
<option key={t.id} value={t.id}>
|
||||
{t.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<SelectField
|
||||
label="Ziel-Bereich"
|
||||
required
|
||||
value={targetDivisionId}
|
||||
onChange={(v) => {
|
||||
setTargetDivisionId(v);
|
||||
setTargetTeamId("");
|
||||
}}
|
||||
placeholder="Ziel-Bereich wählen…"
|
||||
options={divisions.map((d) => ({ value: d.id, label: d.name }))}
|
||||
/>
|
||||
<SelectField
|
||||
label="Ziel-Team"
|
||||
required
|
||||
value={targetTeamId}
|
||||
onChange={setTargetTeamId}
|
||||
placeholder="Ziel-Team wählen…"
|
||||
options={teamsInTargetDivision.map((t) => ({ value: t.id, label: t.name }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddMove}
|
||||
className="mt-4 rounded border border-border px-4 py-2 text-sm font-semibold text-ink-body hover:bg-surface"
|
||||
>
|
||||
<Button variant="secondary" onClick={handleAddMove} className="mt-4">
|
||||
+ Zur Reorganisation hinzufügen
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{pendingMoves.length > 0 && (
|
||||
@@ -340,9 +326,14 @@ export function ReorgWorkbench({ employees, divisions, departments, teams, reorg
|
||||
</span>
|
||||
<span className="ml-2 text-xs text-ink-muted">({m.employeeIds.length} Mitarbeiter:innen)</span>
|
||||
</div>
|
||||
<button type="button" onClick={() => removeMove(m.id)} aria-label="Entfernen" className="text-ink-muted hover:text-danger-solid">
|
||||
<Button
|
||||
variant="icon"
|
||||
onClick={() => removeMove(m.id)}
|
||||
aria-label={`${m.label} aus der Reorganisation entfernen`}
|
||||
className="hover:!text-danger-solid"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -376,22 +367,13 @@ export function ReorgWorkbench({ employees, divisions, departments, teams, reorg
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div className="mt-4 flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPendingMoves([])}
|
||||
className="rounded border border-border px-4 py-2 text-sm font-semibold text-ink-body hover:bg-surface"
|
||||
>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<Button variant="secondary" onClick={() => setPendingMoves([])}>
|
||||
Verwerfen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleApply}
|
||||
disabled={applying}
|
||||
className="rounded bg-brand-500 px-4 py-2 text-sm font-semibold text-white disabled:opacity-50"
|
||||
>
|
||||
</Button>
|
||||
<Button onClick={handleApply} pending={applying}>
|
||||
Reorganisation durchführen
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -408,15 +390,10 @@ export function ReorgWorkbench({ employees, divisions, departments, teams, reorg
|
||||
wirksam ab {fmtDate(s.effective_date)} · durchgeführt am {fmtDate(s.applied_at)}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleUndo(s.id)}
|
||||
disabled={undoingId === s.id}
|
||||
className="flex items-center gap-1.5 rounded border border-border px-3 py-1.5 text-xs font-semibold text-ink-body hover:bg-surface disabled:opacity-50"
|
||||
>
|
||||
<Button variant="secondary" size="sm" onClick={() => handleUndo(s.id)} pending={undoingId === s.id}>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
Rückgängig machen
|
||||
</button>
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
Reference in New Issue
Block a user