UNPKG

one

Version:

One is a React framework for web, iOS, and Android apps.

289 lines (269 loc) • 7.81 kB
import { useEffect, useRef, useState } from 'react' import type { KeyboardTypeOptions, ReturnKeyTypeOptions, } from 'react-native' import type { KeyboardType, TextContentType } from '../../textTypes' import type { TextInputProps } from './textInputTypes' // shared mappings for the universal TextInput, mirroring Expo's utils plus // the platform keyboard/return tables from their iOS and Android inputs. export function resolveEditable( editable: boolean | undefined, readOnly: boolean | undefined ): boolean { if (readOnly) return false return editable ?? true } export function inputModeToKeyboardType( inputMode: TextInputProps['inputMode'] ): KeyboardTypeOptions | undefined { switch (inputMode) { case 'decimal': return 'decimal-pad' case 'email': return 'email-address' case 'none': return 'default' case 'numeric': return 'numeric' case 'search': return 'web-search' case 'tel': return 'phone-pad' case 'text': return 'default' case 'url': return 'url' default: return undefined } } export function enterKeyHintToReturnKeyType( enterKeyHint: TextInputProps['enterKeyHint'] ): ReturnKeyTypeOptions | undefined { switch (enterKeyHint) { case 'enter': return 'default' case 'done': return 'done' case 'go': return 'go' case 'next': return 'next' case 'previous': return 'previous' case 'search': return 'search' case 'send': return 'send' default: return undefined } } // web autocomplete tokens to the UIKit content types our spec carries (see // textTypes.ts). unmapped tokens apply nothing: the native converter fails // loudly on unknown values, so unlike Expo this never passes one through. const autoCompleteMap: Record<string, TextContentType> = { 'additional-name': 'middleName', 'address-line1': 'streetAddressLine1', 'address-line2': 'streetAddressLine2', bday: 'birthdate', 'bday-day': 'birthdateDay', 'bday-month': 'birthdateMonth', 'bday-year': 'birthdateYear', 'cc-additional-name': 'creditCardName', 'cc-csc': 'creditCardSecurityCode', 'cc-exp': 'creditCardExpiration', 'cc-exp-month': 'creditCardExpirationMonth', 'cc-exp-year': 'creditCardExpirationYear', 'cc-family-name': 'creditCardFamilyName', 'cc-given-name': 'creditCardGivenName', 'cc-name': 'creditCardName', 'cc-number': 'creditCardNumber', 'cc-type': 'creditCardType', country: 'countryName', 'country-name': 'countryName', 'current-password': 'password', email: 'emailAddress', 'family-name': 'familyName', 'given-name': 'givenName', honorific: 'namePrefix', 'honorific-prefix': 'namePrefix', 'honorific-suffix': 'nameSuffix', name: 'name', 'new-password': 'newPassword', nickname: 'nickname', off: 'none', 'one-time-code': 'oneTimeCode', organization: 'organizationName', 'organization-title': 'jobTitle', password: 'password', 'postal-code': 'postalCode', tel: 'telephoneNumber', 'tel-area-code': 'telephoneNumber', 'tel-country-code': 'telephoneNumber', 'tel-extension': 'telephoneNumber', 'tel-local': 'telephoneNumber', 'tel-local-prefix': 'telephoneNumber', 'tel-local-suffix': 'telephoneNumber', 'tel-national': 'telephoneNumber', url: 'URL', username: 'username', } export function autoCompleteToTextContentType( autoComplete: string | undefined ): TextContentType | undefined { if (!autoComplete) return undefined return autoCompleteMap[autoComplete] } // UIKit keyboard type names carried by the OneNativeTextField spec. export function iosKeyboardType(value: KeyboardTypeOptions): KeyboardType { switch (value) { case 'ascii-capable': return 'asciiCapable' case 'numbers-and-punctuation': return 'numbersAndPunctuation' case 'url': return 'url' case 'number-pad': return 'numberPad' case 'phone-pad': return 'phonePad' case 'name-phone-pad': return 'namePhonePad' case 'email-address': return 'emailAddress' case 'decimal-pad': return 'decimalPad' case 'twitter': return 'twitter' case 'web-search': return 'webSearch' case 'numeric': return 'decimalPad' default: return 'default' } } // SwiftUI submit label names carried by the OneNativeTextField spec. export type IosSubmitLabel = | 'done' | 'go' | 'send' | 'join' | 'route' | 'search' | 'return' | 'next' | 'continue' export function iosSubmitLabel(value: ReturnKeyTypeOptions): IosSubmitLabel { if (value === 'google' || value === 'yahoo') return 'search' if ( value === 'default' || value === 'none' || value === 'previous' || value === 'emergency-call' ) { return 'return' } return value as IosSubmitLabel } // keyboard type names carried by the Compose textfield node. export type AndroidKeyboardType = | 'default' | 'number' | 'decimal' | 'email' | 'password' | 'phone' | 'url' export function androidKeyboardType( value: KeyboardTypeOptions ): AndroidKeyboardType { switch (value) { case 'email-address': return 'email' case 'numeric': case 'decimal-pad': return 'decimal' case 'number-pad': return 'number' case 'phone-pad': return 'phone' case 'url': return 'url' default: return 'default' } } // IME action names carried by the Compose textfield node. export type AndroidImeAction = | 'default' | 'none' | 'go' | 'search' | 'send' | 'previous' | 'next' | 'done' export function androidImeAction(value: ReturnKeyTypeOptions): AndroidImeAction { if (value === 'google' || value === 'yahoo') return 'search' if (value === 'join' || value === 'route' || value === 'emergency-call') return 'default' return value as AndroidImeAction } // maxLength enforcement shared by platforms without a native clamp. a // non-positive or missing limit means unlimited. export function applyMaxLength(text: string, maxLength?: number): string { if (maxLength == null || maxLength <= 0) return text return text.length > maxLength ? text.slice(0, maxLength) : text } // focus state for the native fields. the controlled protocol never echoes a // requested value back as an event, so a focus or blur this side asks for // (ref.focus(), ref.blur(), autoFocus) reports its own transition here, and // native events report only the changes the user makes. each transition // reaches onFocus or onBlur exactly once. export function useTextInputFocus({ autoFocus, editable, onFocus, onBlur, }: { autoFocus: boolean | undefined editable: boolean onFocus: (() => void) | undefined onBlur: (() => void) | undefined }) { const [focused, setFocused] = useState(autoFocus ?? false) const [focusRevision, setFocusRevision] = useState(0) const isFocusedRef = useRef(false) const latest = useRef({ editable, onFocus, onBlur }) latest.current = { editable, onFocus, onBlur } const report = (next: boolean) => { if (isFocusedRef.current === next) return isFocusedRef.current = next if (next) latest.current.onFocus?.() else latest.current.onBlur?.() } const request = (next: boolean) => { // a disabled field cannot take focus, so asking for it changes nothing. if (next && !latest.current.editable) return setFocused(next) setFocusRevision((revision) => revision + 1) report(next) } useEffect(() => { if (autoFocus && latest.current.editable) report(true) }, []) return { focused, focusRevision, focus: () => request(true), blur: () => request(false), isFocused: () => isFocusedRef.current, handleFocusChange: (next: boolean) => { setFocused(next) report(next) }, } }