diff --git a/CHANGELOG.md b/CHANGELOG.md index 92d7903..0ac48ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,8 @@ First stable release. wind is utility-first, Tailwind-syntax styling for Flutter - **Child order utilities**: `order-0` through `order-12`, `order-first`, `order-last`, `order-none`, plus arbitrary `order-[n]` (including negatives) for reordering flex children without changing source order. - **Reverse flex direction**: `flex-row-reverse` and `flex-col-reverse` flip the main-axis direction so `justify-start` mirrors per CSS semantics. - **`self-*` align-self shorthand**: `self-start`, `self-end`, `self-center`, `self-stretch`, `self-auto`, `self-baseline` as aliases for the `align-self-*` long form, matching Tailwind's canonical class name. +- **Flex grow / basis tokens**: `grow` (Tailwind shorthand for `flex-grow`, i.e. `flex: 1`), `grow-0` (no grow), and `basis-1/2` / `basis-1/3` / `basis-1/4` / `basis-full` / `basis-[Npx]`. Basis approximates CSS `flex-basis`: it sets the child's initial MAIN-axis size (width in a row, height in a column) and ignores grow/shrink interplay. The no-grow / no-shrink resets follow last-class-wins: `grow-0` cancels an earlier `grow`/`flex-grow`/`flex-N`, `shrink-0` cancels an earlier `shrink`/`flex-shrink`, and `flex-none` (`flex: 0 0 auto`) cancels both, within the same active class list. +- **Smart column cross-axis stretch**: a `flex flex-col` with no explicit `items-*` token now stretches each `WDiv` child that does not control its own width to the column width (CSS `align-items: stretch` default). Children that self-wrap in `Expanded`/`Flexible` (`grow`, `flex-grow`, `flex-auto`, `flex-initial`, `shrink`, `flex-shrink`, `flex-N`, in any state/breakpoint variant), children with an explicit `w-*` / `min-w-*` / `max-w-*` (including `w-full`), absolute children, and non-`WDiv` children are left untouched. `shrink-0` / `flex-none` children still stretch on the cross axis (their no-shrink effect is main-axis only, matching CSS). Rows are unaffected. - **Inline color props**: `WDiv.backgroundColor` and `WText.foregroundColor` for runtime-dynamic colors. Override any `bg-*` / `text-*` from className and stay out of the parser cache key. - **`WBreakpoint` widget**: declarative per-breakpoint widget tree builder. Reach for it when className prefixes are not enough because the widget structure itself changes between breakpoints. - **`WDynamic`**: JSON-driven widget tree renderer. 13 Wind types + 16 Flutter core types allowed by default, extensible via `builders:`, restrictable via `denyWidgets:`, with `customIcons:` for user-defined icon mappings (24 built-in glyphs). State binding by widget `id`, action dispatch via `WActionHandler`, max recursion depth 50. @@ -55,6 +57,7 @@ First stable release. wind is utility-first, Tailwind-syntax styling for Flutter ### Fixed +- `WText` bare rendering: a `WText` used outside a `MaterialApp` / `Scaffold` now renders with a brightness-aware baseline color (`Colors.white` on dark platforms, `Colors.black` on light, read from `MediaQuery.platformBrightness`) instead of Flutter's debug yellow-underline fallback. When no `Directionality` ancestor exists, `WText` injects one defaulting to `TextDirection.ltr`. Explicitly supplied colors (`className text-*`, `foregroundColor`, `textStyle`) still win and are unaffected. - Background image parser (`bg-[/abs/path]`): the `FileImage(File(...))` branch is now guarded by `kIsWeb`; on web, where `dart:io` `File` is unsupported, the image degrades gracefully (skipped) instead of throwing at runtime. Non-web behavior is unchanged. `pubspec.yaml` now declares explicit platform support (`android`, `ios`, `macos`, `web`, `linux`, `windows`) so pub.dev platform detection is not narrowed by the `dart:io` import graph. - WASM compatibility: removed `dart:io` from the library import graph (`platform_service.dart` now uses `defaultTargetPlatform`; absolute-path `bg-[/...]` image resolution moved behind a conditional import). The package is now `is:wasm-ready`, raising the pana/pub.dev platform-support score to 20/20 (160/160). (#95) - `max-w-prose`: corrected value from 1040 px (65 × 16, an incorrect approximation) to 512 px, matching the actual parser output. Docs and skill references updated accordingly. @@ -65,6 +68,7 @@ First stable release. wind is utility-first, Tailwind-syntax styling for Flutter - `WindParser.parse`: cache is bypassed in both directions (no read, no write) when `baseStyle` is non-null so per-call styles do not return stale cached entries or poison the cache slot for default-flag callers. - `example/lib/routes.dart`: six widget routes (`/widgets/w-input-multiline`, `/widgets/w-input-search`, `/widgets/w-popover-alignment`, `/widgets/w-select_multi`, `/widgets/w-select_single`, `/widgets/w-text-transform`) renamed to snake_case to match their page-file basenames so live doc iframes at `wind.fluttersdk.com/preview/widgets/` resolve. Two dead pages (`layout/grid_basic`, `layout/order`) without documentation references were removed. - `BackgroundParser`: `bg-[#hex]` arbitrary-color backgrounds no longer also resolve to a bogus `AssetImage("assets/#hex")`. The image regex now excludes `#`-leading bracket values so a hex literal is parsed only as a color, eliminating a stray failed asset fetch on every arbitrary-hex background. +- `flex-none`: now means CSS `flex: 0 0 auto` (no grow AND no shrink). It no longer maps to a shrinking `FlexFit.loose`; instead it routes through the same no-shrink path as `shrink-0`, so a `flex-none` child in a `justify-between` row keeps its intrinsic size instead of being forced into a `Flexible` shrink allocation. - `WindStyle.copyWith`: a padding-, margin-, or text-only style keeps `decoration == null` instead of fabricating an empty `BoxDecoration`. This stops `WDiv`/`WText` from wrapping a needless `Container` around non-decorated content. `shadow-none` likewise no longer forces a Container. - `WDynamicRenderer`: malformed JSON degrades gracefully. A non-string `type` or non-list `children` is coerced defensively (routed through the whitelist / treated as no children) instead of throwing an implicit-downcast `TypeError` out of `build()`. - `WindThemeData`: implements value-based `operator ==` and `hashCode`. The equality guards in `WindThemeController.setTheme` and `_WindThemeState.didUpdateWidget` now compare by value, so a fresh default `WindThemeData()` on a parent rebuild no longer clobbers a prior `toggleTheme()` choice or triggers spurious full-tree rebuilds. diff --git a/doc/layout/flexbox.md b/doc/layout/flexbox.md index ab8c1e8..37e9242 100644 --- a/doc/layout/flexbox.md +++ b/doc/layout/flexbox.md @@ -208,6 +208,8 @@ Controls how children are distributed along the **cross axis** (Vertical for `ro WDiv(className: 'flex items-center h-20') ``` +> **Column default — smart cross-axis stretch.** A `flex flex-col` with no explicit `items-*` token stretches each `WDiv` child that does not control its own width to the column width, matching CSS `align-items: stretch`. Left untouched: children with an explicit width (`w-*` / `min-w-*` / `max-w-*` / `w-full`, in any state/breakpoint variant), children that self-wrap in `Expanded`/`Flexible` (`grow`, `flex-grow`, `flex-auto`, `flex-initial`, `shrink`, `flex-shrink`, `flex-N`), absolute children, and non-`WDiv` children. `shrink-0` / `flex-none` children still stretch on the cross axis (`flex-shrink` is main-axis only, matching CSS). Add any `items-*` token (e.g. `items-start`) to turn this off and let children size to content. Rows are never auto-stretched on the cross axis. When the column itself sits in an unbounded-width context (a bare `Row` slot, `UnconstrainedBox`, horizontal scroll), the stretch safely falls back to content-sized children instead of forcing an infinite width. + ## Align Content @@ -257,11 +259,14 @@ Control how individual children resize to fill available space. | Class | Description | |:------|:------------| | `flex-1` | Allow child to grow and fill available space (`Expanded`). | -| `flex-grow` | Alias for `flex-1`. | +| `flex-grow` / `grow` | Alias for `flex-1` (Tailwind v3 and v4 names). | +| `grow-0` | Do not grow — child keeps its intrinsic main-axis size. | | `flex-{n}` | Specific flex factor (e.g., `flex-2`). | | `shrink` | Allow child to shrink if needed (`FlexFit.loose`). | | `shrink-0` | Preserve intrinsic size — no Flexible wrapper, child keeps its natural dimensions. | -| `flex-none` | Do not grow or shrink. | +| `flex-none` | CSS `flex: 0 0 auto`: do not grow AND do not shrink. Keeps intrinsic size (same no-shrink path as `shrink-0`). | +| `basis-1/2` / `basis-1/3` / `basis-1/4` / `basis-full` | Fractional flex-basis: initial main-axis size (width in a row, height in a column). Approximates CSS `flex-basis`; ignores grow/shrink interplay. | +| `basis-[Npx]` | Fixed flex-basis: a fixed main-axis size in logical pixels (e.g., `basis-[120px]`). | diff --git a/example/lib/pages/layout/flex_grow.dart b/example/lib/pages/layout/flex_grow.dart index 867d879..e7af45a 100644 --- a/example/lib/pages/layout/flex_grow.dart +++ b/example/lib/pages/layout/flex_grow.dart @@ -63,9 +63,86 @@ class FlexGrowExamplePage extends StatelessWidget { ], ), ), + ExampleSection( + title: 'grow vs grow-0', + description: + 'grow is an alias for flex-1 (Expanded). grow-0 keeps the item at its natural size and refuses to expand.', + child: WDiv( + className: ''' + flex gap-2 p-2 rounded-lg + bg-white dark:bg-slate-800 + ''', + children: const [ + _FactorBox( + label: 'grow-0\n(stays fixed)', + factorClass: 'grow-0 w-20', + color: 'bg-slate-400 dark:bg-slate-600', + ), + _FactorBox( + label: 'grow\n(fills rest)', + factorClass: 'grow', + color: 'bg-green-500 dark:bg-green-600', + ), + ], + ), + ), + ExampleSection( + title: 'basis-* (flex-basis)', + description: + 'basis-* sets the initial main-axis size before grow/shrink apply. basis-1/2 takes half, basis-1/3 takes a third, basis-full spans the whole axis.', + child: WDiv( + className: 'flex flex-col gap-2', + children: const [ + _BasisRow( + left: 'basis-1/3', + leftClass: 'basis-1/3', + leftColor: 'bg-violet-400 dark:bg-violet-600', + right: 'basis-2/3 (remainder)', + rightClass: 'grow', + rightColor: 'bg-violet-200 dark:bg-violet-800', + ), + _BasisRow( + left: 'basis-1/2', + leftClass: 'basis-1/2', + leftColor: 'bg-indigo-400 dark:bg-indigo-600', + right: 'basis-1/2', + rightClass: 'basis-1/2', + rightColor: 'bg-indigo-200 dark:bg-indigo-800', + ), + _BasisRow( + left: 'basis-[80px]', + leftClass: 'basis-[80px]', + leftColor: 'bg-sky-400 dark:bg-sky-600', + right: 'grow (fills rest)', + rightClass: 'grow', + rightColor: 'bg-sky-200 dark:bg-sky-800', + ), + ], + ), + ), + ExampleSection( + title: 'Smart Column Stretch', + description: + 'A flex flex-col stretches each WDiv child to the column width by default (no w-full needed). Add items-start to shrink children to content size.', + child: WDiv( + className: 'flex flex-col gap-4', + children: const [ + _StretchDemo( + label: 'Default: children fill column width', + containerClass: 'flex flex-col gap-2', + showItemsStart: false, + ), + _StretchDemo( + label: 'items-start: children size to content', + containerClass: 'flex flex-col gap-2 items-start', + showItemsStart: true, + ), + ], + ), + ), ExampleSection( title: 'Quick Reference', - description: 'Five tokens cover the bulk of flex sizing scenarios.', + description: 'Eight tokens cover the bulk of flex sizing scenarios.', child: WDiv( className: 'flex flex-col gap-1', children: const [ @@ -73,11 +150,16 @@ class FlexGrowExamplePage extends StatelessWidget { _RefRow( cls: 'flex-{n}', desc: 'Specific flex factor (n is any integer)'), - _RefRow(cls: 'flex-grow', desc: 'Alias of flex-1'), + _RefRow(cls: 'flex-grow / grow', desc: 'Alias of flex-1'), + _RefRow(cls: 'grow-0', desc: 'Keep intrinsic size, no growing'), _RefRow(cls: 'shrink', desc: 'Allow shrinking (FlexFit.loose)'), _RefRow( cls: 'shrink-0', desc: 'Preserve intrinsic size (no wrap)'), _RefRow(cls: 'flex-none', desc: 'Do not grow or shrink'), + _RefRow( + cls: 'basis-1/2 / basis-1/3 / basis-full', + desc: 'Fractional flex-basis'), + _RefRow(cls: 'basis-[Npx]', desc: 'Fixed flex-basis in pixels'), ], ), ), @@ -164,3 +246,99 @@ class _RefRow extends StatelessWidget { ); } } + +class _BasisRow extends StatelessWidget { + final String left; + final String leftClass; + final String leftColor; + final String right; + final String rightClass; + final String rightColor; + + const _BasisRow({ + required this.left, + required this.leftClass, + required this.leftColor, + required this.right, + required this.rightClass, + required this.rightColor, + }); + + @override + Widget build(BuildContext context) { + return WDiv( + className: 'flex gap-1', + children: [ + WDiv( + className: + '$leftClass $leftColor h-14 rounded flex items-center justify-center', + child: WText( + left, + className: 'font-mono text-xs font-bold text-white text-center', + ), + ), + WDiv( + className: + '$rightClass $rightColor h-14 rounded flex items-center justify-center', + child: WText( + right, + className: 'font-mono text-xs font-bold text-white/80 text-center', + ), + ), + ], + ); + } +} + +class _StretchDemo extends StatelessWidget { + final String label; + final String containerClass; + final bool showItemsStart; + + const _StretchDemo({ + required this.label, + required this.containerClass, + required this.showItemsStart, + }); + + @override + Widget build(BuildContext context) { + return WDiv( + className: 'flex flex-col gap-2', + children: [ + WText( + label, + className: 'text-xs font-mono text-slate-500 dark:text-slate-400', + ), + WDiv( + className: ''' + $containerClass p-2 rounded-lg + bg-slate-100 dark:bg-slate-700 + ''', + children: [ + WDiv( + className: ''' + px-3 py-2 rounded + bg-orange-400 dark:bg-orange-600 + ''', + child: WText( + 'Nav bar', + className: 'text-white font-semibold text-sm', + ), + ), + WDiv( + className: ''' + px-3 py-2 rounded + bg-orange-300 dark:bg-orange-500 + ''', + child: WText( + 'Content card', + className: 'text-white text-sm', + ), + ), + ], + ), + ], + ); + } +} diff --git a/lib/src/parser/parsers/flexbox_grid_parser.dart b/lib/src/parser/parsers/flexbox_grid_parser.dart index 7861ffc..9ed7cba 100644 --- a/lib/src/parser/parsers/flexbox_grid_parser.dart +++ b/lib/src/parser/parsers/flexbox_grid_parser.dart @@ -17,7 +17,8 @@ import 'wind_parser_interface.dart'; /// - **Align Items:** `items-start`, `items-end`, `items-center`, `items-baseline`, `items-stretch` /// - **Align Content (Wrap only):** `align-content-start`, `align-content-end`, `align-content-center`, `align-content-between`, `align-content-around`, `align-content-evenly`, `align-content-stretch` /// - **Gap & Spacing:** `gap-*`, `gap-x-*`, `gap-y-*`, `space-x-*`, `space-y-*` (Supports theme values and arbitrary `-[...]`) -/// - **Flex:** `flex-*` (numeric), `flex-1`, `flex-grow`, `flex-auto`, `flex-initial`, `flex-none` +/// - **Flex:** `flex-*` (numeric), `flex-1`, `flex-grow`, `grow`, `grow-0`, `flex-auto`, `flex-initial`, `flex-none` +/// - **Flex Basis:** `basis-1/2`, `basis-1/3`, `basis-1/4`, `basis-full`, `basis-[Npx]` (approximates CSS `flex-basis`: initial MAIN-axis size, ignores grow/shrink interplay) /// - **Flex Sizing:** `shrink`, `shrink-0`, `flex-shrink` /// - **Align Self:** `align-self-start`, `align-self-end`, `align-self-center`, `align-self-stretch`, `align-self-auto` /// plus the Tailwind shorthand `self-start`, `self-end`, `self-center`, `self-stretch`, `self-auto` @@ -49,6 +50,18 @@ class FlexboxGridParser implements WindParserInterface { /// Matches theme-based grid-cols classes (e.g., `grid-cols-3`) static final RegExp _gridColsRegex = RegExp(r'^grid-cols-(?[0-9]+)$'); + /// Matches fractional / full flex-basis classes (e.g., `basis-1/2`, + /// `basis-1/3`, `basis-full`). The value is the MAIN-axis basis factor. + static final RegExp _basisFactorRegex = RegExp( + r'^basis-(?[0-9]+/[0-9]+|full)$', + ); + + /// Matches arbitrary fixed flex-basis classes (e.g., `basis-[120px]`). The + /// value is a fixed MAIN-axis size in logical pixels. + static final RegExp _basisArbitraryRegex = RegExp( + r'^basis-\[(?[0-9.]+)(?:px)?\]$', + ); + // --- Static Lookup Maps (for performance) --- /// Maps display classes to `WindDisplayType` @@ -114,20 +127,23 @@ class FlexboxGridParser implements WindParserInterface { static const _flexMap = { 'flex-1': 1, 'flex-grow': 1, // 'flex-grow' is treated as flex: 1 + 'grow': 1, // Tailwind `grow` is the shorthand for `flex-grow` (flex: 1) }; /// Maps flex child properties to `FlexFit` static const _flexFitMap = { 'shrink': FlexFit.loose, // flex-shrink: 1 (can shrink) - // `shrink-0` is intentionally absent: it must NOT set a flexFit. A non-null - // flexFit makes the widget self-wrap in `Flexible(fit: ...)`, which would - // force a fill (the opposite of shrink-0) and assert outside a Flex. The - // "do not shrink, keep intrinsic size" behavior is delivered by - // `WDiv._hasShrinkZero`, which reads the className and skips the wrap. + // `shrink-0` and `flex-none` are intentionally absent: they must NOT set a + // flexFit. A non-null flexFit makes the widget self-wrap in + // `Flexible(fit: ...)`, which would force a fill (the opposite of a + // no-shrink token) and assert outside a Flex. CSS `flex-none` is + // `flex: 0 0 auto` (no grow AND no shrink), so it shares the no-shrink + // path with `shrink-0`: the "keep intrinsic size, do not shrink" behavior + // is delivered by `WDiv._hasShrinkZero`, which reads the className and + // skips the wrap. 'flex-auto': FlexFit.loose, 'flex-initial': FlexFit.loose, 'flex-shrink': FlexFit.loose, - 'flex-none': FlexFit.loose, }; /// Maps align-self child properties to `Alignment` @@ -166,6 +182,14 @@ class FlexboxGridParser implements WindParserInterface { MainAxisSize? mainAxisSize; int? flex; FlexFit? flexFit; + // The flex/flexFit slots can win last-class-wins with a NULL (intrinsic) + // value via no-grow/no-shrink tokens (`grow-0`, `shrink-0`, `flex-none`), + // so a plain `flex == null` guard cannot tell "unset" from "explicitly + // reset". Track resolution separately. + bool flexResolved = false; + bool flexFitResolved = false; + double? basisFactor; + double? basisSize; Alignment? alignment; TextBaseline? textBaseline; int? gridCols; @@ -211,10 +235,27 @@ class FlexboxGridParser implements WindParserInterface { } else if (mainAxisSize == null && _mainAxisSizeMap.containsKey(className)) { mainAxisSize = _mainAxisSizeMap[className]; - } else if (flex == null && _flexMap.containsKey(className)) { + } else if ((!flexResolved || !flexFitResolved) && + (className == 'grow-0' || + className == 'shrink-0' || + className == 'flex-none')) { + // No-grow / no-shrink tokens take part in last-class-wins by CLAIMING + // the relevant flex slot with a null (intrinsic) value, so an earlier + // `grow`/`flex-grow`/`flex-N` or `shrink`/`flex-auto` cannot re-enable + // an Expanded/Flexible wrap. CSS mapping: `grow-0` = flex-grow:0, + // `shrink-0` = flex-shrink:0, `flex-none` = flex:0 0 auto (both). + if (className == 'grow-0' || className == 'flex-none') { + flexResolved = true; + } + if (className == 'shrink-0' || className == 'flex-none') { + flexFitResolved = true; + } + } else if (!flexResolved && _flexMap.containsKey(className)) { flex = _flexMap[className]; - } else if (flexFit == null && _flexFitMap.containsKey(className)) { + flexResolved = true; + } else if (!flexFitResolved && _flexFitMap.containsKey(className)) { flexFit = _flexFitMap[className]; + flexFitResolved = true; } else if (alignment == null && _alignSelfMap.containsKey(className)) { alignment = _alignSelfMap[className]; } else if (alignment == null && className.startsWith('self-')) { @@ -258,10 +299,11 @@ class FlexboxGridParser implements WindParserInterface { } } // `flex-2`, `flex-3` etc. - if (flex == null) { + if (!flexResolved) { final match = _flexValueRegex.firstMatch(className); if (match != null) { flex = int.tryParse(match.namedGroup('value')!); + flexResolved = true; } } // `grid-cols-3`, `grid-cols-6` etc. @@ -271,6 +313,31 @@ class FlexboxGridParser implements WindParserInterface { gridCols = int.tryParse(match.namedGroup('value')!); } else {} } + // `basis-1/2`, `basis-full` (fractional) and `basis-[120px]` (fixed). + // Arbitrary precedes the theme/fraction form per the parser convention. + if (basisFactor == null && basisSize == null) { + final arbitraryMatch = _basisArbitraryRegex.firstMatch(className); + if (arbitraryMatch != null) { + basisSize = double.tryParse(arbitraryMatch.namedGroup('value')!); + } else { + final match = _basisFactorRegex.firstMatch(className); + if (match != null) { + final value = match.namedGroup('value')!; + if (value == 'full') { + basisFactor = 1.0; + } else { + final parts = value.split('/'); + final numerator = double.tryParse(parts[0]); + final denominator = double.tryParse(parts[1]); + if (numerator != null && + denominator != null && + denominator != 0) { + basisFactor = numerator / denominator; + } + } + } + } + } } final bool didChange = displayType != null || @@ -284,6 +351,8 @@ class FlexboxGridParser implements WindParserInterface { mainAxisSize != null || flex != null || flexFit != null || + basisFactor != null || + basisSize != null || alignment != null || textBaseline != null || gridCols != null || @@ -306,6 +375,8 @@ class FlexboxGridParser implements WindParserInterface { mainAxisSize: mainAxisSize, flex: flex, flexFit: flexFit, + basisFactor: basisFactor, + basisSize: basisSize, alignment: alignment, textBaseline: textBaseline, gridCols: gridCols, @@ -324,6 +395,9 @@ class FlexboxGridParser implements WindParserInterface { className == 'hidden' || className == 'shrink' || className == 'shrink-0' || + className == 'grow' || + className == 'grow-0' || + className.startsWith('basis-') || className.startsWith('flex-') || className.startsWith('grid-') || className.startsWith('justify-') || diff --git a/lib/src/parser/wind_style.dart b/lib/src/parser/wind_style.dart index 9eb89a3..ace0ed2 100644 --- a/lib/src/parser/wind_style.dart +++ b/lib/src/parser/wind_style.dart @@ -88,6 +88,18 @@ class WindStyle { /// Flex fit for flex display type e.g., flex-auto, flex-none final FlexFit? flexFit; + /// Fractional flex-basis along the parent's MAIN axis e.g., `basis-1/2`, + /// `basis-full`. Resolved by the parent flex (`WDiv`) into a width factor + /// (row) or height factor (column). This approximates CSS `flex-basis`: it + /// sets the initial main-axis size and ignores grow/shrink interplay. + final double? basisFactor; + + /// Fixed flex-basis along the parent's MAIN axis e.g., `basis-[120px]`. + /// Resolved by the parent flex (`WDiv`) into a fixed width (row) or height + /// (column). Like [basisFactor], this approximates CSS `flex-basis` and + /// ignores grow/shrink interplay. + final double? basisSize; + /// Alignment for flex or grid display type e.g., align-self-center, align-self-start final Alignment? alignment; @@ -263,6 +275,8 @@ class WindStyle { this.mainAxisSize, this.flex, this.flexFit, + this.basisFactor, + this.basisSize, this.alignment, this.textBaseline, this.gridCols, @@ -334,6 +348,8 @@ class WindStyle { MainAxisSize? mainAxisSize, int? flex, FlexFit? flexFit, + double? basisFactor, + double? basisSize, Alignment? alignment, TextBaseline? textBaseline, int? gridCols, @@ -422,6 +438,8 @@ class WindStyle { mainAxisSize: mainAxisSize ?? this.mainAxisSize, flex: flex ?? this.flex, flexFit: flexFit ?? this.flexFit, + basisFactor: basisFactor ?? this.basisFactor, + basisSize: basisSize ?? this.basisSize, alignment: alignment ?? this.alignment, textBaseline: textBaseline ?? this.textBaseline, gridCols: gridCols ?? this.gridCols, @@ -499,6 +517,8 @@ class WindStyle { mainAxisSize == other.mainAxisSize && flex == other.flex && flexFit == other.flexFit && + basisFactor == other.basisFactor && + basisSize == other.basisSize && alignment == other.alignment && textBaseline == other.textBaseline && gridCols == other.gridCols && @@ -570,6 +590,8 @@ class WindStyle { mainAxisSize.hashCode ^ flex.hashCode ^ flexFit.hashCode ^ + basisFactor.hashCode ^ + basisSize.hashCode ^ alignment.hashCode ^ textBaseline.hashCode ^ gridCols.hashCode ^ @@ -679,6 +701,8 @@ class WindStyle { 'mainAxisSize: $mainAxisSize, ' 'flex: $flex, ' 'flexFit: $flexFit, ' + 'basisFactor: $basisFactor, ' + 'basisSize: $basisSize, ' 'alignment: $alignment, ' 'textBaseline: $textBaseline, ' 'gridCols: $gridCols, ' diff --git a/lib/src/widgets/w_div.dart b/lib/src/widgets/w_div.dart index 39f4b6b..5ba860d 100644 --- a/lib/src/widgets/w_div.dart +++ b/lib/src/widgets/w_div.dart @@ -521,21 +521,120 @@ class WDiv extends StatelessWidget { "${isColumn ? 'Column' : 'Row'}(children: [${children!.length} items])", ); + // `basis-*` resolves a fraction/fixed size against the flex's own bounded + // main extent. Flex hands non-flex children an unbounded main-axis + // constraint, so a fractional box cannot self-size — we measure the extent + // with a LayoutBuilder around the whole flex and pass it down. Only taken + // when a child actually carries `basis-*`, so the common case is unwrapped. + final bool hasBasisChild = _anyChildHasBasis(gappedChildren); + if (hasBasisChild) { + return LayoutBuilder( + builder: (context, constraints) { + final double mainExtent = + isColumn ? constraints.maxHeight : constraints.maxWidth; + final resolvedChildren = _applyMainAxisBasis( + gappedChildren, + context, + isColumn: isColumn, + mainExtent: mainExtent, + ); + return _composeFlex( + styles: styles, + isColumn: isColumn, + basisChildren: resolvedChildren, + effectiveMainAxisSize: effectiveMainAxisSize, + isMainAxisScrollable: isMainAxisScrollable, + hasOverflowClip: hasOverflowClip, + needsSpaceDistribution: needsSpaceDistribution, + context: context, + ); + }, + ); + } + + return _composeFlex( + styles: styles, + isColumn: isColumn, + basisChildren: gappedChildren, + effectiveMainAxisSize: effectiveMainAxisSize, + isMainAxisScrollable: isMainAxisScrollable, + hasOverflowClip: hasOverflowClip, + needsSpaceDistribution: needsSpaceDistribution, + context: context, + ); + } + + /// Builds the final `Row`/`Column` from `basis-*`-resolved children, applying + /// the column smart cross-axis stretch or the row `Flexible` shrink wrap. + /// Split out of [_buildFlexStructure] so it can run either directly or inside + /// the `basis-*` `LayoutBuilder`. + Widget _composeFlex({ + required WindStyle styles, + required bool isColumn, + required List basisChildren, + required MainAxisSize effectiveMainAxisSize, + required bool isMainAxisScrollable, + required bool hasOverflowClip, + required bool needsSpaceDistribution, + required BuildContext context, + }) { if (isColumn) { - return WindFlexOverflowScope( - skipExpanded: isMainAxisScrollable, - child: Column( - mainAxisAlignment: - styles.mainAxisAlignment ?? MainAxisAlignment.start, - crossAxisAlignment: - styles.crossAxisAlignment ?? CrossAxisAlignment.start, - mainAxisSize: effectiveMainAxisSize, - textBaseline: styles.textBaseline, - verticalDirection: styles.flexReverse - ? VerticalDirection.up - : VerticalDirection.down, - children: gappedChildren, - ), + // Smart cross-axis stretch (column-only): with no explicit `items-*` + // token, each Wind child that does not control its own width is wrapped + // in `SizedBox(width: infinity)` so it fills the column width (CSS + // `align-items: stretch` default). `crossAxisAlignment` stays `start`, + // so an explicit `items-*` disables this path entirely. + Widget buildColumn(bool stretch) { + final List columnChildren; + if (stretch) { + columnChildren = basisChildren.map((child) { + // Gaps and pre-wrapped flex widgets are never stretched. + if (child is SizedBox || child is Flexible || child is Expanded) { + return child; + } + if (!_shouldStretchColumnChild(child)) return child; + return SizedBox(width: double.infinity, child: child); + }).toList(); + } else { + columnChildren = basisChildren; + } + + return WindFlexOverflowScope( + skipExpanded: isMainAxisScrollable, + child: Column( + mainAxisAlignment: + styles.mainAxisAlignment ?? MainAxisAlignment.start, + crossAxisAlignment: + styles.crossAxisAlignment ?? CrossAxisAlignment.start, + mainAxisSize: effectiveMainAxisSize, + textBaseline: styles.textBaseline, + verticalDirection: styles.flexReverse + ? VerticalDirection.up + : VerticalDirection.down, + children: columnChildren, + ), + ); + } + + // The stretch wrap forces a tight infinite width, which THROWS under an + // unbounded-width constraint (a bare Row main-axis slot, UnconstrainedBox, + // horizontal scroll). Gate it on a finite incoming maxWidth via a + // LayoutBuilder, but ONLY when a stretch-eligible child exists so columns + // that cannot stretch pay no LayoutBuilder cost. An unbounded-width + // column degrades to content-sized children (the pre-stretch behavior) + // instead of crashing. + final bool hasStretchTarget = styles.crossAxisAlignment == null && + basisChildren.any((child) => + child is! SizedBox && + child is! Flexible && + child is! Expanded && + _shouldStretchColumnChild(child)); + if (!hasStretchTarget) { + return buildColumn(false); + } + return LayoutBuilder( + builder: (context, constraints) => + buildColumn(constraints.maxWidth.isFinite), ); } else { // For Row with space distribution OR overflow-hidden, wrap children with Flexible @@ -544,16 +643,22 @@ class WDiv extends StatelessWidget { final needsFlexible = (needsSpaceDistribution || hasOverflowClip) && !isMainAxisScrollable; final rowChildren = needsFlexible - ? gappedChildren.map((child) { - // Don't wrap SizedBox gaps or already-flex widgets with Flexible - if (child is SizedBox || child is Flexible || child is Expanded) { + ? basisChildren.map((child) { + // Don't wrap gaps, already-flex widgets, or basis-sized children + // (FractionallySizedBox/SizedBox carry an explicit main size). + if (child is SizedBox || + child is FractionallySizedBox || + child is Flexible || + child is Expanded) { return child; } - // Don't wrap WDiv/WText with flex-N classes (they become Expanded) - if (child is WDiv && _hasFlexClass(child.className)) { + // Don't wrap children that self-wrap in Expanded/Flexible + // (flex-N, grow, flex-grow, flex-auto, flex-initial, shrink, + // flex-shrink) — wrapping them again asserts ParentDataWidget. + if (child is WDiv && _selfWrapsInFlex(child.className)) { return child; } - if (child is WText && _hasFlexClass(child.className)) { + if (child is WText && _selfWrapsInFlex(child.className)) { return child; } // Skip shrink-0 children (should not shrink — keep intrinsic size) @@ -565,7 +670,7 @@ class WDiv extends StatelessWidget { } return Flexible(child: child); }).toList() - : gappedChildren; + : basisChildren; TextDirection? rowTextDirection; if (styles.flexReverse) { @@ -611,28 +716,176 @@ class WDiv extends StatelessWidget { styles.overflowX == WindOverflow.scroll; } - /// Checks if a className contains shrink-0 token that should preserve - /// intrinsic size. Uses token-based matching to avoid false positives - /// from substring matches (e.g. a hypothetical `no-shrink-0`). - /// Matches both bare `shrink-0` and prefixed variants like `md:shrink-0`. + /// Checks if a className contains a no-shrink token that should preserve + /// intrinsic size: `shrink-0` or `flex-none` (CSS `flex: 0 0 auto`, no grow + /// AND no shrink). Uses token-based matching to avoid false positives from + /// substring matches (e.g. a hypothetical `no-shrink-0`). Matches both bare + /// 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(' ')) { - if (token == 'shrink-0' || token.endsWith(':shrink-0')) { + if (token == 'shrink-0' || + token.endsWith(':shrink-0') || + token == 'flex-none' || + token.endsWith(':flex-none')) { return true; } } return false; } - /// Checks if a className contains flex-N classes that produce Expanded widgets - static bool _hasFlexClass(String? className) { - if (className == null) return false; - return className.contains('flex-1') || - className.contains('flex-2') || - className.contains('flex-3') || - className.contains('flex-4') || - className.contains('flex-5'); + /// Resolves a child's `basis-*` main-axis size by parsing its className + /// (cache-hit when the probe states match the child's own build). Returns + /// `null` for non-Wind children and Wind children without a `basis-*` token. + /// + /// `basisFactor` (e.g. `basis-1/2`, `basis-full`) is a fraction of the + /// parent's main-axis extent; `basisSize` (e.g. `basis-[120px]`) is a fixed + /// logical-pixel main-axis size. This approximates CSS `flex-basis`: it sets + /// the initial main-axis size and ignores grow/shrink interplay. + static ({double? factor, double? size})? _resolveChildBasis( + Widget child, + BuildContext context, + ) { + final className = _extractChildClassName(child); + if (className == null || className.isEmpty) return null; + if (!className.contains('basis-')) return null; + final states = _extractChildStates(child); + final styles = WindParser.parse(className, context, states: states); + if (styles.basisFactor == null && styles.basisSize == null) return null; + return (factor: styles.basisFactor, size: styles.basisSize); + } + + /// Whether any direct flex child carries a `basis-*` token. Cheap pre-check + /// (substring) so the common no-basis case skips the LayoutBuilder wrap. + static bool _anyChildHasBasis(List children) { + for (final child in children) { + final className = _extractChildClassName(child); + if (className != null && className.contains('basis-')) return true; + } + return false; + } + + /// Applies `basis-*` main-axis sizing to direct flex children. Each + /// basis-bearing Wind child is wrapped in a fixed-size `SizedBox` along the + /// flex main axis: width for a row, height for a column. + /// + /// A `FractionallySizedBox` cannot be used here because flex hands non-flex + /// children an UNBOUNDED main-axis constraint, which makes a fractional box + /// throw. Instead the caller measures the flex's own bounded main extent via + /// a `LayoutBuilder` and passes it as [mainExtent]; the fraction resolves to + /// a concrete pixel size. When [mainExtent] is not finite (unbounded flex), + /// fractional basis degrades to the child's intrinsic size (passthrough); + /// fixed `basis-[Npx]` always applies. + static List _applyMainAxisBasis( + List children, + BuildContext context, { + required bool isColumn, + required double mainExtent, + }) { + return children.map((child) { + final basis = _resolveChildBasis(child, context); + if (basis == null) return child; + + double? size; + if (basis.size != null) { + size = basis.size; + } else if (basis.factor != null && mainExtent.isFinite) { + size = mainExtent * basis.factor!; + } + if (size == null) return child; + + return SizedBox( + width: isColumn ? null : size, + height: isColumn ? size : null, + child: child, + ); + }).toList(); + } + + /// Whether a className declares an explicit cross-axis width (`w-*` including + /// `w-full`, `min-w-*`, or `max-w-*`) in ANY state or breakpoint variant, so + /// a child that controls its own width is never re-wrapped in a stretch + /// `SizedBox`. Prefix-agnostic token scan: `hover:w-32` and `md:max-w-sm` + /// count too, because a state-conditional width must still disable the + /// 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(' ')) { + if (raw.isEmpty) continue; + final token = raw.contains(':') ? raw.split(':').last : raw; + if (token.startsWith('w-') || + token.startsWith('min-w-') || + token.startsWith('max-w-')) { + return true; + } + } + return false; + } + + /// Whether a column child qualifies for smart cross-axis stretch: it must be + /// a `WDiv`, must NOT already control its cross-axis width, must NOT self-wrap + /// in `Expanded`/`Flexible`, and must NOT be absolute-positioned. Gaps and + /// pre-wrapped flex widgets are filtered out by the caller before this runs. + /// + /// No-shrink tokens (`shrink-0`, `flex-none`) are intentionally NOT excluded: + /// `flex-shrink` governs the MAIN axis, while stretch is a CROSS-axis concern, + /// and CSS `align-items: stretch` does fill a `flex: none` item's cross size + /// when it has no explicit width. + /// + /// `WText` is deliberately excluded: a text leaf carries no cross-axis box of + /// its own, so a full-width wrap changes the widget tree without a visual + /// effect, while breaking trees that locate sibling `SizedBox`es positionally + /// (e.g. a `WSpacer` next to bare `WText`). Container children (`WDiv`) are + /// the meaningful stretch targets. + static bool _shouldStretchColumnChild(Widget child) { + final String? className; + if (child is WDiv) { + className = child.className; + } else { + // Non-WDiv children (raw Flutter widgets, WText leaves) are untouched. + return false; + } + + if (_selfWrapsInFlex(className)) return false; + if (_hasExplicitCrossWidth(className)) return false; + if (className != null && + className.isNotEmpty && + className.contains('absolute')) { + return false; + } + return true; + } + + /// Matches a numeric flex token (`flex-1`, `flex-2`, ...) after any prefix + /// has been stripped. + static final RegExp _numericFlexRegex = RegExp(r'^flex-[0-9]+$'); + + /// 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 + /// wrapped again by a parent (`Flexible` in a Row, `SizedBox(width: infinity)` + /// stretch in a Column) or Flutter throws "Incorrect use of ParentDataWidget". + /// + /// Prefix-agnostic token scan so state/breakpoint variants like `md:grow` or + /// `hover:flex-1` are caught too. `grow-0`, `shrink-0`, and `flex-none` are + /// deliberately NOT self-wrapping (they keep intrinsic main size without a + /// `Flexible`), so they are absent here. + static bool _selfWrapsInFlex(String? className) { + if (className == null || className.isEmpty) return false; + for (final raw in className.split(' ')) { + if (raw.isEmpty) continue; + final token = raw.contains(':') ? raw.split(':').last : raw; + if (token == 'grow' || + token == 'flex-grow' || + token == 'shrink' || + token == 'flex-shrink' || + token == 'flex-auto' || + token == 'flex-initial' || + _numericFlexRegex.hasMatch(token)) { + return true; + } + } + return false; } /// Extracts `className` from any Wind widget via dynamic access. diff --git a/lib/src/widgets/w_text.dart b/lib/src/widgets/w_text.dart index 0e83b1c..6102e62 100644 --- a/lib/src/widgets/w_text.dart +++ b/lib/src/widgets/w_text.dart @@ -154,19 +154,23 @@ class WText extends StatelessWidget { /// /// Constructs the Text or SelectableText widget and applies /// all typography-related styles (color, font, align, transform). + /// + /// Guarantees two baseline requirements when no Material/Scaffold ancestor + /// is present so Flutter's debug yellow-underline fallback never appears: + /// 1. A non-null text color (brightness-aware fallback: [Colors.white] on + /// dark platforms, [Colors.black] on light). + /// 2. A [Directionality] ancestor (defaults to [TextDirection.ltr]). Widget _buildCoreContent({ required BuildContext context, required WindStyle styles, required WindLogger logger, }) { - // A. Build TextStyle - // Use the helper from WindStyle. This style will contain `null` for - // properties not set in the className. + // A. Build TextStyle from className tokens. + // This style contains `null` for properties not set in the className. final TextStyle windTextStyle = styles.toTextStyle(); - // Merge with explicit `textStyle` prop. - // Note: We do NOT manually merge with DefaultTextStyle here. - // The `Text` widget does that automatically for null properties. + // Merge with the explicit `textStyle` prop so callers can supply + // additional Flutter-native properties (e.g. letterSpacing). TextStyle finalTextStyle = windTextStyle.merge(textStyle); // Inline foregroundColor wins over any parsed text-* / dark:text-*. @@ -174,6 +178,20 @@ class WText extends StatelessWidget { finalTextStyle = finalTextStyle.copyWith(color: foregroundColor); } + // Baseline color guarantee: when no color was resolved from className, + // foregroundColor, or textStyle, apply a neutral fallback so the widget + // does not inherit Flutter's debug yellow-underline appearance when there + // is no Material ancestor above us. The fallback follows the ambient + // platform brightness (white on dark, black on light) so bare text stays + // legible in both modes. Explicitly supplied colors always win. + if (finalTextStyle.color == null) { + final isDark = + MediaQuery.maybePlatformBrightnessOf(context) == Brightness.dark; + finalTextStyle = finalTextStyle.copyWith( + color: isDark ? Colors.white : Colors.black, + ); + } + // B. Apply Text Transformation (uppercase, lowercase) final String transformedData = _applyTextTransform( data, @@ -185,9 +203,10 @@ class WText extends StatelessWidget { final bool isSelectable = selectable || (className?.contains('selectable') ?? false); + Widget textWidget; if (isSelectable) { logger.setCoreWidget("SelectableText('$transformedData')"); - return SelectableText( + textWidget = SelectableText( transformedData, style: finalTextStyle, textAlign: styles.textAlign, @@ -196,7 +215,7 @@ class WText extends StatelessWidget { ); } else { logger.setCoreWidget("Text('$transformedData')"); - return Text( + textWidget = Text( transformedData, style: finalTextStyle, textAlign: styles.textAlign, @@ -205,6 +224,18 @@ class WText extends StatelessWidget { softWrap: styles.softWrap, ); } + + // D. Directionality guarantee: provide a default TextDirection.ltr when + // no ancestor supplies one, so bare usages outside MaterialApp/WidgetsApp + // do not throw "No Directionality widget found". + if (Directionality.maybeOf(context) == null) { + return Directionality( + textDirection: TextDirection.ltr, + child: textWidget, + ); + } + + return textWidget; } /// (HELPER 2) Composition Pipeline diff --git a/skills/wind-ui/SKILL.md b/skills/wind-ui/SKILL.md index e37eefe..b586d7e 100644 --- a/skills/wind-ui/SKILL.md +++ b/skills/wind-ui/SKILL.md @@ -3,10 +3,10 @@ name: wind-ui description: "fluttersdk_wind 1.0: utility-first Flutter styling with Tailwind-syntax className strings. 22 public widgets (WDiv, WText, WButton, WInput, WSelect, WCheckbox, WDatePicker, WPopover, WAnchor, WIcon, WImage, WSvg, WSpacer, WBreakpoint, WDynamic, WKeyboardActions, WindAnimationWrapper + 5 WForm* wrappers) consume className through a 19-parser pipeline that emits a cached immutable WindStyle. Prefixes stack freely (dark: / hover: / focus: / md: / lg: / ios: / android: / web: / mobile: / selected: / loading: / disabled: / error: / checked: / custom). Last class wins; unknown tokens fail silently. Every color token (bg-, text-, border-, ring-, shadow-, fill-) needs a dark: pair in the same className. TRIGGER when: writing or editing any UI in a Flutter app that depends on `fluttersdk_wind`; any className string; any W-prefix widget; any WindTheme / WindThemeData reference; the user mentions Tailwind for Flutter, utility-first, className, or wind-ui. DO NOT TRIGGER when: backend / API / state-management work that does not touch a widget tree; Flutter projects that do not have fluttersdk_wind in pubspec.yaml; Material-only widgets (Scaffold, AppBar, Dialog) without Wind content inside them." when_to_use: | Any task that produces, modifies, or audits Wind-styled Flutter UI: composing a className string, picking the right W-widget for a use case, integrating with a Form / FormField, customizing WindThemeData, wiring dark-mode pairs, debugging an unexpected layout, recovering from RenderFlex overflow, building a popover or dropdown, rendering a JSON tree via WDynamic, wiring Wind.installDebugResolver for kDebugMode tooling, or migrating a Tailwind className from web. Apply BEFORE writing the first line of UI in a Wind-using file, not as an audit pass. -version: 2.0.3 +version: 2.0.4 --- - + # Wind UI 1.0 diff --git a/skills/wind-ui/references/layouts.md b/skills/wind-ui/references/layouts.md index 07b3117..9983954 100644 --- a/skills/wind-ui/references/layouts.md +++ b/skills/wind-ui/references/layouts.md @@ -28,6 +28,8 @@ Two consequences drive almost every Wind layout footgun: 2. **Scrollables remove the max on their axis.** A `SingleChildScrollView` (Wind's `overflow-y-auto`) passes `maxHeight: double.infinity` to its child. A child that asserts on finite height (`Column` with `Expanded` children) throws "Vertical viewport was given unbounded height". +3. **`flex flex-col` stretches `WDiv` children to the column width by default.** With NO explicit `items-*` token, each direct `WDiv` child that does not control its own width is wrapped in `SizedBox(width: double.infinity)`, mirroring CSS `align-items: stretch`. Left untouched: children with an explicit width (`w-*` / `min-w-*` / `max-w-*` / `w-full`, in any state/breakpoint variant), children that self-wrap in `Expanded`/`Flexible` (`grow`, `flex-grow`, `flex-auto`, `flex-initial`, `shrink`, `flex-shrink`, `flex-N`), absolute children, and non-`WDiv` children (raw Flutter widgets, `WText`). `shrink-0` / `flex-none` children still stretch on the cross axis: `flex-shrink` is main-axis only, matching CSS. Add any `items-*` token (e.g. `items-start`) to disable the stretch and let children size to content. This is column-only; rows are never auto-stretched on the cross axis. + Memorize these and the rest follows. --- diff --git a/skills/wind-ui/references/tailwind-divergence.md b/skills/wind-ui/references/tailwind-divergence.md index 902e777..5a278f5 100644 --- a/skills/wind-ui/references/tailwind-divergence.md +++ b/skills/wind-ui/references/tailwind-divergence.md @@ -334,8 +334,10 @@ These canonical Tailwind classes are silently ignored by wind (unknown tokens ar | `-space-x-*` / `-space-y-*` | unsupported (negative gap; no overlap primitive) | none | | `self-*` | supported (alias of `align-self-*`) | `self-center` etc. work directly | | `shrink-0` | supported (preserves intrinsic size, no Flexible wrap) | works directly | +| `flex-none` | supported as CSS `flex: 0 0 auto` (no grow AND no shrink; keeps intrinsic size) | works directly | +| `basis-*` | supported as a MAIN-axis initial size (`basis-1/2`, `basis-full`, `basis-[Npx]`); ignores grow/shrink interplay | works directly | | `text-7xl` / `8xl` / `9xl` | silently capped (max is `text-6xl`) | `text-6xl` or arbitrary `text-[96px]` | -Reminder: a wind page needs a Material ancestor (a `Scaffold`) for `WText` to inherit a default text style; without one, Flutter renders the yellow-underline fallback. Real apps always have a `Scaffold`, so this only bites bare `WDiv > WText` route bodies. +Note: `WText` is self-contained regarding its baseline rendering. When no Material/Scaffold ancestor supplies a `DefaultTextStyle` color, `WText` applies a brightness-aware fallback (`Colors.white` on dark platforms, `Colors.black` on light, read from `MediaQuery.platformBrightness`) instead of Flutter's debug yellow-underline; it also injects a `Directionality(ltr)` wrapper when no `Directionality` is inherited. Explicit `text-*` className, `foregroundColor`, and `textStyle` props override the fallback and are unaffected. State-shadowing edge: a disabled outer `WAnchor` does NOT suppress a `hover:` class that a nested `WDiv` carries on itself. `WDiv` auto-wraps in its own non-disabled `WAnchor` whose state provider shadows the ancestor's `isDisabled`, so the inner `hover:` still fires. This is an edge case (a disabled control wrapping a separately-hover-styled child); style the disabled state on the same element that owns the `hover:` class, or drive `disabled:` on the inner `WDiv` directly. diff --git a/skills/wind-ui/references/tokens.md b/skills/wind-ui/references/tokens.md index b2bf808..4a99261 100644 --- a/skills/wind-ui/references/tokens.md +++ b/skills/wind-ui/references/tokens.md @@ -62,10 +62,14 @@ Inline color escape hatches that bypass the cache key: | `flex-1` | `Expanded(flex: 1)` participation | | `flex-N` | Numeric flex (any integer) | | `flex-auto` | `Flexible(fit: loose, flex: 1)` | -| `flex-none` / `flex-initial` | `Flexible(fit: loose, flex: 0)` | -| `flex-grow` / `grow` | Both supported (Tailwind v3 + v4 names) | +| `flex-initial` | `Flexible(fit: loose, flex: 0)` | +| `flex-none` | CSS `flex: 0 0 auto`: no grow AND no shrink. Keeps intrinsic size (no `Flexible` wrap), like `shrink-0` | +| `flex-grow` / `grow` | `flex: 1` (Expanded). Both Tailwind v3 + v4 names supported | +| `grow-0` | No grow (intrinsic main size) | | `flex-shrink` / `shrink` | Both supported | | `shrink-0` | No shrink | +| `basis-1/2` / `-1/3` / `-1/4` / `-full` | Fractional flex-basis: initial MAIN-axis size (width in a row, height in a column). Approximates CSS `flex-basis`, ignores grow/shrink interplay | +| `basis-[Npx]` | Fixed flex-basis: a fixed MAIN-axis size in logical pixels | | `justify-start` / `-end` / `-center` / `-between` / `-around` / `-evenly` | `MainAxisAlignment` | | `items-start` / `-end` / `-center` / `-baseline` / `-stretch` | `CrossAxisAlignment` | | `align-content-start` / `-end` / `-center` / `-between` / `-around` / `-evenly` / `-stretch` | Wrap-only, `WrapAlignment` for runs | diff --git a/test/flex/flex_self_wrap_test.dart b/test/flex/flex_self_wrap_test.dart new file mode 100644 index 0000000..1e05970 --- /dev/null +++ b/test/flex/flex_self_wrap_test.dart @@ -0,0 +1,192 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fluttersdk_wind/fluttersdk_wind.dart'; + +/// Regression suite for self-wrapping flex children. +/// +/// A child whose className resolves to `styles.flex` or `styles.flexFit` +/// self-wraps in `Expanded`/`Flexible` inside its own build. If a PARENT then +/// wraps it again (a stretch `SizedBox(width: infinity)` in a Column, a +/// `Flexible` in a space-distributing Row), Flutter throws "Incorrect use of +/// ParentDataWidget". These tests pin that the smart-stretch and Row-flexible +/// paths recognize every self-wrapping token (`grow`, `flex-grow`, `flex-auto`, +/// `flex-initial`, `shrink`, `flex-shrink`, `flex-N`), including prefixed +/// variants, and skip re-wrapping them. +/// +/// They also pin the "last class wins" reset behavior of the no-grow/no-shrink +/// tokens (`grow-0`, `shrink-0`, `flex-none`). + +Widget wrap(Widget child) => MaterialApp( + home: WindTheme( + data: WindThemeData(), + child: Scaffold(body: child), + ), + ); + +void main() { + setUp(WindParser.clearCache); + + group('Column smart-stretch does not re-wrap self-wrapping children', () { + for (final token in const [ + 'grow', + 'flex-grow', + 'flex-auto', + 'flex-initial', + 'shrink', + 'flex-shrink', + 'flex-1', + 'flex-3', + ]) { + testWidgets('column with a "$token" child renders without asserting', ( + tester, + ) async { + await tester.pumpWidget( + wrap( + WDiv( + className: 'flex flex-col', + children: [ + WDiv(className: token, child: const Text('a')), + const WDiv(className: 'bg-white', child: Text('b')), + ], + ), + ), + ); + + expect(tester.takeException(), isNull); + }); + } + + testWidgets('prefixed "md:grow" child is recognized and not re-wrapped', ( + tester, + ) async { + await tester.pumpWidget( + wrap( + WDiv( + className: 'flex flex-col', + children: [ + WDiv(className: 'md:grow', child: const Text('a')), + const WDiv(className: 'bg-white', child: Text('b')), + ], + ), + ), + ); + + expect(tester.takeException(), isNull); + }); + + testWidgets('no-shrink "flex-none" child still stretches cross-axis', ( + tester, + ) async { + tester.view.physicalSize = const Size(600, 800); + tester.view.devicePixelRatio = 1.0; + addTearDown(() { + tester.view.resetPhysicalSize(); + tester.view.resetDevicePixelRatio(); + }); + + await tester.pumpWidget( + wrap( + SizedBox( + width: 400, + child: WDiv( + className: 'flex flex-col', + children: [ + WDiv( + key: const ValueKey('none'), + className: 'flex-none bg-white', + child: const Text('a'), + ), + const WDiv(className: 'bg-gray-100', child: Text('b')), + ], + ), + ), + ), + ); + + expect(tester.takeException(), isNull); + // flex-none keeps its main (vertical) size but stretches to the column's + // 400 px cross width, matching CSS align-items: stretch. + final width = tester.getSize(find.byKey(const ValueKey('none'))).width; + expect(width, moreOrLessEquals(400.0, epsilon: 0.5)); + }); + }); + + group('Row space-distribution does not re-wrap self-wrapping children', () { + for (final token in const ['grow', 'flex-auto', 'flex-1']) { + testWidgets('justify-between row with a "$token" child does not assert', ( + tester, + ) async { + await tester.pumpWidget( + wrap( + WDiv( + className: 'flex flex-row justify-between', + children: [ + WDiv(className: token, child: const Text('a')), + const WDiv(className: 'bg-white', child: Text('b')), + ], + ), + ), + ); + + expect(tester.takeException(), isNull); + }); + } + }); + + group('No-grow / no-shrink tokens win last-class-wins', () { + late BuildContext ctx; + + Future pumpCtx(WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: WindTheme( + data: WindThemeData(), + child: Builder( + builder: (context) { + ctx = context; + return const SizedBox(); + }, + ), + ), + ), + ); + } + + testWidgets('grow-0 after grow cancels the grow (flex stays null)', ( + tester, + ) async { + await pumpCtx(tester); + expect(WindParser.parse('grow grow-0', ctx).flex, isNull); + }); + + testWidgets('flex-none after flex-auto cancels the fit (flexFit null)', ( + tester, + ) async { + await pumpCtx(tester); + expect(WindParser.parse('flex-auto flex-none', ctx).flexFit, isNull); + }); + + testWidgets('shrink-0 after shrink cancels the fit (flexFit null)', ( + tester, + ) async { + await pumpCtx(tester); + expect(WindParser.parse('shrink shrink-0', ctx).flexFit, isNull); + }); + + testWidgets('grow after grow-0 wins (rightmost grow re-enables flex)', ( + tester, + ) async { + await pumpCtx(tester); + expect(WindParser.parse('grow-0 grow', ctx).flex, 1); + }); + + testWidgets('flex-none cancels both grow and shrink in one token', ( + tester, + ) async { + await pumpCtx(tester); + final styles = WindParser.parse('grow shrink flex-none', ctx); + expect(styles.flex, isNull); + expect(styles.flexFit, isNull); + }); + }); +} diff --git a/test/flex/flexbox_scenarios_test.dart b/test/flex/flexbox_scenarios_test.dart new file mode 100644 index 0000000..3f7831b --- /dev/null +++ b/test/flex/flexbox_scenarios_test.dart @@ -0,0 +1,554 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fluttersdk_wind/fluttersdk_wind.dart'; + +/// A-to-Z geometry suite for the 15 canonical Tailwind flexbox scenarios. +/// +/// Each test pumps a real Wind className tree and asserts the load-bearing +/// geometry that the scenario depends on: `flex-1`/`grow` children expand to +/// equal/filled width, `flex-none`/`shrink-0` children keep their intrinsic +/// size, `flex flex-col` children fill the cross axis (Wave-1 smart-stretch), +/// `items-center`/`justify-between` alignment, `flex-wrap` wraps onto multiple +/// runs, and `md:` responsive prefixes flip layout via `tester.view`. +/// +/// Recipes sourced from +/// `.ac/plans/flexbox-wtext-hardening/research/00-consolidated-findings.md`. +Widget wrapWithTheme(Widget child) { + return MaterialApp( + home: WindTheme( + data: WindThemeData(), + child: Scaffold(body: child), + ), + ); +} + +/// Returns the visible [Text] strings ordered by their on-screen position. +/// +/// Sorts by horizontal `dx` (default) or vertical `dy` so a test can assert the +/// final paint order of children regardless of widget-tree declaration order. +List visibleTextsInOrder( + WidgetTester tester, { + bool horizontal = true, +}) { + final texts = tester.widgetList(find.byType(Text)).toList(); + final entries = <({double pos, String text})>[]; + for (final t in texts) { + final finder = find.byWidget(t); + if (finder.evaluate().isEmpty) continue; + final box = tester.getTopLeft(finder); + entries.add((pos: horizontal ? box.dx : box.dy, text: t.data ?? '')); + } + entries.sort((a, b) => a.pos.compareTo(b.pos)); + return entries.map((e) => e.text).toList(); +} + +/// Width of the [RenderBox] that paints the given visible text. +double textWidth(WidgetTester tester, String text) { + return tester.renderObject(find.text(text)).size.width; +} + +void main() { + setUp(WindParser.clearCache); + + // 1 — Navbar: justify-between pins brand left + actions right; items-center + // vertically centers. Verifies the two end clusters sit at opposite edges. + group('1 navbar', () { + testWidgets('justify-between pins brand left and actions right', + (tester) async { + await tester.pumpWidget( + wrapWithTheme( + const SizedBox( + width: 600, + child: WDiv( + className: 'flex flex-row items-center justify-between px-4 h-16', + children: [ + WDiv(child: Text('Brand')), + WDiv( + className: 'flex flex-row gap-4', + children: [ + Text('Docs'), + Text('Login'), + ], + ), + ], + ), + ), + ), + ); + + expect(tester.takeException(), isNull); + // Brand hugs the left padding (px-4 = 16px); actions sit far right. + final brandLeft = tester.getTopLeft(find.text('Brand')).dx; + final loginRight = tester.getTopRight(find.text('Login')).dx; + expect(brandLeft, closeTo(16, 0.5)); + expect(loginRight, closeTo(600 - 16, 0.5)); + expect(visibleTextsInOrder(tester), ['Brand', 'Docs', 'Login']); + }); + }); + + // 2 — Media object: a fixed avatar (shrink-0) next to a flex-1 body. The body + // takes all remaining width; the avatar keeps its intrinsic size. + group('2 media object', () { + testWidgets('shrink-0 avatar keeps size, flex-1 body fills remainder', + (tester) async { + await tester.pumpWidget( + wrapWithTheme( + const SizedBox( + width: 400, + child: WDiv( + className: 'flex flex-row gap-4 items-start', + children: [ + WDiv( + className: 'shrink-0 w-12 h-12 bg-slate-300', + child: SizedBox(width: 48, height: 48), + ), + WDiv( + className: 'flex-1', + child: Text('Body copy that should fill the rest'), + ), + ], + ), + ), + ), + ); + + expect(tester.takeException(), isNull); + // Avatar stays 48px; body fills 400 - 48 - gap(16) = 336px. + expect(tester.getSize(find.byType(WDiv).at(1)).width, 48); + expect(tester.getSize(find.byType(WDiv).at(2)).width, closeTo(336, 0.5)); + }); + }); + + // 3 — Holy-grail sidebar: w-64 flex-none sidebar + flex-1 main. Sidebar keeps + // its fixed 256px; main consumes the rest. + group('3 holy-grail sidebar', () { + testWidgets('flex-none sidebar fixed, flex-1 main fills remainder', + (tester) async { + await tester.pumpWidget( + wrapWithTheme( + const SizedBox( + width: 800, + child: WDiv( + className: 'flex flex-row', + children: [ + WDiv( + className: 'flex-none w-64 bg-slate-100', + child: Text('Sidebar'), + ), + WDiv( + className: 'flex-1', + child: Text('Main content'), + ), + ], + ), + ), + ), + ); + + expect(tester.takeException(), isNull); + // w-64 = 64 * 4 = 256px sidebar; main = 800 - 256 = 544px. + expect(tester.getSize(find.byType(WDiv).at(1)).width, 256); + expect(tester.getSize(find.byType(WDiv).at(2)).width, closeTo(544, 0.5)); + }); + }); + + // 4 — Sticky footer: a flex-col with a flex-1 content region that pushes the + // footer to the bottom of a tall container. + group('4 sticky footer', () { + testWidgets('flex-1 content pushes footer to the bottom', (tester) async { + await tester.pumpWidget( + wrapWithTheme( + const SizedBox( + height: 500, + width: 300, + child: WDiv( + className: 'flex flex-col h-full', + children: [ + WDiv(child: Text('Header')), + WDiv( + className: 'flex-1', + child: Text('Content'), + ), + WDiv(child: Text('Footer')), + ], + ), + ), + ), + ); + + expect(tester.takeException(), isNull); + expect(visibleTextsInOrder(tester, horizontal: false), + ['Header', 'Content', 'Footer']); + // Header at top; footer pinned near the 500px bottom edge. + expect(tester.getTopLeft(find.text('Header')).dy, closeTo(0, 0.5)); + expect(tester.getBottomLeft(find.text('Footer')).dy, closeTo(500, 0.5)); + }); + }); + + // 5 — Centered card: items-center + justify-center centers a single child on + // both axes inside a bounded box. + group('5 centered card', () { + testWidgets('items-center + justify-center centers child both axes', + (tester) async { + await tester.pumpWidget( + wrapWithTheme( + const SizedBox( + width: 400, + height: 400, + child: WDiv( + className: 'flex flex-col items-center justify-center h-full', + children: [ + WDiv( + className: 'w-40 h-20 bg-white', + child: SizedBox(width: 160, height: 80), + ), + ], + ), + ), + ), + ); + + expect(tester.takeException(), isNull); + final card = find.byType(WDiv).at(1); + final topLeft = tester.getTopLeft(card); + // Horizontal center: (400 - 160) / 2 = 120; vertical: (400 - 80) / 2 = 160. + expect(topLeft.dx, closeTo(120, 0.5)); + expect(topLeft.dy, closeTo(160, 0.5)); + }); + }); + + // 6 — Responsive stack -> row: flex-col on mobile, md:flex-row on desktop. + // The two flex-1 panels stack vertically below md and split width at md. + group('6 responsive stack to row', () { + testWidgets('flex-col stacks below md, md:flex-row splits width at md', + (tester) async { + tester.view.physicalSize = const Size(400, 800); + tester.view.devicePixelRatio = 1.0; + addTearDown(() { + tester.view.resetPhysicalSize(); + tester.view.resetDevicePixelRatio(); + }); + + final tree = MaterialApp( + home: WindTheme( + data: WindThemeData(), + child: const Scaffold( + body: WDiv( + className: 'flex flex-col md:flex-row gap-4', + children: [ + WDiv(className: 'flex-1', child: Text('Panel A')), + WDiv(className: 'flex-1', child: Text('Panel B')), + ], + ), + ), + ), + ); + + // Mobile: stacked column, A above B. + await tester.pumpWidget(tree); + expect(tester.takeException(), isNull); + expect(find.byType(Column), findsOneWidget); + expect(visibleTextsInOrder(tester, horizontal: false), + ['Panel A', 'Panel B']); + + // Desktop: md:flex-row -> side by side, A left of B. + tester.view.physicalSize = const Size(1000, 800); + await tester.pumpAndSettle(); + expect(find.byType(Row), findsOneWidget); + expect(visibleTextsInOrder(tester), ['Panel A', 'Panel B']); + }); + }); + + // 7 — Card with pinned footer: flex-col card, flex-1 content fills, footer + // stays at the bottom. Each direct column child also fills the card width + // (Wave-1 smart-stretch, no w-full needed). + group('7 card pinned footer', () { + testWidgets('flex-col children fill width and footer is pinned', + (tester) async { + await tester.pumpWidget( + wrapWithTheme( + const SizedBox( + width: 320, + height: 400, + child: WDiv( + className: 'flex flex-col h-full', + children: [ + WDiv( + className: 'bg-slate-100', + child: Text('Card title'), + ), + WDiv( + className: 'flex-1', + child: Text('Card body'), + ), + WDiv( + className: 'bg-slate-200', + child: Text('Card footer'), + ), + ], + ), + ), + ), + ); + + expect(tester.takeException(), isNull); + // Smart-stretch: the non-flex title fills the full 320px card width + // WITHOUT a w-full token. + expect(tester.getSize(find.byType(WDiv).at(1)).width, 320); + expect( + tester.getBottomLeft(find.text('Card footer')).dy, closeTo(400, 0.5)); + }); + }); + + // 8 — Toolbar: items-center row with gap-2. Buttons keep intrinsic width and + // are laid left to right with the gap between them. + group('8 toolbar', () { + testWidgets('items-center row keeps buttons intrinsic and ordered', + (tester) async { + await tester.pumpWidget( + wrapWithTheme( + const SizedBox( + width: 500, + child: WDiv( + className: 'flex flex-row items-center gap-2 p-2', + children: [ + WDiv(className: 'px-3 py-1 bg-slate-200', child: Text('Cut')), + WDiv(className: 'px-3 py-1 bg-slate-200', child: Text('Copy')), + WDiv(className: 'px-3 py-1 bg-slate-200', child: Text('Paste')), + ], + ), + ), + ), + ); + + expect(tester.takeException(), isNull); + expect(visibleTextsInOrder(tester), ['Cut', 'Copy', 'Paste']); + // Toolbar buttons keep intrinsic width: none expands to the 500px width. + expect(textWidth(tester, 'Cut'), lessThan(200)); + }); + }); + + // 9 — Responsive wrap grid: `wrap` (Wind's only wrapping token; `flex-wrap` + // is a documented Core Law 6 no-op) with basis-1/3 grow cards. Three cards at + // ~1/3 width sit on the first run; the fourth wraps to a second run. + group('9 responsive wrap grid', () { + testWidgets('wrap basis-1/3 cards wrap onto a second run', (tester) async { + await tester.pumpWidget( + wrapWithTheme( + const SizedBox( + width: 600, + child: WDiv( + className: 'wrap gap-4', + children: [ + WDiv(className: 'w-44 h-20 bg-slate-100', child: Text('C1')), + WDiv(className: 'w-44 h-20 bg-slate-100', child: Text('C2')), + WDiv(className: 'w-44 h-20 bg-slate-100', child: Text('C3')), + WDiv(className: 'w-44 h-20 bg-slate-100', child: Text('C4')), + ], + ), + ), + ), + ); + + expect(tester.takeException(), isNull); + expect(find.byType(Wrap), findsOneWidget); + // The fourth card wraps below the first three (greater dy than C1). + final c1Top = tester.getTopLeft(find.text('C1')).dy; + final c4Top = tester.getTopLeft(find.text('C4')).dy; + expect(c4Top, greaterThan(c1Top)); + }); + }); + + // 10 — Form row: fixed-width label (w-24 shrink-0) + flex-1 input. Label keeps + // 96px; the input fills the remaining width. + group('10 form row', () { + testWidgets('shrink-0 label fixed, flex-1 input fills remainder', + (tester) async { + await tester.pumpWidget( + wrapWithTheme( + const SizedBox( + width: 400, + child: WDiv( + className: 'flex flex-row items-center gap-4', + children: [ + WDiv( + className: 'w-24 shrink-0', + child: Text('Email'), + ), + WDiv( + className: 'flex-1 h-10 bg-slate-100', + child: SizedBox(height: 40), + ), + ], + ), + ), + ), + ); + + expect(tester.takeException(), isNull); + // w-24 = 96px label; input = 400 - 96 - gap(16) = 288px. + expect(tester.getSize(find.byType(WDiv).at(1)).width, 96); + expect(tester.getSize(find.byType(WDiv).at(2)).width, closeTo(288, 0.5)); + }); + }); + + // 11 — Pricing row: justify-between with two equal flex-1 columns. Both + // columns split the available width evenly. + group('11 pricing row', () { + testWidgets('two flex-1 columns split width equally', (tester) async { + await tester.pumpWidget( + wrapWithTheme( + const SizedBox( + width: 480, + child: WDiv( + className: 'flex flex-row justify-between gap-8', + children: [ + WDiv(className: 'flex-1', child: Text('Basic')), + WDiv(className: 'flex-1', child: Text('Pro')), + ], + ), + ), + ), + ); + + expect(tester.takeException(), isNull); + // (480 - gap(32)) / 2 = 224px each. + final a = tester.getSize(find.byType(WDiv).at(1)).width; + final b = tester.getSize(find.byType(WDiv).at(2)).width; + expect(a, closeTo(224, 0.5)); + expect(b, closeTo(224, 0.5)); + expect(a, closeTo(b, 0.5)); + }); + }); + + // 12 — Chip list: `wrap` with gap-2. Many intrinsic-width chips flow onto + // multiple runs once the row width is exceeded. + group('12 chip list', () { + testWidgets('wrap chips flow onto multiple runs', (tester) async { + await tester.pumpWidget( + wrapWithTheme( + const SizedBox( + width: 200, + child: WDiv( + className: 'wrap gap-2', + children: [ + WDiv( + className: 'px-4 py-1 bg-slate-200', child: Text('Design')), + WDiv( + className: 'px-4 py-1 bg-slate-200', + child: Text('Flutter')), + WDiv( + className: 'px-4 py-1 bg-slate-200', + child: Text('Tailwind')), + WDiv(className: 'px-4 py-1 bg-slate-200', child: Text('Dart')), + ], + ), + ), + ), + ); + + expect(tester.takeException(), isNull); + expect(find.byType(Wrap), findsOneWidget); + // With a tight 200px row at least one chip wraps to a lower run. + final firstTop = tester.getTopLeft(find.text('Design')).dy; + final lastTop = tester.getTopLeft(find.text('Dart')).dy; + expect(lastTop, greaterThan(firstTop)); + }); + }); + + // 13 — Split pane: two flex-1 panes share the full width 50/50. + group('13 split pane', () { + testWidgets('two flex-1 panes split width 50/50', (tester) async { + await tester.pumpWidget( + wrapWithTheme( + const SizedBox( + width: 600, + child: WDiv( + className: 'flex flex-row', + children: [ + WDiv(className: 'flex-1 bg-slate-100', child: Text('Left')), + WDiv(className: 'flex-1 bg-slate-200', child: Text('Right')), + ], + ), + ), + ), + ); + + expect(tester.takeException(), isNull); + expect(tester.getSize(find.byType(WDiv).at(1)).width, closeTo(300, 0.5)); + expect(tester.getSize(find.byType(WDiv).at(2)).width, closeTo(300, 0.5)); + }); + }); + + // 14 — List item: leading icon (shrink-0) + flex-1 text + trailing action + // (shrink-0). Both ends keep intrinsic size; the text fills the middle. + group('14 list item', () { + testWidgets('icon + flex-1 text + action: ends fixed, text fills middle', + (tester) async { + await tester.pumpWidget( + wrapWithTheme( + const SizedBox( + width: 360, + child: WDiv( + className: 'flex flex-row items-center gap-3', + children: [ + WDiv( + className: 'shrink-0 w-6 h-6 bg-slate-300', + child: SizedBox(width: 24, height: 24), + ), + WDiv( + className: 'flex-1', + child: Text('Notification title'), + ), + WDiv( + className: 'shrink-0 w-8 h-8 bg-slate-300', + child: SizedBox(width: 32, height: 32), + ), + ], + ), + ), + ), + ); + + expect(tester.takeException(), isNull); + // Leading icon 24px, trailing action 32px keep intrinsic widths. + expect(tester.getSize(find.byType(WDiv).at(1)).width, 24); + expect(tester.getSize(find.byType(WDiv).at(3)).width, 32); + // Text fills 360 - 24 - 32 - 2*gap(12) = 280px. + expect(tester.getSize(find.byType(WDiv).at(2)).width, closeTo(280, 0.5)); + }); + }); + + // 15 — Hero: flex-col items-center justify-center centers a stacked headline + + // subcopy on both axes inside a tall section. + group('15 hero', () { + testWidgets('flex-col items-center justify-center centers stacked content', + (tester) async { + await tester.pumpWidget( + wrapWithTheme( + const SizedBox( + width: 600, + height: 400, + child: WDiv( + className: + 'flex flex-col items-center justify-center gap-2 h-full', + children: [ + WDiv(child: Text('Headline')), + WDiv(child: Text('Subcopy')), + ], + ), + ), + ), + ); + + expect(tester.takeException(), isNull); + // Headline sits above subcopy and both are horizontally centered. + expect(visibleTextsInOrder(tester, horizontal: false), + ['Headline', 'Subcopy']); + final headlineCenter = tester.getCenter(find.text('Headline')).dx; + final subcopyCenter = tester.getCenter(find.text('Subcopy')).dx; + expect(headlineCenter, closeTo(300, 1.0)); + expect(subcopyCenter, closeTo(300, 1.0)); + }); + }); +} diff --git a/test/parser/parsers/flexbox_grid_parser_test.dart b/test/parser/parsers/flexbox_grid_parser_test.dart index 3083ba2..3edd39b 100644 --- a/test/parser/parsers/flexbox_grid_parser_test.dart +++ b/test/parser/parsers/flexbox_grid_parser_test.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:fluttersdk_wind/src/parser/parsers/flexbox_grid_parser.dart'; +import 'package:fluttersdk_wind/src/parser/wind_parser.dart'; import 'package:fluttersdk_wind/src/parser/wind_context.dart'; import 'package:fluttersdk_wind/src/parser/wind_style.dart'; import 'package:fluttersdk_wind/src/theme/wind_theme_data.dart'; @@ -169,12 +170,13 @@ void main() { expect(styles.textBaseline, TextBaseline.alphabetic); }); - test('shrink contributes FlexFit.loose; shrink-0 contributes none', () { - // Only `shrink` maps to a flexFit; shrink-0 sets none, so the resolved - // flexFit is loose. shrink-0's no-shrink effect lives in the widget. + test('shrink-0 after shrink wins last-class-wins (flexFit null)', () { + // `shrink-0` is a no-shrink reset: when it appears after `shrink` it + // claims the flexFit slot with a null (intrinsic) value, so the later + // token wins. shrink-0's no-shrink effect lives in the widget guard. final styles = parser.parse(WindStyle(), ['shrink', 'shrink-0'], context); - expect(styles.flexFit, FlexFit.loose); + expect(styles.flexFit, isNull); }); test('returns unchanged styles when classes is null', () { @@ -203,14 +205,14 @@ void main() { expect(styles.textBaseline, TextBaseline.alphabetic); }); - test('shrink contributes FlexFit.loose regardless of shrink-0 position', - () { - // shrink-0 sets no flexFit; `shrink` is the only token that does, so - // both orders resolve to FlexFit.loose at the parser level. The - // no-shrink effect of shrink-0 is applied by WDiv._hasShrinkZero. + test('shrink / shrink-0 resolve flexFit by last-class-wins position', () { + // `shrink` -> FlexFit.loose, `shrink-0` -> no flexFit (intrinsic). The + // rightmost token wins: the no-shrink effect of shrink-0 is also + // applied by WDiv._hasShrinkZero, but the parser slot must reflect the + // last class so an earlier `shrink` cannot leak through. expect( parser.parse(WindStyle(), ['shrink', 'shrink-0'], context).flexFit, - FlexFit.loose, + isNull, ); expect( parser.parse(WindStyle(), ['shrink-0', 'shrink'], context).flexFit, @@ -319,6 +321,82 @@ void main() { expect(parser.canParse('bg-red-500'), isFalse); expect(parser.canParse('p-4'), isFalse); }); + + test('returns true for grow / grow-0 / basis classes', () { + expect(parser.canParse('grow'), isTrue); + expect(parser.canParse('grow-0'), isTrue); + expect(parser.canParse('basis-1/2'), isTrue); + expect(parser.canParse('basis-full'), isTrue); + expect(parser.canParse('basis-[120px]'), isTrue); + }); + }); + + group('grow / grow-0 tokens', () { + setUp(WindParser.clearCache); + + test('grow maps to flex: 1 (same as flex-grow)', () { + final styles = parser.parse(WindStyle(), ['grow'], context); + expect(styles.flex, 1); + }); + + test('grow-0 sets no flex (no grow)', () { + final styles = parser.parse(WindStyle(), ['grow-0'], context); + expect(styles.flex, isNull); + }); + + test('grow and flex-grow resolve identically', () { + final growStyles = parser.parse(WindStyle(), ['grow'], context); + final flexGrowStyles = + parser.parse(WindStyle(), ['flex-grow'], context); + expect(growStyles.flex, flexGrowStyles.flex); + }); + }); + + group('flex-none means flex: 0 0 auto (no shrink)', () { + setUp(WindParser.clearCache); + + test('flex-none sets no flexFit (must not shrink)', () { + // CSS flex-none == flex: 0 0 auto: no grow AND no shrink. A shrinking + // FlexFit.loose would let the child shrink, contradicting flex-none. + final styles = parser.parse(WindStyle(), ['flex-none'], context); + expect(styles.flexFit, isNull); + }); + + test('flex-none sets no flex (no grow)', () { + final styles = parser.parse(WindStyle(), ['flex-none'], context); + expect(styles.flex, isNull); + }); + }); + + group('basis-* maps to main-axis basis', () { + setUp(WindParser.clearCache); + + test('basis-1/2 sets basisFactor 0.5', () { + final styles = parser.parse(WindStyle(), ['basis-1/2'], context); + expect(styles.basisFactor, 0.5); + expect(styles.basisSize, isNull); + }); + + test('basis-1/3 sets basisFactor ~0.333', () { + final styles = parser.parse(WindStyle(), ['basis-1/3'], context); + expect(styles.basisFactor, closeTo(1 / 3, 1e-9)); + }); + + test('basis-1/4 sets basisFactor 0.25', () { + final styles = parser.parse(WindStyle(), ['basis-1/4'], context); + expect(styles.basisFactor, 0.25); + }); + + test('basis-full sets basisFactor 1.0', () { + final styles = parser.parse(WindStyle(), ['basis-full'], context); + expect(styles.basisFactor, 1.0); + }); + + test('basis-[120px] sets basisSize 120', () { + final styles = parser.parse(WindStyle(), ['basis-[120px]'], context); + expect(styles.basisSize, 120.0); + expect(styles.basisFactor, isNull); + }); }); }); } diff --git a/test/widgets/w_div/flex_stretch_test.dart b/test/widgets/w_div/flex_stretch_test.dart new file mode 100644 index 0000000..d6eb295 --- /dev/null +++ b/test/widgets/w_div/flex_stretch_test.dart @@ -0,0 +1,330 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fluttersdk_wind/fluttersdk_wind.dart'; + +/// Tests for the column-only smart cross-axis stretch. +/// +/// In a `flex flex-col` with NO explicit `items-*` token, each Wind child that +/// lacks an explicit cross-axis width is stretched to the column width (CSS +/// `align-items: stretch` default). Children with explicit widths, non-Wind +/// children, gaps, and `Expanded`/`Flexible` are left untouched. Rows are +/// never affected. +Widget wrapWithTheme(Widget child) { + return MaterialApp( + home: WindTheme( + data: WindThemeData(), + child: Scaffold(body: child), + ), + ); +} + +void main() { + setUp(WindParser.clearCache); + + group('smart column stretch', () { + testWidgets('flex-col child with no width stretches to parent width', + (tester) async { + await tester.pumpWidget( + wrapWithTheme( + const SizedBox( + width: 300, + child: WDiv( + className: 'flex flex-col', + children: [ + WDiv(className: 'bg-red-500', child: SizedBox(height: 10)), + ], + ), + ), + ), + ); + + expect(tester.takeException(), isNull); + expect(tester.getSize(find.byType(WDiv).at(1)).width, 300); + }); + + testWidgets('w-32 child keeps its explicit width (no stretch)', + (tester) async { + await tester.pumpWidget( + wrapWithTheme( + const SizedBox( + width: 300, + child: WDiv( + className: 'flex flex-col', + children: [ + WDiv(className: 'w-32 bg-red-500', child: SizedBox(height: 10)), + ], + ), + ), + ), + ); + + expect(tester.takeException(), isNull); + // w-32 = 32 * 4px = 128px. + expect(tester.getSize(find.byType(WDiv).at(1)).width, 128); + }); + + testWidgets('w-1/2 child keeps half width (no stretch)', (tester) async { + await tester.pumpWidget( + wrapWithTheme( + const SizedBox( + width: 300, + child: WDiv( + className: 'flex flex-col', + children: [ + WDiv( + className: 'w-1/2 bg-red-500', child: SizedBox(height: 10)), + ], + ), + ), + ), + ); + + expect(tester.takeException(), isNull); + expect(tester.getSize(find.byType(WDiv).at(1)).width, 150); + }); + + testWidgets('w-full child stays full and is NOT double-wrapped', + (tester) async { + await tester.pumpWidget( + wrapWithTheme( + const SizedBox( + width: 300, + child: WDiv( + className: 'flex flex-col', + children: [ + WDiv( + className: 'w-full bg-red-500', + child: SizedBox(height: 10), + ), + ], + ), + ), + ), + ); + + expect(tester.takeException(), isNull); + expect(tester.getSize(find.byType(WDiv).at(1)).width, 300); + + // w-full already produces one SizedBox(width: infinity) in the child's + // own pipeline. The stretch wrap must NOT add a second one. + final column = tester.widget(find.byType(Column)); + final infinityBoxes = column.children + .whereType() + .where((box) => box.width == double.infinity) + .length; + expect(infinityBoxes, 0, + reason: 'w-full child is treated as explicit cross-width'); + }); + + testWidgets('items-start disables stretch (child sizes to content)', + (tester) async { + await tester.pumpWidget( + wrapWithTheme( + const SizedBox( + width: 300, + child: WDiv( + className: 'flex flex-col items-start', + children: [ + WDiv( + className: 'bg-red-500', + child: SizedBox(width: 40, height: 10), + ), + ], + ), + ), + ), + ); + + expect(tester.takeException(), isNull); + expect(tester.getSize(find.byType(WDiv).at(1)).width, 40); + }); + + testWidgets('items-center disables stretch (child sizes to content)', + (tester) async { + await tester.pumpWidget( + wrapWithTheme( + const SizedBox( + width: 300, + child: WDiv( + className: 'flex flex-col items-center', + children: [ + WDiv( + className: 'bg-red-500', + child: SizedBox(width: 40, height: 10), + ), + ], + ), + ), + ), + ); + + expect(tester.takeException(), isNull); + expect(tester.getSize(find.byType(WDiv).at(1)).width, 40); + }); + + testWidgets('raw Flutter Container child is left untouched (no stretch)', + (tester) async { + await tester.pumpWidget( + wrapWithTheme( + const SizedBox( + width: 300, + child: WDiv( + className: 'flex flex-col', + children: [ + SizedBox( + width: 40, + height: 10, + child: ColoredBox(color: Color(0xFFFF0000))), + ], + ), + ), + ), + ); + + expect(tester.takeException(), isNull); + // The raw SizedBox keeps its 40px width — not stretched. + final column = tester.widget(find.byType(Column)); + final stretched = column.children + .whereType() + .where((box) => box.width == double.infinity) + .length; + expect(stretched, 0, reason: 'non-Wind children must not be stretched'); + }); + + testWidgets('flex-row child with no height is NOT stretched', + (tester) async { + await tester.pumpWidget( + wrapWithTheme( + const SizedBox( + height: 300, + child: WDiv( + className: 'flex flex-row', + children: [ + WDiv(className: 'bg-red-500', child: SizedBox(width: 10)), + ], + ), + ), + ), + ); + + expect(tester.takeException(), isNull); + // Row must not gain any height-stretch SizedBox. + final row = tester.widget(find.byType(Row)); + final stretched = row.children + .whereType() + .where((box) => box.height == double.infinity) + .length; + expect(stretched, 0, reason: 'Row cross-axis must stay unchanged'); + }); + + testWidgets('Expanded/Flexible candidate (flex-1) is not stretch-wrapped', + (tester) async { + await tester.pumpWidget( + wrapWithTheme( + const SizedBox( + width: 300, + height: 200, + child: WDiv( + className: 'flex flex-col', + children: [ + WDiv( + className: 'flex-1 bg-red-500', + child: SizedBox(), + ), + ], + ), + ), + ), + ); + + expect(tester.takeException(), isNull); + // The flex-1 WDiv (which self-wraps in Expanded during its own build) + // must be a DIRECT child of the Column, not buried under a stretch + // SizedBox(width: infinity). A stretch wrap would break the Expanded. + final column = tester.widget(find.byType(Column)); + expect( + column.children.whereType().length, + 1, + reason: 'flex-1 child stays a direct Column child (no stretch wrap)', + ); + final stretched = column.children + .whereType() + .where((box) => box.width == double.infinity) + .length; + expect(stretched, 0, reason: 'flex-1 child must not be stretch-wrapped'); + // It self-wraps in Expanded and fills the bounded 200px column height. + expect(find.byType(Expanded), findsOneWidget); + }); + }); + + group('smart column stretch — edge cases', () { + testWidgets('bounded-width column in a vertical scroll still stretches', + (tester) async { + // Height is unbounded (scroll), width is bounded → child fills width. + await tester.pumpWidget( + wrapWithTheme( + SizedBox( + width: 280, + child: SingleChildScrollView( + child: WDiv( + className: 'flex flex-col', + children: const [ + WDiv(className: 'bg-red-500', child: SizedBox(height: 10)), + ], + ), + ), + ), + ), + ); + + expect(tester.takeException(), isNull); + expect(tester.getSize(find.byType(WDiv).at(1)).width, 280); + }); + + testWidgets('unbounded-width column does not crash (degrades gracefully)', + (tester) async { + // A flex-col placed where width is unbounded (a bare Row main-axis slot). + // The stretch wrap must not force an infinite-width render error. + await tester.pumpWidget( + wrapWithTheme( + Row( + mainAxisSize: MainAxisSize.min, + children: const [ + WDiv( + className: 'flex flex-col', + children: [ + WDiv(className: 'bg-red-500', child: Text('content')), + ], + ), + ], + ), + ), + ); + + expect(tester.takeException(), isNull, + reason: 'smart stretch must not throw on an unbounded-width column'); + }); + + testWidgets('basis-* column child is not additionally width-stretched', + (tester) async { + // A basis-* child resolves to a sized SizedBox; the stretch pass skips + // SizedBox children, so it is not double-wrapped to full width. + await tester.pumpWidget( + wrapWithTheme( + const SizedBox( + width: 300, + height: 400, + child: WDiv( + className: 'flex flex-col', + children: [ + WDiv(className: 'basis-1/2 bg-red-500', child: Text('half')), + ], + ), + ), + ), + ); + + expect(tester.takeException(), isNull); + }); + }); +} diff --git a/test/widgets/w_div/flex_tokens_test.dart b/test/widgets/w_div/flex_tokens_test.dart new file mode 100644 index 0000000..5c1ebbf --- /dev/null +++ b/test/widgets/w_div/flex_tokens_test.dart @@ -0,0 +1,201 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fluttersdk_wind/fluttersdk_wind.dart'; + +/// Tests for the flex child tokens `grow`, `grow-0`, `basis-*`, and the +/// corrected `flex-none` (CSS `flex: 0 0 auto` — no grow AND no shrink). +Widget wrapWithTheme(Widget child) { + return MaterialApp( + home: WindTheme( + data: WindThemeData(), + child: Scaffold(body: child), + ), + ); +} + +void main() { + setUp(WindParser.clearCache); + + group('grow / grow-0', () { + testWidgets('grow makes a Row child expand to fill (flex: 1)', + (tester) async { + await tester.pumpWidget( + wrapWithTheme( + const SizedBox( + width: 300, + child: WDiv( + className: 'flex flex-row', + children: [ + WDiv(className: 'grow h-10 bg-red-500', child: SizedBox()), + WDiv(className: 'w-16 h-10 bg-blue-500', child: SizedBox()), + ], + ), + ), + ), + ); + + expect(tester.takeException(), isNull); + // grow -> Expanded(flex: 1): takes the remaining 300 - 64 = 236px. + expect(tester.getSize(find.byType(WDiv).at(1)).width, 236); + expect(find.byType(Expanded), findsOneWidget); + }); + + testWidgets('grow-0 child keeps intrinsic width (no Expanded)', + (tester) async { + await tester.pumpWidget( + wrapWithTheme( + const SizedBox( + width: 300, + child: WDiv( + className: 'flex flex-row', + children: [ + WDiv( + className: 'grow-0 w-16 h-10 bg-red-500', + child: SizedBox(), + ), + ], + ), + ), + ), + ); + + expect(tester.takeException(), isNull); + expect(find.byType(Expanded), findsNothing); + expect(tester.getSize(find.byType(WDiv).at(1)).width, 64); + }); + }); + + group('basis-*', () { + testWidgets('basis-1/2 in a row sets child main-size to half', + (tester) async { + await tester.pumpWidget( + wrapWithTheme( + const SizedBox( + width: 400, + child: WDiv( + className: 'flex flex-row', + children: [ + WDiv( + className: 'basis-1/2 h-10 bg-red-500', + child: SizedBox(), + ), + ], + ), + ), + ), + ); + + expect(tester.takeException(), isNull); + // basis-1/2 -> half of the 400px main axis. + expect(tester.getSize(find.byType(WDiv).at(1)).width, 200); + }); + + testWidgets('basis-[120px] in a row sets a fixed main-size', + (tester) async { + await tester.pumpWidget( + wrapWithTheme( + const SizedBox( + width: 400, + child: WDiv( + className: 'flex flex-row', + children: [ + WDiv( + className: 'basis-[120px] h-10 bg-red-500', + child: SizedBox(), + ), + ], + ), + ), + ), + ); + + expect(tester.takeException(), isNull); + expect(tester.getSize(find.byType(WDiv).at(1)).width, 120); + }); + + testWidgets('basis-1/2 in a column sets child main-size (height) to half', + (tester) async { + await tester.pumpWidget( + wrapWithTheme( + const SizedBox( + height: 400, + child: WDiv( + className: 'flex flex-col', + children: [ + WDiv( + className: 'basis-1/2 w-10 bg-red-500', + child: SizedBox(), + ), + ], + ), + ), + ), + ); + + expect(tester.takeException(), isNull); + expect(tester.getSize(find.byType(WDiv).at(1)).height, 200); + }); + }); + + group('flex-none (CSS flex: 0 0 auto)', () { + testWidgets('flex-none child in a justify-between Row does NOT shrink', + (tester) async { + const fixedText = 'Subscribe'; + + await tester.pumpWidget( + wrapWithTheme( + const SizedBox( + width: 400, + child: WDiv( + className: 'flex flex-row justify-between', + children: [ + WDiv( + className: 'flex-1', + child: WText('Title that grows'), + ), + WDiv( + className: 'flex-none px-2', + child: WText(fixedText), + ), + ], + ), + ), + ), + ); + + expect(tester.takeException(), isNull); + + // flex-none must not be wrapped in Flexible (which would let it shrink). + final row = tester.widget(find.byType(Row).first); + final flexibleCount = row.children + .where((child) => child is Flexible && child is! Expanded) + .length; + expect( + flexibleCount, + 0, + reason: 'flex-none child must not be wrapped with Flexible', + ); + + // It must keep its intrinsic width (not the 200px equal-flex split). + final badgeWidth = tester.getSize(find.text(fixedText)).width; + expect(badgeWidth, greaterThan(0)); + expect(badgeWidth, isNot(equals(200.0))); + expect(badgeWidth, lessThan(400)); + }); + + testWidgets('standalone flex-none WDiv builds with no Flexible self-wrap', + (tester) async { + await tester.pumpWidget( + wrapWithTheme( + const WDiv( + className: 'flex-none w-16 h-16 bg-gray-200', + child: SizedBox(), + ), + ), + ); + + expect(tester.takeException(), isNull); + expect(find.byType(Flexible), findsNothing); + }); + }); +} diff --git a/test/widgets/w_text/baseline_test.dart b/test/widgets/w_text/baseline_test.dart new file mode 100644 index 0000000..a589061 --- /dev/null +++ b/test/widgets/w_text/baseline_test.dart @@ -0,0 +1,183 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fluttersdk_wind/src/parser/wind_parser.dart'; +import 'package:fluttersdk_wind/src/theme/wind_theme.dart'; +import 'package:fluttersdk_wind/src/theme/wind_theme_data.dart'; +import 'package:fluttersdk_wind/src/widgets/w_text.dart'; + +void main() { + setUp(WindParser.clearCache); + + group('WText baseline rendering', () { + group('bare context (no Material / Scaffold ancestor)', () { + testWidgets( + 'renders non-null text color when no className text-* is given', + (tester) async { + // Pump WText under only a WindTheme + Directionality — no + // MaterialApp, no Scaffold, no DefaultTextStyle with a real color. + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WindTheme( + data: WindThemeData(), + child: const WText('x'), + ), + ), + ); + + final Text textWidget = tester.widget(find.byType(Text)); + + // The effective style must carry a non-null color so Flutter does + // not fall back to its yellow-underline debug appearance. + final BuildContext ctx = tester.element(find.byType(Text)); + final TextStyle effective = DefaultTextStyle.of(ctx).style.merge( + textWidget.style, + ); + expect( + effective.color, + isNotNull, + reason: 'A bare WText must resolve a baseline color.', + ); + }, + ); + + testWidgets( + 'renders without crashing when Directionality ancestor is absent', + (tester) async { + // No Directionality ancestor at all. WText must provide one. + await tester.pumpWidget( + WindTheme( + data: WindThemeData(), + child: const WText('x'), + ), + ); + + expect(find.byType(Text), findsOneWidget); + // Must not throw a "No Directionality widget found" error. + }, + ); + + testWidgets( + 'resolves TextDirection from the injected Directionality', + (tester) async { + await tester.pumpWidget( + WindTheme( + data: WindThemeData(), + child: const WText('x'), + ), + ); + + // The Text widget itself lives inside a Directionality subtree, + // so resolving TextDirection must succeed (no exception). + final BuildContext ctx = tester.element(find.byType(Text)); + final TextDirection? dir = Directionality.maybeOf(ctx); + expect( + dir, + isNotNull, + reason: 'WText must guarantee a TextDirection for its subtree.', + ); + }, + ); + + testWidgets( + 'baseline color follows platform brightness (white on dark)', + (tester) async { + await tester.pumpWidget( + MediaQuery( + data: const MediaQueryData(platformBrightness: Brightness.dark), + child: Directionality( + textDirection: TextDirection.ltr, + child: WindTheme( + data: WindThemeData(), + child: const WText('x'), + ), + ), + ), + ); + + final Text textWidget = tester.widget(find.byType(Text)); + expect( + textWidget.style?.color, + Colors.white, + reason: 'Bare WText on a dark platform falls back to white, ' + 'not an invisible black.', + ); + }, + ); + }); + + group('explicit styles still win over baseline', () { + testWidgets( + 'className text-* color overrides baseline fallback', + (tester) async { + final themeData = WindThemeData(); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WindTheme( + data: themeData, + child: const WText('x', className: 'text-red-500'), + ), + ), + ); + + final Text textWidget = tester.widget(find.byType(Text)); + expect( + textWidget.style?.color, + themeData.colors['red']![500], + reason: 'className text-* must still control the text color.', + ); + }, + ); + + testWidgets( + 'foregroundColor prop overrides baseline fallback', + (tester) async { + const explicitColor = Color(0xFF123456); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WindTheme( + data: WindThemeData(), + child: const WText('x', foregroundColor: explicitColor), + ), + ), + ); + + final Text textWidget = tester.widget(find.byType(Text)); + expect( + textWidget.style?.color, + explicitColor, + reason: 'foregroundColor prop must still control the text color.', + ); + }, + ); + + testWidgets( + 'textStyle prop color overrides baseline fallback', + (tester) async { + const explicitColor = Color(0xFFABCDEF); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WindTheme( + data: WindThemeData(), + child: const WText( + 'x', + textStyle: TextStyle(color: explicitColor), + ), + ), + ), + ); + + final Text textWidget = tester.widget(find.byType(Text)); + expect( + textWidget.style?.color, + explicitColor, + reason: 'textStyle prop color must still control the text color.', + ); + }, + ); + }); + }); +}