/**
* Input — a text field with a label, a description and an error line.
*
* The field's focus state is animated rather than switched. A border that
* snaps between two colours reads as a redraw; one that crosses over in
* 150ms reads as the field responding to you. The interpolation runs on the
* UI thread, so it costs nothing and keeps up with a fast tab through a form.
*
* Two backgrounds, because a field's job changes with what surrounds it:
* `outline` sits on the page and draws its own edge; `filled` sits inside a
* card or a sheet, where a second border next to the container's own reads as
* a seam.
*
* ```tsx
*
*
* } />
* ```
*
* ## An icon inside the field, and why it lives here
*
* `startContent` and `endContent` are measured and turned into padding on the
* text, so a value never runs underneath them however wide they turn out to
* be. They are positioned against the *field box* rather than against the
* component, which is the whole reason they are a prop rather than something
* you compose: a label and a description are laid out above and below the
* field, so anything centred on the component as a whole drifts upward the
* moment a label is added.
*
* Both are pressable. A button dropped in there — a clear ✕, a show-password
* eye — gets its own touches, while the padding around it stays transparent so
* a tap that misses still lands on the field and puts the caret in it. An icon
* that is only decoration should say so with `interactiveContent={false}`,
* which gives the whole field back to the caret and takes the icon out of the
* accessibility tree.
*
* `InputGroup` is still the right answer for a decorator that is not part of
* the field — a button attached to its end, a select bolted to its start, an
* addon with its own background. It measures the same way; it just spans a
* different box.
*/
import { forwardRef, useCallback, useEffect, useState, type ReactNode } from 'react';
import {
TextInput,
View,
type LayoutChangeEvent,
type TextInputProps,
} from 'react-native';
import Animated, {
interpolateColor,
useAnimatedStyle,
useSharedValue,
withTiming,
} from 'react-native-reanimated';
import { tv, type VariantProps } from 'tailwind-variants';
import { useCSSVariable } from 'uniwind';
import type { KeyboardAvoidanceMode } from '../../hooks/use-keyboard-avoidance';
import { IconColorProvider } from '../../icons';
import { KeyboardAvoider } from '../../primitives/keyboard-avoider';
import { Text, textChildren } from '../../primitives/text';
import { Label } from '../label';
import { inputContentPadding } from './input-content-padding';
/** Long enough to read as a transition, short enough not to lag a fast tab. */
const FOCUS_DURATION = 150;
const inputVariants = tv({
slots: {
container: 'w-full gap-1.5',
/*
* `font-normal` on a field that is not bold: this is a TextInput, so it
* does not go through the Text primitive and inherits nothing. The class
* is what an app's `--font-normal` token attaches to, and without it a
* custom font would stop at the edge of every field in the library.
*/
field: 'w-full rounded-lg border font-normal text-foreground',
description: 'text-sm text-muted-foreground',
error: 'text-sm text-destructive',
/*
* The box the content is positioned against, and the reason this works
* where wrapping the whole component does not: it is the field alone, so
* a label above it and a description below it cannot move what is inside.
*/
fieldBox: 'relative w-full',
startContent:
'absolute bottom-0 start-0 top-0 z-10 flex-row items-center justify-center gap-2',
endContent:
'absolute bottom-0 end-0 top-0 z-10 flex-row items-center justify-center gap-2',
},
variants: {
variant: {
// The border colour is animated, so it is deliberately not set here —
// only the background and the resting border width belong to the class.
outline: { field: 'bg-background' },
filled: { field: 'bg-muted' },
},
size: {
/*
* The font size is given as a length rather than through a `text-*`
* step, because those set a size and a line height together — 16px text
* in a 24px line box. The extra leading lands above the glyphs, so in a
* field of fixed height the text and the placeholder end up sitting
* below the middle of the box, a few pixels off whatever is beside them.
* A length sets the size alone and leaves the line box the font's own.
*/
// The content boxes take the field's own horizontal padding, so an icon
// sits at exactly the inset the text would have started at.
sm: { field: 'h-10 px-3 text-[14px]', startContent: 'px-3', endContent: 'px-3' },
md: { field: 'h-12 px-3.5 text-[16px]', startContent: 'px-3.5', endContent: 'px-3.5' },
lg: { field: 'h-14 px-4 text-[16px]', startContent: 'px-4', endContent: 'px-4' },
},
multiline: {
// A multiline field grows, so a fixed height would crop it. Text starts
// at the top rather than floating in the middle of an empty box — and
// once there are several lines the leading is wanted back, since here it
// separates the lines instead of pushing one off centre.
//
// The content follows the text to the top for the same reason: centred
// in a box that grows, an icon slides further from the line it belongs
// to with every line typed.
true: {
field: 'h-auto min-h-24 py-3 leading-normal',
startContent: 'bottom-auto py-3',
endContent: 'bottom-auto py-3',
},
},
disabled: {
true: {
field: 'opacity-[0.64]',
startContent: 'opacity-[0.64]',
endContent: 'opacity-[0.64]',
},
},
},
defaultVariants: {
variant: 'outline',
size: 'md',
},
});
type InputVariantProps = VariantProps;
export interface InputProps
extends TextInputProps,
Omit {
className?: string;
containerClassName?: string;
label?: string;
description?: string;
/** Error message. When set, the field renders in its invalid state. */
errorMessage?: string;
/** Marks the field required — an asterisk on the label, and the a11y state. */
isRequired?: boolean;
disabled?: boolean;
/**
* Content inside the field, before the text — usually an icon. Measured, so
* the text is padded clear of it however wide it is.
*
* `start`, not `left`: it follows the reading direction, and swaps sides
* under `Direction dir="rtl"` along with the text it introduces.
*/
startContent?: ReactNode;
/** Content inside the field, after the text — an icon, a unit, a count. */
endContent?: ReactNode;
/**
* Whether touches reach the content.
*
* On by default, so a button placed in the field — a clear ✕, a
* show-password eye, a unit picker — is pressable without anything else
* being passed. Only the content itself takes those touches: the padding
* around it is transparent, so a tap that misses the button still lands on
* the field and puts the caret in it.
*
* Turn it off for pure decoration, where the icon should not be a target at
* all and every pixel of the field should focus it. That also drops the
* content from the accessibility tree, which is right for an icon that only
* restates the label.
*/
interactiveContent?: boolean;
/**
* Keep the field clear of the software keyboard. Moves by exactly the
* overlap, and not at all when the field is already clear — or when the
* keyboard belongs to a different field. The overlap is re-read every frame
* while the field is focused, so the field keeps its place in the page as it
* scrolls under and back out of the keyboard.
*
* Install `react-native-keyboard-controller` for this to behave on Android.
*
* Do not toggle this at runtime — it changes which component renders the
* container, which would remount the field and drop focus.
*/
avoidKeyboard?: boolean;
/**
* How the field gets clear. `lift` moves it up by its overlap and follows
* the scroll — right for a field in the flow of a page. `dock` makes it
* travel with the keyboard, for a composer already pinned near the bottom
* edge; pair it with `keyboardBottomInset`.
*/
keyboardMode?: KeyboardAvoidanceMode;
/** Gap kept between the field and the keyboard. `keyboardMode="lift"` only. */
keyboardOffset?: number;
/**
* How far above the bottom edge the field already sits — usually the safe
* area inset. `keyboardMode="dock"` only.
*/
keyboardBottomInset?: number;
}
const AnimatedTextInput = Animated.createAnimatedComponent(TextInput);
export const Input = forwardRef(
(
{
className,
containerClassName,
label,
description,
errorMessage,
isRequired,
disabled,
startContent,
endContent,
interactiveContent = true,
variant,
size,
avoidKeyboard = false,
keyboardMode = 'lift',
keyboardOffset = 16,
keyboardBottomInset = 0,
onFocus,
onBlur,
style,
...props
},
ref
) => {
const [focused, setFocused] = useState(false);
const invalid = !!errorMessage;
// Measured so the text can be padded clear of the content, whatever it
// turns out to be — an icon, a unit, a two-word label.
const [startWidth, setStartWidth] = useState(0);
const [endWidth, setEndWidth] = useState(0);
const placeholderColor = useCSSVariable('--color-muted-foreground');
const restColor = useCSSVariable('--color-input');
const focusColor = useCSSVariable('--color-ring');
const errorColor = useCSSVariable('--color-destructive');
const slots = inputVariants({
variant,
size,
multiline: !!props.multiline,
disabled: !!disabled,
});
/*
* Border colour is driven by one 0..1 value rather than by a class per
* state. Uniwind can only swap a class wholesale, which is the snap this
* is here to avoid, and a shared value crosses between the two colours on
* the UI thread without a re-render.
*/
const focus = useSharedValue(0);
useEffect(() => {
focus.value = withTiming(focused ? 1 : 0, { duration: FOCUS_DURATION });
}, [focused, focus]);
const resting = typeof restColor === 'string' ? restColor : '#e5e5e5';
const active = invalid
? typeof errorColor === 'string'
? errorColor
: '#ef4444'
: typeof focusColor === 'string'
? focusColor
: '#a3a3a3';
// An invalid field is tinted even at rest — the error is a fact about the
// value, not about whether the field happens to be focused.
const idle = invalid ? active : resting;
const borderStyle = useAnimatedStyle(() => ({
borderColor: interpolateColor(focus.value, [0, 1], [idle, active]),
}));
const handleFocus = useCallback>(
(event) => {
setFocused(true);
onFocus?.(event);
},
[onFocus]
);
const handleBlur = useCallback>(
(event) => {
setFocused(false);
onBlur?.(event);
},
[onBlur]
);
const handleStartLayout = useCallback((event: LayoutChangeEvent) => {
setStartWidth(event.nativeEvent.layout.width);
}, []);
const handleEndLayout = useCallback((event: LayoutChangeEvent) => {
setEndWidth(event.nativeEvent.layout.width);
}, []);
const startPadding = inputContentPadding(startWidth, !!startContent);
const endPadding = inputContentPadding(endWidth, !!endContent);
/*
* The content is quieter than the value. An icon at the same weight as the
* text reads as part of what was typed, and the placeholder colour is
* already the field's own word for "not the value".
*/
const iconColor = typeof placeholderColor === 'string' ? placeholderColor : undefined;
const field = (
);
const body = (
<>
{label ? (
) : null}
{startContent || endContent ? (
/*
* Only wrapped when there is something to position. An extra view
* around every field in an app is a real cost in a long form, and
* the field is `w-full` either way so the wrapper changes nothing
* else about the layout.
*/
{startContent ? (
{textChildren(startContent)}
) : null}
{field}
{endContent ? (
{textChildren(endContent)}
) : null}
) : (
field
)}
{errorMessage ? (
{errorMessage}
) : description ? (
{description}
) : null}
>
);
const containerClasses = slots.container({ className: containerClassName });
/*
* The keyboard hook is deliberately behind a component boundary rather
* than an `enabled` flag. Calling it at all has global consequences —
* without the keyboard controller installed it falls back to Reanimated's
* useAnimatedKeyboard, which switches Android out of adjustResize for the
* whole app. A field that never asked to avoid the keyboard must not do
* that to every other screen.
*/
if (avoidKeyboard) {
return (
{body}
);
}
return {body};
}
);
Input.displayName = 'Input';