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
1 change: 1 addition & 0 deletions resources/android/BareTextInputRenderer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ object BareTextInputRenderer {
wasFocused = state.isFocused
}
.then(modifier)
.nuiContentType(props.contentType)
.nuiAutofocus(props.autofocus),
enabled = !props.disabled,
readOnly = props.readOnly,
Expand Down
1 change: 1 addition & 0 deletions resources/android/FilledTextInputRenderer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions resources/android/OutlinedTextInputRenderer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
41 changes: 41 additions & 0 deletions resources/android/TextInputShared.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -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) }
Expand Down
45 changes: 45 additions & 0 deletions resources/ios/NativeUITextInputCore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
47 changes: 46 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`, `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)
Expand Down Expand Up @@ -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();
}
Expand Down Expand Up @@ -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).
*
Expand Down
94 changes: 94 additions & 0 deletions tests/BaseTextInputContentTypeTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
<?php

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

// ── content-type (NativePHP/mobile-air#422) ──────────────────────────────────

function contentTypeProps(string $inputClass, array $attrs): array
{
$input = new $inputClass;
$input->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');
});
Loading