From 44747b58f04581c52d40fa8408927e9412c2b67a Mon Sep 17 00:00:00 2001 From: Anilcan Cakir Date: Sat, 4 Apr 2026 23:05:27 +0300 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20add=20CSS=20positioning=20utilities?= =?UTF-8?q?=20=E2=80=94=20relative/absolute=20+=20inset=20offsets=20(#49)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add PositionParser for CSS-like positioning with Stack/Positioned rendering: - `relative` parent creates Stack, `absolute` child becomes Positioned - Offset tokens: top-*, right-*, bottom-*, left-* (spacing scale) - Inset shortcuts: inset-*, inset-x-*, inset-y-* - Negative offsets: -top-*, -inset-* - Arbitrary values: top-[24px], left-[50%] - fixed/sticky recognized but deferred (no rendering) Includes 34 new tests (26 parser + 8 widget), documentation, example page, and post-change checklist sync. Closes #49 --- CHANGELOG.md | 7 + README.md | 2 +- doc/layout/positioning.md | 274 ++++++++++++++ example/lib/pages/layout/positioning.dart | 344 ++++++++++++++++++ example/lib/routes.dart | 2 + lib/src/parser/parsers/position_parser.dart | 176 +++++++++ lib/src/parser/wind_parser.dart | 2 + lib/src/parser/wind_style.dart | 56 ++- lib/src/widgets/w_div.dart | 110 ++++++ skills/wind-ui/SKILL.md | 31 +- test/parser/parsers/position_parser_test.dart | 266 ++++++++++++++ test/widgets/w_div/position_test.dart | 249 +++++++++++++ 12 files changed, 1514 insertions(+), 5 deletions(-) create mode 100644 doc/layout/positioning.md create mode 100644 example/lib/pages/layout/positioning.dart create mode 100644 lib/src/parser/parsers/position_parser.dart create mode 100644 test/parser/parsers/position_parser_test.dart create mode 100644 test/widgets/w_div/position_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 48958f9..c3ef869 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,13 @@ This project follows [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.0. ## [Unreleased] +### Added +- **CSS Positioning**: `relative` and `absolute` position types with Stack/Positioned rendering +- **Offset Utilities**: `top-*`, `right-*`, `bottom-*`, `left-*` offset tokens using spacing scale +- **Inset Shortcuts**: `inset-*`, `inset-x-*`, `inset-y-*` for multi-side offsets +- **Negative Offsets**: `-top-*`, `-inset-*` for negative positioning +- **Arbitrary Position Values**: `top-[24px]`, `left-[50%]` bracket syntax + --- ## [1.0.0-alpha.5] - 2026-03-31 diff --git a/README.md b/README.md index f817642..2674702 100644 --- a/README.md +++ b/README.md @@ -247,7 +247,7 @@ WDynamic(
Layout — flex, grid, positioning, overflow -`flex` `flex-row` `flex-col` `flex-wrap` `flex-1` `grid` `grid-cols-{n}` `gap-{n}` `justify-center` `justify-between` `items-center` `items-start` `self-center` `wrap` `hidden` `overflow-hidden` `overflow-y-auto` +`flex` `flex-row` `flex-col` `flex-wrap` `flex-1` `grid` `grid-cols-{n}` `gap-{n}` `justify-center` `justify-between` `items-center` `items-start` `self-center` `wrap` `hidden` `overflow-hidden` `overflow-y-auto` `relative` `absolute` `top-{n}` `right-{n}` `bottom-{n}` `left-{n}` `inset-{n}` `inset-x-{n}` `inset-y-{n}` `top-[24px]` `left-[50%]` `-top-{n}` `-inset-{n}`
diff --git a/doc/layout/positioning.md b/doc/layout/positioning.md new file mode 100644 index 0000000..08ad47b --- /dev/null +++ b/doc/layout/positioning.md @@ -0,0 +1,274 @@ +# Positioning + +Utilities for controlling how elements are positioned in the layout using `relative` and `absolute` placement. + +- [Position Types](#position-types) +- [Offset Utilities](#offset-utilities) +- [Inset Shortcuts](#inset-shortcuts) +- [Negative Offsets](#negative-offsets) +- [Arbitrary Values](#arbitrary-values) +- [Common Patterns](#common-patterns) +- [Combining with Flex](#combining-with-flex) +- [Future Work](#future-work) +- [Related Documentation](#related-documentation) + + + +```dart +// Badge overlay — red dot on an avatar +WDiv( + className: 'relative', + children: [ + WDiv(className: 'w-12 h-12 rounded-full bg-gray-300'), + WDiv(className: 'absolute top-0 right-0 w-3 h-3 rounded-full bg-red-500'), + ], +) +``` + + +## Position Types + +Wind maps CSS `position` values to Flutter's `Stack` widget. A `relative` parent establishes a stacking context; `absolute` children are placed inside it using offset utilities. + +| Wind className | CSS Equivalent | Flutter Widget | +|:---------------|:---------------|:---------------| +| `relative` | `position: relative` | `Stack` (parent) | +| `absolute` | `position: absolute` | `Positioned` (child inside `Stack`) | + +> [!NOTE] +> An `absolute` child must live inside a `relative` parent. Wind will wrap the `relative` container in a `Stack` and each `absolute` child in a `Positioned` widget automatically. + +```dart +WDiv( + className: 'relative w-32 h-32 bg-gray-100', + children: [ + WDiv(className: 'absolute top-2 left-2 w-8 h-8 bg-blue-500'), + ], +) +``` + + +## Offset Utilities + +Control the position of `absolute` children using directional offset classes. Values follow the spacing scale (`spacing * n`). + +| Class | CSS Equivalent | Description | +|:------|:---------------|:------------| +| `top-{n}` | `top: {n}` | Distance from the top edge | +| `right-{n}` | `right: {n}` | Distance from the right edge | +| `bottom-{n}` | `bottom: {n}` | Distance from the bottom edge | +| `left-{n}` | `left: {n}` | Distance from the left edge | + +Default spacing scale (base unit = 4px): + +| Class | Value | +|:------|:------| +| `top-0` | 0px | +| `top-1` | 4px | +| `top-2` | 8px | +| `top-4` | 16px | +| `top-6` | 24px | +| `top-8` | 32px | + +The same scale applies to `right-*`, `bottom-*`, and `left-*`. + +```dart +WDiv( + className: 'relative h-48 bg-white border border-gray-200 rounded-lg', + children: [ + WDiv( + className: 'absolute bottom-4 right-4 px-3 py-2 bg-blue-600 rounded', + child: WText('Action', className: 'text-sm text-white'), + ), + ], +) +``` + + +## Inset Shortcuts + +Apply offsets to multiple sides at once with `inset-*` shorthand classes. + +| Class | Sides Affected | Description | +|:------|:---------------|:------------| +| `inset-{n}` | top, right, bottom, left | All four sides | +| `inset-x-{n}` | left, right | Horizontal sides only | +| `inset-y-{n}` | top, bottom | Vertical sides only | +| `inset-0` | top, right, bottom, left | Full stretch (fills parent) | + +```dart +// Full overlay — covers the entire relative parent +WDiv( + className: 'relative w-full h-48', + children: [ + WImage(src: 'assets/photo.jpg', className: 'w-full h-full'), + // Semi-transparent overlay that fills the image + WDiv( + className: 'absolute inset-0 bg-black opacity-40', + ), + WText( + 'Caption', + className: 'absolute bottom-4 left-4 text-white font-semibold', + ), + ], +) +``` + +```dart +// Horizontal inset — leaves top and bottom unconstrained +WDiv( + className: 'absolute inset-x-4 bottom-4 bg-white rounded p-3', + child: WText('Bottom bar'), +) +``` + + +## Negative Offsets + +Prefix any offset class with `-` to pull an element outside its parent's boundary. + +| Class | Value | Description | +|:------|:------|:------------| +| `-top-{n}` | negative top | Pulls element above the parent | +| `-right-{n}` | negative right | Pulls element to the right of the parent | +| `-bottom-{n}` | negative bottom | Pulls element below the parent | +| `-left-{n}` | negative left | Pulls element to the left of the parent | +| `-inset-{n}` | negative all sides | Expands element beyond all four parent edges | + +```dart +// Notification badge — overlaps the top-right corner of an icon button +WDiv( + className: 'relative', + children: [ + WDiv(className: 'w-10 h-10 rounded bg-gray-200 flex items-center justify-center'), + WDiv( + className: 'absolute -top-1 -right-1 w-4 h-4 rounded-full bg-red-500 flex items-center justify-center', + child: WText('3', className: 'text-[10px] text-white font-bold'), + ), + ], +) +``` + + +## Arbitrary Values + +Use bracket notation when you need an exact pixel, percentage, or rem value not in the theme scale. + +```dart +// Exact pixel value +WDiv(className: 'absolute top-[24px] left-[24px]') + +// Percentage — center horizontally +WDiv(className: 'absolute left-[50%]') + +// Mixed — precise multi-side offset +WDiv(className: 'absolute top-[12px] right-[8px] bottom-[12px] left-[8px]') +``` + +> [!NOTE] +> Percentage values like `left-[50%]` set the offset to 50% of the parent's width. To visually center an absolute element, pair it with a negative translate if needed (see Common Patterns below). + + +## Common Patterns + +### Badge Overlay + +A notification dot positioned on top of an avatar or icon. + +```dart +WDiv( + className: 'relative w-12 h-12', + children: [ + WDiv(className: 'w-12 h-12 rounded-full bg-indigo-500'), + WDiv( + className: 'absolute -top-1 -right-1 w-5 h-5 rounded-full bg-red-500 border-2 border-white flex items-center justify-center', + child: WText('2', className: 'text-[9px] text-white font-bold'), + ), + ], +) +``` + +### Floating Action Button (FAB) + +An action button pinned to the bottom-right corner of a scrollable view. + +```dart +WDiv( + className: 'relative flex-1', + children: [ + WDiv( + className: 'w-full h-full overflow-y-auto p-4', + child: WText('Scrollable content...'), + ), + WDiv( + className: 'absolute bottom-6 right-6 w-14 h-14 rounded-full bg-blue-600 flex items-center justify-center shadow-lg', + child: WIcon(Icons.add_outlined, className: 'text-white'), + ), + ], +) +``` + +### Full Overlay + +A semi-transparent scrim that covers an entire card or image. + +```dart +WDiv( + className: 'relative rounded-xl overflow-hidden', + children: [ + WImage(src: 'assets/hero.jpg', className: 'w-full h-48 object-cover'), + WDiv(className: 'absolute inset-0 bg-gradient-to-t from-black/60 to-transparent'), + WDiv( + className: 'absolute bottom-0 left-0 right-0 p-4', + child: WText('Hero Title', className: 'text-white text-lg font-bold'), + ), + ], +) +``` + + +## Combining with Flex + +`relative` and `absolute` compose naturally with flex layouts. The `relative` container itself can be a flex row or column — the `Stack` wraps around the flex widget, and `absolute` children are layered on top. + +```dart +// Navigation bar with an absolute badge on the icon +WDiv( + className: 'flex flex-row items-center justify-between px-4 py-3 bg-white border-b border-gray-200', + children: [ + WText('Inbox', className: 'text-base font-semibold text-gray-900'), + WDiv( + className: 'relative', + children: [ + WIcon(Icons.notifications_outlined, className: 'text-gray-700'), + WDiv( + className: 'absolute -top-1 -right-1 w-4 h-4 rounded-full bg-red-500', + ), + ], + ), + ], +) +``` + +> [!NOTE] +> When you add `absolute` children to a flex item, Wind promotes that item to a `Stack`. The flex layout of the parent is preserved. + + +## Future Work + +The following position types are tracked but not yet implemented in v1: + +| Class | Status | +|:------|:-------| +| `fixed` | Deferred — maps to Flutter's `Overlay`/`Stack` at the root level | +| `sticky` | Deferred — requires custom `SliverPersistentHeader` integration | + +Until these land, use `Overlay` directly or Flutter's `Stack` at the `Scaffold` body level for fixed positioning. + + +## Related Documentation + +- [Flexbox & Layout](./flexbox.md) +- [Grid Layout](./grid.md) +- [Sizing](../sizing/width.md) +- [Spacing](../spacing/padding.md) diff --git a/example/lib/pages/layout/positioning.dart b/example/lib/pages/layout/positioning.dart new file mode 100644 index 0000000..acc188e --- /dev/null +++ b/example/lib/pages/layout/positioning.dart @@ -0,0 +1,344 @@ +import 'package:flutter/material.dart'; +import 'package:fluttersdk_wind/fluttersdk_wind.dart'; + +/// Positioning Example +/// Demonstrates CSS-inspired positioning utilities: relative, absolute, inset, offsets +class PositioningExamplePage extends StatefulWidget { + const PositioningExamplePage({super.key}); + + @override + State createState() => _PositioningExamplePageState(); +} + +class _PositioningExamplePageState extends State { + bool _showOverlay = false; + + @override + Widget build(BuildContext context) { + return WDiv( + className: 'w-full h-full overflow-y-auto p-4', + child: WDiv( + className: 'flex flex-col gap-6', + children: [ + _buildHeader(), + _buildBadgeOverlay(), + _buildFabPositioning(), + _buildFullOverlay(), + _buildCardWithLabel(), + _buildFlexWithPositioned(), + _buildInteractiveDemo(), + ], + ), + ); + } + + Widget _buildHeader() { + return WDiv( + className: ''' + w-full p-4 rounded-xl + bg-gradient-to-r from-violet-600 to-purple-700 + ''', + children: [ + WText( + 'CSS Positioning', + className: 'text-lg font-bold text-white', + ), + WText( + 'Use relative and absolute to layer and overlap elements', + className: 'text-sm text-violet-200', + ), + ], + ); + } + + Widget _buildBadgeOverlay() { + return _buildSection( + title: 'Badge Overlay', + description: + 'relative parent + absolute top-0 right-0 child pins a badge to the corner', + children: [ + WDiv( + className: 'flex gap-6 p-4 bg-gray-100 dark:bg-slate-800 rounded-lg', + children: [ + // Notification bell with badge + WDiv( + className: 'relative w-12 h-12', + children: [ + WDiv( + className: + 'w-12 h-12 bg-blue-500 rounded-xl flex items-center justify-center', + child: WIcon(Icons.notifications_outlined), + ), + WDiv( + className: + 'absolute top-0 right-0 w-5 h-5 bg-red-500 rounded-full flex items-center justify-center', + child: WText('3', className: 'text-white text-xs font-bold'), + ), + ], + ), + // Shopping cart with badge + WDiv( + className: 'relative w-12 h-12', + children: [ + WDiv( + className: + 'w-12 h-12 bg-green-500 rounded-xl flex items-center justify-center', + child: WIcon(Icons.shopping_cart_outlined), + ), + WDiv( + className: + 'absolute top-0 right-0 w-5 h-5 bg-orange-500 rounded-full flex items-center justify-center', + child: WText('12', className: 'text-white text-xs font-bold'), + ), + ], + ), + // Message icon with dot + WDiv( + className: 'relative w-12 h-12', + children: [ + WDiv( + className: + 'w-12 h-12 bg-indigo-500 rounded-xl flex items-center justify-center', + child: WIcon(Icons.chat_bubble_outlined), + ), + WDiv( + className: + 'absolute top-1 right-1 w-3 h-3 bg-green-400 rounded-full', + ), + ], + ), + ], + ), + ], + ); + } + + Widget _buildFabPositioning() { + return _buildSection( + title: 'FAB Positioning', + description: + 'absolute bottom-4 right-4 pins a floating action button to the container corner', + children: [ + WDiv( + className: + 'relative h-40 bg-gray-100 dark:bg-slate-800 rounded-lg overflow-hidden', + children: [ + WDiv( + className: 'p-4', + child: WText( + 'Container content — the FAB floats above it', + className: 'text-sm text-gray-600 dark:text-gray-400', + ), + ), + WDiv( + className: 'absolute bottom-4 right-4', + child: WDiv( + className: + 'w-12 h-12 bg-violet-600 rounded-full shadow-lg flex items-center justify-center', + child: WIcon(Icons.add, className: 'text-white'), + ), + ), + ], + ), + ], + ); + } + + Widget _buildFullOverlay() { + return _buildSection( + title: 'Full Overlay', + description: + 'absolute inset-0 stretches a child to cover the entire parent', + children: [ + WDiv( + className: 'relative h-36 bg-blue-500 rounded-lg overflow-hidden', + children: [ + WDiv( + className: 'p-4 flex flex-col gap-1', + children: [ + WText( + 'Sarah Johnson', + className: 'text-white font-bold text-base', + ), + WText( + 'Senior Product Designer', + className: 'text-blue-200 text-sm', + ), + WText( + 'San Francisco, CA', + className: 'text-blue-200 text-sm', + ), + ], + ), + // Semi-transparent overlay + WDiv( + className: + 'absolute inset-0 bg-black opacity-40 flex items-center justify-center', + child: WText( + 'absolute inset-0', + className: 'text-white font-mono text-sm font-bold', + ), + ), + ], + ), + ], + ); + } + + Widget _buildCardWithLabel() { + return _buildSection( + title: 'Card with Positioned Label', + description: + 'Use negative offsets like -top-3 to overlap an element across a card border', + children: [ + WDiv( + className: + 'relative p-4 pt-6 bg-white dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-xl shadow-sm', + children: [ + WDiv( + className: + 'absolute -top-3 left-4 px-3 py-1 bg-violet-600 rounded-full', + child: WText( + 'Featured', + className: 'text-white text-xs font-semibold', + ), + ), + WText( + 'Wind UI Framework', + className: + 'text-base font-bold text-gray-900 dark:text-white mb-1', + ), + WText( + 'Utility-first styling for Flutter. Build fast, expressive UIs with familiar Tailwind syntax.', + className: 'text-sm text-gray-600 dark:text-gray-400', + ), + ], + ), + ], + ); + } + + Widget _buildFlexWithPositioned() { + return _buildSection( + title: 'Flex + Positioning', + description: + 'relative flex flex-row — normal children participate in flex layout while absolute children float above', + children: [ + WDiv( + className: + 'relative flex flex-row gap-3 p-4 bg-gray-100 dark:bg-slate-800 rounded-lg', + children: [ + WDiv( + className: + 'flex-1 h-16 bg-blue-400 rounded-lg flex items-center justify-center', + child: WText('flex item 1', className: 'text-white text-sm'), + ), + WDiv( + className: + 'flex-1 h-16 bg-blue-400 rounded-lg flex items-center justify-center', + child: WText('flex item 2', className: 'text-white text-sm'), + ), + WDiv( + className: + 'flex-1 h-16 bg-blue-400 rounded-lg flex items-center justify-center', + child: WText('flex item 3', className: 'text-white text-sm'), + ), + // Floating badge over the flex container + WDiv( + className: + 'absolute top-2 right-2 px-2 py-1 bg-amber-400 rounded-full', + child: WText( + 'NEW', + className: 'text-amber-900 text-xs font-bold', + ), + ), + ], + ), + ], + ); + } + + Widget _buildInteractiveDemo() { + return _buildSection( + title: 'Interactive Demo', + description: 'Toggle an absolute overlay on and off with setState', + children: [ + WDiv( + className: + 'relative h-40 bg-gradient-to-br from-slate-700 to-slate-900 rounded-xl overflow-hidden', + children: [ + WDiv( + className: 'p-4 flex flex-col gap-2', + children: [ + WText( + 'Dashboard Overview', + className: 'text-white font-bold', + ), + WText( + 'Monthly revenue: \$48,320', + className: 'text-slate-400 text-sm', + ), + WText( + 'Active users: 1,284', + className: 'text-slate-400 text-sm', + ), + ], + ), + if (_showOverlay) + WDiv( + className: + 'absolute inset-0 bg-black opacity-70 flex items-center justify-center', + child: WText( + 'Overlay active — absolute inset-0', + className: 'text-white font-semibold text-sm', + ), + ), + ], + ), + WDiv( + className: 'flex gap-3 mt-2', + children: [ + WButton( + onTap: () => setState(() => _showOverlay = !_showOverlay), + className: + 'px-4 py-2 bg-violet-600 rounded-lg flex items-center gap-2', + child: WText( + _showOverlay ? 'Hide Overlay' : 'Show Overlay', + className: 'text-white text-sm font-medium', + ), + ), + WDiv( + className: + 'px-3 py-2 bg-gray-100 dark:bg-slate-700 rounded-lg flex items-center', + child: WText( + _showOverlay ? 'overlay: visible' : 'overlay: hidden', + className: 'text-xs font-mono text-gray-600 dark:text-gray-300', + ), + ), + ], + ), + ], + ); + } + + Widget _buildSection({ + required String title, + required String description, + required List children, + }) { + return WDiv( + className: 'flex flex-col gap-2', + children: [ + WText( + title, + className: 'text-lg font-semibold text-gray-900 dark:text-white', + ), + WText( + description, + className: 'text-sm text-gray-600 dark:text-gray-400 mb-4', + ), + ...children, + ], + ); + } +} diff --git a/example/lib/routes.dart b/example/lib/routes.dart index d366157..648740f 100644 --- a/example/lib/routes.dart +++ b/example/lib/routes.dart @@ -107,6 +107,7 @@ import 'pages/layout/sizing_basic.dart'; import 'pages/layout/sizing_width.dart'; import 'pages/layout/sizing_height.dart'; import 'pages/layout/aspect_ratio_basic.dart'; +import 'pages/layout/positioning.dart'; // Responsive import 'pages/responsive/card.dart'; @@ -309,6 +310,7 @@ final Map appRoutes = { '/layout/sizing_width': const SizingWidthExamplePage(), '/layout/sizing_height': const SizingHeightExamplePage(), '/layout/aspect_ratio_basic': const AspectRatioBasicExamplePage(), + '/layout/positioning': const PositioningExamplePage(), // Responsive '/responsive/card': const ResponsiveCardExamplePage(), diff --git a/lib/src/parser/parsers/position_parser.dart b/lib/src/parser/parsers/position_parser.dart new file mode 100644 index 0000000..90daa14 --- /dev/null +++ b/lib/src/parser/parsers/position_parser.dart @@ -0,0 +1,176 @@ +import '../wind_context.dart'; +import '../wind_style.dart'; +import 'wind_parser_interface.dart'; + +/// **The Position Parser** +/// +/// Handles CSS positioning utility classes. Prefixes (like `hover:`, `md:`, `dark:`) +/// are resolved by the core parser before being passed to this parser. +/// +/// ### Supported Utility Classes: +/// - **Position Type:** `relative`, `absolute` (applied), `fixed`, `sticky` (claimed but ignored) +/// - **Top:** `top-{value}`, `-top-{value}` +/// - **Right:** `right-{value}`, `-right-{value}` +/// - **Bottom:** `bottom-{value}`, `-bottom-{value}` +/// - **Left:** `left-{value}`, `-left-{value}` +/// - **Inset (All Sides):** `inset-{value}`, `-inset-{value}` +/// - **Inset Horizontal:** `inset-x-{value}`, `-inset-x-{value}` +/// - **Inset Vertical:** `inset-y-{value}`, `-inset-y-{value}` +/// +/// ### Values: +/// - **Theme Scale:** `top-4`, `inset-x-2` (resolves via `WindThemeData.getSpacing`) +/// - **Arbitrary Values:** `top-[24px]`, `left-[50%]` (supports px and % suffixes) +/// +/// Returns a [WindStyle] with resolved position properties using "Last Class Wins" logic. +class PositionParser implements WindParserInterface { + const PositionParser(); + + /// Position type keywords + static const _positionTypes = {'relative', 'absolute', 'fixed', 'sticky'}; + + /// Regex for theme-based offset classes + /// e.g., top-4, inset-x-2, -bottom-8 + static final _themeOffsetRegex = RegExp( + r'^-?(?top|bottom|left|right|inset-x|inset-y|inset)-(?[a-zA-Z0-9./]+)$', + ); + + /// Regex for arbitrary offset classes + /// e.g., top-[24px], left-[50%], -inset-[10px] + static final _arbitraryOffsetRegex = RegExp( + r'^-?(?top|bottom|left|right|inset-x|inset-y|inset)-\[(?[0-9.]+(?:px|%)?)\]$', + ); + + @override + WindStyle parse( + WindStyle styles, + List? classes, + WindContext context, + ) { + if (classes == null) return styles; + + final theme = context.theme; + + // "Last Class Wins" (reverse iteration) requires us to track + // which values have been set. + WindPositionType? positionType; + double? pTop; + double? pRight; + double? pBottom; + double? pLeft; + + for (var i = classes.length - 1; i >= 0; i--) { + final className = classes[i]; + + // 1. Position type keywords + if (_positionTypes.contains(className)) { + if (positionType == null) { + // Only relative and absolute produce a style change + if (className == 'relative') { + positionType = WindPositionType.relative; + } else if (className == 'absolute') { + positionType = WindPositionType.absolute; + } + // fixed/sticky are claimed but ignored — no style change + } + continue; + } + + // Detect negative prefix + final isNegative = className.startsWith('-'); + + double? value; + String? root; + + // 2. Arbitrary Values (e.g., top-[24px], -left-[50%]) + final arbitraryMatch = _arbitraryOffsetRegex.firstMatch(className); + if (arbitraryMatch != null) { + root = arbitraryMatch.namedGroup('root')!; + final valueStr = arbitraryMatch.namedGroup('value')!; + value = double.tryParse( + valueStr.replaceAll('px', '').replaceAll('%', ''), + ); + } + + // 3. Theme Values (e.g., top-4, inset-x-2) + if (value == null) { + final themeMatch = _themeOffsetRegex.firstMatch(className); + if (themeMatch != null) { + root = themeMatch.namedGroup('root')!; + final valueKey = themeMatch.namedGroup('value')!; + value = theme.getSpacing(valueKey); + } + } + + if (value == null || root == null) continue; + + // Apply negative sign + if (isNegative) { + value = -value; + } + + // Apply "Last Class Wins" logic (only set if null) + switch (root) { + case 'top': + pTop ??= value; + break; + case 'bottom': + pBottom ??= value; + break; + case 'left': + pLeft ??= value; + break; + case 'right': + pRight ??= value; + break; + case 'inset': + pTop ??= value; + pRight ??= value; + pBottom ??= value; + pLeft ??= value; + break; + case 'inset-x': + pLeft ??= value; + pRight ??= value; + break; + case 'inset-y': + pTop ??= value; + pBottom ??= value; + break; + } + } + + final bool didChange = positionType != null || + pTop != null || + pRight != null || + pBottom != null || + pLeft != null; + + if (!didChange) { + return styles; + } + + return styles.copyWith( + positionType: positionType, + positionTop: pTop, + positionRight: pRight, + positionBottom: pBottom, + positionLeft: pLeft, + ); + } + + @override + bool canParse(String className) { + if (_positionTypes.contains(className)) return true; + + return className.startsWith('top-') || + className.startsWith('bottom-') || + className.startsWith('left-') || + className.startsWith('right-') || + className.startsWith('inset-') || + className.startsWith('-top-') || + className.startsWith('-bottom-') || + className.startsWith('-left-') || + className.startsWith('-right-') || + className.startsWith('-inset-'); + } +} diff --git a/lib/src/parser/wind_parser.dart b/lib/src/parser/wind_parser.dart index 54cc2d4..148e485 100644 --- a/lib/src/parser/wind_parser.dart +++ b/lib/src/parser/wind_parser.dart @@ -5,6 +5,7 @@ import 'parsers/border_parser.dart'; import 'parsers/margin_parser.dart'; import 'parsers/padding_parser.dart'; import 'parsers/sizing_parser.dart'; +import 'parsers/position_parser.dart'; import 'parsers/flexbox_grid_parser.dart'; import 'parsers/wind_parser_interface.dart'; import 'parsers/text_parser.dart'; @@ -83,6 +84,7 @@ class WindParser { 'sizing': const SizingParser(), 'padding': const PaddingParser(), 'margin': const MarginParser(), + 'position': const PositionParser(), 'flexbox_grid': const FlexboxGridParser(), 'text': const TextParser(), 'debug': const DebugParser(), diff --git a/lib/src/parser/wind_style.dart b/lib/src/parser/wind_style.dart index 49ded4a..a034ad6 100644 --- a/lib/src/parser/wind_style.dart +++ b/lib/src/parser/wind_style.dart @@ -10,6 +10,9 @@ enum WindOverflow { visible, hidden, scroll, auto } /// Animation types for animate-* classes enum WindAnimationType { spin, ping, pulse, bounce, none } +/// Position types for positioned elements e.g., relative, absolute +enum WindPositionType { relative, absolute } + /// **The Immutable Style Object** /// /// `WindStyle` represents a resolved set of style properties derived from @@ -214,6 +217,23 @@ class WindStyle { /// Animation type e.g., animate-spin, animate-pulse, animate-bounce final WindAnimationType? animationType; + // ============== POSITIONING PROPERTIES ============== + + /// Position type e.g., relative, absolute + final WindPositionType? positionType; + + /// Top offset for positioned elements e.g., top-4, top-[10px] + final double? positionTop; + + /// Right offset for positioned elements e.g., right-4, right-[10px] + final double? positionRight; + + /// Bottom offset for positioned elements e.g., bottom-4, bottom-[10px] + final double? positionBottom; + + /// Left offset for positioned elements e.g., left-4, left-[10px] + final double? positionLeft; + const WindStyle({ this.isHidden = false, this.displayType = WindDisplayType.block, @@ -276,6 +296,11 @@ class WindStyle { this.strokeColor, this.animationType, this.preserveColors = false, + this.positionType, + this.positionTop, + this.positionRight, + this.positionBottom, + this.positionLeft, }); WindStyle copyWith({ @@ -340,6 +365,11 @@ class WindStyle { Color? strokeColor, WindAnimationType? animationType, bool? preserveColors, + WindPositionType? positionType, + double? positionTop, + double? positionRight, + double? positionBottom, + double? positionLeft, }) { final currentDec = this.decoration ?? const BoxDecoration(); @@ -419,6 +449,11 @@ class WindStyle { strokeColor: strokeColor ?? this.strokeColor, animationType: animationType ?? this.animationType, preserveColors: preserveColors ?? this.preserveColors, + positionType: positionType ?? this.positionType, + positionTop: positionTop ?? this.positionTop, + positionRight: positionRight ?? this.positionRight, + positionBottom: positionBottom ?? this.positionBottom, + positionLeft: positionLeft ?? this.positionLeft, ); } @@ -487,7 +522,12 @@ class WindStyle { fillColor == other.fillColor && strokeColor == other.strokeColor && animationType == other.animationType && - preserveColors == other.preserveColors; + preserveColors == other.preserveColors && + positionType == other.positionType && + positionTop == other.positionTop && + positionRight == other.positionRight && + positionBottom == other.positionBottom && + positionLeft == other.positionLeft; @override int get hashCode => @@ -551,7 +591,12 @@ class WindStyle { fillColor.hashCode ^ strokeColor.hashCode ^ animationType.hashCode ^ - preserveColors.hashCode; + preserveColors.hashCode ^ + positionType.hashCode ^ + positionTop.hashCode ^ + positionRight.hashCode ^ + positionBottom.hashCode ^ + positionLeft.hashCode; /// Calculates the effective line height as a multiplier for TextStyle.height. /// @@ -653,7 +698,12 @@ class WindStyle { 'fillColor: $fillColor, ' 'strokeColor: $strokeColor, ' 'animationType: $animationType, ' - 'preserveColors: $preserveColors' + 'preserveColors: $preserveColors, ' + 'positionType: $positionType, ' + 'positionTop: $positionTop, ' + 'positionRight: $positionRight, ' + 'positionBottom: $positionBottom, ' + 'positionLeft: $positionLeft' '}'; } } diff --git a/lib/src/widgets/w_div.dart b/lib/src/widgets/w_div.dart index fb37d09..6688f01 100644 --- a/lib/src/widgets/w_div.dart +++ b/lib/src/widgets/w_div.dart @@ -181,6 +181,7 @@ class WDiv extends StatelessWidget { final Widget? coreContent = _buildCoreStructure( styles: styles, logger: logger, + context: context, ); // 5. COMPOSE DECORATORS (The Decorator Layer) @@ -271,9 +272,27 @@ class WDiv extends StatelessWidget { Widget? _buildCoreStructure({ required WindStyle styles, required WindLogger logger, + required BuildContext context, }) { + final bool isRelative = styles.positionType == WindPositionType.relative; + // Case A: Single Child if (child != null) { + // Relative positioning: wrap in Stack + if (isRelative) { + logger.setCoreWidget("Stack(relative, single child)"); + if (_isAbsolutePositioned(child!)) { + return Stack( + clipBehavior: Clip.none, + children: [_buildPositionedChild(child!, context)], + ); + } + return Stack( + clipBehavior: Clip.none, + children: [child!], + ); + } + // If flex display is specified, wrap single child in Row/Column for alignment if (styles.displayType == WindDisplayType.flex) { final direction = styles.flexDirection ?? Axis.horizontal; @@ -330,6 +349,59 @@ class WDiv extends StatelessWidget { // Case B: Multiple Children (Layout required) if (children != null) { + // Relative positioning: separate normal vs absolute children, + // build normal layout, wrap absolutes in Positioned, combine in Stack. + if (isRelative) { + final normalChildren = []; + final absoluteChildren = []; + + for (final child in children!) { + if (_isAbsolutePositioned(child)) { + absoluteChildren.add(child); + } else { + normalChildren.add(child); + } + } + + final positionedWidgets = absoluteChildren + .map((child) => _buildPositionedChild(child, context)) + .toList(); + + // Build normal children through existing layout pipeline + Widget? normalLayout; + if (normalChildren.isNotEmpty) { + final type = styles.displayType ?? WindDisplayType.block; + // Temporarily use normalChildren for layout building + final tempDiv = WDiv( + className: className, + children: normalChildren, + ); + switch (type) { + case WindDisplayType.flex: + normalLayout = tempDiv._buildFlexStructure(styles, logger); + case WindDisplayType.grid: + normalLayout = tempDiv._buildGridStructure(styles, logger); + case WindDisplayType.wrap: + normalLayout = tempDiv._buildWrapStructure(styles, logger); + default: + normalLayout = tempDiv._buildBlockStructure(styles, logger); + } + } + + logger.setCoreWidget( + "Stack(relative, ${normalChildren.length} normal + " + "${absoluteChildren.length} absolute)", + ); + + return Stack( + clipBehavior: Clip.none, + children: [ + if (normalLayout != null) normalLayout, + ...positionedWidgets, + ], + ); + } + // If we have children but no display type specified, we fallback // to the default 'block' behavior. final type = styles.displayType ?? WindDisplayType.block; @@ -463,6 +535,38 @@ class WDiv extends StatelessWidget { className.contains('flex-5'); } + /// Checks if a child widget has `absolute` in its className. + /// Uses token-based matching (same pattern as `_hasShrinkZero`). + /// Matches both bare `absolute` and prefixed variants like `md:absolute`. + static bool _isAbsolutePositioned(Widget child) { + String? className; + if (child is WDiv) className = child.className; + if (child is WText) className = child.className; + if (className == null || className.isEmpty) return false; + for (final token in className.split(' ')) { + if (token == 'absolute' || token.endsWith(':absolute')) return true; + } + return false; + } + + /// Wraps an absolute-positioned child in a [Positioned] widget + /// by parsing the child's className for offset values. + Widget _buildPositionedChild(Widget child, BuildContext context) { + String? childClassName; + if (child is WDiv) childClassName = child.className; + if (child is WText) childClassName = child.className; + final childStyles = childClassName != null + ? WindParser.parse(childClassName, context) + : const WindStyle(); + return Positioned( + top: childStyles.positionTop, + right: childStyles.positionRight, + bottom: childStyles.positionBottom, + left: childStyles.positionLeft, + child: child, + ); + } + /// Builds a Grid layout using Wrap for flexible item heights. /// /// Unlike CSS Grid which allows variable heights, Flutter's GridView forces @@ -992,6 +1096,12 @@ class WDiv extends StatelessWidget { ); } + // Absolute-positioned elements are out of normal flow — + // skip Expanded/Flexible wrapping (parent handles Positioned). + if (styles.positionType == WindPositionType.absolute) { + return widgetToBuild ?? const SizedBox.shrink(); + } + // Apply Flex/Expanded (flex-*) if (styles.flex != null) { logger.wrapWith("Expanded", "flex: ${styles.flex}"); diff --git a/skills/wind-ui/SKILL.md b/skills/wind-ui/SKILL.md index 19323d6..1c034d4 100644 --- a/skills/wind-ui/SKILL.md +++ b/skills/wind-ui/SKILL.md @@ -45,6 +45,9 @@ Flutter constraint resolution differs fundamentally from CSS. Wind UI maps `clas | `flex flex-row` or `flex` | Row | Direction default | | `wrap` or `grid` | Wrap | NOT `flex-wrap` (no-op!) | | `overflow-y-auto` | SingleChildScrollView | Needs bounded height | +| `relative` | Stack(clipBehavior: Clip.none) | Children split: normal layout + Positioned | +| `absolute top-4 right-4` | Positioned(top: 16, right: 16) | Must be inside a `relative` parent | +| `absolute inset-0` | Positioned(top: 0, right: 0, bottom: 0, left: 0) | Full overlay pattern | | `hidden` | SizedBox.shrink() | | **Critical Layout Gotchas:** @@ -109,7 +112,26 @@ WDiv( ) ``` -**Note:** `h-full` inside a scrollable parent results in an infinite height error. Use `min-h-screen` instead. Native Flutter widgets (e.g., `ListView.builder`, charts) inside a `Row` or `Column` MUST be wrapped in a manual `Expanded()`. +```dart +// ❌ WRONG — absolute without relative parent +WDiv( + className: 'flex flex-row', + children: [ + WDiv(className: 'absolute top-0 right-0', child: badge), // No Stack! + ], +) + +// ✅ CORRECT — relative parent creates Stack +WDiv( + className: 'relative flex flex-row', + children: [ + WDiv(className: 'flex-1', child: content), + WDiv(className: 'absolute top-0 right-0', child: badge), // Positioned overlay + ], +) +``` + +**Note:** `h-full` inside a scrollable parent results in an infinite height error. Use `min-h-screen` instead. Native Flutter widgets (e.g., `ListView.builder`, charts) inside a `Row` or `Column` MUST be wrapped in a manual `Expanded()`. `absolute` children only work inside a `relative` parent — only `WDiv` and `WText` are detected as absolute; wrap other widgets in a `WDiv`. ## 3. Widget Quick Reference @@ -158,6 +180,13 @@ WDiv( **Border Radius:** `rounded` (4), `rounded-md` (6), `rounded-lg` (8), `rounded-xl` (12), `rounded-2xl` (16), `rounded-full` (9999) +**Position Types:** `relative` (renders Stack), `absolute` (renders Positioned inside a Stack) +**Offsets (spacing scale):** `top-{n}` `right-{n}` `bottom-{n}` `left-{n}` (e.g., `top-4` = 16px) +**Insets:** `inset-{n}` (all sides), `inset-x-{n}` (left+right), `inset-y-{n}` (top+bottom) +**Negative offsets:** `-top-{n}`, `-inset-{n}` (prefix with `-`) +**Arbitrary offsets:** `top-[24px]`, `left-[50%]` +**Note:** `fixed` and `sticky` are not implemented. Absolute children must be inside a `relative` parent. + ## 5. State & Modifier Prefixes | Prefix | Trigger | Example | diff --git a/test/parser/parsers/position_parser_test.dart b/test/parser/parsers/position_parser_test.dart new file mode 100644 index 0000000..49e8c1b --- /dev/null +++ b/test/parser/parsers/position_parser_test.dart @@ -0,0 +1,266 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fluttersdk_wind/src/parser/parsers/position_parser.dart'; +import 'package:fluttersdk_wind/src/parser/wind_context.dart'; +import 'package:fluttersdk_wind/src/parser/wind_parser.dart'; +import 'package:fluttersdk_wind/src/parser/wind_style.dart'; +import 'package:fluttersdk_wind/src/theme/wind_theme_data.dart'; + +// Helper function to create a WindContext for testing +WindContext createTestContext({ + bool isHovering = false, + bool isFocused = false, + bool isDisabled = false, + String activeBreakpoint = 'base', + Brightness brightness = Brightness.light, + String platform = 'unknown', + bool isMobile = false, +}) { + return WindContext( + theme: WindThemeData().copyWith(brightness: brightness), + activeBreakpoint: activeBreakpoint, + platform: platform, + isMobile: isMobile, + screenWidth: 400, + screenHeight: 800, + activeStates: { + if (isHovering) 'hover', + if (isFocused) 'focus', + if (isDisabled) 'disabled', + }, + ); +} + +void main() { + group('PositionParser', () { + late PositionParser parser; + late WindContext context; + + setUp(() { + WindParser.clearCache(); + parser = const PositionParser(); + context = createTestContext(); + }); + + group('canParse', () { + test('returns true for position type tokens', () { + expect(parser.canParse('relative'), isTrue); + expect(parser.canParse('absolute'), isTrue); + expect(parser.canParse('fixed'), isTrue); + expect(parser.canParse('sticky'), isTrue); + }); + + test('returns true for directional offset tokens', () { + expect(parser.canParse('top-4'), isTrue); + expect(parser.canParse('bottom-0'), isTrue); + expect(parser.canParse('left-2'), isTrue); + expect(parser.canParse('right-8'), isTrue); + }); + + test('returns true for inset tokens', () { + expect(parser.canParse('inset-0'), isTrue); + expect(parser.canParse('inset-x-4'), isTrue); + expect(parser.canParse('inset-y-2'), isTrue); + }); + + test('returns true for negative offset tokens', () { + expect(parser.canParse('-top-4'), isTrue); + expect(parser.canParse('-bottom-2'), isTrue); + expect(parser.canParse('-left-8'), isTrue); + expect(parser.canParse('-right-1'), isTrue); + expect(parser.canParse('-inset-2'), isTrue); + expect(parser.canParse('-inset-x-4'), isTrue); + expect(parser.canParse('-inset-y-2'), isTrue); + }); + + test('returns true for arbitrary value tokens', () { + expect(parser.canParse('top-[24px]'), isTrue); + expect(parser.canParse('left-[50%]'), isTrue); + expect(parser.canParse('-top-[10px]'), isTrue); + }); + + test('returns false for unrelated classes', () { + expect(parser.canParse('p-4'), isFalse); + expect(parser.canParse('flex'), isFalse); + expect(parser.canParse('bg-red-500'), isFalse); + expect(parser.canParse('text-lg'), isFalse); + expect(parser.canParse('m-4'), isFalse); + }); + + test('returns false for empty string', () { + expect(parser.canParse(''), isFalse); + }); + }); + + group('parse', () { + group('position type', () { + test('relative sets positionType to relative', () { + final styles = WindStyle(); + final result = parser.parse(styles, ['relative'], context); + + expect(result.positionType, WindPositionType.relative); + }); + + test('absolute sets positionType to absolute', () { + final styles = WindStyle(); + final result = parser.parse(styles, ['absolute'], context); + + expect(result.positionType, WindPositionType.absolute); + }); + }); + + group('fixed and sticky are claimed but ignored', () { + test('fixed does not set positionType', () { + final styles = WindStyle(); + final result = parser.parse(styles, ['fixed'], context); + + expect(result.positionType, isNull); + }); + + test('sticky does not set positionType', () { + final styles = WindStyle(); + final result = parser.parse(styles, ['sticky'], context); + + expect(result.positionType, isNull); + }); + }); + + group('theme scale offsets', () { + test('top-4 resolves to 16.0', () { + final styles = WindStyle(); + final result = parser.parse(styles, ['top-4'], context); + + expect(result.positionTop, 16.0); + }); + + test('bottom-0 resolves to 0.0', () { + final styles = WindStyle(); + final result = parser.parse(styles, ['bottom-0'], context); + + expect(result.positionBottom, 0.0); + }); + + test('left-8 resolves to 32.0', () { + final styles = WindStyle(); + final result = parser.parse(styles, ['left-8'], context); + + expect(result.positionLeft, 32.0); + }); + + test('right-2 resolves to 8.0', () { + final styles = WindStyle(); + final result = parser.parse(styles, ['right-2'], context); + + expect(result.positionRight, 8.0); + }); + }); + + group('inset shortcuts', () { + test('inset-4 sets all four sides to 16.0', () { + final styles = WindStyle(); + final result = parser.parse(styles, ['inset-4'], context); + + expect(result.positionTop, 16.0); + expect(result.positionRight, 16.0); + expect(result.positionBottom, 16.0); + expect(result.positionLeft, 16.0); + }); + + test('inset-x-2 sets left and right to 8.0', () { + final styles = WindStyle(); + final result = parser.parse(styles, ['inset-x-2'], context); + + expect(result.positionLeft, 8.0); + expect(result.positionRight, 8.0); + expect(result.positionTop, isNull); + expect(result.positionBottom, isNull); + }); + + test('inset-y-3 sets top and bottom to 12.0', () { + final styles = WindStyle(); + final result = parser.parse(styles, ['inset-y-3'], context); + + expect(result.positionTop, 12.0); + expect(result.positionBottom, 12.0); + expect(result.positionLeft, isNull); + expect(result.positionRight, isNull); + }); + }); + + group('arbitrary values', () { + test('top-[24px] resolves to 24.0', () { + final styles = WindStyle(); + final result = parser.parse(styles, ['top-[24px]'], context); + + expect(result.positionTop, 24.0); + }); + + test('left-[50%] resolves to 50.0', () { + final styles = WindStyle(); + final result = parser.parse(styles, ['left-[50%]'], context); + + expect(result.positionLeft, 50.0); + }); + }); + + group('negative offsets', () { + test('-top-4 resolves to -16.0', () { + final styles = WindStyle(); + final result = parser.parse(styles, ['-top-4'], context); + + expect(result.positionTop, -16.0); + }); + + test('-inset-2 sets all four sides to -8.0', () { + final styles = WindStyle(); + final result = parser.parse(styles, ['-inset-2'], context); + + expect(result.positionTop, -8.0); + expect(result.positionRight, -8.0); + expect(result.positionBottom, -8.0); + expect(result.positionLeft, -8.0); + }); + }); + + group('last class wins', () { + test('top-4 top-8 resolves to 32.0 (last wins)', () { + final styles = WindStyle(); + final result = parser.parse(styles, ['top-4', 'top-8'], context); + + expect(result.positionTop, 32.0); + }); + }); + + group('edge cases', () { + test('null classes returns styles unchanged', () { + final styles = WindStyle(); + final result = parser.parse(styles, null, context); + + expect(result, styles); + }); + + test('empty classes list returns styles unchanged', () { + final styles = WindStyle(); + final result = parser.parse(styles, [], context); + + expect(result, styles); + }); + }); + + group('mixed tokens', () { + test('absolute top-4 right-2 sets type and offsets', () { + final styles = WindStyle(); + final result = parser.parse( + styles, + ['absolute', 'top-4', 'right-2'], + context, + ); + + expect(result.positionType, WindPositionType.absolute); + expect(result.positionTop, 16.0); + expect(result.positionRight, 8.0); + }); + }); + }); + }); +} diff --git a/test/widgets/w_div/position_test.dart b/test/widgets/w_div/position_test.dart new file mode 100644 index 0000000..51dc4d5 --- /dev/null +++ b/test/widgets/w_div/position_test.dart @@ -0,0 +1,249 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fluttersdk_wind/fluttersdk_wind.dart'; + +void main() { + setUp(() { + WindParser.clearCache(); + }); + + group('WDiv Position — Stack/Positioned rendering', () { + testWidgets( + 'relative parent with children renders Stack widget in tree', + (tester) async { + await tester.pumpWidget( + MaterialApp( + home: WindTheme( + data: WindThemeData(), + child: const WDiv( + className: 'relative', + children: [ + WText('Child 1'), + WText('Child 2'), + ], + ), + ), + ), + ); + + expect(tester.takeException(), isNull); + expect(find.byType(Stack), findsOneWidget); + }, + ); + + testWidgets( + 'absolute child inside relative parent renders Positioned widget', + (tester) async { + await tester.pumpWidget( + MaterialApp( + home: WindTheme( + data: WindThemeData(), + child: const WDiv( + className: 'relative', + children: [ + WText('Normal child'), + WDiv( + className: 'absolute', + child: WText('Absolute child'), + ), + ], + ), + ), + ), + ); + + expect(tester.takeException(), isNull); + expect(find.byType(Stack), findsOneWidget); + expect(find.byType(Positioned), findsOneWidget); + }, + ); + + testWidgets( + 'mixed layout: relative flex parent with normal + absolute children', + (tester) async { + await tester.pumpWidget( + MaterialApp( + home: WindTheme( + data: WindThemeData(), + child: SizedBox( + width: 400, + height: 400, + child: const WDiv( + className: 'relative flex flex-row', + children: [ + WText('Normal 1'), + WText('Normal 2'), + WDiv( + className: 'absolute bottom-4 right-4', + child: WText('Absolute'), + ), + ], + ), + ), + ), + ), + ); + + expect(tester.takeException(), isNull); + + // Stack wraps everything + expect(find.byType(Stack), findsOneWidget); + + // Normal children rendered in a Row (flex-row) + expect(find.byType(Row), findsOneWidget); + + // Absolute child wrapped in Positioned + final positionedFinder = find.byType(Positioned); + expect(positionedFinder, findsOneWidget); + + final positioned = tester.widget(positionedFinder); + expect(positioned.bottom, 16.0); // bottom-4 = 4 * 4 = 16 + expect(positioned.right, 16.0); // right-4 = 4 * 4 = 16 + }, + ); + + testWidgets( + 'absolute inset-0 renders Positioned with all 4 sides = 0', + (tester) async { + await tester.pumpWidget( + MaterialApp( + home: WindTheme( + data: WindThemeData(), + child: const WDiv( + className: 'relative', + children: [ + WText('Background'), + WDiv( + className: 'absolute inset-0', + child: WText('Overlay'), + ), + ], + ), + ), + ), + ); + + expect(tester.takeException(), isNull); + + final positionedFinder = find.byType(Positioned); + expect(positionedFinder, findsOneWidget); + + final positioned = tester.widget(positionedFinder); + expect(positioned.top, 0.0); + expect(positioned.right, 0.0); + expect(positioned.bottom, 0.0); + expect(positioned.left, 0.0); + }, + ); + + testWidgets( + 'absolute child skips Expanded/Flexible wrapping', + (tester) async { + await tester.pumpWidget( + MaterialApp( + home: WindTheme( + data: WindThemeData(), + child: const WDiv( + className: 'relative', + children: [ + WDiv( + className: 'absolute top-4 left-2', + child: WText('Positioned'), + ), + ], + ), + ), + ), + ); + + expect(tester.takeException(), isNull); + + // Should have Positioned, NOT Expanded or Flexible + expect(find.byType(Positioned), findsOneWidget); + expect(find.byType(Expanded), findsNothing); + expect(find.byType(Flexible), findsNothing); + + final positioned = tester.widget( + find.byType(Positioned), + ); + expect(positioned.top, 16.0); // top-4 = 16 + expect(positioned.left, 8.0); // left-2 = 8 + }, + ); + + testWidgets( + 'single child with relative wraps in Stack', + (tester) async { + await tester.pumpWidget( + MaterialApp( + home: WindTheme( + data: WindThemeData(), + child: const WDiv( + className: 'relative', + child: WText('Single child'), + ), + ), + ), + ); + + expect(tester.takeException(), isNull); + expect(find.byType(Stack), findsOneWidget); + }, + ); + + testWidgets( + 'relative without any absolute children still renders Stack', + (tester) async { + await tester.pumpWidget( + MaterialApp( + home: WindTheme( + data: WindThemeData(), + child: const WDiv( + className: 'relative', + children: [ + WText('Normal 1'), + WText('Normal 2'), + ], + ), + ), + ), + ); + + expect(tester.takeException(), isNull); + expect(find.byType(Stack), findsOneWidget); + expect(find.byType(Positioned), findsNothing); + }, + ); + + testWidgets( + 'negative offset: absolute -top-2 renders Positioned(top: -8.0)', + (tester) async { + await tester.pumpWidget( + MaterialApp( + home: WindTheme( + data: WindThemeData(), + child: const WDiv( + className: 'relative', + children: [ + WText('Content'), + WDiv( + className: 'absolute -top-2', + child: WText('Negative offset'), + ), + ], + ), + ), + ), + ); + + expect(tester.takeException(), isNull); + + final positionedFinder = find.byType(Positioned); + expect(positionedFinder, findsOneWidget); + + final positioned = tester.widget(positionedFinder); + expect(positioned.top, -8.0); // -top-2 = -(2 * 4) = -8 + }, + ); + }); +} From aa8b74e64c81c83d77190c172bf0fc423eadb5cf Mon Sep 17 00:00:00 2001 From: Anilcan Cakir Date: Sat, 4 Apr 2026 23:21:51 +0300 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20address=20Copilot=20review=20?= =?UTF-8?q?=E2=80=94=20context-aware=20positioning,=20drop=20%=20offsets,?= =?UTF-8?q?=20dark=20mode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - _isAbsolutePositioned now uses WindParser.parse with context + states instead of raw token matching — handles responsive/state prefixes correctly - _buildPositionedChild passes child states for state-prefixed offsets - Dynamic className/states extraction supports all Wind widgets, not just WDiv/WText - Remove % unit from arbitrary offsets (Flutter Positioned uses logical pixels) - Add dark: variants to all colors in positioning example page - Use wrapWithTheme() helper in position widget tests --- CHANGELOG.md | 2 +- README.md | 2 +- doc/layout/positioning.md | 10 +- example/lib/pages/layout/positioning.dart | 32 ++-- lib/src/parser/parsers/position_parser.dart | 14 +- lib/src/widgets/w_div.dart | 51 +++-- skills/wind-ui/SKILL.md | 2 +- test/parser/parsers/position_parser_test.dart | 8 +- test/widgets/w_div/position_test.dart | 180 +++++++++--------- 9 files changed, 153 insertions(+), 148 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c3ef869..0817406 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ This project follows [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.0. - **Offset Utilities**: `top-*`, `right-*`, `bottom-*`, `left-*` offset tokens using spacing scale - **Inset Shortcuts**: `inset-*`, `inset-x-*`, `inset-y-*` for multi-side offsets - **Negative Offsets**: `-top-*`, `-inset-*` for negative positioning -- **Arbitrary Position Values**: `top-[24px]`, `left-[50%]` bracket syntax +- **Arbitrary Position Values**: `top-[24px]`, `left-[12px]` bracket syntax (px only) --- diff --git a/README.md b/README.md index 2674702..15b3a0b 100644 --- a/README.md +++ b/README.md @@ -247,7 +247,7 @@ WDynamic(
Layout — flex, grid, positioning, overflow -`flex` `flex-row` `flex-col` `flex-wrap` `flex-1` `grid` `grid-cols-{n}` `gap-{n}` `justify-center` `justify-between` `items-center` `items-start` `self-center` `wrap` `hidden` `overflow-hidden` `overflow-y-auto` `relative` `absolute` `top-{n}` `right-{n}` `bottom-{n}` `left-{n}` `inset-{n}` `inset-x-{n}` `inset-y-{n}` `top-[24px]` `left-[50%]` `-top-{n}` `-inset-{n}` +`flex` `flex-row` `flex-col` `flex-wrap` `flex-1` `grid` `grid-cols-{n}` `gap-{n}` `justify-center` `justify-between` `items-center` `items-start` `self-center` `wrap` `hidden` `overflow-hidden` `overflow-y-auto` `relative` `absolute` `top-{n}` `right-{n}` `bottom-{n}` `left-{n}` `inset-{n}` `inset-x-{n}` `inset-y-{n}` `top-[24px]` `left-[24px]` `-top-{n}` `-inset-{n}`
diff --git a/doc/layout/positioning.md b/doc/layout/positioning.md index 08ad47b..9dd4166 100644 --- a/doc/layout/positioning.md +++ b/doc/layout/positioning.md @@ -152,22 +152,16 @@ WDiv( ## Arbitrary Values -Use bracket notation when you need an exact pixel, percentage, or rem value not in the theme scale. +Use bracket notation when you need an exact pixel value not in the theme scale. Only `px` values are supported — percentage (`%`) offsets are not supported because Flutter's `Positioned` widget uses logical pixels, not relative units. ```dart // Exact pixel value -WDiv(className: 'absolute top-[24px] left-[24px]') - -// Percentage — center horizontally -WDiv(className: 'absolute left-[50%]') +WDiv(className: 'absolute top-[24px] left-[12px]') // Mixed — precise multi-side offset WDiv(className: 'absolute top-[12px] right-[8px] bottom-[12px] left-[8px]') ``` -> [!NOTE] -> Percentage values like `left-[50%]` set the offset to 50% of the parent's width. To visually center an absolute element, pair it with a negative translate if needed (see Common Patterns below). - ## Common Patterns diff --git a/example/lib/pages/layout/positioning.dart b/example/lib/pages/layout/positioning.dart index acc188e..dae198f 100644 --- a/example/lib/pages/layout/positioning.dart +++ b/example/lib/pages/layout/positioning.dart @@ -66,12 +66,12 @@ class _PositioningExamplePageState extends State { children: [ WDiv( className: - 'w-12 h-12 bg-blue-500 rounded-xl flex items-center justify-center', + 'w-12 h-12 bg-blue-500 dark:bg-blue-600 rounded-xl flex items-center justify-center', child: WIcon(Icons.notifications_outlined), ), WDiv( className: - 'absolute top-0 right-0 w-5 h-5 bg-red-500 rounded-full flex items-center justify-center', + 'absolute top-0 right-0 w-5 h-5 bg-red-500 dark:bg-red-600 rounded-full flex items-center justify-center', child: WText('3', className: 'text-white text-xs font-bold'), ), ], @@ -82,12 +82,12 @@ class _PositioningExamplePageState extends State { children: [ WDiv( className: - 'w-12 h-12 bg-green-500 rounded-xl flex items-center justify-center', + 'w-12 h-12 bg-green-500 dark:bg-green-600 rounded-xl flex items-center justify-center', child: WIcon(Icons.shopping_cart_outlined), ), WDiv( className: - 'absolute top-0 right-0 w-5 h-5 bg-orange-500 rounded-full flex items-center justify-center', + 'absolute top-0 right-0 w-5 h-5 bg-orange-500 dark:bg-orange-600 rounded-full flex items-center justify-center', child: WText('12', className: 'text-white text-xs font-bold'), ), ], @@ -98,12 +98,12 @@ class _PositioningExamplePageState extends State { children: [ WDiv( className: - 'w-12 h-12 bg-indigo-500 rounded-xl flex items-center justify-center', + 'w-12 h-12 bg-indigo-500 dark:bg-indigo-600 rounded-xl flex items-center justify-center', child: WIcon(Icons.chat_bubble_outlined), ), WDiv( className: - 'absolute top-1 right-1 w-3 h-3 bg-green-400 rounded-full', + 'absolute top-1 right-1 w-3 h-3 bg-green-400 dark:bg-green-500 rounded-full', ), ], ), @@ -134,7 +134,7 @@ class _PositioningExamplePageState extends State { className: 'absolute bottom-4 right-4', child: WDiv( className: - 'w-12 h-12 bg-violet-600 rounded-full shadow-lg flex items-center justify-center', + 'w-12 h-12 bg-violet-600 dark:bg-violet-700 rounded-full shadow-lg flex items-center justify-center', child: WIcon(Icons.add, className: 'text-white'), ), ), @@ -151,7 +151,8 @@ class _PositioningExamplePageState extends State { 'absolute inset-0 stretches a child to cover the entire parent', children: [ WDiv( - className: 'relative h-36 bg-blue-500 rounded-lg overflow-hidden', + className: + 'relative h-36 bg-blue-500 dark:bg-blue-600 rounded-lg overflow-hidden', children: [ WDiv( className: 'p-4 flex flex-col gap-1', @@ -197,7 +198,7 @@ class _PositioningExamplePageState extends State { children: [ WDiv( className: - 'absolute -top-3 left-4 px-3 py-1 bg-violet-600 rounded-full', + 'absolute -top-3 left-4 px-3 py-1 bg-violet-600 dark:bg-violet-700 rounded-full', child: WText( 'Featured', className: 'text-white text-xs font-semibold', @@ -230,26 +231,27 @@ class _PositioningExamplePageState extends State { children: [ WDiv( className: - 'flex-1 h-16 bg-blue-400 rounded-lg flex items-center justify-center', + 'flex-1 h-16 bg-blue-400 dark:bg-blue-500 rounded-lg flex items-center justify-center', child: WText('flex item 1', className: 'text-white text-sm'), ), WDiv( className: - 'flex-1 h-16 bg-blue-400 rounded-lg flex items-center justify-center', + 'flex-1 h-16 bg-blue-400 dark:bg-blue-500 rounded-lg flex items-center justify-center', child: WText('flex item 2', className: 'text-white text-sm'), ), WDiv( className: - 'flex-1 h-16 bg-blue-400 rounded-lg flex items-center justify-center', + 'flex-1 h-16 bg-blue-400 dark:bg-blue-500 rounded-lg flex items-center justify-center', child: WText('flex item 3', className: 'text-white text-sm'), ), // Floating badge over the flex container WDiv( className: - 'absolute top-2 right-2 px-2 py-1 bg-amber-400 rounded-full', + 'absolute top-2 right-2 px-2 py-1 bg-amber-400 dark:bg-amber-500 rounded-full', child: WText( 'NEW', - className: 'text-amber-900 text-xs font-bold', + className: + 'text-amber-900 dark:text-amber-950 text-xs font-bold', ), ), ], @@ -301,7 +303,7 @@ class _PositioningExamplePageState extends State { WButton( onTap: () => setState(() => _showOverlay = !_showOverlay), className: - 'px-4 py-2 bg-violet-600 rounded-lg flex items-center gap-2', + 'px-4 py-2 bg-violet-600 dark:bg-violet-700 rounded-lg flex items-center gap-2', child: WText( _showOverlay ? 'Hide Overlay' : 'Show Overlay', className: 'text-white text-sm font-medium', diff --git a/lib/src/parser/parsers/position_parser.dart b/lib/src/parser/parsers/position_parser.dart index 90daa14..ec0a692 100644 --- a/lib/src/parser/parsers/position_parser.dart +++ b/lib/src/parser/parsers/position_parser.dart @@ -19,7 +19,7 @@ import 'wind_parser_interface.dart'; /// /// ### Values: /// - **Theme Scale:** `top-4`, `inset-x-2` (resolves via `WindThemeData.getSpacing`) -/// - **Arbitrary Values:** `top-[24px]`, `left-[50%]` (supports px and % suffixes) +/// - **Arbitrary Values:** `top-[24px]`, `left-[12px]` (supports px suffix only; `%` is unsupported) /// /// Returns a [WindStyle] with resolved position properties using "Last Class Wins" logic. class PositionParser implements WindParserInterface { @@ -35,9 +35,11 @@ class PositionParser implements WindParserInterface { ); /// Regex for arbitrary offset classes - /// e.g., top-[24px], left-[50%], -inset-[10px] + /// e.g., top-[24px], left-[12px], -inset-[10px] + /// Note: percentage values (e.g., left-[50%]) are intentionally unsupported — + /// Flutter's Positioned uses logical pixels, not percentages. static final _arbitraryOffsetRegex = RegExp( - r'^-?(?top|bottom|left|right|inset-x|inset-y|inset)-\[(?[0-9.]+(?:px|%)?)\]$', + r'^-?(?top|bottom|left|right|inset-x|inset-y|inset)-\[(?[0-9.]+(?:px)?)\]$', ); @override @@ -81,14 +83,12 @@ class PositionParser implements WindParserInterface { double? value; String? root; - // 2. Arbitrary Values (e.g., top-[24px], -left-[50%]) + // 2. Arbitrary Values (e.g., top-[24px], left-[12px]) final arbitraryMatch = _arbitraryOffsetRegex.firstMatch(className); if (arbitraryMatch != null) { root = arbitraryMatch.namedGroup('root')!; final valueStr = arbitraryMatch.namedGroup('value')!; - value = double.tryParse( - valueStr.replaceAll('px', '').replaceAll('%', ''), - ); + value = double.tryParse(valueStr.replaceAll('px', '')); } // 3. Theme Values (e.g., top-4, inset-x-2) diff --git a/lib/src/widgets/w_div.dart b/lib/src/widgets/w_div.dart index 6688f01..5a69749 100644 --- a/lib/src/widgets/w_div.dart +++ b/lib/src/widgets/w_div.dart @@ -281,7 +281,7 @@ class WDiv extends StatelessWidget { // Relative positioning: wrap in Stack if (isRelative) { logger.setCoreWidget("Stack(relative, single child)"); - if (_isAbsolutePositioned(child!)) { + if (_isAbsolutePositioned(child!, context)) { return Stack( clipBehavior: Clip.none, children: [_buildPositionedChild(child!, context)], @@ -356,7 +356,7 @@ class WDiv extends StatelessWidget { final absoluteChildren = []; for (final child in children!) { - if (_isAbsolutePositioned(child)) { + if (_isAbsolutePositioned(child, context)) { absoluteChildren.add(child); } else { normalChildren.add(child); @@ -535,28 +535,45 @@ class WDiv extends StatelessWidget { className.contains('flex-5'); } - /// Checks if a child widget has `absolute` in its className. - /// Uses token-based matching (same pattern as `_hasShrinkZero`). - /// Matches both bare `absolute` and prefixed variants like `md:absolute`. - static bool _isAbsolutePositioned(Widget child) { - String? className; - if (child is WDiv) className = child.className; - if (child is WText) className = child.className; - if (className == null || className.isEmpty) return false; - for (final token in className.split(' ')) { - if (token == 'absolute' || token.endsWith(':absolute')) return true; + /// Extracts `className` from any Wind widget via dynamic access. + static String? _extractChildClassName(Widget child) { + try { + final dynamic windChild = child; + final Object? className = windChild.className; + return className is String ? className : null; + } catch (_) { + return null; } - return false; + } + + /// Extracts `states` from any Wind widget via dynamic access. + static Set? _extractChildStates(Widget child) { + try { + final dynamic windChild = child; + final Object? states = windChild.states; + return states is Set ? states : null; + } catch (_) { + return null; + } + } + + /// Checks if a child widget resolves to `absolute` positioning + /// by parsing its className through WindParser (context-aware). + static bool _isAbsolutePositioned(Widget child, BuildContext context) { + final className = _extractChildClassName(child); + if (className == null || className.isEmpty) return false; + final states = _extractChildStates(child); + final childStyles = WindParser.parse(className, context, states: states); + return childStyles.positionType == WindPositionType.absolute; } /// Wraps an absolute-positioned child in a [Positioned] widget /// by parsing the child's className for offset values. Widget _buildPositionedChild(Widget child, BuildContext context) { - String? childClassName; - if (child is WDiv) childClassName = child.className; - if (child is WText) childClassName = child.className; + final childClassName = _extractChildClassName(child); + final childStates = _extractChildStates(child); final childStyles = childClassName != null - ? WindParser.parse(childClassName, context) + ? WindParser.parse(childClassName, context, states: childStates) : const WindStyle(); return Positioned( top: childStyles.positionTop, diff --git a/skills/wind-ui/SKILL.md b/skills/wind-ui/SKILL.md index 1c034d4..df93285 100644 --- a/skills/wind-ui/SKILL.md +++ b/skills/wind-ui/SKILL.md @@ -184,7 +184,7 @@ WDiv( **Offsets (spacing scale):** `top-{n}` `right-{n}` `bottom-{n}` `left-{n}` (e.g., `top-4` = 16px) **Insets:** `inset-{n}` (all sides), `inset-x-{n}` (left+right), `inset-y-{n}` (top+bottom) **Negative offsets:** `-top-{n}`, `-inset-{n}` (prefix with `-`) -**Arbitrary offsets:** `top-[24px]`, `left-[50%]` +**Arbitrary offsets:** `top-[24px]`, `left-[24px]` (px only; `%` is unsupported) **Note:** `fixed` and `sticky` are not implemented. Absolute children must be inside a `relative` parent. ## 5. State & Modifier Prefixes diff --git a/test/parser/parsers/position_parser_test.dart b/test/parser/parsers/position_parser_test.dart index 49e8c1b..01fa3a1 100644 --- a/test/parser/parsers/position_parser_test.dart +++ b/test/parser/parsers/position_parser_test.dart @@ -75,7 +75,8 @@ void main() { test('returns true for arbitrary value tokens', () { expect(parser.canParse('top-[24px]'), isTrue); - expect(parser.canParse('left-[50%]'), isTrue); + expect(parser.canParse('left-[50%]'), + isTrue); // canParse only checks prefix, not value format expect(parser.canParse('-top-[10px]'), isTrue); }); @@ -195,11 +196,12 @@ void main() { expect(result.positionTop, 24.0); }); - test('left-[50%] resolves to 50.0', () { + test('left-[50%] is ignored when percentage offsets are unsupported', + () { final styles = WindStyle(); final result = parser.parse(styles, ['left-[50%]'], context); - expect(result.positionLeft, 50.0); + expect(result.positionLeft, isNull); }); }); diff --git a/test/widgets/w_div/position_test.dart b/test/widgets/w_div/position_test.dart index 51dc4d5..0fd50ad 100644 --- a/test/widgets/w_div/position_test.dart +++ b/test/widgets/w_div/position_test.dart @@ -2,6 +2,20 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:fluttersdk_wind/fluttersdk_wind.dart'; +/// Helper to wrap widget in MaterialApp with WindTheme. +/// +/// Note: Scaffold is intentionally omitted here because several tests assert +/// [find.byType(Stack)] with [findsOneWidget] — Scaffold internally renders +/// its own Stack, which would cause those assertions to fail. +Widget wrapWithTheme(Widget child) { + return MaterialApp( + home: WindTheme( + data: WindThemeData(), + child: child, + ), + ); +} + void main() { setUp(() { WindParser.clearCache(); @@ -12,16 +26,13 @@ void main() { 'relative parent with children renders Stack widget in tree', (tester) async { await tester.pumpWidget( - MaterialApp( - home: WindTheme( - data: WindThemeData(), - child: const WDiv( - className: 'relative', - children: [ - WText('Child 1'), - WText('Child 2'), - ], - ), + wrapWithTheme( + const WDiv( + className: 'relative', + children: [ + WText('Child 1'), + WText('Child 2'), + ], ), ), ); @@ -35,19 +46,16 @@ void main() { 'absolute child inside relative parent renders Positioned widget', (tester) async { await tester.pumpWidget( - MaterialApp( - home: WindTheme( - data: WindThemeData(), - child: const WDiv( - className: 'relative', - children: [ - WText('Normal child'), - WDiv( - className: 'absolute', - child: WText('Absolute child'), - ), - ], - ), + wrapWithTheme( + const WDiv( + className: 'relative', + children: [ + WText('Normal child'), + WDiv( + className: 'absolute', + child: WText('Absolute child'), + ), + ], ), ), ); @@ -62,23 +70,20 @@ void main() { 'mixed layout: relative flex parent with normal + absolute children', (tester) async { await tester.pumpWidget( - MaterialApp( - home: WindTheme( - data: WindThemeData(), - child: SizedBox( - width: 400, - height: 400, - child: const WDiv( - className: 'relative flex flex-row', - children: [ - WText('Normal 1'), - WText('Normal 2'), - WDiv( - className: 'absolute bottom-4 right-4', - child: WText('Absolute'), - ), - ], - ), + wrapWithTheme( + SizedBox( + width: 400, + height: 400, + child: const WDiv( + className: 'relative flex flex-row', + children: [ + WText('Normal 1'), + WText('Normal 2'), + WDiv( + className: 'absolute bottom-4 right-4', + child: WText('Absolute'), + ), + ], ), ), ), @@ -106,19 +111,16 @@ void main() { 'absolute inset-0 renders Positioned with all 4 sides = 0', (tester) async { await tester.pumpWidget( - MaterialApp( - home: WindTheme( - data: WindThemeData(), - child: const WDiv( - className: 'relative', - children: [ - WText('Background'), - WDiv( - className: 'absolute inset-0', - child: WText('Overlay'), - ), - ], - ), + wrapWithTheme( + const WDiv( + className: 'relative', + children: [ + WText('Background'), + WDiv( + className: 'absolute inset-0', + child: WText('Overlay'), + ), + ], ), ), ); @@ -140,18 +142,15 @@ void main() { 'absolute child skips Expanded/Flexible wrapping', (tester) async { await tester.pumpWidget( - MaterialApp( - home: WindTheme( - data: WindThemeData(), - child: const WDiv( - className: 'relative', - children: [ - WDiv( - className: 'absolute top-4 left-2', - child: WText('Positioned'), - ), - ], - ), + wrapWithTheme( + const WDiv( + className: 'relative', + children: [ + WDiv( + className: 'absolute top-4 left-2', + child: WText('Positioned'), + ), + ], ), ), ); @@ -175,13 +174,10 @@ void main() { 'single child with relative wraps in Stack', (tester) async { await tester.pumpWidget( - MaterialApp( - home: WindTheme( - data: WindThemeData(), - child: const WDiv( - className: 'relative', - child: WText('Single child'), - ), + wrapWithTheme( + const WDiv( + className: 'relative', + child: WText('Single child'), ), ), ); @@ -195,16 +191,13 @@ void main() { 'relative without any absolute children still renders Stack', (tester) async { await tester.pumpWidget( - MaterialApp( - home: WindTheme( - data: WindThemeData(), - child: const WDiv( - className: 'relative', - children: [ - WText('Normal 1'), - WText('Normal 2'), - ], - ), + wrapWithTheme( + const WDiv( + className: 'relative', + children: [ + WText('Normal 1'), + WText('Normal 2'), + ], ), ), ); @@ -219,19 +212,16 @@ void main() { 'negative offset: absolute -top-2 renders Positioned(top: -8.0)', (tester) async { await tester.pumpWidget( - MaterialApp( - home: WindTheme( - data: WindThemeData(), - child: const WDiv( - className: 'relative', - children: [ - WText('Content'), - WDiv( - className: 'absolute -top-2', - child: WText('Negative offset'), - ), - ], - ), + wrapWithTheme( + const WDiv( + className: 'relative', + children: [ + WText('Content'), + WDiv( + className: 'absolute -top-2', + child: WText('Negative offset'), + ), + ], ), ), );