PVR Tech Studio

Forms

Token-styled, accessible form primitives — core fields, pickers, entry and selection controls, a Tiptap rich-text editor, and six form demo pages with a pure validate() pattern.

12 min read
Updated July 15, 2026

The form primitives live in src/components/ui/ and are re-exported from the barrel, so you import them from one place:

import {
    Input, Textarea, Select, Combobox, Label, Checkbox, Radio, Field,
    DatePicker, TimePicker, DateRangePicker, ColorPicker,
    PasswordInput, TagsInput, PhoneInput, OtpInput, FileUpload,
    ToggleGroup, OptionCard, Rating, Slider, Stepper,
} from '@/components/ui'

Two deliberate exceptions live outside the barrel:

  • RichTextEditor is imported directly from @/components/ui/RichTextEditor — it pulls in Tiptap/ProseMirror (~340 kB) and must stay out of the eagerly-loaded barrel.
  • Editable (click-to-edit, x-editable style) is a feature component in src/components/editable/Editable.tsx.

They share one design language: one shared field style (fieldBase), colors from tokens only, accessible by construction (visually-hidden native inputs, aria-invalid wiring, htmlFor association, keyboard support), and i18n-agnostic (user-facing strings arrive as props). Field ties a Label, a control, and a hint/error line together.

Architecture & files

Primitives (all in src/components/ui/, barrel-exported unless noted):

FileExports
Input.tsxInput, fieldBase, InputSize, INPUT_SIZE
Textarea.tsxTextarea
Select.tsxSelect, SelectOption (type)
Combobox.tsxCombobox, ComboboxOption (type)
Label.tsx / Checkbox.tsx / Radio.tsx / Field.tsxLabel / Checkbox / Radio / Field
DatePicker.tsxDatePicker, PickerTrigger, DATEPICKER_PORTAL
TimePicker.tsx / DateRangePicker.tsx / ColorPicker.tsxTimePicker / DateRangePicker, DateRange / ColorPicker
PasswordInput.tsxPasswordInput, PasswordRequirementLabels (type)
TagsInput.tsx / PhoneInput.tsx / OtpInput.tsx / FileUpload.tsxthose four components
ToggleGroup.tsx / OptionCard.tsx / Rating.tsx / Slider.tsx / Stepper.tsxthose five components
RichTextEditor.tsxRichTextEditornot in the barrel, import by path

Supporting modules: src/components/editable/Editable.tsx (click-to-edit), src/lib/masks.ts (format-on-type helpers), src/lib/password.ts (passwordScore), src/lib/datetime.ts (local-timezone string↔Date bridges), src/data/countries.ts (dial codes for PhoneInput), src/styles/_datepicker.scss and src/styles/_editor.scss (third-party theming through raw token vars).

Third-party libraries (all MIT): react-select (+ creatable for Combobox), react-datepicker, react-colorful (ColorPicker), and Tiptap (RichTextEditor).

Usage

Wrap each control in a Field for a consistent label + hint/error row:

<Field label="Email" htmlFor="email" hint="We'll never share your email.">
    <Input id="email" type="email" placeholder="jane@example.com" />
</Field>

Field renders the Label (with an optional required asterisk) above your control and a hint/error line below. Pass error to switch the line to danger styling; set invalid on the control to turn its border and ring red. Every control accepts invalid and an id, so they all drop into Field the same way.

Core field primitives

Input

Text-like input. Extends Omit<InputHTMLAttributes<HTMLInputElement>, 'prefix'>; forwards its ref. When any addon prop is set, it renders as an input group — a focus-within wrapper carries the border/ring and the inner input goes borderless.

PropTypeDefaultDescription
invalidbooleanSets aria-invalid → danger border + ring (via fieldBase).
inputSize'sm' | 'md' | 'lg''md'Height/padding/text size (h-8/h-10/h-12).
prefixReactNodeText/element addon flush left, inside the border (e.g. https://).
suffixReactNodeAddon flush right (e.g. .00, a copy button).
leadingIconReactNodeIcon inside the field at the start.
trailingIconReactNodeIcon inside the field at the end (e.g. a live-validation check).
classNamestringExtra classes (merged after the size classes).
…restnative input attributesAll native input attributes (minus prefix).

Textarea

Multi-line input on the same fieldBase. Props: invalid, inputSize ('sm' | 'md' | 'lg'), rows (default 4), className, plus native textarea attributes.

Select

A react-select wrapper (not a native <select>). Fully themed via classNames + unstyled; the menu is portaled to <body> so it isn't clipped. Single-select by default; pass isMulti for a tag-style multi-select — the props are a discriminated union, so value/defaultValue/onChange become string[]-typed when isMulti is set.

PropTypeDefaultDescription
optionsSelectOption[]{value, label, isDisabled?}[].
isMultibooleanfalseMulti-select; values render as removable chips.
value / defaultValuestring (or string[] with isMulti)Controlled / uncontrolled value(s).
onChange(v: string) => void (or string[])Called with the value(s) ('' when cleared, single).
placeholderstringPlaceholder text.
invalid / disabled / isSearchablebooleanDanger state / disable / allow typing to filter.
size'sm' | 'md' | 'lg''md'Control min-height + font size (matches InputSize).
id / name / className / aria-labelstringStandard field props.

Combobox

Searchable single-select that can also create a new option on the fly — built on react-select/creatable, themed identically to Select, isClearable, menu portaled to <body>. Props: options (ComboboxOption[]), value, onChange ('' when cleared; also fires with the typed label on create), createLabel, placeholder, invalid, disabled, plus id/name/className/aria-label.

Label / Checkbox / Radio / Field

  • Label — extends LabelHTMLAttributes; required shows a danger * after the text.
  • Checkbox / Radio — token-styled; the native input is visually hidden as a peer, the box + indicator are sibling layers driven by peer-checked:. Props: label, disabled, className, plus native input attrs (minus type). name groups radios.
  • Field — the standard label + control + hint/error row:
PropTypeDefaultDescription
labelReactNodeRenders a Label above the control (omit for label-less rows).
htmlForstringAssociates the label with a control id.
requiredbooleanPasses through to the Label (danger asterisk).
hintReactNodeHelper text below the control (hidden when error is set).
errorReactNodeError text; replaces the hint and colors the line danger.
classNamestringExtra classes on the row wrapper.
childrenReactNodeThe control.

Date, time & color pickers

The three date/time pickers wrap react-datepicker with a shared fieldBase-styled button trigger (PickerTrigger) and a body-level portal (DATEPICKER_PORTAL = 'datepicker-portal') so the popper escapes panel/modal overflow: hidden. Values are Date | null — bridge string state with src/lib/datetime.ts.

Props: value / onChange (Date | null), min/max, dateFormat (date-fns; defaults 'PP', 'PP p' with showTime, 'MMM yyyy' with monthYear), showTime, monthYear, inline (no trigger/portal), timeIntervals (default 15), placeholder, invalid/disabled, id/className.

Entry controls

  • PasswordInput — show/hide toggle, an optional strength meter (a single danger→success gradient bar revealed by clip-path) and a requirements checklist. Scoring comes from the pure passwordScore(). Props: value/onChange (plain string), strength, strengthLabels ([string×4]), requirements + requirementLabels ({length, case, number, symbol}), showLabel/hideLabel, plus standard field props.
  • TagsInput — tag/token input on fieldBase — type + Enter to add, Backspace or × to remove; de-dupes and respects max. Props: value (string[]), onChange, max, removeLabel, plus standard field props.
  • PhoneInput — a dial-code country picker (portaled Popover, data from src/data/countries.ts) + a masked national number (maskPhone). Props: value, onChange (masked value), onCountryChange, defaultCountry (default 'US'), placeholder, plus standard field props.
  • OtpInput — segmented one-time-code / PIN input with auto-advance, backspace-to-previous, arrow-key nav, paste-to-fill. Props: length (default 6), value, onChange, onComplete, type ('number' | 'text', default 'number'), plus group attrs.
  • FileUpload — click-to-browse + drag-and-drop dashed drop zone (keyboard-activatable), with a selected-file list. Controlled (files + onFiles) or uncontrolled. Props: multiple, accept, files, onFiles, browseLabel, dropLabel, hint, removeLabel, plus standard props.

Selection & step controls

  • ToggleGroup — segmented / toggle button group, single-select by default or multiple (a discriminated union). Buttons carry aria-pressed. Props: options (ToggleOption[], label may be a node), multiple, value, onChange, size ('sm' | 'md'), className/aria-label.
  • OptionCard — selectable card/tile with no visible checkbox or radio — the card itself is the control (aria-pressed). Four variants: default, horizontal, filled, plain. Props: selected, onSelect, title, description, icon, variant, plus standard props.
  • Rating — star rating, interactive (with hover preview) when onChange is passed and not readOnly. Stars use the warning token. Props: value, onChange, max (default 5), readOnly, size, clearable, className/aria-label.
  • Slider — a native <input type="range"> with a token-filled track and a styled thumb. Props: value, onChange (number), min/max (0/100), step (default 1), showValue, formatValue, plus standard props.
  • Stepper — horizontal step indicator (done / current / upcoming) with connector lines. Props: steps (StepperStep[]), current (zero-based), onStepClick (if set, completed/active steps become clickable — no jumping ahead), className.

RichTextEditor

Rich-text editor built on Tiptap (MIT, headless) with a token-styled toolbar. Controlled via an HTML string value. Extensions: StarterKit + Underline + Link + Placeholder + TextAlign. The full toolbar covers bold/italic/underline/strike, H1–H3, lists, quote, code block, link popover, text align, clear formatting, and undo/redo; minimal trims it to bold/italic/underline/bullet-list/link.

Not barrel-exported — import directly:

import {RichTextEditor} from '@/components/ui/RichTextEditor'
PropTypeDefaultDescription
valuestringCurrent value as an HTML string.
onChange(html: string) => voidFired with editor.getHTML() on every update.
placeholderstringEmpty-document placeholder.
minimalbooleanCompact toolbar.
readOnlybooleanView-only: hides the toolbar, keeps content crisp.
disabledbooleanLocks editing and dims the whole control.
invalidbooleanDanger border (pairs with Field error).
linkLabels{add, remove, url, apply} stringsEnglish fallbacksLabels for the link popover — pass t() values.
className / aria-labelstringWrapper class / textbox aria-label.

Content styling lives in src/styles/_editor.scss, and the same rules style the .rte-preview class, so rendering saved HTML read-only looks identical to the editing surface.

Editable (inline edit)

src/components/editable/Editable.tsx — a click-to-edit value shown as an x-editable-style dashed-underline link. Two modes: popup (default — a portaled Popover with the control + Save/Cancel) and inline (swaps the control in place). Four control types: text, textarea, select (uses Select, optionally searchable), and date (an inline DatePicker; string values bridge via parseLocalDate/formatLocalDate). Props: type (default 'text'), value, onCommit, mode, options, searchable, side/align, emptyLabel, saveLabel/cancelLabel, className/aria-label.

Configuration & customization

  • fieldBase — exported from Input.tsx (and re-exported from the barrel) as the single source of truth for text-field styling (border, background, padding, placeholder color, focus ring, disabled state, aria-[invalid=true] danger ring). Reuse it for any new field-like control: <input className={cn(fieldBase, 'h-10')} />.
  • Input sizesInputSize = 'sm' | 'md' | 'lg' is shared across the text controls. The class map is exported as INPUT_SIZE.
  • invalid and error states — two coordinated flags: invalid on the control sets aria-invalid and/or turns the border + ring danger; error on the Field replaces the hint with a danger message. Use them together.
  • Input maskssrc/lib/masks.ts holds pure format-on-type helpers: maskCardNumber, maskExpiry, maskCvc, maskPhone, and the digits stripper. Use them inline in a controlled onChange.
  • Password scoringsrc/lib/password.ts exports passwordScore(pw){score: 0–4, tone}.
  • Local date/time helperssrc/lib/datetime.ts bridges string state with the Date-based pickers. A bare new Date('YYYY-MM-DD') parses as UTC midnight and shifts a day in western timezones — always use parseLocalDate/formatLocalDate (and the time equivalents) when persisting picker values as strings.
  • Picker/editor theming — both third-party surfaces are themed only through raw token vars (no @apply, no hex, no --color-*) in _datepicker.scss and _editor.scss.

A complete validated form

The FormValidationPage pattern — a pure validate() returns a per-field map of i18n keys; validation runs on blur (for touched fields) and on submit. No form library.

interface Values { name: string; email: string; plan: string; terms: boolean }
type Errors = Partial<Record<keyof Values, string>>
 
/** Pure validation — returns a per-field error-KEY map (empty = valid). */
function validate(v: Values): Errors {
    const e: Errors = {}
    if (!v.name.trim()) e.name = 'validationErrNameRequired'
    if (!v.email.trim()) e.email = 'validationErrEmailRequired'
    else if (!isEmail(v.email)) e.email = 'validationErrEmailInvalid'
    if (!v.plan) e.plan = 'validationErrPlanRequired'
    if (!v.terms) e.terms = 'validationErrTermsRequired'
    return e
}
 
function CreateAccount() {
    const {t} = useTranslation('forms')
    const [values, setValues] = useState<Values>({name: '', email: '', plan: '', terms: false})
    const [errors, setErrors] = useState<Errors>({})
    const err = (k: keyof Values) => (errors[k] ? t(errors[k]!) : undefined)
 
    function submit(e: React.FormEvent) {
        e.preventDefault()
        const found = validate(values)
        setErrors(found)
        if (Object.keys(found).length === 0) toast({title: t('validationSubmitToastTitle'), tone: 'success'})
    }
 
    return (
        <form onSubmit={submit} noValidate className="space-y-4">
            <Field label={t('validationFullName')} htmlFor="name" required error={err('name')}>
                <Input id="name" value={values.name} invalid={!!errors.name}
                       onChange={(e) => setValues({...values, name: e.target.value})} />
            </Field>
            <Field error={err('terms')}>
                <Checkbox label={t('validationTerms')} checked={values.terms}
                          onChange={(e) => setValues({...values, terms: e.target.checked})} />
            </Field>
            <Button type="submit">{t('validationSubmit')}</Button>
        </form>
    )
}

Input group with addons + a masked value:

<Input id="card" inputMode="numeric" value={card}
       onChange={(e) => setCard(maskCardNumber(e.target.value))}
       leadingIcon={<CreditCard className="h-4 w-4" />} placeholder="4242 4242 4242 4242" />

Demo pages

Six pages in src/pages/forms/, wired via appRoutes in src/routes.tsx (two are lazy). All page copy lives in the forms i18n namespace (~590 keys).

PageRouteLazyShows
Form Elements/forms/elementsnoEvery control: types, sizes, addons, tiles, pickers, upload, states.
Form Layouts/forms/layoutsnoLayout patterns (vertical/horizontal/inline/two-col/floating) + complete forms (settings, checkout, sticky footer, wizards).
Form Validation/forms/validationnoPure validate() — submit/blur/live timing, async check, error summary.
Form Plugins/forms/pluginsyesThe "plugin-alternatives" controls gallery: pickers, multi-select, tags, masks, rich text.
Inline Editable/forms/inline-editablenoEditable — popup/inline modes, text/select/textarea/date, placements.
Editors/forms/editorsyesRichTextEditor — validated article form, live preview, count/autosave/read-only variants.

Highlights: Form Validation demos submit / blur (with a touched map) / live as-you-type timing plus special cases (password + confirm, a debounced async availability check with a spinner, constraint validation, and an error summary Alert whose entries focus their field via refs). Editors validates the rich-text body on the stripped plain text via a plainText() HTML-stripper (an "empty" Tiptap document is <p></p>, which is truthy).

Best practices

  • Import from the barrel @/components/ui — except RichTextEditor (by path, lazy pages only) and Editable (@/components/editable/Editable).
  • Wrap every control in a Field and associate the label with htmlFor + control id.
  • Reuse fieldBase (and INPUT_SIZE) for any new field-like control instead of re-declaring styling.
  • Keep validation pure: a validate(values) => Errors returning i18n keys, called on blur/submit.
  • Pair invalid (on the control) with error (on the Field) so the visual and the message agree.
  • For Select/Combobox, drive them with value/onChange + an options array — never <option>.
  • Keep picker state as Date | null; convert with src/lib/datetime.ts (never new Date('YYYY-MM-DD')).
  • Validate rich-text emptiness on the stripped plain text, not the raw HTML.

Troubleshooting

SymptomLikely cause & fix
Select/Combobox menu is clipped inside a modalIt's portaled to <body> by design; keep menuPortalTarget={document.body}.
Select options render at the wrong font sizeThe styles prop's per-size fontSize beats react-select's unlayered Emotion — don't remove it.
A picked date shifts by one day after savingYou round-tripped through new Date('YYYY-MM-DD') (UTC). Use parseLocalDate/formatLocalDate.
Rich text ballooned the main bundleRichTextEditor was imported from an eager module. Import it by path from a React.lazy page only.
Saved rich-text HTML renders unstyledWrap the rendered HTML in className="rte-preview" so _editor.scss styles it.
Rich-text "required" check never failsAn empty Tiptap doc is <p></p>, which is truthy — strip tags first (the plainText() pattern).
Error message shows but the field looks normalYou set error on the Field but forgot invalid on the control (or vice-versa). Set both.
Label doesn't focus the control on clickMissing htmlFor/id pairing (Select/Combobox map id to inputId).

Was this page helpful?