initial commit
This commit is contained in:
@@ -0,0 +1,246 @@
|
||||
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}
|
||||
/>
|
||||
------------------------------------------------------------------------- */
|
||||
Reference in New Issue
Block a user