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
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,13 @@ Disabled controls draw from the `surface-variant` (fill) and
`on-surface-variant` (label) tokens on both platforms — adjust those two
tokens to tune disabled contrast app-wide.

`outlined-text-input` draws a transparent box by default (Material 3's
outlined container), so on a colored screen the field reads as part of the
page rather than as a field. Declare the optional `input-fill` / `on-input`
pair to give it a body of its own — `input-fill` paints the box, `on-input`
recolors everything inside it (typed text, placeholder, icons,
prefix/suffix). Leave them unset and nothing changes.

Icons accept platform enum overrides in Blade, matching the fluent API:

```blade
Expand Down
16 changes: 16 additions & 0 deletions config/native-ui.php
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,22 @@
'surface-variant' => '#F1F5F9',
'on-surface-variant' => '#475569',

// Text-field container, for the OUTLINED variant. Both are unset
// on purpose, and unset means "transparent box, Material 3
// defaults inside" — the outlined field then reads as whatever is
// behind it, which is correct on a plain page and invisible on a
// colored one. Declare the pair to give the field a body of its
// own:
//
// 'input-fill' => '#FFFFFF',
// 'on-input' => '#0F172A',
//
// `on-input` recolors everything drawn INSIDE the box — typed
// text, placeholder, icons, prefix/suffix — so declare it
// alongside any fill dark enough to swallow the default grays.
// The label and supporting text sit outside the box and keep
// taking their color from the surface behind it.

// Outline = neutral borders (text fields, dividers, cards).
// outline-variant = softer edges: hairline dividers, card seams.
'outline' => '#CBD5E1',
Expand Down
36 changes: 36 additions & 0 deletions resources/android/NativeUITheme.kt
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,25 @@ data class NativeUITokens(
val accent: Color,
val onAccent: Color,

// Text-field container. Named for the ROLE, not for a variant: this is
// the box the user types into, wherever it is drawn.
//
// [inputFill] is transparent unless the app declares `input-fill`, which
// is both Material 3's outlined container and what
// `OutlinedTextFieldDefaults.colors()` already resolved to — so an app
// that says nothing sees nothing change.
//
// [onInput] is NULL when undeclared rather than resolved to a default,
// because there is no single default to resolve to: content inside a text
// field is deliberately two-tone today (typed text `onSurface`, icons,
// labels and placeholders `onSurfaceVariant`), and collapsing that pair
// onto one token would restyle every existing field. Null therefore means
// "keep each slot's existing color"; a declared value overrides the lot,
// which is what you want the moment `input-fill` is dark enough that the
// M3 grays stop being legible on it.
val inputFill: Color,
val onInput: Color?,

// Radii
val radiusSm: Dp,
val radiusMd: Dp,
Expand Down Expand Up @@ -81,6 +100,8 @@ data class NativeUITokens(
onSuccess = parseHex("#FFFFFF"),
accent = parseHex("#FB923C"),
onAccent = parseHex("#FFFFFF"),
inputFill = Color.Transparent,
onInput = null,
radiusSm = 4.dp, radiusMd = 8.dp, radiusLg = 16.dp, radiusFull = 9999.dp,
fontSm = 14.sp, fontMd = 16.sp, fontLg = 20.sp, fontXl = 24.sp,
fontFamily = "System",
Expand Down Expand Up @@ -173,6 +194,13 @@ object NativeUITheme {
onSuccess = color(map["on-success"], fb.onSuccess),
accent = color(map["accent"], fb.accent),
onAccent = color(map["on-accent"], fb.onAccent),
inputFill = color(map["input-fill"], fb.inputFill),
// Optional on purpose — see the token declaration. `color()` can't
// express "absent", so this one keeps the fallback's own null-ness
// instead of substituting a color for it. The dark block's fallback
// is the resolved LIGHT token set, so declaring `on-input` under
// `light` alone still covers both.
onInput = optionalColor(map["on-input"], fb.onInput),
radiusSm = radiusSm, radiusMd = radiusMd, radiusLg = radiusLg, radiusFull = radiusFull,
fontSm = fontSm, fontMd = fontMd, fontLg = fontLg, fontXl = fontXl,
fontFamily = fontFamily,
Expand Down Expand Up @@ -270,6 +298,14 @@ private fun asMap(any: Any?): Map<String, Any?> = when (any) {
private fun color(any: Any?, fallback: Color): Color =
(any as? String)?.takeIf { it.startsWith("#") }?.let(::parseHex) ?: fallback

/**
* [color] for tokens whose absence is meaningful. An undeclared token yields
* the fallback — including when the fallback is itself null — so "nobody has
* declared this" survives the light → dark inheritance chain intact.
*/
private fun optionalColor(any: Any?, fallback: Color?): Color? =
(any as? String)?.takeIf { it.startsWith("#") }?.let(::parseHex) ?: fallback

private fun numDp(any: Any?, fallback: Dp): Dp = when (any) {
is Int -> any.dp
is Long -> any.toInt().dp
Expand Down
54 changes: 40 additions & 14 deletions resources/android/OutlinedTextInputRenderer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@ import com.nativephp.plugins.native_ui.NativeUITheme
*
* All colors drawn from [NativeUITheme] — per-instance color overrides are
* intentionally not honored (plan doc Model 3).
*
* The container is filled with the `input-fill` theme token and its contents
* take `on-input`. Both are transparent / absent by default, which is what
* `OutlinedTextFieldDefaults.colors()` already resolved to, so an app that
* declares neither renders exactly as before.
*/
object OutlinedTextInputRenderer {
@OptIn(ExperimentalMaterial3Api::class)
Expand Down Expand Up @@ -110,6 +115,18 @@ object OutlinedTextInputRenderer {
}
}

// Everything INSIDE the box. Two tones by default — typed text at full
// emphasis, labels, placeholders and icons muted — which is the M3
// hierarchy this renderer has always drawn. A declared `on-input`
// collapses both onto itself, because the moment `input-fill` is a
// saturated color the muted gray stops being a hierarchy and starts
// being unreadable. Supporting text is excluded: M3 draws it BELOW the
// box, on the surface behind the field, so it keeps that surface's
// colors. So is the focused label color, which is a focus accent
// (`primary`) rather than in-field content.
val fieldTextColor = theme.onInput ?: theme.onSurface
val fieldDecorationColor = theme.onInput ?: theme.onSurfaceVariant

val textSize = when (props.size) {
"sm" -> theme.fontSm
"lg" -> theme.fontLg
Expand Down Expand Up @@ -148,7 +165,7 @@ object OutlinedTextInputRenderer {
suffix = suffixSlot(props.suffix),
leadingIcon = leadingIconSlot(props.leadingIcon),
trailingIcon = if (props.loading) {
{ CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp, color = theme.onSurfaceVariant) }
{ CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp, color = fieldDecorationColor) }
} else trailingIconSlot(props.trailingIcon),
isError = props.isError,
singleLine = props.singleLine,
Expand All @@ -161,31 +178,40 @@ object OutlinedTextInputRenderer {
selectionReporter.flush(value)
dispatcher.onSubmit(value.text)
}),
textStyle = TextStyle(fontSize = textSize, color = theme.onSurface, fontFamily = customFontFamily, lineHeight = lineHeight),
textStyle = TextStyle(fontSize = textSize, color = fieldTextColor, fontFamily = customFontFamily, lineHeight = lineHeight),
colors = OutlinedTextFieldDefaults.colors(
focusedTextColor = theme.onSurface,
unfocusedTextColor = theme.onSurface,
disabledTextColor = theme.onSurface.copy(alpha = 0.6f),
errorTextColor = theme.onSurface,
focusedTextColor = fieldTextColor,
unfocusedTextColor = fieldTextColor,
disabledTextColor = fieldTextColor.copy(alpha = 0.6f),
errorTextColor = fieldTextColor,
// The container defaults to Transparent in
// OutlinedTextFieldDefaults, so naming it here changes nothing
// until `input-fill` is declared. All four states take the
// same value: a field that vanishes into the page is just as
// wrong once it's focused or in error.
focusedContainerColor = theme.inputFill,
unfocusedContainerColor = theme.inputFill,
disabledContainerColor = theme.inputFill,
errorContainerColor = theme.inputFill,
cursorColor = theme.primary,
errorCursorColor = theme.destructive,
focusedBorderColor = theme.primary,
unfocusedBorderColor = theme.outline,
disabledBorderColor = theme.outline.copy(alpha = 0.5f),
errorBorderColor = theme.destructive,
focusedLabelColor = theme.primary,
unfocusedLabelColor = theme.onSurfaceVariant,
disabledLabelColor = theme.onSurfaceVariant.copy(alpha = 0.6f),
unfocusedLabelColor = fieldDecorationColor,
disabledLabelColor = fieldDecorationColor.copy(alpha = 0.6f),
errorLabelColor = theme.destructive,
focusedPlaceholderColor = theme.onSurfaceVariant,
unfocusedPlaceholderColor = theme.onSurfaceVariant,
focusedPlaceholderColor = fieldDecorationColor,
unfocusedPlaceholderColor = fieldDecorationColor,
focusedSupportingTextColor = theme.onSurfaceVariant,
unfocusedSupportingTextColor = theme.onSurfaceVariant,
errorSupportingTextColor = theme.destructive,
focusedLeadingIconColor = theme.onSurfaceVariant,
unfocusedLeadingIconColor = theme.onSurfaceVariant,
focusedTrailingIconColor = theme.onSurfaceVariant,
unfocusedTrailingIconColor = theme.onSurfaceVariant,
focusedLeadingIconColor = fieldDecorationColor,
unfocusedLeadingIconColor = fieldDecorationColor,
focusedTrailingIconColor = fieldDecorationColor,
unfocusedTrailingIconColor = fieldDecorationColor,
),
)
}
Expand Down
5 changes: 5 additions & 0 deletions resources/boost/guidelines/core.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,11 @@
- Disabled controls use the `surface-variant` (fill) + `on-surface-variant`
(label) tokens on both platforms — tune disabled contrast by adjusting
those two tokens, not per-component.
- `outlined-text-input` has NO fill by default — its box is transparent, so on
a colored screen it stops reading as a field. Declare the optional
`input-fill` / `on-input` token pair to give it one; `on-input` recolors
everything drawn inside the box. Do not reach for `surface` / `surface-variant`
for this, or every card moves with the field.
- Buttons render their variant token solid; for a softer tonal fill set
opacity on the token itself (e.g. `'secondary' => 'fuchsia-500/70'`).
- `<native:icon>` accepts platform enum overrides as attributes —
Expand Down
62 changes: 46 additions & 16 deletions resources/ios/NativeUIOutlinedTextInputRenderer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ import SwiftUI
/// All chrome colors resolve from `NativeUITheme.shared`. Per-instance color
/// overrides are intentionally not supported (Model 3 — drop to
/// `<pressable>` for fully custom input visuals).
///
/// The box is filled with the `input-fill` theme token and its contents take
/// `on-input`. Both are transparent / absent by default, which is Material 3's
/// outlined container and reproduces this renderer exactly as it was before
/// the tokens existed; an app that wants its fields to read as fields on a
/// colored screen declares the pair and gets one.
struct NativeUIOutlinedTextInputRenderer: View {
let node: NativeUINode

Expand Down Expand Up @@ -51,6 +57,28 @@ struct NativeUIOutlinedTextInputRenderer: View {

let supportingColor: Color = isError ? theme.destructive : theme.onSurfaceVariant

// Everything INSIDE the box. Two tones by default — typed text at full
// emphasis, icons and affixes muted — which is the M3 hierarchy and
// what this renderer has always drawn. A declared `on-input` collapses
// both onto itself, because the moment `input-fill` is a saturated
// color the muted gray stops being a hierarchy and starts being
// unreadable.
//
// `label` and `supporting` sit OUTSIDE the box and are deliberately
// NOT included: they are painted on the surface behind the field, so
// they keep taking their color from it.
let fieldTextColor: Color = theme.onInput ?? theme.onSurface
let fieldDecorationColor: Color = theme.onInput ?? theme.onSurfaceVariant

// Honor user-supplied border radius via class (e.g. `rounded-full` →
// 9999 → Capsule shape). Falls back to Material 3's outlined default
// (theme.radiusMd ≈ 4pt) when no class radius is set. Hoisted out of
// the background below now that the fill and the stroke both need it —
// one shape, two paints, so they cannot drift apart.
let cornerRadius: CGFloat = (node.style?.borderRadius ?? 0) > 0
? CGFloat(node.style!.borderRadius)
: theme.radiusMd

// The visible label doubles as the field's accessibility label unless
// an explicit a11y_label override was provided. When the field is in
// an error state, the supporting text must be announced: it rides the
Expand All @@ -71,49 +99,51 @@ struct NativeUIOutlinedTextInputRenderer: View {
if !leadingIcon.isEmpty {
Image(systemName: getIconForName(leadingIcon))
.nuiScaledFont(size: metrics.iconSize)
.foregroundStyle(theme.onSurfaceVariant)
.foregroundStyle(fieldDecorationColor)
}
if !prefixText.isEmpty {
Text(prefixText)
.nuiScaledFont(size: metrics.textSize)
.foregroundStyle(theme.onSurfaceVariant)
.foregroundStyle(fieldDecorationColor)
}

NativeUITextInputCore(
node: node,
textSize: metrics.textSize,
contentColor: disabled ? theme.onSurface.opacity(0.6) : theme.onSurface,
contentColor: disabled ? fieldTextColor.opacity(0.6) : fieldTextColor,
tintColor: isError ? theme.destructive : theme.primary
)
.frame(maxWidth: .infinity, alignment: .leading)

if !suffixText.isEmpty {
Text(suffixText)
.nuiScaledFont(size: metrics.textSize)
.foregroundStyle(theme.onSurfaceVariant)
.foregroundStyle(fieldDecorationColor)
}
if loading {
ProgressView().controlSize(.small)
} else if !trailingIcon.isEmpty {
Image(systemName: getIconForName(trailingIcon))
.nuiScaledFont(size: metrics.iconSize)
.foregroundStyle(theme.onSurfaceVariant)
.foregroundStyle(fieldDecorationColor)
}
}
.padding(.horizontal, metrics.hPadding)
.padding(.vertical, metrics.vPadding)
.background(
// Honor user-supplied border radius via class (e.g.
// `rounded-full` → 9999 → Capsule shape). Falls back to
// Material 3's outlined default (theme.radiusMd ≈ 4pt)
// when no class radius is set.
RoundedRectangle(
cornerRadius: (node.style?.borderRadius ?? 0) > 0
? CGFloat(node.style!.borderRadius)
: theme.radiusMd,
style: .continuous
)
.stroke(borderColor, lineWidth: isError ? 2 : 1)
// Fill first, stroke over it, both on the same shape so the
// border still sits exactly where it did — a stroke centers on
// its path, and the path here is the background's own frame,
// unchanged. The fill is decoration only: `.allowsHitTesting`
// keeps it from swallowing taps that used to fall through the
// hollow middle of the box.
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
.fill(theme.inputFill)
.allowsHitTesting(false)
.overlay(
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
.stroke(borderColor, lineWidth: isError ? 2 : 1)
)
)
.opacity(disabled ? 0.6 : 1.0)
.allowsHitTesting(!disabled && !readOnly)
Expand Down
Loading