diff --git a/resources/android/BareTextInputRenderer.kt b/resources/android/BareTextInputRenderer.kt index a52a7ef..3c7be58 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,27 @@ 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. 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).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 } // 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..997671c 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).foregroundStyle($0) } // Apply `.foregroundColor` (not just `.foregroundStyle`) so the TYPED // text adopts `contentColor`. SwiftUI's TextField/SecureField don't @@ -91,9 +102,14 @@ 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) + // `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: @@ -108,27 +124,31 @@ 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) + .labelsHidden() } 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) .focused($isFocused) + .labelsHidden() } } else { if selectionEnabled { - TextField(placeholder, text: $text, selection: $selection) + TextField(placeholder, text: $text, selection: $selection, prompt: styledPrompt) .foregroundColor(contentColor) .focused($isFocused) + .labelsHidden() } else { - TextField(placeholder, text: $text) + TextField(placeholder, text: $text, prompt: styledPrompt) .foregroundColor(contentColor) .focused($isFocused) + .labelsHidden() } } } 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',