PVR Tech Studio

Form Components

Token-styled, accessible form primitives — the core set (Input, Textarea, Select, Combobox, Label, Checkbox, Radio, Field), date/time & color pickers, entry controls (PasswordInput, TagsInput, PhoneInput, OtpInput, FileUpload), selection controls (ToggleGroup, OptionCard, Rating, Slider, Stepper), the Tiptap-based RichTextEditor, and the click-to-edit Editable — plus the six form demo pages and the pure validate() pattern.

28 min read
Updated August 13, 2026

Overview

The form primitives live in src/components/ui/ and are re-exported from the barrel src/components/ui/index.ts, 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 (see RichTextEditor bundle isolation).
  • 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 — text-like controls (Input, Textarea, TagsInput, PasswordInput, the picker triggers) use the exported fieldBase class string, so they look and focus identically.
  • Colors from tokens only — border-border, bg-surface, text-foreground, ring-ring, and danger for errors — so every skin and dark mode work automatically. See Design tokens & dark mode. Even the third-party pieces (react-select, react-datepicker, react-colorful, Tiptap) are re-themed through raw token vars in the SCSS layer.
  • Accessible by construction — native inputs (visually hidden for Checkbox/Radio), aria-invalid wiring, label association via htmlFor, visible focus rings, and keyboard support on the composite controls (OTP arrow/backspace/paste nav, file-upload Enter/Space, slider = a native <input type=range>).
  • i18n-agnostic components — user-facing strings come in as props (browseLabel, removeLabel, strengthLabels, linkLabels, …); pages pass t('forms:…') keys.
  • Field ties a Label, a control, and a hint/error line together in a consistent row.

Validation in this template is plain functions, not a form library — a pure validate(values) helper returns a per-field error map of i18n keys, rendered via t(). See FormValidationPage.

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.tsxLabel
Checkbox.tsxCheckbox
Radio.tsxRadio
Field.tsxField
DatePicker.tsxDatePicker, PickerTrigger, DATEPICKER_PORTAL
TimePicker.tsxTimePicker
DateRangePicker.tsxDateRangePicker, DateRange (type)
ColorPicker.tsxColorPicker
PasswordInput.tsxPasswordInput, PasswordRequirementLabels (type)
TagsInput.tsxTagsInput
PhoneInput.tsxPhoneInput
OtpInput.tsxOtpInput
FileUpload.tsxFileUpload
ToggleGroup.tsxToggleGroup, ToggleOption (type)
OptionCard.tsxOptionCard, OptionCardVariant (type)
Rating.tsxRating
Slider.tsxSlider
Stepper.tsxStepper, StepperStep (type)
RichTextEditor.tsxRichTextEditor — not in the barrel, import by path
index.tsBarrel — re-exports all of the above except RichTextEditor

Feature components & supporting modules:

FilePurpose
src/components/editable/Editable.tsxClick-to-edit value (Editable, EditableOption/EditableType types)
src/lib/masks.tsPure format-on-type helpers: digits, maskCardNumber, maskExpiry, maskCvc, maskPhone
src/lib/password.tspasswordScore(pw) → {score: 0–4, tone} (pure, no i18n)
src/lib/datetime.tsparseLocalDate/formatLocalDate/parseLocalTime/formatLocalTime — local-timezone string↔Date bridges
src/data/countries.tsCOUNTRIES/Country — dial-code list (emoji flags) for PhoneInput
src/styles/_datepicker.scssreact-datepicker themed via raw token vars (.dp-popper)
src/styles/_editor.scssTiptap content styling (.rte .ProseMirror + read-only .rte-preview)

Third-party libraries (all MIT): react-select (+ react-select/creatable for Combobox), react-datepicker (date/time/range pickers), react-colorful (ColorPicker), Tiptap (@tiptap/react + StarterKit/Underline/Link/Placeholder/TextAlign for RichTextEditor).

Demo pages (in src/pages/forms/ — six routes):

PageRouteFileLazyShows
Form Elements/forms/elementsFormElementsPage.tsxnoEvery control: types, sizes, addons, tiles, pickers, upload, states.
Form Layouts/forms/layoutsFormLayoutsPage.tsxnoLayout patterns (vertical/horizontal/inline/two-col/floating) + complete forms (settings, checkout, sticky, wizards).
Form Validation/forms/validationFormValidationPage.tsxnoPure validate() — submit/blur/live timing, async check, error summary.
Form Plugins/forms/pluginsFormPluginsPage.tsxyesThe "plugin" controls gallery: pickers, multi-select, tags, masks, rich text.
Inline Editable/forms/inline-editableInlineEditablePage.tsxnoEditable — popup/inline modes, text/select/textarea/date, placements.
Editors/forms/editorsEditorsPage.tsxyesRichTextEditor — validated article form, live preview, count/autosave/read-only variants.

All page copy lives in the forms i18n namespace (src/locales/en/forms.json, ~590 keys, grouped by prefix: elements*, layouts*, validation*, plugins*, inline*, editors*).

Usage

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

import {Field, Input} from '@/components/ui'
 
<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 in this guide accepts invalid and an id for label association, so they all drop into Field the same way.

API / Props

Core field primitives

Input

Text-like input. Extends Omit<InputHTMLAttributes<HTMLInputElement>, 'prefix'> (so type, value, onChange, placeholder, disabled, etc. all pass through). Forwards its ref. When any addon prop (prefix/suffix/leadingIcon/trailingIcon) is set, it renders as an input group — a focus-within wrapper carries the border/ring and the inner input goes borderless; with no addons it renders a plain <input> identical to before.

PropTypeDefaultDescription
invalidboolean—Sets aria-invalid → danger border + ring (via fieldBase).
inputSize'sm' | 'md' | 'lg''md'Height/padding/text size (see Input sizes).
prefixReactNode—Text/element addon flush left, inside the border (e.g. https://).
suffixReactNode—Addon flush right (e.g. .00, a copy button).
leadingIconReactNode—Icon inside the field at the start.
trailingIconReactNode—Icon inside the field at the end (e.g. a live-validation check).
classNamestring—Extra classes (merged after the size classes).
…restnative input attributes—All native input attributes (minus prefix).

Textarea

Multi-line input. Extends TextareaHTMLAttributes<HTMLTextAreaElement>; uses the same fieldBase. Forwards its ref.

PropTypeDefaultDescription
invalidboolean—Sets aria-invalid → danger border + ring.
inputSize'sm' | 'md' | 'lg''md'Min-height / vertical padding / text size per size.
rowsnumber4Initial visible rows (still user-resizable vertically).
classNamestring—Extra classes (e.g. resize-none).
…restnative textarea attrs—All native textarea attributes.

Select

A react-select wrapper (not a native <select>). Fully themed with the design tokens via react-select's classNames + unstyled, so it re-skins and dark-modes automatically. The menu is portaled to <body> so it isn't clipped inside scrollable containers (e.g. a Modal).

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.
valuestring (or string[] with isMulti)—Controlled selected value(s).
defaultValuestring (or string[] with isMulti)—Uncontrolled initial value(s).
onChange(v: string) => void (or string[])—Called with the value(s) ('' when cleared, single).
placeholderstring—Placeholder text.
invalidboolean—Danger border + ring; sets aria-invalid.
disabledboolean—Disable the control.
isSearchablebooleanfalseAllow typing to filter options.
size'sm' | 'md' | 'lg''md'Control min-height + font size (matches InputSize).
idstring—inputId (for label association).
namestring—Field name.
classNamestring—Extra classes on the container.
aria-labelstring—Accessible label when there's no visible one.

SelectOption: { value: string; label: string; isDisabled?: boolean }.

Combobox

Searchable single-select that can also create a new option on the fly (autocomplete + add) — built on react-select/creatable, themed identically to Select, isClearable, menu portaled to <body>. Options created at runtime are kept in internal state and merged with options.

PropTypeDefaultDescription
optionsComboboxOption[]—{value, label}[].
valuestring—Controlled selected value.
onChange(value: string) => void—Called with the value ('' when cleared); also fires with the typed label on create.
createLabelstring—Prefix on the "create new" row (e.g. "Create"); renders Create "input".
placeholderstring—Placeholder text.
invalidboolean—Danger border + ring.
disabledboolean—Disable the control.
id / name / className / aria-labelstring—Same as Select.

Label

Field label with an optional required asterisk. Extends LabelHTMLAttributes<HTMLLabelElement> (so htmlFor passes through).

PropTypeDefaultDescription
requiredboolean—Shows a danger * after the text.
classNamestring—Extra classes.

Checkbox

Token-styled checkbox. The native input is visually hidden as a peer; the box + checkmark are sibling layers driven by peer-checked: (the check fades/scales in). Extends Omit<InputHTMLAttributes<HTMLInputElement>, 'type'>; forwards its ref.

PropTypeDefaultDescription
labelReactNode—Text shown next to the box.
disabledboolean—Disables and dims the row.
classNamestring—Extra classes on the wrapping label.
…restnative input attrs (minus type)—checked, onChange, name, etc.

Radio

Same peer pattern as Checkbox, with a filled dot indicator. Extends Omit<InputHTMLAttributes<HTMLInputElement>, 'type'>; forwards its ref. Same props as Checkbox (name groups radios).

Field

Label + control + hint/error wrapper for consistent form rows.

PropTypeDefaultDescription
labelReactNode—Renders a Label above the control (omit for label-less rows).
htmlForstring—Associates the label with a control id.
requiredboolean—Passes through to the Label (danger asterisk).
hintReactNode—Helper text below the control (hidden when error is set).
errorReactNode—Error text; replaces the hint and colors the line danger.
classNamestring—Extra classes on the row wrapper.
childrenReactNode—The control (Input, Select, Checkbox, …).

Date, time & color pickers

The three date/time pickers wrap react-datepicker with a shared fieldBase-styled button trigger (PickerTrigger, exported from DatePicker.tsx) and a body-level portal (DATEPICKER_PORTAL = 'datepicker-portal') so the popper escapes panel/modal overflow: hidden. Theming lives in src/styles/_datepicker.scss (raw token vars, .dp-popper z-index 60). Values are Date | null — bridge string state with src/lib/datetime.ts.

DatePicker

PropTypeDefaultDescription
valueDate | null—Selected date.
onChange(date: Date | null) => void—Selection callback.
min / maxDate—Selectable range bounds.
dateFormatstringadaptsdate-fns format; defaults to 'PP', 'PP p' with showTime, 'MMM yyyy' with monthYear.
showTimeboolean—Also select a time (date + time).
monthYearboolean—Month/year-only picker.
inlineboolean—Render the calendar inline (no trigger input, no portal).
timeIntervalsnumber15Minutes between time options (with showTime).
placeholderstring—Trigger placeholder.
invalid / disabledboolean—Danger border / disabled trigger.
id / classNamestring—Trigger id / wrapper classes.

TimePicker

Time-only picker (react-datepicker showTimeSelectOnly).

PropTypeDefaultDescription
valueDate | null—Selected time (a Date).
onChange(date: Date | null) => void—Selection callback.
minuteStepnumber15Minutes between options.
timeFormatstring'p'date-fns time format (localized, e.g. "2:30 PM").
placeholderstring'--:--'Trigger placeholder.
invalid / disabled / id / className—As DatePicker.

DateRangePicker

Start–end range picker (react-datepicker selectsRange). DateRange = [Date | null, Date | null].

PropTypeDefaultDescription
valueDateRange—[start, end].
onChange(range: DateRange) => void—Selection callback.
min / maxDate—Selectable bounds.
dateFormatstring'PP'date-fns format.
monthsShownnumber2Calendar months shown side-by-side.
placeholder / invalid / disabled / id / className—As DatePicker.

ColorPicker

Built on react-colorful: a saturation/hue area + hex field + preset swatches, inside a portaled Popover. The trigger is a token-styled button showing the current swatch + hex.

PropTypeDefaultDescription
valuestring—Current hex color.
onChange(hex: string) => void—Fired from the picker, hex field, or a preset.
presetsstring[]10-color setSwatch palette (literal hex values are intentional here — this is a color value input, not themable chrome).
disabled / id / aria-label / className—Trigger attributes.

Entry controls

PasswordInput

Password field with show/hide toggle, an optional strength meter (single danger→success gradient bar revealed by clip-path) and a requirements checklist. Scoring comes from the pure passwordScore() in src/lib/password.ts. All labels are props (i18n-agnostic).

PropTypeDefaultDescription
valuestring—Controlled value.
onChange(value: string) => void—Change callback (plain string, not an event).
strengthboolean—Show the strength meter below the field.
strengthLabels[string, string, string, string]—Labels for scores 1–4 (weak/fair/good/strong).
requirementsboolean—Show the checklist (needs requirementLabels).
requirementLabels{length, case, number, symbol} strings—Checklist row labels.
showLabel / hideLabelstring—aria-labels for the eye toggle.
placeholder / invalid / disabled / id / autoComplete / className—Standard field props.

TagsInput

Tag/token input on the fieldBase shell — type + Enter to add, Backspace (on empty draft) or the ✕ to remove. De-dupes and respects max.

PropTypeDefaultDescription
valuestring[]—Current tags.
onChange(tags: string[]) => void—Fired on add/remove.
maxnumber—Maximum tag count (further adds are ignored).
removeLabelstring—aria-label prefix for a tag's remove button.
placeholder / invalid / disabled / id / className—Standard field props (placeholder hides once tags exist).

PhoneInput

Phone field: a dial-code country picker (portaled Popover with emoji flags, data from src/data/countries.ts) + a masked national number (maskPhone from src/lib/masks.ts).

PropTypeDefaultDescription
valuestring—The national number (already masked).
onChange(value: string) => void—Receives the masked value.
onCountryChange(country: Country) => void—Fired when the dial code changes.
defaultCountrystring'US'ISO code of the initial country.
placeholderstring'(555) 000-0000'Number placeholder.
invalid / disabled / id / className—Standard field props.

OtpInput

Segmented one-time-code / PIN input with auto-advance, backspace-to-previous, arrow-key nav, paste-to-fill, and select-on-focus.

PropTypeDefaultDescription
lengthnumber6Number of boxes.
valuestring—Joined value.
onChange(value: string) => void—Fired on every keystroke.
onComplete(value: string) => void—Fired once every box is filled.
type'number' | 'text''number'number restricts to digits (numeric inputMode).
invalid / disabled / className / aria-label—Group attributes (role="group").

FileUpload

Token-styled file upload: click-to-browse + drag-and-drop dashed drop zone (keyboard-activatable), with a selected-file list (name, formatted size, remove). Controlled (files + onFiles) or uncontrolled. All text comes from props so the component stays i18n-agnostic.

PropTypeDefaultDescription
multipleboolean—Allow multiple files (single mode keeps only the first).
acceptstring—Native accept filter (e.g. image/*).
filesFile[]—Controlled selection; omit to let it track its own.
onFiles(files: File[]) => void—Fired with the current list whenever it changes.
browseLabelReactNode—Primary CTA text (e.g. "Click to upload").
dropLabelReactNode—Secondary line (e.g. "or drag and drop").
hintReactNode—Small helper (accepted types / max size).
removeLabelstring—aria-label for the per-file remove button.
invalid / disabled / id / className—Standard props.

Selection & step controls

ToggleGroup

Segmented / toggle button group — single-select by default or multiple (a discriminated union, so value/onChange are string/string[] accordingly). Buttons carry aria-pressed. This is the general-purpose primitive; the customizer-bound OptionPills is a separate component.

PropTypeDefaultDescription
optionsToggleOption[]—{value, label, icon?}[] (label may be a node — e.g. an icon-only group).
multiplebooleanfalseMulti-select mode.
valuestring (or string[] with multiple)—Active value(s).
onChange(v: string) => void (or string[])—Selection callback.
size'sm' | 'md''md'Button padding/text size.
className / aria-labelstring—Group attributes (role="group").

OptionCard

Selectable card/tile with no visible checkbox or radio — the card itself is the control (aria-pressed). The parent drives single- or multi-select via selected/onSelect. Four variants: default (vertical, icon tile, corner check), horizontal (icon left, check-circle right), filled (whole tile fills bg-primary when selected), plain (text-forward, left accent bar + corner check).

PropTypeDefaultDescription
selectedboolean—Selected state.
onSelect() => void—Click handler (parent toggles).
titleReactNode—Tile title.
descriptionReactNode—Optional secondary line.
iconReactNode—Optional leading icon.
variantOptionCardVariant'default'default | horizontal | filled | plain.
disabled / className / aria-label—Standard props.

Rating

Star rating — interactive (with hover preview) when onChange is passed and not readOnly (role="radiogroup"), otherwise a read-only role="img". Stars use the warning token.

PropTypeDefaultDescription
valuenumber—Current rating.
onChange(value: number) => void—Makes it interactive.
maxnumber5Number of stars.
readOnlyboolean—Force display-only.
size'sm' | 'md' | 'lg''md'Star size.
clearableboolean—Clicking the current value again resets to 0.
className / aria-labelstring—Wrapper attributes.

Slider

Range slider — a native <input type="range"> (keyboard + a11y for free) with a token-filled track (computed linear-gradient from the raw --primary/--border vars) and a styled thumb.

PropTypeDefaultDescription
valuenumber—Current value.
onChange(value: number) => void—Change callback (already a number).
min / maxnumber0/100Range bounds.
stepnumber1Step increment.
showValueboolean—Show the current value right of the track.
formatValue(value: number) => string—Format the shown value (e.g. (v) => `${v}%`).
disabled / id / className / aria-label—Standard props.

Stepper

Horizontal step indicator (done / current / upcoming) with connector lines — drives a wizard (see FormLayoutsPage's WizardForm). Labels/descriptions hide on sm breakpoints down.

PropTypeDefaultDescription
stepsStepperStep[]—{label, description?}[].
currentnumber—Zero-based index of the active step.
onStepClick(index: number) => void—If set, completed/active steps become clickable (no jumping ahead).
classNamestring—Extra classes.

RichTextEditor

Rich-text editor built on Tiptap (MIT, headless) with a token-styled toolbar so it re-skins and dark-modes like the rest of the app. Controlled via an HTML string value. Extensions: StarterKit + Underline + Link (autolink, rel="noopener noreferrer") + Placeholder + TextAlign. The full toolbar covers bold/italic/underline/strike, H1–H3, bullet/numbered lists, quote, code block, link popover (add/remove/apply), text align, clear formatting, and undo/redo; minimal trims it to bold/italic/underline/bullet-list/link for comments & notes. 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 (see EditorsPage's preview tab and comments).

Not barrel-exported — import directly:

import {RichTextEditor} from '@/components/ui/RichTextEditor'
PropTypeDefaultDescription
valuestring—Current value as an HTML string.
onChange(html: string) => void—Fired with editor.getHTML() on every update.
placeholderstring—Empty-document placeholder.
minimalboolean—Compact toolbar.
readOnlyboolean—View-only: hides the toolbar, locks editing, keeps content crisp (no dim).
disabledboolean—Locks editing and dims the whole control.
invalidboolean—Danger border (pairs with Field error).
linkLabels{add, remove, url, apply} stringsEnglish fallbacksLabels for the link popover — pass t() values.
className / aria-labelstring—Wrapper class / textbox aria-label.

External value changes are synced in with setContent (skipped while it matches getHTML(), so your own typing doesn't loop).

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 buttons) and inline (swaps the control in place). Four control types: text (Enter commits, Escape reverts; blur commits in inline mode), textarea (Ctrl/Cmd+Enter commits), select (uses Select, optionally searchable; commits immediately on change in inline mode), and date (an inline DatePicker in the popup; commits on pick in inline mode — string values bridge via parseLocalDate/formatLocalDate from src/lib/datetime.ts).

PropTypeDefaultDescription
type'text' | 'select' | 'textarea' | 'date''text'The editing control.
valuestring—Current value (dates as 'YYYY-MM-DD').
onCommit(value: string) => void—Fired with the trimmed value on save.
mode'popup' | 'inline''popup'Popover with Save/Cancel vs. in-place swap.
optionsEditableOption[]—For select (also resolves the display label).
searchableboolean—Searchable select ("select2" style).
side'top' | 'bottom''bottom'Popup placement.
align'start' | 'end''start'Popup alignment.
emptyLabelstring—Muted italic label when the value is empty.
saveLabel / cancelLabelstring'Save'/'Cancel'Popup button labels — pass t() values.
className / aria-labelstring—Trigger attributes.

Configuration & customization

fieldBase — the shared field style

fieldBase is 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, and the aria-[invalid=true] danger ring. Input, Textarea, TagsInput, and PasswordInput build on it, and you should reuse it for any new field-like control so it matches the theme automatically:

import {fieldBase} from '@/components/ui'
import {cn} from '@/lib/cn'
 
<input className={cn(fieldBase, 'h-10')} />

The demo pages use it directly for one-off composites — e.g. the native <select> sample and the floating-label fields on FormElementsPage/FormLayoutsPage.

Input sizes

InputSize = 'sm' | 'md' | 'lg' is shared across the text controls: Input inputSize (h-8/h-10/h-12), Textarea inputSize (min-height + text size), and Select size (min-height + Emotion font size). The class map is exported as INPUT_SIZE from Input.tsx if a custom control needs to match.

The Field wrapper

Field is the standard row layout: it renders Label (with required) + your control + a single hint/error paragraph. Use it for every form control so labels, spacing, and error messaging stay consistent. It works with any control passed as children — including Checkbox (pass only error, no label, when the checkbox already has its own label) and the pickers/editors above.

invalid and error states

There are two coordinated flags:

  • invalid on the control (every primitive in this guide accepts it) sets aria-invalid and/or turns the border + focus ring danger.
  • error on the Field replaces the hint with a danger-colored message.

They're used together: invalid={!!errors.email} on the input, error={errors.email} on the field.

<Field label="Email" htmlFor="email" required error={errors.email}>
    <Input id="email" invalid={!!errors.email} value={email} onChange={onChange} onBlur={onBlur} />
</Field>

The react-select-based Select API

Because Select wraps react-select, treat it as a controlled component driven by string values, not a DOM <select>:

  • You can't use <option> children — pass an options array of {value, label}.
  • onChange gives you the value string directly (react-select's option object is unwrapped for you); with isMulti it's a string array.
  • Use value for controlled or defaultValue for uncontrolled.
  • Set isSearchable to allow typing; disable individual options with isDisabled on the option.
  • The menu is portaled to document.body — it renders above modals and won't clip in scroll containers.
  • Need create-on-the-fly? Use Combobox instead of extending Select.

Input masks

src/lib/masks.ts holds pure format-on-type helpers for controlled inputs: maskCardNumber (4242 4242 4242 4242), maskExpiry (MM/YY), maskCvc (max 4 digits), maskPhone ((555) 123-4567, used internally by PhoneInput), and the digits stripper. Use them inline:

<Input inputMode="numeric" value={card} onChange={(e) => setCard(maskCardNumber(e.target.value))} />

Password strength scoring

src/lib/password.ts exports passwordScore(pw) → {score: 0|1|2|3|4, tone: PasswordTone} — one point each for length ≥ 8, mixed case, a digit, and a symbol; tones map danger → warning → info → success. PasswordInput consumes it; reuse it anywhere you need the same rules (the checklist in PasswordInput mirrors the same four checks).

Local date/time string helpers

src/lib/datetime.ts bridges string state ('YYYY-MM-DD', 'HH:mm') with the Date-based pickers: parseLocalDate/formatLocalDate and parseLocalTime/formatLocalTime. They build/read local dates — a bare new Date('YYYY-MM-DD') parses as UTC midnight and shifts a day in western timezones, so always use these when persisting picker values as strings (as Editable type="date" does).

Picker & editor theming (SCSS)

Both third-party surfaces are themed only through raw token vars (no @apply, no hex, no --color-* — the same approach as _calendar.scss for FullCalendar):

  • src/styles/_datepicker.scss recolors/rounds react-datepicker (.dp-popper, z-index 60), makes the wrapper span full width so triggers fill their Field, and keeps the library's own layout so date+time, multi-month range, and month pickers lay out correctly. The popper renders into the body-level #datepicker-portal node (DATEPICKER_PORTAL) so it escapes overflow: hidden ancestors (e.g. a collapsed Panel body).
  • src/styles/_editor.scss styles Tiptap content under .rte .ProseMirror and the read-only .rte-preview with the same rules, so edited and rendered documents look identical.

RichTextEditor bundle isolation

RichTextEditor is deliberately not re-exported from src/components/ui/index.ts: the barrel is imported by the eager app entry, and routing Tiptap/ProseMirror (~340 kB) through it would hoist the editor into the main bundle. Import it by path (@/components/ui/RichTextEditor) from lazy pages only — its two consumers (EditorsPage, FormPluginsPage) are both code-split, so Tiptap stays in their async chunks. Keep it that way when reusing it.

Examples

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:

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 {toast} = useToast()
    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 label={t('validationPlan')} htmlFor="plan" required error={err('plan')}>
                <Select
                    id="plan"
                    value={values.plan}
                    invalid={!!errors.plan}
                    onChange={(plan) => setValues({...values, plan})}
                    placeholder={t('validationPlanPlaceholder')}
                    options={[
                        {value: 'starter', label: t('validationPlanStarter')},
                        {value: 'pro', label: t('validationPlanPro')},
                        {value: 'enterprise', label: t('validationPlanEnterprise')},
                    ]}
                />
            </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>
    )
}

An input group with addons and a masked value:

<Field label="Website" htmlFor="web">
    <Input id="web" prefix="https://" suffix=".com" placeholder="yoursite" />
</Field>
 
<Field label="Card number" htmlFor="card">
    <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"
    />
</Field>

Date + time pickers (values are Date | null):

const [when, setWhen] = useState<Date | null>(null)
const [range, setRange] = useState<DateRange>([null, null])
 
<Field label="Starts" htmlFor="starts">
    <DatePicker id="starts" value={when} onChange={setWhen} showTime min={new Date()} />
</Field>
<Field label="Duration" htmlFor="dates">
    <DateRangePicker id="dates" value={range} onChange={setRange} />
</Field>

A rich-text field inside a validated Field (emptiness checked on the stripped plain text — the EditorsPage pattern):

import {RichTextEditor} from '@/components/ui/RichTextEditor'
 
<Field label={t('editorsFieldBody')} required error={err('body')}>
    <RichTextEditor
        value={body}
        onChange={setBody}
        invalid={!!errors.body}
        placeholder={t('editorsBodyPlaceholder')}
        linkLabels={{add: t('editorsLinkAdd'), remove: t('editorsLinkRemove'), url: t('editorsLinkUrl'), apply: t('editorsLinkApply')}}
    />
</Field>

A radio group:

<Field label="Notifications">
    <div className="space-y-2">
        <Radio name="notify" value="all" label="All activity" defaultChecked />
        <Radio name="notify" value="mentions" label="Only mentions" />
        <Radio name="notify" value="none" label="Nothing" />
    </div>
</Field>

Demo pages

  • Form Elements (src/pages/forms/FormElementsPage.tsx, /forms/elements) — the full catalog in SectionHeading-grouped Panels: input types (text/email/password/number/tel/url/search), sizes (inputSize sm/md/lg), floating labels, input groups & addons (prefix/suffix/leading/trailing), selects (searchable, size="sm", error state, a native <select> on fieldBase), textareas; checkboxes/radios plain + card-styles + all four OptionCard tile variants (multi-select rows for checkboxes, single-select for radios), a segmented control and Switches; date & time (basic/min-max/month-year, date+time, range, time-only, an inline calendar, ColorPicker, a quantity stepper); advanced (OTP, masked card/expiry/CVC, PhoneInput, Rating + Slider, Select isMulti + ToggleGroup single/multiple, TagsInput + Combobox, PasswordInput with strength + requirements); and upload & states (single/multiple FileUpload, default/error/success/disabled/read-only states, a button row).
  • Form Layouts (src/pages/forms/FormLayoutsPage.tsx, /forms/layouts) — layout patterns (vertical, horizontal label-left rows, inline invite bar, two-column grid, floating labels) and complete forms: a settings page (row-per-setting with Switch/Select, danger zone), a checkout/billing form (masked card fields + a sticky order summary using formatMoney), a scrollable form with a sticky action footer, a 3-step wizard driven by Stepper, and a second wizard driven by Progress + a "Step X of N" counter.
  • Form Validation (src/pages/forms/FormValidationPage.tsx, /forms/validation) — validation timing (on submit; on blur with a touched map, re-validating touched fields as they change; live as-you-type with trailingIcon check/cross feedback) and special cases: password + confirm (PasswordInput strength meter), an async availability check (debounced fake lookup with a spinner trailing icon), constraint validation (URL, number range, maxLength char counter), and an error summary Alert whose entries focus their field via refs. All validators are pure and return i18n keys (isEmail is shared from src/components/auth/validators.ts). No form library.
  • Form Plugins (src/pages/forms/FormPluginsPage.tsx, /forms/plugins, lazy) — the "plugin-alternatives" gallery, entirely in-house primitives: pickers (date/min-max/month, date+time/range/time, inline calendar, ColorPicker with a live swatch), selection & entry (Select single + isMulti, creatable Combobox, TagsInput with max, a character-counter Textarea, Slider + Rating, PasswordInput + OtpInput, masked card/phone inputs, a read-only API-key field with a copy-button suffix, image FileUpload), and rich text (full + minimal RichTextEditor side by side).
  • Inline Editable (src/pages/forms/InlineEditablePage.tsx, /forms/inline-editable) — the Editable component in the x-editable idiom: popup groups for text, select, searchable select, textarea, and date fields; inline mode (in-place swap; select/date commit on change); placement (all four side/align combinations); and two composed examples (an editable user profile card and an order-details card).
  • Editors (src/pages/forms/EditorsPage.tsx, /forms/editors, lazy) — RichTextEditor in anger: a validated article form (title/category/tags/body/visibility/cover-upload/agree, with the rich-text body validated via a plainText() HTML-stripper), a live Preview / HTML tab pair (preview renders through .rte-preview), a minimal comment composer with a posted-comments list, and three variants — live word/character count, a debounced autosave indicator, and a read-only document.

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 function returning i18n keys, called on blur/submit; render with t().
  • 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 and an options array — never <option> children.
  • Keep picker state as Date | null; when you must persist strings, convert with src/lib/datetime.ts (never new Date('YYYY-MM-DD')).
  • Reuse src/lib/masks.ts for card/expiry/CVC/phone formatting — don't re-write the regexes.
  • Pass all user-facing labels, hints, errors, and component label-props (browseLabel, strengthLabels, linkLabels, saveLabel, …) through i18n t().
  • Validate rich-text emptiness on the stripped plain text, not the raw HTML (an "empty" Tiptap document is <p></p>).

Troubleshooting

SymptomLikely cause & fix
Select/Combobox menu is clipped inside a modalIt's portaled to <body> by design; if you re-styled it, keep menuPortalTarget={document.body}.
Select options render at the wrong font sizeThe styles prop's per-size fontSize on control/menu is required to beat react-select's unlayered Emotion — don't remove it.
Passing <option> to Select does nothingIt's not a native select. Use the options prop.
Date/time popper is clipped or hiddenThe pickers render into the body-level #datepicker-portal (DATEPICKER_PORTAL); keep portalId if you fork them (inline skips it).
A picked date shifts by one day after savingYou round-tripped through new Date('YYYY-MM-DD') (UTC). Use parseLocalDate/formatLocalDate from src/lib/datetime.ts.
Date field is narrower than the other inputs_datepicker.scss forces the wrapper to display: block; width: 100% — make sure the SCSS layer is loaded / not overridden.
Rich text ballooned the main bundleRichTextEditor was imported from an eager module. Import it by path from a code-split page only (see bundle isolation above).
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 on EditorsPage).
Error message shows but the field looks normalYou set error on the Field but forgot invalid on the control (or vice-versa). Set both.
Checkbox/radio look unstyledThe native input is hidden as a peer; the box is a sibling. Don't remove the peer/sibling structure or add a second wrapping label.
Label doesn't focus the control on clickMissing htmlFor/id pairing. Match Field htmlFor to the control id (Select/Combobox map it to inputId).
Field colors wrong in dark mode / a skinA hardcoded color slipped in — use tokens / fieldBase (the SCSS partials only use raw --* vars).

FAQ

Which validation library does this use? None — validation is plain functions returning an error-key map. See FormValidationPage. You can bolt on a library, but the primitives don't require one.

How do I make a multi-select? Pass isMulti to Select — value/onChange become string[] and selections render as removable chips. For free-text tokens use TagsInput; for search-and-create use Combobox.

Can I use a native <select> instead? You can, styled with fieldBase (there's a sample on FormElementsPage), but the Select component gives you themed, portaled menus, searchable options, and multi-select for free.

How do I show a required marker? Pass required to Field (it forwards to Label) for the danger asterisk. This is visual only — enforce it in your validate().

Why isn't RichTextEditor in the barrel? Bundle size — Tiptap/ProseMirror would land in the main chunk. Import it by path from lazy pages.

What's the difference between Editable and the editors page? Editable is click-to-edit for single values (x-editable style, /forms/inline-editable); RichTextEditor is a document editor (/forms/editors).

Where do the phone country codes come from? src/data/countries.ts (COUNTRIES) — a curated list with emoji flags (no image assets). Country names are proper nouns and stay literal.

Notes for designers & content editors

  • All labels, hints, and errors are translatable — they're i18n keys in the forms namespace, not literals; components receive their strings as props.
  • Error tone is always danger (red). Hints are muted. Required fields get a red asterisk. The password meter runs danger → warning → info → success; rating stars use warning.
  • Field spacing and focus rings are consistent across all controls because of fieldBase and Field — avoid one-off spacing so forms stay uniform. The three sizes (sm/md/lg) are the only sanctioned height variants.
  • Even the third-party pickers/editor re-skin automatically — they're themed via design tokens, so a new skin needs no picker/editor work.
  • Keep hint text short; it sits directly under the control and is replaced by the error when present.
  • OptionCard gives four tile looks (default/horizontal/filled/plain) — pick one per selection group, don't mix variants within a group.

Was this page helpful?