Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion resources/android/BareTextInputRenderer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
3 changes: 2 additions & 1 deletion resources/android/FilledTextInputRenderer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion resources/android/OutlinedTextInputRenderer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
26 changes: 26 additions & 0 deletions resources/android/TextInputShared.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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")),
)
}
Expand Down Expand Up @@ -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)
}
9 changes: 9 additions & 0 deletions resources/ios/NativeUITextInputCore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions src/Elements/BaseTextInput.php
Original file line number Diff line number Diff line change
Expand Up @@ -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']));
}
Expand Down Expand Up @@ -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;
Expand Down
52 changes: 52 additions & 0 deletions tests/BaseTextInputAutofocusTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
<?php

use Native\Mobile\Edge\CallbackRegistry;
use Native\Mobile\UI\Elements\BareTextInput;
use Native\Mobile\UI\Elements\FilledTextInput;
use Native\Mobile\UI\Elements\OutlinedTextInput;

function autofocusProps(string $inputClass, array $attrs): array
{
$input = new $inputClass;
$input->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();
});