From 1e2ce927103dd660298549ba956a3f8e72903113 Mon Sep 17 00:00:00 2001 From: Conor Sinclair Date: Mon, 18 May 2026 17:44:09 +0200 Subject: [PATCH] text_field: secure: true for masked password input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a `secure: true` prop to `text_field`. iOS swaps the underlying SwiftUI control from `TextField` to `SecureField` when the prop is set; cleartext still reaches the BEAM via `on_change` so apps can hash/store the value normally. The prop is a plain pass-through through `Mob.Renderer.prepare_props/4` — unknown atoms-as-values already serialise as-is — so no renderer changes were needed. Android consumes the same prop via `PasswordVisualTransformation` once mob_new's `MobBridge.kt.eex` template is updated in a companion PR. Until then `secure: true` is a graceful no-op on Android — the prop travels but the Compose `TextField` ignores it (regular field, no breakage). Reveal-toggle ("eye" button) is intentionally deferred. The iOS implementation needs `ZStack`-and-opacity to retain focus across SecureField/TextField swaps (a plain if/else tears down the field, dropping focus + dismissing the keyboard on every toggle). Worth its own PR with the matching Compose `trailingIcon` slot wired up. CHANGELOG entry lands under [Unreleased] — version bump rides along with the next release commit per the repo's pattern. --- CHANGELOG.md | 16 ++++++++++++++++ ios/MobNode.h | 1 + ios/MobRootView.swift | 11 ++++++++++- ios/mob_nif.m | 4 ++++ test/mob/renderer_test.exs | 16 ++++++++++++++++ 5 files changed, 47 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ca845233..576531f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,22 @@ Full module documentation: [hexdocs.pm/mob](https://hexdocs.pm/mob). --- +## [Unreleased] + +### Added +- `text_field` now accepts a `secure: true` prop. iOS renders the field + as a SwiftUI `SecureField` (masked input) instead of the plain + `TextField`. The prop flows through the existing renderer + passthrough; cleartext still reaches the BEAM via `on_change` so apps + can hash/store the value as normal. Android consumes the same prop + via `PasswordVisualTransformation` once `mob_new`'s `MobBridge.kt.eex` + template is updated in a companion PR — until then the prop is a + graceful no-op on Android (renders as a regular field), no breakage. + + Reveal-toggle ("eye" button) is intentionally deferred — its + interaction with SwiftUI focus retention requires a `ZStack`-and-opacity + rebuild of `MobTextField` and warrants its own change. + ## [0.6.7] ### Added diff --git a/ios/MobNode.h b/ios/MobNode.h index 8f8ba9a4..a21cae7e 100644 --- a/ios/MobNode.h +++ b/ios/MobNode.h @@ -125,6 +125,7 @@ NS_ASSUME_NONNULL_BEGIN @property(nonatomic, copy, nonnull) NSString *keyboardTypeStr; // "default","number","decimal","email","phone","url" @property(nonatomic, copy, nonnull) NSString *returnKeyStr; // "done","next","go","search","send" +@property(nonatomic, assign) BOOL isSecure; // mask input (SecureField on iOS) @property(nonatomic, copy, nullable) void (^onFocus)(void); @property(nonatomic, copy, nullable) void (^onBlur)(void); @property(nonatomic, copy, nullable) void (^onSubmit)(void); diff --git a/ios/MobRootView.swift b/ios/MobRootView.swift index 8701aba6..6a9ae1fb 100644 --- a/ios/MobRootView.swift +++ b/ios/MobRootView.swift @@ -1028,8 +1028,17 @@ private struct MobTextField: View { } } + @ViewBuilder + private var field: some View { + if node.isSecure { + SecureField(placeholder, text: $text) + } else { + TextField(placeholder, text: $text) + } + } + var body: some View { - TextField(placeholder, text: $text) + field .focused($isFocused) .keyboardType(keyboardType) .submitLabel(submitLabel) diff --git a/ios/mob_nif.m b/ios/mob_nif.m index 0fc43bc3..2a91a112 100644 --- a/ios/mob_nif.m +++ b/ios/mob_nif.m @@ -797,6 +797,10 @@ static void mob_send_change_float(int handle, double value) { if ([returnKey isKindOfClass:[NSString class]]) node.returnKeyStr = returnKey; + id secure = props[@"secure"]; + if ([secure isKindOfClass:[NSNumber class]]) + node.isSecure = [secure boolValue]; + id onFocus = props[@"on_focus"]; if (onFocus && [onFocus isKindOfClass:[NSNumber class]]) { int handle = [onFocus intValue]; diff --git a/test/mob/renderer_test.exs b/test/mob/renderer_test.exs index 1f35da2c..ad924e82 100644 --- a/test/mob/renderer_test.exs +++ b/test/mob/renderer_test.exs @@ -544,6 +544,22 @@ defmodule Mob.RendererTest do assert decoded["props"]["return_key"] == "next" end + test "secure boolean is passed through unchanged" do + tree = %{type: :text_field, props: %{value: "", secure: true}, children: []} + Renderer.render(tree, :android, MockNIF) + {:set_root, [json]} = Enum.find(MockNIF.calls(), fn {f, _} -> f == :set_root end) + decoded = :json.decode(json) + assert decoded["props"]["secure"] == true + end + + test "secure defaults to absent when unset" do + tree = %{type: :text_field, props: %{value: ""}, children: []} + Renderer.render(tree, :android, MockNIF) + {:set_root, [json]} = Enum.find(MockNIF.calls(), fn {f, _} -> f == :set_root end) + decoded = :json.decode(json) + refute Map.has_key?(decoded["props"], "secure") + end + test "register_tap receives {pid, tag} for tagged taps" do pid = self() tree = %{type: :button, props: %{text: "Tap", on_tap: {pid, :my_action}}, children: []}