Input — Advanced Multi-Type Input for React NativesteemCreated with Sketch.

in Steem Dev19 hours ago

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

PropTypeDefaultDescription
typeInputType"text"text \text-number \number \decimal \password \email \phone \date \time \datetime. Drives keyboard, sanitization, and auto-attached icon
variantInputVariant"outline"outline (bordered) \filled \transparent (no border/background)
startElement / endElementReact.ReactNodeCustom element before/after the text input; overrides the automatic type icon
startElementStyle / endElementStyleStyleProp<ViewStyle>Style for the side-element wrapper
containerStyle / mainStyleStyleProp<ViewStyle>Style for the bordered field row / the outermost wrapper
requiredbooleanMarks the field required for accessibility tooling (aria-required, web only)
readOnlybooleanFully locks the field; takes precedence over typeable
typeablebooleantrueDate/time only: whether the value can be typed by hand. false makes the field calendar-only — tapping anywhere opens the picker
selectablebooleantrueDate/time only: whether tapping the field/icon opens the picker
formatstringtype-specificTokens YYYY MM DD HH hh mm ss A; other characters are literal separators. Also determines 12h vs 24h time picker
minimumDate / maximumDateDateClamps typed and picked date/time values
dateValueDateControlled Date value backing date/time/datetime types
onDateChange(date: Date) => voidFires on commit — typed-to-completion, or picker Done
min / maxnumberNumber/decimal only: bounds enforced (clamped) on blur
minLengthnumberNon-numeric, non-date types: flags an error border on blur if under length; doesn't block typing
maxLengthnumberNative 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.