diff --git a/README.md b/README.md
index 9004b034..89ebdf17 100644
--- a/README.md
+++ b/README.md
@@ -279,6 +279,7 @@ Remix provides a comprehensive set of production-ready components:
### Input Components
- **TextField** - Text input with validation support
+- **TextArea** - Multiline text input with safe auto-growing defaults
- **Select** - Dropdown selection with keyboard navigation
### Display Components
diff --git a/docs.json b/docs.json
index 0294de6d..d3fd6a2d 100644
--- a/docs.json
+++ b/docs.json
@@ -165,6 +165,10 @@
"title": "Tabs",
"href": "/components/tabs"
},
+ {
+ "title": "TextArea",
+ "href": "/components/textarea"
+ },
{
"title": "TextField",
"href": "/components/textfield"
diff --git a/docs/components/textarea.mdx b/docs/components/textarea.mdx
new file mode 100644
index 00000000..c00aeb3f
--- /dev/null
+++ b/docs/components/textarea.mdx
@@ -0,0 +1,268 @@
+---
+title: TextArea
+description: A multiline text editor with native auto-growth, labels, supporting text, validation, and Remix styling
+keywords: [flutter, remix, textarea, multiline, textfield, form, editor]
+---
+
+`RemixTextArea` is the multiline constructor for Remix's existing text editor.
+It uses the same `TextFieldStyler`, `TextFieldSpec`, controller, focus, input,
+selection, and accessibility pipeline as `RemixTextField`.
+
+## When to use this
+
+- **Long-form input**: Comments, descriptions, notes, and messages
+- **Multiple paragraphs**: Content where line breaks must be preserved
+- **Growing forms**: Editors that should expand naturally inside parent constraints
+- **Validated content**: Multiline fields with labels, helper text, and errors
+
+Use `RemixTextField` for compact single-line values such as names, search, and
+email addresses.
+
+## Basic implementation
+
+
+```dart
+import 'package:flutter/material.dart';
+import 'package:remix/remix.dart';
+
+class TextAreaExample extends StatelessWidget {
+ const TextAreaExample({super.key});
+
+ @override
+ Widget build(BuildContext context) {
+ return const SizedBox(
+ width: 360,
+ child: RemixTextArea(
+ label: 'Notes',
+ hintText: 'Add details',
+ helperText: 'Line breaks are preserved',
+ ),
+ );
+ }
+}
+```
+
+
+The default editor starts at two lines, uses the multiline keyboard and newline
+action, and grows with its content because `maxLines` is `null`. Flutter lays it
+out within the constraints supplied by its parent. TextArea does not add a
+browser-style drag-resize handle or a fixed height.
+
+## Controlled value, validation, and length
+
+
+```dart
+import 'package:flutter/material.dart';
+import 'package:remix/remix.dart';
+
+class FeedbackEditor extends StatefulWidget {
+ const FeedbackEditor({super.key});
+
+ @override
+ State createState() => _FeedbackEditorState();
+}
+
+class _FeedbackEditorState extends State {
+ final controller = TextEditingController();
+ bool showError = false;
+
+ @override
+ void dispose() {
+ controller.dispose();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return RemixTextArea(
+ controller: controller,
+ label: 'Feedback',
+ hintText: 'What could be better?',
+ helperText: showError ? 'Feedback is required' : 'Up to 500 characters',
+ error: showError,
+ maxLength: 500,
+ minLines: 3,
+ maxLines: 8,
+ onChanged: (value) {
+ if (showError && value.trim().isNotEmpty) {
+ setState(() => showError = false);
+ }
+ },
+ );
+ }
+}
+```
+
+
+When setting both line limits, `maxLines` must be greater than or equal to
+`minLines`, and both values must be positive. Set both together when overriding
+the default two-line minimum with a smaller maximum.
+
+## Disabled and read-only
+
+
+```dart
+import 'package:flutter/material.dart';
+import 'package:remix/remix.dart';
+
+class TextAreaStates extends StatelessWidget {
+ const TextAreaStates({super.key, required this.savedController});
+
+ final TextEditingController savedController;
+
+ @override
+ Widget build(BuildContext context) {
+ return Column(
+ spacing: 16,
+ children: [
+ const RemixTextArea(
+ label: 'Disabled notes',
+ hintText: 'Editing is unavailable',
+ enabled: false,
+ ),
+ RemixTextArea(
+ controller: savedController,
+ label: 'Saved notes',
+ readOnly: true,
+ ),
+ ],
+ );
+ }
+}
+```
+
+
+A read-only TextArea remains focusable and selectable. A disabled TextArea does
+not accept input or expose an editing action.
+
+## Custom styling
+
+TextArea deliberately reuses the TextField anatomy. Use `TextFieldStyler` for
+fluent styling or provide a resolved `TextFieldSpec`; there are no separate
+TextArea style or spec types. `RemixTextArea` intentionally has no `styleFrom`
+callable because `TextFieldStyler`'s callable creates a `RemixTextField`; pass
+the styler through `style` as shown below.
+
+
+Do not pass `fortalTextFieldStyle()` to a TextArea. That preset pins the input
+container to a single-line height, so the editor stops growing and taller
+content scrolls inside a one-line box with no layout error to signal it. There
+is no Fortal TextArea preset: Radix Themes' TextArea is not part of this
+package's mapped parity surface, so its metrics have no reference data to
+derive from. Style TextArea with `TextFieldStyler` directly, using `minHeight`
+rather than `height` for any floor you need.
+
+
+
+```dart
+import 'package:flutter/material.dart';
+import 'package:remix/remix.dart';
+
+class StyledTextArea extends StatelessWidget {
+ const StyledTextArea({super.key});
+
+ @override
+ Widget build(BuildContext context) {
+ return RemixTextArea(
+ label: 'Project summary',
+ hintText: 'Describe the project',
+ helperText: 'Markdown is supported',
+ style: TextFieldStyler()
+ .backgroundColor(const Color(0xFFF8FAFC))
+ .textColor(const Color(0xFF172033))
+ .hintColor(const Color(0xFF667085))
+ .paddingAll(14)
+ .borderRadiusAll(const Radius.circular(12))
+ .border(
+ BoxBorderMix.all(
+ BorderSideMix(color: const Color(0xFF7C3AED), width: 1.5),
+ ),
+ )
+ .onFocused(
+ TextFieldStyler().border(
+ BoxBorderMix.all(
+ BorderSideMix(color: const Color(0xFF8B5CF6), width: 2.5),
+ ),
+ ),
+ ),
+ );
+ }
+}
+```
+
+
+## Constructor
+
+
+```dart
+import 'package:flutter/gestures.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+import 'package:remix/remix.dart';
+
+RemixTextArea remixTextAreaConstructor({
+ Key? key,
+ TextEditingController? controller,
+ FocusNode? focusNode,
+ String? label,
+ String? hintText,
+ String? helperText,
+ bool error = false,
+ TextInputType? keyboardType = TextInputType.multiline,
+ TextInputAction? textInputAction = TextInputAction.newline,
+ TextCapitalization textCapitalization = TextCapitalization.none,
+ TextDirection? textDirection,
+ bool enabled = true,
+ bool readOnly = false,
+ bool autofocus = false,
+ int? maxLines,
+ int? minLines = 2,
+ int? maxLength,
+ MaxLengthEnforcement? maxLengthEnforcement,
+ ValueChanged? onChanged,
+ VoidCallback? onEditingComplete,
+ ValueChanged? onSubmitted,
+ AppPrivateCommandCallback? onAppPrivateCommand,
+ List? inputFormatters,
+ bool? showCursor,
+ bool autocorrect = true,
+ bool enableSuggestions = true,
+ SmartDashesType? smartDashesType,
+ SmartQuotesType? smartQuotesType,
+ DragStartBehavior dragStartBehavior = DragStartBehavior.start,
+ bool enableInteractiveSelection = true,
+ TextSelectionControls? selectionControls,
+ GestureTapCallback? onTap,
+ TapRegionCallback? onTapOutside,
+ TapRegionUpCallback? onPressUpOutside,
+ bool onTapAlwaysCalled = false,
+ ScrollController? scrollController,
+ ScrollPhysics? scrollPhysics,
+ Iterable? autofillHints,
+ ContentInsertionConfiguration? contentInsertionConfiguration,
+ Clip clipBehavior = Clip.hardEdge,
+ String? restorationId,
+ bool stylusHandwritingEnabled = true,
+ bool enableIMEPersonalizedLearning = true,
+ EditableTextContextMenuBuilder? contextMenuBuilder,
+ SpellCheckConfiguration? spellCheckConfiguration,
+ TextMagnifierConfiguration? magnifierConfiguration,
+ bool canRequestFocus = true,
+ bool? ignorePointers,
+ UndoHistoryController? undoController,
+ Object groupId = EditableText,
+ Widget? leading,
+ Widget? trailing,
+ String? semanticLabel,
+ String? semanticHint,
+ bool excludeSemantics = false,
+ TextFieldStyler style = const TextFieldStyler.create(),
+ TextFieldSpec? styleSpec,
+}) => throw UnimplementedError();
+```
+
+
+`obscureText`, `obscuringCharacter`, and `expands` are intentionally absent.
+TextArea always uses nonsecure, nonexpanding multiline behavior. Override
+`keyboardType` or `textInputAction` when a multiline workflow needs a more
+specific keyboard or a completion action.
diff --git a/docs/components/textfield.mdx b/docs/components/textfield.mdx
index 716d379c..5596dcb2 100644
--- a/docs/components/textfield.mdx
+++ b/docs/components/textfield.mdx
@@ -452,9 +452,18 @@ Optional. The style configuration for the text field.
### Style Methods
+The input anatomy is always a horizontal row. Forwarded container methods are
+the full `BoxStyler` surface, while generated `spacing` and
+`crossAxisAlignment` methods control that row directly. The row follows the
+ambient text direction; direction and main-axis controls are intentionally not
+part of its styling API. The separate `layout` styler wraps the label, input
+row, and helper text in a real Flex layout. It defaults to a vertical, min-size,
+start-aligned layout with 8 pixels of spacing, and its full Flex direction and
+alignment surface is honored.
+
#### `color(Color value)`
-Sets text color
+Sets the container color. Use `textColor` for editable text.
#### `text(TextStyler value)`
@@ -464,9 +473,14 @@ Sets editable text style using a TextStyler.
Sets background color
-#### `container(FlexBoxStyler value)`
+#### `container(BoxStyler value)`
+
+Styles the box around the fixed input row.
-Sets container that wraps editable text area
+#### `layout(FlexBoxStyler value)`
+
+Styles the label/input/helper layout. Its full Flex direction, spacing, and
+alignment controls are supported.
#### `borderRadius(BorderRadiusGeometryMix radius)`
@@ -538,7 +552,12 @@ Sets margin
#### `spacing(double value)`
-Sets flex spacing
+Sets spacing between the leading widget, editor, and trailing widget.
+
+#### `crossAxisAlignment(CrossAxisAlignment value)`
+
+Aligns the editor and accessories on the input row's cross axis.
+`CrossAxisAlignment.baseline` uses `TextBaseline.alphabetic`.
#### `decoration(DecorationMix value)`
@@ -592,10 +611,6 @@ Sets a foreground decoration painted on top of the component.
Applies a matrix transformation to the component.
-#### `flex(FlexStyler value)`
-
-Configures the flex layout properties.
-
#### `labelStyle(TextStyleMix value)`
Sets label/text style using TextStyleMix directly
diff --git a/packages/playground/lib/registry/component_registry.dart b/packages/playground/lib/registry/component_registry.dart
index e1ae43ea..bf7b218d 100644
--- a/packages/playground/lib/registry/component_registry.dart
+++ b/packages/playground/lib/registry/component_registry.dart
@@ -21,6 +21,7 @@ import 'entries/slider_entry.dart';
import 'entries/spinner_entry.dart';
import 'entries/switch_entry.dart';
import 'entries/textfield_entry.dart';
+import 'entries/textarea_entry.dart';
import 'entries/tooltip_entry.dart';
// Map component slugs to a builder that returns the component inside FortalScope.
@@ -33,6 +34,10 @@ final Map components = {
brightness: Theme.of(context).brightness,
child: PreviewShell(child: buildTextFieldExample()),
),
+ 'textarea': (context) => FortalScope(
+ brightness: Theme.of(context).brightness,
+ child: PreviewShell(child: buildTextAreaExample()),
+ ),
'checkbox': (context) => FortalScope(
brightness: Theme.of(context).brightness,
child: PreviewShell(child: buildCheckboxExample()),
diff --git a/packages/playground/lib/registry/entries/textarea_entry.dart b/packages/playground/lib/registry/entries/textarea_entry.dart
new file mode 100644
index 00000000..553e8f3e
--- /dev/null
+++ b/packages/playground/lib/registry/entries/textarea_entry.dart
@@ -0,0 +1,219 @@
+import 'package:flutter/material.dart';
+import 'package:remix/remix.dart';
+
+import '../../widgets/comparison_view.dart';
+
+Widget buildTextAreaExample() => const _TextAreaExample();
+
+class _TextAreaExample extends StatefulWidget {
+ const _TextAreaExample();
+
+ @override
+ State<_TextAreaExample> createState() => _TextAreaExampleState();
+}
+
+class _TextAreaExampleState extends State<_TextAreaExample> {
+ final _remixController = TextEditingController(
+ text: 'First paragraph.\nSecond paragraph.',
+ );
+ final _materialController = TextEditingController(
+ text: 'First paragraph.\nSecond paragraph.',
+ );
+ final _remixReadOnlyController = TextEditingController(
+ text: 'This content can be selected but not changed.',
+ );
+ final _materialReadOnlyController = TextEditingController(
+ text: 'This content can be selected but not changed.',
+ );
+
+ @override
+ void dispose() {
+ _remixController.dispose();
+ _materialController.dispose();
+ _remixReadOnlyController.dispose();
+ _materialReadOnlyController.dispose();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final isDark = Theme.of(context).brightness == Brightness.dark;
+ final surface = isDark ? const Color(0xFF202124) : const Color(0xFFF8FAFC);
+ final foreground = isDark ? Colors.white : const Color(0xFF172033);
+ final muted = isDark ? const Color(0xFFB8BDC7) : const Color(0xFF667085);
+
+ final customStyle = TextFieldStyler()
+ .backgroundColor(surface)
+ .textColor(foreground)
+ .hintColor(muted)
+ .paddingAll(14)
+ .borderRadiusAll(const Radius.circular(12))
+ .border(
+ BoxBorderMix.all(
+ BorderSideMix(color: const Color(0xFF7C3AED), width: 1.5),
+ ),
+ )
+ .label(TextStyler().color(foreground).fontWeight(FontWeight.w600))
+ .helperText(TextStyler().color(muted))
+ .onFocused(
+ TextFieldStyler().border(
+ BoxBorderMix.all(
+ BorderSideMix(color: const Color(0xFF8B5CF6), width: 2.5),
+ ),
+ ),
+ );
+
+ Widget field(Widget child) => SizedBox(width: 320, child: child);
+
+ return SingleChildScrollView(
+ padding: const EdgeInsets.all(24),
+ child: SingleChildScrollView(
+ scrollDirection: Axis.horizontal,
+ child: ComparisonView(
+ remix: [
+ field(
+ const RemixTextArea(
+ key: ValueKey('textarea-empty'),
+ label: 'Empty',
+ hintText: 'Start typing at the top…',
+ ),
+ ),
+ field(
+ RemixTextArea(
+ key: const ValueKey('textarea-filled'),
+ controller: _remixController,
+ label: 'Controlled value',
+ helperText: 'Line breaks are preserved',
+ ),
+ ),
+ field(
+ const RemixTextArea(
+ key: ValueKey('textarea-error'),
+ label: 'Project summary',
+ hintText: 'Describe the project',
+ helperText: 'A summary is required',
+ error: true,
+ ),
+ ),
+ field(
+ const RemixTextArea(
+ label: 'Limited feedback',
+ hintText: 'Up to 120 characters',
+ maxLength: 120,
+ maxLines: 4,
+ ),
+ ),
+ field(
+ const RemixTextArea(
+ key: ValueKey('textarea-disabled'),
+ label: 'Disabled',
+ hintText: 'Editing unavailable',
+ enabled: false,
+ ),
+ ),
+ field(
+ RemixTextArea(
+ controller: _remixReadOnlyController,
+ label: 'Read only',
+ readOnly: true,
+ ),
+ ),
+ field(
+ RemixTextArea(
+ label: 'Custom Remix styling',
+ hintText: 'Same TextFieldStyler anatomy',
+ helperText: 'Grows within its parent constraints',
+ style: customStyle,
+ ),
+ ),
+ ],
+ material: [
+ field(
+ const TextField(
+ minLines: 2,
+ maxLines: null,
+ decoration: InputDecoration(
+ labelText: 'Empty',
+ hintText: 'Start typing at the top…',
+ border: OutlineInputBorder(),
+ ),
+ ),
+ ),
+ field(
+ TextField(
+ controller: _materialController,
+ minLines: 2,
+ maxLines: null,
+ decoration: const InputDecoration(
+ labelText: 'Controlled value',
+ helperText: 'Line breaks are preserved',
+ border: OutlineInputBorder(),
+ ),
+ ),
+ ),
+ field(
+ const TextField(
+ minLines: 2,
+ maxLines: null,
+ decoration: InputDecoration(
+ labelText: 'Project summary',
+ hintText: 'Describe the project',
+ errorText: 'A summary is required',
+ border: OutlineInputBorder(),
+ ),
+ ),
+ ),
+ field(
+ const TextField(
+ minLines: 2,
+ maxLines: 4,
+ maxLength: 120,
+ decoration: InputDecoration(
+ labelText: 'Limited feedback',
+ hintText: 'Up to 120 characters',
+ border: OutlineInputBorder(),
+ ),
+ ),
+ ),
+ field(
+ const TextField(
+ minLines: 2,
+ maxLines: null,
+ enabled: false,
+ decoration: InputDecoration(
+ labelText: 'Disabled',
+ hintText: 'Editing unavailable',
+ border: OutlineInputBorder(),
+ ),
+ ),
+ ),
+ field(
+ TextField(
+ controller: _materialReadOnlyController,
+ minLines: 2,
+ maxLines: null,
+ readOnly: true,
+ decoration: const InputDecoration(
+ labelText: 'Read only',
+ border: OutlineInputBorder(),
+ ),
+ ),
+ ),
+ field(
+ const TextField(
+ minLines: 2,
+ maxLines: null,
+ decoration: InputDecoration(
+ labelText: 'Custom styling',
+ hintText: 'Material comparison',
+ helperText: 'Grows within its parent constraints',
+ border: OutlineInputBorder(),
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
diff --git a/packages/playground/lib/routes/all_components.dart b/packages/playground/lib/routes/all_components.dart
index 302d539e..4ef32d01 100644
--- a/packages/playground/lib/routes/all_components.dart
+++ b/packages/playground/lib/routes/all_components.dart
@@ -14,6 +14,7 @@ import '../registry/entries/select_entry.dart';
import '../registry/entries/slider_entry.dart';
import '../registry/entries/spinner_entry.dart';
import '../registry/entries/switch_entry.dart';
+import '../registry/entries/textarea_entry.dart';
import '../registry/entries/tooltip_entry.dart';
class AllComponentsPage extends StatelessWidget {
@@ -55,6 +56,7 @@ class AllComponentsPage extends StatelessWidget {
_section('Slider', buildSliderExample()),
_section('Spinner', buildSpinnerExample()),
_section('Switch', buildSwitchExample()),
+ _section('TextArea', buildTextAreaExample()),
_section('Tooltip', buildTooltipExample()),
],
);
diff --git a/packages/remix/CHANGELOG.md b/packages/remix/CHANGELOG.md
index 0f6eac19..04b7fc98 100644
--- a/packages/remix/CHANGELOG.md
+++ b/packages/remix/CHANGELOG.md
@@ -1,5 +1,27 @@
## Unreleased
+- **FEAT**: Add `RemixTextArea`, a constructor-only multiline facade over
+ `RemixTextField` with two-line auto-growing defaults and the canonical
+ `TextFieldStyler` / `TextFieldSpec` styling surface.
+- **BREAKING**: Change `TextFieldSpec.container` and
+ `TextFieldStyler.container` from `FlexBoxSpec` / `FlexBoxStyler` to
+ `BoxSpec` / `BoxStyler` because TextField and TextArea always render their
+ input anatomy as a fixed horizontal row. The full forwarded Box surface is
+ honored, generated `spacing` and `crossAxisAlignment` methods control the row
+ directly, and the row follows the ambient text direction. Misleading Flex
+ direction, main-axis, vertical-direction, text-direction, text-baseline, and
+ `flex(FlexStyler)` container methods are no longer exposed.
+- **FIX**: Render the separate label/input/helper `layout` as a real `FlexBox`
+ so its full generated Flex direction surface is truthful. The base style
+ preserves the existing vertical, min-size, start-aligned layout with 8px
+ spacing, while explicit row layouts are now honored without a forced-column
+ assertion.
+- **FIX**: Remove the unreleased `RemixTextArea.styleFrom` affordance because
+ the shared `TextFieldStyler` callable constructs a single-line
+ `RemixTextField`. Pass a `TextFieldStyler` to `RemixTextArea.style` instead.
+- **FIX**: Top-align multiline TextField hints and expose label, hint, helper,
+ error, and interactive accessory semantics once without narrowing the
+ existing composite tap target.
- **FEAT**: Add an optional styled `RemixCheckbox.label` inside the checkbox's
pointer, focus, and single semantics target, with a 48-by-48 default minimum
target and an explicit `Size.zero` compact opt-out. Mix now generates the
diff --git a/packages/remix/README.md b/packages/remix/README.md
index 54c59ced..2f4c968a 100644
--- a/packages/remix/README.md
+++ b/packages/remix/README.md
@@ -279,6 +279,7 @@ Remix provides a comprehensive set of production-ready components:
### Input Components
- **TextField** - Text input with validation support
+- **TextArea** - Multiline text input with safe auto-growing defaults
- **Select** - Dropdown selection with keyboard navigation
### Display Components
diff --git a/packages/remix/lib/src/components/textfield/fortal_textfield_styles.dart b/packages/remix/lib/src/components/textfield/fortal_textfield_styles.dart
index 64f5c148..4103fc00 100644
--- a/packages/remix/lib/src/components/textfield/fortal_textfield_styles.dart
+++ b/packages/remix/lib/src/components/textfield/fortal_textfield_styles.dart
@@ -49,10 +49,10 @@ TextFieldStyler _fortalTextFieldBaseStyler(
return TextFieldStyler(
container: .height(metrics.height)
.paddingX(metrics.paddingX)
- .spacing(metrics.spacing)
- .crossAxisAlignment(.center)
.borderRadiusAll(metrics.radius)
.clipBehavior(.antiAlias),
+ spacing: metrics.spacing,
+ crossAxisAlignment: .center,
text: .style(metrics.text.mix()),
hintText: .style(metrics.text.mix()).textHeightBehavior(
TextHeightBehaviorMix()
diff --git a/packages/remix/lib/src/components/textfield/textarea_widget.dart b/packages/remix/lib/src/components/textfield/textarea_widget.dart
new file mode 100644
index 00000000..0289ee84
--- /dev/null
+++ b/packages/remix/lib/src/components/textfield/textarea_widget.dart
@@ -0,0 +1,88 @@
+part of 'textfield.dart';
+
+/// A multiline text editor backed by the same implementation as
+/// [RemixTextField].
+///
+/// Text areas and text fields share one accessible editing pipeline and one
+/// styling anatomy. This constructor-only facade supplies safe multiline
+/// defaults without forking a spec or build path that could drift over time.
+class RemixTextArea extends RemixTextField {
+ const RemixTextArea({
+ super.key,
+ super.controller,
+ super.focusNode,
+ super.label,
+ super.hintText,
+ super.helperText,
+ super.error,
+ TextInputType? keyboardType = TextInputType.multiline,
+ TextInputAction? textInputAction = TextInputAction.newline,
+ super.textCapitalization,
+ super.textDirection,
+ super.enabled,
+ super.readOnly,
+ super.autofocus,
+ int? maxLines,
+ int? minLines = 2,
+ super.maxLength,
+ super.maxLengthEnforcement,
+ super.onChanged,
+ super.onEditingComplete,
+ super.onSubmitted,
+ super.onAppPrivateCommand,
+ super.inputFormatters,
+ super.showCursor,
+ super.autocorrect,
+ super.enableSuggestions,
+ super.smartDashesType,
+ super.smartQuotesType,
+ super.dragStartBehavior,
+ super.enableInteractiveSelection,
+ super.selectionControls,
+ super.onTap,
+ super.onTapOutside,
+ super.onPressUpOutside,
+ super.onTapAlwaysCalled,
+ super.scrollController,
+ super.scrollPhysics,
+ super.autofillHints,
+ super.contentInsertionConfiguration,
+ super.clipBehavior,
+ super.restorationId,
+ super.stylusHandwritingEnabled,
+ super.enableIMEPersonalizedLearning,
+ super.contextMenuBuilder,
+ super.spellCheckConfiguration,
+ super.magnifierConfiguration,
+ super.canRequestFocus,
+ super.ignorePointers,
+ super.undoController,
+ super.groupId,
+ super.leading,
+ super.trailing,
+ super.semanticLabel,
+ super.semanticHint,
+ super.excludeSemantics,
+ super.style,
+ super.styleSpec,
+ }) : assert(
+ minLines == null || minLines > 0,
+ 'minLines must be greater than zero.',
+ ),
+ assert(
+ maxLines == null || maxLines > 0,
+ 'maxLines must be greater than zero.',
+ ),
+ assert(
+ maxLines == null || minLines == null || maxLines >= minLines,
+ 'maxLines must be greater than or equal to minLines.',
+ ),
+ super(
+ keyboardType: keyboardType,
+ textInputAction: textInputAction,
+ minLines: minLines,
+ maxLines: maxLines,
+ expands: false,
+ obscureText: false,
+ );
+}
diff --git a/packages/remix/lib/src/components/textfield/textfield.dart b/packages/remix/lib/src/components/textfield/textfield.dart
index fae3fd0e..6e3d4c10 100644
--- a/packages/remix/lib/src/components/textfield/textfield.dart
+++ b/packages/remix/lib/src/components/textfield/textfield.dart
@@ -18,5 +18,6 @@ import '../../rendering/remix_box_effects.dart';
part 'textfield_spec.dart';
part 'textfield_style.dart';
part 'textfield_widget.dart';
+part 'textarea_widget.dart';
part 'fortal_textfield_styles.dart';
part 'textfield.g.dart';
diff --git a/packages/remix/lib/src/components/textfield/textfield.g.dart b/packages/remix/lib/src/components/textfield/textfield.g.dart
index 373c22f9..3bd1feec 100644
--- a/packages/remix/lib/src/components/textfield/textfield.g.dart
+++ b/packages/remix/lib/src/components/textfield/textfield.g.dart
@@ -19,7 +19,9 @@ mixin _$TextFieldSpec implements Spec, Diagnosticable {
EdgeInsets? get scrollPadding;
Brightness? get keyboardAppearance;
bool? get cursorOpacityAnimates;
- StyleSpec get container;
+ StyleSpec get container;
+ double? get spacing;
+ CrossAxisAlignment? get crossAxisAlignment;
StyleSpec get layout;
StyleSpec get helperText;
StyleSpec get label;
@@ -42,7 +44,9 @@ mixin _$TextFieldSpec implements Spec, Diagnosticable {
EdgeInsets? scrollPadding,
Brightness? keyboardAppearance,
bool? cursorOpacityAnimates,
- StyleSpec? container,
+ StyleSpec? container,
+ double? spacing,
+ CrossAxisAlignment? crossAxisAlignment,
StyleSpec? layout,
StyleSpec? helperText,
StyleSpec? label,
@@ -63,6 +67,8 @@ mixin _$TextFieldSpec implements Spec, Diagnosticable {
cursorOpacityAnimates:
cursorOpacityAnimates ?? this.cursorOpacityAnimates,
container: container ?? this.container,
+ spacing: spacing ?? this.spacing,
+ crossAxisAlignment: crossAxisAlignment ?? this.crossAxisAlignment,
layout: layout ?? this.layout,
helperText: helperText ?? this.helperText,
label: label ?? this.label,
@@ -102,6 +108,12 @@ mixin _$TextFieldSpec implements Spec, Diagnosticable {
t,
),
container: container.lerp(other?.container, t),
+ spacing: MixOps.lerp(spacing, other?.spacing, t),
+ crossAxisAlignment: MixOps.lerpSnap(
+ crossAxisAlignment,
+ other?.crossAxisAlignment,
+ t,
+ ),
layout: layout.lerp(other?.layout, t),
helperText: helperText.lerp(other?.helperText, t),
label: label.lerp(other?.label, t),
@@ -128,6 +140,8 @@ mixin _$TextFieldSpec implements Spec, Diagnosticable {
keyboardAppearance,
cursorOpacityAnimates,
container,
+ spacing,
+ crossAxisAlignment,
layout,
helperText,
label,
@@ -187,6 +201,13 @@ mixin _$TextFieldSpec implements Spec, Diagnosticable {
..add(DiagnosticsProperty('keyboardAppearance', keyboardAppearance))
..add(DiagnosticsProperty('cursorOpacityAnimates', cursorOpacityAnimates))
..add(DiagnosticsProperty('container', container))
+ ..add(DoubleProperty('spacing', spacing))
+ ..add(
+ EnumProperty(
+ 'crossAxisAlignment',
+ crossAxisAlignment,
+ ),
+ )
..add(DiagnosticsProperty('layout', layout))
..add(DiagnosticsProperty('helperText', helperText))
..add(DiagnosticsProperty('label', label))
@@ -661,7 +682,9 @@ class TextFieldStyler extends MixStyler
final Prop? $scrollPadding;
final Prop? $keyboardAppearance;
final Prop? $cursorOpacityAnimates;
- final Prop>? $container;
+ final Prop>? $container;
+ final Prop? $spacing;
+ final Prop? $crossAxisAlignment;
final Prop>? $layout;
final Prop>? $helperText;
final Prop>? $label;
@@ -680,7 +703,9 @@ class TextFieldStyler extends MixStyler
Prop? scrollPadding,
Prop? keyboardAppearance,
Prop? cursorOpacityAnimates,
- Prop>? container,
+ Prop>? container,
+ Prop? spacing,
+ Prop? crossAxisAlignment,
Prop>? layout,
Prop>? helperText,
Prop>? label,
@@ -701,6 +726,8 @@ class TextFieldStyler extends MixStyler
$keyboardAppearance = keyboardAppearance,
$cursorOpacityAnimates = cursorOpacityAnimates,
$container = container,
+ $spacing = spacing,
+ $crossAxisAlignment = crossAxisAlignment,
$layout = layout,
$helperText = helperText,
$label = label,
@@ -719,7 +746,9 @@ class TextFieldStyler extends MixStyler
EdgeInsets? scrollPadding,
Brightness? keyboardAppearance,
bool? cursorOpacityAnimates,
- FlexBoxStyler? container,
+ BoxStyler? container,
+ double? spacing,
+ CrossAxisAlignment? crossAxisAlignment,
FlexBoxStyler? layout,
TextStyler? helperText,
TextStyler? label,
@@ -741,6 +770,8 @@ class TextFieldStyler extends MixStyler
keyboardAppearance: Prop.maybe(keyboardAppearance),
cursorOpacityAnimates: Prop.maybe(cursorOpacityAnimates),
container: Prop.maybeMix(container),
+ spacing: Prop.maybe(spacing),
+ crossAxisAlignment: Prop.maybe(crossAxisAlignment),
layout: Prop.maybeMix(layout),
helperText: Prop.maybeMix(helperText),
label: Prop.maybeMix(label),
@@ -774,8 +805,12 @@ class TextFieldStyler extends MixStyler
TextFieldStyler().keyboardAppearance(value);
factory TextFieldStyler.cursorOpacityAnimates(bool value) =>
TextFieldStyler().cursorOpacityAnimates(value);
- factory TextFieldStyler.container(FlexBoxStyler value) =>
+ factory TextFieldStyler.container(BoxStyler value) =>
TextFieldStyler().container(value);
+ factory TextFieldStyler.spacing(double value) =>
+ TextFieldStyler().spacing(value);
+ factory TextFieldStyler.crossAxisAlignment(CrossAxisAlignment value) =>
+ TextFieldStyler().crossAxisAlignment(value);
factory TextFieldStyler.layout(FlexBoxStyler value) =>
TextFieldStyler().layout(value);
factory TextFieldStyler.helperText(TextStyler value) =>
@@ -784,6 +819,20 @@ class TextFieldStyler extends MixStyler
TextFieldStyler().label(value);
factory TextFieldStyler.containerEffects(RemixBoxEffectsMix value) =>
TextFieldStyler().containerEffects(value);
+ factory TextFieldStyler.alignment(AlignmentGeometry value) =>
+ TextFieldStyler().alignment(value);
+ factory TextFieldStyler.padding(EdgeInsetsGeometryMix value) =>
+ TextFieldStyler().padding(value);
+ factory TextFieldStyler.margin(EdgeInsetsGeometryMix value) =>
+ TextFieldStyler().margin(value);
+ factory TextFieldStyler.constraints(BoxConstraintsMix value) =>
+ TextFieldStyler().constraints(value);
+ factory TextFieldStyler.decoration(DecorationMix value) =>
+ TextFieldStyler().decoration(value);
+ factory TextFieldStyler.foregroundDecoration(DecorationMix value) =>
+ TextFieldStyler().foregroundDecoration(value);
+ factory TextFieldStyler.clipBehavior(Clip value) =>
+ TextFieldStyler().clipBehavior(value);
factory TextFieldStyler.color(Color value) => TextFieldStyler().color(value);
factory TextFieldStyler.gradient(GradientMix value) =>
TextFieldStyler().gradient(value);
@@ -951,125 +1000,121 @@ class TextFieldStyler extends MixStyler
endAngle: endAngle,
tileMode: tileMode,
);
- factory TextFieldStyler.row() => TextFieldStyler().row();
- factory TextFieldStyler.column() => TextFieldStyler().column();
- factory TextFieldStyler.alignment(AlignmentGeometry value) =>
- TextFieldStyler().alignment(value);
- factory TextFieldStyler.padding(EdgeInsetsGeometryMix value) =>
- TextFieldStyler().padding(value);
- factory TextFieldStyler.margin(EdgeInsetsGeometryMix value) =>
- TextFieldStyler().margin(value);
- factory TextFieldStyler.constraints(BoxConstraintsMix value) =>
- TextFieldStyler().constraints(value);
- factory TextFieldStyler.decoration(DecorationMix value) =>
- TextFieldStyler().decoration(value);
- factory TextFieldStyler.foregroundDecoration(DecorationMix value) =>
- TextFieldStyler().foregroundDecoration(value);
- factory TextFieldStyler.clipBehavior(Clip value) =>
- TextFieldStyler().clipBehavior(value);
- factory TextFieldStyler.direction(Axis value) =>
- TextFieldStyler().direction(value);
- factory TextFieldStyler.mainAxisAlignment(MainAxisAlignment value) =>
- TextFieldStyler().mainAxisAlignment(value);
- factory TextFieldStyler.crossAxisAlignment(CrossAxisAlignment value) =>
- TextFieldStyler().crossAxisAlignment(value);
- factory TextFieldStyler.mainAxisSize(MainAxisSize value) =>
- TextFieldStyler().mainAxisSize(value);
- factory TextFieldStyler.spacing(double value) =>
- TextFieldStyler().spacing(value);
- factory TextFieldStyler.verticalDirection(VerticalDirection value) =>
- TextFieldStyler().verticalDirection(value);
- factory TextFieldStyler.textDirection(TextDirection value) =>
- TextFieldStyler().textDirection(value);
- factory TextFieldStyler.textBaseline(TextBaseline value) =>
- TextFieldStyler().textBaseline(value);
factory TextFieldStyler.transform(
Matrix4 value, {
Alignment alignment = .center,
}) => TextFieldStyler().transform(value, alignment: alignment);
+ TextFieldStyler alignment(AlignmentGeometry value) {
+ return container(BoxStyler().alignment(value));
+ }
+
+ TextFieldStyler padding(EdgeInsetsGeometryMix value) {
+ return container(BoxStyler().padding(value));
+ }
+
+ TextFieldStyler margin(EdgeInsetsGeometryMix value) {
+ return container(BoxStyler().margin(value));
+ }
+
+ TextFieldStyler constraints(BoxConstraintsMix value) {
+ return container(BoxStyler().constraints(value));
+ }
+
+ TextFieldStyler decoration(DecorationMix value) {
+ return container(BoxStyler().decoration(value));
+ }
+
+ TextFieldStyler foregroundDecoration(DecorationMix value) {
+ return container(BoxStyler().foregroundDecoration(value));
+ }
+
+ TextFieldStyler clipBehavior(Clip value) {
+ return container(BoxStyler().clipBehavior(value));
+ }
+
TextFieldStyler color(Color value) {
- return container(FlexBoxStyler().color(value));
+ return container(BoxStyler().color(value));
}
TextFieldStyler gradient(GradientMix value) {
- return container(FlexBoxStyler().gradient(value));
+ return container(BoxStyler().gradient(value));
}
TextFieldStyler border(BoxBorderMix value) {
- return container(FlexBoxStyler().border(value));
+ return container(BoxStyler().border(value));
}
TextFieldStyler borderRadius(BorderRadiusGeometryMix value) {
- return container(FlexBoxStyler().borderRadius(value));
+ return container(BoxStyler().borderRadius(value));
}
TextFieldStyler elevation(ElevationShadow value) {
- return container(FlexBoxStyler().elevation(value));
+ return container(BoxStyler().elevation(value));
}
TextFieldStyler shadow(BoxShadowMix value) {
- return container(FlexBoxStyler().shadow(value));
+ return container(BoxStyler().shadow(value));
}
TextFieldStyler shadows(List value) {
- return container(FlexBoxStyler().shadows(value));
+ return container(BoxStyler().shadows(value));
}
TextFieldStyler width(double value) {
- return container(FlexBoxStyler().width(value));
+ return container(BoxStyler().width(value));
}
TextFieldStyler height(double value) {
- return container(FlexBoxStyler().height(value));
+ return container(BoxStyler().height(value));
}
TextFieldStyler size(double width, double height) {
- return container(FlexBoxStyler().size(width, height));
+ return container(BoxStyler().size(width, height));
}
TextFieldStyler minWidth(double value) {
- return container(FlexBoxStyler().minWidth(value));
+ return container(BoxStyler().minWidth(value));
}
TextFieldStyler maxWidth(double value) {
- return container(FlexBoxStyler().maxWidth(value));
+ return container(BoxStyler().maxWidth(value));
}
TextFieldStyler minHeight(double value) {
- return container(FlexBoxStyler().minHeight(value));
+ return container(BoxStyler().minHeight(value));
}
TextFieldStyler maxHeight(double value) {
- return container(FlexBoxStyler().maxHeight(value));
+ return container(BoxStyler().maxHeight(value));
}
TextFieldStyler scale(double scale, {Alignment alignment = .center}) {
- return container(FlexBoxStyler().scale(scale, alignment: alignment));
+ return container(BoxStyler().scale(scale, alignment: alignment));
}
TextFieldStyler rotate(double radians, {Alignment alignment = .center}) {
- return container(FlexBoxStyler().rotate(radians, alignment: alignment));
+ return container(BoxStyler().rotate(radians, alignment: alignment));
}
TextFieldStyler translate(double x, double y, [double z = 0.0]) {
- return container(FlexBoxStyler().translate(x, y, z));
+ return container(BoxStyler().translate(x, y, z));
}
TextFieldStyler skew(double skewX, double skewY) {
- return container(FlexBoxStyler().skew(skewX, skewY));
+ return container(BoxStyler().skew(skewX, skewY));
}
TextFieldStyler textStyle(TextStyler value) {
- return container(FlexBoxStyler().textStyle(value));
+ return container(BoxStyler().textStyle(value));
}
TextFieldStyler image(DecorationImageMix value) {
- return container(FlexBoxStyler().image(value));
+ return container(BoxStyler().image(value));
}
TextFieldStyler shape(ShapeBorderMix value) {
- return container(FlexBoxStyler().shape(value));
+ return container(BoxStyler().shape(value));
}
TextFieldStyler backgroundImage(
@@ -1079,7 +1124,7 @@ class TextFieldStyler extends MixStyler
ImageRepeat repeat = .noRepeat,
}) {
return container(
- FlexBoxStyler().backgroundImage(
+ BoxStyler().backgroundImage(
image,
fit: fit,
alignment: alignment,
@@ -1095,7 +1140,7 @@ class TextFieldStyler extends MixStyler
ImageRepeat repeat = .noRepeat,
}) {
return container(
- FlexBoxStyler().backgroundImageUrl(
+ BoxStyler().backgroundImageUrl(
url,
fit: fit,
alignment: alignment,
@@ -1111,7 +1156,7 @@ class TextFieldStyler extends MixStyler
ImageRepeat repeat = .noRepeat,
}) {
return container(
- FlexBoxStyler().backgroundImageAsset(
+ BoxStyler().backgroundImageAsset(
path,
fit: fit,
alignment: alignment,
@@ -1128,7 +1173,7 @@ class TextFieldStyler extends MixStyler
TileMode? tileMode,
}) {
return container(
- FlexBoxStyler().linearGradient(
+ BoxStyler().linearGradient(
colors: colors,
stops: stops,
begin: begin,
@@ -1148,7 +1193,7 @@ class TextFieldStyler extends MixStyler
TileMode? tileMode,
}) {
return container(
- FlexBoxStyler().radialGradient(
+ BoxStyler().radialGradient(
colors: colors,
stops: stops,
center: center,
@@ -1169,7 +1214,7 @@ class TextFieldStyler extends MixStyler
TileMode? tileMode,
}) {
return container(
- FlexBoxStyler().sweepGradient(
+ BoxStyler().sweepGradient(
colors: colors,
stops: stops,
center: center,
@@ -1188,7 +1233,7 @@ class TextFieldStyler extends MixStyler
TileMode? tileMode,
}) {
return container(
- FlexBoxStyler().foregroundLinearGradient(
+ BoxStyler().foregroundLinearGradient(
colors: colors,
stops: stops,
begin: begin,
@@ -1208,7 +1253,7 @@ class TextFieldStyler extends MixStyler
TileMode? tileMode,
}) {
return container(
- FlexBoxStyler().foregroundRadialGradient(
+ BoxStyler().foregroundRadialGradient(
colors: colors,
stops: stops,
center: center,
@@ -1229,7 +1274,7 @@ class TextFieldStyler extends MixStyler
TileMode? tileMode,
}) {
return container(
- FlexBoxStyler().foregroundSweepGradient(
+ BoxStyler().foregroundSweepGradient(
colors: colors,
stops: stops,
center: center,
@@ -1240,76 +1285,8 @@ class TextFieldStyler extends MixStyler
);
}
- TextFieldStyler row() {
- return container(FlexBoxStyler().row());
- }
-
- TextFieldStyler column() {
- return container(FlexBoxStyler().column());
- }
-
- TextFieldStyler alignment(AlignmentGeometry value) {
- return container(FlexBoxStyler().alignment(value));
- }
-
- TextFieldStyler padding(EdgeInsetsGeometryMix value) {
- return container(FlexBoxStyler().padding(value));
- }
-
- TextFieldStyler margin(EdgeInsetsGeometryMix value) {
- return container(FlexBoxStyler().margin(value));
- }
-
- TextFieldStyler constraints(BoxConstraintsMix value) {
- return container(FlexBoxStyler().constraints(value));
- }
-
- TextFieldStyler decoration(DecorationMix value) {
- return container(FlexBoxStyler().decoration(value));
- }
-
- TextFieldStyler foregroundDecoration(DecorationMix value) {
- return container(FlexBoxStyler().foregroundDecoration(value));
- }
-
- TextFieldStyler clipBehavior(Clip value) {
- return container(FlexBoxStyler().clipBehavior(value));
- }
-
- TextFieldStyler direction(Axis value) {
- return container(FlexBoxStyler().direction(value));
- }
-
- TextFieldStyler mainAxisAlignment(MainAxisAlignment value) {
- return container(FlexBoxStyler().mainAxisAlignment(value));
- }
-
- TextFieldStyler crossAxisAlignment(CrossAxisAlignment value) {
- return container(FlexBoxStyler().crossAxisAlignment(value));
- }
-
- TextFieldStyler mainAxisSize(MainAxisSize value) {
- return container(FlexBoxStyler().mainAxisSize(value));
- }
-
- TextFieldStyler spacing(double value) {
- return container(FlexBoxStyler().spacing(value));
- }
-
- TextFieldStyler verticalDirection(VerticalDirection value) {
- return container(FlexBoxStyler().verticalDirection(value));
- }
-
- TextFieldStyler textDirection(TextDirection value) {
- return container(FlexBoxStyler().textDirection(value));
- }
-
- TextFieldStyler textBaseline(TextBaseline value) {
- return container(FlexBoxStyler().textBaseline(value));
- }
-
TextFieldStyler transform(Matrix4 value, {Alignment alignment = .center}) {
- return container(FlexBoxStyler().transform(value, alignment: alignment));
+ return container(BoxStyler().transform(value, alignment: alignment));
}
/// Sets the text.
@@ -1373,10 +1350,20 @@ class TextFieldStyler extends MixStyler
}
/// Sets the container.
- TextFieldStyler container(FlexBoxStyler value) {
+ TextFieldStyler container(BoxStyler value) {
return merge(TextFieldStyler(container: value));
}
+ /// Sets the spacing.
+ TextFieldStyler spacing(double value) {
+ return merge(TextFieldStyler(spacing: value));
+ }
+
+ /// Sets the crossAxisAlignment.
+ TextFieldStyler crossAxisAlignment(CrossAxisAlignment value) {
+ return merge(TextFieldStyler(crossAxisAlignment: value));
+ }
+
/// Sets the layout.
TextFieldStyler layout(FlexBoxStyler value) {
return merge(TextFieldStyler(layout: value));
@@ -1450,6 +1437,11 @@ class TextFieldStyler extends MixStyler
other?.$cursorOpacityAnimates,
),
container: MixOps.merge($container, other?.$container),
+ spacing: MixOps.merge($spacing, other?.$spacing),
+ crossAxisAlignment: MixOps.merge(
+ $crossAxisAlignment,
+ other?.$crossAxisAlignment,
+ ),
layout: MixOps.merge($layout, other?.$layout),
helperText: MixOps.merge($helperText, other?.$helperText),
label: MixOps.merge($label, other?.$label),
@@ -1480,6 +1472,8 @@ class TextFieldStyler extends MixStyler
keyboardAppearance: MixOps.resolve(context, $keyboardAppearance),
cursorOpacityAnimates: MixOps.resolve(context, $cursorOpacityAnimates),
container: MixOps.resolve(context, $container),
+ spacing: MixOps.resolve(context, $spacing),
+ crossAxisAlignment: MixOps.resolve(context, $crossAxisAlignment),
layout: MixOps.resolve(context, $layout),
helperText: MixOps.resolve(context, $helperText),
label: MixOps.resolve(context, $label),
@@ -1512,6 +1506,8 @@ class TextFieldStyler extends MixStyler
DiagnosticsProperty('cursorOpacityAnimates', $cursorOpacityAnimates),
)
..add(DiagnosticsProperty('container', $container))
+ ..add(DiagnosticsProperty('spacing', $spacing))
+ ..add(DiagnosticsProperty('crossAxisAlignment', $crossAxisAlignment))
..add(DiagnosticsProperty('layout', $layout))
..add(DiagnosticsProperty('helperText', $helperText))
..add(DiagnosticsProperty('label', $label))
@@ -1533,6 +1529,8 @@ class TextFieldStyler extends MixStyler
$keyboardAppearance,
$cursorOpacityAnimates,
$container,
+ $spacing,
+ $crossAxisAlignment,
$layout,
$helperText,
$label,
diff --git a/packages/remix/lib/src/components/textfield/textfield_spec.dart b/packages/remix/lib/src/components/textfield/textfield_spec.dart
index edc81534..8f540db1 100644
--- a/packages/remix/lib/src/components/textfield/textfield_spec.dart
+++ b/packages/remix/lib/src/components/textfield/textfield_spec.dart
@@ -136,19 +136,32 @@ class TextFieldSpec with _$TextFieldSpec {
/// Styling specification for the text field's container.
///
- /// Controls the text field's layout, background, borders, padding,
- /// and other visual container properties. Uses [FlexBoxSpec]
- /// to support flexible layout arrangements.
+ /// Controls the text field's background, borders, padding, constraints, and
+ /// other box styling. The input anatomy is rendered separately as a fixed
+ /// horizontal row.
@override
@MixableField(forwardStyler: true)
- final StyleSpec container;
+ final StyleSpec container;
- /// Styling specification for the vertical layout that wraps the label,
- /// input container, and helper text.
+ /// Spacing between the leading widget, editor, and trailing widget.
///
- /// Rendered as a [ColumnBox], so its [FlexBoxSpec] controls the vertical
- /// spacing between the label, field, and helper text, as well as any
- /// horizontal spacing (padding/alignment) around them.
+ /// This is an explicit generated control because the input container's
+ /// direction is fixed to a row while its child spacing remains configurable.
+ @override
+ final double? spacing;
+
+ /// Cross-axis alignment for the input row's editor and accessories.
+ ///
+ /// Baseline alignment uses [TextBaseline.alphabetic].
+ @override
+ final CrossAxisAlignment? crossAxisAlignment;
+
+ /// Styling specification for the layout that wraps the label, input
+ /// container, and helper text.
+ ///
+ /// Rendered as a [FlexBox], so its full [FlexBoxSpec] controls the direction,
+ /// spacing, alignment, and box styling around the label, field, and helper
+ /// text. The base style defaults this layout to a vertical column.
@override
final StyleSpec layout;
@@ -179,7 +192,9 @@ class TextFieldSpec with _$TextFieldSpec {
/// - Cursor width defaults to 2.0 logical pixels
/// - Selection styles default to tight sizing
/// - Scroll padding defaults to 20.0 on all sides
- /// - All [StyleSpec] properties default to empty specifications
+ /// - The outer layout defaults to a vertical, min-size, start-aligned flex
+ /// with 8 logical pixels between the label, input, and helper
+ /// - All other [StyleSpec] properties default to empty specifications
///
/// Example:
/// ```dart
@@ -202,7 +217,9 @@ class TextFieldSpec with _$TextFieldSpec {
this.scrollPadding = const EdgeInsets.all(20.0),
this.keyboardAppearance,
this.cursorOpacityAnimates,
- StyleSpec? container,
+ StyleSpec? container,
+ this.spacing,
+ this.crossAxisAlignment,
StyleSpec? layout,
StyleSpec? helperText,
StyleSpec? label,
@@ -211,8 +228,8 @@ class TextFieldSpec with _$TextFieldSpec {
hintText = hintText ?? const StyleSpec(spec: TextSpec()),
helperText = helperText ?? const StyleSpec(spec: TextSpec()),
label = label ?? const StyleSpec(spec: TextSpec()),
- container = container ?? const StyleSpec(spec: FlexBoxSpec()),
- layout = layout ?? const StyleSpec(spec: FlexBoxSpec());
+ container = container ?? const StyleSpec(spec: BoxSpec()),
+ layout = layout ?? _defaultTextFieldLayout;
// Deliberate: route effects through lerpNullable so shadows/blends animate;
// the generator's default snap-lerps unrecognized spec types.
@@ -230,6 +247,19 @@ class TextFieldSpec with _$TextFieldSpec {
}
}
+const _defaultTextFieldLayout = StyleSpec(
+ spec: FlexBoxSpec(
+ flex: StyleSpec(
+ spec: FlexSpec(
+ direction: Axis.vertical,
+ crossAxisAlignment: CrossAxisAlignment.start,
+ mainAxisSize: MainAxisSize.min,
+ spacing: 8,
+ ),
+ ),
+ ),
+);
+
/// Backward-compatible name for [TextFieldSpec].
///
/// The generated style API is based on [TextFieldSpec], so resolved values use
diff --git a/packages/remix/lib/src/components/textfield/textfield_style.dart b/packages/remix/lib/src/components/textfield/textfield_style.dart
index a0733c96..e980f49f 100644
--- a/packages/remix/lib/src/components/textfield/textfield_style.dart
+++ b/packages/remix/lib/src/components/textfield/textfield_style.dart
@@ -21,7 +21,7 @@ extension RemixTextFieldStylerRemixHelpers on TextFieldStyler {
TextFieldStyler backgroundColor(Color value) {
return merge(
TextFieldStyler(
- container: FlexBoxStyler(decoration: BoxDecorationMix(color: value)),
+ container: BoxStyler(decoration: BoxDecorationMix(color: value)),
),
);
}
@@ -171,8 +171,4 @@ extension RemixTextFieldStylerRemixHelpers on TextFieldStyler {
style: this,
);
}
-
- TextFieldStyler flex(FlexStyler value) {
- return merge(TextFieldStyler(container: FlexBoxStyler().flex(value)));
- }
}
diff --git a/packages/remix/lib/src/components/textfield/textfield_widget.dart b/packages/remix/lib/src/components/textfield/textfield_widget.dart
index 02172d77..e33f0094 100644
--- a/packages/remix/lib/src/components/textfield/textfield_widget.dart
+++ b/packages/remix/lib/src/components/textfield/textfield_widget.dart
@@ -259,41 +259,151 @@ class RemixTextField extends StatelessWidget {
static final styleFrom = TextFieldStyler.new;
- Widget _buildResolved(
- TextFieldSpec spec,
- WidgetStatesController styleController,
- ) {
- return NakedTextField(
- groupId: groupId,
- controller: controller,
- focusNode: focusNode,
- undoController: undoController,
- keyboardType: keyboardType,
- textInputAction: textInputAction,
- textCapitalization: textCapitalization,
+ @override
+ Widget build(BuildContext context) => _RemixTextFieldBody(config: this);
+}
+
+class _RemixTextFieldBody extends StatefulWidget {
+ const _RemixTextFieldBody({required this.config});
+
+ final RemixTextField config;
+
+ @override
+ State<_RemixTextFieldBody> createState() => _RemixTextFieldBodyState();
+}
+
+class _RemixTextFieldBodyState extends State<_RemixTextFieldBody> {
+ late final WidgetStatesController _styleController;
+ final _activePressSources = <_RemixTextFieldPressSource>{};
+ FocusNode? _internalFocusNode;
+
+ FocusNode get _effectiveFocusNode =>
+ widget.config.focusNode ?? _internalFocusNode!;
+
+ @override
+ void initState() {
+ super.initState();
+ _styleController = WidgetStatesController({
+ if (!widget.config.enabled || widget.config.readOnly) .disabled,
+ if (widget.config.error) .error,
+ });
+ if (widget.config.focusNode == null) {
+ _internalFocusNode = FocusNode(
+ debugLabel: '${widget.config.runtimeType} (internal)',
+ );
+ }
+ }
+
+ @override
+ void didUpdateWidget(_RemixTextFieldBody oldWidget) {
+ super.didUpdateWidget(oldWidget);
+ final oldExternalFocusNode = oldWidget.config.focusNode;
+ final newExternalFocusNode = widget.config.focusNode;
+
+ if (!identical(oldExternalFocusNode, newExternalFocusNode)) {
+ if (oldExternalFocusNode == null && newExternalFocusNode != null) {
+ // Deliberate: the dispose must be deferred, not synchronous. This
+ // state's didUpdateWidget runs before NakedTextField's, and Naked
+ // reads the outgoing node's `hasFocus` to decide whether to move
+ // focus onto the incoming one. Disposing here detaches the node and
+ // unfocuses it first, so focus would be silently dropped across a
+ // null -> external swap.
+ final obsoleteInternalNode = _internalFocusNode!;
+ _internalFocusNode = null;
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ obsoleteInternalNode.dispose();
+ });
+ } else if (oldExternalFocusNode != null && newExternalFocusNode == null) {
+ _internalFocusNode = FocusNode(
+ debugLabel: '${widget.config.runtimeType} (internal)',
+ );
+ }
+ }
+
+ _styleController
+ ..update(.disabled, !widget.config.enabled || widget.config.readOnly)
+ ..update(.error, widget.config.error);
+
+ if (!widget.config.enabled || widget.config.ignorePointers == true) {
+ _activePressSources.clear();
+ _styleController.update(.pressed, false);
+ }
+ }
+
+ void _updatePressSource(_RemixTextFieldPressSource source, bool pressed) {
+ if (!mounted) return;
+
+ if (pressed) {
+ _activePressSources.add(source);
+ } else {
+ _activePressSources.remove(source);
+ }
+ _styleController.update(.pressed, _activePressSources.isNotEmpty);
+ }
+
+ @override
+ void dispose() {
+ _styleController.dispose();
+ _internalFocusNode?.dispose();
+ super.dispose();
+ }
+
+ Widget _buildResolved(TextFieldSpec spec) {
+ final config = widget.config;
+ final isMultiline =
+ config.expands || config.maxLines != 1 || (config.minLines ?? 1) > 1;
+ final hintAlignment = isMultiline
+ ? AlignmentDirectional.topStart
+ : AlignmentDirectional.centerStart;
+ final acceptsPointerEvents =
+ config.enabled && config.ignorePointers != true;
+ final effectiveSemanticErrorText = config.error
+ ? _joinSemanticText([config.helperText])
+ : null;
+ final joinedSemanticHint = _joinSemanticText([
+ config.semanticHint ?? config.hintText,
+ if (!config.error) config.helperText,
+ ]);
+ // Deliberate: NakedTextField concatenates semanticHint and
+ // semanticErrorText unconditionally without deduplicating, so a hint that
+ // already equals the error text would be announced twice. Dropping the
+ // hint here is what keeps the announcement single.
+ final effectiveSemanticHint =
+ joinedSemanticHint == effectiveSemanticErrorText
+ ? null
+ : joinedSemanticHint;
+
+ final nakedTextField = NakedTextField(
+ groupId: config.groupId,
+ controller: config.controller,
+ focusNode: _effectiveFocusNode,
+ undoController: config.undoController,
+ keyboardType: config.keyboardType,
+ textInputAction: config.textInputAction,
+ textCapitalization: config.textCapitalization,
textAlign: spec.textAlign ?? .start,
- textDirection: textDirection,
- readOnly: readOnly,
- showCursor: showCursor,
- autofocus: autofocus,
- obscuringCharacter: obscuringCharacter,
- obscureText: obscureText,
- autocorrect: autocorrect,
- smartDashesType: smartDashesType,
- smartQuotesType: smartQuotesType,
- enableSuggestions: enableSuggestions,
- maxLines: maxLines,
- minLines: minLines,
- expands: expands,
- maxLength: maxLength,
- maxLengthEnforcement: maxLengthEnforcement,
- onChanged: onChanged,
- onEditingComplete: onEditingComplete,
- onSubmitted: onSubmitted,
- onAppPrivateCommand: onAppPrivateCommand,
- inputFormatters: inputFormatters,
- enabled: enabled,
- error: error,
+ textDirection: config.textDirection,
+ readOnly: config.readOnly,
+ showCursor: config.showCursor,
+ autofocus: config.autofocus,
+ obscuringCharacter: config.obscuringCharacter,
+ obscureText: config.obscureText,
+ autocorrect: config.autocorrect,
+ smartDashesType: config.smartDashesType,
+ smartQuotesType: config.smartQuotesType,
+ enableSuggestions: config.enableSuggestions,
+ maxLines: config.maxLines,
+ minLines: config.minLines,
+ expands: config.expands,
+ maxLength: config.maxLength,
+ maxLengthEnforcement: config.maxLengthEnforcement,
+ onChanged: config.onChanged,
+ onEditingComplete: config.onEditingComplete,
+ onSubmitted: config.onSubmitted,
+ onAppPrivateCommand: config.onAppPrivateCommand,
+ inputFormatters: config.inputFormatters,
+ enabled: config.enabled,
+ error: config.error,
cursorWidth: spec.cursorWidth ?? 2.0,
cursorHeight: spec.cursorHeight,
cursorRadius: spec.cursorRadius,
@@ -303,33 +413,33 @@ class RemixTextField extends StatelessWidget {
selectionWidthStyle: spec.selectionWidthStyle ?? .tight,
keyboardAppearance: spec.keyboardAppearance,
scrollPadding: spec.scrollPadding ?? const .all(20.0),
- dragStartBehavior: dragStartBehavior,
- enableInteractiveSelection: enableInteractiveSelection,
- selectionControls: selectionControls,
- onTap: onTap,
- onTapAlwaysCalled: onTapAlwaysCalled,
- onTapOutside: onTapOutside,
- scrollController: scrollController,
- scrollPhysics: scrollPhysics,
- autofillHints: autofillHints,
- contentInsertionConfiguration: contentInsertionConfiguration,
- clipBehavior: clipBehavior,
- restorationId: restorationId,
- onTapUpOutside: onPressUpOutside,
- stylusHandwritingEnabled: stylusHandwritingEnabled,
- enableIMEPersonalizedLearning: enableIMEPersonalizedLearning,
- contextMenuBuilder: contextMenuBuilder,
- canRequestFocus: canRequestFocus,
- spellCheckConfiguration: spellCheckConfiguration,
- magnifierConfiguration: magnifierConfiguration,
- onHoverChange: (value) => styleController.update(.hovered, value),
- onFocusChange: (value) => styleController.update(.focused, value),
- onPressChange: (value) => styleController.update(.pressed, value),
- ignorePointers: ignorePointers,
- semanticLabel: semanticLabel ?? label,
- semanticHint: semanticHint ?? hintText,
- semanticErrorText: error ? helperText : null,
- excludeSemantics: excludeSemantics,
+ dragStartBehavior: config.dragStartBehavior,
+ enableInteractiveSelection: config.enableInteractiveSelection,
+ selectionControls: config.selectionControls,
+ onTap: config.onTap,
+ onTapAlwaysCalled: config.onTapAlwaysCalled,
+ onTapOutside: config.onTapOutside,
+ scrollController: config.scrollController,
+ scrollPhysics: config.scrollPhysics,
+ autofillHints: config.autofillHints,
+ contentInsertionConfiguration: config.contentInsertionConfiguration,
+ clipBehavior: config.clipBehavior,
+ restorationId: config.restorationId,
+ onTapUpOutside: config.onPressUpOutside,
+ stylusHandwritingEnabled: config.stylusHandwritingEnabled,
+ enableIMEPersonalizedLearning: config.enableIMEPersonalizedLearning,
+ contextMenuBuilder: config.contextMenuBuilder,
+ canRequestFocus: config.canRequestFocus,
+ spellCheckConfiguration: config.spellCheckConfiguration,
+ magnifierConfiguration: config.magnifierConfiguration,
+ onFocusChange: (value) => _styleController.update(.focused, value),
+ onPressChange: acceptsPointerEvents
+ ? (bool pressed) => _updatePressSource(.editable, pressed)
+ : null,
+ ignorePointers: config.ignorePointers,
+ semanticLabel: config.semanticLabel ?? config.label,
+ semanticHint: effectiveSemanticHint,
+ semanticErrorText: effectiveSemanticErrorText,
builder: (BuildContext context, _, Widget editableText) {
final textFieldState = NakedTextFieldState.of(context);
final styledEditableText = StyleSpecBuilder(
@@ -340,15 +450,20 @@ class RemixTextField extends StatelessWidget {
),
);
- final editableWithHint = hintText != null
+ final editableWithHint = config.hintText != null
? Stack(
- alignment: AlignmentDirectional.centerStart,
+ alignment: hintAlignment,
children: [
if (textFieldState.text.isEmpty)
Positioned.fill(
child: Align(
- alignment: AlignmentDirectional.centerStart,
- child: StyledText(hintText!, styleSpec: spec.hintText),
+ alignment: hintAlignment,
+ child: ExcludeSemantics(
+ child: StyledText(
+ config.hintText!,
+ styleSpec: spec.hintText,
+ ),
+ ),
),
),
styledEditableText,
@@ -356,72 +471,77 @@ class RemixTextField extends StatelessWidget {
)
: styledEditableText;
- final withAccessories = RemixFlexBoxWithEffects(
- styleSpec: spec.container,
- direction: Axis.horizontal,
- containerEffects: spec.containerEffects,
- children: [
- ?leading,
- // ignore: avoid-flexible-outside-flex
- Expanded(child: editableWithHint),
- ?trailing,
- ],
- );
-
- final needsWrapper = label != null || helperText != null;
-
- return needsWrapper
- ? ColumnBox(
- styleSpec: spec.layout,
- children: [
- if (label != null) StyledText(label!, styleSpec: spec.label),
- withAccessories,
- if (helperText != null)
- StyledText(helperText!, styleSpec: spec.helperText),
- ],
+ return config.error
+ ? Semantics(
+ validationResult: SemanticsValidationResult.invalid,
+ child: editableWithHint,
)
- : withAccessories;
+ : editableWithHint;
},
);
- }
- @override
- Widget build(BuildContext context) => _RemixTextFieldBody(config: this);
-}
-
-class _RemixTextFieldBody extends StatefulWidget {
- const _RemixTextFieldBody({required this.config});
-
- final RemixTextField config;
-
- @override
- State<_RemixTextFieldBody> createState() => _RemixTextFieldBodyState();
-}
-
-class _RemixTextFieldBodyState extends State<_RemixTextFieldBody> {
- late final WidgetStatesController _styleController;
-
- @override
- void initState() {
- super.initState();
- _styleController = WidgetStatesController({
- if (!widget.config.enabled || widget.config.readOnly) .disabled,
- if (widget.config.error) .error,
- });
- }
+ final withAccessories = RemixBoxWithEffects(
+ styleSpec: spec.container,
+ containerEffects: spec.containerEffects,
+ child: Row(
+ spacing: spec.spacing ?? 0,
+ crossAxisAlignment:
+ spec.crossAxisAlignment ?? CrossAxisAlignment.center,
+ textBaseline: TextBaseline.alphabetic,
+ children: [
+ ?config.leading,
+ Expanded(child: nakedTextField),
+ ?config.trailing,
+ ],
+ ),
+ );
- @override
- void didUpdateWidget(_RemixTextFieldBody oldWidget) {
- super.didUpdateWidget(oldWidget);
- _styleController
- ..update(.disabled, !widget.config.enabled || widget.config.readOnly)
- ..update(.error, widget.config.error);
- }
+ final needsWrapper = config.label != null || config.helperText != null;
+ Widget composite = needsWrapper
+ ? FlexBox(
+ styleSpec: spec.layout,
+ children: [
+ if (config.label != null)
+ ExcludeSemantics(
+ child: StyledText(config.label!, styleSpec: spec.label),
+ ),
+ withAccessories,
+ if (config.helperText != null)
+ ExcludeSemantics(
+ child: StyledText(
+ config.helperText!,
+ styleSpec: spec.helperText,
+ ),
+ ),
+ ],
+ )
+ : withAccessories;
+
+ composite = _RemixTextFieldFallbackGestureDetector(
+ enabled: acceptsPointerEvents,
+ onTapAlwaysCalled: config.onTapAlwaysCalled,
+ onPressChange: (bool pressed) => _updatePressSource(.fallback, pressed),
+ onTap: () {
+ if (config.canRequestFocus && _effectiveFocusNode.canRequestFocus) {
+ _effectiveFocusNode.requestFocus();
+ }
+ config.onTap?.call();
+ },
+ child: composite,
+ );
- @override
- void dispose() {
- _styleController.dispose();
- super.dispose();
+ if (config.enabled) {
+ composite = MouseRegion(
+ onEnter: (_) => _styleController.update(.hovered, true),
+ onExit: (_) => _styleController.update(.hovered, false),
+ cursor: SystemMouseCursors.text,
+ child: composite,
+ );
+ }
+
+ return config.excludeSemantics
+ ? ExcludeSemantics(child: composite)
+ : composite;
}
@override
@@ -432,21 +552,73 @@ class _RemixTextFieldBodyState extends State<_RemixTextFieldBody> {
style: _baseStyle.merge(config.style),
styleSpec: config.styleSpec,
controller: _styleController,
- builder: (context, spec) => config._buildResolved(spec, _styleController),
+ builder: (context, spec) => _buildResolved(spec),
+ );
+ }
+}
+
+enum _RemixTextFieldPressSource { editable, fallback }
+
+class _RemixTextFieldFallbackGestureDetector extends StatelessWidget {
+ const _RemixTextFieldFallbackGestureDetector({
+ required this.enabled,
+ required this.onTapAlwaysCalled,
+ required this.onPressChange,
+ required this.onTap,
+ required this.child,
+ });
+
+ final bool enabled;
+ final bool onTapAlwaysCalled;
+ final ValueChanged onPressChange;
+ final VoidCallback onTap;
+ final Widget child;
+
+ @override
+ Widget build(BuildContext context) {
+ if (!enabled) return child;
+
+ return TextSelectionGestureDetector(
+ onTapTrackReset: () => onPressChange(false),
+ onTapDown: (_) => onPressChange(true),
+ onSingleTapUp: (_) => onPressChange(false),
+ onSingleTapCancel: () => onPressChange(false),
+ onUserTap: onTap,
+ onDoubleTapDown: (_) => onPressChange(false),
+ onTripleTapDown: (_) => onPressChange(false),
+ onDragSelectionStart: (_) => onPressChange(false),
+ onUserTapAlwaysCalled: onTapAlwaysCalled,
+ behavior: HitTestBehavior.translucent,
+ child: child,
);
}
}
+String? _joinSemanticText(Iterable values) {
+ final pieces = [];
+ for (final value in values) {
+ final normalized = value?.trim();
+ if (normalized == null ||
+ normalized.isEmpty ||
+ pieces.contains(normalized)) {
+ continue;
+ }
+ pieces.add(normalized);
+ }
+
+ return pieces.isEmpty ? null : pieces.join('\n');
+}
+
/// Baseline style merged beneath the user-supplied style.
///
-/// It seeds the vertical [ColumnBox] wrapper (the [TextFieldSpec.layout])
-/// with the default min-size / start-alignment layout and an 8px vertical
-/// spacing. Merging it underneath the caller's style means customizing a
-/// single layout property (e.g. `.layout(.spacing(12))`) keeps
-/// the remaining defaults instead of falling back to `ColumnBox`'s
-/// `mainAxisSize: max` / `crossAxisAlignment: center`.
+/// It seeds the [FlexBox] wrapper (the [TextFieldSpec.layout]) with a vertical,
+/// min-size, start-aligned layout and 8px spacing. Merging it underneath the
+/// caller's style means customizing a single layout property (for example,
+/// `.layout(.spacing(12))`) keeps the remaining defaults instead of falling
+/// back to `FlexBox`'s horizontal / max / center defaults.
final TextFieldStyler _baseStyle = TextFieldStyler(
layout: FlexBoxStyler()
+ .direction(.vertical)
.mainAxisSize(.min)
.crossAxisAlignment(.start)
.spacing(8),
diff --git a/packages/remix/test/components/styler_factory_shorthand_test.dart b/packages/remix/test/components/styler_factory_shorthand_test.dart
index d905fa0b..ace3ac9a 100644
--- a/packages/remix/test/components/styler_factory_shorthand_test.dart
+++ b/packages/remix/test/components/styler_factory_shorthand_test.dart
@@ -166,7 +166,7 @@ void main() {
expectSameSpec(
TextFieldStyler().color(Colors.white).textColor(Colors.black),
TextFieldStyler(
- container: FlexBoxStyler(
+ container: BoxStyler(
decoration: BoxDecorationMix(color: Colors.white),
),
text: TextStyler(style: TextStyleMix(color: Colors.black)),
diff --git a/packages/remix/test/components/textfield/textarea_widget_test.dart b/packages/remix/test/components/textfield/textarea_widget_test.dart
new file mode 100644
index 00000000..100b224a
--- /dev/null
+++ b/packages/remix/test/components/textfield/textarea_widget_test.dart
@@ -0,0 +1,613 @@
+import 'dart:ui' show SemanticsInputType;
+
+import 'package:flutter/gestures.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter/rendering.dart';
+import 'package:flutter/services.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:naked_ui/naked_ui.dart';
+import 'package:remix/remix.dart';
+
+import '../../helpers/test_helpers.dart';
+
+void main() {
+ group('RemixTextArea', () {
+ testWidgets('uses safe multiline defaults', (tester) async {
+ await tester.pumpRemixApp(const RemixTextArea());
+
+ final textArea = tester.widget(find.byType(RemixTextArea));
+ final nakedTextField = tester.widget(
+ find.byType(NakedTextField),
+ );
+ final editableText = tester.widget(
+ find.byType(EditableText),
+ );
+
+ expect(textArea, isA());
+ expect(textArea.minLines, 2);
+ expect(textArea.maxLines, isNull);
+ expect(nakedTextField.keyboardType, TextInputType.multiline);
+ expect(nakedTextField.textInputAction, TextInputAction.newline);
+ expect(nakedTextField.minLines, 2);
+ expect(nakedTextField.maxLines, isNull);
+ expect(nakedTextField.expands, isFalse);
+ expect(nakedTextField.obscureText, isFalse);
+ expect(editableText.minLines, 2);
+ expect(editableText.maxLines, isNull);
+ expect(editableText.expands, isFalse);
+ expect(editableText.obscureText, isFalse);
+ });
+
+ testWidgets('preserves newlines through the shared input pipeline', (
+ tester,
+ ) async {
+ final controller = TextEditingController();
+ addTearDown(controller.dispose);
+ String? changedValue;
+
+ await tester.pumpRemixApp(
+ RemixTextArea(
+ controller: controller,
+ onChanged: (value) => changedValue = value,
+ ),
+ );
+
+ await tester.enterText(find.byType(RemixTextArea), 'first\nsecond');
+ await tester.pump();
+
+ expect(controller.text, 'first\nsecond');
+ expect(changedValue, 'first\nsecond');
+ });
+
+ testWidgets('forwards valid line and input overrides', (tester) async {
+ await tester.pumpRemixApp(
+ const RemixTextArea(
+ minLines: 3,
+ maxLines: 5,
+ keyboardType: TextInputType.streetAddress,
+ textInputAction: TextInputAction.done,
+ ),
+ );
+
+ final nakedTextField = tester.widget(
+ find.byType(NakedTextField),
+ );
+ expect(nakedTextField.minLines, 3);
+ expect(nakedTextField.maxLines, 5);
+ expect(nakedTextField.keyboardType, TextInputType.streetAddress);
+ expect(nakedTextField.textInputAction, TextInputAction.done);
+ expect(nakedTextField.expands, isFalse);
+ expect(nakedTextField.obscureText, isFalse);
+ });
+
+ testWidgets('forwards the complete supported editing surface', (
+ tester,
+ ) async {
+ final controller = TextEditingController(text: 'Initial note');
+ final focusNode = FocusNode();
+ final undoController = UndoHistoryController();
+ final scrollController = ScrollController();
+ final groupId = Object();
+ final formatter = FilteringTextInputFormatter.allow(RegExp('[a-z ]'));
+ final insertion = ContentInsertionConfiguration(
+ onContentInserted: (_) {},
+ allowedMimeTypes: const ['image/png'],
+ );
+ const spellCheck = SpellCheckConfiguration.disabled();
+ final magnifier = TextMagnifier.adaptiveMagnifierConfiguration;
+ void onChanged(String _) {}
+ void onEditingComplete() {}
+ void onSubmitted(String _) {}
+ void onPrivateCommand(String _, Map __) {}
+ void onTap() {}
+ void onTapOutside(PointerDownEvent _) {}
+ void onTapUpOutside(PointerUpEvent _) {}
+ Widget contextMenuBuilder(
+ BuildContext context,
+ EditableTextState editableTextState,
+ ) => const SizedBox();
+
+ addTearDown(controller.dispose);
+ addTearDown(focusNode.dispose);
+ addTearDown(undoController.dispose);
+ addTearDown(scrollController.dispose);
+
+ await tester.pumpRemixApp(
+ RemixTextArea(
+ key: const ValueKey('complete-surface'),
+ controller: controller,
+ focusNode: focusNode,
+ undoController: undoController,
+ groupId: groupId,
+ label: 'Notes',
+ hintText: 'Write notes',
+ helperText: 'Plain text',
+ error: false,
+ keyboardType: TextInputType.streetAddress,
+ textInputAction: TextInputAction.done,
+ textCapitalization: TextCapitalization.sentences,
+ textDirection: TextDirection.rtl,
+ enabled: true,
+ readOnly: false,
+ autofocus: false,
+ minLines: 2,
+ maxLines: 4,
+ maxLength: 120,
+ maxLengthEnforcement: MaxLengthEnforcement.none,
+ onChanged: onChanged,
+ onEditingComplete: onEditingComplete,
+ onSubmitted: onSubmitted,
+ onAppPrivateCommand: onPrivateCommand,
+ inputFormatters: [formatter],
+ showCursor: true,
+ autocorrect: false,
+ enableSuggestions: false,
+ smartDashesType: SmartDashesType.disabled,
+ smartQuotesType: SmartQuotesType.disabled,
+ dragStartBehavior: DragStartBehavior.down,
+ enableInteractiveSelection: false,
+ selectionControls: materialTextSelectionHandleControls,
+ onTap: onTap,
+ onTapOutside: onTapOutside,
+ onPressUpOutside: onTapUpOutside,
+ onTapAlwaysCalled: true,
+ scrollController: scrollController,
+ scrollPhysics: const ClampingScrollPhysics(),
+ autofillHints: const [AutofillHints.postalAddress],
+ contentInsertionConfiguration: insertion,
+ clipBehavior: Clip.none,
+ restorationId: 'notes-field',
+ stylusHandwritingEnabled: false,
+ enableIMEPersonalizedLearning: false,
+ contextMenuBuilder: contextMenuBuilder,
+ spellCheckConfiguration: spellCheck,
+ magnifierConfiguration: magnifier,
+ canRequestFocus: true,
+ ignorePointers: false,
+ leading: const Icon(Icons.notes),
+ trailing: const Icon(Icons.edit),
+ semanticLabel: 'Detailed notes',
+ semanticHint: 'Enter multiple lines',
+ excludeSemantics: false,
+ style: TextFieldStyler().cursorColor(Colors.indigo),
+ ),
+ );
+ await tester.pump();
+
+ final area = tester.widget(find.byType(RemixTextArea));
+ final naked = tester.widget(find.byType(NakedTextField));
+ expect(area.key, const ValueKey('complete-surface'));
+ expect(naked.controller, same(controller));
+ expect(naked.focusNode, same(focusNode));
+ expect(naked.undoController, same(undoController));
+ expect(naked.groupId, same(groupId));
+ expect(naked.keyboardType, TextInputType.streetAddress);
+ expect(naked.textInputAction, TextInputAction.done);
+ expect(naked.textCapitalization, TextCapitalization.sentences);
+ expect(naked.textDirection, TextDirection.rtl);
+ expect(naked.minLines, 2);
+ expect(naked.maxLines, 4);
+ expect(naked.maxLength, 120);
+ expect(naked.maxLengthEnforcement, MaxLengthEnforcement.none);
+ expect(naked.onChanged, same(onChanged));
+ expect(naked.onEditingComplete, same(onEditingComplete));
+ expect(naked.onSubmitted, same(onSubmitted));
+ expect(naked.onAppPrivateCommand, same(onPrivateCommand));
+ expect(naked.inputFormatters, contains(same(formatter)));
+ expect(naked.showCursor, isTrue);
+ expect(naked.autocorrect, isFalse);
+ expect(naked.enableSuggestions, isFalse);
+ expect(naked.smartDashesType, SmartDashesType.disabled);
+ expect(naked.smartQuotesType, SmartQuotesType.disabled);
+ expect(naked.dragStartBehavior, DragStartBehavior.down);
+ expect(naked.enableInteractiveSelection, isFalse);
+ expect(
+ naked.selectionControls,
+ same(materialTextSelectionHandleControls),
+ );
+ expect(naked.onTap, same(onTap));
+ expect(naked.onTapOutside, same(onTapOutside));
+ expect(naked.onTapUpOutside, same(onTapUpOutside));
+ expect(naked.onTapAlwaysCalled, isTrue);
+ expect(naked.scrollController, same(scrollController));
+ expect(naked.scrollPhysics, isA());
+ expect(naked.autofillHints, [AutofillHints.postalAddress]);
+ expect(naked.contentInsertionConfiguration, same(insertion));
+ expect(naked.clipBehavior, Clip.none);
+ expect(naked.restorationId, 'notes-field');
+ expect(naked.stylusHandwritingEnabled, isFalse);
+ expect(naked.enableIMEPersonalizedLearning, isFalse);
+ expect(naked.contextMenuBuilder, same(contextMenuBuilder));
+ expect(naked.spellCheckConfiguration, same(spellCheck));
+ expect(naked.magnifierConfiguration, same(magnifier));
+ expect(naked.canRequestFocus, isTrue);
+ expect(naked.ignorePointers, isFalse);
+ expect(naked.semanticLabel, 'Detailed notes');
+ expect(naked.semanticHint, 'Enter multiple lines\nPlain text');
+ expect(naked.excludeSemantics, isFalse);
+ expect(naked.expands, isFalse);
+ expect(naked.obscureText, isFalse);
+ expect(naked.cursorColor, Colors.indigo);
+ expect(find.byIcon(Icons.notes), findsOneWidget);
+ expect(find.byIcon(Icons.edit), findsOneWidget);
+ });
+
+ testWidgets('uses the canonical fluent styler and raw spec unchanged', (
+ tester,
+ ) async {
+ await tester.pumpRemixApp(
+ RemixTextArea(
+ style: TextFieldStyler()
+ .cursorColor(Colors.teal)
+ .textAlign(TextAlign.end),
+ ),
+ );
+ await tester.pump();
+
+ var naked = tester.widget(find.byType(NakedTextField));
+ expect(naked.cursorColor, Colors.teal);
+ expect(naked.textAlign, TextAlign.end);
+
+ await tester.pumpRemixApp(
+ const RemixTextArea(
+ styleSpec: TextFieldSpec(
+ cursorColor: Colors.orange,
+ cursorWidth: 5,
+ textAlign: TextAlign.center,
+ ),
+ ),
+ );
+ await tester.pump();
+
+ naked = tester.widget(find.byType(NakedTextField));
+ expect(naked.cursorColor, Colors.orange);
+ expect(naked.cursorWidth, 5);
+ expect(naked.textAlign, TextAlign.center);
+ });
+
+ testWidgets('multiline scrolling cancels editable pressed styling', (
+ tester,
+ ) async {
+ final controller = TextEditingController(
+ text: List.generate(12, (index) => 'Line $index').join('\n'),
+ );
+ final scrollController = ScrollController();
+ addTearDown(controller.dispose);
+ addTearDown(scrollController.dispose);
+
+ await tester.pumpRemixApp(
+ SizedBox(
+ width: 320,
+ child: RemixTextArea(
+ controller: controller,
+ scrollController: scrollController,
+ minLines: 2,
+ maxLines: 2,
+ style: TextFieldStyler(
+ cursorColor: Colors.blue,
+ ).onPressed(TextFieldStyler(cursorColor: Colors.red)),
+ ),
+ ),
+ );
+ await tester.pump();
+
+ Color? cursorColor() => tester
+ .widget(find.byType(NakedTextField))
+ .cursorColor;
+ final editable = find.byType(EditableText);
+ final gesture = await tester.startGesture(tester.getCenter(editable));
+ await tester.pump(kPressTimeout);
+ expect(cursorColor(), Colors.red);
+
+ await gesture.moveBy(const Offset(0, -40));
+ await tester.pump(const Duration(milliseconds: 16));
+ await gesture.moveBy(const Offset(0, -40));
+ await tester.pump();
+ expect(cursorColor(), Colors.blue);
+ expect(scrollController.offset, greaterThan(0));
+
+ await gesture.up();
+ });
+
+ testWidgets(
+ 'tracks controller and error replacements through shared state',
+ (tester) async {
+ final firstController = TextEditingController(text: 'first');
+ final secondController = TextEditingController(text: 'second');
+ addTearDown(firstController.dispose);
+ addTearDown(secondController.dispose);
+ var controller = firstController;
+ var error = false;
+ late StateSetter rebuild;
+
+ await tester.pumpRemixApp(
+ StatefulBuilder(
+ builder: (context, setState) {
+ rebuild = setState;
+ return RemixTextArea(
+ controller: controller,
+ hintText: 'Add details',
+ helperText: error ? 'Notes are required' : 'Optional notes',
+ error: error,
+ style: TextFieldStyler(cursorColor: Colors.blue).variant(
+ ContextVariant.widgetState(.error),
+ TextFieldStyler(cursorColor: Colors.red),
+ ),
+ );
+ },
+ ),
+ );
+ await tester.pump();
+
+ expect(
+ tester.widget(find.byType(EditableText)).controller,
+ same(firstController),
+ );
+ expect(
+ tester
+ .widget(find.byType(NakedTextField))
+ .cursorColor,
+ Colors.blue,
+ );
+
+ rebuild(() {
+ controller = secondController;
+ error = true;
+ });
+ await tester.pump();
+
+ expect(
+ tester.widget(find.byType(EditableText)).controller,
+ same(secondController),
+ );
+ expect(find.text('second'), findsOneWidget);
+ expect(
+ tester
+ .widget(find.byType(NakedTextField))
+ .cursorColor,
+ Colors.red,
+ );
+ expect(
+ tester
+ .widget(find.byType(NakedTextField))
+ .semanticHint,
+ 'Add details',
+ );
+ expect(
+ tester
+ .widget(find.byType(NakedTextField))
+ .semanticErrorText,
+ 'Notes are required',
+ );
+ },
+ );
+
+ testWidgets('focuses a caller node from the non-editable composite', (
+ tester,
+ ) async {
+ final focusNode = FocusNode();
+ addTearDown(focusNode.dispose);
+
+ await tester.pumpRemixApp(
+ RemixTextArea(label: 'Notes', focusNode: focusNode),
+ );
+ await tester.tap(find.text('Notes'));
+ await tester.pump();
+
+ expect(focusNode.hasFocus, isTrue);
+ });
+
+ testWidgets('wraps and grows without overflow at 200 percent text scale', (
+ tester,
+ ) async {
+ final controller = TextEditingController(
+ text: 'A long first line that wraps in a narrow field.\nSecond line.',
+ );
+ addTearDown(controller.dispose);
+
+ await tester.pumpRemixApp(
+ MediaQuery(
+ data: const MediaQueryData(textScaler: TextScaler.linear(2)),
+ child: SizedBox(
+ width: 180,
+ child: RemixTextArea(
+ controller: controller,
+ minLines: 2,
+ maxLines: 5,
+ hintText: 'Long multiline guidance',
+ ),
+ ),
+ ),
+ );
+ await tester.pump();
+
+ expect(tester.takeException(), isNull);
+ expect(tester.getSize(find.byType(RemixTextArea)).width, 180);
+ expect(tester.getSize(find.byType(EditableText)).height, greaterThan(40));
+ });
+
+ test('rejects invalid line ranges at construction', () {
+ expect(() => RemixTextArea(minLines: 0), throwsAssertionError);
+ expect(() => RemixTextArea(maxLines: 0), throwsAssertionError);
+ expect(
+ () => RemixTextArea(minLines: 3, maxLines: 2),
+ throwsAssertionError,
+ );
+ });
+
+ for (final textDirection in TextDirection.values) {
+ testWidgets('top-aligns the empty hint toward ${textDirection.name}', (
+ tester,
+ ) async {
+ await tester.pumpRemixApp(
+ const RemixTextArea(hintText: 'Write a note'),
+ textDirection: textDirection,
+ );
+ await tester.pump();
+
+ final hint = find.text('Write a note');
+ final editable = find.byType(EditableText);
+ expect(
+ tester.getTopLeft(hint).dy,
+ closeTo(tester.getTopLeft(editable).dy, 0.5),
+ );
+ if (textDirection == TextDirection.ltr) {
+ expect(
+ tester.getTopLeft(hint).dx,
+ closeTo(tester.getTopLeft(editable).dx, 0.5),
+ );
+ } else {
+ expect(
+ tester.getTopRight(hint).dx,
+ closeTo(tester.getTopRight(editable).dx, 0.5),
+ );
+ }
+ });
+ }
+
+ testWidgets('exposes enabled multiline semantics once', (tester) async {
+ final semantics = tester.ensureSemantics();
+ final controller = TextEditingController(text: 'abc');
+ addTearDown(controller.dispose);
+ try {
+ await tester.pumpRemixApp(
+ RemixTextArea(
+ controller: controller,
+ label: 'Notes',
+ hintText: 'Add details',
+ helperText: 'Markdown supported',
+ maxLength: 10,
+ ),
+ );
+ await tester.pump();
+
+ final fields = tester.semantics
+ .simulatedAccessibilityTraversal()
+ .where(
+ (node) => node.getSemanticsData().flagsCollection.isTextField,
+ )
+ .toList();
+ expect(fields, hasLength(1));
+ expect(
+ fields.single,
+ matchesSemantics(
+ label: 'Notes',
+ value: 'abc',
+ hint: 'Add details\nMarkdown supported',
+ textDirection: TextDirection.ltr,
+ maxValueLength: 10,
+ currentValueLength: 3,
+ inputType: SemanticsInputType.text,
+ isTextField: true,
+ isMultiline: true,
+ isFocusable: true,
+ hasEnabledState: true,
+ isEnabled: true,
+ hasTapAction: true,
+ hasFocusAction: true,
+ ),
+ );
+ } finally {
+ semantics.dispose();
+ }
+ });
+
+ testWidgets('keeps leading and trailing actions independent', (
+ tester,
+ ) async {
+ final semantics = tester.ensureSemantics();
+ try {
+ await tester.pumpRemixApp(
+ RemixTextArea(
+ semanticLabel: 'Notes',
+ leading: IconButton(
+ tooltip: 'Insert template',
+ onPressed: () {},
+ icon: const Icon(Icons.add),
+ ),
+ trailing: IconButton(
+ tooltip: 'Clear notes',
+ onPressed: () {},
+ icon: const Icon(Icons.clear),
+ ),
+ ),
+ );
+ await tester.pump();
+
+ final tapNodes = tester.semantics
+ .simulatedAccessibilityTraversal()
+ .where(
+ (node) => node.getSemanticsData().hasAction(SemanticsAction.tap),
+ )
+ .toList();
+ expect(tapNodes, hasLength(3));
+ expect(
+ tapNodes.where((node) => node.getSemanticsData().label == 'Notes'),
+ hasLength(1),
+ );
+ expect(
+ tapNodes.where(
+ (node) => node.getSemanticsData().tooltip == 'Insert template',
+ ),
+ hasLength(1),
+ );
+ expect(
+ tapNodes.where(
+ (node) => node.getSemanticsData().tooltip == 'Clear notes',
+ ),
+ hasLength(1),
+ );
+ } finally {
+ semantics.dispose();
+ }
+ });
+
+ for (final state in [
+ (name: 'read-only', enabled: true, readOnly: true),
+ (name: 'disabled', enabled: false, readOnly: false),
+ ]) {
+ testWidgets('${state.name} keeps exact multiline field semantics', (
+ tester,
+ ) async {
+ final semantics = tester.ensureSemantics();
+ try {
+ await tester.pumpRemixApp(
+ RemixTextArea(
+ semanticLabel: 'Notes',
+ enabled: state.enabled,
+ readOnly: state.readOnly,
+ ),
+ );
+ await tester.pump();
+
+ final fields = tester.semantics
+ .simulatedAccessibilityTraversal()
+ .where(
+ (node) => node.getSemanticsData().flagsCollection.isTextField,
+ )
+ .toList();
+ expect(fields, hasLength(1));
+ expect(
+ fields.single,
+ matchesSemantics(
+ label: 'Notes',
+ value: '',
+ textDirection: TextDirection.ltr,
+ currentValueLength: 0,
+ inputType: SemanticsInputType.text,
+ isTextField: true,
+ isMultiline: true,
+ isFocusable: true,
+ hasEnabledState: true,
+ isEnabled: state.enabled,
+ isReadOnly: true,
+ hasFocusAction: state.enabled,
+ ),
+ );
+ } finally {
+ semantics.dispose();
+ }
+ });
+ }
+ });
+}
diff --git a/packages/remix/test/components/textfield/textfield_spec_test.dart b/packages/remix/test/components/textfield/textfield_spec_test.dart
index 70f3800f..c2a5da25 100644
--- a/packages/remix/test/components/textfield/textfield_spec_test.dart
+++ b/packages/remix/test/components/textfield/textfield_spec_test.dart
@@ -23,7 +23,26 @@ void main() {
expect(spec.selectionWidthStyle, equals(BoxWidthStyle.tight));
expect(spec.scrollPadding, equals(const EdgeInsets.all(20.0)));
expect(spec.keyboardAppearance, isNull);
- expect(spec.container, equals(const StyleSpec(spec: FlexBoxSpec())));
+ expect(spec.container, equals(const StyleSpec(spec: BoxSpec())));
+ expect(spec.spacing, isNull);
+ expect(spec.crossAxisAlignment, isNull);
+ expect(
+ spec.layout,
+ equals(
+ const StyleSpec(
+ spec: FlexBoxSpec(
+ flex: StyleSpec(
+ spec: FlexSpec(
+ direction: Axis.vertical,
+ crossAxisAlignment: CrossAxisAlignment.start,
+ mainAxisSize: MainAxisSize.min,
+ spacing: 8,
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
expect(spec.helperText, equals(const StyleSpec(spec: TextSpec())));
expect(spec.label, equals(const StyleSpec(spec: TextSpec())));
});
@@ -41,7 +60,10 @@ void main() {
const selectionWidthStyle = BoxWidthStyle.max;
const scrollPadding = EdgeInsets.all(10);
const keyboardAppearance = Brightness.dark;
- final container = StyleSpec(spec: const FlexBoxSpec());
+ final container = StyleSpec(spec: const BoxSpec());
+ const spacing = 12.0;
+ const crossAxisAlignment = CrossAxisAlignment.start;
+ final layout = StyleSpec(spec: const FlexBoxSpec());
final helperText = StyleSpec(spec: const TextSpec());
final label = StyleSpec(spec: const TextSpec());
@@ -59,6 +81,9 @@ void main() {
scrollPadding: scrollPadding,
keyboardAppearance: keyboardAppearance,
container: container,
+ spacing: spacing,
+ crossAxisAlignment: crossAxisAlignment,
+ layout: layout,
helperText: helperText,
label: label,
);
@@ -76,6 +101,9 @@ void main() {
expect(spec.scrollPadding, equals(scrollPadding));
expect(spec.keyboardAppearance, equals(keyboardAppearance));
expect(spec.container, equals(container));
+ expect(spec.spacing, equals(spacing));
+ expect(spec.crossAxisAlignment, equals(crossAxisAlignment));
+ expect(spec.layout, equals(layout));
expect(spec.helperText, equals(helperText));
expect(spec.label, equals(label));
});
@@ -98,6 +126,9 @@ void main() {
expect(copy.scrollPadding, equals(spec.scrollPadding));
expect(copy.keyboardAppearance, equals(spec.keyboardAppearance));
expect(copy.container, equals(spec.container));
+ expect(copy.spacing, equals(spec.spacing));
+ expect(copy.crossAxisAlignment, equals(spec.crossAxisAlignment));
+ expect(copy.layout, equals(spec.layout));
expect(copy.helperText, equals(spec.helperText));
expect(copy.label, equals(spec.label));
});
@@ -141,6 +172,18 @@ void main() {
expect(copy.textAlign, equals(TextAlign.center));
expect(copy.textAlign, isNot(equals(spec.textAlign)));
});
+
+ test('returns copy with new input row controls', () {
+ const spec = TextFieldSpec();
+
+ final copy = spec.copyWith(
+ spacing: 12,
+ crossAxisAlignment: CrossAxisAlignment.end,
+ );
+
+ expect(copy.spacing, 12);
+ expect(copy.crossAxisAlignment, CrossAxisAlignment.end);
+ });
});
group('lerp', () {
@@ -212,6 +255,15 @@ void main() {
expect(result.scrollPadding, equals(const EdgeInsets.all(15)));
});
+
+ test('interpolates input row spacing', () {
+ const spec1 = TextFieldSpec(spacing: 4);
+ const spec2 = TextFieldSpec(spacing: 12);
+
+ final result = spec1.lerp(spec2, 0.5);
+
+ expect(result.spacing, 8);
+ });
});
group('Equality & Props', () {
@@ -231,9 +283,12 @@ void main() {
});
test('props includes all relevant properties', () {
- const spec = TextFieldSpec();
+ const spec = TextFieldSpec(
+ spacing: 13,
+ crossAxisAlignment: CrossAxisAlignment.baseline,
+ );
- expect(spec.props.length, equals(17));
+ expect(spec.props.length, equals(19));
expect(spec.props, contains(spec.text));
expect(spec.props, contains(spec.hintText));
expect(spec.props, contains(spec.textAlign));
@@ -247,6 +302,8 @@ void main() {
expect(spec.props, contains(spec.scrollPadding));
expect(spec.props, contains(spec.keyboardAppearance));
expect(spec.props, contains(spec.container));
+ expect(spec.props, contains(13));
+ expect(spec.props, contains(CrossAxisAlignment.baseline));
expect(spec.props, contains(spec.layout));
expect(spec.props, contains(spec.helperText));
expect(spec.props, contains(spec.label));
@@ -277,6 +334,9 @@ void main() {
expect(properties.any((p) => p.name == 'scrollPadding'), isTrue);
expect(properties.any((p) => p.name == 'keyboardAppearance'), isTrue);
expect(properties.any((p) => p.name == 'container'), isTrue);
+ expect(properties.any((p) => p.name == 'spacing'), isTrue);
+ expect(properties.any((p) => p.name == 'crossAxisAlignment'), isTrue);
+ expect(properties.any((p) => p.name == 'layout'), isTrue);
expect(properties.any((p) => p.name == 'helperText'), isTrue);
expect(properties.any((p) => p.name == 'label'), isTrue);
});
diff --git a/packages/remix/test/components/textfield/textfield_style_test.dart b/packages/remix/test/components/textfield/textfield_style_test.dart
index 25cd37f3..6c6da1dd 100644
--- a/packages/remix/test/components/textfield/textfield_style_test.dart
+++ b/packages/remix/test/components/textfield/textfield_style_test.dart
@@ -46,7 +46,7 @@ void main() {
final selectionWidthStyle = Prop.maybe(BoxWidthStyle.max);
final scrollPadding = Prop.maybe(const EdgeInsets.all(10));
final keyboardAppearance = Prop.maybe(Brightness.dark);
- final container = Prop.maybeMix(FlexBoxStyler());
+ final container = Prop.maybeMix(BoxStyler());
final helperText = Prop.maybeMix(TextStyler());
final label = Prop.maybeMix(TextStyler());
final variants = >[];
@@ -110,7 +110,7 @@ void main() {
selectionWidthStyle: BoxWidthStyle.max,
scrollPadding: const EdgeInsets.all(10),
keyboardAppearance: Brightness.dark,
- container: FlexBoxStyler(),
+ container: BoxStyler(),
helperText: TextStyler(),
label: TextStyler(),
animation: AnimationConfig.linear(const Duration(milliseconds: 200)),
@@ -165,7 +165,7 @@ void main() {
style.$container,
equals(
Prop.maybeMix(
- FlexBoxStyler(decoration: BoxDecorationMix(color: Colors.grey)),
+ BoxStyler(decoration: BoxDecorationMix(color: Colors.grey)),
),
),
);
@@ -175,9 +175,9 @@ void main() {
styleMethodTest(
'container() sets container styling',
initial: TextFieldStyler(),
- modify: (style) => style.container(FlexBoxStyler()),
+ modify: (style) => style.container(BoxStyler()),
expect: (style) {
- expect(style, equals(TextFieldStyler.container(FlexBoxStyler())));
+ expect(style, equals(TextFieldStyler.container(BoxStyler())));
},
);
@@ -298,7 +298,18 @@ void main() {
initial: TextFieldStyler(),
modify: (style) => style.spacing(12),
expect: (style) {
- expect(style, equals(TextFieldStyler.spacing(12)));
+ expect(style, equals(TextFieldStyler(spacing: 12)));
+ expect(TextFieldStyler.spacing(12), equals(style));
+ },
+ );
+
+ styleMethodTest(
+ 'crossAxisAlignment() aligns the input row',
+ initial: TextFieldStyler(),
+ modify: (style) => style.crossAxisAlignment(.start),
+ expect: (style) {
+ expect(style, equals(TextFieldStyler(crossAxisAlignment: .start)));
+ expect(TextFieldStyler.crossAxisAlignment(.start), equals(style));
},
);
diff --git a/packages/remix/test/components/textfield/textfield_widget_test.dart b/packages/remix/test/components/textfield/textfield_widget_test.dart
index dea2db2b..79403717 100644
--- a/packages/remix/test/components/textfield/textfield_widget_test.dart
+++ b/packages/remix/test/components/textfield/textfield_widget_test.dart
@@ -1,6 +1,9 @@
import 'dart:ui';
+import 'package:flutter/foundation.dart';
+import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
+import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:naked_ui/naked_ui.dart';
@@ -8,6 +11,35 @@ import 'package:remix/remix.dart';
import '../../helpers/test_helpers.dart';
+List _semanticNodes(
+ WidgetTester tester,
+ bool Function(SemanticsData data) predicate,
+) => tester.semantics
+ .simulatedAccessibilityTraversal()
+ .where((node) => predicate(node.getSemanticsData()))
+ .toList();
+
+List _textFieldSemanticNodes(WidgetTester tester) =>
+ _semanticNodes(tester, (data) => data.flagsCollection.isTextField);
+
+int _semanticTextOccurrences(WidgetTester tester, String text) {
+ var count = 0;
+ for (final node in tester.semantics.simulatedAccessibilityTraversal()) {
+ final data = node.getSemanticsData();
+ for (final value in [
+ data.label,
+ data.value,
+ data.hint,
+ data.tooltip,
+ data.increasedValue,
+ data.decreasedValue,
+ ]) {
+ count += RegExp(RegExp.escape(text)).allMatches(value).length;
+ }
+ }
+ return count;
+}
+
void main() {
group('RemixTextField', () {
testWidgets('Fortal variants keep text and container colors separate', (
@@ -19,8 +51,7 @@ void main() {
var spec = fortalTextFieldStyle(
variant: FortalTextFieldVariant.surface,
).resolve(context).spec;
- var decoration =
- spec.container.spec.box!.spec.decoration! as BoxDecoration;
+ var decoration = spec.container.spec.decoration! as BoxDecoration;
expect(spec.text.spec.style?.color, colors.gray.scale.step(12));
expect(decoration.color, colors.colorSurface);
@@ -31,7 +62,7 @@ void main() {
spec = fortalTextFieldStyle(
variant: FortalTextFieldVariant.soft,
).resolve(context).spec;
- decoration = spec.container.spec.box!.spec.decoration! as BoxDecoration;
+ decoration = spec.container.spec.decoration! as BoxDecoration;
expect(spec.text.spec.style?.color, colors.accent.scale.step(12));
expect(decoration.color, colors.accent.scale.alphaStep(3));
@@ -385,7 +416,9 @@ void main() {
});
group('Semantics & Accessibility', () {
- testWidgets('forwards pointer and semantics behavior', (tester) async {
+ testWidgets('forwards pointer behavior and owns composite exclusion', (
+ tester,
+ ) async {
void onTap() {}
void onTapUpOutside(PointerUpEvent event) {}
@@ -407,7 +440,13 @@ void main() {
expect(textField.onTapAlwaysCalled, isTrue);
expect(textField.onTapUpOutside, same(onTapUpOutside));
expect(textField.ignorePointers, isTrue);
- expect(textField.excludeSemantics, isTrue);
+ expect(textField.excludeSemantics, isFalse);
+ expect(
+ tester
+ .widget(find.byType(RemixTextField))
+ .excludeSemantics,
+ isTrue,
+ );
});
testWidgets('uses semantic label parameter', (tester) async {
@@ -442,6 +481,952 @@ void main() {
// When only label is provided, it should be used for both label and semantic label
expect(find.text('Email'), findsOneWidget);
});
+
+ testWidgets('announces label, hint, and non-error helper exactly once', (
+ tester,
+ ) async {
+ final semantics = tester.ensureSemantics();
+ try {
+ await tester.pumpRemixApp(
+ const RemixTextField(
+ label: 'Email address',
+ hintText: 'name@example.com',
+ helperText: 'Used for receipts',
+ ),
+ );
+ await tester.pump();
+
+ final fields = _textFieldSemanticNodes(tester);
+ expect(fields, hasLength(1));
+ expect(
+ fields.single,
+ isSemantics(
+ label: 'Email address',
+ hint: 'name@example.com\nUsed for receipts',
+ isTextField: true,
+ isMultiline: false,
+ hasTapAction: true,
+ ),
+ );
+ expect(_semanticTextOccurrences(tester, 'Email address'), 1);
+ expect(_semanticTextOccurrences(tester, 'name@example.com'), 1);
+ expect(_semanticTextOccurrences(tester, 'Used for receipts'), 1);
+ } finally {
+ semantics.dispose();
+ }
+ });
+
+ testWidgets('announces an error once on the live field node', (
+ tester,
+ ) async {
+ final semantics = tester.ensureSemantics();
+ try {
+ await tester.pumpRemixApp(
+ const RemixTextField(
+ label: 'Email address',
+ hintText: 'name@example.com',
+ helperText: 'Enter a valid email',
+ error: true,
+ ),
+ );
+ await tester.pump();
+
+ final fields = _textFieldSemanticNodes(tester);
+ expect(fields, hasLength(1));
+ expect(
+ fields.single,
+ isSemantics(
+ label: 'Email address',
+ hint: 'name@example.com\nEnter a valid email',
+ isTextField: true,
+ isLiveRegion: true,
+ ),
+ );
+ expect(_semanticTextOccurrences(tester, 'Enter a valid email'), 1);
+ } finally {
+ semantics.dispose();
+ }
+ });
+
+ testWidgets('marks an error field as semantically invalid', (
+ tester,
+ ) async {
+ final semantics = tester.ensureSemantics();
+ try {
+ await tester.pumpRemixApp(
+ const RemixTextField(
+ semanticLabel: 'Email address',
+ helperText: 'Enter a valid email',
+ error: true,
+ ),
+ );
+ await tester.pump();
+
+ final fields = _textFieldSemanticNodes(tester);
+ expect(fields, hasLength(1));
+ expect(
+ fields.single.getSemanticsData().validationResult,
+ SemanticsValidationResult.invalid,
+ );
+ } finally {
+ semantics.dispose();
+ }
+ });
+
+ testWidgets('deduplicates matching error and hint text', (tester) async {
+ final semantics = tester.ensureSemantics();
+ try {
+ await tester.pumpRemixApp(
+ const RemixTextField(
+ semanticLabel: 'Summary',
+ hintText: 'A summary is required',
+ helperText: 'A summary is required',
+ error: true,
+ ),
+ );
+ await tester.pump();
+
+ final fields = _textFieldSemanticNodes(tester);
+ expect(fields, hasLength(1));
+ expect(
+ fields.single.getSemanticsData().hint,
+ 'A summary is required',
+ );
+ expect(
+ fields.single.getSemanticsData().flagsCollection.isLiveRegion,
+ isTrue,
+ );
+ expect(_semanticTextOccurrences(tester, 'A summary is required'), 1);
+ } finally {
+ semantics.dispose();
+ }
+ });
+
+ testWidgets(
+ 'semantic overrides and duplicate supporting text stay exact',
+ (tester) async {
+ final semantics = tester.ensureSemantics();
+ try {
+ await tester.pumpRemixApp(
+ const RemixTextField(
+ label: 'Visible label',
+ semanticLabel: 'Account email',
+ hintText: 'Visible guidance',
+ semanticHint: 'Screen reader guidance',
+ helperText: 'Screen reader guidance',
+ ),
+ );
+ await tester.pump();
+
+ final fields = _textFieldSemanticNodes(tester);
+ expect(fields, hasLength(1));
+ expect(fields.single.getSemanticsData().label, 'Account email');
+ expect(
+ fields.single.getSemanticsData().hint,
+ 'Screen reader guidance',
+ );
+ expect(_semanticTextOccurrences(tester, 'Account email'), 1);
+ expect(
+ _semanticTextOccurrences(tester, 'Screen reader guidance'),
+ 1,
+ );
+ expect(_semanticTextOccurrences(tester, 'Visible label'), 0);
+ expect(_semanticTextOccurrences(tester, 'Visible guidance'), 0);
+ } finally {
+ semantics.dispose();
+ }
+ },
+ );
+
+ testWidgets('deduplicates normalized supporting text', (tester) async {
+ final semantics = tester.ensureSemantics();
+ try {
+ await tester.pumpRemixApp(
+ const RemixTextField(
+ semanticLabel: 'Summary',
+ hintText: 'Add details',
+ helperText: ' Add details ',
+ ),
+ );
+ await tester.pump();
+
+ final fields = _textFieldSemanticNodes(tester);
+ expect(fields, hasLength(1));
+ expect(fields.single.getSemanticsData().hint, 'Add details');
+ expect(_semanticTextOccurrences(tester, 'Add details'), 1);
+ } finally {
+ semantics.dispose();
+ }
+ });
+
+ testWidgets('interactive accessories keep independent semantic actions', (
+ tester,
+ ) async {
+ final semantics = tester.ensureSemantics();
+ try {
+ await tester.pumpRemixApp(
+ RemixTextField(
+ label: 'Search notes',
+ leading: const Icon(
+ Icons.search,
+ semanticLabel: 'Search decoration',
+ ),
+ trailing: IconButton(
+ tooltip: 'Clear notes',
+ onPressed: () {},
+ icon: const Icon(Icons.clear),
+ ),
+ ),
+ );
+ await tester.pump();
+
+ final fields = _textFieldSemanticNodes(tester);
+ final clearButtons = _semanticNodes(
+ tester,
+ (data) => data.tooltip == 'Clear notes',
+ );
+ final decorations = _semanticNodes(
+ tester,
+ (data) => data.label == 'Search decoration',
+ );
+
+ expect(fields, hasLength(1));
+ expect(clearButtons, hasLength(1));
+ expect(decorations, hasLength(1));
+ expect(fields.single.getSemanticsData().label, 'Search notes');
+ expect(
+ fields.single.getSemanticsData().hasAction(SemanticsAction.tap),
+ isTrue,
+ );
+ expect(
+ clearButtons.single.getSemanticsData().hasAction(
+ SemanticsAction.tap,
+ ),
+ isTrue,
+ );
+ expect(
+ decorations.single.getSemanticsData().hasAction(
+ SemanticsAction.tap,
+ ),
+ isFalse,
+ );
+ expect(
+ _semanticNodes(
+ tester,
+ (data) => data.hasAction(SemanticsAction.tap),
+ ),
+ hasLength(2),
+ );
+ } finally {
+ semantics.dispose();
+ }
+ });
+
+ testWidgets('decorative accessories add no duplicate field name', (
+ tester,
+ ) async {
+ final semantics = tester.ensureSemantics();
+ try {
+ await tester.pumpRemixApp(
+ const RemixTextField(
+ semanticLabel: 'Named field',
+ leading: Icon(Icons.notes),
+ trailing: Icon(Icons.edit),
+ ),
+ );
+ await tester.pump();
+
+ expect(_textFieldSemanticNodes(tester), hasLength(1));
+ expect(_semanticTextOccurrences(tester, 'Named field'), 1);
+ expect(
+ _semanticNodes(
+ tester,
+ (data) =>
+ data.label.isNotEmpty &&
+ data.label != 'Named field' &&
+ !data.flagsCollection.scopesRoute,
+ ),
+ isEmpty,
+ );
+ } finally {
+ semantics.dispose();
+ }
+ });
+
+ testWidgets('excludeSemantics hides the field and accessory subtree', (
+ tester,
+ ) async {
+ final semantics = tester.ensureSemantics();
+ try {
+ await tester.pumpRemixApp(
+ RemixTextField(
+ semanticLabel: 'Private note',
+ excludeSemantics: true,
+ trailing: IconButton(
+ tooltip: 'Clear private note',
+ onPressed: () {},
+ icon: const Icon(Icons.clear),
+ ),
+ ),
+ );
+ await tester.pump();
+
+ expect(_textFieldSemanticNodes(tester), isEmpty);
+ expect(
+ _semanticNodes(
+ tester,
+ (data) => data.tooltip == 'Clear private note',
+ ),
+ isEmpty,
+ );
+ } finally {
+ semantics.dispose();
+ }
+ });
+ });
+
+ group('Composite Interaction', () {
+ testWidgets(
+ 'label, helper, and container padding retain the tap target',
+ (tester) async {
+ var taps = 0;
+ bool? focusedDuringTap;
+ final focusNode = FocusNode();
+ addTearDown(focusNode.dispose);
+
+ await tester.pumpRemixApp(
+ RemixTextField(
+ label: 'Field label',
+ helperText: 'Field helper',
+ hintText: 'Field hint',
+ focusNode: focusNode,
+ onTap: () {
+ taps++;
+ focusedDuringTap = focusNode.hasFocus;
+ },
+ onTapAlwaysCalled: true,
+ style: TextFieldStyler().width(280).paddingAll(24),
+ ),
+ );
+ await tester.pump();
+
+ final editable = tester.widget(
+ find.byType(EditableText),
+ );
+ final inputRow = find.descendant(
+ of: find.byType(RemixTextField),
+ matching: find.byType(Row),
+ );
+ expect(inputRow, findsOneWidget);
+ final container = find.descendant(
+ of: find.byType(RemixTextField),
+ matching: find.byWidgetPredicate(
+ (widget) =>
+ widget is Box &&
+ widget.styleSpec?.spec.padding == const EdgeInsets.all(24),
+ ),
+ );
+ expect(container, findsOneWidget);
+
+ await tester.tap(find.text('Field label'));
+ await tester.pump();
+ expect(taps, 1);
+ expect(editable.focusNode.hasFocus, isTrue);
+ expect(focusedDuringTap, isFalse);
+
+ await tester.tap(find.text('Field helper'));
+ await tester.pump();
+ expect(taps, 2);
+
+ await tester.tapAt(tester.getTopLeft(container) + const Offset(4, 4));
+ await tester.pump();
+ expect(taps, 3);
+ },
+ );
+
+ for (final alwaysCalled in [false, true]) {
+ testWidgets(
+ 'consecutive fallback taps respect onTapAlwaysCalled=$alwaysCalled',
+ (tester) async {
+ var taps = 0;
+ await tester.pumpRemixApp(
+ RemixTextField(
+ key: ValueKey(alwaysCalled),
+ label: 'Tap target',
+ onTap: () => taps++,
+ onTapAlwaysCalled: alwaysCalled,
+ ),
+ );
+ await tester.pump();
+
+ final location = tester.getCenter(find.text('Tap target'));
+ await tester.tapAt(location);
+ await tester.pump(const Duration(milliseconds: 50));
+ await tester.tapAt(location);
+ await tester.pump();
+
+ expect(taps, alwaysCalled ? 2 : 1);
+ },
+ );
+ }
+
+ for (final platform in TargetPlatform.values) {
+ testWidgets(
+ 'fallback multi-tap matches the editable on ${platform.name}',
+ (tester) async {
+ debugDefaultTargetPlatformOverride = platform;
+ try {
+ var fallbackTaps = 0;
+ await tester.pumpRemixApp(
+ RemixTextField(
+ label: 'Fallback target',
+ onTap: () => fallbackTaps++,
+ ),
+ );
+
+ for (var index = 0; index < 4; index++) {
+ await tester.tap(find.text('Fallback target'));
+ await tester.pump(const Duration(milliseconds: 50));
+ }
+
+ var editableTaps = 0;
+ await tester.pumpRemixApp(
+ RemixTextField(onTap: () => editableTaps++),
+ );
+
+ for (var index = 0; index < 4; index++) {
+ await tester.tap(find.byType(EditableText));
+ await tester.pump(const Duration(milliseconds: 50));
+ }
+
+ expect(fallbackTaps, editableTaps);
+ } finally {
+ debugDefaultTargetPlatformOverride = null;
+ }
+ },
+ );
+ }
+
+ testWidgets('accessory taps do not activate or focus the field', (
+ tester,
+ ) async {
+ var fieldTaps = 0;
+ var accessoryTaps = 0;
+
+ await tester.pumpRemixApp(
+ RemixTextField(
+ onTap: () => fieldTaps++,
+ onTapAlwaysCalled: true,
+ style: TextFieldStyler(
+ cursorColor: Colors.blue,
+ ).onPressed(TextFieldStyler(cursorColor: Colors.red)),
+ trailing: IconButton(
+ key: const ValueKey('clear-accessory'),
+ onPressed: () => accessoryTaps++,
+ icon: const Icon(Icons.clear),
+ ),
+ ),
+ );
+ await tester.pump();
+
+ final focusNode = tester
+ .widget(find.byType(EditableText))
+ .focusNode;
+ final gesture = await tester.startGesture(
+ tester.getCenter(find.byKey(const ValueKey('clear-accessory'))),
+ );
+ await tester.pump();
+
+ expect(
+ tester
+ .widget(find.byType(NakedTextField))
+ .cursorColor,
+ Colors.blue,
+ );
+ expect(accessoryTaps, 0);
+ expect(fieldTaps, 0);
+ expect(focusNode.hasFocus, isFalse);
+
+ await gesture.up();
+ await tester.pump();
+
+ expect(accessoryTaps, 1);
+ expect(fieldTaps, 0);
+ expect(focusNode.hasFocus, isFalse);
+ });
+
+ testWidgets('ignorePointers disables fallback interaction', (
+ tester,
+ ) async {
+ final focusNode = FocusNode();
+ addTearDown(focusNode.dispose);
+ var taps = 0;
+
+ await tester.pumpRemixApp(
+ RemixTextField(
+ label: 'Ignored target',
+ focusNode: focusNode,
+ ignorePointers: true,
+ onTap: () => taps++,
+ ),
+ );
+
+ await tester.tap(find.text('Ignored target'));
+ await tester.pump();
+
+ expect(taps, 0);
+ expect(focusNode.hasFocus, isFalse);
+ });
+
+ for (final state in [
+ (
+ name: 'read-only',
+ enabled: true,
+ readOnly: true,
+ canRequestFocus: true,
+ expectedTaps: 1,
+ expectedFocus: true,
+ ),
+ (
+ name: 'disabled',
+ enabled: false,
+ readOnly: false,
+ canRequestFocus: true,
+ expectedTaps: 0,
+ expectedFocus: false,
+ ),
+ (
+ name: 'non-focusable',
+ enabled: true,
+ readOnly: false,
+ canRequestFocus: false,
+ expectedTaps: 1,
+ expectedFocus: false,
+ ),
+ ]) {
+ testWidgets('${state.name} fallback mirrors Naked tap and focus', (
+ tester,
+ ) async {
+ final focusNode = FocusNode();
+ addTearDown(focusNode.dispose);
+ var taps = 0;
+
+ await tester.pumpRemixApp(
+ RemixTextField(
+ label: 'Fallback target',
+ enabled: state.enabled,
+ readOnly: state.readOnly,
+ canRequestFocus: state.canRequestFocus,
+ focusNode: focusNode,
+ onTap: () => taps++,
+ ),
+ );
+ await tester.tap(find.text('Fallback target'));
+ await tester.pump();
+
+ expect(taps, state.expectedTaps);
+ expect(focusNode.hasFocus, state.expectedFocus);
+ });
+ }
+
+ testWidgets('hovering labels and accessories retains hovered styling', (
+ tester,
+ ) async {
+ await tester.pumpRemixApp(
+ RemixTextField(
+ label: 'Hover target',
+ trailing: const Icon(Icons.info, key: ValueKey('hover-accessory')),
+ style: TextFieldStyler(
+ cursorColor: Colors.blue,
+ ).onHovered(TextFieldStyler(cursorColor: Colors.red)),
+ ),
+ );
+ await tester.pump();
+
+ Color? cursorColor() => tester
+ .widget(find.byType(NakedTextField))
+ .cursorColor;
+ final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse);
+ addTearDown(mouse.removePointer);
+ await mouse.addPointer(location: Offset.zero);
+
+ await mouse.moveTo(tester.getCenter(find.text('Hover target')));
+ await tester.pump();
+ expect(cursorColor(), Colors.red);
+
+ await mouse.moveTo(
+ tester.getCenter(find.byKey(const ValueKey('hover-accessory'))),
+ );
+ await tester.pump();
+ expect(cursorColor(), Colors.red);
+
+ await mouse.moveTo(const Offset(5, 5));
+ await tester.pump();
+ expect(cursorColor(), Colors.blue);
+ });
+
+ testWidgets('fallback press down, up, and cancel drive pressed styling', (
+ tester,
+ ) async {
+ await tester.pumpRemixApp(
+ RemixTextField(
+ label: 'Press target',
+ style: TextFieldStyler(
+ cursorColor: Colors.blue,
+ ).onPressed(TextFieldStyler(cursorColor: Colors.red)),
+ ),
+ );
+ await tester.pump();
+
+ Color? cursorColor() => tester
+ .widget(find.byType(NakedTextField))
+ .cursorColor;
+ final location = tester.getCenter(find.text('Press target'));
+
+ var gesture = await tester.startGesture(location);
+ await tester.pump();
+ expect(cursorColor(), Colors.red);
+ await gesture.up();
+ await tester.pump();
+ expect(cursorColor(), Colors.blue);
+
+ await tester.pump(kDoubleTapTimeout);
+ gesture = await tester.startGesture(location);
+ await tester.pump();
+ expect(cursorColor(), Colors.red);
+ await gesture.cancel();
+ await tester.pump();
+ expect(cursorColor(), Colors.blue);
+ });
+
+ testWidgets('selection drag cancels editable pressed styling', (
+ tester,
+ ) async {
+ final controller = TextEditingController(
+ text: 'Drag across this editable text to select it',
+ );
+ addTearDown(controller.dispose);
+
+ await tester.pumpRemixApp(
+ SizedBox(
+ width: 360,
+ child: RemixTextField(
+ controller: controller,
+ style: TextFieldStyler(
+ cursorColor: Colors.blue,
+ ).onPressed(TextFieldStyler(cursorColor: Colors.red)),
+ ),
+ ),
+ );
+ await tester.pump();
+
+ Color? cursorColor() => tester
+ .widget(find.byType(NakedTextField))
+ .cursorColor;
+ final editable = find.byType(EditableText);
+ final gesture = await tester.startGesture(
+ tester.getCenter(editable),
+ kind: PointerDeviceKind.mouse,
+ );
+ await tester.pump(kPressTimeout);
+ expect(cursorColor(), Colors.red);
+
+ await gesture.moveBy(const Offset(50, 0));
+ await tester.pump(const Duration(milliseconds: 16));
+ await gesture.moveBy(const Offset(50, 0));
+ await tester.pump();
+ expect(cursorColor(), Colors.blue);
+ expect(controller.selection.isCollapsed, isFalse);
+
+ await gesture.up();
+ });
+
+ testWidgets('editable and fallback press sources overlap safely', (
+ tester,
+ ) async {
+ await tester.pumpRemixApp(
+ RemixTextField(
+ label: 'Fallback target',
+ style: TextFieldStyler(
+ cursorColor: Colors.blue,
+ ).onPressed(TextFieldStyler(cursorColor: Colors.red)),
+ ),
+ );
+ await tester.pump();
+
+ Color? cursorColor() => tester
+ .widget(find.byType(NakedTextField))
+ .cursorColor;
+ final gesture = await tester.startGesture(
+ tester.getCenter(find.byType(EditableText)),
+ );
+ await tester.pump(kPressTimeout);
+ expect(cursorColor(), Colors.red);
+ expect(
+ NakedTextFieldState.of(
+ tester.element(find.byType(EditableText)),
+ ).isPressed,
+ isTrue,
+ );
+
+ // Both the outer composite fallback and Naked's editable detector see
+ // an editable press. Releasing either source must not clear the other.
+ final onEditablePressChange = tester
+ .widget(find.byType(NakedTextField))
+ .onPressChange;
+ expect(onEditablePressChange, isNotNull);
+ onEditablePressChange!(false);
+ await tester.pump();
+ expect(cursorColor(), Colors.red);
+
+ await gesture.up();
+ await tester.pump();
+ expect(cursorColor(), Colors.blue);
+ });
+
+ for (final region in ['fallback', 'editable']) {
+ testWidgets('rapid double-tap clears $region pressed styling', (
+ tester,
+ ) async {
+ await tester.pumpRemixApp(
+ RemixTextField(
+ label: 'Press target',
+ style: TextFieldStyler(
+ cursorColor: Colors.blue,
+ ).onPressed(TextFieldStyler(cursorColor: Colors.red)),
+ ),
+ );
+
+ final target = region == 'fallback'
+ ? find.text('Press target')
+ : find.byType(EditableText);
+ await tester.tap(target);
+ await tester.pump(const Duration(milliseconds: 50));
+ await tester.tap(target);
+ await tester.pump();
+
+ final textField = tester.widget(
+ find.byType(NakedTextField),
+ );
+ expect(textField.cursorColor, Colors.blue);
+ });
+ }
+
+ for (final textFieldCase in [
+ (
+ name: 'TextField',
+ build: (Key key, TextFieldStyler style) =>
+ RemixTextField(key: key, style: style),
+ ),
+ (
+ name: 'TextArea',
+ build: (Key key, TextFieldStyler style) =>
+ RemixTextArea(key: key, style: style),
+ ),
+ ]) {
+ testWidgets(
+ '${textFieldCase.name} consumes generated input-row controls',
+ (tester) async {
+ const fieldKey = ValueKey('generated-row-controls-field');
+ await tester.pumpRemixApp(
+ textFieldCase.build(
+ fieldKey,
+ TextFieldStyler().spacing(12).crossAxisAlignment(.start),
+ ),
+ );
+ await tester.pump();
+
+ final inputRow = find.descendant(
+ of: find.byKey(fieldKey),
+ matching: find.byType(Row),
+ );
+ expect(inputRow, findsOneWidget);
+ final row = tester.widget(inputRow);
+ expect(row.spacing, 12);
+ expect(row.crossAxisAlignment, CrossAxisAlignment.start);
+ },
+ );
+
+ testWidgets(
+ '${textFieldCase.name} renders the generated Box container surface',
+ (tester) async {
+ const fieldKey = ValueKey('box-surface-field');
+ await tester.pumpRemixApp(
+ textFieldCase.build(
+ fieldKey,
+ TextFieldStyler(
+ container: BoxStyler()
+ .color(Colors.amber)
+ .paddingAll(7)
+ .alignment(.center),
+ ),
+ ),
+ );
+ await tester.pump();
+
+ expect(tester.takeException(), isNull);
+ final boxes = tester.widgetList(
+ find.descendant(
+ of: find.byKey(fieldKey),
+ matching: find.byType(Box),
+ ),
+ );
+ final container = boxes.singleWhere(
+ (box) =>
+ box.styleSpec?.spec.decoration ==
+ const BoxDecoration(color: Colors.amber),
+ );
+ expect(container.styleSpec?.spec.padding, const EdgeInsets.all(7));
+ expect(container.styleSpec?.spec.alignment, Alignment.center);
+ expect(
+ find.descendant(
+ of: find.byKey(fieldKey),
+ matching: find.byType(Row),
+ ),
+ findsOneWidget,
+ );
+ },
+ );
+ }
+
+ testWidgets(
+ 'TextField fluent baseline alignment renders text and accessories',
+ (tester) async {
+ final controller = TextEditingController(text: 'Baseline input');
+ addTearDown(controller.dispose);
+
+ await tester.pumpRemixApp(
+ RemixTextField(
+ controller: controller,
+ leading: const Text('Leading'),
+ trailing: const Text('Trailing'),
+ style: TextFieldStyler().crossAxisAlignment(.baseline),
+ ),
+ );
+ await tester.pump();
+
+ expect(tester.takeException(), isNull);
+ final row = tester.widget(find.byType(Row));
+ final editable = tester.widget(
+ find.byType(EditableText),
+ );
+ expect(row.crossAxisAlignment, CrossAxisAlignment.baseline);
+ expect(row.textBaseline, TextBaseline.alphabetic);
+ expect(editable.controller.text, 'Baseline input');
+ expect(find.text('Leading'), findsOneWidget);
+ expect(find.text('Trailing'), findsOneWidget);
+ },
+ );
+
+ testWidgets(
+ 'TextArea raw baseline spec renders multiline text and accessories',
+ (tester) async {
+ final controller = TextEditingController(text: 'First\nsecond');
+ addTearDown(controller.dispose);
+
+ await tester.pumpRemixApp(
+ RemixTextArea(
+ controller: controller,
+ leading: const Text('Leading'),
+ trailing: const Text('Trailing'),
+ styleSpec: const TextFieldSpec(
+ crossAxisAlignment: CrossAxisAlignment.baseline,
+ ),
+ ),
+ );
+ await tester.pump();
+
+ expect(tester.takeException(), isNull);
+ final row = tester.widget(find.byType(Row));
+ final editable = tester.widget(
+ find.byType(EditableText),
+ );
+ expect(row.crossAxisAlignment, CrossAxisAlignment.baseline);
+ expect(row.textBaseline, TextBaseline.alphabetic);
+ expect(editable.controller.text, 'First\nsecond');
+ expect(find.text('Leading'), findsOneWidget);
+ expect(find.text('Trailing'), findsOneWidget);
+ },
+ );
+
+ testWidgets('input row follows ambient text direction', (tester) async {
+ const fieldKey = ValueKey('ambient-direction-field');
+ const leadingKey = ValueKey('leading');
+ const trailingKey = ValueKey('trailing');
+ await tester.pumpRemixApp(
+ const RemixTextField(
+ key: fieldKey,
+ leading: SizedBox(key: leadingKey, width: 20, height: 20),
+ trailing: SizedBox(key: trailingKey, width: 20, height: 20),
+ ),
+ textDirection: TextDirection.rtl,
+ );
+ await tester.pump();
+
+ final inputRow = find.descendant(
+ of: find.byKey(fieldKey),
+ matching: find.byType(Row),
+ );
+ expect(inputRow, findsOneWidget);
+ expect(tester.widget(inputRow).textDirection, isNull);
+ expect(
+ tester.getCenter(find.byKey(leadingKey)).dx,
+ greaterThan(tester.getCenter(find.byKey(trailingKey)).dx),
+ );
+ });
+
+ testWidgets('owns, swaps, and disposes only internal focus nodes', (
+ tester,
+ ) async {
+ final external = FocusNode(debugLabel: 'caller-owned');
+ addTearDown(external.dispose);
+ FocusNode? suppliedNode;
+ late StateSetter rebuild;
+
+ await tester.pumpRemixApp(
+ StatefulBuilder(
+ builder: (context, setState) {
+ rebuild = setState;
+ return RemixTextField(focusNode: suppliedNode);
+ },
+ ),
+ );
+ await tester.pump();
+
+ final firstInternal = tester
+ .widget(find.byType(EditableText))
+ .focusNode;
+ firstInternal.requestFocus();
+ await tester.pump();
+ expect(firstInternal.hasFocus, isTrue);
+
+ rebuild(() => suppliedNode = external);
+ await tester.pump();
+ await tester.pump();
+ expect(
+ tester.widget(find.byType(EditableText)).focusNode,
+ same(external),
+ );
+ expect(external.hasFocus, isTrue);
+ expect(() => firstInternal.addListener(() {}), throwsFlutterError);
+
+ rebuild(() => suppliedNode = null);
+ await tester.pump();
+ await tester.pump();
+ final secondInternal = tester
+ .widget(find.byType(EditableText))
+ .focusNode;
+ expect(secondInternal, isNot(same(firstInternal)));
+ expect(secondInternal, isNot(same(external)));
+ expect(secondInternal.hasFocus, isTrue);
+
+ void listener() {}
+ expect(() => external.addListener(listener), returnsNormally);
+ external.removeListener(listener);
+
+ await tester.pumpRemixApp(const SizedBox());
+ expect(() => secondInternal.addListener(() {}), throwsFlutterError);
+ });
});
group('Input Formatters', () {
@@ -558,7 +1543,7 @@ void main() {
await tester.pumpRemixApp(
RemixTextField(
style: TextFieldStyler().container(
- FlexBoxStyler(
+ BoxStyler(
decoration: BoxDecorationMix(color: Colors.grey),
padding: EdgeInsetsGeometryMix.all(16),
),
@@ -583,19 +1568,112 @@ void main() {
await tester.pumpAndSettle();
final flex = tester
- .widget(find.byType(ColumnBox))
+ .widget(find.byType(FlexBox))
.styleSpec
?.spec
.flex
?.spec;
// Customizing spacing keeps the min-size / start-alignment defaults
- // instead of falling back to ColumnBox's max / center.
+ // instead of falling back to FlexBox's max / center.
+ expect(flex?.direction, Axis.vertical);
expect(flex?.spacing, 12);
expect(flex?.mainAxisSize, MainAxisSize.min);
expect(flex?.crossAxisAlignment, CrossAxisAlignment.start);
});
+ testWidgets('default layout remains vertical', (tester) async {
+ await tester.pumpRemixApp(
+ const RemixTextField(
+ label: 'Label',
+ hintText: 'Hint',
+ helperText: 'Helper',
+ ),
+ );
+ await tester.pumpAndSettle();
+
+ final flex = tester
+ .widget(find.byType(FlexBox))
+ .styleSpec
+ ?.spec
+ .flex
+ ?.spec;
+
+ expect(flex?.direction, Axis.vertical);
+ expect(
+ tester.getCenter(find.text('Label')).dy,
+ lessThan(tester.getCenter(find.text('Hint')).dy),
+ );
+ expect(
+ tester.getCenter(find.text('Hint')).dy,
+ lessThan(tester.getCenter(find.text('Helper')).dy),
+ );
+ });
+
+ testWidgets('raw default spec layout remains vertical', (tester) async {
+ await tester.pumpRemixApp(
+ const RemixTextField(
+ label: 'Label',
+ hintText: 'Hint',
+ helperText: 'Helper',
+ styleSpec: TextFieldSpec(),
+ ),
+ );
+ await tester.pumpAndSettle();
+
+ final flex = tester.widget(find.byType(FlexBox));
+ final resolved = flex.styleSpec?.spec.flex?.spec;
+
+ expect(resolved?.direction, Axis.vertical);
+ expect(resolved?.mainAxisSize, MainAxisSize.min);
+ expect(resolved?.crossAxisAlignment, CrossAxisAlignment.start);
+ expect(resolved?.spacing, 8);
+ expect(
+ tester.getCenter(find.text('Label')).dy,
+ lessThan(tester.getCenter(find.text('Hint')).dy),
+ );
+ expect(
+ tester.getCenter(find.text('Hint')).dy,
+ lessThan(tester.getCenter(find.text('Helper')).dy),
+ );
+ });
+
+ testWidgets('explicit row layout is honored without assertion', (
+ tester,
+ ) async {
+ await tester.pumpRemixApp(
+ RemixTextField(
+ label: 'Label',
+ hintText: 'Hint',
+ helperText: 'Helper',
+ style: TextFieldStyler()
+ .container(BoxStyler().width(200))
+ .layout(
+ FlexBoxStyler()
+ .row()
+ .mainAxisSize(.min)
+ .crossAxisAlignment(.center),
+ ),
+ ),
+ );
+ await tester.pumpAndSettle();
+
+ expect(tester.takeException(), isNull);
+ final flex = tester
+ .widget(find.byType(FlexBox))
+ .styleSpec
+ ?.spec
+ .flex
+ ?.spec;
+ final labelCenter = tester.getCenter(find.text('Label'));
+ final hintCenter = tester.getCenter(find.text('Hint'));
+ final helperCenter = tester.getCenter(find.text('Helper'));
+
+ expect(flex?.direction, Axis.horizontal);
+ expect(labelCenter.dx, lessThan(hintCenter.dx));
+ expect(hintCenter.dx, lessThan(helperCenter.dx));
+ });
+
testWidgets('applies width and height constraints', (tester) async {
await tester.pumpRemixApp(
RemixTextField(style: TextFieldStyler().width(300).height(60)),
@@ -641,11 +1719,7 @@ void main() {
testWidgets('uses styleSpec when provided', (tester) async {
const spec = TextFieldSpec(
container: StyleSpec(
- spec: FlexBoxSpec(
- box: StyleSpec(
- spec: BoxSpec(decoration: BoxDecoration(color: Colors.red)),
- ),
- ),
+ spec: BoxSpec(decoration: BoxDecoration(color: Colors.red)),
),
textAlign: TextAlign.center,
cursorWidth: 3.0,
@@ -654,15 +1728,15 @@ void main() {
await tester.pumpRemixApp(const RemixTextField(styleSpec: spec));
await tester.pumpAndSettle();
- final rowBoxDecorations = tester
- .widgetList(find.byType(RowBox))
- .map((box) => box.styleSpec?.spec.box?.spec.decoration);
+ final boxDecorations = tester
+ .widgetList(find.byType(Box))
+ .map((box) => box.styleSpec?.spec.decoration);
final textField = tester.widget(
find.byType(NakedTextField),
);
expect(
- rowBoxDecorations,
+ boxDecorations,
contains(equals(const BoxDecoration(color: Colors.red))),
);
expect(textField.textAlign, TextAlign.center);
@@ -710,6 +1784,23 @@ void main() {
});
group('Hint Text Visibility', () {
+ testWidgets('single-line hint remains vertically centered', (
+ tester,
+ ) async {
+ await tester.pumpRemixApp(
+ RemixTextField(
+ hintText: 'Centered hint',
+ style: TextFieldStyler().height(72),
+ ),
+ );
+ await tester.pump();
+
+ expect(
+ tester.getCenter(find.text('Centered hint')).dy,
+ closeTo(tester.getCenter(find.byType(EditableText)).dy, 0.5),
+ );
+ });
+
testWidgets('hides hint text when field has content', (tester) async {
final controller = TextEditingController();
diff --git a/packages/remix/test/fortal/fortal_control_matrix_test.dart b/packages/remix/test/fortal/fortal_control_matrix_test.dart
index 93683b30..dad03db1 100644
--- a/packages/remix/test/fortal/fortal_control_matrix_test.dart
+++ b/packages/remix/test/fortal/fortal_control_matrix_test.dart
@@ -293,15 +293,14 @@ void main() {
).build(context),
);
expect(
- resolved.spec.container.spec.box?.spec.constraints?.maxHeight,
+ resolved.spec.container.spec.constraints?.maxHeight,
entry.value,
reason: '$variant ${entry.key}',
);
expect(resolved.spec.containerEffects?.behindContent, isNotNull);
if (entry.key == FortalTextFieldSize.size2) {
final decoration =
- resolved.spec.container.spec.box!.spec.decoration
- as BoxDecoration;
+ resolved.spec.container.spec.decoration as BoxDecoration;
recipes.add((
color: decoration.color,
containerEffects: resolved.spec.containerEffects,
diff --git a/packages/remix/test/public_api_compatibility_test.dart b/packages/remix/test/public_api_compatibility_test.dart
index 4eb55591..db6f594a 100644
--- a/packages/remix/test/public_api_compatibility_test.dart
+++ b/packages/remix/test/public_api_compatibility_test.dart
@@ -41,6 +41,7 @@ void main() {
const progress = RemixProgress(value: 0.5);
const tabBar = RemixTabBar(child: Text('Tabs'));
const spinner = RemixSpinner();
+ const textArea = RemixTextArea(label: 'Notes');
const checkboxGroup = RemixCheckboxGroup(
values: {'one'},
child: RemixCheckboxGroupItem(
@@ -70,6 +71,8 @@ void main() {
expect(progress.value, 0.5);
expect(tabBar.child, isA());
expect(spinner, isA());
+ expect(textArea, isA());
+ expect(textArea.label, 'Notes');
expect(checkbox.selected, isFalse);
expect(checkboxGroup.values.single, 'one');
expect(checkboxGroup.child, isA>());
diff --git a/packages/remix/test/public_api_test.dart b/packages/remix/test/public_api_test.dart
index e3a9deb5..84f37b78 100644
--- a/packages/remix/test/public_api_test.dart
+++ b/packages/remix/test/public_api_test.dart
@@ -5,6 +5,14 @@ import 'package:remix/remix.dart';
enum Interest { design, code }
void main() {
+ test('RemixTextArea is exported as the multiline TextField facade', () {
+ const textArea = RemixTextArea();
+
+ expect(textArea, isA());
+ expect(textArea.minLines, 2);
+ expect(textArea.maxLines, isNull);
+ });
+
test('the skeleton family is constructible from the public API', () {
const spec = SkeletonSpec(
container: StyleSpec(spec: BoxSpec()),
diff --git a/packages/remix/tool/fortal_parity/check.dart b/packages/remix/tool/fortal_parity/check.dart
index 651db828..cc9f30b6 100644
--- a/packages/remix/tool/fortal_parity/check.dart
+++ b/packages/remix/tool/fortal_parity/check.dart
@@ -4,7 +4,7 @@ import 'dart:typed_data';
const _expectedIntegrity =
'sha512-I0/h2CRNTpYNB7Mi3xFIvSsQq5a108d7kK8dTO5zp5b9HR5QJXKag6B8tjpz2ITkVYkFdkGk45doNkSr7OxwNw==';
-const _expectedNakedUiVersion = '1.0.0-beta.8';
+const _expectedNakedUiVersion = '1.0.0-beta.9';
const _expectedNakedUiConstraint = '^1.0.0-beta.8';
const _expectedMappedFamilies = {
'avatar',
diff --git a/pubspec.lock b/pubspec.lock
index 6a043d63..ea26ae5b 100644
--- a/pubspec.lock
+++ b/pubspec.lock
@@ -452,10 +452,10 @@ packages:
dependency: transitive
description:
name: naked_ui
- sha256: "52702627d304a22b26bc10c8270088267596998de4884c9dda19be1614dfdc2b"
+ sha256: "38d42f7512504226d8ceea34bc108edb416dab639c44e6f39a65860f703f534d"
url: "https://pub.dev"
source: hosted
- version: "1.0.0-beta.8"
+ version: "1.0.0-beta.9"
nested:
dependency: transitive
description: