one
Version:
One is a React framework for web, iOS, and Android apps.
289 lines (269 loc) • 7.81 kB
text/typescript
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)
},
}
}