Skip to content

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

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

refactor!: Replace InputBorder enum with a class hierarchy#6773
ndonkoHenri wants to merge 12 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. 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.

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

Behavior changes

  • Theme-driven border colors by default. The enabled border was always drawn black unless border_color was set, including in dark mode. A border without an explicit side now lets the Material theme resolve the color and weight per state. This is Flutter's own behavior and works correctly with dark mode and custom themes.
  • An explicit side on a single border styles the enabled state only. Focused, error and disabled stay theme-resolved unless the ControlState dictionary form is used.
  • Underline borders now honor a corner radius. The old border_radius was applied only to outlined borders and silently dropped for underlines.
  • DropdownM2 menu corners moved to a dedicated menu_border_radius property; the field's border no longer shapes the open menu.
  • CupertinoTextField draws a box decoration, so it translates the border differently: InputBorder.none() now actually removes the border (the enum value was ignored), and an outline without a side keeps the native iOS hairline where InputBorder.OUTLINE used to paint a solid black box. Its DEFAULT, FOCUSED and DISABLED entries apply; ERROR is ignored, as the control renders no error state.

Also in this PR

Concrete defaults for properties whose Flutter default is a fixed constant: Properties like Paint.color were declared Optional[X] = None 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"). Those now state their default in the signature: the eight Paint style properties, RoundedRectangleBorder.radius, 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.

Optional = None stays wherever a widget resolves the value at runtime from the theme, the platform or its own state — Paint.gradient and CupertinoAppBar.brightness are untouched, for example.

Rendering is unchanged throughout: every Dart parser already fell back to the same constant for an absent key, 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. Passing None explicitly still works at runtime but is now a type error.

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.

Summary by Sourcery

Refactor input border configuration to a class-based, per-state model aligned with Flutter APIs, while tightening default values, updating related controls, and documenting the breaking change.

New Features:

  • Introduce a class-based InputBorder hierarchy with OutlineInputBorder, UnderlineInputBorder, and InputBorder.none matching Flutter behavior.
  • Allow form field borders (TextField, Dropdown, DropdownM2, CupertinoTextField) to be configured per control state via ControlState-mapped borders.
  • Add menu_border_radius to DropdownM2 so the field border and open menu shape can be styled independently.

Enhancements:

  • Unify border parsing and mapping into reusable helpers that translate serialized InputBorder definitions into Material InputDecoration and BoxDecoration values.
  • Set concrete default values in Python controls for properties whose Flutter defaults are fixed constants, improving type accuracy and encoding consistency.
  • Adjust CupertinoTextField to interpret the new InputBorder model, including proper support for borderless and theme-native appearance.
  • Record a protocol constraint for nested dataclass emission in the messaging encoder to document differ-dependent behavior.

Documentation:

  • Add a breaking-change guide describing the new InputBorder class hierarchy and migration from loose border properties.
  • Split the InputBorder docs into an index plus dedicated OutlineInputBorder and UnderlineInputBorder pages with new examples.
  • Extend DropdownM2 docs with an examples section demonstrating separate styling of field borders and menu radius.
  • Update release notes with a 1.0.x section and missing 0.86.x entries.

Tests:

  • Add example apps demonstrating InputBorder styles and per-state styling, DropdownM2 styling, and update existing examples to the new border API.

Chores:

  • Export OutlineInputBorder and UnderlineInputBorder from the main flet package and wire them into the symbol map for IDE discovery.

`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: 77f332b
Status: ✅  Deploy successful!
Preview URL: https://6fa1c80b.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.
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