Files
bsky-social-app/src/components/forms/TextField.tsx
T
2026-07-23 18:25:40 +03:00

456 lines
10 KiB
TypeScript

import {createContext, useContext, useMemo, useRef} from 'react'
import {
type AccessibilityProps,
StyleSheet,
TextInput,
type TextInputProps,
type TextStyle,
View,
type ViewStyle,
} from 'react-native'
import {HITSLOP_20} from '#/lib/constants'
import {mergeRefs} from '#/lib/merge-refs'
import {
applyFonts,
atoms as a,
platform,
type TextStyleProp,
tokens,
useAlf,
useTheme,
web,
} from '#/alf'
import {useInteractionState} from '#/components/hooks/useInteractionState'
import {type Props as SVGIconProps} from '#/components/icons/common'
import {Text} from '#/components/Typography'
const Context = createContext<{
inputRef: React.RefObject<TextInput | null> | null
isInvalid: boolean
hovered: boolean
onHoverIn: () => void
onHoverOut: () => void
focused: boolean
onFocus: () => void
onBlur: () => void
}>({
inputRef: null,
isInvalid: false,
hovered: false,
onHoverIn: () => {},
onHoverOut: () => {},
focused: false,
onFocus: () => {},
onBlur: () => {},
})
Context.displayName = 'TextFieldContext'
export function useTextFieldContext() {
return useContext(Context)
}
export type RootProps = React.PropsWithChildren<
{isInvalid?: boolean} & TextStyleProp
>
export function Root({children, isInvalid = false, style}: RootProps) {
const inputRef = useRef<TextInput>(null)
const {
state: hovered,
onIn: onHoverIn,
onOut: onHoverOut,
} = useInteractionState()
const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
const context = useMemo(
() => ({
inputRef,
hovered,
onHoverIn,
onHoverOut,
focused,
onFocus,
onBlur,
isInvalid,
}),
[
inputRef,
hovered,
onHoverIn,
onHoverOut,
focused,
onFocus,
onBlur,
isInvalid,
],
)
return (
<Context.Provider value={context}>
<View
style={[
a.flex_row,
a.align_center,
a.relative,
a.w_full,
a.px_md,
style,
]}
{...web({
onClick: () => inputRef.current?.focus(),
onMouseOver: onHoverIn,
onMouseOut: onHoverOut,
})}>
{children}
</View>
</Context.Provider>
)
}
export function useSharedInputStyles() {
const t = useTheme()
return useMemo(() => {
const hover: ViewStyle[] = [
{
borderColor: t.palette.contrast_100,
},
]
const focus: ViewStyle[] = [
{
backgroundColor: t.palette.primary_25,
borderColor: t.palette.primary_500,
},
]
const error: ViewStyle[] = [
{
backgroundColor: t.palette.negative_25,
borderColor: t.palette.negative_300,
},
]
const errorHover: ViewStyle[] = [
{
backgroundColor: t.palette.negative_25,
borderColor: t.palette.negative_500,
},
]
return {
chromeHover: StyleSheet.flatten(hover),
chromeFocus: StyleSheet.flatten(focus),
chromeError: StyleSheet.flatten(error),
chromeErrorHover: StyleSheet.flatten(errorHover),
}
}, [t])
}
export type InputProps = Omit<
TextInputProps,
'value' | 'onChangeText' | 'placeholder'
> & {
label: string
/**
* @deprecated Controlled inputs are *strongly* discouraged. Use `defaultValue` instead where possible.
*
* See https://github.com/facebook/react-native-website/pull/4247
*
* Note: This guidance no longer applies once we migrate to the New Architecture!
*/
value?: string
onChangeText?: (value: string) => void
isInvalid?: boolean
inputRef?: React.RefObject<TextInput | null> | React.ForwardedRef<TextInput>
/**
* Note: this currently falls back to the label if not specified. However,
* most new designs have no placeholder. We should eventually remove this fallback
* behaviour, but for now just pass `null` if you want no placeholder -sfn
*/
placeholder?: string | null | undefined
}
export function createInput(Component: typeof TextInput) {
return function Input({
label,
placeholder,
value,
onChangeText,
onFocus,
onBlur,
isInvalid,
inputRef,
style,
...rest
}: InputProps) {
const t = useTheme()
const {fonts} = useAlf()
const ctx = useContext(Context)
const withinRoot = Boolean(ctx.inputRef)
const {chromeHover, chromeFocus, chromeError, chromeErrorHover} =
useSharedInputStyles()
if (!withinRoot) {
return (
<Root isInvalid={isInvalid}>
<Input
label={label}
placeholder={placeholder}
value={value}
onChangeText={onChangeText}
isInvalid={isInvalid}
{...rest}
/>
</Root>
)
}
const refs = mergeRefs([ctx.inputRef, inputRef!].filter(Boolean))
const flattened = StyleSheet.flatten<TextStyle>([
a.relative,
a.z_20,
a.flex_1,
a.text_md,
t.atoms.text,
a.px_xs,
{
// paddingVertical doesn't work w/multiline - esb
lineHeight: a.text_md.fontSize * 1.2,
textAlignVertical: rest.multiline ? 'top' : undefined,
minHeight: rest.multiline ? 80 : undefined,
minWidth: 0,
paddingTop: 13,
paddingBottom: 13,
},
/*
* Margins are needed here to avoid autofill background overlapping the
* top and bottom borders - esb
*/
web({
paddingTop: 11,
paddingBottom: 11,
marginTop: 2,
marginBottom: 2,
}),
style,
])
applyFonts(flattened, fonts.family)
// should always be defined on `typography`
// @ts-ignore
if (flattened.fontSize) {
// @ts-ignore
flattened.fontSize = Math.round(
// @ts-ignore
flattened.fontSize * fonts.scaleMultiplier,
)
}
return (
<>
<Component
accessibilityHint={undefined}
hitSlop={HITSLOP_20}
{...rest}
accessibilityLabel={label}
ref={refs}
value={value}
onChangeText={onChangeText}
onFocus={e => {
ctx.onFocus()
onFocus?.(e)
}}
onBlur={e => {
ctx.onBlur()
onBlur?.(e)
}}
placeholder={placeholder === null ? undefined : placeholder || label}
placeholderTextColor={t.palette.contrast_500}
keyboardAppearance={t.name === 'light' ? 'light' : 'dark'}
style={flattened}
/>
<View
style={[
a.z_10,
a.absolute,
a.inset_0,
{borderRadius: 10},
t.atoms.bg_contrast_50,
{borderColor: 'transparent', borderWidth: 1},
ctx.hovered ? chromeHover : {},
ctx.focused ? chromeFocus : {},
ctx.isInvalid || isInvalid ? chromeError : {},
(ctx.isInvalid || isInvalid) && (ctx.hovered || ctx.focused)
? chromeErrorHover
: {},
]}
/>
</>
)
}
}
export const Input = createInput(TextInput)
export function LabelText({
nativeID,
children,
}: React.PropsWithChildren<{nativeID?: string}>) {
const t = useTheme()
return (
<Text
nativeID={nativeID}
style={[a.text_sm, a.font_medium, t.atoms.text_contrast_medium, a.mb_sm]}>
{children}
</Text>
)
}
export function Icon({icon: Comp}: {icon: React.ComponentType<SVGIconProps>}) {
const t = useTheme()
const ctx = useContext(Context)
const {hover, focus, errorHover, errorFocus} = useMemo(() => {
const hover: TextStyle[] = [
{
color: t.palette.contrast_800,
},
]
const focus: TextStyle[] = [
{
color: t.palette.primary_500,
},
]
const errorHover: TextStyle[] = [
{
color: t.palette.negative_500,
},
]
const errorFocus: TextStyle[] = [
{
color: t.palette.negative_500,
},
]
return {
hover,
focus,
errorHover,
errorFocus,
}
}, [t])
return (
<View style={[a.z_20, a.pr_xs]}>
<Comp
size="md"
style={[
{color: t.palette.contrast_500, pointerEvents: 'none', flexShrink: 0},
ctx.hovered ? hover : {},
ctx.focused ? focus : {},
ctx.isInvalid && ctx.hovered ? errorHover : {},
ctx.isInvalid && ctx.focused ? errorFocus : {},
]}
/>
</View>
)
}
export function SuffixText({
children,
label,
accessibilityHint,
style,
}: React.PropsWithChildren<
TextStyleProp & {
label: string
accessibilityHint?: AccessibilityProps['accessibilityHint']
}
>) {
const t = useTheme()
const ctx = useContext(Context)
return (
<Text
accessibilityLabel={label}
accessibilityHint={accessibilityHint}
numberOfLines={1}
style={[
a.z_20,
a.pr_sm,
a.text_md,
t.atoms.text_contrast_medium,
a.pointer_events_none,
web([{marginTop: -2}, a.leading_snug]),
(ctx.hovered || ctx.focused) && {color: t.palette.contrast_800},
style,
]}>
{children}
</Text>
)
}
export function GhostText({
children,
value,
}: {
children: string
value: string
}) {
const t = useTheme()
// eslint-disable-next-line bsky-internal/avoid-unwrapped-text
return (
<View
style={[
a.pointer_events_none,
a.absolute,
a.z_10,
{
paddingLeft: platform({
native:
// input padding
tokens.space.md +
// icon
tokens.space.xl +
// icon padding
tokens.space.xs +
// text input padding
tokens.space.xs,
web:
// icon
tokens.space.xl +
// icon padding
tokens.space.xs +
// text input padding
tokens.space.xs,
}),
},
web(a.pr_md),
a.overflow_hidden,
a.max_w_full,
]}
aria-hidden={true}
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants">
<Text
style={[
{color: 'transparent'},
a.text_md,
{lineHeight: a.text_md.fontSize * 1.1875},
a.w_full,
]}
numberOfLines={1}>
{children}
<Text
style={[
t.atoms.text_contrast_low,
a.text_md,
{lineHeight: a.text_md.fontSize * 1.1875},
]}>
{value}
</Text>
</Text>
</View>
)
}