Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 26 additions & 7 deletions resources/android/BareTextInputRenderer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {

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.

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
Expand Down
16 changes: 15 additions & 1 deletion resources/ios/NativeUIBareTextInputRenderer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
30 changes: 25 additions & 5 deletions resources/ios/NativeUITextInputCore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ""
Expand Down Expand Up @@ -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
Expand All @@ -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)

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.

.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:
Expand All @@ -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()
}
}
}
Expand Down
30 changes: 30 additions & 0 deletions src/Elements/BareTextInput.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -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
Expand All @@ -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
Expand Down
12 changes: 12 additions & 0 deletions tests/ElementColorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down