Input — Advanced Multi-Type Input for React Native
Input
A single TextInput replacement that adapts itself to ten different input types — plain text, numbers, decimals, passwords, email, phone, and full date/time picking — instead of forcing you to hand-roll masking, validation, and a separate picker component for each one. Pass type, and Input resolves the right keyboard, sanitizer, auto-attached icon, and (for date/time) an inline typing mask plus a modal picker, all from one component.
How it works internally
Type-driven configuration. A module-scope TYPE_CONFIG map resolves each InputType to its keyboardType, autoCapitalize, and an optional sanitize function — built once at import time rather than recomputed per render. Sanitizers are pure string-in/string-out functions (sanitizeNumber, sanitizeDecimal, sanitizeTextNumber, sanitizePhone) run on every keystroke inside handleChangeText, so typing "abc123" into a number field simply never shows the letters rather than showing-then-stripping them.
Self-managed display value. Sanitization and date masking only have a visible effect if something re-renders the TextInput with the cleaned string — relying on a consumer to pipe onChangeText back into value is exactly how unrestricted input slips through on an uncontrolled field. Input keeps its own internalValue state so the sanitized/masked text is always what's shown, while still staying in sync with an externally controlled value or dateValue via two useEffects that watch for controlled updates.
Date/time format engine. Formats are plain token strings — YYYY, MM, DD, HH (24h), hh (12h), mm, ss, A (AM/PM) — with any other character treated as a literal separator ("DD/MM/YYYY", "hh:mm A"). parseFormat walks the format once into an ordered list of token/literal segments; formatWithPattern renders a Date through those segments; maskFormattedInput runs on every keystroke, stripping non-digit input and re-laying raw characters into the format's slots so separators (-, /, :) appear automatically as the user types, stopping cleanly at the first incomplete slot rather than producing a malformed string. parseFormattedValue does the inverse — it only returns a Date once every token slot is fully typed and in range (month 1–12, day 1–31, hour 0–23, minute 0–59); anything incomplete returns null, so a half-typed date never fires onDateChange. Whether the picker's time column shows a 12-hour AM/PM selector or a 24-hour one is derived automatically from whether the resolved format contains hh or A — there's no separate prop for it.
Typed vs. locked date fields. typeable (default true) and selectable (default true) are independent: with both on, tapping the field lets you type through the mask while only the calendar/clock icon opens the picker modal. Setting typeable={false} computes isLockedDateField, which switches the field to editable={false} with pointerEvents="none" on the TextInput itself and wraps it in a Pressable so tapping anywhere on the field opens the picker instead — readOnly takes precedence over both and fully locks the field regardless of type.
Mask ghost text. While a date/time field is partially typed, an absolutely-positioned overlay Text (pointer-events disabled) renders the untyped remainder of the format string in muted color directly after the real input — e.g. typing 2024- shows 2024-MM-DD ghosted in behind the cursor — computed as a simple string slice (resolvedFormat.slice(internalValue.length)) rather than anything more elaborate, and only mounted at all when the field is typeable and partially filled.
Numeric clamping happens on blur, not on keystroke. min/max can't be enforced while typing, because an intermediate value (e.g. "1" while typing toward "15") is legitimately out of range mid-entry. handleBlur parses the current value, clamps it if numeric bounds are set, and pushes the clamped string back through both internalValue and onChangeText. minLength is handled the same way but only flags — hasError flips the container border to the theme's destructive color rather than blocking further typing, since blocking someone from typing fewer characters than a minimum isn't something you can actually do.
Automatic start/end elements, explicit props win. date/datetime auto-attach a calendar icon, time auto-attaches a clock icon, and password auto-attaches an eye/eye-off toggle (EyeOffIcon is the same eye glyph with a diagonal Line from react-native-svg drawn over it via StyleSheet.absoluteFill, rather than a second icon asset). Passing startElement/endElement explicitly overrides the automatic one outright rather than rendering both.
Two picker layouts, one shared body. The Calendar/TimeSelector picker content (pickerBody) is built once and rendered inside two different shells depending on platform: on web it's an absolutely-positioned popover anchored under (or, if it doesn't fit, above) the field — measured via containerRef.measureInWindow and flipped/clamped with the same logic DropdownMenuContent uses for its own anchoring — while native platforms get a centered, bottom-sheet-style Modal. Both shells host the exact same Calendar/TimeSelector/footer tree, so there's no behavioral drift between platforms, only positioning differs. A datetime type adds a Date/Time tab row above the picker body and tracks the active tab in ui.pickerStage.
Draft/commit separation for the picker. Taps inside the open Calendar or TimeSelector update a separate draftDate state, not the committed value — so Cancel can discard in-progress picks with no side effects. Only pressing Done (applyDraft) clamps the draft against minimumDate/maximumDate, commits it to internalDate, formats it into internalValue, and fires onDateChange/onChangeText.
<Input
type="datetime"
format="DD/MM/YYYY hh:mm A"
dateValue={appointment}
onDateChange={setAppointment}
minimumDate={new Date()}
typeable
selectable
/>
<Input
type="password"
placeholder="Password"
variant="filled"
/>
<Input
type="decimal"
placeholder="0.00"
min={0}
max={10000}
startElement={<CurrencyIcon />}
/>
Key props
| Prop | Type | Default | Description | |||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
type | InputType | "text" | text \ | text-number \ | number \ | decimal \ | password \ | email \ | phone \ | date \ | time \ | datetime. Drives keyboard, sanitization, and auto-attached icon |
variant | InputVariant | "outline" | outline (bordered) \ | filled \ | transparent (no border/background) | |||||||
startElement / endElement | React.ReactNode | — | Custom element before/after the text input; overrides the automatic type icon | |||||||||
startElementStyle / endElementStyle | StyleProp<ViewStyle> | — | Style for the side-element wrapper | |||||||||
containerStyle / mainStyle | StyleProp<ViewStyle> | — | Style for the bordered field row / the outermost wrapper | |||||||||
required | boolean | — | Marks the field required for accessibility tooling (aria-required, web only) | |||||||||
readOnly | boolean | — | Fully locks the field; takes precedence over typeable | |||||||||
typeable | boolean | true | Date/time only: whether the value can be typed by hand. false makes the field calendar-only — tapping anywhere opens the picker | |||||||||
selectable | boolean | true | Date/time only: whether tapping the field/icon opens the picker | |||||||||
format | string | type-specific | Tokens YYYY MM DD HH hh mm ss A; other characters are literal separators. Also determines 12h vs 24h time picker | |||||||||
minimumDate / maximumDate | Date | — | Clamps typed and picked date/time values | |||||||||
dateValue | Date | — | Controlled Date value backing date/time/datetime types | |||||||||
onDateChange | (date: Date) => void | — | Fires on commit — typed-to-completion, or picker Done | |||||||||
min / max | number | — | Number/decimal only: bounds enforced (clamped) on blur | |||||||||
minLength | number | — | Non-numeric, non-date types: flags an error border on blur if under length; doesn't block typing | |||||||||
maxLength | number | — | Native TextInput prop; also caps typed date/time mask length |
Label
A minimal, themeable text label — the small caption that typically sits above a form field.
How it works internally: Label is a React.memo'd wrapper around Text with no state or logic of its own. Its style comes from three layered sources resolved once per render inside a useMemo: useComponentConfig("label") (an app-wide theme override for fontSize/fontWeight/marginBottom), hard-coded fallbacks (12/"600"/6) if the theme doesn't specify them, and colors.mutedForeground for color — with the caller's own style prop spread last so it always wins. Because everything funnels through one useMemo keyed on [colors.mutedForeground, config, style], Label only recomputes its style object when the theme or the caller's style actually changes, not on every parent re-render.
<Label>Email address</Label>
<Input type="email" placeholder="[email protected]" />
Key props: Label accepts all standard TextProps (it's a direct Text wrapper) — style, children, numberOfLines, onPress, etc. There are no custom props beyond that; theming is done globally via ThemeProvider's config.components.label, not per-instance.