Files
alpenwerk-hr/components/hire/StepPosition.tsx
Maximilian Stubhan d9367a8ce4 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.
2026-07-25 13:11:09 +02:00

88 lines
3.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { X } from "lucide-react";
import { Button } from "@/components/ui/Button";
import { Field, SelectField, TextField } from "@/components/ui/Field";
import { Lookup } from "@/components/ui/Lookup";
import { fmtDate } from "@/lib/format";
import type { OpenPositionResolved } from "@/lib/positions";
import type { HireDraftData } from "./types";
type StepPositionProps = {
draft: HireDraftData;
update: (patch: Partial<HireDraftData>) => void;
openPositions: OpenPositionResolved[];
};
export function StepPosition({ draft, update, openPositions }: StepPositionProps) {
const selected = openPositions.find((p) => p.id === draft.positionId) ?? null;
async function search(query: string): Promise<OpenPositionResolved[]> {
const q = query.toLowerCase();
return openPositions.filter((p) => p.title.toLowerCase().includes(q) || p.position_number.includes(q));
}
return (
<div className="flex flex-col gap-4">
{!selected ? (
<Field
label="Position"
required
hint={openPositions.length === 0 ? "Derzeit sind keine offenen Positionen vorhanden." : undefined}
>
{(p) => (
<Lookup<OpenPositionResolved>
{...p}
placeholder="Positionsname oder -nummer…"
onSearch={search}
onSelect={(pos) => update({ positionId: pos.id })}
renderResult={(pos) => (
<div>
<div className="font-semibold text-ink">{pos.title}</div>
<div className="text-xs text-ink-muted">
{pos.position_number} · {pos.orgLabel}
</div>
</div>
)}
/>
)}
</Field>
) : (
<div>
<p className="mb-1 block text-sm font-semibold text-ink">Position</p>
<div className="flex items-center justify-between rounded border border-border bg-surface p-3">
<div>
<div className="text-sm font-semibold text-ink">{selected.title}</div>
<div className="text-xs text-ink-muted">
{selected.position_number} · {selected.orgLabel}
</div>
<div className="text-xs text-ink-muted">Gültig ab {fmtDate(selected.valid_from)}</div>
</div>
<Button variant="icon" onClick={() => update({ positionId: "" })} aria-label="Auswahl aufheben">
<X className="h-4 w-4" />
</Button>
</div>
</div>
)}
<SelectField
label="Besetzung"
required
value={draft.besetzung}
onChange={(v) => update({ besetzung: v as HireDraftData["besetzung"] })}
placeholder="Bitte wählen…"
options={[
{ value: "Extern", label: "Extern" },
{ value: "Intern", label: "Intern" },
]}
/>
<TextField
label="Führungskraft"
value={selected?.managerName ?? ""}
onChange={() => {}}
disabled
hint="Ergibt sich aus der gewählten Position."
/>
</div>
);
}