import type { ReactNode, Ref } from "react";
import {
Platform,
TextInput as RNTextInput,
type TextInputProps as RNTextInputProps,
} from "react-native";
import type { VariantProps } from "tailwind-variants";
import { tv } from "../../core/tv";
import { useColorVariable } from "../../core/useColorToken";
import { StableAccentScope } from "../containers/StableAccentScope";
import { View } from "../primitives/View";
// A slot is overlaid on the field, never laid out beside the input:
// react-native's TextInput is a leaf, and a frame carrying the border would
// need the focus state in JS (native has no `focus-within`). The slot box is
// as wide as the field is tall (`control-md`), so a 20px glyph lands 12px from
// the edge and a `sm` IconButton is centered inside the border, and the
// text is padded past it. On a multiline field the box pins to the top, level
// with the first line.
//
// A tap on the slot reaches the input unless a pressable in it takes it. On
// web that is `pointer-events: none` on the box, inherited by a glyph while a
// `PressableBox` re-enables itself (its `pointerEvents="auto"`); react-native-
// web's `box-none` would instead force `auto` back on every child. On native
// the box is `box-none` and the glyph is inert on its own (`Icon`).
const slotPointerEvents = Platform.OS === "web" ? undefined : "box-none";
const inputVariants = tv({
slots: {
input: [
"bg-highlight text-sharp",
"border",
"transition-[border-color,background-color,outline-color] duration-fast ease-in",
"outline-interactive-outlined-pressable", // to have proper outline color transition
process.env.EXPO_PUBLIC_STORYBOOK_ENABLED
? ""
: "border-interactive-outlined-pressable",
"hover:border-interactive-outlined-hover",
"focus:border-interactive-outlined-focus",
"focus:outline-1 focus:outline-interactive-outlined-focus focus:outline-offset-0",
"active:border-interactive-outlined-active",
"disabled:bg-disabled-interactive-muted disabled:border-interactive-outlined-disabled disabled:text-form-disabled-text disabled:cursor-not-allowed",
"placeholder:text-form-placeholder",
].join(" "),
frame: "relative",
startSlot:
"absolute left-0 w-control-md flex-center web:pointer-events-none",
endSlot:
"absolute right-0 w-control-md flex-center web:pointer-events-none",
},
variants: {
multiline: {
// Centering the text of a single-line field is per-platform. iOS
// centers the line itself, but only without a line-height —
// `text-base-size-only` is `text-base` minus the 1.4 line-height the
// scale pairs with it, which iOS would turn into leading above the
// glyphs (the value then sits ~3pt low while the placeholder, drawn
// without those attributes, stays centered). Android lays the text out
// from the top of the box — `min-h-control-md` makes it taller than the
// line — until `align-middle` sets its gravity (RN maps the style
// `verticalAlign` to `textAlignVertical`); on web that would be a real
// `vertical-align` on the ``, hence the platform scope. Web
// keeps the scale: an `` centers its text whatever the
// line-height is. Centered on every platform, the line needs no
// vertical padding, which a compact field has no room for.
false: {
input:
"web:text-base native:text-base-size-only android:align-middle min-h-control-md rounded-md py-0",
startSlot: "inset-y-0",
endSlot: "inset-y-0",
},
// Multiline is a paragraph: there the line-height is what spaces the
// lines, and the text belongs at the top of the box.
true: {
input: "text-base min-h-[80px] resize-y rounded-xs py-xs",
startSlot: "top-0 h-control-md",
endSlot: "top-0 h-control-md",
},
},
withStartSlot: {
true: { input: "pl-control-md" },
false: {},
},
withEndSlot: {
true: { input: "pr-control-md" },
false: {},
},
forceStyle: {
undefined: {
input: process.env.EXPO_PUBLIC_STORYBOOK_ENABLED
? "border-interactive-outlined-pressable"
: "",
},
hover: {
input: process.env.EXPO_PUBLIC_STORYBOOK_ENABLED
? "border-interactive-outlined-hover"
: "",
},
focus: {
input: process.env.EXPO_PUBLIC_STORYBOOK_ENABLED
? "border-interactive-outlined-focus outline-1 outline-interactive-outlined-focus outline-offset-0"
: "",
},
press: {
input: process.env.EXPO_PUBLIC_STORYBOOK_ENABLED
? "border-interactive-outlined-active"
: "",
},
},
},
// The horizontal padding is per side: the side with a slot takes the slot
// width instead, so neither value has to cancel the other.
compoundVariants: [
{ multiline: false, withStartSlot: false, class: { input: "pl-m" } },
{ multiline: false, withEndSlot: false, class: { input: "pr-m" } },
{ multiline: true, withStartSlot: false, class: { input: "pl-xs" } },
{ multiline: true, withEndSlot: false, class: { input: "pr-xs" } },
],
defaultVariants: {
multiline: false,
withStartSlot: false,
withEndSlot: false,
forceStyle: "undefined",
},
});
type InputVariantProps = Pick<
VariantProps,
"forceStyle" | "multiline"
>;
const MODE_PROPS = {
password: {
secureTextEntry: true,
autoComplete: "current-password",
},
number: {
inputMode: "numeric",
keyboardType: "numeric",
},
tel: {
inputMode: "tel",
autoComplete: "tel",
keyboardType: "phone-pad",
},
email: {
inputMode: "email",
autoComplete: "email",
keyboardType: "email-address",
},
url: {
inputMode: "url",
keyboardType: "url",
},
search: {
inputMode: "search",
},
webSearch: {
inputMode: "search",
keyboardType: "web-search",
},
} as const satisfies Record>;
export type InputTextMode = keyof typeof MODE_PROPS;
export interface InputTextProps
extends Omit, InputVariantProps {
/**
* On the input itself. With a `startSlot` / `endSlot`, on the frame around
* it instead: that frame is then the box the parent lays out, and the input
* stretches to it.
*/
className?: string;
disabled?: boolean;
/**
* Overlaid on the field's leading square, the text padded past it. Fixed-width
* content only: an `Icon` (which carries its own `text-*` ink) or a `sm`
* `IconButton`. A tap on it reaches the input unless the content takes it.
*/
startSlot?: ReactNode;
/** Same as `startSlot`, on the trailing square. */
endSlot?: ReactNode;
/**
* The field is in error: its border takes the danger accent (through the
* `interactive-outlined-*` tokens, so hover and focus stay on it) and it is
* `aria-invalid`. From `FormItem`'s render params, next to `describedBy`.
*/
invalid?: boolean;
mode?: InputTextMode;
ref?: Ref;
/**
* react-native's types have no `aria-describedby` / `aria-required`, but
* react-native-web forwards both and native ignores unknown props. Web only
* in effect: neither platform has an equivalent.
*/
"aria-describedby"?: string;
"aria-required"?: boolean;
}
export function InputText({
className,
disabled,
invalid,
mode,
multiline,
forceStyle,
startSlot,
endSlot,
...props
}: InputTextProps): ReactNode {
const placeholderColor =
Platform.OS === "web"
? undefined
: // oxlint-disable-next-line react/rules-of-hooks -- native only, web is set via css.
useColorVariable("--color-form-placeholder");
const modeProps = mode ? MODE_PROPS[mode] : undefined;
// Same forwarding as `aria-describedby`: untyped by react-native, rendered by
// react-native-web.
const ariaInvalidProps = { "aria-invalid": invalid === true };
const withStartSlot = startSlot !== undefined;
const withEndSlot = endSlot !== undefined;
const withSlot = withStartSlot || withEndSlot;
const styles = inputVariants({
multiline: multiline === true,
forceStyle,
withStartSlot,
withEndSlot,
});
const input = (
);
// Stable: `invalid` toggles while the field is being edited, and a plain
// AccentScope would remount the input (and drop its focus) with it.
return (
{withSlot ? (
{input}
{withStartSlot ? (
{startSlot}
) : null}
{withEndSlot ? (
{endSlot}
) : null}
) : (
input
)}
);
}