Add placeholder-color to bare-text-input - #46
Conversation
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 <noreply@anthropic.com>
shanerbaner82
left a comment
There was a problem hiding this comment.
Reviewed the 5-file diff against the surrounding renderers, the core collector, and the iOS SDK's SwiftUI interface. Three low-severity findings, nothing blocking — the design (explicit sibling dark-placeholder-color rather than a dark: class variant, mirroring Icon's dark-color) is the right call given no placeholder-* Tailwind class exists to derive from.
Verified clean:
- Swift compiles. Every new initializer exists in the SDK with the exact argument order used —
TextField<S>(_:text:prompt:),(_:text:prompt:axis:),(_:text:selection:prompt:axis:), andSecureField<S>(_:text:prompt:). The defaultedvar placeholderColor: Color? = nilkeeps the memberwise init source-compatible with the two existing call sites in the outlined/filled renderers, which pass no placeholder color —prompt: nilis behaviourally identical to the old inits. - PHP end-to-end. Ran the suite against this branch's
src: 214 passed, including the new case. Confirmed through the real collector path thatclass="text-slate-700 dark:text-slate-300"alongsideplaceholder-color/dark-placeholder-colorproduces exactly{color, dark_color, placeholder_color, dark_placeholder_color}—buildDarkPropsdoes not clobber the sibling attribute. Degenerate values ("",notacolor, a valueless attribute) fall through to nativeColorParser, which returns the default rather than throwing, so there's no crash path. - No merge conflict —
mainhas moved (c311666touches the same file) but in a different region.
Details inline.
| // SecureField has no selection binding — caret reporting is | ||
| // intentionally never available for secure fields. | ||
| SecureField(placeholder, text: $text) | ||
| SecureField(placeholder, text: $text, prompt: styledPrompt) |
There was a problem hiding this comment.
Moving to prompt: changes what the first argument means.
With prompt == nil the title is the placeholder — the current behaviour. With a non-nil prompt the title becomes the field's label, and containers that show labels render it visibly.
NativeUIListRenderer.swift:61 wraps arbitrary children in a SwiftUI List (same at NativeUIVirtualListRenderer.swift:76), and BareTextInput's own docblock advertises "inline editors" as a use case. So:
<native:list>
<native:bare-text-input placeholder="Name" placeholder-color="slate-400" />
</native:list>can render "Name" twice — once as a leading label, once as the coloured placeholder. It's latent today and only appears once someone sets the new attribute, which makes it the kind of thing that gets reported as "placeholder-color broke my list" rather than traced back here.
.labelsHidden() on the field is the cheap guard. Worth a device check inside a <native:list> before merge.
| // 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) } |
There was a problem hiding this comment.
Text.foregroundColor(_:) is deprecated — the SDK marks it renamed: "foregroundStyle(_:)". This adds a deprecation warning to every consuming app's Xcode build, and 0699b3c landed on main recently for exactly that reason (silencing the deprecated databaseEnabled warning).
SwiftUI has shipped a Text-returning foregroundStyle(_:) overload since iOS 17, and this file already requires iOS 18 (the unguarded TextSelection state above), so the swap is free and keeps the Text? type:
let styledPrompt: Text? = placeholderColor.map { Text(placeholder).foregroundStyle($0) }Note this is specifically the Text-returning overload — unlike the .foregroundColor on the fields below, which the comment there explains is deliberate.
| // 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 { |
There was a problem hiding this comment.
The explicit placeholder colour bypasses the disabled treatment.
displayedTextColor on the line above fades typed text to alpha = 0.6f when props.disabled, and the two fallback branches here inherit that fade through effectiveTextColor. But the two new override branches return argbToComposeColor(...) at full strength, and Compose applies no view-level alpha to compensate.
<native:bare-text-input disabled placeholder="Message" placeholder-color="slate-400" />renders a full-strength placeholder on Android, while iOS fades the whole field via .opacity(0.6) in NativeUIBareTextInputRenderer. The same markup reads as enabled on one platform and disabled on the other.
Applying the same .copy(alpha = 0.6f) to the resolved colour when props.disabled would restore parity.
Summary
bare-text-input(the chromeless text-input variant) already supports a per-instancecolorfor the typed text, but the placeholder always rendered in the platform default gray with no way to override it. This is a real problem for custom chrome — glass pills, dark cards, anywhere the wrapper picks its own background — where the default placeholder gray can end up low-contrast or clash with the surrounding palette.This adds
placeholder-color(+dark-placeholder-color), mirroringcolor's existing hex/Tailwind-token grammar end-to-end, scoped to the bare variant only (outlined/filled stay Model 3 theme-only, matching howcoloris scoped today).What changed
PHP (
src/Elements/BareTextInput.php): newplaceholderColor()/darkPlaceholderColor()setters, wired from theplaceholder-color/placeholderColoranddark-placeholder-color/darkPlaceholderColorattributes, resolved through the sameresolveColorValue()grammar ascolor(hex, Tailwind palette name,/Nopacity modifier).Note on the dark variant:
color's dark companion (dark_color) is auto-derived by the collector from adark:text-*Tailwind class onclass=. There's noplaceholder-*Tailwind class parsed anywhere in the collector, so there's nothing to derive a dark companion from.dark-placeholder-coloris instead a plain sibling attribute — the same shapeIconalready uses for its owndark-coloroverride (src/Elements/Icon.php), which faced the identical problem.iOS (
resources/ios/NativeUITextInputCore.swift,NativeUIBareTextInputRenderer.swift):NativeUITextInputCoretakes a new optionalplaceholderColor: Color?. Applied via SwiftUI'sTextField(_:text:prompt:)/SecureField(_:text:prompt:)family — thetitleparam still carriesplaceholder(used for accessibility), whileprompt: Text?is what actually renders as the visible placeholder when non-nil, letting it be recolored independently of both the accessibility label and the typed text.nil(the default, and what outlined/filled always pass) falls back to the platform's default placeholder styling — zero behavior change for every other variant. Verified by typechecking the modified files directly against the iOS 18.2 simulator SDK.Android (
resources/android/BareTextInputRenderer.kt): readsplaceholder_color/dark_placeholder_coloroff the node props and gives them priority over the existing derived fallback (60%-alpha of the effective text color) that's used when onlycolor/dark_coloris set.Tests (
tests/ElementColorTest.php): added a case mirroring the existingcolor/dark-colorresolution tests, assertingplaceholder-colorandcolorresolve independently anddark-placeholder-colorcarries its opacity modifier through correctly.