From 09546990543a3d4bb5362cebde6a6b5858f6ccd1 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Tue, 4 Aug 2026 13:11:37 -0400 Subject: [PATCH 1/7] chore(remix): harden package publish foundation --- packages/remix/.pubignore | 3 +++ packages/remix/CHANGELOG.md | 5 +++-- packages/remix/README.md | 4 ++-- packages/remix/pubspec.yaml | 2 +- packages/remix/tool/fortal_parity/check.dart | 8 ++++---- 5 files changed, 13 insertions(+), 9 deletions(-) diff --git a/packages/remix/.pubignore b/packages/remix/.pubignore index 6ea31560..ede70cb8 100644 --- a/packages/remix/.pubignore +++ b/packages/remix/.pubignore @@ -6,3 +6,6 @@ demo/macos/Podfile.lock docs/* scripts/* test/* +reference/* +tool/* +radix_colors.generated.json diff --git a/packages/remix/CHANGELOG.md b/packages/remix/CHANGELOG.md index 70a7567f..8b52af21 100644 --- a/packages/remix/CHANGELOG.md +++ b/packages/remix/CHANGELOG.md @@ -28,8 +28,9 @@ - **FEAT**: Expose every Fortal recipe parameter on its generated widget, including `highContrast` and Avatar's `fallbackLength`, while retaining both the unnamed `variant:` constructor and generated named variant constructors. -- **FIX**: Pin `naked_ui 1.0.0-beta.8` so open labelled tooltips retain one - interactive semantics node without hiding unlabelled overlay semantics. +- **FIX**: Allow `naked_ui` releases compatible with `^1.0.0-beta.8` so open + labelled tooltips retain one interactive semantics node without hiding + unlabelled overlay semantics. - **FIX**: Keep dialog titles and actions fixed while structured descriptions and body content scroll within bounded dialogs. - **FIX**: Resolve Fortal Avatar icon geometry through scaled design tokens for diff --git a/packages/remix/README.md b/packages/remix/README.md index 9004b034..59fbe440 100644 --- a/packages/remix/README.md +++ b/packages/remix/README.md @@ -65,14 +65,14 @@ With Remix, you get: Add Remix to your project: ```bash -flutter pub add remix +flutter pub add remix:^1.0.0-beta.1 ``` Or add it to your `pubspec.yaml`: ```yaml dependencies: - remix: ^1.0.0 + remix: ^1.0.0-beta.1 ``` ### Your First Component diff --git a/packages/remix/pubspec.yaml b/packages/remix/pubspec.yaml index 01d61b88..8ab4eb0e 100644 --- a/packages/remix/pubspec.yaml +++ b/packages/remix/pubspec.yaml @@ -23,7 +23,7 @@ dependencies: sdk: flutter mix: ^2.2.0-beta.1 mix_annotations: ^2.2.0-beta.1 - naked_ui: 1.0.0-beta.8 + naked_ui: ^1.0.0-beta.8 dev_dependencies: flutter_test: diff --git a/packages/remix/tool/fortal_parity/check.dart b/packages/remix/tool/fortal_parity/check.dart index dd3f01c1..651db828 100644 --- a/packages/remix/tool/fortal_parity/check.dart +++ b/packages/remix/tool/fortal_parity/check.dart @@ -5,6 +5,7 @@ import 'dart:typed_data'; const _expectedIntegrity = 'sha512-I0/h2CRNTpYNB7Mi3xFIvSsQq5a108d7kK8dTO5zp5b9HR5QJXKag6B8tjpz2ITkVYkFdkGk45doNkSr7OxwNw=='; const _expectedNakedUiVersion = '1.0.0-beta.8'; +const _expectedNakedUiConstraint = '^1.0.0-beta.8'; const _expectedMappedFamilies = { 'avatar', 'badge', @@ -632,11 +633,10 @@ void _checkNakedPin( final packageSource = packagePubspec.readAsStringSync(); _expect( RegExp( - '^ naked_ui: ${RegExp.escape(_expectedNakedUiVersion)}\\s*\$', + '^ naked_ui: ${RegExp.escape(_expectedNakedUiConstraint)}\\s*\$', multiLine: true, ).hasMatch(packageSource), - 'Remix must declare the exact naked_ui $_expectedNakedUiVersion ' - 'dependency.', + 'Remix must constrain naked_ui to $_expectedNakedUiConstraint.', failures, ); final workspaceSource = workspacePubspec.readAsStringSync(); @@ -831,7 +831,7 @@ Never _finish(List failures) { stdout.writeln( 'Verified @radix-ui/themes 3.3.0 contract: ' '20 mapped families, 3 Fortal extensions, Chromium fixtures, ' - 'coverage ledger, hosted Naked $_expectedNakedUiVersion pin, and no ' + 'coverage ledger, hosted Naked $_expectedNakedUiVersion resolution, and no ' 'undocumented approximations.', ); exit(0); From 4354a964f26b5da78deb8b571323e8c54d120cf6 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Mon, 3 Aug 2026 21:38:31 -0400 Subject: [PATCH 2/7] fix(remix): correct textfield composite behavior --- .../textfield/textfield_widget.dart | 222 ++++++- .../textfield/textfield_widget_test.dart | 584 +++++++++++++++++- 2 files changed, 771 insertions(+), 35 deletions(-) diff --git a/packages/remix/lib/src/components/textfield/textfield_widget.dart b/packages/remix/lib/src/components/textfield/textfield_widget.dart index 02172d77..8e83241b 100644 --- a/packages/remix/lib/src/components/textfield/textfield_widget.dart +++ b/packages/remix/lib/src/components/textfield/textfield_widget.dart @@ -262,11 +262,26 @@ class RemixTextField extends StatelessWidget { Widget _buildResolved( TextFieldSpec spec, WidgetStatesController styleController, + FocusNode effectiveFocusNode, ) { - return NakedTextField( + final isMultiline = expands || maxLines != 1 || (minLines ?? 1) > 1; + final hintAlignment = isMultiline + ? AlignmentDirectional.topStart + : AlignmentDirectional.centerStart; + final baseSemanticHint = semanticHint ?? hintText; + final effectiveSemanticErrorText = error + ? _joinSemanticText([helperText]) + : null; + final effectiveSemanticHint = _joinSemanticText([ + if (!error || baseSemanticHint != effectiveSemanticErrorText) + baseSemanticHint, + if (!error) helperText, + ]); + + final nakedTextField = NakedTextField( groupId: groupId, controller: controller, - focusNode: focusNode, + focusNode: effectiveFocusNode, undoController: undoController, keyboardType: keyboardType, textInputAction: textInputAction, @@ -322,14 +337,12 @@ class RemixTextField extends StatelessWidget { 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, + semanticHint: effectiveSemanticHint, + semanticErrorText: effectiveSemanticErrorText, builder: (BuildContext context, _, Widget editableText) { final textFieldState = NakedTextFieldState.of(context); final styledEditableText = StyleSpecBuilder( @@ -342,13 +355,18 @@ class RemixTextField extends StatelessWidget { final editableWithHint = 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( + hintText!, + styleSpec: spec.hintText, + ), + ), ), ), styledEditableText, @@ -356,33 +374,63 @@ 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, - ], - ); + return editableWithHint; + }, + ); - final needsWrapper = label != null || helperText != null; + final withAccessories = RemixFlexBoxWithEffects( + styleSpec: spec.container, + direction: Axis.horizontal, + containerEffects: spec.containerEffects, + children: [ + ?leading, + // ignore: avoid-flexible-outside-flex + Expanded(child: nakedTextField), + ?trailing, + ], + ); - return needsWrapper - ? ColumnBox( - styleSpec: spec.layout, - children: [ - if (label != null) StyledText(label!, styleSpec: spec.label), - withAccessories, - if (helperText != null) - StyledText(helperText!, styleSpec: spec.helperText), - ], - ) - : withAccessories; + final needsWrapper = label != null || helperText != null; + Widget composite = needsWrapper + ? ColumnBox( + styleSpec: spec.layout, + children: [ + if (label != null) + ExcludeSemantics( + child: StyledText(label!, styleSpec: spec.label), + ), + withAccessories, + if (helperText != null) + ExcludeSemantics( + child: StyledText(helperText!, styleSpec: spec.helperText), + ), + ], + ) + : withAccessories; + + composite = _RemixTextFieldFallbackGestureDetector( + enabled: enabled, + onTapAlwaysCalled: onTapAlwaysCalled, + onPressChange: (pressed) => styleController.update(.pressed, pressed), + onTap: () { + if (canRequestFocus && effectiveFocusNode.canRequestFocus) { + effectiveFocusNode.requestFocus(); + } + onTap?.call(); }, + child: composite, ); + + if (enabled) { + composite = MouseRegion( + onEnter: (_) => styleController.update(.hovered, true), + onExit: (_) => styleController.update(.hovered, false), + cursor: SystemMouseCursors.text, + child: composite, + ); + } + + return excludeSemantics ? ExcludeSemantics(child: composite) : composite; } @override @@ -400,6 +448,10 @@ class _RemixTextFieldBody extends StatefulWidget { class _RemixTextFieldBodyState extends State<_RemixTextFieldBody> { late final WidgetStatesController _styleController; + FocusNode? _internalFocusNode; + + FocusNode get _effectiveFocusNode => + widget.config.focusNode ?? _internalFocusNode!; @override void initState() { @@ -408,11 +460,33 @@ class _RemixTextFieldBodyState extends State<_RemixTextFieldBody> { 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) { + 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); @@ -421,6 +495,7 @@ class _RemixTextFieldBodyState extends State<_RemixTextFieldBody> { @override void dispose() { _styleController.dispose(); + _internalFocusNode?.dispose(); super.dispose(); } @@ -432,11 +507,92 @@ 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) => + config._buildResolved(spec, _styleController, _effectiveFocusNode), ); } } +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) { + final gestures = {}; + + if (enabled) { + void handleTapUp(TapDragUpDetails details) { + onPressChange(false); + if (details.consecutiveTapCount == 1 || onTapAlwaysCalled) { + onTap(); + } + } + + void configure(BaseTapAndDragGestureRecognizer recognizer) { + recognizer + ..dragStartBehavior = DragStartBehavior.down + ..onTapDown = (_) { + onPressChange(true); + } + ..onTapUp = handleTapUp + ..onCancel = () => onPressChange(false); + } + + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.iOS: + gestures[TapAndHorizontalDragGestureRecognizer] = + GestureRecognizerFactoryWithHandlers< + TapAndHorizontalDragGestureRecognizer + >( + () => TapAndHorizontalDragGestureRecognizer(debugOwner: this), + configure, + ); + case TargetPlatform.linux: + case TargetPlatform.macOS: + case TargetPlatform.windows: + gestures[TapAndPanGestureRecognizer] = + GestureRecognizerFactoryWithHandlers( + () => TapAndPanGestureRecognizer(debugOwner: this), + configure, + ); + } + } + + return RawGestureDetector( + gestures: gestures, + behavior: HitTestBehavior.translucent, + excludeFromSemantics: true, + child: child, + ); + } +} + +String? _joinSemanticText(Iterable values) { + final pieces = []; + for (final value in values) { + if (value == null || value.trim().isEmpty || pieces.contains(value)) { + continue; + } + pieces.add(value); + } + + return pieces.isEmpty ? null : pieces.join('\n'); +} + /// Baseline style merged beneath the user-supplied style. /// /// It seeds the vertical [ColumnBox] wrapper (the [TextFieldSpec.layout]) diff --git a/packages/remix/test/components/textfield/textfield_widget_test.dart b/packages/remix/test/components/textfield/textfield_widget_test.dart index dea2db2b..e16cf773 100644 --- a/packages/remix/test/components/textfield/textfield_widget_test.dart +++ b/packages/remix/test/components/textfield/textfield_widget_test.dart @@ -1,6 +1,7 @@ import 'dart:ui'; 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 +9,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', ( @@ -385,7 +415,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 +439,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 +480,531 @@ 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('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('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; + + await tester.pumpRemixApp( + RemixTextField( + label: 'Field label', + helperText: 'Field helper', + hintText: 'Field hint', + onTap: () => taps++, + onTapAlwaysCalled: true, + style: TextFieldStyler().width(280).paddingAll(24), + ), + ); + await tester.pump(); + + final editable = tester.widget( + find.byType(EditableText), + ); + final container = find.byType(RowBox); + expect(container, findsOneWidget); + + await tester.tap(find.text('Field label')); + await tester.pump(); + expect(taps, 1); + expect(editable.focusNode.hasFocus, isTrue); + + 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); + }, + ); + } + + 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, + 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; + await tester.tap(find.byKey(const ValueKey('clear-accessory'))); + await tester.pump(); + + expect(accessoryTaps, 1); + expect(fieldTaps, 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); + + gesture = await tester.startGesture(location); + await tester.pump(); + expect(cursorColor(), Colors.red); + await gesture.cancel(); + await tester.pump(); + expect(cursorColor(), Colors.blue); + }); + + 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', () { @@ -710,6 +1273,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(); From 3d37ecea9be4f156b521f4389426249615fc2127 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Mon, 3 Aug 2026 21:38:53 -0400 Subject: [PATCH 3/7] feat(remix): add textarea component --- docs.json | 4 + docs/components/textarea.mdx | 256 ++++++++ .../lib/registry/component_registry.dart | 5 + .../lib/registry/entries/textarea_entry.dart | 229 ++++++++ .../playground/lib/routes/all_components.dart | 2 + packages/remix/CHANGELOG.md | 6 + .../components/textfield/textarea_widget.dart | 90 +++ .../src/components/textfield/textfield.dart | 1 + .../textfield/textarea_widget_test.dart | 554 ++++++++++++++++++ .../test/public_api_compatibility_test.dart | 3 + packages/remix/test/public_api_test.dart | 8 + 11 files changed, 1158 insertions(+) create mode 100644 docs/components/textarea.mdx create mode 100644 packages/playground/lib/registry/entries/textarea_entry.dart create mode 100644 packages/remix/lib/src/components/textfield/textarea_widget.dart create mode 100644 packages/remix/test/components/textfield/textarea_widget_test.dart diff --git a/docs.json b/docs.json index a8f8ad78..d5a5553b 100644 --- a/docs.json +++ b/docs.json @@ -153,6 +153,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..7e51bd9e --- /dev/null +++ b/docs/components/textarea.mdx @@ -0,0 +1,256 @@ +--- +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. + + +```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/packages/playground/lib/registry/component_registry.dart b/packages/playground/lib/registry/component_registry.dart index a5fb4aee..b5b4baa2 100644 --- a/packages/playground/lib/registry/component_registry.dart +++ b/packages/playground/lib/registry/component_registry.dart @@ -17,6 +17,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. @@ -29,6 +30,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..06445d63 --- /dev/null +++ b/packages/playground/lib/registry/entries/textarea_entry.dart @@ -0,0 +1,229 @@ +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> { + late final TextEditingController _remixController; + late final TextEditingController _materialController; + late final TextEditingController _remixReadOnlyController; + late final TextEditingController _materialReadOnlyController; + + @override + void initState() { + super.initState(); + _remixController = TextEditingController( + text: 'First paragraph.\nSecond paragraph.', + ); + _materialController = TextEditingController( + text: 'First paragraph.\nSecond paragraph.', + ); + _remixReadOnlyController = TextEditingController( + text: 'This content can be selected but not changed.', + ); + _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-focused'), + autofocus: true, + label: 'Empty and focused', + 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 9a3f120e..9d12ca5a 100644 --- a/packages/playground/lib/routes/all_components.dart +++ b/packages/playground/lib/routes/all_components.dart @@ -13,6 +13,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 { @@ -53,6 +54,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 8b52af21..e99dccb2 100644 --- a/packages/remix/CHANGELOG.md +++ b/packages/remix/CHANGELOG.md @@ -1,5 +1,11 @@ ## Unreleased +- **FEAT**: Add `RemixTextArea`, a constructor-only multiline facade over + `RemixTextField` with two-line auto-growing defaults and the canonical + `TextFieldStyler` / `TextFieldSpec` styling surface. +- **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 the Remix rendering subsystem for layered inset and outer shadows, gradients, outlines, backdrop blur, blend modes, and ordered color filters used by the Radix-accurate Fortal recipes. 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..968866eb --- /dev/null +++ b/packages/remix/lib/src/components/textfield/textarea_widget.dart @@ -0,0 +1,90 @@ +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, + ); + + static final styleFrom = TextFieldStyler.new; +} 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/test/components/textfield/textarea_widget_test.dart b/packages/remix/test/components/textfield/textarea_widget_test.dart new file mode 100644 index 00000000..43be3f24 --- /dev/null +++ b/packages/remix/test/components/textfield/textarea_widget_test.dart @@ -0,0 +1,554 @@ +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 { + expect(RemixTextArea.styleFrom(), isA()); + + 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( + '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(); + try { + await tester.pumpRemixApp( + const RemixTextArea( + label: 'Notes', + hintText: 'Add details', + helperText: 'Markdown supported', + ), + ); + await tester.pump(); + + final fields = tester.semantics + .simulatedAccessibilityTraversal() + .where( + (node) => node.getSemanticsData().flagsCollection.isTextField, + ) + .toList(); + expect(fields, hasLength(1)); + expect( + fields.single, + isSemantics( + label: 'Notes', + hint: 'Add details\nMarkdown supported', + isTextField: true, + isMultiline: true, + hasEnabledState: true, + isEnabled: true, + isReadOnly: false, + hasTapAction: 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 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, + isSemantics( + label: 'Notes', + isTextField: true, + isMultiline: true, + hasEnabledState: true, + isEnabled: state.enabled, + isReadOnly: true, + hasTapAction: false, + ), + ); + } finally { + semantics.dispose(); + } + }); + } + }); +} diff --git a/packages/remix/test/public_api_compatibility_test.dart b/packages/remix/test/public_api_compatibility_test.dart index 368a0ef1..7b6160e7 100644 --- a/packages/remix/test/public_api_compatibility_test.dart +++ b/packages/remix/test/public_api_compatibility_test.dart @@ -18,6 +18,7 @@ void main() { const progress = RemixProgress(value: 0.5); const tabBar = RemixTabBar(child: Text('Tabs')); const spinner = RemixSpinner(); + const textArea = RemixTextArea(label: 'Notes'); expect(button.label, 'Save'); expect(card.child, isA()); @@ -28,6 +29,8 @@ void main() { expect(progress.value, 0.5); expect(tabBar.child, isA()); expect(spinner, isA()); + expect(textArea, isA()); + expect(textArea.label, 'Notes'); }); test('generated wrappers preserve generic and named constructors', () { diff --git a/packages/remix/test/public_api_test.dart b/packages/remix/test/public_api_test.dart index 23c90b8c..88406145 100644 --- a/packages/remix/test/public_api_test.dart +++ b/packages/remix/test/public_api_test.dart @@ -2,6 +2,14 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:remix/remix.dart'; 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('mode-aware Fortal filters are constructible from the public API', () { final modifier = fortalModeAwareFilter( light: const [RemixCssColorFilterOperation.brightness(1.1)], From 86c37bde14ae8db4527856c3664020256e34cbd7 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Mon, 3 Aug 2026 21:57:02 -0400 Subject: [PATCH 4/7] refactor(playground): simplify textarea preview state --- .../lib/registry/entries/textarea_entry.dart | 33 +++++++------------ 1 file changed, 12 insertions(+), 21 deletions(-) diff --git a/packages/playground/lib/registry/entries/textarea_entry.dart b/packages/playground/lib/registry/entries/textarea_entry.dart index 06445d63..4d223abf 100644 --- a/packages/playground/lib/registry/entries/textarea_entry.dart +++ b/packages/playground/lib/registry/entries/textarea_entry.dart @@ -13,27 +13,18 @@ class _TextAreaExample extends StatefulWidget { } class _TextAreaExampleState extends State<_TextAreaExample> { - late final TextEditingController _remixController; - late final TextEditingController _materialController; - late final TextEditingController _remixReadOnlyController; - late final TextEditingController _materialReadOnlyController; - - @override - void initState() { - super.initState(); - _remixController = TextEditingController( - text: 'First paragraph.\nSecond paragraph.', - ); - _materialController = TextEditingController( - text: 'First paragraph.\nSecond paragraph.', - ); - _remixReadOnlyController = TextEditingController( - text: 'This content can be selected but not changed.', - ); - _materialReadOnlyController = TextEditingController( - text: 'This content can be selected but not changed.', - ); - } + 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() { From be830f2fc20f519183b13c31370cc3fd9d2ef33e Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Tue, 4 Aug 2026 13:58:05 -0400 Subject: [PATCH 5/7] feat(remix): add textarea component --- README.md | 1 + docs/components/textarea.mdx | 4 +- docs/components/textfield.mdx | 31 +- .../lib/registry/entries/textarea_entry.dart | 5 +- packages/remix/CHANGELOG.md | 16 + packages/remix/README.md | 1 + .../textfield/fortal_textfield_styles.dart | 4 +- .../components/textfield/textarea_widget.dart | 2 - .../src/components/textfield/textfield.g.dart | 274 +++++---- .../components/textfield/textfield_spec.dart | 56 +- .../components/textfield/textfield_style.dart | 6 +- .../textfield/textfield_widget.dart | 158 ++--- .../styler_factory_shorthand_test.dart | 2 +- .../textfield/textarea_widget_test.dart | 75 ++- .../textfield/textfield_spec_test.dart | 68 ++- .../textfield/textfield_style_test.dart | 23 +- .../textfield/textfield_widget_test.dart | 547 +++++++++++++++++- .../fortal/fortal_control_matrix_test.dart | 5 +- 18 files changed, 992 insertions(+), 286 deletions(-) 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/components/textarea.mdx b/docs/components/textarea.mdx index 7e51bd9e..9de4d6f1 100644 --- a/docs/components/textarea.mdx +++ b/docs/components/textarea.mdx @@ -139,7 +139,9 @@ not accept input or expose an editing action. 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. +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. ```dart 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/entries/textarea_entry.dart b/packages/playground/lib/registry/entries/textarea_entry.dart index 4d223abf..553e8f3e 100644 --- a/packages/playground/lib/registry/entries/textarea_entry.dart +++ b/packages/playground/lib/registry/entries/textarea_entry.dart @@ -73,9 +73,8 @@ class _TextAreaExampleState extends State<_TextAreaExample> { remix: [ field( const RemixTextArea( - key: ValueKey('textarea-empty-focused'), - autofocus: true, - label: 'Empty and focused', + key: ValueKey('textarea-empty'), + label: 'Empty', hintText: 'Start typing at the top…', ), ), diff --git a/packages/remix/CHANGELOG.md b/packages/remix/CHANGELOG.md index e99dccb2..40cbc223 100644 --- a/packages/remix/CHANGELOG.md +++ b/packages/remix/CHANGELOG.md @@ -3,6 +3,22 @@ - **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. diff --git a/packages/remix/README.md b/packages/remix/README.md index 59fbe440..c7e332db 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 index 968866eb..0289ee84 100644 --- a/packages/remix/lib/src/components/textfield/textarea_widget.dart +++ b/packages/remix/lib/src/components/textfield/textarea_widget.dart @@ -85,6 +85,4 @@ class RemixTextArea extends RemixTextField { expands: false, obscureText: false, ); - - static final styleFrom = TextFieldStyler.new; } 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 8e83241b..9a37cf8a 100644 --- a/packages/remix/lib/src/components/textfield/textfield_widget.dart +++ b/packages/remix/lib/src/components/textfield/textfield_widget.dart @@ -262,21 +262,26 @@ class RemixTextField extends StatelessWidget { Widget _buildResolved( TextFieldSpec spec, WidgetStatesController styleController, - FocusNode effectiveFocusNode, - ) { + FocusNode effectiveFocusNode, { + required ValueChanged onEditablePressChange, + required ValueChanged onFallbackPressChange, + }) { final isMultiline = expands || maxLines != 1 || (minLines ?? 1) > 1; final hintAlignment = isMultiline ? AlignmentDirectional.topStart : AlignmentDirectional.centerStart; - final baseSemanticHint = semanticHint ?? hintText; + final acceptsPointerEvents = enabled && ignorePointers != true; final effectiveSemanticErrorText = error ? _joinSemanticText([helperText]) : null; - final effectiveSemanticHint = _joinSemanticText([ - if (!error || baseSemanticHint != effectiveSemanticErrorText) - baseSemanticHint, + final joinedSemanticHint = _joinSemanticText([ + semanticHint ?? hintText, if (!error) helperText, ]); + final effectiveSemanticHint = + joinedSemanticHint == effectiveSemanticErrorText + ? null + : joinedSemanticHint; final nakedTextField = NakedTextField( groupId: groupId, @@ -338,7 +343,7 @@ class RemixTextField extends StatelessWidget { spellCheckConfiguration: spellCheckConfiguration, magnifierConfiguration: magnifierConfiguration, onFocusChange: (value) => styleController.update(.focused, value), - onPressChange: (value) => styleController.update(.pressed, value), + onPressChange: acceptsPointerEvents ? onEditablePressChange : null, ignorePointers: ignorePointers, semanticLabel: semanticLabel ?? label, semanticHint: effectiveSemanticHint, @@ -374,25 +379,34 @@ class RemixTextField extends StatelessWidget { ) : styledEditableText; - return editableWithHint; + return error + ? Semantics( + validationResult: SemanticsValidationResult.invalid, + child: editableWithHint, + ) + : editableWithHint; }, ); - final withAccessories = RemixFlexBoxWithEffects( + final withAccessories = RemixBoxWithEffects( styleSpec: spec.container, - direction: Axis.horizontal, containerEffects: spec.containerEffects, - children: [ - ?leading, - // ignore: avoid-flexible-outside-flex - Expanded(child: nakedTextField), - ?trailing, - ], + child: Row( + spacing: spec.spacing ?? 0, + crossAxisAlignment: + spec.crossAxisAlignment ?? CrossAxisAlignment.center, + textBaseline: TextBaseline.alphabetic, + children: [ + ?leading, + Expanded(child: nakedTextField), + ?trailing, + ], + ), ); final needsWrapper = label != null || helperText != null; Widget composite = needsWrapper - ? ColumnBox( + ? FlexBox( styleSpec: spec.layout, children: [ if (label != null) @@ -409,9 +423,9 @@ class RemixTextField extends StatelessWidget { : withAccessories; composite = _RemixTextFieldFallbackGestureDetector( - enabled: enabled, + enabled: acceptsPointerEvents, onTapAlwaysCalled: onTapAlwaysCalled, - onPressChange: (pressed) => styleController.update(.pressed, pressed), + onPressChange: onFallbackPressChange, onTap: () { if (canRequestFocus && effectiveFocusNode.canRequestFocus) { effectiveFocusNode.requestFocus(); @@ -448,6 +462,7 @@ class _RemixTextFieldBody extends StatefulWidget { class _RemixTextFieldBodyState extends State<_RemixTextFieldBody> { late final WidgetStatesController _styleController; + final _activePressSources = <_RemixTextFieldPressSource>{}; FocusNode? _internalFocusNode; FocusNode get _effectiveFocusNode => @@ -490,6 +505,22 @@ class _RemixTextFieldBodyState extends State<_RemixTextFieldBody> { _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 @@ -507,12 +538,21 @@ class _RemixTextFieldBodyState extends State<_RemixTextFieldBody> { style: _baseStyle.merge(config.style), styleSpec: config.styleSpec, controller: _styleController, - builder: (context, spec) => - config._buildResolved(spec, _styleController, _effectiveFocusNode), + builder: (context, spec) => config._buildResolved( + spec, + _styleController, + _effectiveFocusNode, + onEditablePressChange: (pressed) => + _updatePressSource(.editable, pressed), + onFallbackPressChange: (pressed) => + _updatePressSource(.fallback, pressed), + ), ); } } +enum _RemixTextFieldPressSource { editable, fallback } + class _RemixTextFieldFallbackGestureDetector extends StatelessWidget { const _RemixTextFieldFallbackGestureDetector({ required this.enabled, @@ -530,52 +570,19 @@ class _RemixTextFieldFallbackGestureDetector extends StatelessWidget { @override Widget build(BuildContext context) { - final gestures = {}; - - if (enabled) { - void handleTapUp(TapDragUpDetails details) { - onPressChange(false); - if (details.consecutiveTapCount == 1 || onTapAlwaysCalled) { - onTap(); - } - } - - void configure(BaseTapAndDragGestureRecognizer recognizer) { - recognizer - ..dragStartBehavior = DragStartBehavior.down - ..onTapDown = (_) { - onPressChange(true); - } - ..onTapUp = handleTapUp - ..onCancel = () => onPressChange(false); - } - - switch (defaultTargetPlatform) { - case TargetPlatform.android: - case TargetPlatform.fuchsia: - case TargetPlatform.iOS: - gestures[TapAndHorizontalDragGestureRecognizer] = - GestureRecognizerFactoryWithHandlers< - TapAndHorizontalDragGestureRecognizer - >( - () => TapAndHorizontalDragGestureRecognizer(debugOwner: this), - configure, - ); - case TargetPlatform.linux: - case TargetPlatform.macOS: - case TargetPlatform.windows: - gestures[TapAndPanGestureRecognizer] = - GestureRecognizerFactoryWithHandlers( - () => TapAndPanGestureRecognizer(debugOwner: this), - configure, - ); - } - } - - return RawGestureDetector( - gestures: gestures, + 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, - excludeFromSemantics: true, child: child, ); } @@ -584,10 +591,13 @@ class _RemixTextFieldFallbackGestureDetector extends StatelessWidget { String? _joinSemanticText(Iterable values) { final pieces = []; for (final value in values) { - if (value == null || value.trim().isEmpty || pieces.contains(value)) { + final normalized = value?.trim(); + if (normalized == null || + normalized.isEmpty || + pieces.contains(normalized)) { continue; } - pieces.add(value); + pieces.add(normalized); } return pieces.isEmpty ? null : pieces.join('\n'); @@ -595,14 +605,14 @@ String? _joinSemanticText(Iterable values) { /// 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 index 43be3f24..100b224a 100644 --- a/packages/remix/test/components/textfield/textarea_widget_test.dart +++ b/packages/remix/test/components/textfield/textarea_widget_test.dart @@ -1,3 +1,5 @@ +import 'dart:ui' show SemanticsInputType; + import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; @@ -233,8 +235,6 @@ void main() { testWidgets('uses the canonical fluent styler and raw spec unchanged', ( tester, ) async { - expect(RemixTextArea.styleFrom(), isA()); - await tester.pumpRemixApp( RemixTextArea( style: TextFieldStyler() @@ -265,6 +265,50 @@ void main() { 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 { @@ -423,12 +467,16 @@ void main() { testWidgets('exposes enabled multiline semantics once', (tester) async { final semantics = tester.ensureSemantics(); + final controller = TextEditingController(text: 'abc'); + addTearDown(controller.dispose); try { await tester.pumpRemixApp( - const RemixTextArea( + RemixTextArea( + controller: controller, label: 'Notes', hintText: 'Add details', helperText: 'Markdown supported', + maxLength: 10, ), ); await tester.pump(); @@ -442,15 +490,21 @@ void main() { expect(fields, hasLength(1)); expect( fields.single, - isSemantics( + 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, - isReadOnly: false, hasTapAction: true, + hasFocusAction: true, ), ); } finally { @@ -512,7 +566,7 @@ void main() { (name: 'read-only', enabled: true, readOnly: true), (name: 'disabled', enabled: false, readOnly: false), ]) { - testWidgets('${state.name} keeps multiline field semantics', ( + testWidgets('${state.name} keeps exact multiline field semantics', ( tester, ) async { final semantics = tester.ensureSemantics(); @@ -535,14 +589,19 @@ void main() { expect(fields, hasLength(1)); expect( fields.single, - isSemantics( + 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, - hasTapAction: false, + hasFocusAction: state.enabled, ), ); } finally { 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 e16cf773..79403717 100644 --- a/packages/remix/test/components/textfield/textfield_widget_test.dart +++ b/packages/remix/test/components/textfield/textfield_widget_test.dart @@ -1,5 +1,7 @@ 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'; @@ -49,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); @@ -61,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)); @@ -547,6 +548,31 @@ void main() { } }); + 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 { @@ -612,6 +638,27 @@ void main() { }, ); + 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 { @@ -743,13 +790,20 @@ void main() { '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', - onTap: () => taps++, + focusNode: focusNode, + onTap: () { + taps++; + focusedDuringTap = focusNode.hasFocus; + }, onTapAlwaysCalled: true, style: TextFieldStyler().width(280).paddingAll(24), ), @@ -759,13 +813,26 @@ void main() { final editable = tester.widget( find.byType(EditableText), ); - final container = find.byType(RowBox); + 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(); @@ -803,6 +870,43 @@ void main() { ); } + 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 { @@ -813,6 +917,9 @@ void main() { RemixTextField( onTap: () => fieldTaps++, onTapAlwaysCalled: true, + style: TextFieldStyler( + cursorColor: Colors.blue, + ).onPressed(TextFieldStyler(cursorColor: Colors.red)), trailing: IconButton( key: const ValueKey('clear-accessory'), onPressed: () => accessoryTaps++, @@ -825,7 +932,22 @@ void main() { final focusNode = tester .widget(find.byType(EditableText)) .focusNode; - await tester.tap(find.byKey(const ValueKey('clear-accessory'))); + 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); @@ -833,6 +955,29 @@ void main() { 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', @@ -945,6 +1090,7 @@ void main() { await tester.pump(); expect(cursorColor(), Colors.blue); + await tester.pump(kDoubleTapTimeout); gesture = await tester.startGesture(location); await tester.pump(); expect(cursorColor(), Colors.red); @@ -953,6 +1099,282 @@ void main() { 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 { @@ -1121,7 +1543,7 @@ void main() { await tester.pumpRemixApp( RemixTextField( style: TextFieldStyler().container( - FlexBoxStyler( + BoxStyler( decoration: BoxDecorationMix(color: Colors.grey), padding: EdgeInsetsGeometryMix.all(16), ), @@ -1146,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)), @@ -1204,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, @@ -1217,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); 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, From d1a06e269de6687ff212a3a67f0b5749c25f1d95 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Tue, 4 Aug 2026 22:46:07 -0400 Subject: [PATCH 6/7] chore(remix): validate naked_ui beta.9 parity --- packages/remix/tool/fortal_parity/check.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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', From e202d2a18b52998f33d7e4bd16d68392552cf7e0 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Wed, 5 Aug 2026 17:52:48 -0400 Subject: [PATCH 7/7] refactor(remix): move textfield build into its state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move `_buildResolved` from `RemixTextField` onto `_RemixTextFieldBodyState`. Its only caller was that state, and every argument it took — the style controller, the effective focus node, and two press callbacks — was already state-owned. The callbacks existed solely to bridge back to `_updatePressSource`. No behavior change. Record the two non-obvious invariants in the same file so a later simplification pass does not undo them: - The obsolete internal focus node must be disposed in a post-frame callback. 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. A synchronous dispose detaches and unfocuses first, so focus would be dropped across a null -> external swap. - The `joinedSemanticHint == effectiveSemanticErrorText` check is not dead code. `NakedTextField` concatenates `semanticHint` and `semanticErrorText` unconditionally without deduplicating, so an identical hint and error would be announced twice. Document that `fortalTextFieldStyle()` must not be applied to `RemixTextArea`: it pins the input container to a single-line height, so taller content scrolls inside a one-line box with no layout error to signal it. There is no Fortal TextArea preset because Radix Themes' TextArea is not in this package's mapped parity surface, so its metrics have no reference data to derive from. --- docs/components/textarea.mdx | 10 + .../textfield/textfield_widget.dart | 358 +++++++++--------- 2 files changed, 192 insertions(+), 176 deletions(-) diff --git a/docs/components/textarea.mdx b/docs/components/textarea.mdx index 9de4d6f1..c00aeb3f 100644 --- a/docs/components/textarea.mdx +++ b/docs/components/textarea.mdx @@ -143,6 +143,16 @@ 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'; diff --git a/packages/remix/lib/src/components/textfield/textfield_widget.dart b/packages/remix/lib/src/components/textfield/textfield_widget.dart index 9a37cf8a..e33f0094 100644 --- a/packages/remix/lib/src/components/textfield/textfield_widget.dart +++ b/packages/remix/lib/src/components/textfield/textfield_widget.dart @@ -259,61 +259,151 @@ class RemixTextField extends StatelessWidget { static final styleFrom = TextFieldStyler.new; - Widget _buildResolved( - TextFieldSpec spec, - WidgetStatesController styleController, - FocusNode effectiveFocusNode, { - required ValueChanged onEditablePressChange, - required ValueChanged onFallbackPressChange, - }) { - final isMultiline = expands || maxLines != 1 || (minLines ?? 1) > 1; + @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 = enabled && ignorePointers != true; - final effectiveSemanticErrorText = error - ? _joinSemanticText([helperText]) + final acceptsPointerEvents = + config.enabled && config.ignorePointers != true; + final effectiveSemanticErrorText = config.error + ? _joinSemanticText([config.helperText]) : null; final joinedSemanticHint = _joinSemanticText([ - semanticHint ?? hintText, - if (!error) helperText, + 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: groupId, - controller: controller, - focusNode: effectiveFocusNode, - undoController: undoController, - keyboardType: keyboardType, - textInputAction: textInputAction, - textCapitalization: textCapitalization, + 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, @@ -323,29 +413,31 @@ 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, - onFocusChange: (value) => styleController.update(.focused, value), - onPressChange: acceptsPointerEvents ? onEditablePressChange : null, - ignorePointers: ignorePointers, - semanticLabel: semanticLabel ?? label, + 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) { @@ -358,7 +450,7 @@ class RemixTextField extends StatelessWidget { ), ); - final editableWithHint = hintText != null + final editableWithHint = config.hintText != null ? Stack( alignment: hintAlignment, children: [ @@ -368,7 +460,7 @@ class RemixTextField extends StatelessWidget { alignment: hintAlignment, child: ExcludeSemantics( child: StyledText( - hintText!, + config.hintText!, styleSpec: spec.hintText, ), ), @@ -379,7 +471,7 @@ class RemixTextField extends StatelessWidget { ) : styledEditableText; - return error + return config.error ? Semantics( validationResult: SemanticsValidationResult.invalid, child: editableWithHint, @@ -397,26 +489,29 @@ class RemixTextField extends StatelessWidget { spec.crossAxisAlignment ?? CrossAxisAlignment.center, textBaseline: TextBaseline.alphabetic, children: [ - ?leading, + ?config.leading, Expanded(child: nakedTextField), - ?trailing, + ?config.trailing, ], ), ); - final needsWrapper = label != null || helperText != null; + final needsWrapper = config.label != null || config.helperText != null; Widget composite = needsWrapper ? FlexBox( styleSpec: spec.layout, children: [ - if (label != null) + if (config.label != null) ExcludeSemantics( - child: StyledText(label!, styleSpec: spec.label), + child: StyledText(config.label!, styleSpec: spec.label), ), withAccessories, - if (helperText != null) + if (config.helperText != null) ExcludeSemantics( - child: StyledText(helperText!, styleSpec: spec.helperText), + child: StyledText( + config.helperText!, + styleSpec: spec.helperText, + ), ), ], ) @@ -424,110 +519,29 @@ class RemixTextField extends StatelessWidget { composite = _RemixTextFieldFallbackGestureDetector( enabled: acceptsPointerEvents, - onTapAlwaysCalled: onTapAlwaysCalled, - onPressChange: onFallbackPressChange, + onTapAlwaysCalled: config.onTapAlwaysCalled, + onPressChange: (bool pressed) => _updatePressSource(.fallback, pressed), onTap: () { - if (canRequestFocus && effectiveFocusNode.canRequestFocus) { - effectiveFocusNode.requestFocus(); + if (config.canRequestFocus && _effectiveFocusNode.canRequestFocus) { + _effectiveFocusNode.requestFocus(); } - onTap?.call(); + config.onTap?.call(); }, child: composite, ); - if (enabled) { + if (config.enabled) { composite = MouseRegion( - onEnter: (_) => styleController.update(.hovered, true), - onExit: (_) => styleController.update(.hovered, false), + onEnter: (_) => _styleController.update(.hovered, true), + onExit: (_) => _styleController.update(.hovered, false), cursor: SystemMouseCursors.text, child: composite, ); } - return excludeSemantics ? ExcludeSemantics(child: composite) : composite; - } - - @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) { - 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(); + return config.excludeSemantics + ? ExcludeSemantics(child: composite) + : composite; } @override @@ -538,15 +552,7 @@ class _RemixTextFieldBodyState extends State<_RemixTextFieldBody> { style: _baseStyle.merge(config.style), styleSpec: config.styleSpec, controller: _styleController, - builder: (context, spec) => config._buildResolved( - spec, - _styleController, - _effectiveFocusNode, - onEditablePressChange: (pressed) => - _updatePressSource(.editable, pressed), - onFallbackPressChange: (pressed) => - _updatePressSource(.fallback, pressed), - ), + builder: (context, spec) => _buildResolved(spec), ); } }