From 03a56c7a2755481a691f38ac5bbce7e271e774e8 Mon Sep 17 00:00:00 2001 From: Eser DENIZ Date: Mon, 24 Aug 2026 14:42:38 +0200 Subject: [PATCH] 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`');