mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-01 00:13:37 +00:00
71 lines
2.5 KiB
TypeScript
71 lines
2.5 KiB
TypeScript
"use client";
|
|
|
|
import * as React from "react";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
type ControlProps = {
|
|
id?: string;
|
|
"aria-describedby"?: string;
|
|
"aria-invalid"?: boolean | "true" | "false";
|
|
"aria-required"?: boolean | "true" | "false";
|
|
};
|
|
|
|
export interface FieldProps {
|
|
/** The visible label. It names the control, so a placeholder never has to. */
|
|
label: React.ReactNode;
|
|
/** One line under the control, read with it. Hidden while there is an error. */
|
|
hint?: React.ReactNode;
|
|
/** The error under the control: read with it, announced, and marks it invalid. */
|
|
error?: React.ReactNode;
|
|
/** Draws the asterisk and sets aria-required on the control. */
|
|
required?: boolean;
|
|
/** Keep the label for screen readers only (a field whose look already says what it is). */
|
|
hideLabel?: boolean;
|
|
className?: string;
|
|
labelClassName?: string;
|
|
/** The one control (Input, select, textarea, ...). */
|
|
children: React.ReactElement<ControlProps>;
|
|
}
|
|
|
|
/**
|
|
* A labelled form field: label above, control, hint or error below (the
|
|
* plugin kit's Field, for the app). The control gets an id when it has none,
|
|
* the label points at it, and the hint or error is tied to it with
|
|
* aria-describedby, so a screen reader reads all three together.
|
|
*
|
|
* ```tsx
|
|
* <Field label={t("title")} error={titleError}>
|
|
* <Input value={title} onChange={...} />
|
|
* </Field>
|
|
* ```
|
|
*/
|
|
export function Field({ label, hint, error, required = false, hideLabel = false, className, labelClassName, children }: FieldProps) {
|
|
const autoId = React.useId();
|
|
const controlId = children.props.id ?? `${autoId}-control`;
|
|
const noteId = `${autoId}-note`;
|
|
const note = error || hint;
|
|
const control = React.cloneElement(children, {
|
|
id: controlId,
|
|
"aria-describedby": [children.props["aria-describedby"], note ? noteId : null].filter(Boolean).join(" ") || undefined,
|
|
...(error ? { "aria-invalid": true } : {}),
|
|
...(required ? { "aria-required": true } : {}),
|
|
});
|
|
return (
|
|
<div className={className}>
|
|
<label
|
|
htmlFor={controlId}
|
|
className={cn(hideLabel ? "sr-only" : "block text-sm font-medium text-foreground mb-1.5", labelClassName)}
|
|
>
|
|
{label}
|
|
{required && <span className="text-destructive" aria-hidden="true"> *</span>}
|
|
</label>
|
|
{control}
|
|
{error ? (
|
|
<p id={noteId} role="alert" className="mt-1 text-sm text-destructive">{error}</p>
|
|
) : hint ? (
|
|
<p id={noteId} className="mt-1 text-xs text-muted-foreground">{hint}</p>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|