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: