diff --git a/resources/android/BareTextInputRenderer.kt b/resources/android/BareTextInputRenderer.kt index a52a7ef..01f577b 100644 --- a/resources/android/BareTextInputRenderer.kt +++ b/resources/android/BareTextInputRenderer.kt @@ -129,7 +129,8 @@ object BareTextInputRenderer { if (wasFocused && !state.isFocused) selectionReporter.flush(value) wasFocused = state.isFocused } - .then(modifier), + .then(modifier) + .nuiAutofocus(props.autofocus), enabled = !props.disabled, readOnly = props.readOnly, textStyle = LocalTextStyle.current.copy( diff --git a/resources/android/FilledTextInputRenderer.kt b/resources/android/FilledTextInputRenderer.kt index 0231c9c..2cc4c48 100644 --- a/resources/android/FilledTextInputRenderer.kt +++ b/resources/android/FilledTextInputRenderer.kt @@ -124,7 +124,8 @@ 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().then(modifier).nuiA11y(props.a11yLabel, props.a11yHint) + .nuiAutofocus(props.autofocus), enabled = props.enabled, readOnly = props.readOnly, interactionSource = interactionSource, diff --git a/resources/android/OutlinedTextInputRenderer.kt b/resources/android/OutlinedTextInputRenderer.kt index 4013484..ce77195 100644 --- a/resources/android/OutlinedTextInputRenderer.kt +++ b/resources/android/OutlinedTextInputRenderer.kt @@ -137,7 +137,8 @@ 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().then(modifier).nuiA11y(props.a11yLabel, props.a11yHint) + .nuiAutofocus(props.autofocus), enabled = props.enabled, readOnly = props.readOnly, interactionSource = interactionSource, diff --git a/resources/android/TextInputShared.kt b/resources/android/TextInputShared.kt index 50173f9..893e453 100644 --- a/resources/android/TextInputShared.kt +++ b/resources/android/TextInputShared.kt @@ -3,7 +3,11 @@ 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.LaunchedEffect +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.TextRange @@ -69,6 +73,7 @@ internal data class TextInputProps( val onSelectionChangeCb: Int, val syncMode: SyncMode, val debounceMs: Int, + val autofocus: Boolean, val selectionDebounceMs: Int, ) { val enabled: Boolean get() = !disabled && !loading @@ -133,6 +138,7 @@ internal fun parseTextInputProps(node: NativeUINode): TextInputProps { onSelectionChangeCb = p.getCallbackId("on_selection_change"), syncMode = parseSyncMode(p.getString("sync_mode", "live")), debounceMs = p.getInt("debounce_ms").let { if (it > 0) it else 300 }, + autofocus = p.getBool("autofocus"), selectionDebounceMs = resolveSelectionDebounceMs(p.getInt("selection_debounce_ms")), ) } @@ -436,3 +442,23 @@ internal fun Modifier.nuiA11y(label: String, hint: String): Modifier { val merged = listOf(label, hint).filter { it.isNotEmpty() }.joinToString(". ") return if (merged.isEmpty()) this else semantics { contentDescription = merged } } + +/** + * Focus the field and raise the keyboard on first composition. + * + * Keyed on [Unit] rather than on the flag: this fires once when the field + * appears, not again on every recomposition - otherwise a re-render would + * steal focus back from wherever the user has since moved it. + */ +@Composable +internal fun Modifier.nuiAutofocus(enabled: Boolean): Modifier { + if (!enabled) return this + + val requester = remember { FocusRequester() } + + LaunchedEffect(Unit) { + runCatching { requester.requestFocus() } + } + + return this.focusRequester(requester) +} diff --git a/resources/ios/NativeUITextInputCore.swift b/resources/ios/NativeUITextInputCore.swift index fdd29d9..cb90180 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 autofocus = p.getBool("autofocus") // 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") @@ -160,6 +161,14 @@ struct NativeUITextInputCore: View { text = serverValue lastSentValue = serverValue initialized = true + + // First appearance only: a later re-render must not steal + // focus back from wherever the user has since moved it. + // Deferred a runloop because @FocusState does not take + // while the view is still being installed. + if autofocus && !disabled && !readOnly { + DispatchQueue.main.async { isFocused = true } + } } } .onChange(of: serverValue) { _, newServerValue in diff --git a/src/Elements/BaseTextInput.php b/src/Elements/BaseTextInput.php index 6cbb6bc..74fc6b3 100644 --- a/src/Elements/BaseTextInput.php +++ b/src/Elements/BaseTextInput.php @@ -101,6 +101,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']) || ! empty($attrs['auto-focus'])) { + $this->autofocus(); + } if (isset($attrs['maxLines']) || isset($attrs['max-lines'])) { $this->maxLines((int) ($attrs['maxLines'] ?? $attrs['max-lines'])); } @@ -322,6 +325,23 @@ public function keepFocusOnSubmit(bool $value = true): static return $this; } + /** + * Focus this field and raise the keyboard as soon as it appears. + * + * For the field that is the reason its screen exists — a form the user + * was just sent to in order to type one thing. Blade: `autofocus` + * (or `auto-focus`). + * + * Only one field per screen should set it. Two would race for first + * responder, and the loser's keyboard flickers. + */ + public function autofocus(bool $value = true): static + { + $this->inputProps['autofocus'] = $value; + + return $this; + } + public function maxLines(int $lines): static { $this->inputProps['max_lines'] = $lines; diff --git a/tests/BaseTextInputAutofocusTest.php b/tests/BaseTextInputAutofocusTest.php new file mode 100644 index 0000000..e43784c --- /dev/null +++ b/tests/BaseTextInputAutofocusTest.php @@ -0,0 +1,52 @@ +applyAttributes($attrs); + + return $input->getResolvedProps(new CallbackRegistry); +} + +it('is absent unless asked for, so no field grabs the keyboard by default', function (string $inputClass) { + expect(autofocusProps($inputClass, ['label' => 'Name'])['autofocus'] ?? null)->toBeNull(); +})->with([ + 'outlined' => OutlinedTextInput::class, + 'filled' => FilledTextInput::class, + 'bare' => BareTextInput::class, +]); + +it('resolves the autofocus attribute in both spellings', function (string $inputClass, string $attribute) { + expect(autofocusProps($inputClass, [$attribute => true])['autofocus'])->toBeTrue(); +})->with([ + 'outlined' => OutlinedTextInput::class, + 'filled' => FilledTextInput::class, + 'bare' => BareTextInput::class, +])->with([ + 'autofocus', + 'auto-focus', +]); + +it('ignores a falsy attribute rather than focusing on it', function () { + // `autofocus="{{ $condition }}"` renders an empty string when the + // condition is false, which must not read as opt-in. + expect(autofocusProps(OutlinedTextInput::class, ['autofocus' => ''])['autofocus'] ?? null)->toBeNull() + ->and(autofocusProps(OutlinedTextInput::class, ['autofocus' => false])['autofocus'] ?? null)->toBeNull(); +}); + +it('is settable fluently', function () { + $props = OutlinedTextInput::make()->autofocus()->getResolvedProps(new CallbackRegistry); + + expect($props['autofocus'])->toBeTrue(); +}); + +it('can be turned back off fluently', function () { + $props = OutlinedTextInput::make()->autofocus()->autofocus(false)->getResolvedProps(new CallbackRegistry); + + expect($props['autofocus'])->toBeFalse(); +});