diff --git a/resources/android/BareTextInputRenderer.kt b/resources/android/BareTextInputRenderer.kt index dba10c8..b813162 100644 --- a/resources/android/BareTextInputRenderer.kt +++ b/resources/android/BareTextInputRenderer.kt @@ -131,6 +131,7 @@ object BareTextInputRenderer { wasFocused = state.isFocused } .then(modifier) + .nuiContentType(props.contentType) .nuiAutofocus(props.autofocus), enabled = !props.disabled, readOnly = props.readOnly, diff --git a/resources/android/FilledTextInputRenderer.kt b/resources/android/FilledTextInputRenderer.kt index 270bb67..d38689c 100644 --- a/resources/android/FilledTextInputRenderer.kt +++ b/resources/android/FilledTextInputRenderer.kt @@ -131,6 +131,7 @@ object FilledTextInputRenderer { // 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) + .nuiContentType(props.contentType) .nuiAutofocus(props.autofocus), enabled = props.enabled, readOnly = props.readOnly, diff --git a/resources/android/OutlinedTextInputRenderer.kt b/resources/android/OutlinedTextInputRenderer.kt index a7d78e0..e9de284 100644 --- a/resources/android/OutlinedTextInputRenderer.kt +++ b/resources/android/OutlinedTextInputRenderer.kt @@ -161,6 +161,7 @@ object OutlinedTextInputRenderer { // 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) + .nuiContentType(props.contentType) .nuiAutofocus(props.autofocus), enabled = props.enabled, readOnly = props.readOnly, diff --git a/resources/android/TextInputShared.kt b/resources/android/TextInputShared.kt index 26f6bb7..c31364e 100644 --- a/resources/android/TextInputShared.kt +++ b/resources/android/TextInputShared.kt @@ -7,9 +7,11 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.autofill.ContentType import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.contentType import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.KeyboardCapitalization @@ -58,6 +60,7 @@ internal data class TextInputProps( val maxLength: Int, val keyboard: KeyboardType, val capitalization: KeyboardCapitalization?, + val contentType: ContentType?, val disabled: Boolean, val readOnly: Boolean, val isError: Boolean, @@ -144,6 +147,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")), + contentType = resolveContentType(p.getString("content_type")), disabled = p.getBool("disabled"), readOnly = p.getBool("read_only"), isError = p.getBool("is_error"), @@ -241,6 +245,43 @@ internal fun resolveCapitalization(explicit: String, secure: Boolean, keyboard: } } +/** + * The `content_type` prop → Compose autofill [ContentType]. Tokens are HTML's + * `autocomplete` vocabulary, normalized to kebab-case by the element; iOS + * resolves the same prop to `UITextContentType` (`NativeUITextInputCore.swift`) + * — keep the two in sync. + * + * Compose already derives a content type from the keyboard type (Email → + * EmailAddress, Password → Password, Phone → PhoneNumber) in + * `CoreTextFieldSemanticsModifier`. An explicit one set through [nuiContentType] + * wins over that: the author's modifier sits at the head of the text field's + * chain, and a node's semantics are applied tail to head, so it is written + * last. That is what lets `keyboard="email"` and `content-type="username"` + * coexist on a login field. + * + * Note a `secure` field with no `keyboard="password"` gets NO derived type — + * masking is a visual transformation, not a keyboard type — so on Android this + * prop is the only way such a field is declared a password at all. + * + * Unknown and empty values return null, matching `resolveKeyboardType`. + */ +internal fun resolveContentType(kind: String): ContentType? = when (kind.lowercase()) { + "username" -> ContentType.Username + "email" -> ContentType.EmailAddress + "password" -> ContentType.Password + "new-password" -> ContentType.NewPassword + "one-time-code" -> ContentType.SmsOtpCode + else -> null +} + +/** + * Declare the field's autofill [ContentType]. Null leaves the modifier chain + * untouched, so a field that doesn't set `content-type` keeps exactly the + * semantics — including Compose's own keyboard-derived type — it had. + */ +internal fun Modifier.nuiContentType(type: ContentType?): Modifier = + if (type == null) this else semantics { contentType = type } + internal fun keyboardOptionsFor(props: TextInputProps): KeyboardOptions = props.capitalization ?.let { KeyboardOptions(keyboardType = props.keyboard, capitalization = it) } diff --git a/resources/ios/NativeUITextInputCore.swift b/resources/ios/NativeUITextInputCore.swift index bedcece..d35e9d9 100644 --- a/resources/ios/NativeUITextInputCore.swift +++ b/resources/ios/NativeUITextInputCore.swift @@ -117,6 +117,9 @@ struct NativeUITextInputCore: View { keyboard: keyboardKind ) let autocorrect = allowsAutocorrection(secure: secure, keyboard: keyboardKind) + // What the field holds, for AutoFill. Nil when the author didn't say, + // and nil applies nothing at all — see `TextContentTypeModifier`. + let contentType = resolveTextContentType(p.getString("content_type")) let onChangeCb = p.getCallbackId("on_change") let onSubmitCb = p.getCallbackId("on_submit") let syncMode = p.getString("sync_mode", default: "live") @@ -211,6 +214,7 @@ struct NativeUITextInputCore: View { .keyboardType(keyboard) .textInputAutocapitalization(capitalization) .autocorrectionDisabled(!autocorrect) + .modifier(TextContentTypeModifier(contentType: contentType)) .disabled(disabled || readOnly) .submitLabel(onSubmitCb != 0 ? .done : .return) // Scroll target for `scrollIntoView()` below. `node.id` is already the @@ -620,6 +624,47 @@ private func allowsAutocorrection(secure: Bool, keyboard: String) -> Bool { } } +/// The `content_type` prop → `UITextContentType`, for AutoFill. Tokens are +/// HTML's `autocomplete` vocabulary, normalized to kebab-case by the element. +/// +/// This is what declares a login form's credential pair to iOS: a `username` +/// field beside a `password` one. Without it iOS has no pair to fill, which is +/// how a secure field filled as the partner of another field could be written +/// in UIKit without SwiftUI's `text` binding ever seeing the value +/// (NativePHP/mobile-air#422). +/// +/// Unknown and empty values return nil — same policy as `resolveKeyboardType`. +private func resolveTextContentType(_ kind: String) -> UITextContentType? { + switch kind.lowercased() { + case "username": return .username + case "email": return .emailAddress + case "password": return .password + case "new-password": return .newPassword + case "one-time-code": return .oneTimeCode + default: return nil + } +} + +/// Applies `.textContentType` only when the author declared one. +/// +/// Deliberately not `.textContentType(nil)` for the unset case. That is not +/// guaranteed to be a no-op: SwiftUI may assign its own content type to a +/// `SecureField` internally, and an explicit nil applied over it would change +/// the AutoFill behavior of every existing password field. Returning `content` +/// untouched keeps each field that doesn't set the prop exactly as it was. +private struct TextContentTypeModifier: ViewModifier { + let contentType: UITextContentType? + + @ViewBuilder + func body(content: Content) -> some View { + if let contentType { + content.textContentType(contentType) + } else { + content + } + } +} + /// Places the reveal ("eye") control inside the field's own chrome, so it sits /// where the trailing icon sits rather than as a separate Show / Hide control /// next to the input — which is what an app has to build today, and which diff --git a/src/Elements/BaseTextInput.php b/src/Elements/BaseTextInput.php index 43691f7..b8baab2 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`, `autocapitalization` / `autocapitalize`, `secure`, `revealable`, `maxLength`, `multiline`, `maxLines`, `minLines` (behavior) + * - `keyboard`, `autocapitalization` / `autocapitalize`, `content-type` / `autocomplete`, `secure`, `revealable`, `maxLength`, `multiline`, `maxLines`, `minLines` (behavior) * - `prefix`, `suffix`, `leading-icon`, `trailing-icon` (decorations) * - `size` (sm | md | lg) * - `a11y-label`, `a11y-hint` (accessibility) @@ -96,6 +96,13 @@ public function applyAttributes(array $attrs): void if ($autocapitalization !== null) { $this->autocapitalize((string) $autocapitalization); } + $contentType = $attrs['content-type'] + ?? $attrs['contentType'] + ?? $attrs['autocomplete'] + ?? null; + if ($contentType !== null && trim((string) $contentType) !== '') { + $this->contentType((string) $contentType); + } if (! empty($attrs['secure'])) { $this->secure(); } @@ -306,6 +313,44 @@ public function autocapitalization(string $mode): static return $this->autocapitalize($mode); } + /** + * What the field holds, for the platform's AutoFill — "username" | + * "email" | "password" | "new-password" | "one-time-code". Spelled as + * HTML's `autocomplete` tokens; `current-password` is accepted as an alias + * for `password`, and camelCase (`newPassword`) is normalized. + * + * This is what lets a login form fill as a pair: iOS maps it to + * `textContentType` and Android to the Compose autofill `contentType`. + * Without it, iOS has no declared credential pair to fill, and a `secure` + * field filled as the partner of another field can be written without the + * value ever reaching `@change` (NativePHP/mobile-air#422). + * + * `new-password` is worth setting on its own account: a sign-up or + * change-password field without it gets no strong-password suggestion. + * `one-time-code` is what surfaces an SMS code above the keyboard. + * + * Nothing is inferred when this is unset, so every existing field keeps + * exactly the behavior it had. Unknown values are ignored natively rather + * than erroring, the same policy as `keyboard` and `autocapitalize`. + * + * Blade: `content-type`, `contentType`, or HTML's `autocomplete`. + */ + public function contentType(string $type): static + { + // camelCase and snake_case → kebab-case, so `newPassword`, + // `new_password` and `new-password` all reach the natives as one form. + $type = (string) preg_replace('/(?<=[a-z0-9])(?=[A-Z])/', '-', trim($type)); + $type = strtolower(str_replace('_', '-', $type)); + + $this->inputProps['content_type'] = match ($type) { + 'current-password' => 'password', + 'email-address' => 'email', + default => $type, + }; + + return $this; + } + /** * Mask the field's contents (password entry). * diff --git a/tests/BaseTextInputContentTypeTest.php b/tests/BaseTextInputContentTypeTest.php new file mode 100644 index 0000000..0f5a311 --- /dev/null +++ b/tests/BaseTextInputContentTypeTest.php @@ -0,0 +1,94 @@ +applyAttributes($attrs); + + return $input->getResolvedProps(new CallbackRegistry); +} + +it('omits content_type entirely when the author did not set one', function (string $inputClass) { + // Absent means "declare nothing" on both platforms — neither resolver may + // see an empty string as a choice, and a secure field must not have one + // inferred for it. + $props = contentTypeProps($inputClass, ['keyboard' => 'email', 'secure' => true]); + + expect($props)->not->toHaveKey('content_type'); +})->with([ + 'outlined' => OutlinedTextInput::class, + 'filled' => FilledTextInput::class, + 'bare' => BareTextInput::class, +]); + +it('resolves content-type from every attribute spelling', function (string $inputClass, string $attribute) { + expect(contentTypeProps($inputClass, [$attribute => 'username'])['content_type'])->toBe('username'); +})->with([ + 'outlined' => OutlinedTextInput::class, + 'filled' => FilledTextInput::class, + 'bare' => BareTextInput::class, +])->with([ + 'content-type', + 'contentType', + 'autocomplete', +]); + +it('passes each supported type through unchanged', function (string $type) { + expect(contentTypeProps(OutlinedTextInput::class, ['content-type' => $type])['content_type'])->toBe($type); +})->with(['username', 'email', 'password', 'new-password', 'one-time-code']); + +it('normalizes case and spelling to the kebab-case form the natives match on', function (string $given, string $expected) { + expect(OutlinedTextInput::make()->contentType($given)->getResolvedProps(new CallbackRegistry)['content_type']) + ->toBe($expected); +})->with([ + ['newPassword', 'new-password'], + ['new_password', 'new-password'], + ['NEW-PASSWORD', 'new-password'], + ['oneTimeCode', 'one-time-code'], + [' Username ', 'username'], +]); + +it('accepts the HTML aliases for the same types', function (string $given, string $expected) { + expect(contentTypeProps(OutlinedTextInput::class, ['autocomplete' => $given])['content_type'])->toBe($expected); +})->with([ + ['current-password', 'password'], + ['email-address', 'email'], +]); + +it('ignores an empty attribute rather than declaring it', function () { + // `content-type="{{ $type }}"` renders an empty string when $type is null. + expect(contentTypeProps(OutlinedTextInput::class, ['content-type' => '']))->not->toHaveKey('content_type') + ->and(contentTypeProps(OutlinedTextInput::class, ['content-type' => ' ']))->not->toHaveKey('content_type'); +}); + +it('passes an unknown type through for the natives to ignore', function () { + // Same policy as `keyboard` / `autocapitalize`: the element does not + // gatekeep the vocabulary, both native resolvers fall back to nothing. + expect(contentTypeProps(OutlinedTextInput::class, ['content-type' => 'street-address'])['content_type']) + ->toBe('street-address'); +}); + +it('sits alongside keyboard and secure without disturbing them', function () { + $props = OutlinedTextInput::make() + ->keyboard('email') + ->contentType('username') + ->getResolvedProps(new CallbackRegistry); + + expect($props['keyboard'])->toBe('email') + ->and($props['content_type'])->toBe('username'); + + $secure = OutlinedTextInput::make() + ->secure() + ->contentType('password') + ->getResolvedProps(new CallbackRegistry); + + expect($secure['secure'])->toBeTrue() + ->and($secure['content_type'])->toBe('password'); +});