Component
Form Fields
Every editable input flows through one component, wired to the --components-form-field-* tokens. Use FormField for Desktop / Tablet and MobileFormField for the Mobile Application — switch tabs to compare.
Playground
Type
Size
State
Label
Required asterisk
Hint text
Hint content
Left icon
Right icon
Preview
*Helper or hint text write here.
JSX
<FormField
type="input"
name="input-field"
label="Label"
hintText="Helper or hint text write here."
/>Props
The full FormField API for Desktop / Tablet. The type prop selects the variant; every state maps to the --components-form-field-* tokens.
| Prop | Type | Default | Description |
|---|---|---|---|
type | 'input' | 'phone' | 'date' | 'date-picker' | 'payment' | 'website' | 'otp' | 'textarea' | 'file-upload' | 'select' | 'input' | Selects the field variant — see the Playground type control for all ten. |
size | 'sm' | 'md' | 'sm' | Input container size. 'sm' = 36px height / 16px icons, 'md' = 44px / 20px. Fields default to 320px wide (min 320px, max 1600px); file upload to 512px. |
state | 'placeholder' | 'hover' | 'filled' | 'focus' | 'error' | 'disabled' | 'loading' | 'placeholder' | Interaction / validation state. Drives border, background, text and icon tokens. |
nameRequired | string | — | Form field name attribute — required for serialisation, label association and validation. |
label | string | 'Label' | Field label rendered as a <label> tied to the input via htmlFor / id. |
showLabel | boolean | true | Show / hide the label title above the input. |
showAsterisk | boolean | true | Show the required asterisk beside the label. |
showHintLabel | boolean | true | Show / hide the helper or hint text below the input (requires hintText). |
hintText | string | 'Helper or hint text write here.' | Helper / hint content. Turns red in the error state. |
showLeftIcon | boolean | true | Show / hide the left icon (type="input" and type="select"). |
showRightIcon | boolean | true | Show / hide the right icon (input, phone, date, date-picker, payment and website types). |
changeLeftIcon | ReactNode | <MailOutlineIcon /> | Swap the default left icon — accepts any icon from nexus/icons. |
changeRightIcon | ReactNode | <HelpOutlineIcon /> | Swap the default right icon — accepts any icon from nexus/icons. |
placeholder | string | per-type default | Placeholder text. Defaults: 'Input field placeholder', '+91 99999 99999', 'MM / DD / YYYY', 'MM/DD/YYYY - MM/DD/YYYY', '0000 0000 0000 0000', 'www.websitename.com', 'Write your description here...'. |
value | string | — | Controlled value / display value for the field. |
changePaymentIcon | ReactNode | <MastercardIcon /> | type="payment" — swap the card brand icon shown at the left (e.g. VisaCreditCardIcon). |
otpValues | string[] | ['0' placeholders] | type="otp" — exactly 6 single-character strings, one per digit box (36×36 at 'sm', 44×44 at 'md'). |
fileName | string | 'AI_Design_Project_MD.doc' | type="file-upload" — item-upload row label. |
fileSize | string | '200 KB of 200 KB' | type="file-upload" — file size label (Mobile uses the shorter '200 KB' form). |
showCompleteItemUpload | boolean | true | type="file-upload" — show the completed row (check icon, 'Complete', delete action). |
showUploadingItemUpload | boolean | true | type="file-upload" — show the in-progress row ('Uploading...', delete action). |
showErrorItemUpload | boolean | true | type="file-upload" — show the failed row ('Upload failed', retry action, error border). |
selectLabel | string | 'Select/Dropdown Item' | type="select" — the selected item label shown in the trigger. |
menuLabel | string | 'Select/Dropdown Menu Item' | type="select" — label text for each item in the open menu list. |
className | string | — | Layout-only classes (margin, width). |
Usage guidelines — Desktop / Tablet
When to use
- Route every editable form input through FormField — text, phone, date, payment, OTP, file upload and select all share one component.
- Always pass a meaningful label and a name; drive the state prop from your validation logic (state={hasError ? 'error' : 'filled'}).
- Pair showAsterisk with real required validation, and use hintText to explain format or errors rather than the placeholder.
When not to use
- Don't build raw <input>, <textarea> or <select> elements with manual styles — they bypass the token layer.
- Don't use a navbar search bar, an inline edit-in-place cell, or a read-only display value here — those have their own patterns.
- Don't leave all three item-upload rows on outside demos — drive showCompleteItemUpload / showUploadingItemUpload / showErrorItemUpload from real upload status — and never hardcode state='error' on every field.
Usage guidelines — Mobile Application
When to use
- Import MobileFormField (not FormField) for touch layouts — the single 48px size gives a comfortable tap target.
- Use showLabel to hide the title in dense mobile forms where context is already clear from surrounding copy.
- Keep field widths between 328px and 460px so they fit a phone viewport without horizontal scroll.
When not to use
- Don't pass a size prop — Mobile is a single fixed size — and never use state='hover' or state='loading'.
- Don't reach for the select / dropdown type on Mobile; it doesn't exist — use a native picker or a bottom sheet.
- Don't mix MobileFormField and FormField in the same form; pick the component that matches the platform.
nexus/components/form-field.tsx
"use client";
import { useId, useState, type ReactNode } from "react";
import {
MailOutlineIcon,
HelpOutlineIcon,
CalendarTodayDateOutlineIcon,
DateRangeCalendarOutlineIcon,
MastercardIcon,
IndiaFlagIcon,
DropdownKeyboardArrowDownOutlineIcon,
DropdownKeyboardArrowUpOutlineIcon,
UploadOutlineIcon,
DocsDocumentFileOutlineIcon,
CheckCircleFillIcon,
CloseOutlineIcon,
ErrorOutlineIcon,
DeleteOutlineIcon,
RefreshOutlineIcon,
ProgressActivityOutlineIcon,
FileDocumentFillIcon,
CursorFilledIcon,
} from "nexus/icons";
/* ────────────────────────────────────────────────────────────────────────
* Nexus Form Field — Desktop/Tablet (`FormField`) and Mobile (`MobileFormField`)
*
* Both components share this single implementation; the `platform` flag picks
* the size, gap, radius and the available states. All visual styling is driven
* by the `--components-form-field-*` tokens. Per the project convention, every
* state's classes are spelled out as full literal strings in a Record so
* Tailwind can statically emit them — never assembled at runtime.
*
* Matches the Figma frames 415:514 (Desktop/Tablet — SM 36px / MD 44px input
* containers, 320px min field width, rounded-4) and 425:471 (Mobile — a single
* 48px container, 328–460px width, rounded-6, mobile typography, no shadows).
* ──────────────────────────────────────────────────────────────────────── */
export type FormFieldType =
| "input"
| "phone"
| "date"
| "date-picker"
| "payment"
| "website"
| "otp"
| "textarea"
| "file-upload"
| "select";
export type FormFieldSize = "sm" | "md";
export type FormFieldState =
| "placeholder"
| "hover"
| "filled"
| "focus"
| "error"
| "disabled"
| "loading";
interface BaseFormFieldProps {
/** Selects the field variant. */
type?: FormFieldType;
/** Interaction / validation state. */
state?: FormFieldState;
/** Field label shown above the input. */
label?: string;
/** Show / hide the label title above the input. */
showLabel?: boolean;
/** Show the required asterisk beside the label. */
showAsterisk?: boolean;
/** Show / hide the helper or hint text below the input. */
showHintLabel?: boolean;
/** Helper or hint text content. */
hintText?: string;
/** Show / hide the left icon inside the input (`type="input" | "select"`). */
showLeftIcon?: boolean;
/** Show / hide the right icon inside the input. */
showRightIcon?: boolean;
/** Override the default left icon (any icon from `nexus/icons`). */
changeLeftIcon?: ReactNode;
/** Override the default right icon (any icon from `nexus/icons`). */
changeRightIcon?: ReactNode;
/** Override the payment brand icon (`type="payment"`). */
changePaymentIcon?: ReactNode;
/** Placeholder text shown in the empty input. */
placeholder?: string;
/** Form field `name` attribute — always required. */
name: string;
/** Controlled value / display value for the field. */
value?: string;
/** OTP digits — exactly 6 single-character strings. */
otpValues?: string[];
/** Uploaded file name (`type="file-upload"`). */
fileName?: string;
/** File size label (`type="file-upload"`). */
fileSize?: string;
/** Show the completed row in the file item-upload list. */
showCompleteItemUpload?: boolean;
/** Show the uploading-in-progress row in the file item-upload list. */
showUploadingItemUpload?: boolean;
/** Show the upload-failed row in the file item-upload list. */
showErrorItemUpload?: boolean;
/** Selected item label (`type="select"`). */
selectLabel?: string;
/** Menu item label (`type="select"`). */
menuLabel?: string;
/** Layout-only classes (margin, width). */
className?: string;
}
export interface FormFieldProps extends BaseFormFieldProps {
/** Input container size. */
size?: FormFieldSize;
}
export type MobileFormFieldProps = BaseFormFieldProps;
/* ── State → token class maps (literal strings for Tailwind) ───────────── */
const CONTAINER: Record<FormFieldState, string> = {
placeholder:
"border-[var(--components-form-field-color-border-light)] bg-[var(--components-form-field-color-background-white)]",
hover:
"border-[var(--components-form-field-color-border-primary-semibold)] bg-[var(--components-form-field-color-background-white)]",
filled:
"border-[var(--components-form-field-color-border-default)] bg-[var(--components-form-field-color-background-white)]",
focus:
"border-[var(--components-form-field-color-border-primary)] bg-[var(--components-form-field-color-background-white)]",
error:
"border-[var(--components-form-field-color-border-error)] bg-[var(--components-form-field-color-background-white)]",
disabled:
"border-[var(--components-form-field-color-border-semibold)] bg-[var(--components-form-field-color-background-disabled)]",
loading:
"border-[var(--components-form-field-color-border-primary)] bg-[var(--components-form-field-color-background-white)]",
};
/** Focus / error / disabled / loading carry a shadow-xs on Desktop only. */
const DESKTOP_SHADOW: Record<FormFieldState, string> = {
placeholder: "",
hover: "",
filled: "",
focus: "shadow-xs",
error: "shadow-xs",
disabled: "shadow-xs",
loading: "shadow-xs",
};
/** Text colour for the typed value. */
const VALUE_TEXT: Record<FormFieldState, string> = {
placeholder: "text-[var(--components-form-field-color-text-body)]",
hover: "text-[var(--components-form-field-color-text-body)]",
filled: "text-[var(--components-form-field-color-text-body)]",
focus: "text-[var(--components-form-field-color-text-body)]",
error: "text-[var(--components-form-field-color-text-body)]",
disabled: "text-[var(--components-form-field-color-text-muted)]",
loading: "text-[var(--components-form-field-color-text-body)]",
};
/** Placeholder colour — always the light token. */
const PLACEHOLDER_TEXT =
"placeholder:text-[var(--components-form-field-color-text-light)]";
/** Icon `<path>` fill per state. */
const ICON_FILL: Record<FormFieldState, string> = {
placeholder: "[&_path]:fill-[var(--components-form-field-color-icon-default)]",
hover: "[&_path]:fill-[var(--components-form-field-color-icon-default)]",
filled: "[&_path]:fill-[var(--components-form-field-color-icon-default)]",
focus: "[&_path]:fill-[var(--components-form-field-color-icon-primary)]",
error: "[&_path]:fill-[var(--components-form-field-color-icon-error)]",
disabled: "[&_path]:fill-[var(--components-form-field-color-icon-disabled)]",
loading: "[&_path]:fill-[var(--components-form-field-color-icon-primary)]",
};
/* ── File-upload drop-zone styling per state ───────────────────────────────
* Desktop file-upload defines three interaction states in Figma — Default
* (415:501), Hover (415:500) and Disable/Read (415:475). Mobile (425:467-447)
* has no hover; its equivalent purple "active" zone is the **Focus** state
* (425:468). Both share this `hover` FileZoneStyle: the *solid* primary border
* (not the semibold variant), a purple-tinted upload chip and the drag-preview
* flourish. Disabled swaps the chip and the "Click to upload" label to their
* muted/disabled tokens. Any other FormFieldState falls back to Default.
* Literal strings so Tailwind emits them.
*/
type FileZoneState = "default" | "hover" | "disabled";
interface FileZoneStyle {
/** Drop-zone border + background. */
zone: string;
/** Upload-icon chip background. */
chip: string;
/** Upload-icon `<path>` fill. */
icon: string;
/** "Click to upload" label colour. */
action: string;
}
const FILE_ZONE: Record<FileZoneState, FileZoneStyle> = {
default: {
zone: "border-[var(--components-form-field-color-border-light)] bg-[var(--components-form-field-color-background-white)]",
chip: "bg-[var(--components-form-field-color-background-subtle)]",
icon: "[&_path]:fill-[var(--components-form-field-color-icon-default)]",
action: "text-[var(--components-button-color-primary-text-primary)]",
},
hover: {
zone: "border-[var(--components-form-field-color-border-primary)] bg-[var(--components-form-field-color-background-white)]",
chip: "bg-[var(--components-form-field-color-icon-subtle)]",
icon: "[&_path]:fill-[var(--components-form-field-color-icon-primary)]",
action: "text-[var(--components-button-color-primary-text-primary)]",
},
disabled: {
zone: "border-[var(--components-form-field-color-border-semibold)] bg-[var(--components-form-field-color-background-disabled)]",
chip: "bg-[var(--components-form-field-color-background-muted)]",
icon: "[&_path]:fill-[var(--components-form-field-color-icon-disabled)]",
action: "text-[var(--components-button-color-primary-text-disabled)]",
},
};
const fileZoneStateFor = (
state: FormFieldState,
platform: "desktop" | "mobile",
): FileZoneState =>
state === "disabled"
? "disabled"
: state === "hover" || (platform === "mobile" && state === "focus")
? "hover"
: "default";
/* ── Per-type defaults ─────────────────────────────────────────────────── */
const PLACEHOLDERS: Record<FormFieldType, string> = {
input: "Input field placeholder",
phone: "+91 99999 99999",
date: "MM / DD / YYYY",
"date-picker": "MM/DD/YYYY - MM/DD/YYYY",
payment: "0000 0000 0000 0000",
website: "www.websitename.com",
otp: "0",
textarea: "Write your description here...",
"file-upload": "",
select: "Select/Dropdown Item",
};
/** Sample value shown for the "filled" family of states. */
const SAMPLES: Record<FormFieldType, string> = {
input: "hello@nexus.design",
phone: "+91 99999 99999",
date: "06 / 29 / 2026",
"date-picker": "06/01/2026 - 06/29/2026",
payment: "1234 5678 9012 3456",
website: "www.websitename.com",
otp: "123456",
textarea:
"A concise description of the project goals, scope, and the design direction we want to explore together.",
"file-upload": "",
select: "Select/Dropdown Item",
};
interface SizeConfig {
/**
* Input-container height class. SM (36px) and Mobile (48px) map to real
* spacing tokens (`h-36` / `h-48`). MD (44px) has **no** spacing token —
* `--spacing-44` doesn't exist, so its height is derived from the spec's
* vertical padding (`py-10`) plus the value line-height, exactly as Button
* derives its 44/56 heights.
*/
height: string;
/** Padding of the plain input container. */
pad: string;
/** Padding of the white body inside the segmented phone / website fields. */
segBodyPad: string;
/** Padding + gap of the subtle prefix "Head" segment (phone / website). */
headPad: string;
headGap: string;
/** Corner radius — Desktop rounded-4, Mobile rounded-6. */
radius: string;
iconPx: number;
gap: string;
valueType: string;
/** Label title / helper-hint typography. */
labelType: string;
hintType: string;
/**
* Square OTP box. SM = the `size-36` token, Mobile = `size-48`; MD needs
* 44×44 with no `--spacing-44` token, so it is derived as 24px content +
* 2×10px padding (`--spacing-24` + `--spacing-20`) per the Figma node.
*/
otpBox: string;
/**
* Fixed textarea container. SM = the `h-80` token; MD and Mobile are 88px
* with no `--spacing-88` token, derived as `--spacing-80` + `--spacing-8`.
*/
textarea: string;
/** File-upload drop-zone padding + content column gap. */
zonePad: string;
fileGap: string;
/** Circular upload-icon chip padding + icon size. */
chipPad: string;
chipIconPx: number;
/** "Click to upload" / "or drag and drop" typography. */
fileActionType: string;
fileBodyType: string;
/** Gap between the action row and the supporting text. */
fileSupportGap: string;
}
const SIZE_CONFIG: Record<"sm" | "md" | "mobile", SizeConfig> = {
sm: {
height: "h-36",
pad: "px-12 py-8",
segBodyPad: "px-12 py-8",
headPad: "px-8",
headGap: "gap-2",
radius: "rounded-4",
iconPx: 16,
gap: "gap-8",
valueType: "typography-b2-regular-14",
labelType: "typography-b2-medium-14",
hintType: "typography-l1-regular-12",
otpBox: "size-36",
textarea: "h-80 px-12 py-8",
zonePad: "p-12",
fileGap: "gap-12",
chipPad: "p-8",
chipIconPx: 20,
fileActionType: "typography-b2-medium-14",
fileBodyType: "typography-b2-regular-14",
fileSupportGap: "gap-0",
},
md: {
height: "",
pad: "px-12 py-10",
segBodyPad: "px-12 py-10",
headPad: "px-8",
headGap: "gap-2",
radius: "rounded-4",
iconPx: 20,
gap: "gap-8",
valueType: "typography-b1-regular-16",
labelType: "typography-b2-medium-14",
hintType: "typography-l1-regular-12",
otpBox: "size-[calc(var(--spacing-24)+var(--spacing-20))]",
textarea: "h-[calc(var(--spacing-80)+var(--spacing-8))] px-12 py-10",
zonePad: "p-12",
fileGap: "gap-20",
chipPad: "p-10",
chipIconPx: 24,
fileActionType: "typography-b1-medium-16",
fileBodyType: "typography-b1-regular-16",
fileSupportGap: "gap-2",
},
mobile: {
height: "h-48",
pad: "px-16 py-12",
segBodyPad: "px-12 py-12",
headPad: "px-12",
headGap: "gap-4",
radius: "rounded-6",
iconPx: 20,
gap: "gap-12",
valueType: "typography-mobile-h4-regular-16",
labelType: "typography-mobile-b2-medium-13",
hintType: "typography-mobile-b2-regular-13",
otpBox: "size-48",
textarea: "h-[calc(var(--spacing-80)+var(--spacing-8))] px-16 py-12",
zonePad: "px-24 py-16",
fileGap: "gap-12",
chipPad: "p-8",
chipIconPx: 20,
fileActionType: "typography-mobile-b1-medium-14",
fileBodyType: "typography-mobile-b1-regular-14",
fileSupportGap: "gap-4",
},
};
interface FileItemConfig {
/** Gap between the drop zone and the item list. */
wrapperGap: string;
/** Row padding / fixed height (Mobile rows are 60px = 48 + 12 tokens). */
row: string;
docIconPx: number;
titleType: string;
metaType: string;
statusType: string;
statusIconPx: number;
/** Non-error row border colour — Desktop light, Mobile default. */
rowBorder: string;
supportText: string;
labels: { complete: string; uploading: string; error: string };
}
const FILE_ITEM: Record<"desktop" | "mobile", FileItemConfig> = {
desktop: {
wrapperGap: "gap-16",
row: "px-12 py-8",
docIconPx: 24,
titleType: "typography-mobile-b3-medium-12",
metaType: "typography-l1-regular-12",
statusType: "typography-l1-regular-12",
statusIconPx: 16,
rowBorder: "border-[var(--components-form-field-color-border-light)]",
supportText: "SVG, PNG, JPG, DOC, XLS or PDF (max. 800x400px)",
labels: { complete: "Complete", uploading: "Uploading...", error: "Upload failed" },
},
mobile: {
wrapperGap: "gap-12",
row: "min-h-[calc(var(--spacing-48)+var(--spacing-12))] px-12 py-10",
docIconPx: 32,
titleType: "typography-mobile-b1-medium-14",
metaType: "typography-mobile-b1-regular-14",
statusType: "typography-b2-regular-14",
statusIconPx: 20,
rowBorder: "border-[var(--components-form-field-color-border-default)]",
supportText: "SVG, PNG, JPG, DOC, XLS, PDF (max 800x400px)",
labels: { complete: "100%", uploading: "48%", error: "failed" },
},
};
/**
* Field width per Figma. No matching spacing tokens exist, so the bounds are
* derived from real tokens: Desktop fields 320–1600px (256+64 / 256×6+64),
* Desktop file upload 512px min (256×2), Mobile 328–460px (256+72 / 256+192+12).
*/
const WIDTHS: Record<"desktop" | "mobile", { field: string; upload: string }> = {
desktop: {
field:
"min-w-[calc(var(--spacing-256)+var(--spacing-64))] max-w-[calc(var(--spacing-256)*6+var(--spacing-64))]",
upload:
"min-w-[calc(var(--spacing-256)*2)] max-w-[calc(var(--spacing-256)*6+var(--spacing-64))]",
},
mobile: {
field:
"min-w-[calc(var(--spacing-256)+var(--spacing-72))] max-w-[calc(var(--spacing-256)+var(--spacing-192)+var(--spacing-12))]",
upload:
"min-w-[calc(var(--spacing-256)+var(--spacing-72))] max-w-[calc(var(--spacing-256)+var(--spacing-192)+var(--spacing-12))]",
},
};
/* ── Shared implementation ─────────────────────────────────────────────── */
interface CoreProps extends BaseFormFieldProps {
platform: "desktop" | "mobile";
size: FormFieldSize;
}
const FormFieldCore = ({
platform,
size,
type = "input",
state = "placeholder",
label = "Label",
showLabel = true,
showAsterisk = true,
showHintLabel = true,
hintText = "Helper or hint text write here.",
showLeftIcon = true,
showRightIcon = true,
changeLeftIcon,
changeRightIcon,
changePaymentIcon,
placeholder,
name,
value,
otpValues,
fileName = "AI_Design_Project_MD.doc",
fileSize,
showCompleteItemUpload = true,
showUploadingItemUpload = true,
showErrorItemUpload = true,
selectLabel = "Select/Dropdown Item",
menuLabel = "Select/Dropdown Menu Item",
className = "",
}: CoreProps) => {
const id = useId();
const cfg = platform === "mobile" ? SIZE_CONFIG.mobile : SIZE_CONFIG[size];
const item = FILE_ITEM[platform];
const disabled = state === "disabled";
const isError = state === "error";
const isFilled = state !== "placeholder";
const [selectOpen, setSelectOpen] = useState(false);
const ph = placeholder ?? PLACEHOLDERS[type];
const sample = value ?? SAMPLES[type];
const fieldValue = isFilled ? sample : "";
const iconFill = ICON_FILL[state];
const valueColor = VALUE_TEXT[state];
const shadow = platform === "desktop" ? DESKTOP_SHADOW[state] : "";
const border = "border-[length:var(--stroke-1)]";
const shell = `flex ${cfg.height} w-full items-center ${cfg.gap} ${cfg.radius} ${border} ${cfg.pad} ${CONTAINER[state]} ${shadow} ${
disabled ? "cursor-not-allowed" : ""
}`;
/** Frame for the segmented phone / website fields — padding lives on the body. */
const segmentedShell = `flex ${cfg.height} w-full items-stretch overflow-clip ${cfg.radius} ${border} ${CONTAINER[state]} ${shadow} ${
disabled ? "cursor-not-allowed" : ""
}`;
const segmentedBody = `flex min-w-0 flex-1 items-center ${cfg.gap} ${cfg.segBodyPad}`;
const inputBase = `min-w-0 flex-1 bg-transparent outline-none ${cfg.valueType} ${valueColor} ${PLACEHOLDER_TEXT} disabled:cursor-not-allowed`;
const iconSize = { width: cfg.iconPx, height: cfg.iconPx };
const LeftIcon = () =>
changeLeftIcon ? (
<span className={`grid shrink-0 place-items-center ${iconFill}`} aria-hidden>
{changeLeftIcon}
</span>
) : (
<MailOutlineIcon {...iconSize} aria-hidden className={`shrink-0 ${iconFill}`} />
);
// Per Figma, the error state always replaces the right icon with M_error_outline.
const RightIcon = () =>
isError ? (
<ErrorOutlineIcon {...iconSize} aria-hidden className={`shrink-0 ${iconFill}`} />
) : changeRightIcon ? (
<span className={`grid shrink-0 place-items-center ${iconFill}`} aria-hidden>
{changeRightIcon}
</span>
) : (
<HelpOutlineIcon {...iconSize} aria-hidden className={`shrink-0 ${iconFill}`} />
);
/** Subtle-background prefix segment used by the phone and website types. */
const Head = ({ children }: { children: ReactNode }) => (
<span
className={`flex shrink-0 items-center ${cfg.headGap} ${cfg.headPad} border-r-[length:var(--stroke-1)] border-[var(--components-form-field-color-border-light)] bg-[var(--components-form-field-color-background-subtle)]`}
>
{children}
</span>
);
/* ── Field body by type ──────────────────────────────────────────────── */
const renderBody = (): ReactNode => {
switch (type) {
case "input":
return (
<div className={shell}>
{showLeftIcon && <LeftIcon />}
<input
id={id}
name={name}
type="text"
disabled={disabled}
aria-invalid={isError || undefined}
placeholder={ph}
defaultValue={fieldValue}
key={state}
className={inputBase}
/>
{showRightIcon && <RightIcon />}
</div>
);
case "phone":
return (
<div className={segmentedShell}>
<Head>
{platform === "mobile" ? (
<IndiaFlagIcon width={24} height={24} aria-hidden className="shrink-0" />
) : (
<span className={`w-20 text-center ${cfg.valueType} ${valueColor}`}>IN</span>
)}
<DropdownKeyboardArrowDownOutlineIcon
width={16}
height={16}
aria-hidden
className={`shrink-0 ${iconFill}`}
/>
</Head>
<span className={segmentedBody}>
<input
id={id}
name={name}
type="tel"
disabled={disabled}
aria-invalid={isError || undefined}
placeholder={ph}
defaultValue={fieldValue}
key={state}
className={inputBase}
/>
{showRightIcon && <RightIcon />}
</span>
</div>
);
case "date":
case "date-picker": {
const CalendarIcon =
type === "date" ? CalendarTodayDateOutlineIcon : DateRangeCalendarOutlineIcon;
return (
<div className={shell}>
<CalendarIcon {...iconSize} aria-hidden className={`shrink-0 ${iconFill}`} />
<input
id={id}
name={name}
type="text"
disabled={disabled}
aria-invalid={isError || undefined}
placeholder={ph}
defaultValue={fieldValue}
key={state}
className={inputBase}
/>
{showRightIcon && <RightIcon />}
</div>
);
}
case "payment":
return (
<div className={shell}>
<span className="grid shrink-0 place-items-center" aria-hidden>
{changePaymentIcon ?? <MastercardIcon {...iconSize} />}
</span>
<input
id={id}
name={name}
type="text"
inputMode="numeric"
disabled={disabled}
aria-invalid={isError || undefined}
placeholder={ph}
defaultValue={fieldValue}
key={state}
className={inputBase}
/>
{showRightIcon && <RightIcon />}
</div>
);
case "website":
return (
<div className={segmentedShell}>
<Head>
<span className={`text-center ${cfg.valueType} ${valueColor}`}>https://</span>
</Head>
<span className={segmentedBody}>
<input
id={id}
name={name}
type="url"
disabled={disabled}
aria-invalid={isError || undefined}
placeholder={ph}
defaultValue={fieldValue}
key={state}
className={inputBase}
/>
{showRightIcon && <RightIcon />}
</span>
</div>
);
case "otp": {
const digits = otpValues ?? (isFilled ? SAMPLES.otp.split("") : ["", "", "", "", "", ""]);
return (
<div role="group" aria-label={label} className="flex gap-8">
{Array.from({ length: 6 }).map((_, i) => (
<input
key={`${state}-${i}`}
name={`${name}-${i + 1}`}
aria-label={`Digit ${i + 1} of 6`}
maxLength={1}
disabled={disabled}
aria-invalid={isError || undefined}
placeholder={ph}
defaultValue={digits[i] ?? ""}
className={`${cfg.otpBox} ${cfg.radius} ${border} ${CONTAINER[state]} ${shadow} text-center ${cfg.valueType} ${valueColor} ${PLACEHOLDER_TEXT} outline-none disabled:cursor-not-allowed`}
/>
))}
</div>
);
}
case "textarea":
return (
<textarea
id={id}
name={name}
disabled={disabled}
aria-invalid={isError || undefined}
placeholder={ph}
defaultValue={fieldValue}
key={state}
className={`w-full resize-y ${cfg.radius} ${border} ${cfg.textarea} ${CONTAINER[state]} ${shadow} ${cfg.valueType} ${valueColor} ${PLACEHOLDER_TEXT} outline-none disabled:cursor-not-allowed`}
/>
);
case "file-upload":
return renderFileUpload();
case "select":
return renderSelect();
default:
return null;
}
};
/* ── File upload: drop zone + stacked item-upload rows ───────────────── */
function renderFileUpload(): ReactNode {
const fzState = fileZoneStateFor(state, platform);
const fz = FILE_ZONE[fzState];
// The purple "active" zone: desktop Hover (415:500) and mobile Focus (425:468).
const isActive = fzState === "hover";
// Mobile "Item Upload" (425:469) tints the drop-zone border a step darker
// than Default — border-default rather than border-light.
const isItemUpload = platform === "mobile" && state === "filled";
const zoneClass = isItemUpload
? "border-[var(--components-form-field-color-border-default)] bg-[var(--components-form-field-color-background-white)]"
: fz.zone;
const zone = (
<div
className={`relative flex w-full items-center justify-center ${cfg.radius} ${border} ${cfg.zonePad} ${zoneClass} ${shadow} ${
disabled ? "cursor-not-allowed" : ""
}`}
>
{/* Active only: the drag-preview file + cursor flourish (415:500 /
425:468). The icons carry their own multi-tone purple fills — do not
recolour. */}
{isActive && (
<span
className="pointer-events-none absolute right-16 top-1/2 size-40 -translate-y-1/2"
aria-hidden
>
<FileDocumentFillIcon width={40} height={40} className="absolute inset-0" />
<CursorFilledIcon width={12} height={12} className="absolute left-28 top-28" />
</span>
)}
<div className={`flex min-w-0 flex-1 flex-col items-center justify-center ${cfg.fileGap}`}>
<span
className={`grid shrink-0 place-items-center rounded-full ${fz.chip} ${cfg.chipPad}`}
aria-hidden
>
<UploadOutlineIcon
width={cfg.chipIconPx}
height={cfg.chipIconPx}
className={fz.icon}
/>
</span>
<div className={`flex w-full flex-col items-center ${cfg.fileSupportGap}`}>
<span className="flex w-full items-center justify-center gap-4">
<button
type="button"
disabled={disabled}
className={`${cfg.fileActionType} ${fz.action} disabled:cursor-not-allowed`}
>
Click to upload
</button>
<span
className={`${cfg.fileBodyType} text-[var(--components-form-field-color-text-muted)]`}
>
or drag and drop
</span>
</span>
<span className="w-full text-center typography-mobile-b3-regular-12 text-[var(--components-form-field-color-text-muted)]">
{item.supportText}
</span>
</div>
</div>
</div>
);
const size_ = fileSize ?? (platform === "mobile" ? "200 KB" : "200 KB of 200 KB");
const itemRow = (kind: "complete" | "uploading" | "error") => (
<div
className={`flex w-full items-center gap-8 rounded-4 ${border} ${item.row} bg-[var(--components-form-field-color-background-white)] ${
kind === "error"
? "border-[var(--components-form-field-color-border-error)]"
: item.rowBorder
}`}
>
<DocsDocumentFileOutlineIcon
width={item.docIconPx}
height={item.docIconPx}
aria-hidden
className="shrink-0 [&_path]:fill-[var(--components-form-field-color-icon-primary-medium)]"
/>
<div className="flex min-w-0 flex-1 flex-col gap-2">
<span
className={`w-full truncate ${item.titleType} text-[var(--components-form-field-color-text-body)]`}
>
{fileName}
</span>
<span className="flex items-center gap-8">
<span className={`${item.metaType} text-[var(--components-form-field-color-text-muted)]`}>
{size_}
</span>
<span
aria-hidden
className="typography-l1-regular-12 text-[var(--components-form-field-color-text-light)]"
>
|
</span>
<span className="flex items-center gap-4">
{kind === "complete" && (
<>
<CheckCircleFillIcon
width={item.statusIconPx}
height={item.statusIconPx}
aria-hidden
className="shrink-0 [&_path]:fill-[var(--components-form-field-color-icon-success)]"
/>
<span className={`${item.statusType} text-[var(--components-form-field-color-text-success)]`}>
{item.labels.complete}
</span>
</>
)}
{kind === "uploading" && (
<>
<UploadOutlineIcon
width={item.statusIconPx}
height={item.statusIconPx}
aria-hidden
className="shrink-0 [&_path]:fill-[var(--components-form-field-color-icon-default)]"
/>
<span className={`${item.statusType} text-[var(--components-form-field-color-text-muted)]`}>
{item.labels.uploading}
</span>
</>
)}
{kind === "error" && (
<>
<CloseOutlineIcon
width={item.statusIconPx}
height={item.statusIconPx}
aria-hidden
className="shrink-0 [&_path]:fill-[var(--components-form-field-color-icon-error)]"
/>
<span className={`${item.statusType} text-[var(--components-form-field-color-text-error)]`}>
{item.labels.error}
</span>
</>
)}
</span>
</span>
</div>
<button
type="button"
aria-label={kind === "error" ? "Retry upload" : "Remove file"}
className="grid shrink-0 place-items-center pointer-coarse:min-h-[44px] pointer-coarse:min-w-[44px]"
>
{kind === "error" ? (
<RefreshOutlineIcon
width={24}
height={24}
aria-hidden
className="[&_path]:fill-[var(--components-form-field-color-icon-error)]"
/>
) : (
<DeleteOutlineIcon
width={24}
height={24}
aria-hidden
className="[&_path]:fill-[var(--components-form-field-color-icon-default)]"
/>
)}
</button>
</div>
);
// Desktop lists items alongside any state; Mobile only in the dedicated
// "Item Upload" state (425:469 = the `filled` state).
const itemsForState = platform === "desktop" || state === "filled";
const hasItems =
itemsForState &&
(showCompleteItemUpload || showUploadingItemUpload || showErrorItemUpload);
if (!hasItems) return zone;
return (
<div className={`flex w-full flex-col ${item.wrapperGap}`}>
{zone}
<div className="flex w-full flex-col gap-8">
{showCompleteItemUpload && itemRow("complete")}
{showUploadingItemUpload && itemRow("uploading")}
{showErrorItemUpload && itemRow("error")}
</div>
</div>
);
}
/* ── Select / dropdown ───────────────────────────────────────────────── */
function renderSelect(): ReactNode {
const open = !disabled && (selectOpen || state === "focus" || state === "loading");
const Chevron = open ? DropdownKeyboardArrowUpOutlineIcon : DropdownKeyboardArrowDownOutlineIcon;
return (
<div className="relative w-full">
<button
type="button"
id={id}
disabled={disabled}
aria-haspopup="listbox"
aria-expanded={open}
onClick={() => setSelectOpen((o) => !o)}
className={`${shell} text-left`}
>
{showLeftIcon && <LeftIcon />}
<span
className={`min-w-0 flex-1 truncate ${cfg.valueType} ${isFilled ? valueColor : "text-[var(--components-form-field-color-text-light)]"}`}
>
{selectLabel}
</span>
<Chevron {...iconSize} aria-hidden className={`shrink-0 ${iconFill}`} />
</button>
{open && state === "loading" && (
<div
role="status"
className="absolute left-0 right-0 top-full z-10 mt-4 flex h-144 flex-col items-center justify-center gap-8 rounded-4 border-[length:var(--stroke-1)] border-[var(--components-form-field-color-border-light)] bg-[var(--components-form-field-color-background-white)] px-16 py-12 shadow-sm"
>
<ProgressActivityOutlineIcon
width={24}
height={24}
aria-hidden
className="animate-spin [&_path]:fill-[var(--components-form-field-color-icon-primary)]"
/>
<span className="w-full text-center typography-b2-regular-14 text-[var(--components-form-field-color-text-body)]">
Please wait, while we load the results...
</span>
</div>
)}
{open && state !== "loading" && (
<ul
role="listbox"
className="absolute left-0 right-0 top-full z-10 mt-4 flex flex-col gap-2 rounded-4 border-[length:var(--stroke-1)] border-[var(--components-form-field-color-border-light)] bg-[var(--components-form-field-color-background-white)] p-4 shadow-md"
>
{[0, 1, 2, 3].map((i) => (
<li
key={i}
role="option"
aria-selected={i === 1}
className={`cursor-pointer rounded-4 px-12 py-8 typography-b2-regular-14 ${
i === 1
? "bg-[var(--components-form-field-color-background-subtle)] text-[var(--components-form-field-color-icon-primary)]"
: "text-[var(--components-form-field-color-text-body)] hover:bg-[var(--components-form-field-color-background-subtle)]"
}`}
>
{menuLabel}
</li>
))}
</ul>
)}
</div>
);
}
/* ── Assembly: label + body + hint ───────────────────────────────────── */
const isFileOrOtp = type === "file-upload" || type === "otp";
const width = type === "file-upload" ? WIDTHS[platform].upload : WIDTHS[platform].field;
// Per Figma, the file-upload type carries no label / asterisk / hint.
if (type === "file-upload") {
return <div className={`flex w-full flex-col ${width} ${className}`}>{renderBody()}</div>;
}
return (
<div className={`flex w-full flex-col gap-6 ${width} ${className}`}>
{showLabel && (
<span className={`flex w-full items-center gap-2 ${cfg.labelType}`}>
<label
htmlFor={isFileOrOtp ? undefined : id}
className="text-[var(--components-form-field-color-text-heading)]"
>
{label}
</label>
{showAsterisk && (
<span
className={`${
platform === "mobile" ? "typography-mobile-b2-regular-13" : ""
} text-[var(--components-form-field-color-text-error)]`}
>
*
</span>
)}
</span>
)}
{renderBody()}
{showHintLabel && hintText && (
<span
className={`${cfg.hintType} ${
isError
? "text-[var(--components-form-field-color-text-error)]"
: "text-[var(--components-form-field-color-text-body)]"
}`}
>
{hintText}
</span>
)}
</div>
);
};
/* ── Public components ──────────────────────────────────────────────────── */
/**
* Desktop / Tablet form field. The `type` prop selects the variant; all visual
* styling flows from the `--components-form-field-*` tokens.
*/
export const FormField = ({ size = "sm", ...props }: FormFieldProps) => (
<FormFieldCore platform="desktop" size={size} {...props} />
);
/**
* Mobile Application form field — a single fixed 48px size (Figma 425:471).
* `hover` and `loading` states and the `select` type do not exist on Mobile.
*/
export const MobileFormField = (props: MobileFormFieldProps) => (
<FormFieldCore platform="mobile" size="md" {...props} />
);