initial commit

This commit is contained in:
2026-09-11 02:46:56 +02:00
commit 20b384039c
288 changed files with 46557 additions and 0 deletions
+141
View File
@@ -0,0 +1,141 @@
/* ============================================================================
INPUT / TEXTAREA - figma frame 18:212 (Type x State)
----------------------------------------------------------------------------
Only TIER-2 semantic tokens. The label, hint and error colours are driven off
two data attributes on the group (`data-invalid`, `data-disabled`) plus
`:focus-within`, so the whole 2x5 matrix is a handful of rules.
Layout (the column and its gaps) is plain Tailwind spacing utilities in
Input.tsx, and they name a step of the spacing scale (`gap-xs` = 8, `ms-2xs`
= 4) rather than the raw `gap-1.5` / `ms-0.5` multiples of --spacing, which
are tier-1. Everything token-driven lives here.
BOX. As with the button, Figma strokes inside the frame, so the 1.5px border
is subtracted from the 16/12 padding and the outer edge stays where it was
drawn.
DEVIATIONS FROM FIGMA, deliberate:
* Height. Figma hand-sets 44px, which does not agree with its own 12px
padding and 24px Body/Medium line-height. Following the tokens gives 48px,
which is also exactly the Medium button, so a field and a button sitting on
one row line up. Textarea keeps Figma's 120px, reached honestly as
rows={4}: 4*24 + 10.5*2 + 1.5*2.
* Focus. Figma paints a 2px grey (`stroke/hover`) border, which both moves
the box by half a pixel and is not a WCAG-visible focus indicator. The
border instead turns `--field-border-focus` at an unchanged 1.5px and the
semantic focus ring is drawn outside it. The green label on focus is kept.
* Disabled. Figma dims the whole group to 50%. The `--field-*-disabled`
tokens already encode that reading and stay opaque, so no group opacity is
applied and text on a translucent dark surface cannot smear.
* The error caption is Figma's "Caption" role (Regular 12), which the type
scale does not publish. It uses `body-sm` (14/20), the smallest published
body role - more legible for a message a user has to act on.
========================================================================= */
@layer theme, base, components, utilities;
@layer components {
/* ---------------------------------------------------------------------
Label, hint, error
------------------------------------------------------------------ */
.sh-field__label {
color: var(--text-primary);
transition: color var(--motion-duration-fast) var(--motion-ease-standard);
}
.sh-field:focus-within .sh-field__label {
color: var(--text-accent);
}
.sh-field[data-invalid="true"] .sh-field__label,
.sh-field[data-invalid="true"]:focus-within .sh-field__label {
color: var(--text-danger);
}
.sh-field[data-disabled="true"] .sh-field__label {
color: var(--text-disabled);
}
.sh-field__required {
color: var(--text-danger);
}
.sh-field__hint {
color: var(--text-secondary);
}
.sh-field[data-disabled="true"] .sh-field__hint {
color: var(--text-disabled);
}
.sh-field__error {
color: var(--text-danger);
}
/* ---------------------------------------------------------------------
The control itself
------------------------------------------------------------------ */
.sh-field__control {
display: block;
inline-size: 100%;
appearance: none;
background-color: var(--field-bg);
color: var(--field-fg);
border: var(--control-border-width) solid var(--field-border);
border-radius: var(--radius-control-lg);
padding-inline: calc(var(--control-padding-inline-md) - var(--control-border-width));
padding-block: calc(var(--control-padding-block-md) - var(--control-border-width));
text-align: start;
transition:
background-color var(--motion-duration-fast) var(--motion-ease-standard),
border-color var(--motion-duration-fast) var(--motion-ease-standard),
color var(--motion-duration-fast) var(--motion-ease-standard);
}
.sh-field__control::placeholder {
color: var(--field-placeholder);
opacity: 1;
}
@media (hover: hover) {
.sh-field__control:hover:not(:disabled) {
border-color: var(--field-border-hover);
}
}
/* Matched to the hover rule's specificity and placed after it, so focusing
a field the pointer happens to be over still shows the focus border. */
.sh-field .sh-field__control:focus-visible {
border-color: var(--field-border-focus);
outline: var(--focus-ring-width) solid var(--focus-ring-color);
outline-offset: var(--focus-ring-offset);
}
.sh-field[data-invalid="true"] .sh-field__control {
border-color: var(--field-border-error);
}
.sh-field[data-invalid="true"] .sh-field__control:focus-visible {
border-color: var(--field-border-error);
outline-color: var(--field-border-error);
}
/* Last, and specific enough to win over the invalid border - a disabled
field reads as disabled even when it is still holding an error. */
.sh-field .sh-field__control:disabled {
background-color: var(--field-bg-disabled);
color: var(--field-fg-disabled);
border-color: var(--border-subtle);
cursor: not-allowed;
}
.sh-field .sh-field__control:disabled::placeholder {
color: var(--field-fg-disabled);
}
/* Only the block axis resizes - growing a textarea sideways would break the
column it sits in. */
.sh-field__control--textarea {
resize: vertical;
}
}
+246
View File
@@ -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}
/>
------------------------------------------------------------------------- */
@@ -0,0 +1,2 @@
export { Input, Textarea } from "./Input";
export type { InputProps, TextareaProps } from "./Input";