diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a3dd0c..bd07373 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ This project follows [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.0. --- +## [Unreleased] + +### Fixed + +- **A multi-line `className` hid its flex tokens from the row and column composers, and this project's own style guide is what put them there.** `.claude/rules/widgets.md` and `SKILL.md` both instruct a className covering 3+ concerns to be a triple-quoted string with one concern per line, so `flex-1` at the end of a line arrives at the composition helpers as `flex-1\n`. Three of them split on a single space and therefore never matched it. Two consequences, both reproduced: a `flex-1` child of an `overflow-hidden` row got wrapped a second time and threw `Incorrect use of ParentDataWidget` (`_selfWrapsInFlex`), and a `w-24 shrink-0` child in a crowded row shrank to its 50pt flex share instead of holding 96pt (`_hasShrinkZero`). The third site, `_hasExplicitCrossWidth`, is corrected for consistency with its own documented "in ANY state or breakpoint variant" intent, but no observable failure could be produced for it: a stretched column child still renders at the width it asked for, so the miss costs a redundant wrapper rather than a wrong layout. All five token scans in `WDiv` now share one hoisted `_whitespaceRegex`, which also stops the two that already split on whitespace from allocating a fresh `RegExp` on every pass through the composition path. (`lib/src/widgets/w_div.dart`, `test/widgets/w_div/multiline_classname_test.dart`) + ## [1.4.0] - 2026-08-21 ### Added diff --git a/lib/src/widgets/w_div.dart b/lib/src/widgets/w_div.dart index c8ce8a5..d8f03d0 100644 --- a/lib/src/widgets/w_div.dart +++ b/lib/src/widgets/w_div.dart @@ -787,7 +787,7 @@ class WDiv extends StatelessWidget { /// tokens and prefixed variants like `md:shrink-0` / `md:flex-none`. static bool _hasShrinkZero(String? className) { if (className == null || className.isEmpty) return false; - for (final token in className.split(' ')) { + for (final token in className.split(_whitespaceRegex)) { if (token == 'shrink-0' || token.endsWith(':shrink-0') || token == 'flex-none' || @@ -879,7 +879,7 @@ class WDiv extends StatelessWidget { /// stretch wrap (a parse without those states active would miss them). static bool _hasExplicitCrossWidth(String? className) { if (className == null || className.isEmpty) return false; - for (final raw in className.split(' ')) { + for (final raw in className.split(_whitespaceRegex)) { if (raw.isEmpty) continue; final token = raw.contains(':') ? raw.split(':').last : raw; if (token.startsWith('w-') || @@ -966,6 +966,15 @@ class WDiv extends StatelessWidget { /// has been stripped. static final RegExp _numericFlexRegex = RegExp(r'^flex-[0-9]+$'); + /// Splits a raw `className` into tokens. + /// + /// Any whitespace, not a single space: this project writes a className with + /// 3+ concerns as a triple-quoted string with one concern per line (see + /// `.claude/rules/widgets.md`), so a token that ends a line arrives as + /// `flex-1\n` and a single-space split never matches it. Hoisted to a field + /// because every caller runs inside the row/column composition path. + static final RegExp _whitespaceRegex = RegExp(r'\s+'); + /// Whether a child's className makes it self-wrap in `Expanded`/`Flexible` /// (i.e. sets `styles.flex` or `styles.flexFit`, see the composition pipeline /// at the bottom of `_buildCompositionPipeline`). Such a child must never be @@ -978,7 +987,7 @@ class WDiv extends StatelessWidget { /// `Flexible`), so they are absent here. static bool _selfWrapsInFlex(String? className) { if (className == null || className.isEmpty) return false; - for (final raw in className.split(' ')) { + for (final raw in className.split(_whitespaceRegex)) { if (raw.isEmpty) continue; final token = raw.contains(':') ? raw.split(':').last : raw; if (token == 'grow' || @@ -1003,7 +1012,7 @@ class WDiv extends StatelessWidget { /// responsive intent at breakpoints where it does not apply. static bool _hasBareFullWidth(String? className) { if (className == null || className.isEmpty) return false; - for (final raw in className.split(RegExp(r'\s+'))) { + for (final raw in className.split(_whitespaceRegex)) { if (raw == 'w-full') return true; } return false; @@ -1046,7 +1055,7 @@ class WDiv extends StatelessWidget { return true; } - for (final token in className.split(RegExp(r'\s+'))) { + for (final token in className.split(_whitespaceRegex)) { if (token.isEmpty || token.contains(':')) continue; if (token == 'grow' || token == 'flex-grow' || diff --git a/test/widgets/w_div/multiline_classname_test.dart b/test/widgets/w_div/multiline_classname_test.dart new file mode 100644 index 0000000..4246401 --- /dev/null +++ b/test/widgets/w_div/multiline_classname_test.dart @@ -0,0 +1,91 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fluttersdk_wind/fluttersdk_wind.dart'; + +/// The composition helpers scan a child's raw `className` for tokens, and this +/// project's own convention writes any className with 3+ concerns as a +/// triple-quoted string with one concern per line (`.claude/rules/widgets.md`). +/// +/// A scan that splits on a single space therefore sees `flex-1\n` rather than +/// `flex-1` for every token that ends a line, and misses it. Each helper fails +/// differently when that happens, so each gets its own case here: the wrap is +/// applied twice, or applied where it should not be, or skipped where it should +/// not be. +void main() { + setUp(() { + WindParser.clearCache(); + }); + + /// Pumps [child] inside a fixed-width Wind surface. + Future pumpAt(WidgetTester tester, double width, Widget child) { + return tester.pumpWidget( + MaterialApp( + home: WindTheme( + data: WindThemeData(), + child: Align( + alignment: Alignment.topLeft, + child: SizedBox(width: width, child: child), + ), + ), + ), + ); + } + + testWidgets( + 'a multi-line flex-1 child is not wrapped a second time', + (tester) async { + // `_selfWrapsInFlex` guards against exactly this: the child already + // carries its own Expanded, so a parent Flexible on top of it throws + // "Incorrect use of ParentDataWidget". `overflow-hidden` wraps + // unconditionally, which is the shortest route to the double wrap. + await pumpAt( + tester, + 400, + const WDiv( + className: 'flex flex-row items-center overflow-hidden', + children: [ + WDiv( + className: ''' + flex-1 + bg-white dark:bg-gray-800 + ''', + child: SizedBox(height: 20), + ), + WDiv(child: SizedBox(width: 24, height: 24)), + ], + ), + ); + + expect(tester.takeException(), isNull); + }, + ); + + testWidgets( + 'a multi-line shrink-0 child keeps its intrinsic width', + (tester) async { + // `shrink-0` is the caller saying "never shrink me". Missing the token + // wraps the child in a Flexible, whose share in a crowded row is smaller + // than the width it asked for. + await pumpAt( + tester, + 100, + const WDiv( + className: 'flex flex-row items-center overflow-hidden', + children: [ + WDiv( + key: Key('fixed'), + className: ''' + w-24 shrink-0 + bg-white dark:bg-gray-800 + ''', + child: SizedBox(height: 20), + ), + WText('Very Long Value Text That Cannot Fit', className: 'text-sm'), + ], + ), + ); + + expect(tester.getSize(find.byKey(const Key('fixed'))).width, 96); + }, + ); +}