Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,16 @@ This project follows [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.0.

### 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)
- Interactive widgets (`WSelect`, `WCheckbox`, `WRadio`, `WDatePicker`) now route their selection, checkmark, and accent colors through the theme `primary` token instead of hardcoded `blue-*` classes and `Colors.blue` shades, so a consumer's brand color drives them. The shipped default look is unchanged (`primary` defaults to the Tailwind blue swatch). `WindThemeData.toThemeData()` keeps its indigo Material `ColorScheme` baseline unless a custom `primary` is registered, so the default Material appearance is unchanged too. (`lib/src/widgets/w_select.dart`, `lib/src/widgets/w_checkbox.dart`, `lib/src/widgets/w_radio.dart`, `lib/src/widgets/w_date_picker.dart`, `lib/src/theme/wind_theme_data.dart`) (WIND-3)
- Documented and test-proved the `WindRecipe`/`WindSlotRecipe` caller-`className` merge contract: the recipe only appends the caller's className last (already the behavior since introduction); the conflict with a base token is resolved one layer down, at parse time, by `WindParser`'s per-family last-wins. No twMerge/`cn` port was added or is planned. (`test/recipe/wind_recipe_test.dart`, `test/parser/parsers/sizing_parser_test.dart`, `doc/styling/wind-recipe.md`, `skills/wind-ui/SKILL.md`)
- `skills/wind-ui/references/tailwind-divergence.md`, `skills/wind-ui/SKILL.md`, and `doc/layout/display.md` reconciled to reflect the `flex-wrap` alias (no longer a listed unsupported token) and the unknown-token debug hint (no longer purely silent). Example pages under `example/lib/pages/` switched from `flex-wrap` to the canonical `wrap` token. (WIND-5)

### 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)
- Seeded default `primary` color token: Wind's default theme now includes a `primary` `MaterialColor` (aliased 1:1 to the Tailwind `blue` swatch), so `bg-primary` / `text-primary` / `border-primary` resolve out of the box and become brand-overridable via `WindThemeData.colors: {'primary': ...}`. Previously `primary` was consulted only by `toThemeData()` and was a silent no-op in className tokens. (`lib/src/theme/defaults/colors.dart`, `lib/src/theme/wind_theme_data.dart`) (WIND-3)
- `flex-wrap` -> `wrap` alias: `FlexboxGridParser` now maps the Tailwind spelling `flex-wrap` directly to `WindDisplayType.wrap` (`canParse` already accepted `flex-*`; only the `parse()` handler was missing). `wrap` remains the canonical, unaliased token; `flex-wrap` prints a one-time `kDebugMode` hint suggesting it. (`lib/src/parser/parsers/flexbox_grid_parser.dart`) (WIND-5)
- Unknown-className debug warning: `WindParser.findAndGroupClasses` now emits a one-time `kDebugMode` `debugPrint` naming any className that no parser recognizes, deduped per unique token per session (mirrors the existing `_warnedAliases` shadow/cycle dedup). The token is still dropped from output; release builds print nothing. Valid Wind tokens handled outside the parser map are exempt so the warning aims only at genuine typos: the widget-consumed `object-*` fit family (read by `WImage`), the `inline-flex`/`inline-block`/`inline` display keywords (emitted by `WBadge`, inert in Flutter), and deliberately inert compat tokens that Wind's own widget docstrings / the consumer contract emit (`transition`/`transition-colors`/etc., `antialiased`, `sr-only`, the `*-nums` font-variant family incl. `tabular-nums`). (`lib/src/parser/wind_parser.dart`) (WIND-5)
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