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
5 changes: 4 additions & 1 deletion resources/android/FilledTextInputRenderer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,10 @@ object FilledTextInputRenderer {
minLines = props.minLines,
visualTransformation = props.visualTransformation(revealed),
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)
Expand Down
5 changes: 4 additions & 1 deletion resources/android/OutlinedTextInputRenderer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,10 @@ object OutlinedTextInputRenderer {
minLines = props.minLines,
visualTransformation = props.visualTransformation(revealed),
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)
Expand Down
41 changes: 37 additions & 4 deletions resources/android/TextInputShared.kt
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import androidx.compose.ui.focus.focusRequester
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
Expand Down Expand Up @@ -58,6 +59,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,
Expand Down Expand Up @@ -144,6 +146,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"),
Expand Down Expand Up @@ -241,10 +244,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
Expand Down
27 changes: 26 additions & 1 deletion resources/ios/NativeUITextInputCore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,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")
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.
Expand Down Expand Up @@ -212,7 +213,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))
// Scroll target for `scrollIntoView()` below. `node.id` is already the
// ForEach identity of every node in the tree, so it is stable across
// republishes; and because it is applied to the view `body` returns
Expand Down Expand Up @@ -542,6 +543,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 {
Expand Down
44 changes: 43 additions & 1 deletion src/Elements/BaseTextInput.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -19,7 +20,7 @@
* Allowed per-instance:
* - `value`, `placeholder`, `label`, `supporting` (content)
* - `disabled`, `readOnly`, `error`, `loading` (state)
* - `keyboard`, `autocapitalization` / `autocapitalize`, `secure`, `revealable`, `maxLength`, `multiline`, `maxLines`, `minLines` (behavior)
* - `keyboard`, `autocapitalization` / `autocapitalize`, `secure`, `revealable`, `maxLength`, `multiline`, `maxLines`, `minLines`, `submit-label` (behavior)
* - `prefix`, `suffix`, `leading-icon`, `trailing-icon` (decorations)
* - `size` (sm | md | lg)
* - `a11y-label`, `a11y-hint` (accessibility)
Expand Down Expand Up @@ -111,6 +112,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 (! empty($attrs['autofocus']) || ! empty($attrs['auto-focus'])) {
$this->autofocus();
}
Expand Down Expand Up @@ -371,6 +375,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;
}

/**
* Focus this field and raise the keyboard as soon as it appears.
*
Expand Down
59 changes: 59 additions & 0 deletions tests/BaseTextInputSubmitLabelTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
<?php

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

it('serializes a valid submit label on every variant', function (string $inputClass) {
$input = new $inputClass;
$input->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`');
Loading