Give the outlined text input a fill color of its own - #60
Open
CodyPChristian wants to merge 1 commit into
Open
Conversation
`NativeUIOutlinedTextInputRenderer` draws its box as a bare `.stroke` and paints nothing inside it. That is Material 3's outlined container — correct, and fine on a white page — but it means the field's background is literally whatever happens to be behind it, and no theme token can change that. Put the same form on a colored screen and the field stops reading as a field: a rounded outline floating on the page color, with the M3 grays for its icons and placeholder chosen against a light background they are no longer on. The workaround today is `surface` or `surface-variant`, and it isn't one. Those tokens are shared with cards, sheets and list rows, so recoloring the field recolors everything else with it. There is no token whose scope is "the box the user types into". Two new optional tokens, named for that role rather than for a variant: 'input-fill' => '#FFFFFF', // the box 'on-input' => '#0F172A', // everything drawn inside it `input-fill` is transparent when undeclared, so a theme that says nothing renders byte-for-byte what it rendered before. `on-input` is genuinely OPTIONAL rather than defaulted — nil, not a color — because there is no single default it could take: a field is deliberately two-tone today (typed text `on-surface`, icons and affixes `on-surface-variant`), and collapsing that pair onto one token would restyle every existing field. Nil means each call site keeps its own color; a declared value takes the lot, which is what you want the moment the fill is dark enough that the muted gray stops being a hierarchy and starts being unreadable. `label` and `supporting` stay on `on-surface-variant`. They sit outside the box, on the surface behind the field, and should keep taking their color from it. The stroke moves into an overlay on the filled shape. Same shape, same `cornerRadius` (hoisted to a local now that two paints need it), same frame, so the border lands exactly where it did. The fill takes `.allowsHitTesting(false)`: a stroke only hit-tests its own line, so the hollow middle of the box used to let taps through, and a decorative fill should not start absorbing them. Android gets the same pair. `OutlinedTextFieldDefaults.colors()` already resolves the container to Transparent, so naming the four container states explicitly changes nothing until `input-fill` is declared. Two open threads this deliberately does not cut across: - NativePHP#46 (placeholder-color on bare-text-input) solves the neighboring problem from the other end — a per-instance color on the variant whose whole contract is per-instance colors. That is the right shape there and the wrong shape here: outlined and filled reject per-instance colors by design (Model 3), so their answer has to be a theme token. The two do not overlap; if NativePHP#46 lands first, the placeholder inside an outlined field is still an open question, and `on-input` is the natural place to answer it. - NativePHP#26 (collapse outlined + filled into one `text-input`) is the reason these are `input-*` and not `outlined-*`. If the variants merge, the token still describes the field container and only one question is left open: whether `filled` should switch from `surface-variant` to `input-fill` too. It is untouched here — it already has a fill, and changing which token feeds it is a visible change to every filled field, which belongs in that issue and not in this PR.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The problem
NativeUIOutlinedTextInputRendererdraws its box as a bare.stroke:There is no
.fillanywhere in the file. Android is the same by default —OutlinedTextFieldDefaults.colors()resolves the container toTransparent.So the field's background is literally whatever happens to be behind it, and
no theme token can change that.
Why it matters
On a white page this is correct and invisible — it is Material 3's outlined
container. Put the same form on a colored screen and the field stops reading
as a field: a rounded outline floating on the page color, with the M3 grays
for its placeholder, icons and affixes chosen against a light background they
are no longer on.
The workaround available today is
surfaceorsurface-variant, and it isn'tone. Those tokens are shared with cards, sheets, list rows and dialogs, so
recoloring the field recolors all of them with it. There is no token whose
scope is "the box the user types into".
The approach
Two new optional theme tokens, named for that role rather than for a variant:
Both go through the existing color grammar (hex,
#RRGGBBAA, Tailwind names,/opacitymodifiers) with no PHP changes —Theme's token map isopen-ended — and both take part in dark auto-derivation like any other token.
input-fillis transparent when undeclared. That is both Material 3'soutlined container and exactly what this renderer painted before, so a theme
that says nothing renders byte-for-byte what it rendered before.
on-inputis genuinely optional — nil, not a default color. There is nosingle default it could take. A field is deliberately two-tone today: typed
text in
on-surface, icons and affixes inon-surface-variant. Collapsingthat pair onto one token would restyle every existing field. So nil means
"every slot keeps its own color", and a declared value takes the lot — which
is what you want the moment the fill is dark enough that the muted gray stops
being a hierarchy and starts being unreadable.
labelandsupportingstay onon-surface-variantin both cases. They sitoutside the box, on the surface behind the field, and should keep taking their
color from it.
On iOS the stroke moves into an overlay on the filled shape. Same shape, same
cornerRadius(hoisted to a local now that two paints need it), same frame —a stroke centers on its path and the path is the background's own frame, so
the border lands exactly where it did. The fill takes
.allowsHitTesting(false): a stroke only hit-tests its own line, so the hollowmiddle of the box used to let taps through to whatever was behind it, and a
decorative fill should not start absorbing them.
Relationship to open work
#46 — "Add placeholder-color to bare-text-input" (open). Solves the
neighboring problem from the other end, and I think both shapes are right in
their own place.
bare-text-input's whole contract is per-instance colors, soa per-instance
placeholder-colorbelongs there.outlinedandfilledreject per-instance colors by design (Model 3 — "drop to
<pressable>forfully custom input visuals"), so their answer has to be a theme token. The two
do not overlap in code and do not compete. If #46 lands first, the placeholder
inside an outlined field is still an open question, and
on-inputis thenatural place to answer it — I left that out of this PR to keep it to one
idea, and it is a two-line follow-up either way.
#26 — "Consider collapsing outlined/filled text inputs into one
text-input" (open). This is why the tokens areinput-*and notoutlined-*, and I do not want to pretend it is uncontroversial. If thevariants merge,
input-fillstill describes the field container and nothingabout it becomes wrong — but one question is left open that this PR
deliberately does not answer: should
filledswitch fromsurface-variantto
input-filltoo? I have not touched it. It already has a fill, andchanging which token feeds it is a visible change to every filled field in
every existing app. That decision belongs in #26, with the rest of the
unification, not smuggled in here.
If maintainers would rather wait for #26 and design one container token set
for the merged element, that is a reasonable call and I would rather hear it
now than have this rot.
Alternatives considered
input-filltosurface. This is what I run locally, and it iswhat most people actually want. Rejected for upstream: it changes the
appearance of every outlined field in every existing app — on a colored
page they would all turn white — and it walks away from the M3 outlined
spec by default. Transparent-unless-declared gets the same result for
anyone who wants it, with no regression for anyone who doesn't.
fillattribute on the element. Rejected: Model 3 saysno per-instance colors on outlined/filled, and
BaseTextInput::getStyle()actively returns
[]to enforce it. Going against that for one propertywould be the start of unpicking the whole policy.
surface-variant. That is the status quo workaround; see above.on-inputto a color instead of leaving it nil. Any default Ipicked would silently restyle existing fields, because there are two current
defaults and not one.
What I verified / what I could not
Verified:
resources/ios/*.swiftcompiled together with the core
NativeRendersources from a real appinstall:
swiftc -typecheck -sdk $(xcrun --sdk iphonesimulator --show-sdk-path) -target arm64-apple-ios18.2-simulator.Clean; no new warnings.
ThemeColorTestcases: one pinning that the pair travels through the color grammar like any
other token, one pinning that they are absent from
Theme::all()whenundeclared — absence is the signal the renderers read, so it is worth a
test.
pint --testpasses;php -lon the changed config and test files.Could not verify:
mobile-ui@maindoes not currently register most of its components againstthe released core — the manifest's
bladepaths sayNative\Mobile\Edge\Components\Textwhile core shipsNative\Mobile\Edge\Components\Native\Text, so 25 of 59 entries failclass_exists()and are skipped silently (52 registered components → 27),taking
<button>and every text input with them. I could not build aworking app against this branch. The equivalent change is running on a
device in an app pinned to an older mobile-ui, which is where the problem
was found; that is not the same as verifying this diff, and I am not
claiming it is.
two data-class fields, one parse line, one null-tolerant parse helper, and
four
*ContainerColorarguments that were already resolving toTransparent.config/native-ui.phpare documented asmeeting WCAG AA.
input-fill/on-inputship with no values, so there isnothing to check — but an app that declares a fill and forgets
on-inputgets
on-surfaceon it, which may not clear AA. The config comment says todeclare the pair together; that is documentation, not enforcement.