import React from "react"; import type { StyleProp, TextStyle, ViewStyle } from "react-native"; import { PixelRatio, Platform, Text as RNText, View } from "react-native"; import type { FieldError } from "react-hook-form"; import type { IconNames } from "@jobber/design"; import { useStyles } from "./InputFieldWrapper.style"; import { PrefixIcon, PrefixLabel } from "./components/Prefix/Prefix"; import { SuffixIcon, SuffixLabel } from "./components/Suffix/Suffix"; import { ClearAction } from "./components/ClearAction"; import { Glimmer } from "../Glimmer/Glimmer"; import { ErrorMessageWrapper } from "../ErrorMessageWrapper"; import type { TextVariation } from "../Typography"; import { useTypographyStyles } from "../Typography"; import { Text } from "../Text"; import { ActivityIndicator } from "../ActivityIndicator"; export interface InputFieldStyleOverride { prefixLabel?: StyleProp; suffixLabel?: StyleProp; container?: StyleProp; placeholderText?: StyleProp; } export interface InputFieldWrapperProps { /** * Highlights the field red and shows message below (if string) to indicate an error */ readonly invalid?: boolean | string; /** * Disable the input */ readonly disabled?: boolean; /** * Hint text that goes above the value once the field is filled out */ readonly placeholder?: string; /** * Text that goes below the input to help the user understand the input */ readonly assistiveText?: string; /** * Controls how the placeholder text is displayed. * - normal: the placeholder text will be displayed in the normal placeholder position * - mini: the placeholder text will float above the input value * - hidden: the placeholder text will not be displayed * @default "normal" */ readonly placeholderMode?: "normal" | "mini" | "hidden"; readonly hasValue?: boolean; /** * Symbol to display before the text input */ readonly prefix?: { icon?: IconNames; label?: string; }; /** * Symbol to display after the text input */ readonly suffix?: { icon?: IconNames; label?: string; onPress?: () => void; }; readonly error?: FieldError; readonly focused?: boolean; readonly children: React.ReactNode; /** * Adds the ClearAction that will call the onClear handler when pressed */ readonly showClearAction?: boolean; /** * Callback called when the user clicks the ClearAction button. Should clear the value passed. * To disallow clearing set the clearable prop to never */ readonly onClear?: () => void; /** * Custom styling to override default style of the input field */ readonly styleOverride?: InputFieldStyleOverride; /** * Add a toolbar below the input field for actions like rewriting the text. */ readonly toolbar?: React.ReactNode; /** * Change the behaviour of when the toolbar becomes visible. */ readonly toolbarVisibility?: "always" | "while-editing"; /** * Show loading indicator. */ readonly loading?: boolean; /** * Change the type of loading indicator to spinner or glimmer. */ readonly loadingType?: "spinner" | "glimmer"; /** * Whether the input is a multiline input. */ readonly multiline?: boolean; } export const INPUT_FIELD_WRAPPER_GLIMMERS_TEST_ID = "ATL-InputFieldWrapper-Glimmers"; export const INPUT_FIELD_WRAPPER_SPINNER_TEST_ID = "ATL-InputFieldWrapper-Spinner"; export function InputFieldWrapper({ invalid, disabled, placeholder, assistiveText, prefix, suffix, placeholderMode = "normal", hasValue = false, error, focused = false, children, onClear, showClearAction = false, styleOverride, toolbar, toolbarVisibility = "while-editing", loading = false, loadingType = "spinner", multiline = false, }: InputFieldWrapperProps) { fieldAffixRequiredPropsCheck([prefix, suffix]); const handleClear = onClear ?? noopClear; warnIfClearActionWithNoOnClear(onClear, showClearAction); const inputInvalid = Boolean(invalid) || Boolean(error); const isToolbarVisible = toolbar && (toolbarVisibility === "always" || focused); const showLoadingSpinner = loading && loadingType === "spinner"; const showLoadingGlimmer = loading && loadingType === "glimmer"; const styles = useStyles(); const placeholderVisible = placeholderMode !== "hidden"; const miniLabelActive = placeholderMode === "mini"; return ( {prefix?.icon && ( )} {placeholderVisible && ( )} {prefix?.label && hasValue && ( )} {children} {showLoadingGlimmer && ( )} {(showClearAction || suffix?.label || suffix?.icon || showLoadingSpinner) && ( {showClearAction && ( )} {suffix?.label && hasValue && ( )} {showLoadingSpinner && ( )} {suffix?.icon && ( )} )} {isToolbarVisible && {toolbar}} {assistiveText && !error && !invalid && ( {assistiveText} )} ); } function shouldApplyScrollTrapWorkaround(isMultiline: boolean): boolean { const isCustomFontScale = PixelRatio.getFontScale() !== 1; // On iOS, when the OS font scale is not default, it causes multiline inputs to become scroll-trapped // which prevents the user from scrolling the parent form view. For now, we're working around this // by limiting the width of the input field, thus providing a gap so the user can scroll more easily. return isMultiline && Platform.OS === "ios" && isCustomFontScale; } function getLabelVariation( error?: FieldError, invalid?: boolean | string, disabled?: boolean, ): TextVariation { if (invalid || error) { return "error"; } else if (disabled) { return "disabled"; } return "subdued"; } function fieldAffixRequiredPropsCheck( affixPair: [ InputFieldWrapperProps["prefix"], InputFieldWrapperProps["suffix"], ], ) { affixPair.map(affix => { if (typeof affix !== "undefined" && !affix.icon && !affix.label) { throw new Error( `One of 'label' or 'icon' is required by the field affix component.`, ); } }); } function warnIfClearActionWithNoOnClear( onClear?: () => void, showClearAction?: boolean, ): void { if (showClearAction && !onClear && __DEV__) { console.warn( "Declare an `onClear` prop on your input. You can set `clearable` to never or `showClearAction` to false if you don't need a clearable input", ); } } function noopClear() { warnIfClearActionWithNoOnClear(undefined, true); } function getMessage({ invalid, error, }: Pick): string | undefined { const messages: string[] = []; if (error?.message) messages.push(error.message); if (invalid && typeof invalid === "string") messages.push(invalid); return messages.join("\n"); } function Placeholder({ placeholder, styleOverride, labelVariation, miniLabelActive, }: { readonly placeholder?: string; readonly styleOverride: StyleProp; readonly labelVariation: TextVariation; readonly miniLabelActive: boolean; }) { const typographyStyles = useTypographyStyles(); return ( <> {!styleOverride ? ( {placeholder} ) : ( {placeholder} )} ); }