Skip to content

Give the outlined text input a fill color of its own - #60

Open
CodyPChristian wants to merge 1 commit into
NativePHP:mainfrom
CodyPChristian:feat/outlined-text-input-fill-token
Open

Give the outlined text input a fill color of its own#60
CodyPChristian wants to merge 1 commit into
NativePHP:mainfrom
CodyPChristian:feat/outlined-text-input-fill-token

Conversation

@CodyPChristian

Copy link
Copy Markdown
Contributor

The problem

NativeUIOutlinedTextInputRenderer draws its box as a bare .stroke:

.background(
    RoundedRectangle(cornerRadius: ..., style: .continuous)
        .stroke(borderColor, lineWidth: isError ? 2 : 1)
)

There is no .fill anywhere in the file. Android is the same by default —
OutlinedTextFieldDefaults.colors() resolves the container to Transparent.

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 surface or surface-variant, and it isn't
one. 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:

'input-fill' => '#FFFFFF',   // the box
'on-input'   => '#0F172A',   // everything drawn inside it

Both go through the existing color grammar (hex, #RRGGBBAA, Tailwind names,
/opacity modifiers) with no PHP changes — Theme's token map is
open-ended — and both take part in dark auto-derivation like any other token.

input-fill is transparent when undeclared. That is both Material 3's
outlined container and exactly what this renderer painted before, so a theme
that says nothing renders byte-for-byte what it rendered before.

on-input is genuinely optional — nil, not a default color. There is no
single default it could take. A field is deliberately two-tone today: typed
text in on-surface, icons and affixes in on-surface-variant. Collapsing
that 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.

label and supporting stay on on-surface-variant in both cases. They sit
outside 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 hollow
middle 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, so
a per-instance placeholder-color belongs there. outlined and filled
reject per-instance colors by design (Model 3 — "drop to <pressable> for
fully 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-input is the
natural 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 are input-* and not
outlined-*, and I do not want to pretend it is uncontroversial. If the
variants merge, input-fill still describes the field container and nothing
about it becomes wrong — but one question is left open that this PR
deliberately does not answer: should filled switch from surface-variant
to input-fill too?
I have not touched it. It already has a fill, and
changing 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

  • Default input-fill to surface. This is what I run locally, and it is
    what 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.
  • A per-instance fill attribute on the element. Rejected: Model 3 says
    no per-instance colors on outlined/filled, and BaseTextInput::getStyle()
    actively returns [] to enforce it. Going against that for one property
    would be the start of unpicking the whole policy.
  • Reuse surface-variant. That is the status quo workaround; see above.
  • Resolve on-input to a color instead of leaving it nil. Any default I
    picked would silently restyle existing fields, because there are two current
    defaults and not one.

What I verified / what I could not

Verified:

  • iOS type-checks against the real SDK. All of resources/ios/*.swift
    compiled together with the core NativeRender sources from a real app
    install:
    swiftc -typecheck -sdk $(xcrun --sdk iphonesimulator --show-sdk-path) -target arm64-apple-ios18.2-simulator.
    Clean; no new warnings.
  • Full Pest suite passes (219 tests), including two new ThemeColorTest
    cases: one pinning that the pair travels through the color grammar like any
    other token, one pinning that they are absent from Theme::all() when
    undeclared — absence is the signal the renderers read, so it is worth a
    test.
  • pint --test passes; php -l on the changed config and test files.

Could not verify:

  • No runtime/device test, so no screenshot of the result.
    mobile-ui@main does not currently register most of its components against
    the released core — the manifest's blade paths say
    Native\Mobile\Edge\Components\Text while core ships
    Native\Mobile\Edge\Components\Native\Text, so 25 of 59 entries fail
    class_exists() and are skipped silently (52 registered components → 27),
    taking <button> and every text input with them. I could not build a
    working 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.
  • Android is compile-unverified. No Kotlin toolchain here. The change is
    two data-class fields, one parse line, one null-tolerant parse helper, and
    four *ContainerColor arguments that were already resolving to
    Transparent.
  • Contrast. The default pairs in config/native-ui.php are documented as
    meeting WCAG AA. input-fill / on-input ship with no values, so there is
    nothing to check — but an app that declares a fill and forgets on-input
    gets on-surface on it, which may not clear AA. The config comment says to
    declare the pair together; that is documentation, not enforcement.

`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.
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.

1 participant