Skip to content

Add placeholder-color to bare-text-input - #46

Open
kgethegodev wants to merge 1 commit into
NativePHP:mainfrom
kgethegodev:feat/bare-text-input-placeholder-color
Open

Add placeholder-color to bare-text-input#46
kgethegodev wants to merge 1 commit into
NativePHP:mainfrom
kgethegodev:feat/bare-text-input-placeholder-color

Conversation

@kgethegodev

@kgethegodev kgethegodev commented Aug 10, 2026

Copy link
Copy Markdown

Summary

bare-text-input (the chromeless text-input variant) already supports a per-instance color for 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), mirroring color's existing hex/Tailwind-token grammar end-to-end, scoped to the bare variant only (outlined/filled stay Model 3 theme-only, matching how color is scoped today).

What changed

  • PHP (src/Elements/BareTextInput.php): new placeholderColor() / darkPlaceholderColor() setters, wired from the placeholder-color/placeholderColor and dark-placeholder-color/darkPlaceholderColor attributes, resolved through the same resolveColorValue() grammar as color (hex, Tailwind palette name, /N opacity modifier).

    Note on the dark variant: color's dark companion (dark_color) is auto-derived by the collector from a dark:text-* Tailwind class on class=. There's no placeholder-* Tailwind class parsed anywhere in the collector, so there's nothing to derive a dark companion from. dark-placeholder-color is instead a plain sibling attribute — the same shape Icon already uses for its own dark-color override (src/Elements/Icon.php), which faced the identical problem.

  • iOS (resources/ios/NativeUITextInputCore.swift, NativeUIBareTextInputRenderer.swift): NativeUITextInputCore takes a new optional placeholderColor: Color?. Applied via SwiftUI's TextField(_:text:prompt:) / SecureField(_:text:prompt:) family — the title param still carries placeholder (used for accessibility), while prompt: 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): reads placeholder_color / dark_placeholder_color off the node props and gives them priority over the existing derived fallback (60%-alpha of the effective text color) that's used when only color/dark_color is set.

  • Tests (tests/ElementColorTest.php): added a case mirroring the existing color/dark-color resolution tests, asserting placeholder-color and color resolve independently and dark-placeholder-color carries its opacity modifier through correctly.

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 shanerbaner82 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:), and SecureField<S>(_:text:prompt:). The defaulted var placeholderColor: Color? = nil keeps the memberwise init source-compatible with the two existing call sites in the outlined/filled renderers, which pass no placeholder color — prompt: nil is 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 that class="text-slate-700 dark:text-slate-300" alongside placeholder-color/dark-placeholder-color produces exactly {color, dark_color, placeholder_color, dark_placeholder_color}buildDarkProps does not clobber the sibling attribute. Degenerate values ("", notacolor, a valueless attribute) fall through to native ColorParser, which returns the default rather than throwing, so there's no crash path.
  • No merge conflictmain has moved (c311666 touches 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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants