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 )} ); }