Skip to content

refactor!: Replace InputBorder enum with a class hierarchy - #6773

Open
ndonkoHenri wants to merge 13 commits into
release/flet-1.0from
fix/textfield-border
Open

refactor!: Replace InputBorder enum with a class hierarchy#6773
ndonkoHenri wants to merge 13 commits into
release/flet-1.0from
fix/textfield-border

Conversation

@ndonkoHenri

@ndonkoHenri ndonkoHenri commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

InputBorder was an enum (OUTLINE/UNDERLINE/NONE) paired with five loose properties on every form field: border_radius, border_width, border_color, focused_border_width and focused_border_color. That shape could not express Flutter's API — gap_padding was unavailable, UnderlineInputBorder's corner radius was silently ignored, and the error and disabled borders could not be styled at all — and every new Flutter border property would have required another top-level property on each control.

InputBorder is now a base class with OutlineInputBorder, UnderlineInputBorder and InputBorder.none(), mirroring Flutter's classes and their defaults. border accepts either a single border or a ControlState dictionary, so the focused, error and disabled borders become stylable.

# before
ft.TextField(border=ft.InputBorder.UNDERLINE)
ft.TextField(
    border_radius=30,
    border_color=ft.Colors.GREEN_800,
    focused_border_color=ft.Colors.GREEN_ACCENT_400,
    focused_border_width=5,
)

# after
ft.TextField(border=ft.UnderlineInputBorder())
ft.TextField(
    border={
        ft.ControlState.DEFAULT: ft.OutlineInputBorder(
            border_radius=30,
            side=ft.BorderSide(color=ft.Colors.GREEN_800),
        ),
        ft.ControlState.FOCUSED: ft.OutlineInputBorder(
            border_radius=30,
            side=ft.BorderSide(width=5, color=ft.Colors.GREEN_ACCENT_400),
        ),
    },
)

Migration guide: website/docs/updates/breaking-changes/v1-0-0/inputborder-class-hierarchy.md.

Nothing is removed in 1.0.0

The old spellings keep working for three minor releases, so existing apps run unedited:

  • InputBorder.OUTLINE, UNDERLINE and NONE still resolve. A metaclass serves them, returning the equivalent class instance — so old code not only runs but produces new-API values, and InputBorder.OUTLINE == OutlineInputBorder() holds.
  • border_radius, border_width, border_color, focused_border_width and focused_border_color still apply on TextField, Dropdown, DropdownM2 and CupertinoTextField.
  • DropdownM2.border_radius is an alias for the new menu_border_radius.

All of them warn through V.deprecated and are scheduled for removal in 1.3.0.

Old and new combine through the repo's non-copying model: Python sends both, Dart prefers the new one. A border carrying a side or per-state entries ignores the deprecated properties entirely, while a bare border=ft.InputBorder.UNDERLINE still picks up a legacy border_color. This is why border is Optional again — with a default_factory the client cannot distinguish a set value from a default, so the fallback could never fire. It becomes non-optional when the deprecations are removed.

What is still breaking

Only what a shim cannot cover:

  • Enum-shaped usage. InputBorder is not iterable, its members have no .value or .name, and InputBorder.OUTLINE is InputBorder.OUTLINE is now False — each access returns a new instance, so compare with ==.
  • Rendering. A border with no explicit side takes its colour and weight from the Material theme per state instead of always painting black, so dark mode and custom themes work. An underline finally honours its border_radius. DropdownM2's open menu is shaped by menu_border_radius rather than the field's radius. On CupertinoTextField, InputBorder.none() now actually removes the border (the enum value was ignored) and an outline without a side keeps the native iOS one.
  • Property reads. Properties whose Flutter default is a fixed constant now declare it rather than Optional[...] = None, so reading one returns the value the control applies instead of None: the eight Paint style properties, RoundedRectangleBorder.radius, Button.autofocus, Text.no_wrap, GridView.clip_behavior, Semantics.container, ExpansionPanelList.spacing, TextField.fit_parent_size, Page.show_semantics_debugger, the three CupertinoAppBar.automatic* flags, Path.Rect.border_radius and canvas.Text.max_width. Rendering is unchanged, and properties a widget resolves at runtime from the theme, the platform or its own state keep None.

Also in this PR

A protocol constraint recorded in protocol.py. The encoder emits nested dataclasses unconditionally, including when they equal their field's default_factory product, while the list, dict and scalar branches beside it prune. That asymmetry is load-bearing: the differ patches nested fields in place with nested-path ops, which require the client to already hold the parent key, so pruning is only safe alongside a differ that emits whole-value replaces for pruned fields.

Docs and examples

  • New breaking-change guide, plus the InputBorder type page split into a union index with OutlineInputBorder and UnderlineInputBorder pages. NoInputBorder is intentionally absent — the class is private and reached through InputBorder.none().
  • New examples: types/input_border/showcase (the three border styles), types/input_border/styling (custom sides, radii and per-state borders), and material/dropdownm2/styling (field border vs menu radius — DropdownM2 had no examples before).
  • Release notes gain a 1.0.x section and the missing 0.86.x entries. The changelog separates the breaking changes from the deprecations.

`InputBorder` was an enum (`OUTLINE`/`UNDERLINE`/`NONE`) paired with five
loose properties on every form field: `border_radius`, `border_width`,
`border_color`, `focused_border_width` and `focused_border_color`. That shape
could not express Flutter's API — `gap_padding` was unavailable,
`UnderlineInputBorder`'s corner radius was silently ignored, and the error and
disabled borders could not be styled at all — and every new Flutter border
property would have required another top-level property on each control.

`InputBorder` is now a base class with `OutlineInputBorder`,
`UnderlineInputBorder` and `InputBorder.none()`, mirroring Flutter's classes
and their defaults. `FormFieldControl.border` and `Dropdown.border` accept
either a single border or a `ControlState` dictionary, so the focused, error
and disabled borders become stylable. The five loose properties are removed.

Behavior changes that follow from the new shape:

* A border with no explicit `side` defers to the Material theme per state
  instead of always painting black, which fixes dark mode and custom themes.
* `DropdownM2` gains `menu_border_radius` for the open menu, which the shared
  `border_radius` used to shape alongside the field.
* `CupertinoTextField` translates the value to its box decoration:
  `InputBorder.none()` now actually removes the border where the enum value
  was ignored, and an outline without a `side` keeps the native iOS hairline.

The M3 `Dropdown` no longer duplicates the border-building logic: it shares
`parseFormFieldBorders` with `buildInputDecoration`, which also populates the
`errorBorder`, `focusedErrorBorder` and `disabledBorder` slots.
Adds the 1.0.0 breaking-change guide for the `InputBorder` class hierarchy,
covering the border styles, corner radius, per-state borders, the `DropdownM2`
menu radius split, and code that reads or compares borders rather than setting
them — `InputBorder` is no longer an enum, so comparisons against its members
raise.

Splits the former single `InputBorder` type page into a union index plus
`OutlineInputBorder` and `UnderlineInputBorder` pages, following the
`OutlinedBorder` layout. `NoInputBorder` is deliberately absent: the class is
private and reached through `InputBorder.none()`.

New example apps: `types/input_border/showcase` for the three border styles,
`types/input_border/styling` for custom sides, radii and per-state borders,
and `material/dropdownm2/styling` showing the field border and the menu radius
side by side. `DropdownM2` had no examples before.

Release notes gain a 1.0.x section and the missing 0.86.x patch entries.
The msgpack encoder emits a nested dataclass unconditionally, including when
it equals its field's `default_factory` product, while the list, dict and
scalar branches beside it prune values that match their defaults.

That asymmetry is load-bearing rather than accidental. The encoder also writes
the `__prev_*` snapshots that are the differ's only model of client state, and
in-place mutations of a nested value produce nested-path patch ops, which
require the client to already hold the parent key. Pruning here without a
differ that emits whole-value replaces for pruned fields leaves the client
applying a patch into a key it never received.

Record the constraint at the branch so it is not removed as a stray
inconsistency.
`CupertinoTextField` decorates with a `BoxDecoration`, which holds a single
static border, so a `ControlState` dictionary passed to `border` collapsed to
its `DEFAULT` entry and the remaining states were silently dropped.

The control already rebuilds on focus change and knows whether it is disabled,
so the applicable entry can be resolved at build time: `DISABLED` takes
precedence, then `FOCUSED`, then `DEFAULT`. As on the Material side, a state
entry without a `side` inherits the default entry's side. `ERROR` remains
unsupported because the control does not render an error state at all.

The translation also moves out of `build()` into `parseFormFieldBoxBorder` in
`utils/form_field.dart`, beside the Material `parseFormFieldBorders`. Both
consume the same wire shape, so keeping them adjacent makes it harder for one
to drift when a border type is added or its defaults change.
Inherited properties are documented on the class that declares them, so
`border` was described only on `FormFieldControl`, in terms of a Material
input decoration: theme-resolved sides and a slot per interactive state.
`CupertinoTextField` renders a box decoration instead, where an outline
without a side keeps the platform border, an underline draws one edge, and
there is no error state to style.

Redeclare the property so the control documents its own behaviour, and drop
the class-level note it replaces. The field is redeclared `kw_only=True`:
`FormFieldControl` is a keyword-only dataclass while this control is not, so
without it the property would become the first positional parameter and
`CupertinoTextField("hello")` would set the border rather than the text.
Flutter defaults `RoundedRectangleBorder.borderRadius` to `BorderRadius.zero`,
a static constructor constant, but the property was declared `Optional` with a
`None` default — which reads as "no radius configured" when the shape always
applies zero.

Declaring it `BorderRadiusValue = 0` makes the signature state what the widget
does. Rendering is unchanged: the Dart parser already falls back to
`BorderRadius.zero` for an absent key, and the encoder prunes a value equal to
its declared default, so the unset case and an explicit `radius=0` both put
nothing on the wire. `BeveledRectangleBorder` and `ContinuousRectangleBorder`
inherit the field. The `copy()` signatures keep `Optional`/`None`, where `None`
means "keep the current value" rather than "no radius".

Passing `radius=None` explicitly still works at runtime but is now a type
error.
The rendered signature already shows `field(default_factory=OutlineInputBorder)`,
so the trailing "Defaults to ..." line restated it. Removed from
`FormFieldControl.border` and `Dropdown.border`, matching how
`CupertinoTextField.border` documents the same property.
Properties whose real default is a fixed constant were declared
`Optional[X] = None`, so the signature said "unset" while the widget always
applied a value. Several docstrings had to spell the truth out in prose —
"Defaults to opaque black", "If not set, the effective default is `4.0`" —
which is the tell that the signature was wrong.

Declare those defaults concretely so the signature states what the control
does. `Optional = None` stays wherever a widget resolves the value at runtime
from the theme, the platform or its own state, because there `None` is honest:
`Paint.gradient` and `CupertinoAppBar.brightness` are untouched, for example.

Paint: `color`, `blend_mode`, `anti_alias`, `stroke_cap`, `stroke_join`,
`stroke_miter_limit`, `stroke_width`, `style`. Controls: `Button.autofocus`,
`FormFieldControl.fit_parent_size`, `Semantics.container`,
`BasePage.show_semantics_debugger`, `Text.no_wrap`, `GridView.clip_behavior`,
`ExpansionPanelList.spacing`, the three `CupertinoAppBar.automatic*` flags,
`canvas.Path.Rect.border_radius` and `canvas.Text.max_width`.

Rendering is unchanged throughout: every Dart parser already falls back to the
same constant when the key is absent, and the encoder prunes a value equal to
its declared default, so the unset case and an explicitly-passed default now
encode identically while non-default values still transmit. Prose that only
restated a default is dropped, since the signature carries it.

Passing `None` explicitly still works at runtime but is now a type error.
The rendered signature already carries the default, so prose repeating it was
redundant on `RoundedRectangleBorder.radius`, `OutlineInputBorder.border_radius`
and `gap_padding`, matching the `border` properties. Also reference
`InputBorder` from the Dart doc comment instead of naming it in prose.

@sourcery-ai sourcery-ai Bot 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.

Hey - I've reviewed your changes and they look great!


Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 16, 2026

Copy link
Copy Markdown

Deploying flet-website-v2 with  Cloudflare Pages  Cloudflare Pages

Latest commit: 96aa0e2
Status: ✅  Deploy successful!
Preview URL: https://2ac9ae6a.flet-website-v2.pages.dev
Branch Preview URL: https://fix-textfield-border.flet-website-v2.pages.dev

View logs

Root changelog covers the two user-facing breaking changes: the `InputBorder`
class hierarchy with per-state borders and the removed loose properties, and
the properties that now declare their constant Flutter default rather than
`Optional = None`, so reading one returns the value the control applies.

The Dart package changelog covers only what extension authors must know: the
`FormFieldInputBorder` enum and its parse helpers are gone, replaced by
`parseInputBorder()`, `parseFormFieldBorders()` and
`parseFormFieldBoxBorder()`.

Also link the pull request from the migration guide's references.
`CupertinoTextField` decorates with a `BoxDecoration`, which holds a single
static border, so `parseFormFieldBoxBorder` resolves the applicable
`ControlState` entry itself rather than handing the framework a slot per state.
That resolution treated `disabled` and `focused` as peers, so a disabled
control whose border map omits `DISABLED` fell through to the `FOCUSED` entry
instead of the default one.

`InputDecorator` short-circuits on disabled and never consults focus, and the
Material path inherits that by populating the border slots; make the Cupertino
path agree. Reachable because `CupertinoTextField` clears `canRequestFocus` in
`didUpdateWidget`, after this build has already chosen a border, so disabling a
focused field painted the focused border until the focus listener caught up.
The class hierarchy replaced the `InputBorder` enum and the five loose border
properties outright, so every app styling a form field had to be edited before
it would run. Keep the old spellings working for three minor releases instead.

`InputBorder.OUTLINE`, `UNDERLINE` and `NONE` resolve again — a metaclass
serves them, returning the equivalent class instance — so old code not only
runs but produces new-API values. `border_radius`, `border_width`,
`border_color`, `focused_border_width` and `focused_border_color` return to
`FormFieldControl` and `Dropdown`, and `DropdownM2.border_radius` becomes an
alias for `menu_border_radius`. All of them warn through `V.deprecated` and are
scheduled for removal in `1.3.0`.

Old and new values combine per the repo's non-copying model: Python sends both
and Dart prefers the new one, so a `border` carrying a side or per-state
entries ignores the deprecated properties, while a bare
`border=InputBorder.UNDERLINE` still picks up a legacy `border_color`. That
requires `border` to be `Optional` again — with a `default_factory` the client
cannot tell a set value from a default, so the fallback could never fire; it
becomes non-optional once the deprecations are removed.

What stays breaking is what no shim can cover: `InputBorder` is no longer
iterable, its members have no `.value` or `.name`, identity comparison no
longer holds, and the rendering changes remain. The changelog now separates
those from the deprecations, and the guide documents both.
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