From 9e3db2be1b6e7f41a1677854fd16b7115e1bb0bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?nishan=20=28o=5E=E2=96=BD=5Eo=29?= Date: Tue, 12 May 2026 10:29:14 +0530 Subject: [PATCH 1/5] [docs][ui] Universal TextInput (#45571) # Why Adds documentation for the universal TextInput introduced in #45205. # How Adds a new textinput universal component docs and updates some examples in textfield docs. # Test Plan Verified rendered docs locally for unversioned/ and v56.0.0/. # Checklist - [ ] I added a `changelog.md` entry and rebuilt the package sources according to [this short guide](https://github.com/expo/expo/blob/main/CONTRIBUTING.md#-before-submitting) - [ ] This diff will work correctly for `npx expo prebuild` & EAS Build (eg: updated a module plugin). - [ ] Conforms with the [Documentation Writing Style Guide](https://github.com/expo/expo/blob/main/guides/Expo%20Documentation%20Writing%20Style%20Guide.md) --------- Co-authored-by: Aman Mittal Co-authored-by: Kudo Chien --- docs/components/plugins/api/APIStaticData.ts | 2 + .../sdk/ui/jetpack-compose/textfield.mdx | 32 +++-- .../sdk/ui/jetpack-compose/usenativestate.mdx | 40 +++--- .../unversioned/sdk/ui/swift-ui/textfield.mdx | 32 +++-- .../sdk/ui/swift-ui/usenativestate.mdx | 52 ++++--- .../sdk/ui/universal/textinput.mdx | 129 ++++++++++++++++++ .../sdk/ui/jetpack-compose/textfield.mdx | 32 +++-- .../sdk/ui/jetpack-compose/usenativestate.mdx | 40 +++--- .../v56.0.0/sdk/ui/swift-ui/textfield.mdx | 32 +++-- .../sdk/ui/swift-ui/usenativestate.mdx | 52 ++++--- .../v56.0.0/sdk/ui/universal/textinput.mdx | 129 ++++++++++++++++++ .../expo-ui/universal/textinput.json | 1 + .../v56.0.0/expo-ui/universal/textinput.json | 1 + .../build/universal/TextInput/index.d.ts | 1 + .../build/universal/TextInput/index.d.ts.map | 2 +- packages/expo-ui/build/universal/index.d.ts | 2 +- .../expo-ui/build/universal/index.d.ts.map | 2 +- .../expo-ui/src/universal/TextInput/index.tsx | 1 + packages/expo-ui/src/universal/index.ts | 2 +- tools/src/commands/GenerateDocsAPIData.ts | 1 + 20 files changed, 435 insertions(+), 150 deletions(-) create mode 100644 docs/pages/versions/unversioned/sdk/ui/universal/textinput.mdx create mode 100644 docs/pages/versions/v56.0.0/sdk/ui/universal/textinput.mdx create mode 100644 docs/public/static/data/unversioned/expo-ui/universal/textinput.json create mode 100644 docs/public/static/data/v56.0.0/expo-ui/universal/textinput.json diff --git a/docs/components/plugins/api/APIStaticData.ts b/docs/components/plugins/api/APIStaticData.ts index 3a742331af4385..96fe054453cb90 100644 --- a/docs/components/plugins/api/APIStaticData.ts +++ b/docs/components/plugins/api/APIStaticData.ts @@ -241,6 +241,7 @@ export const hardcodedTypeLinks: Record = { IterableIterator: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Iterator', KeepAwakeListener: '/versions/latest/sdk/keep-awake/#keepawakelistenerevent', + KeyboardTypeOptions: 'https://reactnative.dev/docs/textinput#keyboardtype', LocationCallback: '/versions/latest/sdk/location/#locationcallbacklocation', LocationErrorCallback: '/versions/latest/sdk/location/#locationerrorcallbackreason', LocationHeadingCallback: '/versions/latest/sdk/location/#locationheadingcallbacklocation', @@ -273,6 +274,7 @@ export const hardcodedTypeLinks: Record = { RefObject: 'https://react.dev/reference/react/useRef', RegExp: 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp', Required: 'https://www.typescriptlang.org/docs/handbook/utility-types.html#requiredtype', + ReturnKeyTypeOptions: 'https://reactnative.dev/docs/textinput#returnkeytype', Response: 'https://developer.mozilla.org/en-US/docs/Web/API/Response', RootParamList: 'https://reactnavigation.org/docs/typescript/#navigator-specific-types', RouteProp: 'https://reactnavigation.org/docs/glossary-of-terms/#route-object', diff --git a/docs/pages/versions/unversioned/sdk/ui/jetpack-compose/textfield.mdx b/docs/pages/versions/unversioned/sdk/ui/jetpack-compose/textfield.mdx index 42e834179ea073..8f38d10866dfbb 100644 --- a/docs/pages/versions/unversioned/sdk/ui/jetpack-compose/textfield.mdx +++ b/docs/pages/versions/unversioned/sdk/ui/jetpack-compose/textfield.mdx @@ -260,11 +260,28 @@ When `onValueChange` is marked with the `'worklet'` directive, it runs synchrono ```tsx WorkletPhoneMaskExample.tsx import { Host, TextField, Text, useNativeState } from '@expo/ui/jetpack-compose'; import { fillMaxWidth } from '@expo/ui/jetpack-compose/modifiers'; +import { useEffectEvent } from 'react'; export default function WorkletPhoneMaskExample() { const phone = useNativeState(''); const selection = useNativeState({ start: 0, end: 0 }); + const handleValueChange = useEffectEvent((v: string) => { + 'worklet'; + const digits = v.replace(/\D/g, '').slice(0, 10); + let formatted = digits; + if (digits.length > 6) { + formatted = `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`; + } else if (digits.length > 3) { + formatted = `(${digits.slice(0, 3)}) ${digits.slice(3)}`; + } + if (formatted !== v) { + phone.value = formatted; + // Snaps to end for demo. Real masks need smarter cursor handling. + selection.value = { start: formatted.length, end: formatted.length }; + } + }); + return ( { - 'worklet'; - const digits = v.replace(/\D/g, '').slice(0, 10); - let formatted = digits; - if (digits.length > 6) { - formatted = `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`; - } else if (digits.length > 3) { - formatted = `(${digits.slice(0, 3)}) ${digits.slice(3)}`; - } - if (formatted !== v) { - phone.value = formatted; - selection.value = { start: formatted.length, end: formatted.length }; - } - }}> + onValueChange={handleValueChange}> (555) 123-4567 diff --git a/docs/pages/versions/unversioned/sdk/ui/jetpack-compose/usenativestate.mdx b/docs/pages/versions/unversioned/sdk/ui/jetpack-compose/usenativestate.mdx index 70767a69360fc0..aa9a1217fa4a7c 100644 --- a/docs/pages/versions/unversioned/sdk/ui/jetpack-compose/usenativestate.mdx +++ b/docs/pages/versions/unversioned/sdk/ui/jetpack-compose/usenativestate.mdx @@ -24,11 +24,32 @@ The example below masks a phone number as the user types. The formatting and the ```tsx WorkletPhoneMaskExample.tsx import { Host, TextField, Text as ComposeText, useNativeState } from '@expo/ui/jetpack-compose'; import { fillMaxWidth } from '@expo/ui/jetpack-compose/modifiers'; +import { useEffectEvent } from 'react'; export default function WorkletPhoneMaskExample() { const maskedPhone = useNativeState(''); const selection = useNativeState({ start: 0, end: 0 }); + const handleValueChange = useEffectEvent((v: string) => { + 'worklet'; + const digits = v.replace(/\D/g, '').slice(0, 10); + let formatted: string; + if (digits.length === 0) { + formatted = ''; + } else if (digits.length <= 3) { + formatted = digits; + } else if (digits.length <= 6) { + formatted = `(${digits.slice(0, 3)}) ${digits.slice(3)}`; + } else { + formatted = `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`; + } + if (formatted !== v) { + maskedPhone.value = formatted; + // Snaps to end for demo. Real masks need smarter cursor handling. + selection.value = { start: formatted.length, end: formatted.length }; + } + }); + return ( { - 'worklet'; - const digits = v.replace(/\D/g, '').slice(0, 10); - let formatted: string; - if (digits.length === 0) { - formatted = ''; - } else if (digits.length <= 3) { - formatted = digits; - } else if (digits.length <= 6) { - formatted = `(${digits.slice(0, 3)}) ${digits.slice(3)}`; - } else { - formatted = `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`; - } - if (formatted !== v) { - maskedPhone.value = formatted; - selection.value = { start: formatted.length, end: formatted.length }; - } - }}> + onValueChange={handleValueChange}> (555) 123-4567 diff --git a/docs/pages/versions/unversioned/sdk/ui/swift-ui/textfield.mdx b/docs/pages/versions/unversioned/sdk/ui/swift-ui/textfield.mdx index 7dd6e92b4721fb..fadb32028a87da 100644 --- a/docs/pages/versions/unversioned/sdk/ui/swift-ui/textfield.mdx +++ b/docs/pages/versions/unversioned/sdk/ui/swift-ui/textfield.mdx @@ -184,11 +184,28 @@ When `onTextChange` is marked with the `'worklet'` directive, it runs synchronou ```tsx WorkletPhoneMaskExample.tsx import { Host, TextField, useNativeState } from '@expo/ui/swift-ui'; import { keyboardType } from '@expo/ui/swift-ui/modifiers'; +import { useEffectEvent } from 'react'; export default function WorkletPhoneMaskExample() { const phone = useNativeState(''); const selection = useNativeState({ start: 0, end: 0 }); + const handleTextChange = useEffectEvent((v: string) => { + 'worklet'; + const digits = v.replace(/\D/g, '').slice(0, 10); + let formatted = digits; + if (digits.length > 6) { + formatted = `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`; + } else if (digits.length > 3) { + formatted = `(${digits.slice(0, 3)}) ${digits.slice(3)}`; + } + if (formatted !== v) { + phone.value = formatted; + // Snaps to end for demo. Real masks need smarter cursor handling. + selection.value = { start: formatted.length, end: formatted.length }; + } + }); + return ( { - 'worklet'; - const digits = v.replace(/\D/g, '').slice(0, 10); - let formatted = digits; - if (digits.length > 6) { - formatted = `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`; - } else if (digits.length > 3) { - formatted = `(${digits.slice(0, 3)}) ${digits.slice(3)}`; - } - if (formatted !== v) { - phone.value = formatted; - selection.value = { start: formatted.length, end: formatted.length }; - } - }} + onTextChange={handleTextChange} /> ); diff --git a/docs/pages/versions/unversioned/sdk/ui/swift-ui/usenativestate.mdx b/docs/pages/versions/unversioned/sdk/ui/swift-ui/usenativestate.mdx index b3d13813f6f9bb..2f9fe889bb0ec8 100644 --- a/docs/pages/versions/unversioned/sdk/ui/swift-ui/usenativestate.mdx +++ b/docs/pages/versions/unversioned/sdk/ui/swift-ui/usenativestate.mdx @@ -19,47 +19,45 @@ import { APIInstallSection } from '~/components/plugins/InstallSection'; > **Note:** Using worklets requires installing [`react-native-reanimated`](https://docs.swmansion.com/react-native-reanimated/) and [`react-native-worklets`](https://docs.swmansion.com/react-native-worklets/) in your project. `useNativeState` itself works without them, but the synchronous UI-thread updates shown below depend on the worklet runtime. -The example below masks a phone number as the user types. The formatting and the write to `maskedPhone.value` both happen synchronously on the UI thread, so there is no flicker between the typed value and the masked value. +The example below masks a phone number as the user types. The formatting and the writes to `maskedPhone.value` (text) and `selection.value` (cursor position) all happen synchronously on the UI thread, so there is no flicker between the typed value and the masked value. ```tsx WorkletPhoneMaskExample.tsx -import { Host, TextField, TextFieldRef, useNativeState } from '@expo/ui/swift-ui'; +import { Host, TextField, useNativeState } from '@expo/ui/swift-ui'; import { keyboardType } from '@expo/ui/swift-ui/modifiers'; -import { useCallback, useRef } from 'react'; -import { runOnJS } from 'react-native-worklets'; +import { useEffectEvent } from 'react'; export default function WorkletPhoneMaskExample() { - const phoneRef = useRef(null); const maskedPhone = useNativeState(''); + const selection = useNativeState({ start: 0, end: 0 }); - const setPhoneCursor = useCallback((position: number) => { - phoneRef.current?.setSelection(position, position); - }, []); + const handleTextChange = useEffectEvent((v: string) => { + 'worklet'; + const digits = v.replace(/\D/g, '').slice(0, 10); + let formatted: string; + if (digits.length === 0) { + formatted = ''; + } else if (digits.length <= 3) { + formatted = digits; + } else if (digits.length <= 6) { + formatted = `(${digits.slice(0, 3)}) ${digits.slice(3)}`; + } else { + formatted = `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`; + } + if (formatted !== v) { + maskedPhone.value = formatted; + // Snaps to end for demo. Real masks need smarter cursor handling. + selection.value = { start: formatted.length, end: formatted.length }; + } + }); return ( { - 'worklet'; - const digits = v.replace(/\D/g, '').slice(0, 10); - let formatted: string; - if (digits.length === 0) { - formatted = ''; - } else if (digits.length <= 3) { - formatted = digits; - } else if (digits.length <= 6) { - formatted = `(${digits.slice(0, 3)}) ${digits.slice(3)}`; - } else { - formatted = `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`; - } - if (formatted !== v) { - maskedPhone.value = formatted; - runOnJS(setPhoneCursor)(formatted.length); - } - }} + onTextChange={handleTextChange} /> ); diff --git a/docs/pages/versions/unversioned/sdk/ui/universal/textinput.mdx b/docs/pages/versions/unversioned/sdk/ui/universal/textinput.mdx new file mode 100644 index 00000000000000..228f0d5334d8c7 --- /dev/null +++ b/docs/pages/versions/unversioned/sdk/ui/universal/textinput.mdx @@ -0,0 +1,129 @@ +--- +title: TextInput +description: A text input backed by native SwiftUI and Jetpack Compose components, with a React Native-compatible API. +sourceCodeUrl: 'https://github.com/expo/expo/tree/main/packages/expo-ui' +packageName: '@expo/ui' +platforms: ['android', 'ios', 'web', 'expo-go'] +--- + +import APISection from '~/components/plugins/APISection'; +import { APIInstallSection } from '~/components/plugins/InstallSection'; + +A text input that routes to [`TextField`](../jetpack-compose/textfield) from `@expo/ui/jetpack-compose` on Android, [`TextField`](../swift-ui/textfield) from `@expo/ui/swift-ui` on iOS, and React Native's [`TextInput`](https://reactnative.dev/docs/textinput) on web. + +The API mirrors React Native's [`TextInput`](https://reactnative.dev/docs/textinput), with two changes: [`value`](#value) and [`selection`](#selection) are observable state objects (created with `useNativeState`), and [`onChangeText`](#onchangetext) can be a worklet for synchronously updating the state on the UI thread. + +## Installation + + + +## Usage + +### Uncontrolled + +Omit [`value`](#value) and the field manages its own text internally. Use [`onChangeText`](#onchangetext) to observe edits, and use the [ref](#textinputref) for imperative actions like `focus`, `blur`, and `clear`. + +```tsx UncontrolledTextInputExample.tsx +import { Button, Column, Host, TextInput, type TextInputRef } from '@expo/ui'; +import { useRef } from 'react'; + +export default function UncontrolledTextInputExample() { + const inputRef = useRef(null); + + return ( + + + console.log(value)} + /> +