From 55a83fd2b6126593129f8031c41bb070e8ea6fa2 Mon Sep 17 00:00:00 2001 From: Kgethego Masilo Date: Mon, 10 Aug 2026 11:54:57 +0200 Subject: [PATCH 1/2] Add placeholder-color to bare-text-input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bare variant already supports a per-instance `color` for the typed text (with a `dark:text-*`-driven dark companion), but the placeholder always rendered in the platform default gray with no way to override it — a problem for custom chrome (glass pills, dark cards) where the default placeholder gray reads as low-contrast or clashes with the surrounding palette. Adds `placeholder-color` / `dark-placeholder-color`, mirroring `color`'s hex/Tailwind-token grammar end-to-end: - PHP: `BareTextInput::placeholderColor()` / `darkPlaceholderColor()`, wired from the `placeholder-color`/`placeholderColor` and `dark-placeholder-color`/`darkPlaceholderColor` attrs, resolved through the same `resolveColorValue()` grammar as `color`. There's no `placeholder-*` Tailwind class for the collector to derive a dark companion from (unlike `dark:text-*` -> `dark_color`), so the dark variant is a plain sibling attribute instead — the same shape `Icon` already uses for its `dark-color`. - iOS: `NativeUITextInputCore` takes an optional `placeholderColor` and applies it via SwiftUI's `TextField(_:text:prompt:)` family, where the `prompt: Text?` param recolors the placeholder independently of the accessibility title and the typed text. `nil` (outlined/filled never pass it) falls back to the unstyled default, so this is a no-op for every other variant. - Android: `BareTextInputRenderer` reads `placeholder_color` / `dark_placeholder_color` and gives them priority over the existing 60%-alpha-of-text-color fallback used when only `color` is set. Scoped to the bare variant only, matching `color` — outlined/filled stay Model 3 theme-only. Co-Authored-By: Claude Sonnet 5 --- resources/android/BareTextInputRenderer.kt | 26 +++++++++++----- .../ios/NativeUIBareTextInputRenderer.swift | 16 +++++++++- resources/ios/NativeUITextInputCore.swift | 21 +++++++++---- src/Elements/BareTextInput.php | 30 +++++++++++++++++++ tests/ElementColorTest.php | 12 ++++++++ 5 files changed, 92 insertions(+), 13 deletions(-) diff --git a/resources/android/BareTextInputRenderer.kt b/resources/android/BareTextInputRenderer.kt index a52a7ef..71379b5 100644 --- a/resources/android/BareTextInputRenderer.kt +++ b/resources/android/BareTextInputRenderer.kt @@ -40,6 +40,11 @@ import com.nativephp.plugins.native_ui.NativeUITheme * - explicit attribute: `color="#334155"` * - tailwind class on the input: `class="text-slate-700"` * - dark mode: `class="text-slate-700 dark:text-slate-300"` + * + * `placeholder_color` (+ `dark_placeholder_color`) recolors the placeholder + * independently of `color`. There's no `placeholder-*` Tailwind class to + * derive a dark companion from, so the dark variant is a plain sibling + * attribute (`dark-placeholder-color`) rather than a `dark:` class variant. */ object BareTextInputRenderer { @Composable @@ -63,13 +68,20 @@ object BareTextInputRenderer { } val displayedTextColor = if (props.disabled) effectiveTextColor.copy(alpha = 0.6f) else effectiveTextColor - // Placeholder follows the override too (faded by ~60%) so a - // dark-text input on a light pill keeps a readable placeholder - // in the same family. - val placeholderColor = if (colorArgb != 0 || darkOverrideArgb != 0) { - effectiveTextColor.copy(alpha = 0.6f) - } else { - theme.onSurfaceVariant + // Placeholder color — independent of `color`/`dark_color`. Explicit + // `placeholder_color`/`dark_placeholder_color` (no `placeholder-*` + // Tailwind class exists to derive the dark companion automatically, + // so it's a plain sibling attribute, same shape as `Icon`'s + // `dark-color`) wins outright; otherwise fall back to the `color` + // override faded ~60% so a dark-text input on a light pill still + // gets a readable placeholder in the same family. + val darkPlaceholderOverrideArgb = if (isDark) node.props.getColor("dark_placeholder_color", 0) else 0 + val placeholderOverrideArgb = node.props.getColor("placeholder_color", 0) + val placeholderColor = when { + darkPlaceholderOverrideArgb != 0 -> argbToComposeColor(darkPlaceholderOverrideArgb) + placeholderOverrideArgb != 0 -> argbToComposeColor(placeholderOverrideArgb) + colorArgb != 0 || darkOverrideArgb != 0 -> effectiveTextColor.copy(alpha = 0.6f) + else -> theme.onSurfaceVariant } // Echo-prevention sync — same shape as the outlined variant, now over a diff --git a/resources/ios/NativeUIBareTextInputRenderer.swift b/resources/ios/NativeUIBareTextInputRenderer.swift index cbb4ba2..1870e8d 100644 --- a/resources/ios/NativeUIBareTextInputRenderer.swift +++ b/resources/ios/NativeUIBareTextInputRenderer.swift @@ -56,11 +56,25 @@ struct NativeUIBareTextInputRenderer: View { return theme.primary }() + // Placeholder color override — independent of `color`/`dark_color`. + // No `placeholder-*` Tailwind class exists to drive a dark + // companion the way `dark:text-*` does for `color`, so + // `dark_placeholder_color` is set directly from a sibling + // `dark-placeholder-color` attribute (mirrors `Icon`'s `dark-color`). + let darkPlaceholderArgb = colorScheme == .dark ? p.getColor("dark_placeholder_color", default: 0) : 0 + let lightPlaceholderArgb = p.getColor("placeholder_color", default: 0) + let placeholderOverride: Color? = { + if darkPlaceholderArgb != 0 { return Color(argb: darkPlaceholderArgb) } + if lightPlaceholderArgb != 0 { return Color(argb: lightPlaceholderArgb) } + return nil + }() + NativeUITextInputCore( node: node, textSize: textSize, contentColor: disabled ? baseTextColor.opacity(0.6) : baseTextColor, - tintColor: resolvedTint + tintColor: resolvedTint, + placeholderColor: placeholderOverride ) .opacity(disabled ? 0.6 : 1.0) .allowsHitTesting(!disabled && !readOnly) diff --git a/resources/ios/NativeUITextInputCore.swift b/resources/ios/NativeUITextInputCore.swift index 0c64322..b1ee9e4 100644 --- a/resources/ios/NativeUITextInputCore.swift +++ b/resources/ios/NativeUITextInputCore.swift @@ -20,6 +20,10 @@ struct NativeUITextInputCore: View { let textSize: CGFloat let contentColor: Color let tintColor: Color + /// Bare-variant-only placeholder color override (`placeholder_color` / + /// `dark_placeholder_color`). `nil` for outlined/filled, which never + /// pass this — the placeholder then keeps the platform default gray. + var placeholderColor: Color? = nil @State private var text: String = "" @State private var lastSentValue: String = "" @@ -82,6 +86,13 @@ struct NativeUITextInputCore: View { fontSize: textSize, fontName: fontName ) + // `placeholder` doubles as the field's accessibility title in every + // init below; `prompt` (when non-nil) is what actually renders as + // the placeholder, letting us recolor it independently of the typed + // text without touching accessibility. `nil` falls back to the + // platform's default placeholder styling — unchanged from before + // this prop existed. + let styledPrompt: Text? = placeholderColor.map { Text(placeholder).foregroundColor($0) } // Apply `.foregroundColor` (not just `.foregroundStyle`) so the TYPED // text adopts `contentColor`. SwiftUI's TextField/SecureField don't @@ -91,7 +102,7 @@ struct NativeUITextInputCore: View { if secure { // SecureField has no selection binding — caret reporting is // intentionally never available for secure fields. - SecureField(placeholder, text: $text) + SecureField(placeholder, text: $text, prompt: styledPrompt) .foregroundColor(contentColor) .focused($isFocused) } else if multiline { @@ -108,13 +119,13 @@ struct NativeUITextInputCore: View { // (multiline) axis too. Kept as parallel branches so the // feature-off path is byte-for-byte the original field. if selectionEnabled { - TextField(placeholder, text: $text, selection: $selection, axis: .vertical) + TextField(placeholder, text: $text, selection: $selection, prompt: styledPrompt, axis: .vertical) .lineLimit(lower...upper) .foregroundColor(contentColor) .frame(maxWidth: .infinity, alignment: .leading) .focused($isFocused) } else { - TextField(placeholder, text: $text, axis: .vertical) + TextField(placeholder, text: $text, prompt: styledPrompt, axis: .vertical) .lineLimit(lower...upper) .foregroundColor(contentColor) .frame(maxWidth: .infinity, alignment: .leading) @@ -122,11 +133,11 @@ struct NativeUITextInputCore: View { } } else { if selectionEnabled { - TextField(placeholder, text: $text, selection: $selection) + TextField(placeholder, text: $text, selection: $selection, prompt: styledPrompt) .foregroundColor(contentColor) .focused($isFocused) } else { - TextField(placeholder, text: $text) + TextField(placeholder, text: $text, prompt: styledPrompt) .foregroundColor(contentColor) .focused($isFocused) } diff --git a/src/Elements/BareTextInput.php b/src/Elements/BareTextInput.php index c5424d2..179aad9 100644 --- a/src/Elements/BareTextInput.php +++ b/src/Elements/BareTextInput.php @@ -41,6 +41,14 @@ class BareTextInput extends BaseTextInput * Dark mode override: `class="text-slate-700 dark:text-slate-300"` * — the collector's `buildDarkProps` already maps `dark.color` to * the `dark_color` prop, which the renderers also honor. + * + * `placeholder-color` (+ `dark-placeholder-color`) styles the + * placeholder text independently of `color` — there's no `placeholder-*` + * Tailwind class parsed by the collector, so unlike `color` the dark + * companion is a plain sibling attribute (same shape as `Icon`'s + * `dark-color`) rather than a `dark:` class variant: + * - `placeholder-color="#94a3b8"` / `placeholder-color="slate-400"` + * - `dark-placeholder-color="slate-500"` */ public function applyAttributes(array $attrs): void { @@ -49,6 +57,14 @@ public function applyAttributes(array $attrs): void if (isset($attrs['color'])) { $this->color($attrs['color']); } + + if (isset($attrs['placeholder-color']) || isset($attrs['placeholderColor'])) { + $this->placeholderColor($attrs['placeholder-color'] ?? $attrs['placeholderColor']); + } + + if (isset($attrs['dark-placeholder-color']) || isset($attrs['darkPlaceholderColor'])) { + $this->darkPlaceholderColor($attrs['dark-placeholder-color'] ?? $attrs['darkPlaceholderColor']); + } } public function color(string $color): static @@ -62,6 +78,20 @@ public function color(string $color): static return $this; } + public function placeholderColor(string $color): static + { + $this->inputProps['placeholder_color'] = $this->resolveColorValue($color); + + return $this; + } + + public function darkPlaceholderColor(string $color): static + { + $this->inputProps['dark_placeholder_color'] = $this->resolveColorValue($color); + + return $this; + } + /** * Lift the Model 3 style lockout that `BaseTextInput` enforces for * the outlined / filled variants. The bare variant is explicitly diff --git a/tests/ElementColorTest.php b/tests/ElementColorTest.php index 5dceb9f..b0b4f01 100644 --- a/tests/ElementColorTest.php +++ b/tests/ElementColorTest.php @@ -67,6 +67,18 @@ function collectProps(string $type, array $attrs): array ->toBe('#334155'); }); +it('resolves bare input placeholder colors independently of color', function () { + $props = collectProps('bare_text_input', [ + 'color' => 'slate-700', + 'placeholder-color' => 'slate-400', + 'dark-placeholder-color' => 'slate-500/50', + ]); + + expect($props['color'])->toBe('#334155'); + expect($props['placeholder_color'])->toBe('#94A3B8'); + expect($props['dark_placeholder_color'])->toBe('#8064748B'); +}); + it('resolves list item color props', function () { $props = collectProps('list_item', [ 'headline' => 'Inbox', From 69fd928c2de5e2955550730143fd46e939b97837 Mon Sep 17 00:00:00 2001 From: Kgethego Masilo Date: Sun, 16 Aug 2026 18:30:44 +0200 Subject: [PATCH 2/2] Address review: fix placeholder label leak, deprecated API, disabled fade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - iOS: add .labelsHidden() to the bare-text-input fields — a non-nil prompt promotes the placeholder text to the field's label, which List and Form render visibly, so placeholder-color could double-render the placeholder text as a leading row label. - iOS: swap Text.foregroundColor(_:) for foregroundStyle(_:) on the styled prompt to drop the deprecation warning (foregroundColor is renamed on Text; the field-level color below stays foregroundColor intentionally). - Android: fade placeholder_color/dark_placeholder_color by the same 0.6 alpha as the typed text when disabled, so a disabled bare input reads as disabled on Android the same way it fades via .opacity(0.6) on iOS. Addresses shanerbaner82's review on #46. Co-Authored-By: Claude Sonnet 5 --- resources/android/BareTextInputRenderer.kt | 13 ++++++++++--- resources/ios/NativeUITextInputCore.swift | 11 ++++++++++- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/resources/android/BareTextInputRenderer.kt b/resources/android/BareTextInputRenderer.kt index 71379b5..3c7be58 100644 --- a/resources/android/BareTextInputRenderer.kt +++ b/resources/android/BareTextInputRenderer.kt @@ -74,12 +74,19 @@ object BareTextInputRenderer { // so it's a plain sibling attribute, same shape as `Icon`'s // `dark-color`) wins outright; otherwise fall back to the `color` // override faded ~60% so a dark-text input on a light pill still - // gets a readable placeholder in the same family. + // gets a readable placeholder in the same family. The two explicit + // overrides get the same disabled fade as `displayedTextColor` + // above, matching iOS's field-wide `.opacity(0.6)` when disabled. val darkPlaceholderOverrideArgb = if (isDark) node.props.getColor("dark_placeholder_color", 0) else 0 val placeholderOverrideArgb = node.props.getColor("placeholder_color", 0) + val disabledFade = props.disabled val placeholderColor = when { - darkPlaceholderOverrideArgb != 0 -> argbToComposeColor(darkPlaceholderOverrideArgb) - placeholderOverrideArgb != 0 -> argbToComposeColor(placeholderOverrideArgb) + darkPlaceholderOverrideArgb != 0 -> argbToComposeColor(darkPlaceholderOverrideArgb).let { + if (disabledFade) it.copy(alpha = it.alpha * 0.6f) else it + } + placeholderOverrideArgb != 0 -> argbToComposeColor(placeholderOverrideArgb).let { + if (disabledFade) it.copy(alpha = it.alpha * 0.6f) else it + } colorArgb != 0 || darkOverrideArgb != 0 -> effectiveTextColor.copy(alpha = 0.6f) else -> theme.onSurfaceVariant } diff --git a/resources/ios/NativeUITextInputCore.swift b/resources/ios/NativeUITextInputCore.swift index b1ee9e4..997671c 100644 --- a/resources/ios/NativeUITextInputCore.swift +++ b/resources/ios/NativeUITextInputCore.swift @@ -92,7 +92,7 @@ struct NativeUITextInputCore: View { // text without touching accessibility. `nil` falls back to the // platform's default placeholder styling — unchanged from before // this prop existed. - let styledPrompt: Text? = placeholderColor.map { Text(placeholder).foregroundColor($0) } + let styledPrompt: Text? = placeholderColor.map { Text(placeholder).foregroundStyle($0) } // Apply `.foregroundColor` (not just `.foregroundStyle`) so the TYPED // text adopts `contentColor`. SwiftUI's TextField/SecureField don't @@ -105,6 +105,11 @@ struct NativeUITextInputCore: View { SecureField(placeholder, text: $text, prompt: styledPrompt) .foregroundColor(contentColor) .focused($isFocused) + // `prompt` (when non-nil) turns `placeholder` into this + // field's label — visibly rendered by containers like + // `List`/`Form`. `.labelsHidden()` keeps it invisible + // (a no-op when `prompt == nil`, i.e. outlined/filled). + .labelsHidden() } else if multiline { // A vertical-axis TextField reports a ~0 intrinsic width when // empty and won't expand to fill an ancestor's `maxWidth: @@ -124,22 +129,26 @@ struct NativeUITextInputCore: View { .foregroundColor(contentColor) .frame(maxWidth: .infinity, alignment: .leading) .focused($isFocused) + .labelsHidden() } else { TextField(placeholder, text: $text, prompt: styledPrompt, axis: .vertical) .lineLimit(lower...upper) .foregroundColor(contentColor) .frame(maxWidth: .infinity, alignment: .leading) .focused($isFocused) + .labelsHidden() } } else { if selectionEnabled { TextField(placeholder, text: $text, selection: $selection, prompt: styledPrompt) .foregroundColor(contentColor) .focused($isFocused) + .labelsHidden() } else { TextField(placeholder, text: $text, prompt: styledPrompt) .foregroundColor(contentColor) .focused($isFocused) + .labelsHidden() } } }