Skip to content
Merged
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
39 changes: 38 additions & 1 deletion resources/android/TextInputShared.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.TextFieldValue
Expand Down Expand Up @@ -50,6 +51,7 @@ internal data class TextInputProps(
val minLines: Int,
val maxLength: Int,
val keyboard: KeyboardType,
val capitalization: KeyboardCapitalization?,
val disabled: Boolean,
val readOnly: Boolean,
val isError: Boolean,
Expand Down Expand Up @@ -113,6 +115,7 @@ internal fun parseTextInputProps(node: NativeUINode): TextInputProps {
minLines = p.getInt("min_lines").let { if (it > 0) it else 1 },
maxLength = p.getInt("max_length"),
keyboard = resolveKeyboardType(p.getString("keyboard")),
capitalization = resolveCapitalization(p.getString("autocapitalize"), p.getString("keyboard")),
disabled = p.getBool("disabled"),
readOnly = p.getBool("read_only"),
isError = p.getBool("is_error"),
Expand Down Expand Up @@ -167,8 +170,42 @@ internal fun resolveKeyboardType(kind: String): KeyboardType = when (kind.lowerc
else -> KeyboardType.Text
}

/**
* Capitalization from the explicit `autocapitalize` prop when the author set
* one, otherwise derived from the keyboard type. Null means "leave Compose's
* own default alone".
*
* Compose already defaults to no capitalization, so Android never had the iOS
* bug (mobile-air #304) — it got the right answer by accident rather than on
* purpose, and `autocapitalize` had no way to take effect at all.
*
* The plain-text case deliberately returns NULL rather than `Sentences`.
* Sentences is what iOS does and would make the platforms agree, but it would
* also silently start capitalizing every unclassified text field in every
* existing Android app. That default is a separate decision; this change only
* makes the prop work and pins the cases the keyboard type already implies.
*
* Unknown values fall through to the derived behaviour rather than erroring,
* matching `resolveKeyboardType`.
*/
internal fun resolveCapitalization(explicit: String, keyboard: String): KeyboardCapitalization? =
when (explicit.lowercase()) {
"none" -> KeyboardCapitalization.None
"sentences" -> KeyboardCapitalization.Sentences
"words" -> KeyboardCapitalization.Words
"characters" -> KeyboardCapitalization.Characters
else -> when (keyboard.lowercase()) {
// Case-sensitive or non-alphabetic content — never capitalize.
"email", "url", "number", "decimal", "phone",
"numberpassword", "password" -> KeyboardCapitalization.None
else -> null
}
}

internal fun keyboardOptionsFor(props: TextInputProps): KeyboardOptions =
KeyboardOptions(keyboardType = props.keyboard)
props.capitalization
?.let { KeyboardOptions(keyboardType = props.keyboard, capitalization = it) }
?: KeyboardOptions(keyboardType = props.keyboard)

/**
* Outbound dispatch state machine. Call [onTextChanged] whenever local text
Expand Down
59 changes: 58 additions & 1 deletion resources/ios/NativeUITextInputCore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,16 @@ struct NativeUITextInputCore: View {
let minLines = p.getInt("min_lines")
let disabled = p.getBool("disabled")
let readOnly = p.getBool("read_only")
let keyboard = resolveKeyboardType(p.getString("keyboard"))
let keyboardKind = p.getString("keyboard")
let keyboard = resolveKeyboardType(keyboardKind)
// Capitalization and autocorrect are derived from the keyboard type
// unless the author overrode them — declaring a field `email` should
// carry its typing behaviour, not just its key layout.
let capitalization = resolveAutocapitalization(
explicit: p.getString("autocapitalize"),
keyboard: keyboardKind
)
let autocorrect = allowsAutocorrection(keyboard: keyboardKind)
let onChangeCb = p.getCallbackId("on_change")
let onSubmitCb = p.getCallbackId("on_submit")
let syncMode = p.getString("sync_mode", default: "live")
Expand Down Expand Up @@ -140,6 +149,8 @@ struct NativeUITextInputCore: View {
.lineSpacing(lineSpacing)
.tint(tintColor)
.keyboardType(keyboard)
.textInputAutocapitalization(capitalization)
.autocorrectionDisabled(!autocorrect)
.disabled(disabled || readOnly)
.submitLabel(onSubmitCb != 0 ? .done : .return)
.onAppear {
Expand Down Expand Up @@ -424,3 +435,49 @@ private func resolveKeyboardType(_ kind: String) -> UIKeyboardType {
default: return .default
}
}

/// Capitalization for the field, from the explicit `autocapitalize` prop when
/// the author set one, otherwise derived from the keyboard type.
///
/// SwiftUI defaults an untouched TextField to `.sentences`, which is why an
/// email field capitalized its first letter even though `.emailAddress` was
/// applied: `keyboardType` sets the key layout and nothing else. Every keyboard
/// kind whose content is case-sensitive or non-alphabetic therefore has to opt
/// out explicitly.
///
/// Unknown `autocapitalize` values fall through to the derived behaviour rather
/// than erroring — same policy as `resolveKeyboardType`.
private func resolveAutocapitalization(explicit: String, keyboard: String) -> TextInputAutocapitalization {
switch explicit.lowercased() {
case "none": return .never
case "sentences": return .sentences
case "words": return .words
case "characters": return .characters
default: break
}

switch keyboard.lowercased() {
// Case-sensitive content — capitalizing the first character is always
// wrong here (an email's local part, a URL's path).
case "email", "url":
return .never
// Numeric keypads have no shift key, so capitalization is moot; `.never`
// just keeps the state honest if the user swaps to a hardware keyboard.
case "number", "decimal", "phone", "numberpassword", "password":
return .never
default:
return .sentences
}
}

/// Whether autocorrect should run. Same reasoning as capitalization: iOS will
/// happily "correct" an email local part or a URL slug into a dictionary word,
/// and the field type is enough to know that's unwanted.
private func allowsAutocorrection(keyboard: String) -> Bool {
switch keyboard.lowercased() {
case "email", "url", "number", "decimal", "phone", "numberpassword", "password":
return false
default:
return true
}
}
25 changes: 24 additions & 1 deletion src/Elements/BaseTextInput.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
* Allowed per-instance:
* - `value`, `placeholder`, `label`, `supporting` (content)
* - `disabled`, `readOnly`, `error`, `loading` (state)
* - `keyboard`, `secure`, `maxLength`, `multiline`, `maxLines`, `minLines` (behavior)
* - `keyboard`, `autocapitalize`, `secure`, `maxLength`, `multiline`, `maxLines`, `minLines` (behavior)
* - `prefix`, `suffix`, `leading-icon`, `trailing-icon` (decorations)
* - `size` (sm | md | lg)
* - `a11y-label`, `a11y-hint` (accessibility)
Expand Down Expand Up @@ -86,6 +86,9 @@ public function applyAttributes(array $attrs): void
if (isset($attrs['keyboard'])) {
$this->keyboard($attrs['keyboard']);
}
if (isset($attrs['autocapitalize']) || isset($attrs['autoCapitalize'])) {
$this->autocapitalize((string) ($attrs['autocapitalize'] ?? $attrs['autoCapitalize']));
}
if (! empty($attrs['secure'])) {
$this->secure();
}
Expand Down Expand Up @@ -254,6 +257,26 @@ public function keyboard(string|int $type): static
return $this;
}

/**
* Autocapitalization — "none" | "sentences" | "words" | "characters".
* Mirrors HTML's `autocapitalize` vocabulary.
*
* Leave it unset and the field derives capitalization from its `keyboard`
* type, which is what you want almost always: an `email` or `url` field
* capitalizes nothing, a plain text field capitalizes sentences. This
* setter exists for the cases the keyboard type can't imply — a name field
* wanting `words`, or a reference-code field wanting `characters`.
*
* Unknown values are ignored natively and fall back to the derived
* behaviour rather than erroring.
*/
public function autocapitalize(string $mode): static
{
$this->inputProps['autocapitalize'] = strtolower(trim($mode));

return $this;
}

public function secure(bool $value = true): static
{
$this->inputProps['secure'] = $value;
Expand Down
29 changes: 29 additions & 0 deletions tests/CollectorElementsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -484,3 +484,32 @@
expect($tree['props']['permanent'])->toBeFalse();
expect($tree['props']['background_interaction'])->toBeFalse();
});

// ── autocapitalize (mobile-air #304) ─────────────────────────────────────────

it('serializes autocapitalize via the fluent API', function () {
$props = OutlinedTextInput::make()
->autocapitalize('Words')
->toArray(new CallbackRegistry)['props'];

// Normalized to lower case so the native resolvers can match on one form.
expect($props['autocapitalize'])->toBe('words');
});

it('serializes autocapitalize from both attribute spellings', function (string $attr) {
$el = OutlinedTextInput::make();
$el->applyAttributes([$attr => 'characters']);

expect($el->toArray(new CallbackRegistry)['props']['autocapitalize'])->toBe('characters');
})->with(['autocapitalize', 'autoCapitalize']);

it('omits autocapitalize entirely when the author did not set one', function () {
// Absent means "derive from the keyboard type" on both platforms — the
// native resolvers must not see an empty string as an explicit choice.
$props = OutlinedTextInput::make()
->keyboard('email')
->toArray(new CallbackRegistry)['props'];

expect($props)->not->toHaveKey('autocapitalize')
->and($props['keyboard'])->toBe('email');
});
Loading