@@ -226,9 +220,8 @@ function RankedAutocompleteDemo() {
activeIndex={activeIndex}
items={items}
listId={listId}
- onActiveIndexChange={setActiveIndex}
- onPick={(item) => updateValue(item.text)}
- pending={pending}
+ onActiveIndexChange={field.setHighlightedIndex}
+ onPick={(item) => field.selectCompletion(items.indexOf(item))}
/>
diff --git a/apps/site/src/lib/code-snippets.ts b/apps/site/src/lib/code-snippets.ts
index 7b52825..b546c99 100644
--- a/apps/site/src/lib/code-snippets.ts
+++ b/apps/site/src/lib/code-snippets.ts
@@ -12,6 +12,7 @@ const range = parseRange("between 5 and 10 kg", { kind: "mass" })`
export const completionsSnippet = `import { completions } from "@pascal-app/lingo/complete"
import { parseDate, parseDateRange } from "@pascal-app/lingo/date"
import { lingoInput } from "@pascal-app/lingo/dom"
+import { useLingoInput } from "@pascal-app/lingo/react"
// Debounce in your UI (~120–150ms) — completions() re-parses on every call
const items = completions("10 kg to 16", { kind: "mass", limit: 8 })
@@ -33,7 +34,17 @@ lingoInput(input, {
kind: "mass",
complete: (text) => completions(text, { kind: "mass", limit: 8 }),
onComplete: (list) => renderGroupedDropdown(list),
-})`
+})
+
+// React exposes the same injected list plus headless highlight/selection state
+const field = useLingoInput({
+ kind: "mass",
+ listboxId: "mass-options",
+ complete: (text) => completions(text, { kind: "mass", limit: 8 }),
+})
+field.completions
+field.setHighlightedIndex(1)
+field.selectCompletion()`
export const strictnessSnippet = `import { lingo } from "@pascal-app/lingo"
@@ -71,6 +82,25 @@ function HeightField() {
return
}`
+export const reactNativeSnippet = `import { Text, TextInput, View } from "react-native"
+import { useLingoTextInput } from "@pascal-app/lingo/react-native"
+
+function WeightField() {
+ const field = useLingoTextInput({
+ kind: "mass",
+ unit: "kg",
+ min: 0,
+ max: "500 kg",
+ })
+
+ return (
+
+
+ {field.errorMessage ? {field.errorMessage} : null}
+
+ )
+}`
+
export const findSnippet = `import { findQuantities } from "@pascal-app/lingo"
const found = findQuantities("ship 2 boxes at 5 kg each by friday")
diff --git a/apps/site/src/lib/docs-catalog.ts b/apps/site/src/lib/docs-catalog.ts
index 9a3b426..f97136e 100644
--- a/apps/site/src/lib/docs-catalog.ts
+++ b/apps/site/src/lib/docs-catalog.ts
@@ -111,6 +111,8 @@ export const docsNavGroups: DocsNavGroup[] = [
page('forms', 'Inputs', 'Turn any input into a natural-language field.', [
'dom',
'react',
+ 'react native',
+ 'textinput',
'input',
'field',
'validation',
@@ -127,6 +129,13 @@ export const docsNavGroups: DocsNavGroup[] = [
['lingo-input', 'web component', 'element', 'defineLingoInput', 'vue', 'svelte', 'angular'],
{ depth: 3, markdownSectionId: 'forms' },
),
+ page(
+ 'forms-react-native',
+ 'React Native',
+ 'DOM-free parsing and canonicalization for TextInput.',
+ ['react native', 'textinput', 'mobile', 'useLingoTextInput'],
+ { depth: 3, markdownSectionId: 'forms' },
+ ),
page(
'forms-range-slider',
'Two-way slider',
diff --git a/apps/site/src/lib/docs.md.ts b/apps/site/src/lib/docs.md.ts
index 7491ff9..f26bba8 100644
--- a/apps/site/src/lib/docs.md.ts
+++ b/apps/site/src/lib/docs.md.ts
@@ -9,6 +9,7 @@ import {
formSnippet,
integrationSnippets,
parseSnippet,
+ reactNativeSnippet,
reactSnippet,
schemaTabs,
strictnessSnippet,
@@ -128,6 +129,12 @@ export const docsMarkdown = [
'',
fenced('tsx', reactSnippet),
'',
+ '### React Native',
+ '',
+ '`useLingoTextInput()` is a DOM-free hook for React Native `TextInput`. It returns controlled text handlers, canonical value and submit state, required/bounds issues, hints, and injected completion state without importing `react-native`.',
+ '',
+ fenced('tsx', reactNativeSnippet),
+ '',
'Caption: Fields never rewrite while typing; commits canonicalize once.',
'',
'Form demo rules:',
diff --git a/bun.lock b/bun.lock
index f06ab28..3bed736 100644
--- a/bun.lock
+++ b/bun.lock
@@ -37,6 +37,7 @@
"@typescript/native-preview": "7.0.0-dev.20260704.1",
"eslint": "^9",
"eslint-config-next": "16.2.10",
+ "jsdom": "^29.1.1",
"react-doctor": "^0.6.2",
"react-grab": "^0.1.48",
"react-scan": "^0.5.7",
@@ -46,7 +47,7 @@
},
"packages/lingo": {
"name": "@pascal-app/lingo",
- "version": "0.2.0",
+ "version": "0.2.1",
"devDependencies": {
"@standard-schema/spec": "^1.1.0",
"@types/react": "^19.2.17",
@@ -757,19 +758,19 @@
"@vercel/analytics": ["@vercel/analytics@2.0.1", "", { "peerDependencies": { "@remix-run/react": "^2", "@sveltejs/kit": "^1 || ^2", "next": ">= 13", "nuxt": ">= 3", "react": "^18 || ^19 || ^19.0.0-rc", "svelte": ">= 4", "vue": "^3", "vue-router": "^4" }, "optionalPeers": ["@remix-run/react", "@sveltejs/kit", "next", "nuxt", "react", "svelte", "vue", "vue-router"] }, "sha512-MTQG6V9qQrt1tsDeF+2Uoo5aPjqbVPys1xvnIftXSJYG2SrwXRHnqEvVoYID7BTruDz4lCd2Z7rM1BdkUehk2g=="],
- "@vitest/expect": ["@vitest/expect@4.1.9", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA=="],
+ "@vitest/expect": ["@vitest/expect@4.1.10", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.10", "@vitest/utils": "4.1.10", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA=="],
- "@vitest/mocker": ["@vitest/mocker@4.1.9", "", { "dependencies": { "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw=="],
+ "@vitest/mocker": ["@vitest/mocker@4.1.10", "", { "dependencies": { "@vitest/spy": "4.1.10", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow=="],
- "@vitest/pretty-format": ["@vitest/pretty-format@4.1.9", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A=="],
+ "@vitest/pretty-format": ["@vitest/pretty-format@4.1.10", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q=="],
- "@vitest/runner": ["@vitest/runner@4.1.9", "", { "dependencies": { "@vitest/utils": "4.1.9", "pathe": "^2.0.3" } }, "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg=="],
+ "@vitest/runner": ["@vitest/runner@4.1.10", "", { "dependencies": { "@vitest/utils": "4.1.10", "pathe": "^2.0.3" } }, "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg=="],
- "@vitest/snapshot": ["@vitest/snapshot@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA=="],
+ "@vitest/snapshot": ["@vitest/snapshot@4.1.10", "", { "dependencies": { "@vitest/pretty-format": "4.1.10", "@vitest/utils": "4.1.10", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw=="],
- "@vitest/spy": ["@vitest/spy@4.1.9", "", {}, "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA=="],
+ "@vitest/spy": ["@vitest/spy@4.1.10", "", {}, "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw=="],
- "@vitest/utils": ["@vitest/utils@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA=="],
+ "@vitest/utils": ["@vitest/utils@4.1.10", "", { "dependencies": { "@vitest/pretty-format": "4.1.10", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA=="],
"acorn": ["acorn@8.17.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg=="],
@@ -1675,7 +1676,7 @@
"vite": ["vite@8.1.3", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.16", "rolldown": "~1.1.3", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.3.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA=="],
- "vitest": ["vitest@4.1.9", "", { "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/browser-webdriverio": "4.1.9", "@vitest/coverage-istanbul": "4.1.9", "@vitest/coverage-v8": "4.1.9", "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ=="],
+ "vitest": ["vitest@4.1.10", "", { "dependencies": { "@vitest/expect": "4.1.10", "@vitest/mocker": "4.1.10", "@vitest/pretty-format": "4.1.10", "@vitest/runner": "4.1.10", "@vitest/snapshot": "4.1.10", "@vitest/spy": "4.1.10", "@vitest/utils": "4.1.10", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.10", "@vitest/browser-preview": "4.1.10", "@vitest/browser-webdriverio": "4.1.10", "@vitest/coverage-istanbul": "4.1.10", "@vitest/coverage-v8": "4.1.10", "@vitest/ui": "4.1.10", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw=="],
"vscode-jsonrpc": ["vscode-jsonrpc@8.2.0", "", {}, "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA=="],
diff --git a/packages/lingo/CHANGELOG.md b/packages/lingo/CHANGELOG.md
index 3200bb0..9316dd9 100644
--- a/packages/lingo/CHANGELOG.md
+++ b/packages/lingo/CHANGELOG.md
@@ -7,12 +7,37 @@ change**, even if the API is untouched.
## [Unreleased]
+### Added
+
+- `@pascal-app/lingo/react-native`: DOM-free `useLingoTextInput()` for React
+ Native `TextInput`, including parse-as-you-type partial states, blur/submit
+ canonicalization, required/bounds validation, backend-ready `submitValue`,
+ and injected ranked completions. The entry imports React only—no
+ `react-native` runtime dependency.
+- `useLingoInput()` now forwards an injected ranked-completion provider and
+ exposes `completions`, `highlightedIndex`, `setHighlightedIndex()`, and
+ `selectCompletion()` for headless React comboboxes. `./complete` remains an
+ explicit, tree-shakeable import; no popup UI or runtime dependency is added.
+
### Changed
+- Shared field format helpers (`toLingoOptions`, `materialize`, commit/hint
+ formatting) now take a DOM-free `LingoFieldFormatOptions` surface so the React
+ Native adapter reuses them without casting through `LingoInputOptions`.
+- The npm `llms.txt` reference now includes full inline React and React Native
+ adapter recipes (completions, capture-phase keyboard notes, `inputProps`,
+ submit/canonical fields) so offline agents can integrate without fetching the
+ site.
- The docs autocomplete showcase now separates ranked completions, loaded-locale
examples, and caller-controlled unit suggestions into three focused,
keyboard-navigable demos instead of one control-heavy panel.
+### Fixed
+
+- The docs autocomplete showcase now handles Enter during React's capture phase,
+ so selecting a highlighted completion wins over the input controller's native
+ primary-parse commit.
+
## [0.2.1] - 2026-07-09
### Added
diff --git a/packages/lingo/README.md b/packages/lingo/README.md
index 7bc5493..02e6003 100644
--- a/packages/lingo/README.md
+++ b/packages/lingo/README.md
@@ -97,6 +97,11 @@ a UX choice, not something the parser needs to survive. Reproduce with
npm install @pascal-app/lingo
```
+Coding agents: the package ships an inline reference at
+`node_modules/@pascal-app/lingo/llms.txt` (also at
+[lingo.pascal.app/llms-small.txt](https://lingo.pascal.app/llms-small.txt)).
+Online, start at [lingo.pascal.app/llms.txt](https://lingo.pascal.app/llms.txt).
+
## The 5-minute tour
### Parse
@@ -398,14 +403,55 @@ Style it with attribute selectors:
### React: `@pascal-app/lingo/react`
```tsx
+import { completions } from '@pascal-app/lingo/complete'
import { useLingoInput } from '@pascal-app/lingo/react'
function HeightField() {
- const { ref, state, value } = useLingoInput({ kind: 'length', unit: 'm', name: 'height_m' })
- return
+ const field = useLingoInput({
+ kind: 'length',
+ unit: 'm',
+ name: 'height_m',
+ listboxId: 'height-options',
+ complete: (text) => completions(text, { kind: 'length' }),
+ })
+ return
}
```
+The hook exposes `completions`, `highlightedIndex`,
+`setHighlightedIndex()`, and `selectCompletion()` for a caller-rendered
+listbox. `./complete` stays an injected import; `./react` does not bundle it or
+ship popup UI.
+
+### React Native: `@pascal-app/lingo/react-native`
+
+```tsx
+import { Text, TextInput, View } from 'react-native'
+import { useLingoTextInput } from '@pascal-app/lingo/react-native'
+
+function WeightField() {
+ const field = useLingoTextInput({
+ kind: 'mass',
+ unit: 'kg',
+ min: 0,
+ max: '500 kg',
+ })
+ return (
+
+
+ {field.errorMessage ? {field.errorMessage} : null}
+
+ )
+}
+```
+
+`onChangeText` parses without rewriting, while blur, submit, or `commit()`
+canonicalizes the display. `value` is the canonical number and `submitValue`
+is the backend-ready string. Inject `complete` from `./complete` for ranked
+suggestions (`completions` / `selectCompletion()`); render them with your own
+`FlatList`. The entry imports React only—there is no DOM controller,
+`react-native` runtime dependency, or bundled completion UI.
+
### Web components: `@pascal-app/lingo/element`
A form-associated custom element for design systems with no framework
@@ -450,9 +496,9 @@ completions('5', { kind: 'length' }).map((c) => c.text)
// ['5 m', '5 ft', '5 cm', '5 in', '5 km', '5 mi']
```
-The DOM field is headless about this too: `lingoInput` accepts injected
-`complete` / `onComplete` hooks, so you render your own dropdown — the library ships
-no UI. When completions are enabled it wires the combobox side of the ARIA
+The DOM field and React hook are headless about this too: inject `complete`,
+render your own dropdown, and use `onComplete` or the hook's completion state.
+When completions are enabled the input gets the combobox side of the ARIA
contract (`role="combobox"`, `aria-autocomplete="list"`, `aria-expanded`), and
`listboxId` sets `aria-controls` for your listbox.
diff --git a/packages/lingo/docs/recipes.md b/packages/lingo/docs/recipes.md
index fac625e..43adb30 100644
--- a/packages/lingo/docs/recipes.md
+++ b/packages/lingo/docs/recipes.md
@@ -696,6 +696,112 @@ const heightField = quantityField({ kind: 'length', unit: 'm', min: 0.3, max: 2.
const weightField = quantityField({ kind: 'mass', unit: 'kg', min: 0 })
```
+### React ranked completions
+
+Inject `completions()` into the hook and render the listbox yourself. The hook
+owns only ranked-list state and selection; popup markup, option ids, styling,
+and keyboard policy stay in your component:
+
+```tsx
+import { completions } from '@pascal-app/lingo/complete'
+import { useLingoInput } from '@pascal-app/lingo/react'
+
+function HeightInput() {
+ const field = useLingoInput({
+ kind: 'length',
+ unit: 'm',
+ listboxId: 'height-options',
+ complete: (text) => completions(text, { kind: 'length', limit: 6 }),
+ })
+
+ return (
+ <>
+
= 0
+ ? `height-option-${field.highlightedIndex}`
+ : undefined
+ }
+ onKeyDownCapture={(event) => {
+ if (event.key === 'ArrowDown') {
+ event.preventDefault()
+ field.setHighlightedIndex(field.highlightedIndex + 1)
+ }
+ if (event.key === 'ArrowUp') {
+ event.preventDefault()
+ field.setHighlightedIndex(field.highlightedIndex - 1)
+ }
+ if (event.key === 'Enter' && field.highlightedIndex >= 0) {
+ event.preventDefault()
+ event.stopPropagation()
+ field.selectCompletion(field.highlightedIndex)
+ }
+ }}
+ />
+
+ {field.completions.map((item, index) => (
+
+ ))}
+
+ >
+ )
+}
+```
+
+The `Completion` type is shared through the DOM layer, but the completion
+engine is not: importing `./react` alone never pulls in `./complete`.
+Handle Enter in React's capture phase so completion selection runs before the
+controller's native Enter-to-commit listener.
+
+### React Native `TextInput`
+
+The native adapter owns display text and returns the four props `TextInput`
+needs. It does not import React Native or emulate browser validation:
+
+```tsx
+import { Text, TextInput, View } from 'react-native'
+import { useLingoTextInput } from '@pascal-app/lingo/react-native'
+
+function PackageWeight({ onWeight }: { onWeight: (kg: number | null) => void }) {
+ const field = useLingoTextInput({
+ kind: 'mass',
+ unit: 'kg',
+ min: 0,
+ max: '500 kg',
+ onValueChange: onWeight,
+ })
+
+ return (
+
+
+ {field.errorMessage ? {field.errorMessage} : null}
+ {!field.errorMessage && field.hint ? {field.hint} : null}
+
+ )
+}
+```
+
+`field.text` is display text; `field.value` is the canonical number in `unit`;
+`field.submitValue` is the backend-ready string. Typing never rewrites text.
+Blur, `onSubmitEditing`, and `commit()` apply canonical formatting. Inject
+`completions()` exactly as on web and render the returned items with a
+`FlatList`; the completion engine remains an explicit import.
+
### React Hook Form
For a whole form, `lingoObject` drops straight into the resolver:
diff --git a/packages/lingo/llms.txt b/packages/lingo/llms.txt
index 6576144..3833bb1 100644
--- a/packages/lingo/llms.txt
+++ b/packages/lingo/llms.txt
@@ -4,7 +4,7 @@
Agent fetch order (online): `https://lingo.pascal.app/llms.txt` (index) → `https://lingo.pascal.app/docs/
.md` (per-topic) or `https://lingo.pascal.app/llms-full.txt` (complete narrative). Offline: this file (`node_modules/@pascal-app/lingo/llms.txt`) is the compressed self-contained reference. Keep user measurements as strings in tool schemas; call lingo to convert, validate, surface spans, and handle ambiguity.
-Entries: `@pascal-app/lingo` (core+units+fuzzy), `@pascal-app/lingo/date`, `@pascal-app/lingo/dom`, `@pascal-app/lingo/element` (``), `@pascal-app/lingo/react`, `@pascal-app/lingo/ai` (LLM tool fields), `@pascal-app/lingo/mcp` (MCP tool helper), `@pascal-app/lingo/describe` (rich value/result descriptions), `@pascal-app/lingo/catalog` (query units/kinds/currencies + ISO country codes), `@pascal-app/lingo/complete` (ranked autocomplete completions), `@pascal-app/lingo/schema` (JSON Schema + OpenAPI + enum reference), `@pascal-app/lingo/locales/{en,en-gb,es,fr,pt,zh,ja}` (tree-shakeable language packs), `@pascal-app/lingo/core`.
+Entries: `@pascal-app/lingo` (core+units+fuzzy), `@pascal-app/lingo/date`, `@pascal-app/lingo/dom`, `@pascal-app/lingo/element` (``), `@pascal-app/lingo/react`, `@pascal-app/lingo/react-native`, `@pascal-app/lingo/ai` (LLM tool fields), `@pascal-app/lingo/mcp` (MCP tool helper), `@pascal-app/lingo/describe` (rich value/result descriptions), `@pascal-app/lingo/catalog` (query units/kinds/currencies + ISO country codes), `@pascal-app/lingo/complete` (ranked autocomplete completions), `@pascal-app/lingo/schema` (JSON Schema + OpenAPI + enum reference), `@pascal-app/lingo/locales/{en,en-gb,es,fr,pt,zh,ja}` (tree-shakeable language packs), `@pascal-app/lingo/core`.
## Core (`@pascal-app/lingo`)
@@ -41,7 +41,7 @@ const items = completions("10 kg to 16", { kind: "mass", limit: 8 })
// [{ text, result, confidence, source }] — source: parse|alternative|unit-ambiguity|unit-prefix|implied-unit|range-implied|cross-kind|date
```
-Distinct from success `alternatives`, failure `candidate`, and issue `suggestions`. Wire into `lingoInput({ complete, onComplete })` for autocomplete dropdowns. Pass `date: (text) => parseDate(text, { now })` to opt into date completions without bundling `@pascal-app/lingo/date` into `@pascal-app/lingo/complete`.
+Distinct from success `alternatives`, failure `candidate`, and issue `suggestions`. Wire into `lingoInput({ complete, onComplete })` or `useLingoInput({ complete })` for autocomplete dropdowns. The React hook exposes `completions`, `highlightedIndex`, `setHighlightedIndex()`, and `selectCompletion()` while keeping popup UI caller-owned. Pass `date: (text) => parseDate(text, { now })` to opt into date completions without bundling `@pascal-app/lingo/date` into `@pascal-app/lingo/complete`.
## Locales (`@pascal-app/lingo/locales/*`)
@@ -110,7 +110,102 @@ field.set("6ft")
field.commit() // hidden input submits canonical value
```
-`field.value` · `.quantity` · `.result` · `.state` ('idle'|'incomplete'|'valid'|'invalid') · `.set()` · `.commit()` · `.update()` · `.destroy()`. Never rewrites while typing; canonicalizes on blur/Enter/submit. With `complete`/`onComplete`, the input gets the headless WAI-ARIA combobox attributes (`role="combobox"`, `aria-autocomplete="list"`, `aria-expanded`); pass `listboxId` to set `aria-controls` for your rendered listbox. React: `useLingoInput(opts)` from `@pascal-app/lingo/react`.
+`field.value` · `.quantity` · `.result` · `.state` ('idle'|'incomplete'|'valid'|'invalid') · `.set()` · `.commit()` · `.update()` · `.destroy()`. Never rewrites while typing; canonicalizes on blur/Enter/submit. With `complete`/`onComplete`, the input gets the headless WAI-ARIA combobox attributes (`role="combobox"`, `aria-autocomplete="list"`, `aria-expanded`); pass `listboxId` to set `aria-controls` for your rendered listbox.
+
+## React (`@pascal-app/lingo/react`)
+
+```tsx
+import { completions } from "@pascal-app/lingo/complete"
+import { useLingoInput } from "@pascal-app/lingo/react"
+
+function HeightField() {
+ const field = useLingoInput({
+ kind: "length",
+ unit: "m",
+ name: "height_m",
+ listboxId: "height-options",
+ complete: (text) => completions(text, { kind: "length" }),
+ })
+ return (
+ <>
+
+
+ {field.completions.map((item, index) => (
+ - {
+ e.preventDefault()
+ field.selectCompletion(index)
+ }}
+ >
+ {item.text}
+
+ ))}
+
+ >
+ )
+}
+```
+
+- Attach `field.ref` to `` / `