Files
alpenwerk-hr/components/ui/Field.tsx
Andrei Laas 96c94642ba
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 11m50s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m13s
Datumsfelder lassen sich wieder tippen
In den Berichten hing jedes Datumsfeld unmittelbar an der Adresszeile: eine
Aenderung hiess router.push, also neu laden und neu rendern. Ein
<input type="date"> meldet beim Tippen der Jahreszahl aber viermal -- 0002,
0020, 0202, 2026 -- und die ersten drei loesten je eine Navigation aus, die das
Feld auf den Stand aus der Adresse zuruecksetzte. Mitten im Tippen. Mit dem
Kalender ging es, weil der in einem Zug ein fertiges Datum setzt; das war der
Hinweis darauf, wo es klemmt.

DateField haelt den Tippstand jetzt bei sich und meldet nur, was eine Aussage
ist: ein vollstaendiges Datum mit vierstelliger Jahreszahl ab 1000, oder das
Leeren des Feldes. Beim Verlassen wird nachgereicht, was liegengeblieben ist.
Der Stichtag im Organigramm ist ein blankes input und benutzt dieselbe Regel
ueber istMeldbaresDatum -- zwei Fassungen davon liefen hier erfahrungsgemaess
auseinander.

Der Abgleich mit dem Wert von aussen laeuft waehrend des Renderns, nicht in
einem Effekt: der liefe erst nach dem Zeichnen, das Feld zeigte also fuer einen
Bildaufbau den alten Stand -- und die Regel gegen setState im Effekt verbietet
ihn aus genau diesem Grund.

Die Felder in den Panels bleiben, wie sie sind: dort ist der Zustand lokal, es
gibt keine Navigation und damit auch kein Zuruecksetzen.
2026-09-29 21:39:35 +02:00

263 lines
8.7 KiB
TypeScript

"use client";
import {
useId,
useState,
type ReactNode,
type SelectHTMLAttributes,
type InputHTMLAttributes,
type TextareaHTMLAttributes,
} from "react";
// Form primitives.
//
// Before these existed the same class chain was written out by hand at 85
// call sites, each with a bare `<label>` next to a bare `<input>` and no
// connection between them — 92 labels, 4 of which used htmlFor, and not a
// single input carried an id. Screen readers announced an unnamed edit box
// and clicking a label focused nothing. Generating the id here makes that
// impossible to get wrong, and gives every field one place to fix focus
// styling, error display and sizing.
// `border-strong` und nicht `border`: der blasse Kartenrahmen kommt gegen
// Weiss auf 1.3:1 und ist als Schmuck in Ordnung, die Umrandung eines
// Bedienelements aber nicht — WCAG 1.4.11 verlangt dafuer 3:1, und ohne sie
// ist auf einem hellen Bildschirm nicht zu sehen, wo ein Feld anfaengt.
// border-strong liegt bei 3.56:1.
export const CONTROL_CLASS =
"w-full rounded border border-border-strong bg-white px-3 py-2 text-sm text-ink " +
"focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-brand-500 " +
"disabled:cursor-not-allowed disabled:border-border disabled:bg-surface disabled:text-ink-muted";
const INVALID_CLASS = "border-danger-solid";
/** Auto-width select for filter bars, where the label is an aria-label. */
export const FILTER_SELECT_CLASS =
"rounded border border-border-strong bg-white px-3 py-2 text-sm text-ink " +
"focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-brand-500";
type FieldChildProps = {
id: string;
"aria-describedby": string | undefined;
"aria-invalid": true | undefined;
};
type FieldShellProps = {
label: string;
/** Marks the label and sets required on the control. */
required?: boolean;
/** Helper text below the control; announced with the field. */
hint?: string;
/** Replaces the hint when set and marks the control invalid. */
error?: string | null;
/** Smaller label, used inside dense side panels. */
dense?: boolean;
className?: string;
};
/**
* Escape hatch for controls this module does not wrap (Lookup, CountryPicker,
* Picklist). Hands the wiring to the caller instead of guessing at it:
*
* <Field label="Land">{(p) => <CountryPicker {...p} … />}</Field>
*/
export function Field({
label,
required,
hint,
error,
dense,
className,
children,
}: FieldShellProps & { children: (props: FieldChildProps) => ReactNode }) {
const id = useId();
const messageId = `${id}-message`;
const message = error ?? hint;
return (
<div className={className}>
<label htmlFor={id} className={dense ? "mb-1 block text-xs font-semibold text-ink-muted" : "mb-1 block text-sm font-semibold text-ink"}>
{label}
{required && <span aria-hidden> *</span>}
{required && <span className="sr-only"> (Pflichtfeld)</span>}
</label>
{children({
id,
"aria-describedby": message ? messageId : undefined,
"aria-invalid": error ? true : undefined,
})}
{message && (
<p id={messageId} className={`mt-1 text-xs ${error ? "font-semibold text-danger-text" : "text-ink-muted"}`}>
{message}
</p>
)}
</div>
);
}
type TextFieldProps = FieldShellProps &
Omit<InputHTMLAttributes<HTMLInputElement>, "onChange" | "id" | "className"> & {
value: string;
/** Receives the value directly — every call site wanted e.target.value. */
onChange: (value: string) => void;
};
export function TextField({ label, required, hint, error, dense, className, value, onChange, ...rest }: TextFieldProps) {
return (
<Field label={label} required={required} hint={hint} error={error} dense={dense} className={className}>
{(p) => (
<input
{...p}
{...rest}
required={required}
value={value}
onChange={(e) => onChange(e.target.value)}
className={`${CONTROL_CLASS} ${error ? INVALID_CLASS : ""}`}
/>
)}
</Field>
);
}
/** Ein vollständiges Datum mit vierstelliger Jahreszahl. */
const VOLLSTAENDIGES_DATUM = /^\d{4}-\d{2}-\d{2}$/;
/**
* Ob ein Datumswert eine Aussage ist — oder ein Zwischenstand beim Tippen.
*
* Exportiert, weil der Stichtag im Organigramm dieselbe Frage stellt und dort
* ein blankes `<input>` steht. Zwei Fassungen derselben Regel laufen in diesem
* Projekt erfahrungsgemäss auseinander.
*/
export function istMeldbaresDatum(wert: string): boolean {
// Leer ist eine Aussage — „kein Datum" —, und die soll durchkommen.
if (wert === "") return true;
if (!VOLLSTAENDIGES_DATUM.test(wert)) return false;
// Ein Jahr unter 1000 entsteht beim Tippen: wer „2026" eingibt, erzeugt
// unterwegs 0002, 0020 und 0202. Das sind gültige Daten und damit nicht von
// einer echten Eingabe zu unterscheiden — ausser an ihrer Grösse.
return Number(wert.slice(0, 4)) >= 1000;
}
/**
* Ein Datumsfeld, das Zwischenstände für sich behält.
*
* Der Anlass: in den Berichten hängt jedes Feld an der Adresszeile — eine
* Änderung heisst `router.push`, also neu laden und neu rendern. Ein
* `<input type="date">` meldet beim Tippen der Jahreszahl aber viermal:
* 0002, 0020, 0202, 2026. Die ersten drei lösten je eine Navigation aus, und
* jede setzte das Feld auf den Stand aus der Adresse zurück — mitten im
* Tippen. Mit der Maus über den Kalender ging es, weil der in einem Zug ein
* fertiges Datum setzt.
*
* Deshalb: der Tippstand bleibt hier, gemeldet wird nur, was eine Aussage ist
* — ein vollständiges Datum mit plausibler Jahreszahl, oder das Leeren des
* Feldes. Beim Verlassen wird nachgereicht, was bis dahin liegengeblieben ist.
*/
export function DateField({ value, onChange, onBlur, ...rest }: Omit<TextFieldProps, "type">) {
const [entwurf, setEntwurf] = useState(value);
const [zuletzt, setZuletzt] = useState(value);
// Von aussen gewinnt: „Zurücksetzen" in den Kriterien leert das Feld, und
// nach dem Melden steht hier ohnehin schon dasselbe.
//
// Während des Renderns angeglichen und nicht in einem Effekt: ein Effekt
// liefe erst nach dem Zeichnen, das Feld zeigte also für einen Bildaufbau
// den alten Stand — und die Regel „kein setState im Effekt" verbietet ihn
// aus genau diesem Grund.
if (value !== zuletzt) {
setZuletzt(value);
setEntwurf(value);
}
return (
<TextField
{...rest}
type="date"
value={entwurf}
onChange={(v) => {
setEntwurf(v);
if (istMeldbaresDatum(v) && v !== value) onChange(v);
}}
onBlur={(e) => {
if (entwurf !== value && istMeldbaresDatum(entwurf)) onChange(entwurf);
onBlur?.(e);
}}
/>
);
}
type Option = { value: string; label: string; disabled?: boolean };
type SelectFieldProps = FieldShellProps &
Omit<SelectHTMLAttributes<HTMLSelectElement>, "onChange" | "id" | "className" | "children"> & {
value: string;
onChange: (value: string) => void;
options: readonly Option[];
/** Prepends a disabled placeholder, for "Bitte wählen…" selects. */
placeholder?: string;
};
export function SelectField({
label,
required,
hint,
error,
dense,
className,
value,
onChange,
options,
placeholder,
...rest
}: SelectFieldProps) {
return (
<Field label={label} required={required} hint={hint} error={error} dense={dense} className={className}>
{(p) => (
<select
{...p}
{...rest}
required={required}
value={value}
onChange={(e) => onChange(e.target.value)}
className={`${CONTROL_CLASS} ${error ? INVALID_CLASS : ""}`}
>
{placeholder && (
<option value="" disabled>
{placeholder}
</option>
)}
{options.map((o) => (
<option key={o.value} value={o.value} disabled={o.disabled}>
{o.label}
</option>
))}
</select>
)}
</Field>
);
}
type TextareaFieldProps = FieldShellProps &
Omit<TextareaHTMLAttributes<HTMLTextAreaElement>, "onChange" | "id" | "className"> & {
value: string;
onChange: (value: string) => void;
};
export function TextareaField({ label, required, hint, error, dense, className, value, onChange, ...rest }: TextareaFieldProps) {
return (
<Field label={label} required={required} hint={hint} error={error} dense={dense} className={className}>
{(p) => (
<textarea
{...p}
{...rest}
required={required}
value={value}
onChange={(e) => onChange(e.target.value)}
className={`${CONTROL_CLASS} ${error ? INVALID_CLASS : ""}`}
/>
)}
</Field>
);
}