From a6c5765106f570726efe1c8de4b1094c66b3234a Mon Sep 17 00:00:00 2001 From: Shane Rosenthal Date: Mon, 10 Aug 2026 21:13:22 -0400 Subject: [PATCH] Derive autocapitalization from the keyboard type, and add an override MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `NativeUITextInputCore` applied `.keyboardType(keyboard)` and nothing else. On iOS `keyboardType` sets the KEY LAYOUT only — it says nothing about capitalization, and an untouched SwiftUI TextField defaults to `.sentences`. So a `keyboard="email"` field showed the email keyboard (which is why the report correctly ruled out the class being ignored) and still capitalized the first letter. Nothing was broken; the behaviour was simply never wired. Two halves, matching what the issue asked for: 1. The field type carries its typing behaviour. Every keyboard kind whose content is case-sensitive or non-alphabetic — email, url, number, decimal, phone, password — resolves to `.never`. Autocorrect is disabled for the same set: iOS will otherwise "correct" an email local part into a dictionary word, which is the same bug wearing a different hat. 2. A new `autocapitalize` attribute — "none" | "sentences" | "words" | "characters", HTML's vocabulary — for what a keyboard type cannot imply (a name field wanting words, a reference code wanting characters). It overrides the derived value. Unknown values fall back to the derived behaviour rather than erroring, matching `resolveKeyboardType`. Android never had the bug — Compose's KeyboardOptions defaults to no capitalization — but it got there by accident, and `autocapitalize` had nowhere to land. It now resolves the same prop through the same rules. One deliberate asymmetry: for a plain text field with nothing specified, iOS capitalizes sentences and Android does not. `resolveCapitalization` returns NULL for that case rather than `Sentences`, leaving Compose's default alone — matching iOS there would silently start capitalizing every unclassified text field in every existing Android app, and that default is a separate decision. Android behaviour therefore changes only when the author sets `autocapitalize`, or uses a keyboard type that already implied None. Fixes NativePHP/mobile-air#304. Native changes are compile-unverified. Co-Authored-By: Claude Opus 5 (1M context) --- resources/android/TextInputShared.kt | 39 ++++++++++++++- resources/ios/NativeUITextInputCore.swift | 59 ++++++++++++++++++++++- src/Elements/BaseTextInput.php | 25 +++++++++- tests/CollectorElementsTest.php | 29 +++++++++++ 4 files changed, 149 insertions(+), 3 deletions(-) diff --git a/resources/android/TextInputShared.kt b/resources/android/TextInputShared.kt index b403136..d8eb644 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.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.TextFieldValue @@ -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, @@ -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"), @@ -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 diff --git a/resources/ios/NativeUITextInputCore.swift b/resources/ios/NativeUITextInputCore.swift index 0c64322..018d9a4 100644 --- a/resources/ios/NativeUITextInputCore.swift +++ b/resources/ios/NativeUITextInputCore.swift @@ -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") @@ -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 { @@ -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 + } +} diff --git a/src/Elements/BaseTextInput.php b/src/Elements/BaseTextInput.php index a17a0f5..0bb79ba 100644 --- a/src/Elements/BaseTextInput.php +++ b/src/Elements/BaseTextInput.php @@ -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) @@ -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(); } @@ -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; diff --git a/tests/CollectorElementsTest.php b/tests/CollectorElementsTest.php index a36a7b4..9a57c98 100644 --- a/tests/CollectorElementsTest.php +++ b/tests/CollectorElementsTest.php @@ -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'); +});