Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,15 @@ This project follows [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.0.

## [Unreleased]

### Changed

- Wind's internal `flex` layout is now fully intrinsic-safe: the column cross-axis stretch and the `basis-*` resolution no longer use a `LayoutBuilder`. Column stretch is a real render object (`WindCrossStretch`) and fractional `basis-*` resolves against the flex's own extent via `WindMainExtentProvider`/`WindFractionBasis`. A `flex flex-col` (with or without `basis-*`) now renders inside an `items-stretch` grid cell, under a Flutter `IntrinsicHeight`/`IntrinsicWidth`, or in a `Table` cell without the `LayoutBuilder does not support returning intrinsic dimensions` assert (the web `_owner != null` cascade that produced). (`lib/src/widgets/w_div.dart`, `lib/src/widgets/wind_equal_height_row.dart`) (WIND-4)

### Added

- Min-width-stretch horizontal scroll primitive ("fill on desktop, scroll on narrow", the shadcn Table pattern), composed from existing tokens with no new className: `overflow-x-auto` on a wrapper with `w-full` (optionally `min-w-[Npx]`) on the inner content. `w-full` inside a horizontal scroll is now sized to `max(viewport, min-w-*)` via the threaded viewport width instead of asserting on the scroll's unbounded width, so the content fills the viewport when wide and honors its min width (scrolling) when narrow. (`lib/src/widgets/wind_min_width_scroll.dart`, `lib/src/state/wind_min_width_scroll_scope.dart`, `lib/src/widgets/w_div.dart`, `example/lib/pages/layout/responsive_table.dart`) (WIND-4)
- Explicit `flex flex-col items-stretch` now equalizes child widths (every eligible child fills the column), closing the asymmetry with `grid ... items-stretch`. Like the smart-stretch default it is intrinsic-safe and unbounded-safe (no `LayoutBuilder`, no infinite-width `SizedBox`), so it also works in a bare `Row` slot or under an intrinsic-measuring ancestor. (`lib/src/widgets/w_div.dart`, `lib/src/widgets/wind_equal_height_row.dart`) (WIND-4)
- Actionable dev-time assert for `h-full` inside a vertical scroll: a child that resolves `h-full` under an `overflow-y-auto`/`overflow-y-scroll` parent (an unbounded height) now fails fast with a message pointing at the fix (`flex-1` inside a `flex flex-col`), instead of a cryptic Flutter unbounded-height error. The scrollable parent threads the signal down via `WindMinWidthScrollScope`; the assert is stripped in release. (`lib/src/widgets/w_div.dart`, `lib/src/state/wind_min_width_scroll_scope.dart`) (WIND-4)
- `cursor-*` utilities: a new `CursorParser` maps Tailwind cursor names (`cursor-pointer`, `cursor-not-allowed`, `cursor-text`, `cursor-grab`/`cursor-grabbing`, `cursor-zoom-in`/`cursor-zoom-out`, the full resize set, etc.) to the matching `SystemMouseCursors`. `WDiv` applies the resolved cursor through a `MouseRegion`, so a plain container can feel clickable on web/desktop without `WAnchor` or a manual `MouseRegion`; on a `hover:`/`focus:`/`active:` `WDiv` the cursor `MouseRegion` sits below the auto-wrapped `WAnchor` and wins. Inert on touch platforms; last token wins; unknown names no-op. (`lib/src/parser/parsers/cursor_parser.dart`, `lib/src/parser/wind_style.dart`, `lib/src/widgets/w_div.dart`) (#125)
- `size-*` sizing utility (Tailwind v3.4+ shorthand): `size-2`, `size-full`, `size-1/2`, `size-[20px]`, `size-[50%]`, `size-screen` set BOTH width and height in one token. Fixes childless `WDiv` sizing: a `WDiv(className: 'size-2 rounded-full bg-...')` status dot now renders its box instead of collapsing (the previous gap was that `size-*` was unrecognized, not that `w-*`/`h-*` were ignored, which already worked childless). A later `w-*`/`h-*` overrides the matching axis. (`lib/src/parser/parsers/sizing_parser.dart`) (#123)
- `grid` equal-height rows via `items-stretch`. By default a Wind `grid` renders as a `Wrap` and sizes each cell to its own content, leaving a row ragged when one card is taller. Adding `items-stretch` (CSS Grid's default `align-items: stretch`) now builds the grid as a column of `IntrinsicHeight` rows so every cell in a row matches the tallest, and cells divide the row width evenly. Cells should size from their own content; `h-full` on a stretched cell is unsupported (it inserts a `LayoutBuilder` that asserts under `IntrinsicHeight`, see the intrinsic-sizing limitation docs). (`lib/src/widgets/w_div.dart`) (#126)
Expand Down
4 changes: 2 additions & 2 deletions doc/layout/flexbox.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,9 +208,9 @@ 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`, `WAnchor` (any child), and `WButton` child that does not control its own width to the column width, matching CSS `align-items: stretch`. For `WAnchor`: when the anchor wraps a `WDiv`, the inner `WDiv`'s className decides (so `WAnchor > WDiv(w-32)` keeps 128 px; a `WAnchor > WDiv` with a self-flex token is excluded just as a direct self-flexing `WDiv` is); when the anchor wraps a `WText` or raw widget, the anchor stretches by policy so its tap surface fills the column. 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, bare `WText` leaves, and raw Flutter widgets. `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.
> **Column cross-axis stretch (default AND explicit `items-stretch`).** A `flex flex-col` with no explicit `items-*` token, OR with an explicit `items-stretch`, stretches each `WDiv`, `WAnchor` (any child), and `WButton` child that does not control its own width to the column width, matching CSS `align-items: stretch`. Explicit `items-stretch` therefore equalizes child widths (every eligible child fills the column), closing the asymmetry with `grid ... items-stretch`. For `WAnchor`: when the anchor wraps a `WDiv`, the inner `WDiv`'s className decides (so `WAnchor > WDiv(w-32)` keeps 128 px; a `WAnchor > WDiv` with a self-flex token is excluded just as a direct self-flexing `WDiv` is); when the anchor wraps a `WText` or raw widget, the anchor stretches by policy so its tap surface fills the column. 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`), `basis-*` children, absolute children, bare `WText` leaves, and raw Flutter widgets. `shrink-0` / `flex-none` children still stretch on the cross axis (`flex-shrink` is main-axis only, matching CSS). Add `items-start` / `items-center` / `items-end` to turn stretch 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.

> **Layout stability: avoid `IntrinsicHeight` / `IntrinsicWidth` in animated subtrees.** Flutter's `IntrinsicHeight` and `IntrinsicWidth` perform an intrinsic-dimension pass that reads child sizes mid-layout. Inside a sheet or route open animation (e.g. `DraggableScrollableSheet`, `PageRouteBuilder`) this lands a `RenderBox was not laid out` assertion because the animation drives a frame before intrinsics resolve. For a connector, rail, or divider that must fill the cross axis to match the tallest sibling, use a `Stack` with a `Positioned(top: 0, bottom: 0)` line instead, or use wind's own `items-stretch` column which is intrinsic-free and animation-safe. Wind itself uses no `IntrinsicHeight` or `IntrinsicWidth`; its smart column stretch is `LayoutBuilder` + `SizedBox(width: double.infinity)` (see `lib/src/widgets/w_div.dart`).
> **Layout stability: wind's flex is intrinsic-safe.** Flutter's `IntrinsicHeight` and `IntrinsicWidth` perform an intrinsic-dimension pass that reads child sizes mid-layout, and a `LayoutBuilder` on that path asserts `LayoutBuilder does not support returning intrinsic dimensions`. Wind's flex uses NO `LayoutBuilder`: column cross-axis stretch is a real render object (`WindCrossStretch`), and `basis-*` resolves against the flex's own extent via a `WindMainExtentProvider` (see `lib/src/widgets/w_div.dart` and `wind_equal_height_row.dart`). So a `flex flex-col` (with or without `basis-*`) renders correctly inside an `items-stretch` grid cell, under an `IntrinsicHeight`/`IntrinsicWidth`, or in a `Table` cell without asserting. For a connector, rail, or divider that must fill the cross axis to match the tallest sibling, prefer a `Stack` with a `Positioned(top: 0, bottom: 0)` line, or use wind's own `items-stretch` column (also intrinsic-free and animation-safe).
>
> ```dart
> // Safe connector pattern: Stack + Positioned, no IntrinsicHeight
Expand Down
25 changes: 25 additions & 0 deletions doc/layout/overflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,31 @@ WDiv(className: 'overflow-x-scroll overflow-y-hidden')
| `overflow-x-hidden` | Clip horizontal overflow |
| `overflow-y-visible` | Allow vertical overflow |

<a name="min-width-scroll"></a>
## Fill on Desktop, Scroll on Narrow (Responsive Table)

Compose `overflow-x-auto` on a wrapper with `w-full` (optionally `min-w-[Npx]`) on the inner content, the same pattern shadcn's `<Table>` uses. The inner content fills the viewport when it is wide and honors its minimum width (so the wrapper scrolls) when it is narrow:

```dart
WDiv(
className: 'overflow-x-auto',
child: WDiv(
className: 'w-full min-w-[600px] flex flex-row',
children: [
WDiv(className: 'flex-1', child: WText('Name')),
WDiv(className: 'flex-1', child: WText('Status')),
WDiv(className: 'flex-1', child: WText('Updated')),
],
),
)
```

On a viewport wider than `600px` the row fills the container (no horizontal scroll). On a narrower viewport it stays `600px` wide and the wrapper scrolls horizontally. This works with no new token: `w-full` inside a horizontal scroll is threaded the viewport width instead of asserting on the scroll's unbounded width, and `min-w-[Npx]` sets the scroll floor. Without a `min-w-*` floor, `w-full` simply fills the viewport.

<x-preview path="layout/responsive_table" size="lg" source="example/lib/pages/layout/responsive_table.dart"></x-preview>

> **`h-full` inside a vertical scroll is a layout error.** A child that resolves `h-full` inside an `overflow-y-auto` / `overflow-y-scroll` parent has an unbounded height and produces a cryptic Flutter failure. Wind raises an actionable assert in debug pointing at the fix: use `flex-1` inside a `flex flex-col` (with the scroll on the column) instead of `h-full` inside a vertical scroll. The scroll container itself may still carry `h-full` (it is bounded by its own parent).

<a name="responsive-design"></a>
## Responsive Design

Expand Down
140 changes: 140 additions & 0 deletions example/lib/pages/layout/responsive_table.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import 'package:flutter/material.dart';
import 'package:fluttersdk_wind/fluttersdk_wind.dart';

import '../../widgets/example_scaffold.dart';

/// Demonstrates the "fill on desktop, scroll on narrow" primitive: an
/// `overflow-x-auto` wrapper around a `w-full min-w-[Npx]` flex table (the
/// shadcn Table pattern), plus the intrinsic-safe `flex flex-col items-stretch`
/// equal-width column.
class ResponsiveTableExamplePage extends StatelessWidget {
const ResponsiveTableExamplePage({super.key});

static const _rows = <(String, String, String)>[
('Checkout API', 'Operational', '2m ago'),
('Billing worker', 'Degraded', '5m ago'),
('Webhook relay', 'Operational', '1m ago'),
];

@override
Widget build(BuildContext context) {
return ExampleScaffold(
title: 'Responsive Table',
description:
'overflow-x-auto + w-full min-w-[Npx] fills the container when wide '
'and scrolls horizontally when narrow, with no unbounded-width crash.',
gradient: 'from-green-500 to-teal-600',
children: [
ExampleSection(
title: 'Fill on desktop, scroll on narrow',
description:
'The wrapper is overflow-x-auto; the table is w-full min-w-[560px]. '
'Wide viewports fill; narrow viewports scroll horizontally.',
child: WDiv(
className: '''
overflow-x-auto rounded-lg
border border-slate-200 dark:border-slate-700
''',
child: WDiv(
className: 'w-full min-w-[560px] flex flex-col',
children: [
WDiv(
className: '''
flex flex-row px-4 py-2
bg-slate-100 dark:bg-slate-800
''',
children: const [
_HeadCell('Service'),
_HeadCell('Status'),
_HeadCell('Updated'),
],
),
for (final row in _rows)
WDiv(
className: '''
flex flex-row px-4 py-2
border-t border-slate-200 dark:border-slate-700
''',
children: [
_BodyCell(row.$1),
_BodyCell(row.$2),
_BodyCell(row.$3),
],
),
],
),
),
),
ExampleSection(
title: 'flex flex-col items-stretch',
description:
'Explicit items-stretch equalizes child widths (every card fills '
'the column), matching grid items-stretch and staying crash-free '
'under intrinsic-measuring ancestors.',
child: WDiv(
className: 'flex flex-col items-stretch gap-2',
children: const [
_StretchCard('Short'),
_StretchCard('A considerably longer label than the first'),
],
),
),
],
);
}
}

class _HeadCell extends StatelessWidget {
final String label;

const _HeadCell(this.label);

@override
Widget build(BuildContext context) {
return WDiv(
className: 'flex-1',
child: WText(
label,
className: 'font-semibold text-slate-900 dark:text-white',
),
);
}
}

class _BodyCell extends StatelessWidget {
final String value;

const _BodyCell(this.value);

@override
Widget build(BuildContext context) {
return WDiv(
className: 'flex-1',
child: WText(
value,
className: 'text-slate-700 dark:text-slate-300',
),
);
}
}

class _StretchCard extends StatelessWidget {
final String label;

const _StretchCard(this.label);

@override
Widget build(BuildContext context) {
return WDiv(
className: '''
p-3 rounded-lg
bg-white dark:bg-slate-800
border border-slate-200 dark:border-slate-700
''',
child: WText(
label,
className: 'text-slate-900 dark:text-white',
),
);
}
}
2 changes: 2 additions & 0 deletions example/lib/routes.dart
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ import 'pages/layout/grid_cols.dart';
import 'pages/layout/grid_gap.dart';
import 'pages/layout/grid_responsive.dart';
import 'pages/layout/responsive.dart';
import 'pages/layout/responsive_table.dart';
import 'pages/layout/responsive_display.dart';
import 'pages/layout/visibility.dart';
import 'pages/layout/z_index.dart';
Expand Down Expand Up @@ -320,6 +321,7 @@ final Map<String, Widget> appRoutes = {
'/layout/grid_gap': const GridGapExamplePage(),
'/layout/grid_responsive': const GridResponsiveExamplePage(),
'/layout/responsive': const ResponsiveExamplePage(),
'/layout/responsive_table': const ResponsiveTableExamplePage(),
'/layout/responsive_display': const ResponsiveDisplayExamplePage(),
'/layout/visibility': const VisibilityExamplePage(),
'/layout/z_index': const ZIndexExamplePage(),
Expand Down
45 changes: 45 additions & 0 deletions lib/src/state/wind_min_width_scroll_scope.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import 'package:flutter/widgets.dart';

import '../widgets/wind_min_width_scroll.dart';

/// Threads a scrollable parent's axis information down to its content, since a
/// scrollable `WDiv` cannot inspect a descendant's `className` directly.
///
/// A `WDiv` with a scrollable axis installs this scope for its scroll content:
///
/// - `overflow-x-auto` / `overflow-x-scroll` sets [horizontalPort] so a `w-full`
/// descendant sizes to `max(viewport, min-w-*)` (fill on desktop, scroll on
/// narrow) instead of asserting on the scroll's unbounded width.
/// - `overflow-y-auto` / `overflow-y-scroll` sets [verticalUnbounded] so a
/// descendant that resolves `h-full` (which would collapse to
/// `double.infinity` on the scroll's unbounded height) can raise an actionable
/// assert pointing at `flex-1` instead.
class WindMinWidthScrollScope extends InheritedWidget {
/// Shared carrier for the horizontal viewport width; non-null only inside a
/// horizontally scrollable parent.
final WindViewportWidthPort? horizontalPort;

/// Whether the nearest scrollable parent leaves the vertical axis unbounded
/// (a vertical scroll), so `h-full` on a child is a layout error.
final bool verticalUnbounded;

const WindMinWidthScrollScope({
super.key,
this.horizontalPort,
this.verticalUnbounded = false,
required super.child,
});

/// Returns the nearest [WindMinWidthScrollScope] and subscribes so dependents
/// rebuild when the axis information changes.
static WindMinWidthScrollScope? maybeOf(BuildContext context) {
return context
.dependOnInheritedWidgetOfExactType<WindMinWidthScrollScope>();
}

@override
bool updateShouldNotify(WindMinWidthScrollScope oldWidget) {
return oldWidget.horizontalPort != horizontalPort ||
oldWidget.verticalUnbounded != verticalUnbounded;
}
}
Loading