Files
alpenwerk-hr/components/ui/Field.tsx
Andrei Laas f7a5c48615
Some checks failed
CI / Lint, Typen, Tests, Build (push) Successful in 11m45s
CI / Migrationen auf leerer Datenbank (push) Has been cancelled
Die Oberflaeche auf das Manner-CD umstellen
Farben, Schrift, Logo und Symbole folgen jetzt dem CD Manual 2022. An der
Logik aendert sich nichts: keine Migration, keine Abfrage, keine Berechtigung.

Die zwei Markenfarben stehen in §1.1 als #F69686 (Rosa) und #164194 (Blau).
Welche davon was traegt, ist nicht gewaehlt, sondern nachgerechnet: Weiss auf
Rosa kommt auf 2.18:1 und faellt damit auch fuer Grossschrift durch, Blau auf
Rosa auf 4.34:1 und reicht fuer das Logo, nicht fuer Text. Schwarz auf Rosa
sind 7.47:1, Weiss auf Blau 9.47:1. Rosa ist deshalb Flaeche, Blau ist
Interaktion — zwei Skalen, brand-* und accent-*, statt einer geteilten.

Von den 26 Stellen mit brand-50/100/200 sind nur sieben auf accent gewandert.
Der Rest meint einen Zustand und keinen Markenton: die Zeile unter dem Zeiger,
der gewaehlte Listeneintrag, der aktive Menuepunkt. Gegen die warme
Seitenflaeche ist ein kuehler Ton dort deutlicher, und Blau heisst in dieser
Oberflaeche ab jetzt "reagiert auf dich". Nach accent gingen die Faelle ohne
eigene Bedeutung — "Geplant", "Offen", der neutrale Protokoll-Chip — und die
offene Planstelle im Organigramm, die vorher ein blasses Blau war und damit
wie eine schwaechere Person aussah.

Die Funktionsfarben bleiben, was sie sind. Das Manual regelt die Identitaet,
nicht die Rueckmeldung: Rot heisst Fehler, weil die Benutzerin das mitbringt.
`info` bleibt bewusst tuerkis — Blau saehe ab jetzt bedienbar aus, und gemessen
kaeme ein blaues info dem violetten Chip auf dE 6.8 nahe, also nicht
unterscheidbar. Violett ist dabei nachgezogen: gegen den Fehler-Chip stand es
bei dE 8.0, "Austritt" und "Befoerderung" waren im Vorbeigehen dieselbe blasse
Flaeche. Jetzt dE 15.0.

Die Schrift ist Barlow. Nachgezaehlt ist das Manual zu 95 % in DIN gesetzt
(Regular 79 %, Bold 16 %); Helvetica Neue steht nur in den Visitenkarten und im
Claim. DIN laesst sich nicht ausliefern — eine Drucklizenz deckt keinen Webfont
—, und die freien Nachbauten der DIN 1451 sind Schilderschriften, bei 14 px in
einer langen Tabelle schlechter lesbar als das, was sie ersetzen. Die Variable
heisst --font-din und nicht --font-barlow: liegt eines Tages eine Web-Lizenz
vor, ist der Wechsel diese eine Deklaration.

Nebenbei zwei Dinge repariert, die vorher schon falsch waren. Die Umrandung von
Eingabefeldern, Knoepfen und Suchfeldern lag bei 1.30:1 und damit unter den 3:1,
die WCAG 1.4.11 fuer Bedienelemente verlangt; border-strong bringt 3.56:1. Und
das mitgelieferte favicon.ico liess sich gar nicht bauen: eingebettet waren
24-Bit-RGB-PNG, waehrend der Kopf 32 bpp behauptete. Alle Rastersymbole liegen
jetzt als RGBA vor, und ihr Blau ist auf den CD-Wert gezogen — samt der
kantengeglaetteten Raender, indem je Pixel der Blauanteil bestimmt und neu
gemischt wurde.

Das Logo ist das Markenlogo (§4.1), nicht das Unternehmenslogo, das §3.2 fuer
eine Anwendung mit der AG als Absender vorsaehe — es liegt nicht vor. Der
Freiraum X/3 steckt im Bauteil selbst und nicht in den Aufrufstellen, sonst
haengt seine Einhaltung daran, dass jede einzelne daran denkt.

docs/farbschema.html zeigt Token, Kontraste und Bauteile nebeneinander und
laesst sich ohne Server oeffnen.

Nicht im Browser gesehen: Anmeldung laeuft ueber das Firmenkonto und die
Datenbank ist von hier nicht erreichbar. Lint, Typen, Schemaabgleich, 524 Tests
und der Build sind sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 19:26:21 +02:00

188 lines
6.0 KiB
TypeScript

"use client";
import { useId, 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>
);
}
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>
);
}