From 03a56c7a2755481a691f38ac5bbce7e271e774e8 Mon Sep 17 00:00:00 2001 From: Eser DENIZ Date: Mon, 24 Aug 2026 14:42:38 +0200 Subject: [PATCH 01/10] Add a submit-label prop for the return key / IME action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Forms with several fields need the platform Next/Go/Search/Send key, but the submit key face was out of reach: hardcoded on iOS (.done when @submit is wired, .return otherwise) and never set on Android (keyboardOptionsFor omitted imeAction entirely). Adds an opt-in submit-label prop (next|done|go|search|send|return) to the three text inputs, mapping to SwiftUI SubmitLabel and Compose ImeAction. Unset keeps each platform's behaviour byte-for-byte. Multiline fields ignore the prop on both platforms — their return key must keep inserting newlines. The filled/outlined Android renderers move from KeyboardActions(onDone) to onAny (the bare renderer's existing shape): with a non-Done IME action an onDone-only handler would silently drop the @submit dispatch. For the default action the two are equivalent. The fluent submitLabel() validates and throws on unknown values; Blade accepts submit-label and submitLabel. --- resources/android/FilledTextInputRenderer.kt | 5 +- .../android/OutlinedTextInputRenderer.kt | 5 +- resources/android/TextInputShared.kt | 41 +++++++++++-- resources/ios/NativeUITextInputCore.swift | 27 ++++++++- src/Elements/BaseTextInput.php | 44 +++++++++++++- tests/BaseTextInputSubmitLabelTest.php | 59 +++++++++++++++++++ 6 files changed, 173 insertions(+), 8 deletions(-) create mode 100644 tests/BaseTextInputSubmitLabelTest.php diff --git a/resources/android/FilledTextInputRenderer.kt b/resources/android/FilledTextInputRenderer.kt index 0231c9c..d15f84b 100644 --- a/resources/android/FilledTextInputRenderer.kt +++ b/resources/android/FilledTextInputRenderer.kt @@ -143,7 +143,10 @@ object FilledTextInputRenderer { minLines = props.minLines, visualTransformation = props.visualTransformation, keyboardOptions = keyboardOptionsFor(props), - keyboardActions = KeyboardActions(onDone = { + // onAny, not onDone: `submit-label` can make the IME action Next / + // Go / Search / Send, and an onDone-only handler would silently + // drop the submit for those. Matches the bare renderer. + keyboardActions = KeyboardActions(onAny = { // Flush the settled caret before the submit event fires. selectionReporter.flush(value) dispatcher.onSubmit(value.text) diff --git a/resources/android/OutlinedTextInputRenderer.kt b/resources/android/OutlinedTextInputRenderer.kt index 4013484..a0a96de 100644 --- a/resources/android/OutlinedTextInputRenderer.kt +++ b/resources/android/OutlinedTextInputRenderer.kt @@ -156,7 +156,10 @@ object OutlinedTextInputRenderer { minLines = props.minLines, visualTransformation = props.visualTransformation, keyboardOptions = keyboardOptionsFor(props), - keyboardActions = KeyboardActions(onDone = { + // onAny, not onDone: `submit-label` can make the IME action Next / + // Go / Search / Send, and an onDone-only handler would silently + // drop the submit for those. Matches the bare renderer. + keyboardActions = KeyboardActions(onAny = { // Flush the settled caret before the submit event fires. selectionReporter.flush(value) dispatcher.onSubmit(value.text) diff --git a/resources/android/TextInputShared.kt b/resources/android/TextInputShared.kt index 50173f9..d262875 100644 --- a/resources/android/TextInputShared.kt +++ b/resources/android/TextInputShared.kt @@ -7,6 +7,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation @@ -52,6 +53,7 @@ internal data class TextInputProps( val maxLength: Int, val keyboard: KeyboardType, val capitalization: KeyboardCapitalization?, + val submitLabel: String, val disabled: Boolean, val readOnly: Boolean, val isError: Boolean, @@ -116,6 +118,7 @@ internal fun parseTextInputProps(node: NativeUINode): TextInputProps { maxLength = p.getInt("max_length"), keyboard = resolveKeyboardType(p.getString("keyboard")), capitalization = resolveCapitalization(p.getString("autocapitalize"), p.getBool("secure"), p.getString("keyboard")), + submitLabel = p.getString("submit_label"), disabled = p.getBool("disabled"), readOnly = p.getBool("read_only"), isError = p.getBool("is_error"), @@ -212,10 +215,40 @@ internal fun resolveCapitalization(explicit: String, secure: Boolean, keyboard: } } -internal fun keyboardOptionsFor(props: TextInputProps): KeyboardOptions = - props.capitalization - ?.let { KeyboardOptions(keyboardType = props.keyboard, capitalization = it) } - ?: KeyboardOptions(keyboardType = props.keyboard) +/** + * IME action for the submit key — the `submit_label` prop. The explicit + * value wins; unset — or unknown, same policy as [resolveKeyboardType] — + * keeps [ImeAction.Default], i.e. exactly the pre-prop behaviour. + * + * "return" is iOS vocabulary (a plain Return key); Android has no exact + * equivalent, so it resolves to the IME default too. + * + * A multiline field ignores the prop entirely: a non-default IME action + * replaces the return key, and multiline's return key must keep inserting + * newlines. iOS ignores the prop for multiline the same way + * (`resolveSubmitLabel` in `NativeUITextInputCore.swift`) — keep the two + * in sync. + */ +internal fun resolveImeAction(explicit: String, multiline: Boolean): ImeAction { + if (multiline) return ImeAction.Default + + return when (explicit.lowercase()) { + "next" -> ImeAction.Next + "done" -> ImeAction.Done + "go" -> ImeAction.Go + "search" -> ImeAction.Search + "send" -> ImeAction.Send + else -> ImeAction.Default + } +} + +internal fun keyboardOptionsFor(props: TextInputProps): KeyboardOptions { + val imeAction = resolveImeAction(props.submitLabel, props.multiline) + + return props.capitalization + ?.let { KeyboardOptions(keyboardType = props.keyboard, capitalization = it, imeAction = imeAction) } + ?: KeyboardOptions(keyboardType = props.keyboard, imeAction = imeAction) +} /** * Outbound dispatch state machine. Call [onTextChanged] whenever local text diff --git a/resources/ios/NativeUITextInputCore.swift b/resources/ios/NativeUITextInputCore.swift index fdd29d9..c1336ba 100644 --- a/resources/ios/NativeUITextInputCore.swift +++ b/resources/ios/NativeUITextInputCore.swift @@ -76,6 +76,7 @@ struct NativeUITextInputCore: View { let syncMode = p.getString("sync_mode", default: "live") let debounceMs = p.getInt("debounce_ms", default: 300) let keepFocus = p.getBool("keep_focus_on_submit") + let submitLabelKind = p.getString("submit_label") // Selection reporting is opt-in (0/absent ⇒ off) and never applies to // secure fields. Read exactly like `on_change` / `debounce_ms` above. let onSelectionCb = p.getCallbackId("on_selection_change") @@ -154,7 +155,7 @@ struct NativeUITextInputCore: View { .textInputAutocapitalization(capitalization) .autocorrectionDisabled(!autocorrect) .disabled(disabled || readOnly) - .submitLabel(onSubmitCb != 0 ? .done : .return) + .submitLabel(resolveSubmitLabel(explicit: submitLabelKind, multiline: multiline, hasSubmit: onSubmitCb != 0)) .onAppear { if !initialized { text = serverValue @@ -424,6 +425,30 @@ private struct NativeUISelectionPayload: Equatable { let end: Int } +/// Submit-key face for the field. The explicit `submit_label` prop wins; +/// unset — or unknown, same policy as `resolveKeyboardType` — keeps the +/// original default: `.done` when `@submit` is wired, `.return` otherwise. +/// +/// A multiline field ignores the prop entirely: on the vertical-axis +/// TextField a non-return submit label swaps newline insertion for a submit +/// action, silently taking away the field's reason to be multiline. Android +/// ignores the prop for multiline the same way (`resolveImeAction` in +/// `TextInputShared.kt`) — keep the two in sync. +private func resolveSubmitLabel(explicit: String, multiline: Bool, hasSubmit: Bool) -> SubmitLabel { + if !multiline { + switch explicit.lowercased() { + case "next": return .next + case "done": return .done + case "go": return .go + case "search": return .search + case "send": return .send + case "return": return .return + default: break + } + } + return hasSubmit ? .done : .return +} + /// Keyboard resolution — accepts string hints ("email", "number", etc.) that /// map to UIKeyboardType. Unknown/empty falls through to default. private func resolveKeyboardType(_ kind: String) -> UIKeyboardType { diff --git a/src/Elements/BaseTextInput.php b/src/Elements/BaseTextInput.php index 6cbb6bc..5bd291f 100644 --- a/src/Elements/BaseTextInput.php +++ b/src/Elements/BaseTextInput.php @@ -2,6 +2,7 @@ namespace Native\Mobile\UI\Elements; +use InvalidArgumentException; use Native\Mobile\Edge\CallbackRegistry; use Native\Mobile\Edge\Element; use Native\Mobile\Icon\AndroidSymbol; @@ -19,7 +20,7 @@ * Allowed per-instance: * - `value`, `placeholder`, `label`, `supporting` (content) * - `disabled`, `readOnly`, `error`, `loading` (state) - * - `keyboard`, `autocapitalize`, `secure`, `maxLength`, `multiline`, `maxLines`, `minLines` (behavior) + * - `keyboard`, `autocapitalize`, `secure`, `maxLength`, `multiline`, `maxLines`, `minLines`, `submit-label` (behavior) * - `prefix`, `suffix`, `leading-icon`, `trailing-icon` (decorations) * - `size` (sm | md | lg) * - `a11y-label`, `a11y-hint` (accessibility) @@ -101,6 +102,9 @@ public function applyAttributes(array $attrs): void if (! empty($attrs['keepFocusOnSubmit']) || ! empty($attrs['keep-focus-on-submit']) || ! empty($attrs['keep-focus'])) { $this->keepFocusOnSubmit(); } + if (isset($attrs['submit-label']) || isset($attrs['submitLabel'])) { + $this->submitLabel((string) ($attrs['submit-label'] ?? $attrs['submitLabel'])); + } if (isset($attrs['maxLines']) || isset($attrs['max-lines'])) { $this->maxLines((int) ($attrs['maxLines'] ?? $attrs['max-lines'])); } @@ -322,6 +326,44 @@ public function keepFocusOnSubmit(bool $value = true): static return $this; } + /** + * Which action the keyboard's submit key advertises — "next" | "done" | + * "go" | "search" | "send" | "return". Maps to SwiftUI's `SubmitLabel` + * on iOS and the IME action on Android. + * + * Leave it unset and each platform keeps its current default (iOS shows + * Done when `@submit` is wired, Return otherwise; Android leaves the IME + * action to the platform). The label is purely cosmetic — pressing the + * key still fires `@submit` and commits per `sync_mode`, whatever face + * it shows. + * + * "return" is iOS vocabulary (a plain Return key); Android has no exact + * equivalent and renders its IME default for it. + * + * IGNORED on a `multiline()` field natively, on both platforms: there + * the return key must keep inserting newlines, and a non-return submit + * label would silently replace that. Not validated here because the + * fluent order (`multiline()` before or after `submitLabel()`) must not + * change the outcome. + * + * Blade: `submit-label` (or `submitLabel`). + */ + public function submitLabel(string $label): static + { + $label = strtolower(trim($label)); + + if (! in_array($label, ['next', 'done', 'go', 'search', 'send', 'return'], true)) { + throw new InvalidArgumentException( + "Unknown submit-label `{$label}`. " + .'Use one of: next, done, go, search, send, return — or omit the attribute to keep the platform default.' + ); + } + + $this->inputProps['submit_label'] = $label; + + return $this; + } + public function maxLines(int $lines): static { $this->inputProps['max_lines'] = $lines; diff --git a/tests/BaseTextInputSubmitLabelTest.php b/tests/BaseTextInputSubmitLabelTest.php new file mode 100644 index 0000000..5253553 --- /dev/null +++ b/tests/BaseTextInputSubmitLabelTest.php @@ -0,0 +1,59 @@ +applyAttributes(['submit-label' => 'next']); + + $props = $input->getResolvedProps(new CallbackRegistry); + + expect($props['submit_label'])->toBe('next'); +})->with([ + 'bare' => [BareTextInput::class], + 'filled' => [FilledTextInput::class], + 'outlined' => [OutlinedTextInput::class], +]); + +it('accepts every documented label value', function (string $label) { + $input = new OutlinedTextInput; + $input->applyAttributes(['submit-label' => $label]); + + $props = $input->getResolvedProps(new CallbackRegistry); + + expect($props['submit_label'])->toBe($label); +})->with(['next', 'done', 'go', 'search', 'send', 'return']); + +it('accepts the camelCase attribute spelling', function () { + $input = new FilledTextInput; + $input->applyAttributes(['submitLabel' => 'send']); + + $props = $input->getResolvedProps(new CallbackRegistry); + + expect($props['submit_label'])->toBe('send'); +}); + +it('normalizes case and surrounding whitespace', function () { + $input = new OutlinedTextInput; + $input->submitLabel(' Next '); + + $props = $input->getResolvedProps(new CallbackRegistry); + + expect($props['submit_label'])->toBe('next'); +}); + +it('does not serialize the prop when unset', function () { + $input = new OutlinedTextInput; + $input->applyAttributes(['placeholder' => 'Name']); + + $props = $input->getResolvedProps(new CallbackRegistry); + + expect($props)->not->toHaveKey('submit_label'); +}); + +it('rejects an unknown submit label', function () { + (new OutlinedTextInput)->submitLabel('confirm'); +})->throws(InvalidArgumentException::class, 'Unknown submit-label `confirm`'); From defb3dbda1ff6e07f93340037f9a1d5c5586f170 Mon Sep 17 00:00:00 2001 From: Eser DENIZ Date: Mon, 24 Aug 2026 14:47:50 +0200 Subject: [PATCH 02/10] Chain keyboard focus between text inputs with next-focus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A multi-field form has no way to move the keyboard to the following field: on iOS every input owns a private @FocusState with no cross- field coordination, and on Android nothing wires an IME Next action to a FocusRequester. Adds an opt-in next-focus prop that names another input's existing ref (the one Native::test() already targets). The element surfaces its own ref to the renderers as focus_ref — the node-level ref is not decoded natively — and each platform keeps a screen-wide focus registry: closures asserting @FocusState on iOS, FocusRequesters on Android, registered while the field is on screen, identity/token-guarded on unregister so stacked screens can shadow refs safely. On submit, the field asks the registry to focus its target. A missing target (recycled row, conditional render, typo'd ref) is a no-op; iOS then falls back to keep-focus-on-submit when set. next-focus wins over keep-focus-on-submit otherwise — moving the keyboard IS keeping it up. Focus is only ever requested from a user-initiated submit, never from server pushes, so nothing can steal focus spontaneously. When next-focus is set and submit-label isn't, both platforms derive a Next submit key, mirroring how capitalization derives from the keyboard type. Multiline fields ignore the chain like they ignore submit-label. An empty ref is treated as unset so Blade can pass a conditional without special-casing the last field. --- resources/android/BareTextInputRenderer.kt | 8 ++ resources/android/FilledTextInputRenderer.kt | 9 +- .../android/OutlinedTextInputRenderer.kt | 9 +- resources/android/TextInputShared.kt | 95 +++++++++++++++++-- resources/ios/NativeUIFocusRegistry.swift | 58 +++++++++++ resources/ios/NativeUITextInputCore.swift | 61 +++++++++--- src/Elements/BaseTextInput.php | 43 ++++++++- tests/BaseTextInputNextFocusTest.php | 65 +++++++++++++ 8 files changed, 327 insertions(+), 21 deletions(-) create mode 100644 resources/ios/NativeUIFocusRegistry.swift create mode 100644 tests/BaseTextInputNextFocusTest.php diff --git a/resources/android/BareTextInputRenderer.kt b/resources/android/BareTextInputRenderer.kt index a52a7ef..98f79ae 100644 --- a/resources/android/BareTextInputRenderer.kt +++ b/resources/android/BareTextInputRenderer.kt @@ -14,6 +14,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor @@ -79,6 +80,7 @@ object BareTextInputRenderer { var value by remember { mutableStateOf(TextFieldValue(props.serverValue, TextRange(props.serverValue.length))) } var lastSentValue by remember { mutableStateOf(props.serverValue) } var wasFocused by remember { mutableStateOf(false) } + val focusRequester = rememberRegisteredFocusRequester(props.focusRef) // Caret / selection reporter — independent of the direct change/submit // dispatchers; no-op unless `on_selection_change` is wired and the @@ -125,6 +127,7 @@ object BareTextInputRenderer { // flush the pending selection on the focused → unfocused edge. modifier = Modifier .fillMaxWidth() + .focusRequester(focusRequester) .onFocusChanged { state -> if (wasFocused && !state.isFocused) selectionReporter.flush(value) wasFocused = state.isFocused @@ -160,6 +163,11 @@ object BareTextInputRenderer { // Flush the settled caret before the submit event fires. selectionReporter.flush(value) props.dispatchSubmit?.invoke(value.text) + // Chained focus (`next-focus`): move the keyboard to the + // target field. A missing target is a no-op. + if (props.nextFocus.isNotEmpty()) { + NativeUIFocusRegistry.request(props.nextFocus) + } }) ) } diff --git a/resources/android/FilledTextInputRenderer.kt b/resources/android/FilledTextInputRenderer.kt index d15f84b..0a4bffc 100644 --- a/resources/android/FilledTextInputRenderer.kt +++ b/resources/android/FilledTextInputRenderer.kt @@ -19,6 +19,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.TextStyle @@ -79,6 +80,7 @@ object FilledTextInputRenderer { } val interactionSource = remember { MutableInteractionSource() } + val focusRequester = rememberRegisteredFocusRequester(props.focusRef) LaunchedEffect(interactionSource) { val focusStack = mutableListOf() interactionSource.interactions.collect { interaction: Interaction -> @@ -124,7 +126,7 @@ object FilledTextInputRenderer { // Full width by default (parity with the iOS renderer's // maxWidth: .infinity); an explicit width in `modifier` (FIXED // layout mode) still wins since it comes later in the chain. - modifier = Modifier.fillMaxWidth().then(modifier).nuiA11y(props.a11yLabel, props.a11yHint), + modifier = Modifier.fillMaxWidth().focusRequester(focusRequester).then(modifier).nuiA11y(props.a11yLabel, props.a11yHint), enabled = props.enabled, readOnly = props.readOnly, interactionSource = interactionSource, @@ -150,6 +152,11 @@ object FilledTextInputRenderer { // Flush the settled caret before the submit event fires. selectionReporter.flush(value) dispatcher.onSubmit(value.text) + // Chained focus (`next-focus`): move the keyboard to the + // target field. A missing target is a no-op. + if (props.nextFocus.isNotEmpty()) { + NativeUIFocusRegistry.request(props.nextFocus) + } }), textStyle = TextStyle(fontSize = textSize, color = theme.onSurface, fontFamily = customFontFamily, lineHeight = lineHeight), colors = TextFieldDefaults.colors( diff --git a/resources/android/OutlinedTextInputRenderer.kt b/resources/android/OutlinedTextInputRenderer.kt index a0a96de..cd42152 100644 --- a/resources/android/OutlinedTextInputRenderer.kt +++ b/resources/android/OutlinedTextInputRenderer.kt @@ -19,6 +19,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.TextStyle @@ -91,6 +92,7 @@ object OutlinedTextInputRenderer { // modes. Passing our own source also means we don't pay for M3's // default ripple-focus-hover machinery elsewhere. val interactionSource = remember { MutableInteractionSource() } + val focusRequester = rememberRegisteredFocusRequester(props.focusRef) LaunchedEffect(interactionSource) { val focusStack = mutableListOf() interactionSource.interactions.collect { interaction: Interaction -> @@ -137,7 +139,7 @@ object OutlinedTextInputRenderer { // Full width by default (parity with the iOS renderer's // maxWidth: .infinity); an explicit width in `modifier` (FIXED // layout mode) still wins since it comes later in the chain. - modifier = Modifier.fillMaxWidth().then(modifier).nuiA11y(props.a11yLabel, props.a11yHint), + modifier = Modifier.fillMaxWidth().focusRequester(focusRequester).then(modifier).nuiA11y(props.a11yLabel, props.a11yHint), enabled = props.enabled, readOnly = props.readOnly, interactionSource = interactionSource, @@ -163,6 +165,11 @@ object OutlinedTextInputRenderer { // Flush the settled caret before the submit event fires. selectionReporter.flush(value) dispatcher.onSubmit(value.text) + // Chained focus (`next-focus`): move the keyboard to the + // target field. A missing target is a no-op. + if (props.nextFocus.isNotEmpty()) { + NativeUIFocusRegistry.request(props.nextFocus) + } }), textStyle = TextStyle(fontSize = textSize, color = theme.onSurface, fontFamily = customFontFamily, lineHeight = lineHeight), colors = OutlinedTextFieldDefaults.colors( diff --git a/resources/android/TextInputShared.kt b/resources/android/TextInputShared.kt index d262875..d11bb75 100644 --- a/resources/android/TextInputShared.kt +++ b/resources/android/TextInputShared.kt @@ -3,7 +3,10 @@ package com.nativephp.plugins.native_ui.ui import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.TextRange @@ -54,6 +57,8 @@ internal data class TextInputProps( val keyboard: KeyboardType, val capitalization: KeyboardCapitalization?, val submitLabel: String, + val focusRef: String, + val nextFocus: String, val disabled: Boolean, val readOnly: Boolean, val isError: Boolean, @@ -119,6 +124,8 @@ internal fun parseTextInputProps(node: NativeUINode): TextInputProps { keyboard = resolveKeyboardType(p.getString("keyboard")), capitalization = resolveCapitalization(p.getString("autocapitalize"), p.getBool("secure"), p.getString("keyboard")), submitLabel = p.getString("submit_label"), + focusRef = p.getString("focus_ref"), + nextFocus = p.getString("next_focus"), disabled = p.getBool("disabled"), readOnly = p.getBool("read_only"), isError = p.getBool("is_error"), @@ -218,18 +225,21 @@ internal fun resolveCapitalization(explicit: String, secure: Boolean, keyboard: /** * IME action for the submit key — the `submit_label` prop. The explicit * value wins; unset — or unknown, same policy as [resolveKeyboardType] — - * keeps [ImeAction.Default], i.e. exactly the pre-prop behaviour. + * derives [ImeAction.Next] when a `next_focus` chain is set (mirroring how + * capitalization derives from the keyboard type), else keeps + * [ImeAction.Default], i.e. exactly the pre-prop behaviour. * * "return" is iOS vocabulary (a plain Return key); Android has no exact - * equivalent, so it resolves to the IME default too. + * equivalent, so it resolves to the IME default — listed explicitly so an + * author's `return` beats the `next_focus` derivation. * - * A multiline field ignores the prop entirely: a non-default IME action + * A multiline field ignores both props entirely: a non-default IME action * replaces the return key, and multiline's return key must keep inserting - * newlines. iOS ignores the prop for multiline the same way + * newlines. iOS ignores them for multiline the same way * (`resolveSubmitLabel` in `NativeUITextInputCore.swift`) — keep the two * in sync. */ -internal fun resolveImeAction(explicit: String, multiline: Boolean): ImeAction { +internal fun resolveImeAction(explicit: String, multiline: Boolean, nextFocus: String): ImeAction { if (multiline) return ImeAction.Default return when (explicit.lowercase()) { @@ -238,18 +248,89 @@ internal fun resolveImeAction(explicit: String, multiline: Boolean): ImeAction { "go" -> ImeAction.Go "search" -> ImeAction.Search "send" -> ImeAction.Send - else -> ImeAction.Default + "return" -> ImeAction.Default + else -> if (nextFocus.isNotEmpty()) ImeAction.Next else ImeAction.Default } } internal fun keyboardOptionsFor(props: TextInputProps): KeyboardOptions { - val imeAction = resolveImeAction(props.submitLabel, props.multiline) + val imeAction = resolveImeAction(props.submitLabel, props.multiline, props.nextFocus) return props.capitalization ?.let { KeyboardOptions(keyboardType = props.keyboard, capitalization = it, imeAction = imeAction) } ?: KeyboardOptions(keyboardType = props.keyboard, imeAction = imeAction) } +/** + * Screen-wide focus routing for text inputs — the `next-focus` prop. + * + * Each renderer whose element carries a `ref` registers its + * [FocusRequester] under that name (a `DisposableEffect` keyed on the + * ref), and the submit handler of a field with `next_focus` set asks the + * registry to move the keyboard there. Focus is only ever requested from + * a user-initiated submit on another field — never from server pushes — + * so the registry cannot steal focus spontaneously. + * + * Main-thread only: registration happens in composition effects and + * requests in IME action handlers. Last registration wins (refs are + * unique per screen by convention — they are the same refs + * `Native::test()` targets); unregistration is identity-guarded so a + * disposed screen can't tear down the ref it was shadowing. iOS keeps the + * same contract (`NativeUIFocusRegistry.swift`) — keep the two in sync. + */ +internal object NativeUIFocusRegistry { + private val entries = mutableMapOf() + + fun register(ref: String, requester: FocusRequester) { + entries[ref] = requester + } + + fun unregister(ref: String, requester: FocusRequester) { + if (entries[ref] === requester) { + entries.remove(ref) + } + } + + /** + * Focus the field registered under [ref]. Returns whether a live + * target existed — a missing or detached target (off-screen, recycled + * row, typo'd ref) is a no-op, never a crash. + */ + fun request(ref: String): Boolean { + val requester = entries[ref] ?: return false + return try { + requester.requestFocus() + true + } catch (_: IllegalStateException) { + // Registered but no longer attached to a composed node (the + // row was recycled between registration and this submit). + false + } + } +} + +/** + * Remember a [FocusRequester] kept registered in [NativeUIFocusRegistry] + * under the field's `focus_ref` while it is in composition. Hang the + * result on the field's `Modifier.focusRequester`; an empty ref returns a + * plain, unregistered requester. + */ +@Composable +internal fun rememberRegisteredFocusRequester(focusRef: String): FocusRequester { + val requester = remember { FocusRequester() } + DisposableEffect(focusRef) { + if (focusRef.isNotEmpty()) { + NativeUIFocusRegistry.register(focusRef, requester) + } + onDispose { + if (focusRef.isNotEmpty()) { + NativeUIFocusRegistry.unregister(focusRef, requester) + } + } + } + return requester +} + /** * Outbound dispatch state machine. Call [onTextChanged] whenever local text * updates; the policy decides whether to forward the change over the bridge diff --git a/resources/ios/NativeUIFocusRegistry.swift b/resources/ios/NativeUIFocusRegistry.swift new file mode 100644 index 0000000..ab813d9 --- /dev/null +++ b/resources/ios/NativeUIFocusRegistry.swift @@ -0,0 +1,58 @@ +import Foundation + +/// Screen-wide focus routing for text inputs — the `next-focus` prop. +/// +/// Each text input core whose element carries a `ref` registers a closure +/// that asserts its own `@FocusState` (keyed by that ref) on appear, and +/// unregisters on disappear. A submitting field with `next_focus` set asks +/// the registry to run the target's closure, moving the keyboard to that +/// field without any cross-view focus state or ancestor restructuring. +/// +/// Main-thread only: every caller is a SwiftUI lifecycle hook or submit +/// handler. Focus is only ever requested from a user-initiated submit on +/// another field — never from server pushes — so the registry cannot steal +/// focus spontaneously. +/// +/// Last registration wins: refs are unique per screen by convention (they +/// are the same refs `Native::test()` targets), but two live screens on a +/// navigation stack may reuse one — the most recently appeared field then +/// receives the focus, which is the visible one anyway. Unregistration is +/// token-guarded so a disappearing pushed screen can't tear down the ref +/// it was shadowing. +final class NativeUIFocusRegistry { + static let shared = NativeUIFocusRegistry() + + private struct Entry { + let token: UUID + let focus: () -> Void + } + + private var entries: [String: Entry] = [:] + + /// Register `focus` under `ref`, replacing any previous registration. + /// Returns the token `unregister` needs. + func register(_ ref: String, focus: @escaping () -> Void) -> UUID { + let token = UUID() + entries[ref] = Entry(token: token, focus: focus) + return token + } + + /// Remove the registration for `ref`, but only while it is still the + /// one identified by `token` — a later registration under the same ref + /// must survive its predecessor's disappearance. + func unregister(_ ref: String, token: UUID) { + if entries[ref]?.token == token { + entries[ref] = nil + } + } + + /// Focus the field registered under `ref`. Returns whether a target + /// existed — a missing target (off-screen, recycled row, typo'd ref) + /// is a no-op, never a crash. + @discardableResult + func focus(_ ref: String) -> Bool { + guard let entry = entries[ref] else { return false } + entry.focus() + return true + } +} diff --git a/resources/ios/NativeUITextInputCore.swift b/resources/ios/NativeUITextInputCore.swift index c1336ba..17b3461 100644 --- a/resources/ios/NativeUITextInputCore.swift +++ b/resources/ios/NativeUITextInputCore.swift @@ -48,6 +48,10 @@ struct NativeUITextInputCore: View { @State private var pendingSelection: NativeUISelectionPayload? = nil @State private var lastEmittedSelection: NativeUISelectionPayload? = nil + // Token for this field's `focus_ref` registration (see + // `NativeUIFocusRegistry`); nil when the element carries no ref. + @State private var focusRegistryToken: UUID? = nil + var body: some View { let p = node.props let placeholder = p.getString("placeholder") @@ -77,6 +81,11 @@ struct NativeUITextInputCore: View { let debounceMs = p.getInt("debounce_ms", default: 300) let keepFocus = p.getBool("keep_focus_on_submit") let submitLabelKind = p.getString("submit_label") + // Focus chaining (`next-focus`): `focus_ref` is this field's own + // address in the focus registry (the element's `ref`, surfaced as a + // prop); `next_focus` is the ref to move the keyboard to on submit. + let focusRef = p.getString("focus_ref") + let nextFocus = p.getString("next_focus") // Selection reporting is opt-in (0/absent ⇒ off) and never applies to // secure fields. Read exactly like `on_change` / `debounce_ms` above. let onSelectionCb = p.getCallbackId("on_selection_change") @@ -155,13 +164,27 @@ struct NativeUITextInputCore: View { .textInputAutocapitalization(capitalization) .autocorrectionDisabled(!autocorrect) .disabled(disabled || readOnly) - .submitLabel(resolveSubmitLabel(explicit: submitLabelKind, multiline: multiline, hasSubmit: onSubmitCb != 0)) + .submitLabel(resolveSubmitLabel(explicit: submitLabelKind, multiline: multiline, hasSubmit: onSubmitCb != 0, nextFocus: nextFocus)) .onAppear { if !initialized { text = serverValue lastSentValue = serverValue initialized = true } + // Make this field focus-addressable. Capturing the FocusState + // binding keeps the registry free of any view reference. + if !focusRef.isEmpty { + let binding = $isFocused + focusRegistryToken = NativeUIFocusRegistry.shared.register(focusRef) { + binding.wrappedValue = true + } + } + } + .onDisappear { + if let token = focusRegistryToken, !focusRef.isEmpty { + NativeUIFocusRegistry.shared.unregister(focusRef, token: token) + focusRegistryToken = nil + } } .onChange(of: serverValue) { _, newServerValue in // Only sync from server when the incoming value differs from what @@ -240,13 +263,26 @@ struct NativeUITextInputCore: View { if onSubmitCb != 0 { NativeElementBridge.sendSubmitEvent(onSubmitCb, nodeId: node.id, text: text) } - // Chat "send and keep typing": SwiftUI resigns first responder on - // return by default. Re-assert focus so the keyboard stays up. NOTE: - // this causes a small keyboard "bounce" on return (resign → refocus) - // that the send button doesn't have — the smooth fix needs a - // UIKit-backed field (see notes), not the multiline workaround which - // mis-sized the field in the flex layout. - if keepFocus { + // Focus routing after submit. `next-focus` wins over + // `keep-focus-on-submit` — moving the keyboard to the chained + // field IS keeping it up; keepFocus is only the fallback when the + // target isn't on screen (recycled row, conditional render, + // typo'd ref). Both run async because SwiftUI resigns first + // responder on return before this handler's effects settle. + if !nextFocus.isEmpty { + DispatchQueue.main.async { + if !NativeUIFocusRegistry.shared.focus(nextFocus) && keepFocus { + isFocused = true + } + } + } else if keepFocus { + // Chat "send and keep typing": SwiftUI resigns first responder + // on return by default. Re-assert focus so the keyboard stays + // up. NOTE: this causes a small keyboard "bounce" on return + // (resign → refocus) that the send button doesn't have — the + // smooth fix needs a UIKit-backed field (see notes), not the + // multiline workaround which mis-sized the field in the flex + // layout. DispatchQueue.main.async { isFocused = true } } } @@ -426,15 +462,17 @@ private struct NativeUISelectionPayload: Equatable { } /// Submit-key face for the field. The explicit `submit_label` prop wins; -/// unset — or unknown, same policy as `resolveKeyboardType` — keeps the -/// original default: `.done` when `@submit` is wired, `.return` otherwise. +/// unset — or unknown, same policy as `resolveKeyboardType` — derives +/// `.next` when a `next_focus` chain is set (mirroring how capitalization +/// derives from the keyboard type), else keeps the original default: +/// `.done` when `@submit` is wired, `.return` otherwise. /// /// A multiline field ignores the prop entirely: on the vertical-axis /// TextField a non-return submit label swaps newline insertion for a submit /// action, silently taking away the field's reason to be multiline. Android /// ignores the prop for multiline the same way (`resolveImeAction` in /// `TextInputShared.kt`) — keep the two in sync. -private func resolveSubmitLabel(explicit: String, multiline: Bool, hasSubmit: Bool) -> SubmitLabel { +private func resolveSubmitLabel(explicit: String, multiline: Bool, hasSubmit: Bool, nextFocus: String) -> SubmitLabel { if !multiline { switch explicit.lowercased() { case "next": return .next @@ -445,6 +483,7 @@ private func resolveSubmitLabel(explicit: String, multiline: Bool, hasSubmit: Bo case "return": return .return default: break } + if !nextFocus.isEmpty { return .next } } return hasSubmit ? .done : .return } diff --git a/src/Elements/BaseTextInput.php b/src/Elements/BaseTextInput.php index 5bd291f..f52efce 100644 --- a/src/Elements/BaseTextInput.php +++ b/src/Elements/BaseTextInput.php @@ -20,7 +20,7 @@ * Allowed per-instance: * - `value`, `placeholder`, `label`, `supporting` (content) * - `disabled`, `readOnly`, `error`, `loading` (state) - * - `keyboard`, `autocapitalize`, `secure`, `maxLength`, `multiline`, `maxLines`, `minLines`, `submit-label` (behavior) + * - `keyboard`, `autocapitalize`, `secure`, `maxLength`, `multiline`, `maxLines`, `minLines`, `submit-label`, `next-focus` (behavior) * - `prefix`, `suffix`, `leading-icon`, `trailing-icon` (decorations) * - `size` (sm | md | lg) * - `a11y-label`, `a11y-hint` (accessibility) @@ -105,6 +105,9 @@ public function applyAttributes(array $attrs): void if (isset($attrs['submit-label']) || isset($attrs['submitLabel'])) { $this->submitLabel((string) ($attrs['submit-label'] ?? $attrs['submitLabel'])); } + if (isset($attrs['next-focus']) || isset($attrs['nextFocus'])) { + $this->nextFocus((string) ($attrs['next-focus'] ?? $attrs['nextFocus'])); + } if (isset($attrs['maxLines']) || isset($attrs['max-lines'])) { $this->maxLines((int) ($attrs['maxLines'] ?? $attrs['max-lines'])); } @@ -364,6 +367,36 @@ public function submitLabel(string $label): static return $this; } + /** + * Move the keyboard focus to another text input when this one is + * submitted — the "Next" affordance of a multi-field form. `$ref` is + * the target input's `ref` (the same ref `Native::test()` targets); + * the chain is explicit, one hop per field. + * + * When set (and `submitLabel()` isn't), the renderers derive a `next` + * submit label, mirroring how capitalization derives from `keyboard`. + * A missing target at submit time (recycled list row, conditional + * render, typo) is a no-op — focus then follows `keep-focus-on-submit` + * or the platform default. `@submit` still fires first, and the field + * being left commits per `sync_mode` on losing focus, so + * `native:model.blur` bindings see the value before the hop. + * + * An empty ref is treated as unset so Blade can pass a conditional + * (`next-focus="{{ $next }}"`) without special-casing the last field. + * + * Blade: `next-focus` (or `nextFocus`). + */ + public function nextFocus(string $ref): static + { + $ref = trim($ref); + + if ($ref !== '') { + $this->inputProps['next_focus'] = $ref; + } + + return $this; + } + public function maxLines(int $lines): static { $this->inputProps['max_lines'] = $lines; @@ -530,6 +563,14 @@ protected function resolveProps(CallbackRegistry $registry): array { $props = $this->inputProps; + // A field is focus-addressable when its element carries a `ref` — + // surfaced to the renderers as a prop, because the node-level ref + // is not decoded natively. Each platform's focus registry keys the + // field under this name; another input's `next_focus` targets it. + if ($this->elementRef !== null && $this->elementRef !== '') { + $props['focus_ref'] = $this->elementRef; + } + if ($this->changeCallback !== null) { $props['on_change'] = $registry->register($this->changeCallback); } diff --git a/tests/BaseTextInputNextFocusTest.php b/tests/BaseTextInputNextFocusTest.php new file mode 100644 index 0000000..ab030f0 --- /dev/null +++ b/tests/BaseTextInputNextFocusTest.php @@ -0,0 +1,65 @@ +applyAttributes(['next-focus' => 'email']); + + $props = $input->getResolvedProps(new CallbackRegistry); + + expect($props['next_focus'])->toBe('email'); +})->with([ + 'bare' => [BareTextInput::class], + 'filled' => [FilledTextInput::class], + 'outlined' => [OutlinedTextInput::class], +]); + +it('accepts the camelCase attribute spelling', function () { + $input = new FilledTextInput; + $input->applyAttributes(['nextFocus' => 'pin']); + + $props = $input->getResolvedProps(new CallbackRegistry); + + expect($props['next_focus'])->toBe('pin'); +}); + +it('treats an empty ref as unset', function () { + $input = new OutlinedTextInput; + $input->applyAttributes(['next-focus' => ' ']); + + $props = $input->getResolvedProps(new CallbackRegistry); + + expect($props)->not->toHaveKey('next_focus'); +}); + +it('trims the target ref', function () { + $input = new OutlinedTextInput; + $input->nextFocus(' email '); + + $props = $input->getResolvedProps(new CallbackRegistry); + + expect($props['next_focus'])->toBe('email'); +}); + +it('surfaces the element ref as the focus_ref prop', function () { + $input = new OutlinedTextInput; + $input->ref('email'); + $input->applyAttributes(['placeholder' => 'Email']); + + $props = $input->getResolvedProps(new CallbackRegistry); + + expect($props['focus_ref'])->toBe('email'); +}); + +it('does not serialize focus_ref without a ref', function () { + $input = new OutlinedTextInput; + $input->applyAttributes(['placeholder' => 'Email']); + + $props = $input->getResolvedProps(new CallbackRegistry); + + expect($props)->not->toHaveKey('focus_ref'); +}); From 783a181e8183d854e0bfaac1cde968503c087448 Mon Sep 17 00:00:00 2001 From: Eser DENIZ Date: Mon, 24 Aug 2026 22:04:10 +0200 Subject: [PATCH 03/10] Give pad keyboards the missing Next/Done key via a keyboard accessory bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit iOS pad-style keyboards (number, decimal, phone) have no return key, so submit-label, next-focus and @submit were physically unreachable on those fields — a chain of number inputs offered no way to hop to the next one. Show a keyboard accessory button above pad keyboards whenever the field declares a submit affordance. It funnels into the exact same submit path as the return key: flush pending sync, fire @submit, then follow next-focus / keep-focus-on-submit; with neither, it dismisses like Done. Title follows resolveSubmitLabel precedence (explicit prop, then Next for a chain, then Done). Gated on the focused field so stacked fields don't each contribute a button. --- resources/ios/NativeUITextInputCore.swift | 113 ++++++++++++++++------ 1 file changed, 83 insertions(+), 30 deletions(-) diff --git a/resources/ios/NativeUITextInputCore.swift b/resources/ios/NativeUITextInputCore.swift index 17b3461..4b343b9 100644 --- a/resources/ios/NativeUITextInputCore.swift +++ b/resources/ios/NativeUITextInputCore.swift @@ -104,6 +104,60 @@ struct NativeUITextInputCore: View { fontName: fontName ) + // One submit routine for both entry points: the keyboard's return key + // (`.onSubmit`) and the accessory-bar button below. Reading `text` / + // `isFocused` inside resolves the live @State values at call time. + let performSubmit = { + // Submit also acts as a commit point — flush pending, then dispatch. + flushPending(onChangeCb: onChangeCb) + // Selection is flushed BEFORE the submit event so PHP sees the final + // caret/selection state ahead of (or alongside) the submit. + if selectionEnabled { + flushSelection(cb: onSelectionCb) + } + if onSubmitCb != 0 { + NativeElementBridge.sendSubmitEvent(onSubmitCb, nodeId: node.id, text: text) + } + // Focus routing after submit. `next-focus` wins over + // `keep-focus-on-submit` — moving the keyboard to the chained + // field IS keeping it up; keepFocus is only the fallback when the + // target isn't on screen (recycled row, conditional render, + // typo'd ref). Both run async because SwiftUI resigns first + // responder on return before this handler's effects settle. + if !nextFocus.isEmpty { + DispatchQueue.main.async { + if !NativeUIFocusRegistry.shared.focus(nextFocus) && keepFocus { + isFocused = true + } + } + } else if keepFocus { + // Chat "send and keep typing": SwiftUI resigns first responder + // on return by default. Re-assert focus so the keyboard stays + // up. NOTE: this causes a small keyboard "bounce" on return + // (resign → refocus) that the send button doesn't have — the + // smooth fix needs a UIKit-backed field (see notes), not the + // multiline workaround which mis-sized the field in the flex + // layout. + DispatchQueue.main.async { isFocused = true } + } else { + // Accessory-button path: unlike the return key, tapping the + // bar doesn't resign first responder — dismiss explicitly so + // "Done" behaves like Done. No-op on the return-key path + // (focus is already gone by the time this runs). + isFocused = false + } + } + // Pad-style keyboards (number / decimal / phone) have NO return key, + // so the submit label, `next-focus` chain and `@submit` are physically + // unreachable from them. Surface the missing key as a keyboard + // accessory button wired into the same submit path. + let padKeyboard = ["number", "decimal", "numberpassword", "phone"].contains(keyboardKind.lowercased()) + let wantsAccessory = padKeyboard && !multiline + && (onSubmitCb != 0 || !nextFocus.isEmpty || !submitLabelKind.isEmpty) + let accessoryTitle = accessoryButtonTitle( + explicit: submitLabelKind, hasSubmit: onSubmitCb != 0, nextFocus: nextFocus + ) + // Apply `.foregroundColor` (not just `.foregroundStyle`) so the TYPED // text adopts `contentColor`. SwiftUI's TextField/SecureField don't // reliably pick up `.foregroundStyle` for the input text on older @@ -253,37 +307,18 @@ struct NativeUITextInputCore: View { } } .onSubmit { - // Submit also acts as a commit point — flush pending, then dispatch. - flushPending(onChangeCb: onChangeCb) - // Selection is flushed BEFORE the submit event so PHP sees the final - // caret/selection state ahead of (or alongside) the submit. - if selectionEnabled { - flushSelection(cb: onSelectionCb) - } - if onSubmitCb != 0 { - NativeElementBridge.sendSubmitEvent(onSubmitCb, nodeId: node.id, text: text) - } - // Focus routing after submit. `next-focus` wins over - // `keep-focus-on-submit` — moving the keyboard to the chained - // field IS keeping it up; keepFocus is only the fallback when the - // target isn't on screen (recycled row, conditional render, - // typo'd ref). Both run async because SwiftUI resigns first - // responder on return before this handler's effects settle. - if !nextFocus.isEmpty { - DispatchQueue.main.async { - if !NativeUIFocusRegistry.shared.focus(nextFocus) && keepFocus { - isFocused = true - } + performSubmit() + } + .toolbar { + // Content is gated on THIS field's focus: SwiftUI merges keyboard + // toolbars from every view that declares one, so an ungated item + // would render once per field on screen. + ToolbarItemGroup(placement: .keyboard) { + if wantsAccessory && isFocused { + Spacer() + Button(accessoryTitle) { performSubmit() } + .font(.body.weight(.semibold)) } - } else if keepFocus { - // Chat "send and keep typing": SwiftUI resigns first responder - // on return by default. Re-assert focus so the keyboard stays - // up. NOTE: this causes a small keyboard "bounce" on return - // (resign → refocus) that the send button doesn't have — the - // smooth fix needs a UIKit-backed field (see notes), not the - // multiline workaround which mis-sized the field in the flex - // layout. - DispatchQueue.main.async { isFocused = true } } } } @@ -488,6 +523,24 @@ private func resolveSubmitLabel(explicit: String, multiline: Bool, hasSubmit: Bo return hasSubmit ? .done : .return } +/// Title for the keyboard accessory button shown above pad-style keyboards +/// (which have no return key to carry the submit label). Same precedence as +/// `resolveSubmitLabel`: the explicit prop wins, then a `next_focus` chain +/// implies "Next", then `@submit` implies "Done". +private func accessoryButtonTitle(explicit: String, hasSubmit: Bool, nextFocus: String) -> String { + switch explicit.lowercased() { + case "next": return "Next" + case "done": return "Done" + case "go": return "Go" + case "search": return "Search" + case "send": return "Send" + case "return": return "Done" + default: break + } + if !nextFocus.isEmpty { return "Next" } + return "Done" +} + /// Keyboard resolution — accepts string hints ("email", "number", etc.) that /// map to UIKeyboardType. Unknown/empty falls through to default. private func resolveKeyboardType(_ kind: String) -> UIKeyboardType { From a091af98ed15b61817a5e2b5116954130baa4f2a Mon Sep 17 00:00:00 2001 From: Eser DENIZ Date: Mon, 24 Aug 2026 22:33:53 +0200 Subject: [PATCH 04/10] Host keyboard toolbars inside bottom sheets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SwiftUI drops toolbar(placement: .keyboard) items for text fields presented in a bare .sheet — the pad-keyboard accessory bar simply never appeared for inputs inside a . Wrap the sheet content in a NavigationStack with the navigation bar hidden: the stack hosts the toolbar, and the sheet looks exactly as before. --- .../ios/NativeUIBottomSheetRenderer.swift | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/resources/ios/NativeUIBottomSheetRenderer.swift b/resources/ios/NativeUIBottomSheetRenderer.swift index 3a2743f..b3d8414 100644 --- a/resources/ios/NativeUIBottomSheetRenderer.swift +++ b/resources/ios/NativeUIBottomSheetRenderer.swift @@ -30,13 +30,21 @@ struct NativeUIBottomSheetRenderer: View { NativeUIBridge.sendSheetDismissEvent(onDismissCb, nodeId: node.id) } }) { - VStack(spacing: 0) { - ForEach(node.children) { child in - NodeView(node: child).equatable() + // NavigationStack (bar hidden) is the toolbar host: SwiftUI + // drops `.toolbar(placement: .keyboard)` items — the text + // inputs' pad-keyboard accessory bar — for fields presented + // in a bare `.sheet`. Wrapping the content gives them a + // host; with the bar hidden the sheet looks identical. + NavigationStack { + VStack(spacing: 0) { + ForEach(node.children) { child in + NodeView(node: child).equatable() + } } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .background(theme.surface) + .toolbar(.hidden, for: .navigationBar) } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - .background(theme.surface) .presentationDetents(resolveDetents(detentsStr)) .presentationDragIndicator(.visible) .interactiveDismissDisabled(permanent) From f1da910915d68d8302e529b8386c50dee9d91e3a Mon Sep 17 00:00:00 2001 From: Eser DENIZ Date: Mon, 24 Aug 2026 22:33:53 +0200 Subject: [PATCH 05/10] Close the Android keyboard when a submit doesn't chain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit KeyboardActions(onAny) consumes the IME action, which also suppresses its platform default — so Done/Go/Send/Search dispatched the submit but left the keyboard up with nothing else to do. After dispatch, when no next-focus chain takes over, perform the default action so the keyboard closes like the platform (and iOS's return key) does. All three input variants. --- resources/android/BareTextInputRenderer.kt | 9 +++++++++ resources/android/FilledTextInputRenderer.kt | 9 +++++++++ resources/android/OutlinedTextInputRenderer.kt | 9 +++++++++ 3 files changed, 27 insertions(+) diff --git a/resources/android/BareTextInputRenderer.kt b/resources/android/BareTextInputRenderer.kt index 98f79ae..926d90a 100644 --- a/resources/android/BareTextInputRenderer.kt +++ b/resources/android/BareTextInputRenderer.kt @@ -20,6 +20,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.sp import com.nativephp.mobile.ui.nativerender.NativeUINode @@ -167,6 +168,14 @@ object BareTextInputRenderer { // target field. A missing target is a no-op. if (props.nextFocus.isNotEmpty()) { NativeUIFocusRegistry.request(props.nextFocus) + } else { + // Consuming the IME action suppresses its platform + // default, so Done/Go/Send/Search left the keyboard up. + // Restore it — close the keyboard like the platform (and + // iOS's return key) does. Next keeps the keyboard: the + // chain moved it, or the target is gone and there is + // nothing sensible to do. + defaultKeyboardAction(ImeAction.Done) } }) ) diff --git a/resources/android/FilledTextInputRenderer.kt b/resources/android/FilledTextInputRenderer.kt index 0a4bffc..8c1d76b 100644 --- a/resources/android/FilledTextInputRenderer.kt +++ b/resources/android/FilledTextInputRenderer.kt @@ -24,6 +24,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp import com.nativephp.mobile.ui.nativerender.NativeUINode @@ -156,6 +157,14 @@ object FilledTextInputRenderer { // target field. A missing target is a no-op. if (props.nextFocus.isNotEmpty()) { NativeUIFocusRegistry.request(props.nextFocus) + } else { + // Consuming the IME action suppresses its platform + // default, so Done/Go/Send/Search left the keyboard up. + // Restore it — close the keyboard like the platform (and + // iOS's return key) does. Next keeps the keyboard: the + // chain moved it, or the target is gone and there is + // nothing sensible to do. + defaultKeyboardAction(ImeAction.Done) } }), textStyle = TextStyle(fontSize = textSize, color = theme.onSurface, fontFamily = customFontFamily, lineHeight = lineHeight), diff --git a/resources/android/OutlinedTextInputRenderer.kt b/resources/android/OutlinedTextInputRenderer.kt index cd42152..eebb94f 100644 --- a/resources/android/OutlinedTextInputRenderer.kt +++ b/resources/android/OutlinedTextInputRenderer.kt @@ -24,6 +24,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp import com.nativephp.mobile.ui.nativerender.NativeUINode @@ -169,6 +170,14 @@ object OutlinedTextInputRenderer { // target field. A missing target is a no-op. if (props.nextFocus.isNotEmpty()) { NativeUIFocusRegistry.request(props.nextFocus) + } else { + // Consuming the IME action suppresses its platform + // default, so Done/Go/Send/Search left the keyboard up. + // Restore it — close the keyboard like the platform (and + // iOS's return key) does. Next keeps the keyboard: the + // chain moved it, or the target is gone and there is + // nothing sensible to do. + defaultKeyboardAction(ImeAction.Done) } }), textStyle = TextStyle(fontSize = textSize, color = theme.onSurface, fontFamily = customFontFamily, lineHeight = lineHeight), From 3c9889367920a7b649633c0622c2adfb05e6655e Mon Sep 17 00:00:00 2001 From: Eser DENIZ Date: Mon, 24 Aug 2026 22:48:44 +0200 Subject: [PATCH 06/10] Replace the keyboard toolbar accessory with a themed safe-area bar; add autofocus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pad-keyboard Next/Done accessory moved off .toolbar(placement: .keyboard): iOS 26 renders those items as a floating Liquid Glass capsule sitting on the keyboard (and drops them from accessibility on 26.1), and SwiftUI never shows them inside a bare .sheet at all — the NavigationStack workaround is gone with it. Instead the focused field claims a shared accessory state and a host pins a full-width, theme-colored bar above the keyboard with safeAreaInset — Apple's own recommended shape for this affordance. Hosts: the bottom-sheet content root, and every screen root via NativeRootHostRegistry (which yields while a sheet is up). The bar's action is refreshed from the focused field's body every render, so a republish that changes the chain mid-entry never leaves a stale target. Also adds an opt-in autofocus prop on all three input variants (iOS + Android): focus the field and raise the keyboard once per appearance — the opening field of a form. A re-render that moves the attribute to an already-mounted field never steals focus. --- resources/android/BareTextInputRenderer.kt | 2 +- resources/android/FilledTextInputRenderer.kt | 2 +- .../android/OutlinedTextInputRenderer.kt | 2 +- resources/android/TextInputShared.kt | 16 ++- .../ios/NativeUIBottomSheetRenderer.swift | 14 +-- resources/ios/NativeUIDrawerHost.swift | 8 ++ .../ios/NativeUIKeyboardAccessoryBar.swift | 119 ++++++++++++++++++ resources/ios/NativeUITextInputCore.swift | 66 ++++++---- src/Elements/BaseTextInput.php | 20 +++ tests/BaseTextInputAutofocusTest.php | 36 ++++++ 10 files changed, 253 insertions(+), 32 deletions(-) create mode 100644 resources/ios/NativeUIKeyboardAccessoryBar.swift create mode 100644 tests/BaseTextInputAutofocusTest.php diff --git a/resources/android/BareTextInputRenderer.kt b/resources/android/BareTextInputRenderer.kt index 926d90a..01fd9bb 100644 --- a/resources/android/BareTextInputRenderer.kt +++ b/resources/android/BareTextInputRenderer.kt @@ -81,7 +81,7 @@ object BareTextInputRenderer { var value by remember { mutableStateOf(TextFieldValue(props.serverValue, TextRange(props.serverValue.length))) } var lastSentValue by remember { mutableStateOf(props.serverValue) } var wasFocused by remember { mutableStateOf(false) } - val focusRequester = rememberRegisteredFocusRequester(props.focusRef) + val focusRequester = rememberRegisteredFocusRequester(props.focusRef, props.autofocus) // Caret / selection reporter — independent of the direct change/submit // dispatchers; no-op unless `on_selection_change` is wired and the diff --git a/resources/android/FilledTextInputRenderer.kt b/resources/android/FilledTextInputRenderer.kt index 8c1d76b..44b9fb5 100644 --- a/resources/android/FilledTextInputRenderer.kt +++ b/resources/android/FilledTextInputRenderer.kt @@ -81,7 +81,7 @@ object FilledTextInputRenderer { } val interactionSource = remember { MutableInteractionSource() } - val focusRequester = rememberRegisteredFocusRequester(props.focusRef) + val focusRequester = rememberRegisteredFocusRequester(props.focusRef, props.autofocus) LaunchedEffect(interactionSource) { val focusStack = mutableListOf() interactionSource.interactions.collect { interaction: Interaction -> diff --git a/resources/android/OutlinedTextInputRenderer.kt b/resources/android/OutlinedTextInputRenderer.kt index eebb94f..9d7d983 100644 --- a/resources/android/OutlinedTextInputRenderer.kt +++ b/resources/android/OutlinedTextInputRenderer.kt @@ -93,7 +93,7 @@ object OutlinedTextInputRenderer { // modes. Passing our own source also means we don't pay for M3's // default ripple-focus-hover machinery elsewhere. val interactionSource = remember { MutableInteractionSource() } - val focusRequester = rememberRegisteredFocusRequester(props.focusRef) + val focusRequester = rememberRegisteredFocusRequester(props.focusRef, props.autofocus) LaunchedEffect(interactionSource) { val focusStack = mutableListOf() interactionSource.interactions.collect { interaction: Interaction -> diff --git a/resources/android/TextInputShared.kt b/resources/android/TextInputShared.kt index d11bb75..c52813d 100644 --- a/resources/android/TextInputShared.kt +++ b/resources/android/TextInputShared.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester @@ -59,6 +60,7 @@ internal data class TextInputProps( val submitLabel: String, val focusRef: String, val nextFocus: String, + val autofocus: Boolean, val disabled: Boolean, val readOnly: Boolean, val isError: Boolean, @@ -126,6 +128,7 @@ internal fun parseTextInputProps(node: NativeUINode): TextInputProps { submitLabel = p.getString("submit_label"), focusRef = p.getString("focus_ref"), nextFocus = p.getString("next_focus"), + autofocus = p.getBool("autofocus"), disabled = p.getBool("disabled"), readOnly = p.getBool("read_only"), isError = p.getBool("is_error"), @@ -316,7 +319,7 @@ internal object NativeUIFocusRegistry { * plain, unregistered requester. */ @Composable -internal fun rememberRegisteredFocusRequester(focusRef: String): FocusRequester { +internal fun rememberRegisteredFocusRequester(focusRef: String, autofocus: Boolean = false): FocusRequester { val requester = remember { FocusRequester() } DisposableEffect(focusRef) { if (focusRef.isNotEmpty()) { @@ -328,6 +331,17 @@ internal fun rememberRegisteredFocusRequester(focusRef: String): FocusRequester } } } + // `autofocus`: focus (and raise the IME on) the field the user came to + // fill, once per composition — a re-render that moves the attribute to + // an already-composed field never steals focus mid-edit. The short + // delay lets the host (screen push, bottom sheet) finish laying out; + // requesting focus on an unplaced node is silently dropped. + if (autofocus) { + LaunchedEffect(Unit) { + delay(200) + runCatching { requester.requestFocus() } + } + } return requester } diff --git a/resources/ios/NativeUIBottomSheetRenderer.swift b/resources/ios/NativeUIBottomSheetRenderer.swift index b3d8414..e3b6207 100644 --- a/resources/ios/NativeUIBottomSheetRenderer.swift +++ b/resources/ios/NativeUIBottomSheetRenderer.swift @@ -30,12 +30,11 @@ struct NativeUIBottomSheetRenderer: View { NativeUIBridge.sendSheetDismissEvent(onDismissCb, nodeId: node.id) } }) { - // NavigationStack (bar hidden) is the toolbar host: SwiftUI - // drops `.toolbar(placement: .keyboard)` items — the text - // inputs' pad-keyboard accessory bar — for fields presented - // in a bare `.sheet`. Wrapping the content gives them a - // host; with the bar hidden the sheet looks identical. - NavigationStack { + // The sheet hosts its own keyboard accessory bar — the + // screen-root host's bar sits BEHIND a presented sheet, so + // fields inside the sheet publish to this copy instead + // (`sheetDepth` makes the root host yield while we're up). + NativeUIKeyboardAccessoryHost { VStack(spacing: 0) { ForEach(node.children) { child in NodeView(node: child).equatable() @@ -43,8 +42,9 @@ struct NativeUIBottomSheetRenderer: View { } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .background(theme.surface) - .toolbar(.hidden, for: .navigationBar) } + .onAppear { NativeUIKeyboardAccessoryState.shared.sheetDepth += 1 } + .onDisappear { NativeUIKeyboardAccessoryState.shared.sheetDepth -= 1 } .presentationDetents(resolveDetents(detentsStr)) .presentationDragIndicator(.visible) .interactiveDismissDisabled(permanent) diff --git a/resources/ios/NativeUIDrawerHost.swift b/resources/ios/NativeUIDrawerHost.swift index edfa3ae..eb0d753 100644 --- a/resources/ios/NativeUIDrawerHost.swift +++ b/resources/ios/NativeUIDrawerHost.swift @@ -21,6 +21,14 @@ func registerNativeUIChrome() { return AnyView(NativeUIBackgroundLayerHost(layerNode: layerNode) { content }) } + // Keyboard accessory bar for pad-keyboard text inputs on plain screens + // (fields inside a bottom sheet use the sheet renderer's own host — a + // root-level bar would sit behind the sheet). No sentinel element: + // the host is driven by the shared focus state, not the tree. + NativeRootHostRegistry.shared.register("native-ui.keyboard-accessory") { _, content in + AnyView(NativeUIKeyboardAccessoryHost(hidesUnderSheets: true) { content }) + } + // Resolve chrome font tokens (per-layout / per-bar `font_name` props on // the root sentinels) for core's chrome renderers — bundle lookup + // CoreText registration + PostScript naming is this plugin's knowledge. diff --git a/resources/ios/NativeUIKeyboardAccessoryBar.swift b/resources/ios/NativeUIKeyboardAccessoryBar.swift new file mode 100644 index 0000000..27836fb --- /dev/null +++ b/resources/ios/NativeUIKeyboardAccessoryBar.swift @@ -0,0 +1,119 @@ +import SwiftUI + +/// The Next/Done affordance for pad-style keyboards (number / decimal / +/// phone), which have no return key of their own. +/// +/// This replaces a `.toolbar(placement: .keyboard)` implementation. iOS 26 +/// renders keyboard-placement toolbar items as a floating Liquid Glass +/// capsule sitting directly on the keyboard (and drops them from the +/// accessibility tree on 26.1), UIKit's `inputAccessoryView` no longer +/// attaches seamlessly to the redesigned keyboard, and SwiftUI never shows +/// keyboard toolbars inside a bare `.sheet` at all. Apple's own guidance for +/// the padding complaints is to show the affordance as a bottom safe-area +/// bar while the field is focused — which is exactly what this does: the +/// focused field publishes its submit affordance to the shared state below, +/// and a HOST (the bottom-sheet content root, or the screen root via +/// `NativeRootHostRegistry`) pins a full-width, theme-colored bar above the +/// keyboard with `.safeAreaInset(edge: .bottom)`. Keyboard avoidance keeps +/// it riding the keyboard's top edge on every iOS version, with no glass. +final class NativeUIKeyboardAccessoryState: ObservableObject { + static let shared = NativeUIKeyboardAccessoryState() + + /// The bar's visible state — nil hides it. Only identity and title are + /// published; the ACTION lives in `perform` (below, non-published) so + /// the focused field can refresh it on every render without triggering + /// view updates. It can genuinely change mid-focus: toggling the Yaniv + /// caller re-renders the focused field with a different `next_focus`. + struct Info: Equatable { + let id: UUID + let title: String + } + + @Published private(set) var info: Info? + + /// Presented bottom sheets. The screen-root host hides its bar while a + /// sheet is up — the sheet hosts its own copy, and the covered screen + /// shouldn't inset for a bar nobody can see. + @Published var sheetDepth: Int = 0 + + private var perform: () -> Void = {} + + /// Called when a field gains focus (or its accessory title changes). + func claim(id: UUID, title: String, perform: @escaping () -> Void) { + self.perform = perform + if info?.id != id || info?.title != title { + info = Info(id: id, title: title) + } + } + + /// Refresh the action from the focused field's body on every render. + /// The closure swap is silent; a title change republishes async (body + /// must not mutate published state synchronously). + func refresh(id: UUID, title: String, perform: @escaping () -> Void) { + guard info?.id == id else { return } + self.perform = perform + if info?.title != title { + DispatchQueue.main.async { [weak self] in + guard let self, self.info?.id == id else { return } + self.info = Info(id: id, title: title) + } + } + } + + /// Called on blur / disappear. Identity-guarded: a field losing focus + /// because ANOTHER field claimed the bar must not tear the new claim + /// down (focus handoff order is not guaranteed). + func release(id: UUID) { + guard info?.id == id else { return } + info = nil + perform = {} + } + + func submit() { + perform() + } +} + +/// Wraps a content root and pins the accessory bar above the keyboard while +/// a field has claimed it. Transparent pass-through (no inset, no cost) +/// while no claim is active. +struct NativeUIKeyboardAccessoryHost: View { + /// Set on the screen-root instance — its bar yields while any bottom + /// sheet is presented (the sheet's own host takes over). + var hidesUnderSheets: Bool = false + @ViewBuilder var content: Content + + @ObservedObject private var state = NativeUIKeyboardAccessoryState.shared + @ObservedObject private var themeStore = NativeUITheme.shared + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + content.safeAreaInset(edge: .bottom, spacing: 0) { + if let info = state.info, !(hidesUnderSheets && state.sheetDepth > 0) { + let theme = themeStore.resolve(for: colorScheme) + HStack { + Spacer() + Button { + state.submit() + } label: { + Text(info.title) + .font(.body.weight(.semibold)) + .foregroundStyle(theme.primary) + // A generous hit target on a 44pt bar. + .padding(.horizontal, 4) + .frame(minHeight: 44) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 16) + .frame(maxWidth: .infinity, minHeight: 44) + .background(theme.surface) + .overlay(alignment: .top) { + Rectangle() + .fill(theme.outlineVariant) + .frame(height: 0.5) + } + } + } + } +} diff --git a/resources/ios/NativeUITextInputCore.swift b/resources/ios/NativeUITextInputCore.swift index 4b343b9..5b41eff 100644 --- a/resources/ios/NativeUITextInputCore.swift +++ b/resources/ios/NativeUITextInputCore.swift @@ -52,6 +52,10 @@ struct NativeUITextInputCore: View { // `NativeUIFocusRegistry`); nil when the element carries no ref. @State private var focusRegistryToken: UUID? = nil + // Stable identity of this field's claim on the shared keyboard + // accessory bar (pad keyboards only — see NativeUIKeyboardAccessoryBar). + @State private var accessoryToken = UUID() + var body: some View { let p = node.props let placeholder = p.getString("placeholder") @@ -149,14 +153,18 @@ struct NativeUITextInputCore: View { } // Pad-style keyboards (number / decimal / phone) have NO return key, // so the submit label, `next-focus` chain and `@submit` are physically - // unreachable from them. Surface the missing key as a keyboard - // accessory button wired into the same submit path. + // unreachable from them. Surface the missing key as the shared + // accessory BAR above the keyboard (NativeUIKeyboardAccessoryBar): + // this field claims the bar while focused and hands it the same + // submit path as the return key. Refreshed from body every render so + // the action never goes stale while focused. let padKeyboard = ["number", "decimal", "numberpassword", "phone"].contains(keyboardKind.lowercased()) let wantsAccessory = padKeyboard && !multiline && (onSubmitCb != 0 || !nextFocus.isEmpty || !submitLabelKind.isEmpty) let accessoryTitle = accessoryButtonTitle( explicit: submitLabelKind, hasSubmit: onSubmitCb != 0, nextFocus: nextFocus ) + let _ = refreshAccessoryClaim(wantsAccessory, title: accessoryTitle, perform: performSubmit) // Apply `.foregroundColor` (not just `.foregroundStyle`) so the TYPED // text adopts `contentColor`. SwiftUI's TextField/SecureField don't @@ -233,12 +241,24 @@ struct NativeUITextInputCore: View { binding.wrappedValue = true } } + // `autofocus`: raise the keyboard on the field the user came to + // fill. Fires per appearance (a fresh sheet presentation is a + // fresh appearance); a re-render that moves the prop to an + // already-mounted field never steals focus. The delay lets a + // presenting sheet's animation settle — focusing mid-transition + // is silently dropped by SwiftUI. + if p.getBool("autofocus") { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { + isFocused = true + } + } } .onDisappear { if let token = focusRegistryToken, !focusRef.isEmpty { NativeUIFocusRegistry.shared.unregister(focusRef, token: token) focusRegistryToken = nil } + NativeUIKeyboardAccessoryState.shared.release(id: accessoryToken) } .onChange(of: serverValue) { _, newServerValue in // Only sync from server when the incoming value differs from what @@ -293,34 +313,38 @@ struct NativeUITextInputCore: View { scheduleSelectionEmit(text: text, cb: onSelectionCb, debounceMs: selDebounceMs) } .onChange(of: isFocused) { _, focused in + if focused { + if wantsAccessory { + NativeUIKeyboardAccessoryState.shared.claim( + id: accessoryToken, title: accessoryTitle, perform: performSubmit + ) + } + return + } // On blur, flush any pending change — covers both `blur` mode // (never dispatched mid-typing) and `debounce` mode (in-flight // timer that should commit immediately rather than race with // focus loss / keyboard dismiss). - if !focused { - flushPending(onChangeCb: onChangeCb) - // Flush any coalesced selection emit immediately on blur so the - // final caret state isn't stranded in the debounce window. - if selectionEnabled { - flushSelection(cb: onSelectionCb) - } + flushPending(onChangeCb: onChangeCb) + // Flush any coalesced selection emit immediately on blur so the + // final caret state isn't stranded in the debounce window. + if selectionEnabled { + flushSelection(cb: onSelectionCb) } + NativeUIKeyboardAccessoryState.shared.release(id: accessoryToken) } .onSubmit { performSubmit() } - .toolbar { - // Content is gated on THIS field's focus: SwiftUI merges keyboard - // toolbars from every view that declares one, so an ungated item - // would render once per field on screen. - ToolbarItemGroup(placement: .keyboard) { - if wantsAccessory && isFocused { - Spacer() - Button(accessoryTitle) { performSubmit() } - .font(.body.weight(.semibold)) - } - } - } + } + + /// Body-time refresh of this field's accessory-bar claim — keeps the + /// bar's action and title current while focused (props can change under + /// a focused field when PHP republishes). No-op unless focused and + /// accessory-worthy; the state object defers any published change. + private func refreshAccessoryClaim(_ wants: Bool, title: String, perform: @escaping () -> Void) { + guard wants, isFocused else { return } + NativeUIKeyboardAccessoryState.shared.refresh(id: accessoryToken, title: title, perform: perform) } // ─── Dispatch policy ───────────────────────────────────────────────────── diff --git a/src/Elements/BaseTextInput.php b/src/Elements/BaseTextInput.php index f52efce..db5b322 100644 --- a/src/Elements/BaseTextInput.php +++ b/src/Elements/BaseTextInput.php @@ -102,6 +102,9 @@ public function applyAttributes(array $attrs): void if (! empty($attrs['keepFocusOnSubmit']) || ! empty($attrs['keep-focus-on-submit']) || ! empty($attrs['keep-focus'])) { $this->keepFocusOnSubmit(); } + if (! empty($attrs['autofocus'])) { + $this->autofocus(); + } if (isset($attrs['submit-label']) || isset($attrs['submitLabel'])) { $this->submitLabel((string) ($attrs['submit-label'] ?? $attrs['submitLabel'])); } @@ -386,6 +389,23 @@ public function submitLabel(string $label): static * * Blade: `next-focus` (or `nextFocus`). */ + /** + * Focus this input (and raise the keyboard) when it first appears — + * the opening field of a form the user came here to fill. Fires once + * per appearance, only on mount: a re-render that moves the attribute + * to an already-mounted field never steals focus mid-edit. + * + * Blade: `autofocus` / `:autofocus="$bool"`. + */ + public function autofocus(bool $value = true): static + { + if ($value) { + $this->inputProps['autofocus'] = true; + } + + return $this; + } + public function nextFocus(string $ref): static { $ref = trim($ref); diff --git a/tests/BaseTextInputAutofocusTest.php b/tests/BaseTextInputAutofocusTest.php new file mode 100644 index 0000000..121c088 --- /dev/null +++ b/tests/BaseTextInputAutofocusTest.php @@ -0,0 +1,36 @@ +applyAttributes(['autofocus' => true]); + + $props = $input->getResolvedProps(new CallbackRegistry); + + expect($props['autofocus'])->toBeTrue(); +})->with([ + 'bare' => [BareTextInput::class], + 'filled' => [FilledTextInput::class], + 'outlined' => [OutlinedTextInput::class], +]); + +it('is absent when not requested', function () { + $input = new BareTextInput; + + $props = $input->getResolvedProps(new CallbackRegistry); + + expect($props)->not->toHaveKey('autofocus'); +}); + +it('is absent when the bound expression is false', function () { + $input = new BareTextInput; + $input->applyAttributes(['autofocus' => false]); + + $props = $input->getResolvedProps(new CallbackRegistry); + + expect($props)->not->toHaveKey('autofocus'); +}); From 2469887545f8c58be9fc652177d10b6f0d42cef1 Mon Sep 17 00:00:00 2001 From: Eser DENIZ Date: Mon, 24 Aug 2026 22:59:51 +0200 Subject: [PATCH 07/10] Hop next-focus synchronously first so the keyboard stays up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chain hop ran only async: the return key resigns first responder (keyboard starts dismissing), one runloop later the target focuses and the keyboard comes back — a visible down-and-up bounce on every Next. Focusing the target synchronously inside the submit handler moves focus within the same transaction, which the system treats as focus moving between fields — keyboard stays up, like UIKit's becomeFirstResponder-in-shouldReturn. The async pass stays as a safety net: where the system's resign still wins, it re-asserts focus exactly as before (bounce, but never a lost keyboard). Both writes are idempotent. --- resources/ios/NativeUITextInputCore.swift | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/resources/ios/NativeUITextInputCore.swift b/resources/ios/NativeUITextInputCore.swift index 5b41eff..4e4c074 100644 --- a/resources/ios/NativeUITextInputCore.swift +++ b/resources/ios/NativeUITextInputCore.swift @@ -126,9 +126,20 @@ struct NativeUITextInputCore: View { // `keep-focus-on-submit` — moving the keyboard to the chained // field IS keeping it up; keepFocus is only the fallback when the // target isn't on screen (recycled row, conditional render, - // typo'd ref). Both run async because SwiftUI resigns first - // responder on return before this handler's effects settle. + // typo'd ref). + // + // The hop runs TWICE. Synchronously first: focus moving to the + // target inside the same transaction as the return key's resign + // reads as focus MOVING between fields, so the keyboard stays up + // instead of playing a down-and-back-up bounce (what UIKit's + // becomeFirstResponder-in-shouldReturn always did). Then again + // async as a safety net: on paths where the system's resign + // still wins after this handler returns, the re-assert restores + // focus exactly as the async-only version did — a bounce, but + // never a lost keyboard. Focusing an already-focused field is a + // no-op, so the second pass costs nothing when the first stuck. if !nextFocus.isEmpty { + NativeUIFocusRegistry.shared.focus(nextFocus) DispatchQueue.main.async { if !NativeUIFocusRegistry.shared.focus(nextFocus) && keepFocus { isFocused = true From 7bd2957ab21436cc4d4c4d237f3346e67639a18d Mon Sep 17 00:00:00 2001 From: Eser DENIZ Date: Mon, 24 Aug 2026 23:04:41 +0200 Subject: [PATCH 08/10] Chain focus through a screen-scoped shared FocusState so the keyboard never dips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two independent per-field @FocusState bools can never chain cleanly: SwiftUI processes the old field's resign and the new field's focus as separate operations, so every Next hop played a keyboard down-and-back-up bounce regardless of timing (sync or async). The accessory host (screen root and bottom-sheet root) now owns one FocusState for its whole subtree, injected through the environment; every text input adds a .focused(scope, equals: key) binding alongside its local bool. A chained hop writes the target's key into that one shared value — an atomic focus MOVE, the Focus Cookbook enum recipe — and the keyboard stays up like UIKit's becomeFirstResponder-in-shouldReturn. The local bool still drives each field's own logic and remains the fallback when no host wraps the context. --- .../ios/NativeUIKeyboardAccessoryBar.swift | 36 ++++++++++++++-- resources/ios/NativeUITextInputCore.swift | 42 ++++++++++++++++++- 2 files changed, 73 insertions(+), 5 deletions(-) diff --git a/resources/ios/NativeUIKeyboardAccessoryBar.swift b/resources/ios/NativeUIKeyboardAccessoryBar.swift index 27836fb..df422ad 100644 --- a/resources/ios/NativeUIKeyboardAccessoryBar.swift +++ b/resources/ios/NativeUIKeyboardAccessoryBar.swift @@ -74,9 +74,30 @@ final class NativeUIKeyboardAccessoryState: ObservableObject { } } -/// Wraps a content root and pins the accessory bar above the keyboard while -/// a field has claimed it. Transparent pass-through (no inset, no cost) -/// while no claim is active. +/// The screen-scoped focus state text inputs share — one `String?` of the +/// focused field's key, injected by the host below. +/// +/// Two independent per-field `@FocusState` bools can never chain without a +/// keyboard bounce: SwiftUI processes the old field's resign and the new +/// field's focus as separate operations, so the keyboard dips down and back +/// up on every hop regardless of timing. With ONE shared value the hop is a +/// single atomic write — SwiftUI treats it as focus MOVING between fields +/// (the Focus Cookbook enum recipe) and the keyboard stays up, exactly like +/// UIKit's becomeFirstResponder-in-shouldReturn. +private struct NativeUIFocusScopeKey: EnvironmentKey { + static let defaultValue: FocusState.Binding? = nil +} + +extension EnvironmentValues { + var nativeUIFocusScope: FocusState.Binding? { + get { self[NativeUIFocusScopeKey.self] } + set { self[NativeUIFocusScopeKey.self] = newValue } + } +} + +/// Wraps a content root, owns the screen's shared focus scope, and pins the +/// accessory bar above the keyboard while a field has claimed it. +/// Transparent pass-through (no inset, no cost) while no claim is active. struct NativeUIKeyboardAccessoryHost: View { /// Set on the screen-root instance — its bar yields while any bottom /// sheet is presented (the sheet's own host takes over). @@ -87,8 +108,15 @@ struct NativeUIKeyboardAccessoryHost: View { @ObservedObject private var themeStore = NativeUITheme.shared @Environment(\.colorScheme) private var colorScheme + /// The shared focus scope for every text input under this host. The + /// sheet host shadows the root host's scope for sheet content — chains + /// never cross a presentation boundary. + @FocusState private var focusedKey: String? + var body: some View { - content.safeAreaInset(edge: .bottom, spacing: 0) { + content + .environment(\.nativeUIFocusScope, $focusedKey) + .safeAreaInset(edge: .bottom, spacing: 0) { if let info = state.info, !(hidesUnderSheets && state.sheetDepth > 0) { let theme = themeStore.resolve(for: colorScheme) HStack { diff --git a/resources/ios/NativeUITextInputCore.swift b/resources/ios/NativeUITextInputCore.swift index 4e4c074..7699f92 100644 --- a/resources/ios/NativeUITextInputCore.swift +++ b/resources/ios/NativeUITextInputCore.swift @@ -56,6 +56,14 @@ struct NativeUITextInputCore: View { // accessory bar (pad keyboards only — see NativeUIKeyboardAccessoryBar). @State private var accessoryToken = UUID() + // Screen-scoped shared focus (see NativeUIFocusScopeKey): one String? of + // the focused field's key for the whole screen, so a next-focus hop is a + // single atomic write — SwiftUI then MOVES focus instead of resigning + // and re-acquiring, and the keyboard stays up. `focusKey` is this + // field's identity in that scope; a @State UUID survives republishes. + @Environment(\.nativeUIFocusScope) private var focusScope + @State private var focusKey = UUID().uuidString + var body: some View { let p = node.props let placeholder = p.getString("placeholder") @@ -227,6 +235,11 @@ struct NativeUITextInputCore: View { } } .nuiScaledFont(size: textSize, fontName: fontName.isEmpty ? nil : fontName) + // Second focus binding, into the screen's shared scope. Coexists + // with the per-field bool above: the system keeps both in sync, the + // bool drives this field's own logic, the scope makes cross-field + // hops atomic. + .modifier(SharedFocusScopeModifier(key: focusKey)) // NOTE: SwiftUI's editable TextField ignores `.lineSpacing` for its // typed text (unlike `Text`), so `leading-*` has no visible effect on // iOS inputs. Kept for intent / forward-compat; leading works on @@ -245,10 +258,18 @@ struct NativeUITextInputCore: View { initialized = true } // Make this field focus-addressable. Capturing the FocusState - // binding keeps the registry free of any view reference. + // bindings keeps the registry free of any view reference. The + // scope write is what makes a chained hop bounce-free (one + // atomic focus move); the bool write is the fallback when no + // host provides a scope, and a harmless no-op otherwise. if !focusRef.isEmpty { let binding = $isFocused + let scope = focusScope + let key = focusKey focusRegistryToken = NativeUIFocusRegistry.shared.register(focusRef) { + if let scope { + scope.wrappedValue = key + } binding.wrappedValue = true } } @@ -349,6 +370,25 @@ struct NativeUITextInputCore: View { } } + // ─── Shared focus scope ────────────────────────────────────────────────── + + /// Applies the screen's shared focus binding when a host provides one. + /// Without a host (no accessory host wrapping this context) the field + /// runs on its per-field bool alone, exactly as before. + private struct SharedFocusScopeModifier: ViewModifier { + let key: String + + @Environment(\.nativeUIFocusScope) private var scope + + func body(content: Content) -> some View { + if let scope { + content.focused(scope, equals: key) + } else { + content + } + } + } + /// Body-time refresh of this field's accessory-bar claim — keeps the /// bar's action and title current while focused (props can change under /// a focused field when PHP republishes). No-op unless focused and From 1fc2b07422a181bc860de9e04783f0ee636fddd3 Mon Sep 17 00:00:00 2001 From: Eser DENIZ Date: Mon, 24 Aug 2026 23:10:22 +0200 Subject: [PATCH 09/10] Hold the keyboard through next-focus hops with an invisible UIKit bridge responder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Even a screen-scoped shared FocusState written atomically inside onSubmit still dips the keyboard on device — SwiftUI processes the return key's resign and the target's focus as separate operations and will not coalesce them across a submit. UIKit never dismisses the keyboard while SOME responder holds it, so bridge the gap the UIKit way: a zero-frame off-screen UITextField becomes first responder synchronously at hop start (handoff, keyboard stays), SwiftUI focuses the target on its own schedule (handoff, keyboard stays), and the bridge cleans up. A grace-period settle resigns the bridge if the target never claims focus, so a broken chain still drops the keyboard instead of stranding typing in an invisible field. Keyboard type and autocorrect mirror the source field so the held keyboard keeps its layout. (cherry picked from commit 9b854ac631a665a5bc6728b5b6c171ab063149a5) --- resources/ios/NativeUIKeyboardBridge.swift | 73 ++++++++++++++++++++++ resources/ios/NativeUITextInputCore.swift | 6 ++ 2 files changed, 79 insertions(+) create mode 100644 resources/ios/NativeUIKeyboardBridge.swift diff --git a/resources/ios/NativeUIKeyboardBridge.swift b/resources/ios/NativeUIKeyboardBridge.swift new file mode 100644 index 0000000..a99dec3 --- /dev/null +++ b/resources/ios/NativeUIKeyboardBridge.swift @@ -0,0 +1,73 @@ +import SwiftUI +import UIKit + +/// Holds the keyboard up through a `next-focus` hop. +/// +/// SwiftUI cannot chain focus between two text fields without a visible +/// keyboard dip: the return key's resign and the target's focus are +/// processed as separate operations — even a screen-scoped shared +/// FocusState written atomically inside `onSubmit` still dips (verified +/// on device; the engine does not coalesce across a submit). UIKit never +/// had this problem, because the keyboard only dismisses when NO +/// responder holds it — `becomeFirstResponder` inside +/// `textFieldShouldReturn` is a responder HANDOFF. +/// +/// So do exactly that. At hop start an invisible, zero-frame `UITextField` +/// takes first responder synchronously (handoff #1 — keyboard stays up), +/// SwiftUI then focuses the target on its own schedule (handoff #2 — +/// keyboard stays up), and the bridge removes itself. If the target never +/// claims focus (recycled row, conditional render, typo'd ref) the bridge +/// resigns after a grace period, dropping the keyboard exactly like a +/// failed hop always did — the user is never left typing into an +/// invisible field. +final class NativeUIKeyboardBridge { + static let shared = NativeUIKeyboardBridge() + + private var field: UITextField? + + /// Bumped on every `hold` so an in-flight `settle` from a previous hop + /// can never tear down a newer hold (fast serial hops). + private var generation = 0 + + private init() {} + + /// Take first responder synchronously. Keyboard type and autocorrect + /// mirror the SOURCE field so the held keyboard keeps its exact layout + /// (chains are homogeneous in practice; a differing target just morphs + /// on handoff #2, as it would anyway). + func hold(keyboardType: UIKeyboardType, autocorrect: Bool) { + generation += 1 + guard let window = keyWindow() else { return } + let f = field ?? UITextField(frame: .zero) + f.keyboardType = keyboardType + f.autocorrectionType = autocorrect ? .yes : .no + f.spellCheckingType = autocorrect ? .yes : .no + field = f + if f.superview !== window { + window.addSubview(f) + } + f.becomeFirstResponder() + } + + /// Schedule the post-hop cleanup: if the bridge still holds first + /// responder after the grace period the target never took over — let + /// the keyboard go. Either way the field leaves the window. + func settle(after delay: TimeInterval = 0.35) { + let gen = generation + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in + guard let self, self.generation == gen, let f = self.field else { return } + if f.isFirstResponder { + f.resignFirstResponder() + } + f.removeFromSuperview() + self.field = nil + } + } + + private func keyWindow() -> UIWindow? { + UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .flatMap { $0.windows } + .first { $0.isKeyWindow } + } +} diff --git a/resources/ios/NativeUITextInputCore.swift b/resources/ios/NativeUITextInputCore.swift index 7699f92..964daf6 100644 --- a/resources/ios/NativeUITextInputCore.swift +++ b/resources/ios/NativeUITextInputCore.swift @@ -147,12 +147,18 @@ struct NativeUITextInputCore: View { // never a lost keyboard. Focusing an already-focused field is a // no-op, so the second pass costs nothing when the first stuck. if !nextFocus.isEmpty { + // The bridge takes first responder BEFORE focus moves, so + // UIKit sees back-to-back responder handoffs and the + // keyboard never dismisses — see NativeUIKeyboardBridge for + // why no SwiftUI-only ordering can achieve this. + NativeUIKeyboardBridge.shared.hold(keyboardType: keyboard, autocorrect: autocorrect) NativeUIFocusRegistry.shared.focus(nextFocus) DispatchQueue.main.async { if !NativeUIFocusRegistry.shared.focus(nextFocus) && keepFocus { isFocused = true } } + NativeUIKeyboardBridge.shared.settle() } else if keepFocus { // Chat "send and keep typing": SwiftUI resigns first responder // on return by default. Re-assert focus so the keyboard stays From 1c275b58c38f6d219318e63957259e3cde2b5ce3 Mon Sep 17 00:00:00 2001 From: Eser DENIZ Date: Mon, 24 Aug 2026 23:35:08 +0200 Subject: [PATCH 10/10] =?UTF-8?q?Intercept=20the=20return=20key=20on=20the?= =?UTF-8?q?=20backing=20UITextField=20=E2=80=94=20the=20dip-free=20chain?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deep research (web + code audit) landed on the real mechanism: SwiftUI queues the keyboard dismissal as part of handling the return key ITSELF, before .onSubmit runs, and since iOS 17 the keyboard is out-of-process so that queued hide cannot be cancelled by anything the submit handler does — sync focus writes, a screen-scoped shared FocusState, and a UIKit bridge responder all dipped identically (and Apple's own Focus Cookbook sample dips; developer forums thread 770226 remains unanswered). Every production framework converged on the same fix: never queue the dismissal. React Native returns NO from textFieldShouldReturn and moves first responder directly; Flutter and Compose Multiplatform patched their identical bugs by never resigning their hidden input view. So do exactly that. An invisible introspection anchor in each field's background finds the UIKit text field backing the SwiftUI TextField (still UITextField-backed through iOS 26), wraps SwiftUI's delegate in a forwarding proxy that reroutes only textFieldShouldReturn, and registers the backing field with the focus registry. A chained return key now runs our submit path and hops with a plain becomeFirstResponder handoff — no dismissal is ever queued, the keyboard never moves. Unchained fields forward to SwiftUI untouched. The proxy reads a per-render-refreshed box, so republishes can't leave it stale. Gone with it: the keyboard bridge (its keyWindow() could grab UIKit's keyboard helper windows and it acted after the dismissal was queued anyway) and the shared FocusState scope (its owner sat outside the NavigationStack, a different hosting controller from the fields — the atomic write never engaged on stack screens). Also: accessory-bar release is now deferred one runloop and sequence-guarded so a pad-keyboard hop can't blink the 44pt bar out and back between blur and claim. --- resources/ios/NativeUIFocusRegistry.swift | 20 +++ .../ios/NativeUIKeyboardAccessoryBar.swift | 56 +++---- resources/ios/NativeUIKeyboardBridge.swift | 73 --------- .../ios/NativeUIReturnKeyInterceptor.swift | 145 ++++++++++++++++++ resources/ios/NativeUITextInputCore.swift | 68 +++----- 5 files changed, 209 insertions(+), 153 deletions(-) delete mode 100644 resources/ios/NativeUIKeyboardBridge.swift create mode 100644 resources/ios/NativeUIReturnKeyInterceptor.swift diff --git a/resources/ios/NativeUIFocusRegistry.swift b/resources/ios/NativeUIFocusRegistry.swift index ab813d9..d7c1b27 100644 --- a/resources/ios/NativeUIFocusRegistry.swift +++ b/resources/ios/NativeUIFocusRegistry.swift @@ -1,4 +1,5 @@ import Foundation +import UIKit /// Screen-wide focus routing for text inputs — the `next-focus` prop. /// @@ -25,6 +26,7 @@ final class NativeUIFocusRegistry { private struct Entry { let token: UUID let focus: () -> Void + weak var backingField: UITextField? } private var entries: [String: Entry] = [:] @@ -46,12 +48,30 @@ final class NativeUIFocusRegistry { } } + /// Attach the UIKit text field backing the SwiftUI field registered + /// under `ref` (found by the return-key interceptor's introspection). + /// A hop can then be a direct responder handoff — the only transition + /// that keeps the keyboard perfectly still. Weak: the entry outlives + /// nothing; a recreated backing re-attaches on its next render. + func attachBackingField(_ ref: String, field: UITextField) { + entries[ref]?.backingField = field + } + /// Focus the field registered under `ref`. Returns whether a target /// existed — a missing target (off-screen, recycled row, typo'd ref) /// is a no-op, never a crash. + /// + /// Prefers a UIKit responder handoff to the target's backing field: + /// with no dismissal queued (the return key was intercepted), UIKit + /// moves the keyboard between fields without any hide/show. The + /// SwiftUI FocusState closure is the fallback for fields whose + /// backing hasn't been introspected yet. @discardableResult func focus(_ ref: String) -> Bool { guard let entry = entries[ref] else { return false } + if let tf = entry.backingField, tf.window != nil, tf.becomeFirstResponder() { + return true + } entry.focus() return true } diff --git a/resources/ios/NativeUIKeyboardAccessoryBar.swift b/resources/ios/NativeUIKeyboardAccessoryBar.swift index df422ad..b50d2b2 100644 --- a/resources/ios/NativeUIKeyboardAccessoryBar.swift +++ b/resources/ios/NativeUIKeyboardAccessoryBar.swift @@ -38,8 +38,13 @@ final class NativeUIKeyboardAccessoryState: ObservableObject { private var perform: () -> Void = {} + /// Monotonic claim counter — a deferred release only lands when no + /// claim happened after it was scheduled (see `release`). + private var claimSeq = 0 + /// Called when a field gains focus (or its accessory title changes). func claim(id: UUID, title: String, perform: @escaping () -> Void) { + claimSeq += 1 self.perform = perform if info?.id != id || info?.title != title { info = Info(id: id, title: title) @@ -60,13 +65,19 @@ final class NativeUIKeyboardAccessoryState: ObservableObject { } } - /// Called on blur / disappear. Identity-guarded: a field losing focus - /// because ANOTHER field claimed the bar must not tear the new claim - /// down (focus handoff order is not guaranteed). + /// Called on blur / disappear. Deferred one runloop and guarded both + /// ways: a field losing focus because ANOTHER field claimed the bar + /// must not tear the new claim down, and during a pad-keyboard hop + /// the release must not remove the bar for a frame between the old + /// field's blur and the new field's claim (a visible 44pt safe-area + /// jump that reads as a keyboard dip). func release(id: UUID) { - guard info?.id == id else { return } - info = nil - perform = {} + let seq = claimSeq + DispatchQueue.main.async { [weak self] in + guard let self, self.claimSeq == seq, self.info?.id == id else { return } + self.info = nil + self.perform = {} + } } func submit() { @@ -74,30 +85,9 @@ final class NativeUIKeyboardAccessoryState: ObservableObject { } } -/// The screen-scoped focus state text inputs share — one `String?` of the -/// focused field's key, injected by the host below. -/// -/// Two independent per-field `@FocusState` bools can never chain without a -/// keyboard bounce: SwiftUI processes the old field's resign and the new -/// field's focus as separate operations, so the keyboard dips down and back -/// up on every hop regardless of timing. With ONE shared value the hop is a -/// single atomic write — SwiftUI treats it as focus MOVING between fields -/// (the Focus Cookbook enum recipe) and the keyboard stays up, exactly like -/// UIKit's becomeFirstResponder-in-shouldReturn. -private struct NativeUIFocusScopeKey: EnvironmentKey { - static let defaultValue: FocusState.Binding? = nil -} - -extension EnvironmentValues { - var nativeUIFocusScope: FocusState.Binding? { - get { self[NativeUIFocusScopeKey.self] } - set { self[NativeUIFocusScopeKey.self] = newValue } - } -} - -/// Wraps a content root, owns the screen's shared focus scope, and pins the -/// accessory bar above the keyboard while a field has claimed it. -/// Transparent pass-through (no inset, no cost) while no claim is active. +/// Wraps a content root and pins the accessory bar above the keyboard while +/// a field has claimed it. Transparent pass-through (no inset, no cost) +/// while no claim is active. struct NativeUIKeyboardAccessoryHost: View { /// Set on the screen-root instance — its bar yields while any bottom /// sheet is presented (the sheet's own host takes over). @@ -108,14 +98,8 @@ struct NativeUIKeyboardAccessoryHost: View { @ObservedObject private var themeStore = NativeUITheme.shared @Environment(\.colorScheme) private var colorScheme - /// The shared focus scope for every text input under this host. The - /// sheet host shadows the root host's scope for sheet content — chains - /// never cross a presentation boundary. - @FocusState private var focusedKey: String? - var body: some View { content - .environment(\.nativeUIFocusScope, $focusedKey) .safeAreaInset(edge: .bottom, spacing: 0) { if let info = state.info, !(hidesUnderSheets && state.sheetDepth > 0) { let theme = themeStore.resolve(for: colorScheme) diff --git a/resources/ios/NativeUIKeyboardBridge.swift b/resources/ios/NativeUIKeyboardBridge.swift deleted file mode 100644 index a99dec3..0000000 --- a/resources/ios/NativeUIKeyboardBridge.swift +++ /dev/null @@ -1,73 +0,0 @@ -import SwiftUI -import UIKit - -/// Holds the keyboard up through a `next-focus` hop. -/// -/// SwiftUI cannot chain focus between two text fields without a visible -/// keyboard dip: the return key's resign and the target's focus are -/// processed as separate operations — even a screen-scoped shared -/// FocusState written atomically inside `onSubmit` still dips (verified -/// on device; the engine does not coalesce across a submit). UIKit never -/// had this problem, because the keyboard only dismisses when NO -/// responder holds it — `becomeFirstResponder` inside -/// `textFieldShouldReturn` is a responder HANDOFF. -/// -/// So do exactly that. At hop start an invisible, zero-frame `UITextField` -/// takes first responder synchronously (handoff #1 — keyboard stays up), -/// SwiftUI then focuses the target on its own schedule (handoff #2 — -/// keyboard stays up), and the bridge removes itself. If the target never -/// claims focus (recycled row, conditional render, typo'd ref) the bridge -/// resigns after a grace period, dropping the keyboard exactly like a -/// failed hop always did — the user is never left typing into an -/// invisible field. -final class NativeUIKeyboardBridge { - static let shared = NativeUIKeyboardBridge() - - private var field: UITextField? - - /// Bumped on every `hold` so an in-flight `settle` from a previous hop - /// can never tear down a newer hold (fast serial hops). - private var generation = 0 - - private init() {} - - /// Take first responder synchronously. Keyboard type and autocorrect - /// mirror the SOURCE field so the held keyboard keeps its exact layout - /// (chains are homogeneous in practice; a differing target just morphs - /// on handoff #2, as it would anyway). - func hold(keyboardType: UIKeyboardType, autocorrect: Bool) { - generation += 1 - guard let window = keyWindow() else { return } - let f = field ?? UITextField(frame: .zero) - f.keyboardType = keyboardType - f.autocorrectionType = autocorrect ? .yes : .no - f.spellCheckingType = autocorrect ? .yes : .no - field = f - if f.superview !== window { - window.addSubview(f) - } - f.becomeFirstResponder() - } - - /// Schedule the post-hop cleanup: if the bridge still holds first - /// responder after the grace period the target never took over — let - /// the keyboard go. Either way the field leaves the window. - func settle(after delay: TimeInterval = 0.35) { - let gen = generation - DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in - guard let self, self.generation == gen, let f = self.field else { return } - if f.isFirstResponder { - f.resignFirstResponder() - } - f.removeFromSuperview() - self.field = nil - } - } - - private func keyWindow() -> UIWindow? { - UIApplication.shared.connectedScenes - .compactMap { $0 as? UIWindowScene } - .flatMap { $0.windows } - .first { $0.isKeyWindow } - } -} diff --git a/resources/ios/NativeUIReturnKeyInterceptor.swift b/resources/ios/NativeUIReturnKeyInterceptor.swift new file mode 100644 index 0000000..a19496f --- /dev/null +++ b/resources/ios/NativeUIReturnKeyInterceptor.swift @@ -0,0 +1,145 @@ +import SwiftUI +import UIKit + +/// Dip-free `next-focus` chaining, the way every production framework does +/// it (React Native, Flutter, Compose all converged here). +/// +/// SwiftUI queues the keyboard dismissal as part of handling the return +/// key ITSELF, before `.onSubmit` runs — and since iOS 17 the keyboard is +/// out-of-process, so a queued hide cannot be cancelled by anything the +/// submit handler does (focus writes, shared FocusState, even a UIKit +/// becomeFirstResponder — all tried, all dipped). The only way to keep +/// the keyboard still is to make sure a dismissal is NEVER queued: +/// intercept `textFieldShouldReturn` on the UIKit text field backing the +/// SwiftUI TextField, run the submit path ourselves, move first +/// responder straight to the target's backing field, and return `false` +/// — exactly React Native's `blurOnSubmit={false}` implementation. +/// +/// Wiring: `ReturnKeyInterceptorAnchor` rides in the field's +/// `.background`. From there it finds the backing `UITextField`, wraps +/// SwiftUI's delegate in `NativeUIReturnKeyProxy` (forwarding everything +/// except `textFieldShouldReturn`), registers the backing field with the +/// focus registry so hops become plain responder handoffs, and re-checks +/// the hook on every SwiftUI update (SwiftUI may reinstall its +/// delegate). Fields with no chain forward return-key handling to +/// SwiftUI untouched — stock submit + dismissal. + +/// Per-field mutable channel between the SwiftUI view (which recomputes +/// its submit closure and chain state every render) and the long-lived +/// UIKit proxy. Read at return-key time, so it can never go stale. +final class NativeUIReturnKeyBox { + /// Whether the return key should be intercepted (a `next-focus` + /// chain exists on a single-line field). + var chains = false + + /// The field's full submit path — flush, submit event, focus hop. + var perform: () -> Void = {} +} + +/// Forwarding delegate wrapper. Everything SwiftUI's coordinator +/// implements keeps working; only the return key is rerouted. +final class NativeUIReturnKeyProxy: NSObject, UITextFieldDelegate { + weak var original: UITextFieldDelegate? + var box: NativeUIReturnKeyBox? + + override func responds(to aSelector: Selector!) -> Bool { + super.responds(to: aSelector) || (original?.responds(to: aSelector) ?? false) + } + + override func forwardingTarget(for aSelector: Selector!) -> Any? { + original + } + + func textFieldShouldReturn(_ textField: UITextField) -> Bool { + if let box, box.chains { + box.perform() + // `false` = no editingDidEndOnExit, no SwiftUI submit + // machinery, and crucially NO queued keyboard dismissal. + return false + } + if let original, original.responds(to: #selector(UITextFieldDelegate.textFieldShouldReturn(_:))) { + return original.textFieldShouldReturn?(textField) ?? true + } + return true + } +} + +/// Invisible zero-size anchor placed in the field's `.background` — +/// the standard introspection seam between a SwiftUI view and its UIKit +/// backing. +struct ReturnKeyInterceptorAnchor: UIViewRepresentable { + let box: NativeUIReturnKeyBox + let focusRef: String + + func makeUIView(context: Context) -> IntrospectionView { + let view = IntrospectionView() + view.isUserInteractionEnabled = false + view.box = box + view.focusRef = focusRef + return view + } + + func updateUIView(_ view: IntrospectionView, context: Context) { + view.box = box + view.focusRef = focusRef + // Deferred: never mutate UIKit delegates mid-SwiftUI-update, and + // give SwiftUI a beat to (re)attach its own machinery first. + DispatchQueue.main.async { view.hook() } + } + + final class IntrospectionView: UIView { + var box: NativeUIReturnKeyBox? + var focusRef: String = "" + + private var proxy: NativeUIReturnKeyProxy? + private weak var hooked: UITextField? + + override func didMoveToWindow() { + super.didMoveToWindow() + DispatchQueue.main.async { [weak self] in self?.hook() } + } + + /// Find the backing field, install (or repair) the delegate + /// proxy, and register the backing field for direct-responder + /// focus hops. Idempotent; called on every SwiftUI update + /// because SwiftUI can reinstall its delegate at any render. + func hook() { + guard window != nil, let tf = findTextField() else { return } + + if !focusRef.isEmpty { + NativeUIFocusRegistry.shared.attachBackingField(focusRef, field: tf) + } + + let p = proxy ?? NativeUIReturnKeyProxy() + proxy = p + p.box = box + if tf.delegate !== p { + p.original = tf.delegate + tf.delegate = p + } + hooked = tf + } + + /// The anchor sits in the field's `.background`, so the nearest + /// UITextField above/beside us IS our field. Climb a few + /// ancestors, searching each level's subtree; the first (i.e. + /// closest) match wins, which keeps sibling rows out of reach. + private func findTextField() -> UITextField? { + var ancestor = superview + for _ in 0..<6 { + guard let a = ancestor else { return nil } + if let tf = Self.firstTextField(in: a) { return tf } + ancestor = a.superview + } + return nil + } + + private static func firstTextField(in view: UIView) -> UITextField? { + if let tf = view as? UITextField { return tf } + for sub in view.subviews { + if let tf = firstTextField(in: sub) { return tf } + } + return nil + } + } +} diff --git a/resources/ios/NativeUITextInputCore.swift b/resources/ios/NativeUITextInputCore.swift index 964daf6..5f581da 100644 --- a/resources/ios/NativeUITextInputCore.swift +++ b/resources/ios/NativeUITextInputCore.swift @@ -56,13 +56,10 @@ struct NativeUITextInputCore: View { // accessory bar (pad keyboards only — see NativeUIKeyboardAccessoryBar). @State private var accessoryToken = UUID() - // Screen-scoped shared focus (see NativeUIFocusScopeKey): one String? of - // the focused field's key for the whole screen, so a next-focus hop is a - // single atomic write — SwiftUI then MOVES focus instead of resigning - // and re-acquiring, and the keyboard stays up. `focusKey` is this - // field's identity in that scope; a @State UUID survives republishes. - @Environment(\.nativeUIFocusScope) private var focusScope - @State private var focusKey = UUID().uuidString + // Channel to the UIKit return-key interceptor (see + // NativeUIReturnKeyInterceptor.swift) — refreshed every render so the + // proxy always runs the CURRENT submit path and chain state. + @State private var returnKeyBox = NativeUIReturnKeyBox() var body: some View { let p = node.props @@ -147,18 +144,17 @@ struct NativeUITextInputCore: View { // never a lost keyboard. Focusing an already-focused field is a // no-op, so the second pass costs nothing when the first stuck. if !nextFocus.isEmpty { - // The bridge takes first responder BEFORE focus moves, so - // UIKit sees back-to-back responder handoffs and the - // keyboard never dismisses — see NativeUIKeyboardBridge for - // why no SwiftUI-only ordering can achieve this. - NativeUIKeyboardBridge.shared.hold(keyboardType: keyboard, autocorrect: autocorrect) + // With the return key intercepted (no dismissal queued), + // this is a plain responder handoff to the target's backing + // UITextField — the keyboard stays perfectly still. The + // async pass is the safety net for a target whose backing + // isn't introspected yet. NativeUIFocusRegistry.shared.focus(nextFocus) DispatchQueue.main.async { if !NativeUIFocusRegistry.shared.focus(nextFocus) && keepFocus { isFocused = true } } - NativeUIKeyboardBridge.shared.settle() } else if keepFocus { // Chat "send and keep typing": SwiftUI resigns first responder // on return by default. Re-assert focus so the keyboard stays @@ -190,6 +186,7 @@ struct NativeUITextInputCore: View { explicit: submitLabelKind, hasSubmit: onSubmitCb != 0, nextFocus: nextFocus ) let _ = refreshAccessoryClaim(wantsAccessory, title: accessoryTitle, perform: performSubmit) + let _ = syncReturnKeyBox(chains: !nextFocus.isEmpty && !multiline, perform: performSubmit) // Apply `.foregroundColor` (not just `.foregroundStyle`) so the TYPED // text adopts `contentColor`. SwiftUI's TextField/SecureField don't @@ -241,11 +238,10 @@ struct NativeUITextInputCore: View { } } .nuiScaledFont(size: textSize, fontName: fontName.isEmpty ? nil : fontName) - // Second focus binding, into the screen's shared scope. Coexists - // with the per-field bool above: the system keeps both in sync, the - // bool drives this field's own logic, the scope makes cross-field - // hops atomic. - .modifier(SharedFocusScopeModifier(key: focusKey)) + // Invisible introspection anchor: finds the UIKit field backing + // this TextField, reroutes its return key through `returnKeyBox`, + // and registers the backing field for direct responder handoffs. + .background(ReturnKeyInterceptorAnchor(box: returnKeyBox, focusRef: focusRef)) // NOTE: SwiftUI's editable TextField ignores `.lineSpacing` for its // typed text (unlike `Text`), so `leading-*` has no visible effect on // iOS inputs. Kept for intent / forward-compat; leading works on @@ -264,18 +260,13 @@ struct NativeUITextInputCore: View { initialized = true } // Make this field focus-addressable. Capturing the FocusState - // bindings keeps the registry free of any view reference. The - // scope write is what makes a chained hop bounce-free (one - // atomic focus move); the bool write is the fallback when no - // host provides a scope, and a harmless no-op otherwise. + // binding keeps the registry free of any view reference. This + // closure is the fallback hop — the registry prefers a direct + // responder handoff to the backing UITextField attached by the + // return-key interceptor, which is what keeps the keyboard up. if !focusRef.isEmpty { let binding = $isFocused - let scope = focusScope - let key = focusKey focusRegistryToken = NativeUIFocusRegistry.shared.register(focusRef) { - if let scope { - scope.wrappedValue = key - } binding.wrappedValue = true } } @@ -376,23 +367,12 @@ struct NativeUITextInputCore: View { } } - // ─── Shared focus scope ────────────────────────────────────────────────── - - /// Applies the screen's shared focus binding when a host provides one. - /// Without a host (no accessory host wrapping this context) the field - /// runs on its per-field bool alone, exactly as before. - private struct SharedFocusScopeModifier: ViewModifier { - let key: String - - @Environment(\.nativeUIFocusScope) private var scope - - func body(content: Content) -> some View { - if let scope { - content.focused(scope, equals: key) - } else { - content - } - } + /// Body-time refresh of the return-key interceptor's channel — the + /// proxy reads it at key-press time, so the submit closure and chain + /// state can never go stale under republishes. + private func syncReturnKeyBox(chains: Bool, perform: @escaping () -> Void) { + returnKeyBox.chains = chains + returnKeyBox.perform = perform } /// Body-time refresh of this field's accessory-bar claim — keeps the