247 lines
6.8 KiB
TypeScript
247 lines
6.8 KiB
TypeScript
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}
|
||
|
|
/>
|
||
|
|
------------------------------------------------------------------------- */
|