Files

247 lines
6.8 KiB
TypeScript
Raw Permalink Normal View History

2026-09-11 02:46:56 +02:00
import { useId } from "react";
import type { ComponentPropsWithRef, ReactNode } from "react";
import "./Input.css";
/** Everything the label / hint / error scaffolding needs, shared by both controls. */
type FieldShellProps = {
/** Visible, always-present label. Never omit it - a placeholder is not a label. */
label: ReactNode;
/** Helper text under the control. Announced through `aria-describedby`. */
hint?: ReactNode;
/** Validation message. Turns the field red and is announced via `role="alert"`. */
error?: ReactNode;
required?: boolean;
/** Auto-generated from `useId()` when omitted, so SSR and hydration agree. */
id?: string;
/** Applied to the field group (label + control + messages). */
className?: string;
/** Applied to the `<input>` / `<textarea>` itself. */
inputClassName?: string;
};
type NativeInputProps = Omit<
ComponentPropsWithRef<"input">,
keyof FieldShellProps | "aria-invalid"
>;
type NativeTextareaProps = Omit<
ComponentPropsWithRef<"textarea">,
keyof FieldShellProps | "aria-invalid"
>;
export type InputProps = FieldShellProps & NativeInputProps;
export type TextareaProps = FieldShellProps & NativeTextareaProps;
function cx(...parts: Array<string | false | null | undefined>): string {
return parts.filter(Boolean).join(" ");
}
type FieldWiring = {
fieldId: string;
hintId: string;
errorId: string;
describedBy: string | undefined;
};
/**
* Stable ids for the control and its two messages, plus the `aria-describedby`
* that points at whichever of them are actually rendered. Any `aria-describedby`
* the caller supplied is preserved and appended rather than overwritten.
*/
function useFieldWiring(
id: string | undefined,
hasHint: boolean,
hasError: boolean,
callerDescribedBy: string | undefined,
): FieldWiring {
const generatedId = useId();
const fieldId = id ?? `sh-field-${generatedId}`;
const hintId = `${fieldId}-hint`;
const errorId = `${fieldId}-error`;
const describedBy =
[hasHint ? hintId : null, hasError ? errorId : null, callerDescribedBy ?? null]
.filter(Boolean)
.join(" ") || undefined;
return { fieldId, hintId, errorId, describedBy };
}
type FieldShellRenderProps = FieldShellProps & {
wiring: FieldWiring;
disabled?: boolean;
children: ReactNode;
};
function FieldShell({
label,
hint,
error,
required = false,
className,
wiring,
disabled,
children,
}: FieldShellRenderProps) {
return (
<div
className={cx("sh-field flex flex-col gap-xs", className)}
data-invalid={error ? "true" : undefined}
data-disabled={disabled ? "true" : undefined}
>
<label className="sh-field__label label-md" htmlFor={wiring.fieldId}>
{label}
{required ? (
<span className="sh-field__required ms-2xs" aria-hidden="true">
*
</span>
) : null}
</label>
{children}
{hint ? (
<p id={wiring.hintId} className="sh-field__hint body-sm">
{hint}
</p>
) : null}
{/* role="alert" so a message that appears after a failed submit is spoken. */}
{error ? (
<p id={wiring.errorId} role="alert" className="sh-field__error body-sm">
{error}
</p>
) : null}
</div>
);
}
/**
* Single-line text field - figma frame 18:212, Type=Text.
*
* The Default / Focused / Filled / Error / Disabled states of the design are all
* driven by the control's own state, so there is no `state` prop to keep in
* sync: pass `error` to get the error state, `disabled` to get the disabled one.
*/
export function Input({
label,
hint,
error,
required = false,
id,
className,
inputClassName,
disabled,
...rest
}: InputProps) {
const wiring = useFieldWiring(id, Boolean(hint), Boolean(error), rest["aria-describedby"]);
return (
<FieldShell
label={label}
hint={hint}
error={error}
required={required}
className={className}
wiring={wiring}
disabled={disabled}
>
<input
{...rest}
id={wiring.fieldId}
disabled={disabled}
required={required}
aria-invalid={error ? true : undefined}
aria-describedby={wiring.describedBy}
className={cx("sh-field__control body-md", inputClassName)}
/>
</FieldShell>
);
}
/**
* Multi-line text field - figma frame 18:212, Type=Textarea.
*
* `rows` defaults to 4, which is what makes the box land on the 120px Figma
* drew: 4 lines of 24 + 2 x 10.5 padding + 2 x 1.5 border.
*/
export function Textarea({
label,
hint,
error,
required = false,
id,
className,
inputClassName,
disabled,
rows = 4,
...rest
}: TextareaProps) {
const wiring = useFieldWiring(id, Boolean(hint), Boolean(error), rest["aria-describedby"]);
return (
<FieldShell
label={label}
hint={hint}
error={error}
required={required}
className={className}
wiring={wiring}
disabled={disabled}
>
<textarea
{...rest}
id={wiring.fieldId}
rows={rows}
disabled={disabled}
required={required}
aria-invalid={error ? true : undefined}
aria-describedby={wiring.describedBy}
className={cx(
"sh-field__control sh-field__control--textarea body-md",
inputClassName,
)}
/>
</FieldShell>
);
}
/* ----------------------------------------------------------------------------
USAGE (kept as a comment - the project has no Storybook)
import { Input, Textarea } from "~/components/ui/Input";
// Default. The id is generated, the label is wired to it with htmlFor.
<Input label="Full name" placeholder="Enter value..." />
// Required: a red asterisk for sighted users, the native `required`
// attribute (which is what actually gets announced) for everyone else.
<Input label="Email address" type="email" required autoComplete="email" />
// Hint and error together - aria-describedby points at both, in order.
<Input
label="Organisation"
hint="The legal name as it appears on your registration."
error="Enter the organisation's legal name."
defaultValue="United Efforts"
/>
// Disabled.
<Input label="Reference number" defaultValue="SHARP-ER/2026/014" disabled />
// Textarea - rows={4} is the 120px box from the design.
<Textarea label="Message" placeholder="Enter value..." />
<Textarea label="Message" rows={8} error="Tell us how we can help." />
// Contact form, controlled, with a submit button from the Button barrel.
const [email, setEmail] = useState("");
<Input
label="Email address"
type="email"
required
value={email}
onChange={(event) => setEmail(event.target.value)}
error={touched && !email ? "Enter your email address." : undefined}
/>
------------------------------------------------------------------------- */