diff --git a/.claude/rules/dynamic.md b/.claude/rules/dynamic.md index 84105a5..f37a9fa 100644 --- a/.claude/rules/dynamic.md +++ b/.claude/rules/dynamic.md @@ -72,7 +72,7 @@ Unknown action names are silently ignored (debugPrint only). Handler exceptions ## Known bugs (test-documented) - **`WSelect` + null value crash.** When `WSelect` receives simple string options and no initial `value`, the renderer infers `WSelect`; the state-init expects non-nullable `String` and throws `type 'Null' is not a subtype of type 'String'`. The renderer's per-type catch path swallows it and emits the red error widget. Workaround for consumers: pass map-typed options or an explicit initial value. Out-of-scope follow-up to fix renderer-side. -- **`WDatePicker.onChange` asymmetry.** `_buildWDatePicker` uses `parseAction(props['onChange'])` (no `_value` injection) instead of `parseValueAction`. To get the selected date, callers must read `state.get(id)` from inside the action handler, not args. Inconsistent with WInput/WCheckbox/WSelect; documented for awareness. +- **`WDatePicker.onChange` API asymmetry (not a defect).** The renderer DOES write the selected date to state before dispatch (`state.set(id, date)` then the action callback). The asymmetry is delivery: because `_buildWDatePicker` uses `parseAction(props['onChange'])` rather than `parseValueAction`, the `DateTime` arrives via `state.get(id)`, not via the action `args._value`. Callers read the date from state inside the handler. Inconsistent with WInput/WCheckbox/WSelect, which inject `_value`; documented for awareness. ## Custom widgets via `builders` @@ -89,7 +89,7 @@ Custom builders bypass the whitelist; they ARE the whitelist for their type. Use ## Custom icons via `customIcons` -`WIcon` inside JSON resolves icon names through `_parseIcon`. 25 built-in mappings (`home`, `star`, `person`, ...). Override or extend via `customIcons` map: `{ 'rocket': Icons.rocket_launch }`. +`WIcon` inside JSON resolves icon names through `_parseIcon`. 24 built-in mappings (`home`, `star`, `person`, ...). Override or extend via `customIcons` map: `{ 'rocket': Icons.rocket_launch }`. ## What never goes in `lib/src/dynamic/` diff --git a/.claude/rules/parsers.md b/.claude/rules/parsers.md index 73342fa..cba5222 100644 --- a/.claude/rules/parsers.md +++ b/.claude/rules/parsers.md @@ -26,6 +26,7 @@ class FooParser implements WindParserInterface { - `canParse(String className)` is O(1). Use `startsWith()` or a pre-compiled `static final RegExp`. No heavy logic. - `parse` returns `styles.copyWith(...)` for every mutation. Never returns null. Never mutates the input list. - The orchestrator's first-match-wins routing means `canParse` order matters: more specific prefixes register before more general ones. Registration is in `lib/src/parser/wind_parser.dart`'s `_parserMap` (a const map of const parser instances). +- `copyWith` does not fabricate an empty `BoxDecoration`. Pass `decoration:` only when setting a real visual box property (color, border, radius, shadow, gradient, image), so the downstream `decoration != null` Container gate stays honest. ## Last-class-wins semantics diff --git a/.claude/rules/widgets.md b/.claude/rules/widgets.md index 9a086af..8d26df0 100644 --- a/.claude/rules/widgets.md +++ b/.claude/rules/widgets.md @@ -63,6 +63,8 @@ When `styles.debug == false` (the default), every logger method is a no-op — o When a new widget needs interaction state from a className prefix, follow `WDiv`'s pattern (`_buildImpl` inside a `Builder`); do not bypass `WAnchor`. +`WButton` / `WAnchor` accept `semanticLabel: String?`, forwarded to the `Semantics` node wrapping the interactive surface. New WAnchor-based widgets expose and forward it the same way (matters for icon-only controls with no visible text). + ## className multi-line For 3+ concerns in one className, use triple-quoted strings, one concern per line, grouping `dark:` pairs beside their light variants: @@ -77,6 +79,10 @@ className: ''' ''', ``` +## Container elision + +A `Container` is emitted only when `styles.decoration != null` OR a non-empty `boxShadow` / `ringShadow` exists. Padding-only, text-only, and `shadow-none` paths must stay Container-free. `WindStyle.copyWith` must NOT fabricate an empty `BoxDecoration`, or the `decoration != null` gate breaks and every styled widget grows a needless Container. + ## What never goes in `lib/src/widgets/` - Test helpers — those live in `test/`. diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index e5f874b..8f0ca69 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -8,12 +8,12 @@ Utility-first Flutter UI plugin. Translates `className` strings (Tailwind syntax ``` lib/src/ -├── widgets/ # 20 W-prefix widgets (WDiv, WText, WButton, WSvg, WDynamic...) +├── widgets/ # 22 W-prefix widgets (WDiv, WText, WButton, WSvg, WDynamic...) ├── parser/ -│ ├── wind_parser.dart # Orchestrator — routes tokens to 17 parsers +│ ├── wind_parser.dart # Orchestrator — routes tokens to 19 parsers │ ├── wind_style.dart # Immutable style value object (parse output) │ ├── wind_context.dart # Theme + breakpoint + brightness + platform + states -│ └── parsers/ # 17 domain parsers (bg, border, flex, text, shadow...) +│ └── parsers/ # 19 domain parsers (bg, border, flex, text, shadow...) ├── theme/ │ ├── wind_theme.dart # WindTheme widget + WindThemeController │ ├── wind_theme_data.dart # Config: colors, screens, spacing, fonts @@ -23,7 +23,7 @@ lib/src/ └── utils/ # Extensions, helpers, color utils, logger ``` -**Data flow:** `className` → WindParser.parse() → 17 parsers (first-match-wins) → WindStyle → Widget.build() +**Data flow:** `className` → WindParser.parse() → 19 parsers (first-match-wins) → WindStyle → Widget.build() **Cache key:** className + breakpoint + brightness + platform + sorted states diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 3dd7dcc..64937c4 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -6,12 +6,12 @@ How the package is organized internally. Companion to [README.md](README.md) (th ``` lib/src/ -├── widgets/ # 20 user-facing W-prefix widgets (see .claude/rules/widgets.md) +├── widgets/ # 22 user-facing W-prefix widgets (see .claude/rules/widgets.md) ├── parser/ -│ ├── wind_parser.dart # Orchestrator: first-match-wins routing to 17 parsers +│ ├── wind_parser.dart # Orchestrator: first-match-wins routing to 19 parsers │ ├── wind_style.dart # Immutable value object (parse output) │ ├── wind_context.dart # Theme + breakpoint + brightness + platform + states -│ └── parsers/ # 17 domain parsers (see .claude/rules/parsers.md) +│ └── parsers/ # 19 domain parsers (see .claude/rules/parsers.md) ├── theme/ │ ├── wind_theme.dart # WindTheme StatefulWidget + WindThemeController │ ├── wind_theme_data.dart # 23 configurable fields; merges with defaults/ @@ -31,7 +31,7 @@ className: String ↓ WindParser.parse(className, context, states:) ↓ -17 domain parsers (first match wins) +19 domain parsers (first match wins) ↓ WindStyle (immutable value object) ↓ @@ -60,7 +60,7 @@ WindTheme( ## Diagnostics bridge -Call `Wind.installDebugResolver()` inside `kDebugMode` to expose 6 fields (`className`, `breakpoint`, `brightness`, `platform`, `states`, `bgColor`, `textColor`) to any consumer of `WindDebugRegistry.current` (e.g., `fluttersdk_dusk` for E2E and any runtime inspector). Idempotent; gated by `kDebugMode` so release builds tree-shake it. There is no `WindDuskIntegration` class in v1.0 — replaced by this bridge via the `fluttersdk_wind_diagnostics_contracts` contract. +Call `Wind.installDebugResolver()` inside `kDebugMode` to expose 7 fields (`className`, `breakpoint`, `brightness`, `platform`, `states`, `bgColor`, `textColor`) to any consumer of `WindDebugRegistry.current` (e.g., `fluttersdk_dusk` for E2E and any runtime inspector). Idempotent; gated by `kDebugMode` so release builds tree-shake it. There is no `WindDuskIntegration` class in v1.0 — replaced by this bridge via the `fluttersdk_wind_diagnostics_contracts` contract. ## Widget API surface (invariants locked at 1.0.0) diff --git a/CHANGELOG.md b/CHANGELOG.md index f9afa12..9c16a06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ This project follows [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.0. --- -## [1.0.0] - 2026-05-27 +## [1.0.0] - TBD First stable release. wind is utility-first, Tailwind-syntax styling for Flutter; v1.0.0 is a complete rewrite of the 0.0.x line with a fresh API surface, a 19-parser pipeline with cached resolution, dark-mode pairs as a first-class contract, and a contracts-based debug bridge for external tooling. All public APIs follow Semantic Versioning 2.0.0 from this point forward. @@ -30,18 +30,20 @@ First stable release. wind is utility-first, Tailwind-syntax styling for Flutter - **CSS positioning utilities**: `relative`, `absolute`, `top-*`, `right-*`, `bottom-*`, `left-*`, `inset-*`, `inset-x-*`, `inset-y-*`, plus negative variants (`-top-*`, `-inset-*`) and arbitrary-px values (`top-[24px]`). - **Child order utilities**: `order-0` through `order-12`, `order-first`, `order-last`, `order-none`, plus arbitrary `order-[n]` (including negatives) for reordering flex children without changing source order. - **Reverse flex direction**: `flex-row-reverse` and `flex-col-reverse` flip the main-axis direction so `justify-start` mirrors per CSS semantics. +- **`self-*` align-self shorthand**: `self-start`, `self-end`, `self-center`, `self-stretch`, `self-auto`, `self-baseline` as aliases for the `align-self-*` long form, matching Tailwind's canonical class name. - **Inline color props**: `WDiv.backgroundColor` and `WText.foregroundColor` for runtime-dynamic colors. Override any `bg-*` / `text-*` from className and stay out of the parser cache key. - **`WBreakpoint` widget**: declarative per-breakpoint widget tree builder. Reach for it when className prefixes are not enough because the widget structure itself changes between breakpoints. -- **`WDynamic`**: JSON-driven widget tree renderer. 13 Wind types + 16 Flutter core types allowed by default, extensible via `builders:`, restrictable via `denyWidgets:`, with `customIcons:` for user-defined icon mappings (25 built-in glyphs). State binding by widget `id`, action dispatch via `WActionHandler`, max recursion depth 50. -- **Accessibility / Semantics** on 7 interactive widgets (`WAnchor`, `WButton`, `WInput`, `WFormInput`, `WCheckbox`, `WSelect`, `WDatePicker`): emit `Semantics` nodes with role + label, password fields mark `obscured`. Enables Playwright `getByRole` / `getByLabel` / `getByText` resolution against the Flutter web build. New optional `WInput.semanticLabel` parameter. +- **`WDynamic`**: JSON-driven widget tree renderer. 13 Wind types + 16 Flutter core types allowed by default, extensible via `builders:`, restrictable via `denyWidgets:`, with `customIcons:` for user-defined icon mappings (24 built-in glyphs). State binding by widget `id`, action dispatch via `WActionHandler`, max recursion depth 50. +- **Accessibility / Semantics** on 7 interactive widgets (`WAnchor`, `WButton`, `WInput`, `WFormInput`, `WCheckbox`, `WSelect`, `WDatePicker`): emit `Semantics` nodes with role + label, password fields mark `obscured`. Enables Playwright `getByRole` / `getByLabel` / `getByText` resolution against the Flutter web build. New optional `semanticLabel` parameters on `WInput`, `WButton`, and `WAnchor`; the button/anchor label gives icon-only controls an accessible name when there is no child text for `MergeSemantics` to absorb. - **`Wind.installDebugResolver()`**: call inside `kDebugMode` to register a `WindDebugResolverImpl` against the new `fluttersdk_wind_diagnostics_contracts` bridge. Resolves 7 fields per Wind widget element: `className`, `breakpoint`, `brightness`, `platform`, `states`, conditional `bgColor`, conditional `textColor`. Consumed by `fluttersdk_dusk` for E2E snapshot capture and by any runtime inspector. Tree-shaken in release builds. -- **`wind-ui` skill v2.0.1 community pattern**: `skills/wind-ui/SKILL.md` section 15 plus `skills/wind-ui/references/community.md` add opt-in star + issue-report CTAs surfaced once per session after a verified Wind task or a genuine wind-side bug. Prose-permission only, never auto-executed, `gh auth status`-gated. (#89) +- **`wind-ui` skill v2.0.2 community pattern**: `skills/wind-ui/SKILL.md` section 15 plus `skills/wind-ui/references/community.md` add opt-in star + issue-report CTAs surfaced once per session after a verified Wind task or a genuine wind-side bug. Prose-permission only, never auto-executed, `gh auth status`-gated. (#89) ### Changed - **BREAKING.** Complete API rewrite from 0.0.x. The legacy `lib/src/parsers/` (28 modules) and `lib/src/components/` (8 modules) directories were deleted and reimplemented under `lib/src/parser/parsers/` and `lib/src/widgets/`. Class names `WText`, `WDiv`, `WButton` are preserved but their constructor signatures, the className token set they accept, and theme integration differ. Not source-compatible with v0. - **BREAKING.** Flutter SDK minimum raised from `>=3.3.0` to `>=3.27.0`. Dart SDK constraint set to `>=3.4.0 <4.0.0`. - **BREAKING.** Parser cache is now always on. The opt-in `trackProvenance` flag on `WindParser.parse()` and the `WindStyle.resolvedVia` field are gone; debug-tooling consumers read widget state through `Wind.installDebugResolver()` and the `fluttersdk_wind_diagnostics_contracts` contract package instead. +- **BREAKING.** The public `DatePickerMode` enum is renamed to `WDatePickerMode`. The old name collided with Flutter Material's own `DatePickerMode`, forcing any consumer importing both `package:flutter/material.dart` and the wind barrel to `hide` one symbol. `WDatePicker` / `WFormDatePicker` `mode:` now takes `WDatePickerMode`. ### Removed @@ -49,18 +51,28 @@ First stable release. wind is utility-first, Tailwind-syntax styling for Flutter - **BREAKING.** `fluttersdk_dusk` as a wind dependency at any level. Consumers needing Dusk for their own E2E add it to their own pubspec. - **BREAKING.** `google_fonts` and `platform_info` dependencies. Consumers depending on them transitively must add explicit deps. - **BREAKING.** `WindParser.parse(trackProvenance:)` parameter, `WindStyle.resolvedVia` field, and the `enableProvenance()` toggle. The contracts-based diagnostic bridge does not require provenance instrumentation. +- **BREAKING.** 13 internal parser classes (`AspectRatioParser`, `BackgroundParser`, `BorderParser`, `FlexboxGridParser`, `MarginParser`, `OpacityParser`, `OverflowParser`, `PaddingParser`, `RingParser`, `SizingParser`, `TextParser`, `TransitionParser`, `ZIndexParser`), `WindPlatformService`, `WindLogger`, `LogEntry`, `WDynamicRenderer`, and `WindDebugResolverImpl` are no longer exported from the public barrel (`package:fluttersdk_wind/fluttersdk_wind.dart`). These were always internal implementation details; any consumer referencing them by name must remove those references. The public widget and theme API is unaffected. ### Fixed +- Background image parser (`bg-[/abs/path]`): the `FileImage(File(...))` branch is now guarded by `kIsWeb`; on web, where `dart:io` `File` is unsupported, the image degrades gracefully (skipped) instead of throwing at runtime. Non-web behavior is unchanged. `pubspec.yaml` now declares explicit platform support (`android`, `ios`, `macos`, `web`, `linux`, `windows`) so pub.dev platform detection is not narrowed by the `dart:io` import graph. +- `max-w-prose`: corrected value from 1040 px (65 × 16, an incorrect approximation) to 512 px, matching the actual parser output. Docs and skill references updated accordingly. +- `WButton` / `WAnchor` `semanticLabel`: the `Semantics` node now sets `excludeSemantics: true` and lifts `onTap`/`onLongPress` onto itself when `semanticLabel` is set, so the label overrides any child text instead of concatenating with it under `MergeSemantics`, while activation is preserved. +- `Wind.installDebugResolver()`: the resolver no longer crashes on a className-less W-widget. `WindDebugResolverImpl.resolve` guarded its dynamic `className` read, so a bare `WAnchor` (or `WBreakpoint` / `WindAnimationWrapper` / `WKeyboardActions`) in the tree no longer throws `NoSuchMethodError` and abort the entire `fluttersdk_dusk` / telescope diagnostic snapshot. - `WInput`: `px-*` horizontal padding now matches the requested value exactly; `OutlineInputBorder.gapPadding` is set to `0.0` so `px-3` produces a 12 px inset instead of 16 px. Multiline geometry unchanged. (#61) - `WindParser.findAndGroupClasses`: duplicate tokens flow through to the parser pipeline so the documented last-class-wins contract holds on repeated overrides like `top-8 top-4 top-8`; previously `.toSet()` dropped the trailing occurrence. - `WindParser.parse`: cache is bypassed in both directions (no read, no write) when `baseStyle` is non-null so per-call styles do not return stale cached entries or poison the cache slot for default-flag callers. - `example/lib/routes.dart`: six widget routes (`/widgets/w-input-multiline`, `/widgets/w-input-search`, `/widgets/w-popover-alignment`, `/widgets/w-select_multi`, `/widgets/w-select_single`, `/widgets/w-text-transform`) renamed to snake_case to match their page-file basenames so live doc iframes at `wind.fluttersdk.com/preview/widgets/` resolve. Two dead pages (`layout/grid_basic`, `layout/order`) without documentation references were removed. +- `BackgroundParser`: `bg-[#hex]` arbitrary-color backgrounds no longer also resolve to a bogus `AssetImage("assets/#hex")`. The image regex now excludes `#`-leading bracket values so a hex literal is parsed only as a color, eliminating a stray failed asset fetch on every arbitrary-hex background. +- `WindStyle.copyWith`: a padding-, margin-, or text-only style keeps `decoration == null` instead of fabricating an empty `BoxDecoration`. This stops `WDiv`/`WText` from wrapping a needless `Container` around non-decorated content. `shadow-none` likewise no longer forces a Container. +- `WDynamicRenderer`: malformed JSON degrades gracefully. A non-string `type` or non-list `children` is coerced defensively (routed through the whitelist / treated as no children) instead of throwing an implicit-downcast `TypeError` out of `build()`. +- `WindThemeData`: implements value-based `operator ==` and `hashCode`. The equality guards in `WindThemeController.setTheme` and `_WindThemeState.didUpdateWidget` now compare by value, so a fresh default `WindThemeData()` on a parent rebuild no longer clobbers a prior `toggleTheme()` choice or triggers spurious full-tree rebuilds. ### Quality -- 1,214 tests across 82 test files; line coverage 90.2% (CI gate enforces `>= 90%` via `./tool/coverage.sh 90`). +- 1,224 tests across 83 test files; line coverage 90.3% (CI gate enforces `>= 90%` via `./tool/coverage.sh 90`). - New regression coverage in `test/parser/wind_parser_cache_test.dart` for the last-class-wins-on-duplicates and `baseStyle`-bypasses-cache contracts. +- New regression coverage for the arbitrary-hex background (`background_parser_test.dart`), `decoration`-stays-null contract (`wind_style_test.dart`), malformed-JSON graceful degradation (`w_dynamic_renderer_test.dart`), `WindThemeData` value equality (`wind_theme_data_test.dart`), and icon-only button Semantics label (`w_button_test.dart`). - `tool/coverage.sh` portable threshold-aware lcov wrapper; GitHub Actions gate fails any PR dropping below 90%. - Surgical `// coverage:ignore-line` pragmas only on lines structurally unreachable from `flutter test` (`kDebugMode` branches, `dart:io` `Platform.is*` branches not matching the CI host). Each pragma carries a one-line WHY comment. @@ -71,3 +83,5 @@ Production deps: `flutter` (SDK), `flutter_svg ^2.0.0`, `fluttersdk_wind_diagnos ## Previous versions The 1.0.0-alpha.1 through 1.0.0-alpha.10 release notes (Feb 2026 to May 2026) are preserved in git history and on the `v0` branch. The 0.0.x line is end-of-life; consumers pin to `^1.0.0` going forward. + +[1.0.0]: https://github.com/fluttersdk/wind/releases/tag/1.0.0 diff --git a/CLAUDE.md b/CLAUDE.md index dd12679..9396c4d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -58,7 +58,7 @@ When source under `lib/` changes, the agent updates each of these in the same ch - Update only when the change is overview-worthy: a new widget added to the public roster, a new top-level feature (theme field, parser token family, integration entry point), a public API addition or removal. - Internal refactors, test additions, doc fixes, dependency tweaks: NO README update needed — the noise harms more than the precision helps. -- Acceptance: README's "What you get" / "The Wind Surface" sections reflect the v1 roster (20 widgets, 17 parsers, 23 theme fields) accurately. +- Acceptance: README's "What you get" / "The Wind Surface" sections reflect the v1 roster (22 widgets, 19 parsers, 23 theme fields) accurately. **One change set, all five surfaces.** Do not split "code now, docs later" — the next session loses context and the docs rot. diff --git a/README.md b/README.md index 594a0cc..0603ec1 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@

Rapidly build modern Flutter apps without ever leaving your widget tree.
- A utility-first styling framework for Flutter. Tailwind-syntax className strings, 20 W-prefix widgets, dark mode, responsive prefixes, and three AI integration layers shipped with the package. + A utility-first styling framework for Flutter. Tailwind-syntax className strings, 22 W-prefix widgets, dark mode, responsive prefixes, and three AI integration layers shipped with the package.

@@ -33,13 +33,13 @@ flutter pub add fluttersdk_wind ``` -Wrap your app in `WindTheme`, then write `className` strings. The full setup walkthrough, all 20 widgets, every parser token, the 23 theme fields, and the three AI integration layers live at the [Getting Started guide](https://fluttersdk.com/wind/getting-started/installation). +Wrap your app in `WindTheme`, then write `className` strings. The full setup walkthrough, all 22 widgets, every parser token, the 23 theme fields, and the three AI integration layers live at the [Getting Started guide](https://fluttersdk.com/wind/getting-started/installation). ## Why Wind? Do you like using Tailwind CSS to style your UIs? **This helps you do that in Flutter.** -Wind is **not** a widget library. It is a utility-first styling engine that maps Tailwind-syntax `className` strings to optimized Flutter widget trees, with a 23-field theme, 17 parsers, and 20 W-prefix widgets. Flutter's structural styling produces six-widget pyramids for a rounded card with a hover state. The Flutter team itself acknowledged the [verbosity pain](https://github.com/flutter/flutter/issues/161345), ran an experimental Decorators feature, found mixed results, and shelved it. Wind closes that gap. +Wind is **not** a widget library. It is a utility-first styling engine that maps Tailwind-syntax `className` strings to optimized Flutter widget trees, with a 23-field theme, 19 parsers, and 22 W-prefix widgets. Flutter's structural styling produces six-widget pyramids for a rounded card with a hover state. The Flutter team itself acknowledged the [verbosity pain](https://github.com/flutter/flutter/issues/161345), ran an experimental Decorators feature, found mixed results, and shelved it. Wind closes that gap. ```dart // Before: Flutter native (15 lines) @@ -77,7 +77,7 @@ WDiv( | | Feature | Description | |:--|:--------|:------------| | 🎨 | **Tailwind syntax, natively** | Same utility classes you write on the web: `flex`, `p-4`, `bg-blue-500`, `rounded-lg`, `shadow-md`. Paste classes between web and Flutter; they work unmodified. | -| 🧩 | **20 W-prefix widgets** | `WDiv`, `WText`, `WButton`, `WInput`, `WSelect`, `WPopover`, `WDatePicker`, `WDynamic`, and 5 `FormField` wrappers (`WFormInput`, `WFormSelect`, `WFormMultiSelect`, `WFormCheckbox`, `WFormDatePicker`). | +| 🧩 | **22 W-prefix widgets** | `WDiv`, `WText`, `WButton`, `WInput`, `WSelect`, `WPopover`, `WDatePicker`, `WDynamic`, and 5 `FormField` wrappers (`WFormInput`, `WFormSelect`, `WFormMultiSelect`, `WFormCheckbox`, `WFormDatePicker`). | | 📱 | **Responsive prefixes** | `sm:`, `md:`, `lg:`, `xl:`, `2xl:` breakpoints, plus custom breakpoints via the theme. | | 🌙 | **First-class dark mode** | `dark:` prefix with runtime toggle and automatic system-brightness sync. Every color token carries its `dark:` pair in the same className. | | 🎯 | **State prefixes** | `hover:`, `focus:`, `disabled:`, `loading:`, `selected:`, and any custom state. Zero `MouseRegion`, zero `setState`, zero `_isHovered` booleans. | diff --git a/dartdoc_options.yaml b/dartdoc_options.yaml new file mode 100644 index 0000000..a6bcf63 --- /dev/null +++ b/dartdoc_options.yaml @@ -0,0 +1,3 @@ +dartdoc: + linkTo: + url: 'https://github.com/fluttersdk/wind/blob/1.0.0/%f%#L%l%' diff --git a/doc/borders/borders.md b/doc/borders/borders.md index 4078349..6c40d8e 100644 --- a/doc/borders/borders.md +++ b/doc/borders/borders.md @@ -182,4 +182,4 @@ WindThemeData( ## Related Documentation -- [Ring Utilities](/doc/borders/ring.md) - Focus rings and outlines +- [Ring Utilities](ring.md) - Focus rings and outlines diff --git a/doc/core-concepts/theming.md b/doc/core-concepts/theming.md index d2c8562..7965d41 100644 --- a/doc/core-concepts/theming.md +++ b/doc/core-concepts/theming.md @@ -206,11 +206,13 @@ final darkTheme = myDefaultTheme.copyWith( | Property | Type | Default | Description | |:---------|:-----|:--------|:------------| -| `brightness` | `Brightness` | `light` | Initial mode (`light` or `dark`) | +| `brightness` | `Brightness` | `light` | Initial mode (`light` or `dark`). Overridden on mount by the OS brightness while `syncWithSystem` is `true` (see note below) | | `syncWithSystem` | `bool` | `true` | Auto-follow OS brightness until the user calls `toggleTheme()` | | `applyDefaultFontFamily` | `bool` | `true` | Inject Wind's default font family as a global `DefaultTextStyle` | | `baseSpacingUnit` | `double` | `4.0` | Multiplier for numeric spacing (`p-4` → `4 * 4 = 16px`) | +> Setting `brightness: Brightness.dark` alone has no effect while `syncWithSystem` is `true` (the default): the controller reads the OS brightness on mount and overrides it, so `dark:` classes stay inactive on a light OS. To pin a fixed mode, pass `WindThemeData(brightness: Brightness.dark, syncWithSystem: false)`, or switch at runtime with `controller.toggleTheme()` / `setTheme(...)`. + ### Tokens | Property | Type | Description | @@ -237,9 +239,24 @@ final darkTheme = myDefaultTheme.copyWith( | Property | Type | Default | Description | |:---------|:-----|:--------|:------------| | `ringColor` | `Color` | Tailwind blue-500 | Default ring color when not overridden by `ring-{color}` | -| `ringWidths` | `Map` | `0,1,2,4,8` | Ring width scale (`ring-2`, etc.) | +| `ringWidths` | `Map` | `0,1,2,DEFAULT=3,4,8` | Ring width scale (`ring`, `ring-2`, etc.) | | `ringOffsets` | `Map` | `0,1,2,4,8` | Ring offset scale (`ring-offset-2`, etc.) | +### Controller methods + +`WindTheme.of(context)` returns a `WindThemeController` exposing these mutators: + +| Method | Signature | Description | +|:-------|:----------|:------------| +| `toggleTheme()` | `void toggleTheme()` | Flip between light and dark, pinning `syncWithSystem` to `false`. | +| `setTheme()` | `void setTheme(WindThemeData newData)` | Replace the active theme data wholesale. | +| `updateTheme()` | `void updateTheme({Brightness? brightness, Map? colors, Map? screens, Map? fontSizes, Map? fontWeights, Map? fontFamilies, Map? borderWidths, Map? borderRadius, double? baseSpacingUnit})` | Partial update via `copyWith`; only the passed fields change. | +| `resetToSystem()` | `void resetToSystem()` | Re-enable automatic OS brightness sync. | + +### Equality + +`WindThemeData` implements value-based `==` and `hashCode` (the `hashCode` covers scalar fields only), so constructing a fresh default `WindThemeData()` during a parent rebuild does not clobber a `toggleTheme()` choice already held by the controller. + ### Widget-level callback `onThemeChanged` is a `WindTheme` widget parameter, not a `WindThemeData` field. It fires on user-initiated `toggleTheme()` calls and is documented in the [Theme Change Callbacks](#theme-change-callbacks) section above. diff --git a/doc/layout/display.md b/doc/layout/display.md index 118fecc..2e4537b 100644 --- a/doc/layout/display.md +++ b/doc/layout/display.md @@ -182,4 +182,3 @@ Display utilities are structural and cannot be customized via `WindThemeData`. T - [Flexbox](./flexbox.md) - Layout direction, alignment, and sizing for flex containers. - [Grid](./grid.md) - Column definitions and gaps for grid containers. -- [Visibility](./visibility.md) - Control visibility without changing layout (opacity). diff --git a/doc/layout/flexbox.md b/doc/layout/flexbox.md index 3db9a6c..ab8c1e8 100644 --- a/doc/layout/flexbox.md +++ b/doc/layout/flexbox.md @@ -236,13 +236,15 @@ Control alignment of an individual flex item, overriding the container's `items- | `align-self-stretch` | `center` | | `align-self-auto` | `center` | +Tailwind's shorthand `self-start` / `self-end` / `self-center` / `self-stretch` / `self-auto` is accepted as an alias for the matching `align-self-*` class, so either form works. + ```dart WDiv( className: 'flex items-start h-20', children: [ WDiv(className: '...'), - // This item centers itself - WDiv(className: 'align-self-center ...'), + // This item centers itself (self-center is shorthand for align-self-center) + WDiv(className: 'self-center ...'), ], ) ``` @@ -362,4 +364,4 @@ At `base` the row scrolls horizontally and children keep their intrinsic width. - [Grid Layout](./grid.md) - [Display Modes](./display.md) -- [Sizing](../sizing/width.md) +- [Sizing](../layout/sizing.md) diff --git a/doc/layout/positioning.md b/doc/layout/positioning.md index 9dd4166..9949d83 100644 --- a/doc/layout/positioning.md +++ b/doc/layout/positioning.md @@ -74,7 +74,7 @@ The same scale applies to `right-*`, `bottom-*`, and `left-*`. ```dart WDiv( - className: 'relative h-48 bg-white border border-gray-200 rounded-lg', + className: 'relative h-48 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg', children: [ WDiv( className: 'absolute bottom-4 right-4 px-3 py-2 bg-blue-600 rounded', @@ -228,13 +228,13 @@ WDiv( ```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', + className: 'flex flex-row items-center justify-between px-4 py-3 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700', children: [ - WText('Inbox', className: 'text-base font-semibold text-gray-900'), + WText('Inbox', className: 'text-base font-semibold text-gray-900 dark:text-gray-100'), WDiv( className: 'relative', children: [ - WIcon(Icons.notifications_outlined, className: 'text-gray-700'), + WIcon(Icons.notifications_outlined, className: 'text-gray-700 dark:text-gray-300'), WDiv( className: 'absolute -top-1 -right-1 w-4 h-4 rounded-full bg-red-500', ), @@ -264,5 +264,5 @@ Until these land, use `Overlay` directly or Flutter's `Stack` at the `Scaffold` - [Flexbox & Layout](./flexbox.md) - [Grid Layout](./grid.md) -- [Sizing](../sizing/width.md) -- [Spacing](../spacing/padding.md) +- [Sizing](../layout/sizing.md) +- [Spacing](../layout/spacing.md) diff --git a/doc/layout/sizing.md b/doc/layout/sizing.md index d4bff04..38a8a67 100644 --- a/doc/layout/sizing.md +++ b/doc/layout/sizing.md @@ -125,7 +125,7 @@ Wind includes a comprehensive max-width scale, perfect for keeping text readable | `max-w-7xl` | 1280px | | `max-w-full` | 100% | | `max-w-screen` | 100vw | -| `max-w-prose` | 65ch (~1040px) | +| `max-w-prose` | 512px | ```dart // Constrain content width and center it @@ -198,4 +198,4 @@ WindThemeData( - [Padding & Margin](./spacing.md) - [Flexbox & Grid](./flexbox.md) -- [Aspect Ratio](./aspect_ratio.md) +- [Aspect Ratio](./aspect-ratio.md) diff --git a/doc/layout/z-index.md b/doc/layout/z-index.md index d106a94..07d3d6f 100644 --- a/doc/layout/z-index.md +++ b/doc/layout/z-index.md @@ -128,6 +128,6 @@ WDiv(className: 'z-popover') // Applies z-index: 5000 ## Related Documentation -- [Position](/doc/layout/position.md) -- [Display](/doc/layout/display.md) -- [WindTheme](/doc/core-concepts/theming.md) +- [Position](positioning.md) +- [Display](display.md) +- [WindTheme](../core-concepts/theming.md) diff --git a/doc/styling/background-color.md b/doc/styling/background-color.md index 1b40c9b..94e05ae 100644 --- a/doc/styling/background-color.md +++ b/doc/styling/background-color.md @@ -130,5 +130,5 @@ Now you can use `bg-brand-500` or `bg-gray-1000`. - [Background Gradient](./background-gradient.md) - [Background Image](./background-image.md) -- [Text Color](./text-color.md) -- [Border Color](./border-color.md) +- [Text Color](../typography/text-color.md) +- [Border Width, Color, & Radius](../borders/borders.md) diff --git a/doc/styling/opacity.md b/doc/styling/opacity.md index dcd076a..8b6134a 100644 --- a/doc/styling/opacity.md +++ b/doc/styling/opacity.md @@ -134,6 +134,6 @@ WDiv(className: 'opacity-glass') // 0.85 ## Related Documentation -- [Background Color](/doc/styling/background-color.md) - Using `/alpha` modifiers for backgrounds -- [Text Color](/doc/typography/text-color.md) - Using `/alpha` modifiers for text -- [Shadow](/doc/styling/shadow.md) - Box shadow utilities +- [Background Color](background-color.md) - Using `/alpha` modifiers for backgrounds +- [Text Color](../typography/text-color.md) - Using `/alpha` modifiers for text +- [Shadow](shadow.md) - Box shadow utilities diff --git a/doc/typography/font-family.md b/doc/typography/font-family.md index dc84a84..462ab19 100644 --- a/doc/typography/font-family.md +++ b/doc/typography/font-family.md @@ -140,7 +140,7 @@ WText('Body text', className: 'font-body') ``` > [!NOTE] -> When using custom fonts (like Google Fonts), ensure the font assets are properly included in your `pubspec.yaml` or loaded via a package like `google_fonts`. +> When using custom fonts, declare the font assets under `flutter: fonts:` in your app's `pubspec.yaml`, then reference the declared family name through `WindThemeData.fontFamilies`. ## Related Documentation diff --git a/doc/typography/text-overflow.md b/doc/typography/text-overflow.md index acfb8e2..4c5dd25 100644 --- a/doc/typography/text-overflow.md +++ b/doc/typography/text-overflow.md @@ -158,5 +158,4 @@ These utilities do not use theme variables. `line-clamp` accepts numeric values ## Related Documentation -- [Word Break & Whitespace](./whitespace.md) - [Font Size](./font-size.md) diff --git a/doc/utilities/responsive-helpers.md b/doc/utilities/responsive-helpers.md index 4b3234d..924fac8 100644 --- a/doc/utilities/responsive-helpers.md +++ b/doc/utilities/responsive-helpers.md @@ -110,4 +110,4 @@ if (wind.isMobile) { print(wind.platform); // 'ios', 'android', 'web', 'macos', etc. ``` -For more details on responsive styling using class names, see the [Responsive Design](/doc/core-concepts/responsive-design) guide. +For more details on responsive styling using class names, see the [Responsive Design](../core-concepts/responsive-design.md) guide. diff --git a/doc/widgets/w-anchor.md b/doc/widgets/w-anchor.md index def3dbc..e45bf56 100644 --- a/doc/widgets/w-anchor.md +++ b/doc/widgets/w-anchor.md @@ -54,6 +54,7 @@ const WAnchor({ bool isDisabled = false, Set? states, MouseCursor? mouseCursor, + String? semanticLabel, }) ``` @@ -68,6 +69,7 @@ const WAnchor({ | `isDisabled` | `bool` | `false` | When true, gestures are ignored and the `disabled:` prefix is activated. | | `states` | `Set?` | `null` | Custom states for dynamic styling (e.g., `{'active'}`). | | `mouseCursor` | `MouseCursor?` | `null` | Custom cursor. Defaults to click when interactive. | +| `semanticLabel` | `String?` | `null` | Accessible name for icon-only controls. When set, excludes the child subtree from semantics so the label overrides any child text; prefer it for icon-only controls rather than controls that already expose readable text. | ## Layout Modes diff --git a/doc/widgets/w-breakpoint.md b/doc/widgets/w-breakpoint.md index 47f16b7..42aee98 100644 --- a/doc/widgets/w-breakpoint.md +++ b/doc/widgets/w-breakpoint.md @@ -137,6 +137,6 @@ Custom keys participate in the same descending resolution as built-ins. ## Related Documentation -- [Responsive Design](../layout/responsive.md) +- [Responsive Design](../core-concepts/responsive-design.md) - [Flexbox & Layout](../layout/flexbox.md) -- [WindTheme](../theme/wind-theme.md) +- [WindTheme](../core-concepts/theming.md) diff --git a/doc/widgets/w-button.md b/doc/widgets/w-button.md index d7575cd..ecc969c 100644 --- a/doc/widgets/w-button.md +++ b/doc/widgets/w-button.md @@ -52,6 +52,7 @@ const WButton({ double loadingSize = 16, Color? loadingColor, Set? states, + String? semanticLabel, }) ``` @@ -71,6 +72,7 @@ const WButton({ | `loadingSize` | `double` | `16` | Size of the default loading spinner. | | `loadingColor` | `Color?` | `null` | Color of the spinner. Falls back to text color, then auto-computes contrast via W3C luminance when no color is resolvable. | | `states` | `Set?` | `null` | Custom state prefixes (e.g., `{'error'}` for `error:` classes). | +| `semanticLabel` | `String?` | `null` | Accessible name for icon-only controls. When set, excludes the child subtree from semantics so the label overrides any child text; prefer it for icon-only controls rather than buttons that already expose readable text. | ## Layout Modes diff --git a/doc/widgets/w-date-picker.md b/doc/widgets/w-date-picker.md index c2867fc..1e6e098 100644 --- a/doc/widgets/w-date-picker.md +++ b/doc/widgets/w-date-picker.md @@ -58,7 +58,7 @@ The calendar popover opens below the trigger and auto-flips if there isn't enoug ```dart const WDatePicker({ Key? key, - DatePickerMode mode = DatePickerMode.single, + WDatePickerMode mode = WDatePickerMode.single, DateTime? value, DateRange? range, ValueChanged? onChanged, @@ -77,7 +77,7 @@ const WDatePicker({ | Prop | Type | Default | Description | |:-----|:-----|:--------|:------------| -| `mode` | `DatePickerMode` | `single` | Selection mode: `single` or `range` | +| `mode` | `WDatePickerMode` | `single` | Selection mode: `single` or `range` | | `value` | `DateTime?` | `null` | Currently selected date (single mode) | | `range` | `DateRange?` | `null` | Currently selected range (range mode) | | `onChanged` | `ValueChanged?` | `null` | Callback fired on date selection (single mode) | @@ -92,12 +92,12 @@ const WDatePicker({ ## Types -### DatePickerMode +### WDatePickerMode Determines if the picker operates in single date or date range selection mode. ```dart -enum DatePickerMode { +enum WDatePickerMode { single, // Pick a single date range, // Pick a start and end date } @@ -130,7 +130,7 @@ When no `displayFormat` is provided, dates display as `"Jan 15, 2025"` format. ## Date Range Selection -Setting `mode: DatePickerMode.range` enables two-click range selection with hover preview. +Setting `mode: WDatePickerMode.range` enables two-click range selection with hover preview. @@ -138,7 +138,7 @@ Setting `mode: DatePickerMode.range` enables two-click range selection with hove DateRange? _dateRange; WDatePicker( - mode: DatePickerMode.range, + mode: WDatePickerMode.range, range: _dateRange, onRangeChanged: (range) => setState(() => _dateRange = range), placeholder: 'Check-in / Check-out', @@ -209,7 +209,7 @@ WDatePicker( ```dart WDatePicker( - mode: DatePickerMode.range, + mode: WDatePickerMode.range, range: _range, onRangeChanged: (range) { setState(() => _range = range); diff --git a/doc/widgets/w-div.md b/doc/widgets/w-div.md index d971b8f..f21af83 100644 --- a/doc/widgets/w-div.md +++ b/doc/widgets/w-div.md @@ -114,7 +114,7 @@ Precedence: inline `backgroundColor` wins over any `bg-*` / `dark:bg-*` resolved `WDiv` automatically becomes interactive when state-based prefixes like `hover:`, `focus:`, or `active:` are used in the `className`. Under the hood, it wraps the content in a `WAnchor` to detect gestures and focus. -For direct gesture support (taps, long presses) or to create semantic buttons, use [WAnchor](/docs/widgets/w-anchor) or [WButton](/docs/widgets/w-button). +For direct gesture support (taps, long presses) or to create semantic buttons, use [WAnchor](w-anchor.md) or [WButton](w-button.md). ## State Variants diff --git a/doc/widgets/w-dynamic.md b/doc/widgets/w-dynamic.md index 5293a3c..f097caf 100644 --- a/doc/widgets/w-dynamic.md +++ b/doc/widgets/w-dynamic.md @@ -20,14 +20,14 @@ WDynamic( json: const { 'type': 'WDiv', 'props': { - 'className': 'p-6 bg-white rounded-xl shadow-sm' + 'className': 'p-6 bg-white dark:bg-gray-800 rounded-xl shadow-sm' }, 'children': [ { 'type': 'WText', 'props': { 'text': 'Hello from JSON!', - 'className': 'text-xl font-bold text-gray-800' + 'className': 'text-xl font-bold text-gray-800 dark:text-gray-100' } }, ], diff --git a/doc/widgets/w-form-date-picker.md b/doc/widgets/w-form-date-picker.md index 8785229..1149f9c 100644 --- a/doc/widgets/w-form-date-picker.md +++ b/doc/widgets/w-form-date-picker.md @@ -72,7 +72,7 @@ WFormDatePicker({ // WDatePicker params DateTime? initialValue, DateRange? initialRange, - DatePickerMode mode = DatePickerMode.single, + WDatePickerMode mode = WDatePickerMode.single, ValueChanged? onChanged, ValueChanged? onRangeChanged, DateTime? minDate, @@ -107,7 +107,7 @@ WFormDatePicker({ | Prop | Type | Default | Description | |:-----|:-----|:--------|:------------| -| `mode` | `DatePickerMode` | `single` | Selection mode: `single` or `range` | +| `mode` | `WDatePickerMode` | `single` | Selection mode: `single` or `range` | | `initialRange` | `DateRange?` | `null` | Initial date range (range mode) | | `onChanged` | `ValueChanged?` | `null` | Called on date selection (single mode) | | `onRangeChanged` | `ValueChanged?` | `null` | Called on range selection (range mode) | @@ -183,7 +183,7 @@ In range mode, the `FormField` internally tracks the range's **start d ```dart WFormDatePicker( label: 'Trip Dates', - mode: DatePickerMode.range, + mode: WDatePickerMode.range, initialRange: null, className: 'p-3 border rounded-lg error:border-red-500', placeholder: 'Select check-in / check-out', diff --git a/doc/widgets/w-form-input.md b/doc/widgets/w-form-input.md index 38fe2b6..f16081a 100644 --- a/doc/widgets/w-form-input.md +++ b/doc/widgets/w-form-input.md @@ -63,9 +63,11 @@ WFormInput({ String? Function(String?)? validator, void Function(String?)? onSaved, AutovalidateMode? autovalidateMode, + String? restorationId, bool enabled = true, // WInput props + FocusNode? focusNode, InputType type = InputType.text, String? placeholder, String? className, @@ -112,6 +114,7 @@ WFormInput({ | `showError` | `bool` | `true` | Whether to display the error message string below the input. | | `errorClassName` | `String` | `'text-red-500...'` | Styling for the validation error message text. | | `controller` | `TextEditingController?` | `null` | Optional external controller for manual text management. | +| `restorationId` | `String?` | `null` | Restoration bucket ID used by Flutter state restoration. | | `validator` | `String? Function(String?)?` | `null` | Form validation logic returning error string or null. | | `type` | `InputType` | `InputType.text` | Determines keyboard layout and visual masking (e.g., `password`). | | `prefix` | `Widget?` | `null` | Widget (like an Icon) displayed before the input text. | diff --git a/doc/widgets/w-form-select.md b/doc/widgets/w-form-select.md index 8493b02..dfbd3b8 100644 --- a/doc/widgets/w-form-select.md +++ b/doc/widgets/w-form-select.md @@ -236,4 +236,3 @@ WindThemeData( - [WSelect - Core Dropdown Widget](./w-select.md) - [WFormInput - Validated Text Input](./w-form-input.md) - [WFormCheckbox - Validated Checkbox](./w-form-checkbox.md) -- [Forms in Wind](../core-concepts/forms.md) diff --git a/doc/widgets/w-keyboard-actions.md b/doc/widgets/w-keyboard-actions.md new file mode 100644 index 0000000..f1400b1 --- /dev/null +++ b/doc/widgets/w-keyboard-actions.md @@ -0,0 +1,230 @@ +# WKeyboardActions + +A wrapper that renders a Done button and field-navigation toolbar above the keyboard for any group of input fields, with optional platform targeting and custom close widget. + +## Table of Contents + +- [Basic Usage](#basic-usage) +- [Constructor](#constructor) +- [Props](#props) +- [Platform Targeting](#platform-targeting) +- [Navigation Between Fields](#navigation-between-fields) +- [Toolbar Styling](#toolbar-styling) +- [Custom Close Button](#custom-close-button) +- [Styling Examples](#styling-examples) +- [Related Documentation](#related-documentation) + + + +```dart +WKeyboardActions( + focusNodes: [_nameFocus, _amountFocus], + toolbarClassName: 'bg-gray-100 dark:bg-gray-800', + child: Column( + children: [ + WInput(focusNode: _nameFocus, placeholder: 'Jane Doe'), + WInput( + focusNode: _amountFocus, + placeholder: '0.00', + type: InputType.number, + ), + ], + ), +) +``` + + +## Basic Usage + +Create a `FocusNode` for each input field and pass them all in the `focusNodes` list. `WKeyboardActions` attaches listeners to every node and shows the toolbar as long as any one of them is focused. + +```dart +class _MyFormState extends State { + final _nameFocus = FocusNode(); + final _amountFocus = FocusNode(); + + @override + void dispose() { + _nameFocus.dispose(); + _amountFocus.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return WKeyboardActions( + focusNodes: [_nameFocus, _amountFocus], + child: Column( + children: [ + WInput(focusNode: _nameFocus, placeholder: 'Full Name'), + WInput( + focusNode: _amountFocus, + placeholder: '0.00', + type: InputType.number, + ), + ], + ), + ); + } +} +``` + + +## Constructor + +```dart +const WKeyboardActions({ + Key? key, + required Widget child, + required List focusNodes, + String platform = 'all', + bool nextFocus = true, + String? toolbarClassName, + Widget Function(FocusNode)? closeWidgetBuilder, +}) +``` + + +## Props + +| Prop | Type | Default | Description | +|:-----|:-----|:--------|:------------| +| `child` | `Widget` | **Required** | The form or column of inputs the toolbar is attached to. | +| `focusNodes` | `List` | **Required** | FocusNodes for the inputs that need keyboard actions. Order determines navigation order. | +| `platform` | `String` | `'all'` | Platform gate: `'all'`, `'ios'`, or `'android'`. | +| `nextFocus` | `bool` | `true` | When true, Previous/Next arrow buttons appear in the toolbar for field navigation. | +| `toolbarClassName` | `String?` | `null` | Wind utility classes applied to the toolbar background (typically `bg-*` classes). | +| `closeWidgetBuilder` | `Widget Function(FocusNode)?` | `null` | Builder that replaces the default "Done" button. Receives the currently-focused FocusNode. | + + +## Platform Targeting + +The `platform` prop gates the toolbar to a specific OS. The most common value is `'ios'`, because iOS numeric keyboards have no built-in Done button. + +| Value | Toolbar visible on | +|:------|:-------------------| +| `'all'` | iOS and Android (default) | +| `'ios'` | iOS only | +| `'android'` | Android only | + +```dart +WKeyboardActions( + platform: 'ios', + focusNodes: [_amountFocus], + child: WInput( + focusNode: _amountFocus, + placeholder: '0.00', + type: InputType.number, + ), +) +``` + + +## Navigation Between Fields + +When `nextFocus: true` (the default), the toolbar shows Previous and Next arrow buttons. They move focus to the previous or next node in the `focusNodes` list, in the order they were provided. The Previous button is disabled at the first field; the Next button is disabled at the last. + +Set `nextFocus: false` to show only the Done button, which is useful for single-field forms. + +```dart +WKeyboardActions( + focusNodes: [_nameFocus, _emailFocus, _amountFocus], + nextFocus: true, + child: Column( + children: [ + WInput(focusNode: _nameFocus, placeholder: 'Jane Doe'), + WInput(focusNode: _emailFocus, placeholder: 'jane@example.com'), + WInput(focusNode: _amountFocus, placeholder: '0.00'), + ], + ), +) +``` + + +## Toolbar Styling + +Pass Wind `bg-*` utility classes via `toolbarClassName` to set the toolbar background. The `dark:` pair is required. + +```dart +WKeyboardActions( + toolbarClassName: 'bg-gray-100 dark:bg-gray-800', + focusNodes: [_focusNode], + child: myInputField, +) +``` + +When `toolbarClassName` is null, the toolbar uses `Theme.of(context).colorScheme.surfaceContainerHighest`. + + +## Custom Close Button + +Supply `closeWidgetBuilder` to replace the default "Done" label with any widget. The builder receives the currently-focused `FocusNode`, which you can call `unfocus()` on. + +```dart +WKeyboardActions( + focusNodes: [_focusNode], + closeWidgetBuilder: (node) => WButton( + onTap: node.unfocus, + className: 'bg-blue-600 text-white px-4 py-1.5 rounded-md text-sm font-medium', + child: const WText('Save & Close', className: 'text-white text-sm font-medium'), + ), + child: myInputField, +) +``` + + +## Styling Examples + +### Minimal (single field, iOS only) + +```dart +WKeyboardActions( + platform: 'ios', + nextFocus: false, + focusNodes: [_amountFocus], + toolbarClassName: 'bg-white dark:bg-gray-900', + child: WInput( + focusNode: _amountFocus, + placeholder: '0.00', + type: InputType.number, + ), +) +``` + +### Multi-field form with custom toolbar color + +```dart +WKeyboardActions( + focusNodes: [_nameFocus, _emailFocus, _amountFocus], + toolbarClassName: 'bg-blue-50 dark:bg-blue-950', + child: Column( + children: [ + WInput(focusNode: _nameFocus, placeholder: 'Jane Doe'), + WInput(focusNode: _emailFocus, placeholder: 'jane@example.com'), + WInput(focusNode: _amountFocus, placeholder: '0.00'), + ], + ), +) +``` + +### Custom close widget + +```dart +WKeyboardActions( + focusNodes: [_focusNode], + toolbarClassName: 'bg-emerald-50 dark:bg-emerald-900', + closeWidgetBuilder: (node) => TextButton.icon( + onPressed: node.unfocus, + icon: const Icon(Icons.check_circle_outlined), + label: const Text('Done'), + ), + child: myInputField, +) +``` + + +## Related Documentation + +- [WInput - The unstyled text input widget](./w-input.md) +- [WFormInput - Form-bound WInput with validator](./w-form-input.md) +- [WDiv - General-purpose container for laying out form fields](../widgets/w-div.md) diff --git a/doc/widgets/w-popover.md b/doc/widgets/w-popover.md index 6ba2d5a..80bf7f4 100644 --- a/doc/widgets/w-popover.md +++ b/doc/widgets/w-popover.md @@ -67,6 +67,7 @@ const WPopover({ bool closeOnContentTap = false, VoidCallback? onOpen, VoidCallback? onClose, + bool autoFlip = true, }) ``` diff --git a/doc/widgets/w-svg.md b/doc/widgets/w-svg.md index 043e110..5403b34 100644 --- a/doc/widgets/w-svg.md +++ b/doc/widgets/w-svg.md @@ -197,4 +197,3 @@ Then use it as: `className: 'fill-brand-500'`. - [WIcon](./w-icon.md) - [WImage](./w-image.md) -- [SVG Parser](../parsers/svg_parser.md) diff --git a/doc/widgets/w-text.md b/doc/widgets/w-text.md index 21f9c79..ed01cdd 100644 --- a/doc/widgets/w-text.md +++ b/doc/widgets/w-text.md @@ -52,7 +52,7 @@ const WText( | Prop | Type | Default | Description | |:-----|:-----|:--------|:------------| -| `data` | `String` | `required` | The text string to display. | +| `data` | `String` | **Required** | The text string to display. | | `className` | `String?` | `null` | Tailwind-like utility classes. | | `style` | `WindStyle?` | `null` | Explicit WindStyle object as a base. | | `textStyle` | `TextStyle?` | `null` | Standard Flutter TextStyle to merge (className takes precedence). | diff --git a/doc/widgets/wind-animation-wrapper.md b/doc/widgets/wind-animation-wrapper.md new file mode 100644 index 0000000..3e5f54e --- /dev/null +++ b/doc/widgets/wind-animation-wrapper.md @@ -0,0 +1,131 @@ +# WindAnimationWrapper + +`WindAnimationWrapper` wraps any widget in a looping animation driven by `WindAnimationType`: spin, ping, pulse, or bounce. It is the +underlying engine behind `animate-*` utility classes; you can also use it directly when you need programmatic control over the type, +duration, or curve. + +## Table of Contents + +- [Basic Usage](#basic-usage) +- [Constructor](#constructor) +- [Props](#props) +- [Animation Types](#animation-types) +- [Styling Examples](#styling-examples) +- [Related Documentation](#related-documentation) + + +## Basic Usage + + + +```dart +WindAnimationWrapper( + animationType: WindAnimationType.spin, + child: WIcon(Icons.refresh_outlined, className: 'text-blue-500 dark:text-blue-400 text-3xl'), +) +``` + +Use `animate-*` className tokens for most cases. Reach for `WindAnimationWrapper` directly when you need to vary duration or curve at +runtime, or when wrapping a widget that does not accept a `className` prop. + + +## Constructor + +```dart +const WindAnimationWrapper({ + Key? key, + required Widget child, + required WindAnimationType animationType, + Duration duration = const Duration(milliseconds: 1000), + Curve curve = Curves.linear, +}) +``` + + +## Props + +| Prop | Type | Default | Description | +|:-----|:-----|:--------|:------------| +| `child` | `Widget` | **Required** | The widget to animate. | +| `animationType` | `WindAnimationType` | **Required** | The animation style. One of `spin`, `ping`, `pulse`, `bounce`, or `none`. | +| `duration` | `Duration` | `Duration(milliseconds: 1000)` | Full cycle length. Applies to all animation types. | +| `curve` | `Curve` | `Curves.linear` | Easing curve for the animation controller. Note: `ping` and `pulse` apply their own `CurvedAnimation` internally; `curve` primarily affects `spin` and `bounce`. | + + +## Animation Types + +| `WindAnimationType` | Effect | Typical use | +|:--------------------|:-------|:------------| +| `spin` | Continuous 360-degree rotation. | Loading spinners, refresh icons. | +| `ping` | Scale from 1.0 to 1.5 with matching opacity fade, repeating. | Notification badges, live indicators. | +| `pulse` | Opacity oscillates from 1.0 to 0.5 and back. | Skeleton placeholders, content loading. | +| `bounce` | Vertical offset oscillates between 0 and -5 px (20 * 0.25). | Scroll indicators, attention grabbers. | +| `none` | No animation; returns `child` unchanged. | Default state; safe to pass unconditionally. | + + +## Styling Examples + +### Custom duration + +Slow a pulse down to a 2-second cycle for a calmer skeleton effect: + +```dart +WindAnimationWrapper( + animationType: WindAnimationType.pulse, + duration: const Duration(milliseconds: 2000), + child: WDiv( + className: 'h-4 w-48 bg-gray-200 dark:bg-gray-700 rounded', + ), +) +``` + +### Ping badge + +Combine with `Stack` + `Positioned` for a notification badge: + +```dart +Stack( + children: [ + WIcon(Icons.notifications_outlined, className: 'text-gray-600 dark:text-gray-300 text-3xl'), + Positioned( + right: 2, + top: 2, + child: WindAnimationWrapper( + animationType: WindAnimationType.ping, + child: WDiv(className: 'w-3 h-3 bg-red-500 dark:bg-red-400 rounded-full'), + ), + ), + ], +) +``` + +### Spin with fast duration + +Reduce the cycle time for a snappier loading indicator: + +```dart +WindAnimationWrapper( + animationType: WindAnimationType.spin, + duration: const Duration(milliseconds: 600), + child: WIcon(Icons.sync_outlined, className: 'text-indigo-500 dark:text-indigo-400 text-2xl'), +) +``` + +### Via className (most common) + +For typical use the `animate-*` className token is the shorter path: + +```dart +WIcon( + Icons.refresh_outlined, + className: 'text-blue-500 dark:text-blue-400 text-2xl animate-spin', +) +``` + + +## Related Documentation + +- [WDiv](./w-div.md): Universal container; supports `animate-*` via `className`. +- [WIcon](./w-icon.md): Icon widget; wraps automatically in `WindAnimationWrapper` when className contains `animate-*`. +- [Animation](../interactivity/animation.md): Full reference for the `animate-*` tokens. +- [Transition](../interactivity/transition.md): Reference for `transition-*`, `duration-*`, and `ease-*` tokens. diff --git a/example/lib/pages/animation/animation_basic.dart b/example/lib/pages/animation/animation_basic.dart index 96cdacc..9207455 100644 --- a/example/lib/pages/animation/animation_basic.dart +++ b/example/lib/pages/animation/animation_basic.dart @@ -37,12 +37,17 @@ class AnimationBasicExamplePage extends StatelessWidget { children: const [ WIcon( Icons.refresh, - className: 'text-blue-500 text-3xl animate-spin', + className: + 'text-blue-500 dark:text-blue-400 text-3xl animate-spin', + ), + WDiv( + className: + 'w-8 h-8 bg-purple-500 dark:bg-purple-400 rounded animate-spin', ), - WDiv(className: 'w-8 h-8 bg-purple-500 rounded animate-spin'), WIcon( Icons.settings, - className: 'text-gray-600 text-3xl animate-spin', + className: + 'text-gray-600 dark:text-gray-400 text-3xl animate-spin', ), ], ), @@ -59,16 +64,18 @@ class AnimationBasicExamplePage extends StatelessWidget { children: [ const WDiv( className: - 'w-12 h-12 bg-gray-300 rounded-full animate-pulse', + 'w-12 h-12 bg-gray-300 dark:bg-gray-600 rounded-full animate-pulse', ), WDiv( className: 'flex flex-col gap-2', children: const [ WDiv( - className: 'h-3 w-32 bg-gray-300 rounded animate-pulse', + className: + 'h-3 w-32 bg-gray-300 dark:bg-gray-600 rounded animate-pulse', ), WDiv( - className: 'h-3 w-24 bg-gray-300 rounded animate-pulse', + className: + 'h-3 w-24 bg-gray-300 dark:bg-gray-600 rounded animate-pulse', ), ], ), @@ -87,13 +94,18 @@ class AnimationBasicExamplePage extends StatelessWidget { children: const [ WIcon( Icons.arrow_downward, - className: 'text-blue-500 text-2xl animate-bounce', + className: + 'text-blue-500 dark:text-blue-400 text-2xl animate-bounce', ), WIcon( Icons.keyboard_arrow_down, - className: 'text-green-500 text-3xl animate-bounce', + className: + 'text-green-500 dark:text-green-400 text-3xl animate-bounce', + ), + WDiv( + className: + 'w-5 h-5 bg-red-500 dark:bg-red-400 rounded animate-bounce', ), - WDiv(className: 'w-5 h-5 bg-red-500 rounded animate-bounce'), ], ), ], @@ -109,14 +121,15 @@ class AnimationBasicExamplePage extends StatelessWidget { children: [ _NotificationBadge( icon: Icons.notifications, - badgeColor: 'bg-red-500', + badgeColor: 'bg-red-500 dark:bg-red-400', ), _NotificationBadge( icon: Icons.mail, - badgeColor: 'bg-blue-500', + badgeColor: 'bg-blue-500 dark:bg-blue-400', ), const WDiv( - className: 'w-4 h-4 bg-green-500 rounded-full animate-ping', + className: + 'w-4 h-4 bg-green-500 dark:bg-green-400 rounded-full animate-ping', ), ], ), diff --git a/example/lib/pages/effects/shadow.dart b/example/lib/pages/effects/shadow.dart index 9eac377..d94e7b5 100644 --- a/example/lib/pages/effects/shadow.dart +++ b/example/lib/pages/effects/shadow.dart @@ -70,11 +70,20 @@ class ShadowExamplePage extends StatelessWidget { WDiv( className: 'flex gap-4 overflow-x-auto p-2', children: [ - _buildColoredShadowBox('shadow-xl shadow-blue-500', 'Blue'), - _buildColoredShadowBox('shadow-xl shadow-red-500', 'Red'), - _buildColoredShadowBox('shadow-xl shadow-green-500', 'Green'), _buildColoredShadowBox( - 'shadow-xl shadow-purple-500', + 'shadow-xl shadow-blue-500 dark:shadow-blue-400', + 'Blue', + ), + _buildColoredShadowBox( + 'shadow-xl shadow-red-500 dark:shadow-red-400', + 'Red', + ), + _buildColoredShadowBox( + 'shadow-xl shadow-green-500 dark:shadow-green-400', + 'Green', + ), + _buildColoredShadowBox( + 'shadow-xl shadow-purple-500 dark:shadow-purple-400', 'Purple', ), ], diff --git a/example/lib/pages/effects/states_basic.dart b/example/lib/pages/effects/states_basic.dart index 69b8b1b..a26ff18 100644 --- a/example/lib/pages/effects/states_basic.dart +++ b/example/lib/pages/effects/states_basic.dart @@ -21,7 +21,8 @@ class StatesBasicExamplePage extends StatelessWidget { WAnchor( onTap: () {}, child: WDiv( - className: "bg-blue-500 hover:bg-blue-700 px-6 py-3 rounded-lg", + className: + "bg-blue-500 dark:bg-blue-600 hover:bg-blue-700 dark:hover:bg-blue-800 px-6 py-3 rounded-lg", children: const [ WText("Hover me", className: "text-white font-medium"), ], @@ -39,11 +40,11 @@ class StatesBasicExamplePage extends StatelessWidget { onTap: () {}, child: WDiv( className: - "bg-white border border-gray-300 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 px-6 py-3 rounded-lg", + "bg-white dark:bg-slate-800 border border-gray-300 dark:border-gray-600 focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 focus:border-blue-500 dark:focus:border-blue-400 px-6 py-3 rounded-lg", children: const [ WText( "Click to focus", - className: "text-gray-700 font-medium", + className: "text-gray-700 dark:text-gray-200 font-medium", ), ], ), @@ -63,7 +64,7 @@ class StatesBasicExamplePage extends StatelessWidget { onTap: () {}, child: WDiv( className: - "bg-green-500 hover:bg-green-700 px-6 py-3 rounded-lg", + "bg-green-500 dark:bg-green-600 hover:bg-green-700 dark:hover:bg-green-800 px-6 py-3 rounded-lg", children: const [ WText("Enabled", className: "text-white font-medium"), ], @@ -74,7 +75,7 @@ class StatesBasicExamplePage extends StatelessWidget { onTap: null, // Disabled child: WDiv( className: - "bg-green-500 disabled:bg-gray-400 px-6 py-3 rounded-lg", + "bg-green-500 dark:bg-green-600 disabled:bg-gray-400 dark:disabled:bg-gray-600 px-6 py-3 rounded-lg", children: const [ WText("Disabled", className: "text-white font-medium"), ], @@ -94,7 +95,7 @@ class StatesBasicExamplePage extends StatelessWidget { onTap: () {}, child: WDiv( className: - "bg-purple-500 hover:bg-purple-700 hover:shadow-lg px-6 py-3 rounded-lg duration-300", + "bg-purple-500 dark:bg-purple-600 hover:bg-purple-700 dark:hover:bg-purple-800 hover:shadow-lg px-6 py-3 rounded-lg duration-300", children: const [ WText( "Smooth transition", diff --git a/example/lib/pages/examples/blog_section.dart b/example/lib/pages/examples/blog_section.dart index 89e5df4..5d88e36 100644 --- a/example/lib/pages/examples/blog_section.dart +++ b/example/lib/pages/examples/blog_section.dart @@ -37,7 +37,7 @@ class BlogSectionExamplePage extends StatelessWidget { category: "Marketing", title: "Boost your conversion rate", description: - "Illo sint voluptas. Error voluptates culpa eligendi. Hic vel totam vitae illo. Non aliquid explicabo necessitatibus unde. Sed exercitationem placeat consectetur nulla deserunt vel. Iusto corrupti dicta.", + "Small copy tweaks on your pricing page can lift sign-ups more than a full redesign. We break down three A/B tests that each moved conversion by double digits, and the reasoning behind every change.", authorName: "Michael Foster", authorRole: "Co-Founder / CTO", authorImage: @@ -48,7 +48,7 @@ class BlogSectionExamplePage extends StatelessWidget { category: "Sales", title: "How to use search engine optimization to drive sales", description: - "Optio cum necessitatibus dolor voluptatum provident commodi et. Qui aperiam fugiat nemo cumque.", + "Organic search still drives the cheapest qualified leads. Here is a practical SEO checklist your sales team can hand to marketing, from keyword intent to internal linking.", authorName: "Lindsay Walton", authorRole: "Front-end Developer", authorImage: @@ -59,7 +59,7 @@ class BlogSectionExamplePage extends StatelessWidget { category: "Business", title: "Improve your customer experience", description: - "Cupiditate maiores ullam eveniet adipisci in doloribus nulla minus. Voluptas iusto libero adipisci rem et corporis. Nostrud sint anim sunt aliqua. Nulla eu labore irure incididunt velit cillum quis magna dolore.", + "Support tickets are a goldmine for product insight. Learn how we route, tag, and review customer feedback so the most common pain points reach the roadmap within a single sprint.", authorName: "Tom Cook", authorRole: "Director of Product", authorImage: diff --git a/example/lib/pages/forms/form_date_picker_basic.dart b/example/lib/pages/forms/form_date_picker_basic.dart index 62d1836..696c5e2 100644 --- a/example/lib/pages/forms/form_date_picker_basic.dart +++ b/example/lib/pages/forms/form_date_picker_basic.dart @@ -1,4 +1,4 @@ -import 'package:flutter/material.dart' hide DatePickerMode; +import 'package:flutter/material.dart'; import 'package:fluttersdk_wind/fluttersdk_wind.dart'; import '../../widgets/example_scaffold.dart'; diff --git a/example/lib/pages/forms/form_date_picker_range.dart b/example/lib/pages/forms/form_date_picker_range.dart index 5137f9f..7922ed0 100644 --- a/example/lib/pages/forms/form_date_picker_range.dart +++ b/example/lib/pages/forms/form_date_picker_range.dart @@ -1,4 +1,4 @@ -import 'package:flutter/material.dart' hide DatePickerMode; +import 'package:flutter/material.dart'; import 'package:fluttersdk_wind/fluttersdk_wind.dart'; import '../../widgets/example_scaffold.dart'; @@ -36,7 +36,7 @@ class _FormDatePickerRangeExamplePageState return ExampleScaffold( title: 'Form Date Range', description: - 'mode: DatePickerMode.range. Validator sees the range start; for length-based rules drive validation from onRangeChanged.', + 'mode: WDatePickerMode.range. Validator sees the range start; for length-based rules drive validation from onRangeChanged.', gradient: 'from-rose-500 to-pink-600', children: [ ExampleSection( @@ -50,7 +50,7 @@ class _FormDatePickerRangeExamplePageState children: [ WFormDatePicker( label: 'Trip Dates', - mode: DatePickerMode.range, + mode: WDatePickerMode.range, placeholder: 'Check-in / check-out', className: _triggerCls, onRangeChanged: (range) { @@ -120,7 +120,7 @@ class _MinStayDemoState extends State<_MinStayDemo> { return Form( child: WFormDatePicker( label: 'Stay (minimum 3 nights)', - mode: DatePickerMode.range, + mode: WDatePickerMode.range, placeholder: 'Check-in / check-out', className: widget.triggerCls, states: _externalError == null ? null : const {'error'}, diff --git a/example/lib/pages/spacing/margin.dart b/example/lib/pages/spacing/margin.dart index caa2046..4b26824 100644 --- a/example/lib/pages/spacing/margin.dart +++ b/example/lib/pages/spacing/margin.dart @@ -35,8 +35,8 @@ class MarginExamplePage extends StatelessWidget { _buildSection( title: 'All Sides (m-{n})', children: [ - _buildMarginDemo('m-2', 'm-2 bg-amber-500'), - _buildMarginDemo('m-4', 'm-4 bg-amber-500'), + _buildMarginDemo('m-2', 'm-2 bg-amber-500 dark:bg-amber-400'), + _buildMarginDemo('m-4', 'm-4 bg-amber-500 dark:bg-amber-400'), ], ), @@ -44,8 +44,8 @@ class MarginExamplePage extends StatelessWidget { _buildSection( title: 'Axis (mx-{n}, my-{n})', children: [ - _buildMarginDemo('mx-6', 'mx-6 bg-orange-500'), - _buildMarginDemo('my-4', 'my-4 bg-red-500'), + _buildMarginDemo('mx-6', 'mx-6 bg-orange-500 dark:bg-orange-400'), + _buildMarginDemo('my-4', 'my-4 bg-red-500 dark:bg-red-400'), ], ), @@ -58,7 +58,7 @@ class MarginExamplePage extends StatelessWidget { children: [ WDiv( className: - 'mx-auto w-32 h-10 bg-emerald-500 rounded-lg flex items-center justify-center', + 'mx-auto w-32 h-10 bg-emerald-500 dark:bg-emerald-400 rounded-lg flex items-center justify-center', child: WText( 'mx-auto', className: 'text-white text-xs font-mono', @@ -77,17 +77,20 @@ class MarginExamplePage extends StatelessWidget { _buildSection( title: 'Individual Sides', children: [ - _buildMarginDemo('mt-4', 'mt-4 bg-blue-500'), - _buildMarginDemo('mb-4', 'mb-4 bg-indigo-500'), - _buildMarginDemo('ml-8', 'ml-8 bg-violet-500'), - _buildMarginDemo('mr-8', 'mr-8 bg-purple-500'), + _buildMarginDemo('mt-4', 'mt-4 bg-blue-500 dark:bg-blue-400'), + _buildMarginDemo('mb-4', 'mb-4 bg-indigo-500 dark:bg-indigo-400'), + _buildMarginDemo('ml-8', 'ml-8 bg-violet-500 dark:bg-violet-400'), + _buildMarginDemo('mr-8', 'mr-8 bg-purple-500 dark:bg-purple-400'), ], ), // Arbitrary Values _buildSection( title: 'Arbitrary Values', - children: [_buildMarginDemo('m-[10px]', 'm-[10px] bg-pink-500')], + children: [ + _buildMarginDemo( + 'm-[10px]', 'm-[10px] bg-pink-500 dark:bg-pink-400'), + ], ), ], ), diff --git a/example/lib/pages/typography/alignment.dart b/example/lib/pages/typography/alignment.dart index 3edb6bf..a9215e2 100644 --- a/example/lib/pages/typography/alignment.dart +++ b/example/lib/pages/typography/alignment.dart @@ -40,7 +40,7 @@ class TypographyAlignmentPage extends StatelessWidget { const SizedBox(height: 4), WText( 'Position text within its container', - className: 'text-sm text-gray-500', + className: 'text-sm text-gray-500 dark:text-gray-400', ), const SizedBox(height: 16), @@ -137,7 +137,7 @@ class TypographyAlignmentPage extends StatelessWidget { borderRadius: BorderRadius.circular(8), ), child: WText( - 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.', + 'Justified text spreads each line to fill the full container width, adding extra space between words so both the left and right edges stay flush. It works best for dense, multi-line paragraphs where a clean block shape matters more than even word spacing, such as printed articles or terms of service.', className: 'text-justify text-gray-800', ), ), diff --git a/example/lib/pages/widgets/date_picker_basic.dart b/example/lib/pages/widgets/date_picker_basic.dart index 231ff29..f9caa96 100644 --- a/example/lib/pages/widgets/date_picker_basic.dart +++ b/example/lib/pages/widgets/date_picker_basic.dart @@ -1,4 +1,4 @@ -import 'package:flutter/material.dart' hide DatePickerMode; +import 'package:flutter/material.dart'; import 'package:fluttersdk_wind/fluttersdk_wind.dart'; import '../../widgets/example_scaffold.dart'; diff --git a/example/lib/pages/widgets/date_picker_range.dart b/example/lib/pages/widgets/date_picker_range.dart index 817df42..f210a43 100644 --- a/example/lib/pages/widgets/date_picker_range.dart +++ b/example/lib/pages/widgets/date_picker_range.dart @@ -1,4 +1,4 @@ -import 'package:flutter/material.dart' hide DatePickerMode; +import 'package:flutter/material.dart'; import 'package:fluttersdk_wind/fluttersdk_wind.dart'; import '../../widgets/example_scaffold.dart'; @@ -34,7 +34,7 @@ class _DatePickerRangeExamplePageState return ExampleScaffold( title: 'Date Range Picker', description: - 'mode: DatePickerMode.range enables two-click range selection with hover preview. range + onRangeChanged for controlled state.', + 'mode: WDatePickerMode.range enables two-click range selection with hover preview. range + onRangeChanged for controlled state.', gradient: 'from-rose-500 to-pink-600', children: [ ExampleSection( @@ -45,7 +45,7 @@ class _DatePickerRangeExamplePageState className: 'flex flex-col gap-3', children: [ WDatePicker( - mode: DatePickerMode.range, + mode: WDatePickerMode.range, range: _range, onRangeChanged: (r) => setState(() => _range = r), placeholder: 'Check-in / Check-out', diff --git a/example/lib/pages/widgets/date_picker_styled.dart b/example/lib/pages/widgets/date_picker_styled.dart index 481979e..e4771cf 100644 --- a/example/lib/pages/widgets/date_picker_styled.dart +++ b/example/lib/pages/widgets/date_picker_styled.dart @@ -1,4 +1,4 @@ -import 'package:flutter/material.dart' hide DatePickerMode; +import 'package:flutter/material.dart'; import 'package:fluttersdk_wind/fluttersdk_wind.dart'; import '../../widgets/example_scaffold.dart'; diff --git a/example/lib/pages/widgets/w_date_picker.dart b/example/lib/pages/widgets/w_date_picker.dart index 56f19bf..781beed 100644 --- a/example/lib/pages/widgets/w_date_picker.dart +++ b/example/lib/pages/widgets/w_date_picker.dart @@ -1,4 +1,4 @@ -import 'package:flutter/material.dart' hide DatePickerMode; +import 'package:flutter/material.dart'; import 'package:fluttersdk_wind/fluttersdk_wind.dart'; /// WDatePicker widget examples. @@ -58,7 +58,7 @@ class _WDatePickerExamplePageState extends State { className: 'w-full p-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-800', child: WDatePicker( - mode: DatePickerMode.range, + mode: WDatePickerMode.range, range: _dateRange, onRangeChanged: (range) => setState(() => _dateRange = range), placeholder: 'Select duration', diff --git a/example/lib/pages/widgets/w_keyboard_actions_basic.dart b/example/lib/pages/widgets/w_keyboard_actions_basic.dart new file mode 100644 index 0000000..31b2607 --- /dev/null +++ b/example/lib/pages/widgets/w_keyboard_actions_basic.dart @@ -0,0 +1,219 @@ +import 'package:flutter/material.dart'; +import 'package:fluttersdk_wind/fluttersdk_wind.dart'; + +import '../../widgets/example_scaffold.dart'; + +class WKeyboardActionsBasicExamplePage extends StatefulWidget { + const WKeyboardActionsBasicExamplePage({super.key}); + + @override + State createState() => + _WKeyboardActionsBasicExamplePageState(); +} + +class _WKeyboardActionsBasicExamplePageState + extends State { + // 1. One FocusNode per field instance. A FocusNode can only be attached to a + // single input at a time, so every WInput on the page owns a distinct node; + // sharing one across sections would trip the focus-attachment assertion. + final _nameFocus = FocusNode(); + final _emailFocus = FocusNode(); + final _amountFocus = FocusNode(); + final _quantityFocus = FocusNode(); + final _contractorEmailFocus = FocusNode(); + final _hoursFocus = FocusNode(); + final _promoFocus = FocusNode(); + + // 2. Track which platform mode the demo showcases. + String _platform = 'all'; + + static const _inputCls = ''' + w-full px-3 py-2 rounded-lg + bg-white dark:bg-slate-800 + border border-slate-300 dark:border-slate-600 + text-slate-900 dark:text-slate-100 + focus:border-blue-500 focus:ring-2 focus:ring-blue-500/30 + '''; + + @override + void dispose() { + _nameFocus.dispose(); + _emailFocus.dispose(); + _amountFocus.dispose(); + _quantityFocus.dispose(); + _contractorEmailFocus.dispose(); + _hoursFocus.dispose(); + _promoFocus.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ExampleScaffold( + title: 'WKeyboardActions', + description: + 'Adds a Done button and field navigation toolbar above the keyboard for numeric and text inputs.', + gradient: 'from-violet-500 to-purple-600', + children: [ + ExampleSection( + title: 'Basic Form', + description: + 'Wrap any group of inputs with WKeyboardActions and pass a FocusNode for each field. ' + 'The toolbar shows Previous/Next arrows and a Done button while any field is focused.', + child: WKeyboardActions( + focusNodes: [ + _nameFocus, + _emailFocus, + _amountFocus, + ], + toolbarClassName: 'bg-slate-100 dark:bg-slate-800', + child: WDiv( + className: 'flex flex-col gap-4', + children: [ + _buildField( + label: 'Full Name', + placeholder: 'Jane Doe', + focusNode: _nameFocus, + ), + _buildField( + label: 'Email Address', + placeholder: 'jane@example.com', + focusNode: _emailFocus, + type: InputType.email, + ), + _buildField( + label: 'Invoice Amount', + placeholder: '0.00', + focusNode: _amountFocus, + type: InputType.number, + ), + ], + ), + ), + ), + ExampleSection( + title: 'Platform Targeting', + description: + 'Use platform: "ios" so the toolbar only appears on iOS, where the numeric keyboard ' + 'has no built-in Done button. Choose a value below to see how the prop changes behavior.', + child: WDiv( + className: 'flex flex-col gap-4', + children: [ + WDiv( + className: 'wrap gap-2', + children: [ + for (final p in ['all', 'ios', 'android']) + WButton( + onTap: () => setState(() => _platform = p), + className: _platform == p + ? 'px-4 py-2 rounded-lg bg-violet-600 text-white text-sm font-medium' + : ''' + px-4 py-2 rounded-lg text-sm font-medium + bg-slate-100 dark:bg-slate-700 + text-slate-700 dark:text-slate-300 + border border-slate-200 dark:border-slate-600 + ''', + child: WText( + 'platform: "$p"', + className: _platform == p + ? 'text-white text-sm font-medium' + : 'text-slate-700 dark:text-slate-300 text-sm font-medium', + ), + ), + ], + ), + WKeyboardActions( + platform: _platform, + focusNodes: [_quantityFocus], + toolbarClassName: 'bg-violet-50 dark:bg-violet-900', + child: _buildField( + label: 'Quantity', + placeholder: '1', + focusNode: _quantityFocus, + type: InputType.number, + ), + ), + ], + ), + ), + ExampleSection( + title: 'Custom Close Button', + description: + 'Supply closeWidgetBuilder to replace the default "Done" label with any widget. ' + 'The builder receives the currently-focused FocusNode so you can call unfocus() on it.', + child: WKeyboardActions( + focusNodes: [_contractorEmailFocus, _hoursFocus], + toolbarClassName: 'bg-emerald-50 dark:bg-emerald-900', + closeWidgetBuilder: (node) => WButton( + onTap: node.unfocus, + className: ''' + bg-emerald-600 hover:bg-emerald-700 + text-white px-4 py-1.5 rounded-md text-sm font-medium + ''', + child: const WText( + 'Dismiss', + className: 'text-white text-sm font-medium', + ), + ), + child: WDiv( + className: 'flex flex-col gap-4', + children: [ + _buildField( + label: 'Email', + placeholder: 'contractor@example.com', + focusNode: _contractorEmailFocus, + type: InputType.email, + ), + _buildField( + label: 'Hours Logged', + placeholder: '8', + focusNode: _hoursFocus, + type: InputType.number, + ), + ], + ), + ), + ), + ExampleSection( + title: 'Navigation Disabled', + description: + 'Set nextFocus: false to remove the Previous/Next arrow buttons from the toolbar ' + 'and show only the Done button.', + child: WKeyboardActions( + focusNodes: [_promoFocus], + nextFocus: false, + toolbarClassName: 'bg-amber-50 dark:bg-amber-900', + child: _buildField( + label: 'Promo Code', + placeholder: 'WIND2025', + focusNode: _promoFocus, + ), + ), + ), + ], + ); + } + + Widget _buildField({ + required String label, + required String placeholder, + required FocusNode focusNode, + InputType type = InputType.text, + }) { + return WDiv( + className: 'flex flex-col gap-1', + children: [ + WText( + label, + className: 'text-sm font-medium text-slate-700 dark:text-slate-300', + ), + WInput( + focusNode: focusNode, + placeholder: placeholder, + type: type, + className: _inputCls, + ), + ], + ); + } +} diff --git a/example/lib/pages/widgets/wind_animation_wrapper_basic.dart b/example/lib/pages/widgets/wind_animation_wrapper_basic.dart new file mode 100644 index 0000000..91537e6 --- /dev/null +++ b/example/lib/pages/widgets/wind_animation_wrapper_basic.dart @@ -0,0 +1,350 @@ +import 'package:flutter/material.dart'; +import 'package:fluttersdk_wind/fluttersdk_wind.dart'; + +/// Demo page for [WindAnimationWrapper]: all four animation types, custom +/// duration, and programmatic animationType switching. +class WindAnimationWrapperBasicExamplePage extends StatefulWidget { + const WindAnimationWrapperBasicExamplePage({super.key}); + + @override + State createState() => + _WindAnimationWrapperBasicExamplePageState(); +} + +class _WindAnimationWrapperBasicExamplePageState + extends State { + WindAnimationType _selectedType = WindAnimationType.spin; + + @override + Widget build(BuildContext context) { + return WDiv( + className: 'w-full h-full overflow-y-auto p-4', + scrollPrimary: true, + child: WDiv( + className: 'flex flex-col gap-6 max-w-4xl mx-auto', + children: [ + _buildHeader(), + _buildSection( + title: 'spin', + description: 'Continuous rotation for loading indicators.', + children: [_buildSpinSection()], + ), + _buildSection( + title: 'ping', + description: 'Scale and fade out for notification badges.', + children: [_buildPingSection()], + ), + _buildSection( + title: 'pulse', + description: 'Opacity pulse for skeleton placeholders.', + children: [_buildPulseSection()], + ), + _buildSection( + title: 'bounce', + description: 'Vertical jump for scroll and attention cues.', + children: [_buildBounceSection()], + ), + _buildSection( + title: 'Programmatic switching', + description: + 'Change animationType at runtime without restarting the widget.', + children: [_buildSwitcher()], + ), + _buildSection( + title: 'Custom duration', + description: 'Slow a pulse to 2 s for a calmer skeleton loader.', + children: [_buildSlowPulse()], + ), + _buildQuickReference(), + ], + ), + ); + } + + Widget _buildHeader() { + return WDiv( + className: ''' + p-6 rounded-xl + bg-gradient-to-r from-violet-500 to-fuchsia-500 + ''', + children: const [ + WText( + 'WindAnimationWrapper', + className: 'text-2xl font-bold text-white', + ), + WText( + 'Looping animations: spin, ping, pulse, bounce', + className: 'text-sm text-white/80', + ), + ], + ); + } + + Widget _buildSpinSection() { + return WDiv( + className: 'flex gap-6 items-center', + children: [ + WindAnimationWrapper( + animationType: WindAnimationType.spin, + child: const WIcon( + Icons.refresh_outlined, + className: 'text-blue-500 dark:text-blue-400 text-3xl', + ), + ), + WindAnimationWrapper( + animationType: WindAnimationType.spin, + child: const WDiv( + className: 'w-8 h-8 bg-violet-500 dark:bg-violet-400 rounded', + ), + ), + WindAnimationWrapper( + animationType: WindAnimationType.spin, + child: const WIcon( + Icons.settings_outlined, + className: 'text-gray-600 dark:text-gray-400 text-3xl', + ), + ), + ], + ); + } + + Widget _buildPingSection() { + return WDiv( + className: 'flex gap-8 items-center', + children: [ + _buildNotificationBadge( + icon: Icons.notifications_outlined, + badgeClassName: 'bg-red-500 dark:bg-red-400', + ), + _buildNotificationBadge( + icon: Icons.mail_outlined, + badgeClassName: 'bg-blue-500 dark:bg-blue-400', + ), + WindAnimationWrapper( + animationType: WindAnimationType.ping, + child: const WDiv( + className: 'w-4 h-4 bg-green-500 dark:bg-green-400 rounded-full', + ), + ), + ], + ); + } + + Widget _buildNotificationBadge({ + required IconData icon, + required String badgeClassName, + }) { + return SizedBox( + width: 40, + height: 40, + child: Stack( + children: [ + Positioned.fill( + child: Center( + child: WIcon( + icon, + className: 'text-3xl text-gray-400 dark:text-gray-500', + ), + ), + ), + Positioned( + right: 2, + top: 2, + child: WindAnimationWrapper( + animationType: WindAnimationType.ping, + child: WDiv( + className: 'w-3 h-3 $badgeClassName rounded-full', + ), + ), + ), + ], + ), + ); + } + + Widget _buildPulseSection() { + return WDiv( + className: 'flex gap-4 items-start', + children: [ + WindAnimationWrapper( + animationType: WindAnimationType.pulse, + child: const WDiv( + className: 'w-12 h-12 bg-gray-300 dark:bg-gray-600 rounded-full', + ), + ), + WDiv( + className: 'flex flex-col gap-2', + children: [ + WindAnimationWrapper( + animationType: WindAnimationType.pulse, + child: const WDiv( + className: 'h-3 w-36 bg-gray-300 dark:bg-gray-600 rounded', + ), + ), + WindAnimationWrapper( + animationType: WindAnimationType.pulse, + child: const WDiv( + className: 'h-3 w-24 bg-gray-300 dark:bg-gray-600 rounded', + ), + ), + WindAnimationWrapper( + animationType: WindAnimationType.pulse, + child: const WDiv( + className: 'h-3 w-28 bg-gray-300 dark:bg-gray-600 rounded', + ), + ), + ], + ), + ], + ); + } + + Widget _buildBounceSection() { + return WDiv( + className: 'flex gap-6 items-end h-12', + children: [ + WindAnimationWrapper( + animationType: WindAnimationType.bounce, + child: const WIcon( + Icons.arrow_downward_outlined, + className: 'text-blue-500 dark:text-blue-400 text-2xl', + ), + ), + WindAnimationWrapper( + animationType: WindAnimationType.bounce, + child: const WIcon( + Icons.keyboard_arrow_down_outlined, + className: 'text-green-500 dark:text-green-400 text-3xl', + ), + ), + WindAnimationWrapper( + animationType: WindAnimationType.bounce, + child: const WDiv( + className: 'w-5 h-5 bg-red-500 dark:bg-red-400 rounded', + ), + ), + ], + ); + } + + Widget _buildSwitcher() { + const types = [ + WindAnimationType.spin, + WindAnimationType.ping, + WindAnimationType.pulse, + WindAnimationType.bounce, + WindAnimationType.none, + ]; + + return WDiv( + className: 'flex flex-col gap-4', + children: [ + WDiv( + className: 'flex gap-2 flex-wrap', + children: types.map((type) { + final isActive = type == _selectedType; + return WButton( + onTap: () => setState(() => _selectedType = type), + className: isActive + ? 'px-3 py-1 rounded bg-violet-500 dark:bg-violet-600 text-white text-sm font-medium' + : 'px-3 py-1 rounded bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 text-sm', + child: WText(type.name), + ); + }).toList(), + ), + WDiv( + className: + 'flex items-center justify-center h-20 rounded-lg bg-gray-50 dark:bg-gray-800', + child: WindAnimationWrapper( + animationType: _selectedType, + child: const WIcon( + Icons.star_outlined, + className: 'text-yellow-500 dark:text-yellow-400 text-4xl', + ), + ), + ), + ], + ); + } + + Widget _buildSlowPulse() { + return WDiv( + className: + 'flex flex-col gap-3 p-4 rounded-lg bg-gray-50 dark:bg-gray-800', + children: [ + const WText( + 'Order summary (loading...)', + className: 'text-sm font-medium text-gray-700 dark:text-gray-300', + ), + WindAnimationWrapper( + animationType: WindAnimationType.pulse, + duration: const Duration(milliseconds: 2000), + child: const WDiv( + className: 'h-4 w-full bg-gray-200 dark:bg-gray-700 rounded', + ), + ), + WindAnimationWrapper( + animationType: WindAnimationType.pulse, + duration: const Duration(milliseconds: 2000), + child: const WDiv( + className: 'h-4 w-3/4 bg-gray-200 dark:bg-gray-700 rounded', + ), + ), + WindAnimationWrapper( + animationType: WindAnimationType.pulse, + duration: const Duration(milliseconds: 2000), + child: const WDiv( + className: 'h-4 w-1/2 bg-gray-200 dark:bg-gray-700 rounded', + ), + ), + ], + ); + } + + Widget _buildQuickReference() { + return WDiv( + className: 'p-4 rounded-lg bg-gray-100 dark:bg-slate-800', + children: [ + const WText( + 'Quick Reference', + className: 'font-semibold text-gray-800 dark:text-white mb-2', + ), + WDiv( + className: 'flex flex-col gap-1', + children: const [ + WText( + 'animate-spin | animate-ping | animate-pulse | animate-bounce', + className: 'text-xs font-mono text-gray-600 dark:text-gray-400', + ), + WText( + 'WindAnimationWrapper(animationType:, duration:, curve:, child:)', + className: 'text-xs font-mono text-gray-600 dark:text-gray-400', + ), + ], + ), + ], + ); + } + + Widget _buildSection({ + required String title, + required String description, + required List children, + }) { + return WDiv( + className: 'flex flex-col gap-4', + children: [ + WText( + title, + className: + 'text-lg font-semibold text-gray-900 dark:text-white font-mono', + ), + WText( + description, + className: 'text-sm text-gray-500 dark:text-gray-400', + ), + ...children, + ], + ); + } +} diff --git a/example/lib/routes.dart b/example/lib/routes.dart index 36cf3a8..df3c0e4 100644 --- a/example/lib/routes.dart +++ b/example/lib/routes.dart @@ -202,6 +202,8 @@ import 'pages/widgets/w_form_select.dart'; import 'pages/widgets/w_form_multiselect.dart'; import 'pages/widgets/w_form_checkbox.dart'; import 'pages/widgets/w_form_checkbox_layout.dart'; +import 'pages/widgets/w_keyboard_actions_basic.dart'; +import 'pages/widgets/wind_animation_wrapper_basic.dart'; import 'pages/widgets/w_svg.dart'; @@ -420,6 +422,9 @@ final Map appRoutes = { '/widgets/w_form_multiselect': const WFormMultiSelectExamplePage(), '/widgets/w_form_checkbox': const WFormCheckboxExamplePage(), '/widgets/w_form_checkbox_layout': const WFormCheckboxLayoutExamplePage(), + '/widgets/w_keyboard_actions_basic': const WKeyboardActionsBasicExamplePage(), + '/widgets/wind_animation_wrapper_basic': + const WindAnimationWrapperBasicExamplePage(), '/widgets/w_svg': const WSvgExamplePage(), }; diff --git a/lib/fluttersdk_wind.dart b/lib/fluttersdk_wind.dart index 1ca3926..4b91c01 100644 --- a/lib/fluttersdk_wind.dart +++ b/lib/fluttersdk_wind.dart @@ -59,21 +59,6 @@ /// For complete documentation, visit the [Wind documentation](https://fluttersdk.com/wind). library; -export 'src/core/platform_service.dart'; -export 'src/parser/parsers/background_parser.dart'; -export 'src/parser/parsers/border_parser.dart'; - -export 'src/parser/parsers/flexbox_grid_parser.dart'; -export 'src/parser/parsers/margin_parser.dart'; -export 'src/parser/parsers/padding_parser.dart'; -export 'src/parser/parsers/sizing_parser.dart'; -export 'src/parser/parsers/text_parser.dart'; -export 'src/parser/parsers/opacity_parser.dart'; -export 'src/parser/parsers/zindex_parser.dart'; -export 'src/parser/parsers/overflow_parser.dart'; -export 'src/parser/parsers/aspectratio_parser.dart'; -export 'src/parser/parsers/transition_parser.dart'; -export 'src/parser/parsers/ring_parser.dart'; export 'src/parser/parsers/wind_parser_interface.dart'; export 'src/parser/wind_context.dart'; export 'src/parser/wind_parser.dart'; @@ -93,7 +78,6 @@ export 'src/theme/defaults/font_families.dart'; export 'src/theme/wind_theme.dart'; export 'src/theme/wind_theme_data.dart'; export 'src/utils/color_utils.dart'; -export 'src/utils/wind_logger.dart'; export 'src/utils/wind_helpers.dart'; export 'src/utils/wind_extensions.dart'; export 'src/widgets/w_anchor.dart'; @@ -123,7 +107,5 @@ export 'src/dynamic/w_dynamic_controller.dart'; export 'src/dynamic/w_dynamic_state.dart'; export 'src/dynamic/w_action_handler.dart'; export 'src/dynamic/w_dynamic_config.dart'; -export 'src/dynamic/w_dynamic_renderer.dart'; -export 'src/debug_resolver.dart'; export 'src/wind_facade.dart'; diff --git a/lib/src/debug_resolver.dart b/lib/src/debug_resolver.dart index d286266..2f4eded 100644 --- a/lib/src/debug_resolver.dart +++ b/lib/src/debug_resolver.dart @@ -9,28 +9,29 @@ import 'parser/wind_parser.dart'; import 'parser/wind_style.dart'; /// Concrete WindDebugResolver wired into the registry at app boot -/// via `Wind.installDebugResolver()`. Resolves the 6 core fields -/// per the fluttersdk_wind_diagnostics_contracts v1 contract. +/// via `Wind.installDebugResolver()`. Resolves up to 7 fields (5 always +/// present, `bgColor` + `textColor` only when non-null) per the +/// fluttersdk_wind_diagnostics_contracts v1 contract. class WindDebugResolverImpl implements WindDebugResolver { const WindDebugResolverImpl(); @override Map resolve(Element element) { final widget = element.widget; - // 1. Filter to W-prefixed widgets with a className API + // 1. Filter to W-prefixed widgets that actually expose a `className` API. if (!widget.runtimeType.toString().startsWith('W')) { return const {}; } - final String? className = (widget as dynamic).className as String?; + final String? className = _readClassName(widget); if (className == null || className.isEmpty) { return const {}; } // 2. Resolve context + style at snapshot time (Element IS a BuildContext) final WindContext ctx = WindContext.build(element); final WindStyle style = WindParser.parse(className, element); - // 3. Build the 6-core-field map. bgColor + textColor conditional - // on non-null resolved values; rest always present when widget - // is a valid W-widget. + // 3. Build the field map: 5 fields always present, bgColor + textColor + // conditional on non-null resolved values (up to 7 total) when the + // widget is a valid W-widget. final Map data = { 'className': className, // WindContext.activeBreakpoint and .platform are `String`, not enums @@ -51,6 +52,23 @@ class WindDebugResolverImpl implements WindDebugResolver { } return data; } + + /// Reads the optional `className` field from a W-widget via dynamic dispatch. + /// + /// Several W-prefixed widgets are interaction- or structure-only and expose + /// no `className` (WAnchor, WBreakpoint, WindAnimationWrapper, + /// WKeyboardActions). Reading `.className` on them throws + /// `NoSuchMethodError`; treating that as the documented "not className- + /// stylable" signal keeps a bare WAnchor in the tree from crashing the whole + /// diagnostic snapshot. Only the missing-member error is handled here; any + /// other failure propagates. + String? _readClassName(Widget widget) { + try { + return (widget as dynamic).className as String?; + } on NoSuchMethodError { + return null; + } + } } /// Formats a Color as 6-char RGB hex (alpha channel dropped, uppercase). diff --git a/lib/src/dynamic/w_dynamic_renderer.dart b/lib/src/dynamic/w_dynamic_renderer.dart index 40d4e83..fd5e336 100644 --- a/lib/src/dynamic/w_dynamic_renderer.dart +++ b/lib/src/dynamic/w_dynamic_renderer.dart @@ -189,14 +189,20 @@ class WDynamicRenderer { return const SizedBox.shrink(); } - // Extract type and props - final String type = safeJson['type'] ?? 'Unknown'; + // Extract type and props. The values come from untrusted JSON, so coerce + // defensively: a non-string `type` falls back to 'Unknown' (which the + // whitelist then rejects) rather than throwing an implicit-downcast + // TypeError out of build(). + final String type = + safeJson['type'] is String ? safeJson['type'] as String : 'Unknown'; final dynamic propsRaw = safeJson['props']; final Map props = propsRaw is Map ? _deepConvertMap(propsRaw) : {}; - // Extract children - final List childrenRaw = safeJson['children'] ?? []; + // Extract children. A non-list `children` is treated as no children + // instead of throwing on the implicit List cast. + final List childrenRaw = + safeJson['children'] is List ? safeJson['children'] as List : const []; final List children = childrenRaw .map((child) => _buildRecursive(child, depth: depth + 1)) .toList(); diff --git a/lib/src/parser/parsers/background_parser.dart b/lib/src/parser/parsers/background_parser.dart index e51d701..c187271 100644 --- a/lib/src/parser/parsers/background_parser.dart +++ b/lib/src/parser/parsers/background_parser.dart @@ -1,5 +1,6 @@ import 'dart:io'; +import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/material.dart'; import '../../theme/wind_theme_data.dart'; @@ -26,9 +27,14 @@ class BackgroundParser implements WindParserInterface { r'^bg-(?:(?[a-zA-Z0-9]+)-?(?[0-9]{0,3})|\[(?#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}))\])$', ); - /// Regex for background image classes + /// Regex for background image classes. + /// + /// The bare-bracket form (`bg-[path]`) is for asset paths and URLs only. A + /// `#`-leading value is a color literal (`bg-[#FF0000]`), handled by + /// [_backgroundColorRegex]; the negative lookahead keeps it out of here so + /// an arbitrary-hex background does not also produce a bogus `AssetImage`. static final _backgroundImageRegex = RegExp( - r'^bg-\[url\((.*?)\)\]$|^bg-\[([^\]]+)\]$', + r'^bg-\[url\((.*?)\)\]$|^bg-\[(?!#)([^\]]+)\]$', ); /// Map for background fit values @@ -245,6 +251,12 @@ class BackgroundParser implements WindParserInterface { imageUrlOrPath.startsWith('https://')) { imageProvider = NetworkImage(imageUrlOrPath); } else if (imageUrlOrPath.startsWith('/')) { + // An absolute filesystem path has no meaning on web, and `dart:io` + // `File` is unsupported there, so degrade gracefully by skipping it. + if (kIsWeb) { + // Web has no dart:io File; skip rather than throw at runtime. + return null; // coverage:ignore-line + } imageProvider = FileImage(File(imageUrlOrPath)); } else { String assetPath = imageUrlOrPath; diff --git a/lib/src/parser/parsers/flexbox_grid_parser.dart b/lib/src/parser/parsers/flexbox_grid_parser.dart index 8c2e6bf..7861ffc 100644 --- a/lib/src/parser/parsers/flexbox_grid_parser.dart +++ b/lib/src/parser/parsers/flexbox_grid_parser.dart @@ -20,6 +20,7 @@ import 'wind_parser_interface.dart'; /// - **Flex:** `flex-*` (numeric), `flex-1`, `flex-grow`, `flex-auto`, `flex-initial`, `flex-none` /// - **Flex Sizing:** `shrink`, `shrink-0`, `flex-shrink` /// - **Align Self:** `align-self-start`, `align-self-end`, `align-self-center`, `align-self-stretch`, `align-self-auto` +/// plus the Tailwind shorthand `self-start`, `self-end`, `self-center`, `self-stretch`, `self-auto` /// - **Grid:** `grid-cols-*` (numeric values only) /// - **Axis Size:** `axis-min`, `axis-max` (Custom Wind utilities) /// @@ -118,6 +119,11 @@ class FlexboxGridParser implements WindParserInterface { /// Maps flex child properties to `FlexFit` static const _flexFitMap = { 'shrink': FlexFit.loose, // flex-shrink: 1 (can shrink) + // `shrink-0` is intentionally absent: it must NOT set a flexFit. A non-null + // flexFit makes the widget self-wrap in `Flexible(fit: ...)`, which would + // force a fill (the opposite of shrink-0) and assert outside a Flex. The + // "do not shrink, keep intrinsic size" behavior is delivered by + // `WDiv._hasShrinkZero`, which reads the className and skips the wrap. 'flex-auto': FlexFit.loose, 'flex-initial': FlexFit.loose, 'flex-shrink': FlexFit.loose, @@ -211,6 +217,12 @@ class FlexboxGridParser implements WindParserInterface { flexFit = _flexFitMap[className]; } else if (alignment == null && _alignSelfMap.containsKey(className)) { alignment = _alignSelfMap[className]; + } else if (alignment == null && className.startsWith('self-')) { + // Tailwind's canonical shorthand `self-*` aliases `align-self-*`. + // Normalize to the long form so both routes share one mapping + // (an unmapped token like `self-baseline` resolves to null, exactly + // as `align-self-baseline` would). + alignment = _alignSelfMap['align-$className']; } else { // No match or skipped. } @@ -317,6 +329,7 @@ class FlexboxGridParser implements WindParserInterface { className.startsWith('justify-') || className.startsWith('items-') || className.startsWith('align-') || + className.startsWith('self-') || className.startsWith('gap-') || className.startsWith('space-x-') || className.startsWith('space-y-') || diff --git a/lib/src/parser/parsers/sizing_parser.dart b/lib/src/parser/parsers/sizing_parser.dart index 5b5b181..3d37fdc 100644 --- a/lib/src/parser/parsers/sizing_parser.dart +++ b/lib/src/parser/parsers/sizing_parser.dart @@ -52,7 +52,9 @@ class SizingParser implements WindParserInterface { '5xl': 1024, '6xl': 1152, '7xl': 1280, - 'prose': 65 * 16, // 65ch ≈ 65 * average char width (16px for body text) + // Deliberate fixed-px divergence from Tailwind's font-relative 65ch: 512px + // matches max-w-lg and avoids font-size dependency at render time. + 'prose': 512, }; /// Parses sizing related classes and returns updated WindStyle diff --git a/lib/src/parser/wind_style.dart b/lib/src/parser/wind_style.dart index 3db4a90..9eb89a3 100644 --- a/lib/src/parser/wind_style.dart +++ b/lib/src/parser/wind_style.dart @@ -383,11 +383,14 @@ class WindStyle { double? positionBottom, double? positionLeft, }) { - final currentDec = this.decoration ?? const BoxDecoration(); - - final updatedDecoration = decoration == null - ? currentDec - : currentDec.copyWith( + // Preserve null when neither side carries a decoration. Fabricating an + // empty BoxDecoration here would flip the `decoration != null` gate that + // widgets use to decide whether to wrap a Container, so a padding-only or + // text-only style would needlessly wrap one. Only build a merged + // decoration when the incoming copyWith actually supplies one. + final BoxDecoration? updatedDecoration = decoration == null + ? this.decoration + : (this.decoration ?? const BoxDecoration()).copyWith( color: decoration.color, image: decoration.image, border: decoration.border, diff --git a/lib/src/theme/wind_theme_data.dart b/lib/src/theme/wind_theme_data.dart index e588fce..3d2a477 100644 --- a/lib/src/theme/wind_theme_data.dart +++ b/lib/src/theme/wind_theme_data.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'defaults/border_radius.dart' as default_border_radius; @@ -137,9 +138,6 @@ class WindThemeData { /// Defaults to 4.0. final double baseSpacingUnit; - /// The default color for ring utility. - /// - /// Defaults to Tailwind's blue-500 (#3B82F6). /// The default color for ring utility. /// /// Defaults to Tailwind's blue-500 (#3B82F6). @@ -482,4 +480,49 @@ class WindThemeData { canvasColor: Colors.transparent, ); } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + + return other is WindThemeData && + other.brightness == brightness && + other.applyDefaultFontFamily == applyDefaultFontFamily && + other.syncWithSystem == syncWithSystem && + other.baseSpacingUnit == baseSpacingUnit && + other.ringColor == ringColor && + mapEquals(other.colors, colors) && + mapEquals(other.screens, screens) && + mapEquals(other.containers, containers) && + mapEquals(other.fontSizes, fontSizes) && + mapEquals(other.fontWeights, fontWeights) && + mapEquals(other.tracking, tracking) && + mapEquals(other.leading, leading) && + mapEquals(other.borderWidths, borderWidths) && + mapEquals(other.borderRadius, borderRadius) && + mapEquals(other.fontFamilies, fontFamilies) && + mapEquals(other.ringWidths, ringWidths) && + mapEquals(other.ringOffsets, ringOffsets) && + mapEquals(other.opacities, opacities) && + mapEquals(other.zIndices, zIndices) && + mapEquals(other.shadows, shadows) && + mapEquals(other.transitionDurations, transitionDurations) && + mapEquals(other.transitionCurves, transitionCurves) && + mapEquals(other.animations, animations); + } + + // hashCode is composed from the scalar fields only. The map fields are + // rebuilt (merged with defaults) on every construction, so hashing them by + // identity would break the equal-objects-share-a-hashCode contract that the + // value-based `operator ==` establishes. A scalar-only hash stays consistent + // with `==` (equal objects always agree on every scalar) at the cost of more + // collisions between themes that differ only in their maps, which is fine. + @override + int get hashCode => Object.hash( + brightness, + applyDefaultFontFamily, + syncWithSystem, + baseSpacingUnit, + ringColor, + ); } diff --git a/lib/src/widgets/w_anchor.dart b/lib/src/widgets/w_anchor.dart index 9f4e648..4b77b7a 100644 --- a/lib/src/widgets/w_anchor.dart +++ b/lib/src/widgets/w_anchor.dart @@ -73,6 +73,20 @@ class WAnchor extends StatefulWidget { /// and the widget is not disabled, otherwise `SystemMouseCursors.basic`. final MouseCursor? mouseCursor; + /// An explicit accessible label for the button Semantics node. + /// + /// When the child carries no readable `Text` for `MergeSemantics` to absorb + /// (an icon-only anchor, for example), set this so screen readers and + /// Playwright `getByRole('button', { name: ... })` can resolve the control. + /// When null, the label falls back to the merged descendant text. + /// + /// Setting this excludes the entire descendant subtree from semantics + /// (`excludeSemantics: true`), so the label overrides any child text rather + /// than concatenating with it. Do not set it on an anchor that wraps its own + /// interactive descendant (a nested field or sub-button): that descendant's + /// Semantics node would be suppressed. + final String? semanticLabel; + /// Creates a `WAnchor` widget. /// /// The [child] argument is required and represents the interactive area. @@ -86,6 +100,7 @@ class WAnchor extends StatefulWidget { this.isDisabled = false, this.states, this.mouseCursor, + this.semanticLabel, }); @override @@ -213,6 +228,29 @@ class _WAnchorState extends State { ), ); + // Accessibility branch selection. + // + // 1. Explicit label: emit the label on the Semantics node and exclude the + // descendant subtree so the child Text does not merge in and double the + // name ("Save\nSave"). Because `excludeSemantics: true` drops the + // descendant GestureDetector's tap SemanticsAction, the activation + // actions are lifted onto this same node so assistive technology can + // still trigger them. `onDoubleTap` has no SemanticsAction equivalent + // and is intentionally not exposed here. + if (widget.semanticLabel != null) { + return Semantics( + button: true, + enabled: !widget.isDisabled, + label: widget.semanticLabel, + onTap: widget.isDisabled ? null : widget.onTap, + onLongPress: widget.isDisabled ? null : widget.onLongPress, + excludeSemantics: true, + child: result, + ); + } + + // 2. No explicit label: keep the MergeSemantics path so the descendant + // Text/WText nodes collapse into this node and supply the name. return MergeSemantics( child: Semantics( button: true, diff --git a/lib/src/widgets/w_button.dart b/lib/src/widgets/w_button.dart index 3a30d22..797239f 100644 --- a/lib/src/widgets/w_button.dart +++ b/lib/src/widgets/w_button.dart @@ -39,6 +39,11 @@ import 'w_text.dart'; /// child: Text('Submit'), /// ) /// ``` +/// +/// See also: +/// +/// * [WAnchor], which `WButton` delegates all state management and interaction handling to. +/// * [WInput], which pairs with `WButton` in form submission flows. class WButton extends StatelessWidget { /// The button content when not loading. final Widget child; @@ -101,6 +106,12 @@ class WButton extends StatelessWidget { /// Example: `states: {'error'}` activates `error:border-red-500`. final Set? states; + /// An explicit accessible label for the button. + /// + /// Required in practice for icon-only buttons, where there is no text child + /// for the Semantics tree to absorb. Forwarded to [WAnchor.semanticLabel]. + final String? semanticLabel; + /// Creates a new [WButton] instance. const WButton({ super.key, @@ -116,6 +127,7 @@ class WButton extends StatelessWidget { this.loadingSize = 16, this.loadingColor, this.states, + this.semanticLabel, }); @override @@ -131,6 +143,7 @@ class WButton extends StatelessWidget { onDoubleTap: isInteractive ? onDoubleTap : null, isDisabled: disabled, states: states, + semanticLabel: semanticLabel, mouseCursor: isInteractive ? SystemMouseCursors.click : SystemMouseCursors.forbidden, diff --git a/lib/src/widgets/w_date_picker.dart b/lib/src/widgets/w_date_picker.dart index 8be91d9..01e789c 100644 --- a/lib/src/widgets/w_date_picker.dart +++ b/lib/src/widgets/w_date_picker.dart @@ -7,7 +7,7 @@ import 'w_popover.dart'; import 'w_text.dart'; /// Date picker selection mode. -enum DatePickerMode { +enum WDatePickerMode { /// Single date selection. single, @@ -80,7 +80,7 @@ typedef DateDisplayFormat = String Function(DateTime date); /// /// ```dart /// WDatePicker( -/// mode: DatePickerMode.range, +/// mode: WDatePickerMode.range, /// range: _dateRange, /// onRangeChanged: (range) => setState(() => _dateRange = range), /// className: 'w-full p-3 border rounded-lg', @@ -89,7 +89,7 @@ typedef DateDisplayFormat = String Function(DateTime date); /// ``` class WDatePicker extends StatefulWidget { /// Selection mode: single date or date range. - final DatePickerMode mode; + final WDatePickerMode mode; /// The currently selected date (single mode). final DateTime? value; @@ -129,7 +129,7 @@ class WDatePicker extends StatefulWidget { /// Creates a new [WDatePicker] instance. const WDatePicker({ super.key, - this.mode = DatePickerMode.single, + this.mode = WDatePickerMode.single, this.value, this.range, this.onChanged, @@ -172,9 +172,9 @@ class _WDatePickerState extends State { } void _initFocusedMonth() { - if (widget.mode == DatePickerMode.single && widget.value != null) { + if (widget.mode == WDatePickerMode.single && widget.value != null) { _focusedMonth = _normalizeToMonth(widget.value!); - } else if (widget.mode == DatePickerMode.range && widget.range != null) { + } else if (widget.mode == WDatePickerMode.range && widget.range != null) { _focusedMonth = _normalizeToMonth(widget.range!.start); } else { _focusedMonth = _normalizeToMonth(DateTime.now()); @@ -232,7 +232,7 @@ class _WDatePickerState extends State { /// Formats the display text for the trigger. String _getDisplayText() { - if (widget.mode == DatePickerMode.single) { + if (widget.mode == WDatePickerMode.single) { return widget.value != null ? _formatDate(widget.value!) : widget.placeholder; @@ -269,7 +269,7 @@ class _WDatePickerState extends State { final normalized = _normalizeToDay(date); - if (widget.mode == DatePickerMode.single) { + if (widget.mode == WDatePickerMode.single) { widget.onChanged?.call(normalized); _closePopover(); } else { @@ -355,7 +355,7 @@ class _WDatePickerState extends State { enabled: !widget.disabled, label: widget.placeholder, value: widget.value?.toIso8601String() ?? - (widget.mode == DatePickerMode.range && widget.range != null + (widget.mode == WDatePickerMode.range && widget.range != null ? widget.range!.start.toIso8601String() : null), child: MergeSemantics( @@ -384,7 +384,7 @@ class _WDatePickerState extends State { setState(() { _isOpen = true; // Reset range selection state when opening - if (widget.mode == DatePickerMode.range && + if (widget.mode == WDatePickerMode.range && widget.range?.isComplete == true) { _rangeStart = null; } @@ -444,16 +444,16 @@ class _WDatePickerState extends State { _CalendarGrid( focusedMonth: _focusedMonth, selectedDate: - widget.mode == DatePickerMode.single ? widget.value : null, + widget.mode == WDatePickerMode.single ? widget.value : null, selectedRange: - widget.mode == DatePickerMode.range ? widget.range : null, + widget.mode == WDatePickerMode.range ? widget.range : null, rangeStart: _rangeStart, hoveredDate: _hoveredDate, minDate: widget.minDate, maxDate: widget.maxDate, onDateSelected: _onDateSelected, onDateHovered: (date) { - if (widget.mode == DatePickerMode.range && + if (widget.mode == WDatePickerMode.range && _rangeStart != null) { setState(() => _hoveredDate = date); } @@ -466,7 +466,7 @@ class _WDatePickerState extends State { } bool get _hasValue { - if (widget.mode == DatePickerMode.single) { + if (widget.mode == WDatePickerMode.single) { return widget.value != null; } return widget.range != null; diff --git a/lib/src/widgets/w_div.dart b/lib/src/widgets/w_div.dart index 8873664..39f4b6b 100644 --- a/lib/src/widgets/w_div.dart +++ b/lib/src/widgets/w_div.dart @@ -44,6 +44,12 @@ import 'w_text.dart'; /// ], /// ) /// ``` +/// +/// See also: +/// +/// * [WText], which renders typography inside a `WDiv` container. +/// * [WAnchor], which `WDiv` auto-wraps into when `hover:` or `focus:` classes are present. +/// * [WindParser], which resolves the [className] string into a [WindStyle] at runtime. class WDiv extends StatelessWidget { /// The utility class string defining the style and layout. /// @@ -890,10 +896,13 @@ class WDiv extends StatelessWidget { // because fractional sizing is handled by the SizedBox layer above. // Container's width/height is still set when it IS created, to ensure // decoration (bg-*) fills the full area. + // An empty shadow list (`shadow-none`) carries no visual and must not, on + // its own, force a Container; that mirrors the `combinedShadows.isNotEmpty` + // guard below where empty shadows never reach the decoration. final bool needsContainer = styles.decoration != null || innerConstraints != null || - styles.boxShadow != null || - styles.ringShadow != null || + (styles.boxShadow?.isNotEmpty ?? false) || + (styles.ringShadow?.isNotEmpty ?? false) || backgroundColor != null; // Track if padding is consumed by Container (so we don't apply it again) diff --git a/lib/src/widgets/w_form_date_picker.dart b/lib/src/widgets/w_form_date_picker.dart index a379454..4a9be51 100644 --- a/lib/src/widgets/w_form_date_picker.dart +++ b/lib/src/widgets/w_form_date_picker.dart @@ -1,4 +1,4 @@ -import 'package:flutter/material.dart' hide DatePickerMode; +import 'package:flutter/material.dart'; import 'w_date_picker.dart'; import 'w_div.dart'; @@ -34,7 +34,7 @@ class WFormDatePicker extends FormField { // WDatePicker params super.initialValue, this.initialRange, - this.mode = DatePickerMode.single, + this.mode = WDatePickerMode.single, this.onChanged, this.onRangeChanged, this.minDate, @@ -77,7 +77,7 @@ class WFormDatePicker extends FormField { ); /// Selection mode: single date or date range. - final DatePickerMode mode; + final WDatePickerMode mode; /// The initially selected date range (range mode). final DateRange? initialRange; @@ -148,7 +148,7 @@ class _WFormDatePickerContent extends StatefulWidget { }); final FormFieldState state; - final DatePickerMode mode; + final WDatePickerMode mode; final DateRange? initialRange; final ValueChanged? onChanged; final ValueChanged? onRangeChanged; @@ -185,7 +185,7 @@ class _WFormDatePickerContentState extends State<_WFormDatePickerContent> { super.didUpdateWidget(oldWidget); // Sync external state changes (e.g. form reset) - if (widget.mode == DatePickerMode.range) { + if (widget.mode == WDatePickerMode.range) { if (widget.state.value == null && _currentRange != null) { setState(() => _currentRange = null); } else if (widget.state.value != null && diff --git a/lib/src/widgets/w_input.dart b/lib/src/widgets/w_input.dart index 2c53802..011fcaf 100644 --- a/lib/src/widgets/w_input.dart +++ b/lib/src/widgets/w_input.dart @@ -65,6 +65,11 @@ enum InputType { /// onSubmitted: (value) => _focusNext(), /// ) /// ``` +/// +/// See also: +/// +/// * [WFormInput], which wraps `WInput` with form validation and `FormField` integration. +/// * [WButton], which is the typical submit trigger paired with `WInput` in a form. class WInput extends StatefulWidget { /// The controlled value of the input. /// @@ -72,6 +77,12 @@ class WInput extends StatefulWidget { final String? value; /// Called when the user changes the input value. + /// + /// Follows Flutter's `TextField.onChanged` contract: it fires on user and + /// IME edits only. Writing `controller.text` programmatically on an external + /// [controller] does NOT fire this callback, exactly as a bare `TextField` + /// behaves. For reactive binding, drive the field through [value] + + /// [onChanged], or use `WFormInput` when you need form-state propagation. final ValueChanged? onChanged; /// The type of input, determining keyboard and behavior. @@ -153,7 +164,9 @@ class WInput extends StatefulWidget { /// Optional external text editing controller. /// - /// If provided, `value` prop is ignored. + /// If provided, the [value] prop is ignored. Note that mutating + /// `controller.text` yourself updates the displayed text but does not fire + /// [onChanged], matching Flutter's `TextField` semantics. See [onChanged]. final TextEditingController? controller; /// Custom states for dynamic styling (e.g., 'error', 'success'). diff --git a/lib/src/widgets/w_text.dart b/lib/src/widgets/w_text.dart index 9a7b076..0e83b1c 100644 --- a/lib/src/widgets/w_text.dart +++ b/lib/src/widgets/w_text.dart @@ -29,6 +29,11 @@ import '../utils/wind_logger.dart'; /// className: 'text-xl text-blue-500 font-bold text-center p-4 uppercase', /// ) /// ``` +/// +/// See also: +/// +/// * [WDiv], which is the primary container for composing layouts around `WText` nodes. +/// * [WAnchor], which wraps `WText` when interactive states (hover, focus) are needed. class WText extends StatelessWidget { /// The text string to display. final String data; diff --git a/llms.txt b/llms.txt index 002af56..f191334 100644 --- a/llms.txt +++ b/llms.txt @@ -1,6 +1,6 @@ # fluttersdk_wind -> Tailwind CSS for Flutter. A utility-first styling framework that maps `className` strings to optimized Flutter widget trees, with 23-field `WindThemeData` theming, responsive prefixes (`sm:` / `md:` / `lg:` / `xl:` / `2xl:`), `dark:`, state prefixes (`hover:` / `focus:` / `disabled:` / `loading:` / `selected:`), platform prefixes (`ios:` / `android:` / `web:` / `mobile:`), 20 W-prefix widgets, server-driven UI via `WDynamic`, and three AI integration layers (hosted MCP server, Claude Code skill, Cursor rules). Open Source. Tailwind syntax. Flutter native. Version 1.0.0 stable. +> Tailwind CSS for Flutter. A utility-first styling framework that maps `className` strings to optimized Flutter widget trees, with 23-field `WindThemeData` theming, responsive prefixes (`sm:` / `md:` / `lg:` / `xl:` / `2xl:`), `dark:`, state prefixes (`hover:` / `focus:` / `disabled:` / `loading:` / `selected:`), platform prefixes (`ios:` / `android:` / `web:` / `mobile:`), 22 W-prefix widgets, server-driven UI via `WDynamic`, and three AI integration layers (hosted MCP server, Claude Code skill, and a skill distributed to 8+ AI clients via fluttersdk/ai). Open Source. Tailwind syntax. Flutter native. Version 1.0.0 stable. **Stack**: Flutter >=3.27.0 · Dart >=3.4.0 · Runtime deps: `flutter_svg`, `fluttersdk_wind_diagnostics_contracts`. No `mockito`, no state management library, no router. @@ -27,7 +27,7 @@ WDiv( child: WText('Hello', className: 'text-xl font-bold dark:text-white'), ) -// Debug only: expose 6-field debug data to inspectors via fluttersdk_wind_diagnostics_contracts +// Debug only: expose 7-field debug data to inspectors via fluttersdk_wind_diagnostics_contracts if (kDebugMode) Wind.installDebugResolver(); ``` @@ -49,6 +49,8 @@ if (kDebugMode) Wind.installDebugResolver(); - [WButton](https://fluttersdk.com/wind/widgets/w-button.md): Button with `hover:` / `focus:` / `active:` / `loading:` / `disabled:` state prefixes. - [WAnchor](https://fluttersdk.com/wind/widgets/w-anchor.md): Inline interactive wrapper; auto-wraps `WDiv` / `WButton` when hover/focus prefixes are detected. - [WPopover](https://fluttersdk.com/wind/widgets/w-popover.md): Overlay container with alignment + trigger / content builders. +- [WKeyboardActions](https://fluttersdk.com/wind/widgets/w-keyboard-actions.md): Above-keyboard toolbar with Done button and field navigation for iOS numeric keyboards; platform-targeted via `platform: 'ios'`. +- [WindAnimationWrapper](https://fluttersdk.com/wind/widgets/wind-animation-wrapper.md): Low-level animation engine; wraps a child in spin, ping, pulse, or bounce with configurable `duration` and `curve`. Used internally by `animate-*` className tokens. ### Form diff --git a/pubspec.yaml b/pubspec.yaml index 40054c2..d8b9506 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -15,6 +15,16 @@ environment: sdk: ">=3.4.0 <4.0.0" flutter: ">=3.27.0" +# Declared explicitly so pub.dev's platform detection is not silently narrowed +# by the `dart:io` import-graph trace; wind supports every Flutter platform. +platforms: + android: + ios: + web: + macos: + windows: + linux: + dependencies: flutter: sdk: flutter diff --git a/skills/wind-ui/SKILL.md b/skills/wind-ui/SKILL.md index 488c493..e37eefe 100644 --- a/skills/wind-ui/SKILL.md +++ b/skills/wind-ui/SKILL.md @@ -3,10 +3,10 @@ name: wind-ui description: "fluttersdk_wind 1.0: utility-first Flutter styling with Tailwind-syntax className strings. 22 public widgets (WDiv, WText, WButton, WInput, WSelect, WCheckbox, WDatePicker, WPopover, WAnchor, WIcon, WImage, WSvg, WSpacer, WBreakpoint, WDynamic, WKeyboardActions, WindAnimationWrapper + 5 WForm* wrappers) consume className through a 19-parser pipeline that emits a cached immutable WindStyle. Prefixes stack freely (dark: / hover: / focus: / md: / lg: / ios: / android: / web: / mobile: / selected: / loading: / disabled: / error: / checked: / custom). Last class wins; unknown tokens fail silently. Every color token (bg-, text-, border-, ring-, shadow-, fill-) needs a dark: pair in the same className. TRIGGER when: writing or editing any UI in a Flutter app that depends on `fluttersdk_wind`; any className string; any W-prefix widget; any WindTheme / WindThemeData reference; the user mentions Tailwind for Flutter, utility-first, className, or wind-ui. DO NOT TRIGGER when: backend / API / state-management work that does not touch a widget tree; Flutter projects that do not have fluttersdk_wind in pubspec.yaml; Material-only widgets (Scaffold, AppBar, Dialog) without Wind content inside them." when_to_use: | Any task that produces, modifies, or audits Wind-styled Flutter UI: composing a className string, picking the right W-widget for a use case, integrating with a Form / FormField, customizing WindThemeData, wiring dark-mode pairs, debugging an unexpected layout, recovering from RenderFlex overflow, building a popover or dropdown, rendering a JSON tree via WDynamic, wiring Wind.installDebugResolver for kDebugMode tooling, or migrating a Tailwind className from web. Apply BEFORE writing the first line of UI in a Wind-using file, not as an audit pass. -version: 2.0.1 +version: 2.0.3 --- - + # Wind UI 1.0 @@ -124,7 +124,7 @@ Inline this catalog as your default reach-for set. For the full per-parser regex **Layout** — `flex` `flex-row` `flex-col` `flex-row-reverse` `flex-col-reverse` `wrap` `grid` `grid-cols-N` `block` `hidden`. `justify-start` `-center` `-end` `-between` `-around` `-evenly`. `items-start` `-center` `-end` `-baseline` `-stretch`. `axis-min` `axis-max` (Wind-only, sets `MainAxisSize`). -**Flex child** — `flex-1` `flex-auto` `flex-none` `flex-N` (numeric). `shrink-0` `grow`. `order-0` through `order-12`, `order-first` / `order-last` / `order-none`, arbitrary `order-[-5]`. +**Flex child** — `flex-1` `flex-auto` `flex-none` `flex-N` (numeric). `shrink-0` `grow`. `self-start` / `-end` / `-center` / `-stretch` / `-auto` (align-self shorthand; `align-self-*` long form also works). `order-0` through `order-12`, `order-first` / `order-last` / `order-none`, arbitrary `order-[-5]`. **Spacing** — `p-N` `px-N` `py-N` `pt-N` `pr-N` `pb-N` `pl-N` (no `ps-`/`pe-`). `m-N` and axes (no negative margin, no `ms-`/`me-`, `mx-auto` for horizontal centering). `gap-N` `gap-x-N` `gap-y-N` `space-x-N` `space-y-N`. Arbitrary `p-[18px]`, `gap-[3.5]` (no `%` for spacing). Default unit: 4 px per step. `p-4` = 16 px. @@ -185,6 +185,9 @@ Wind hides most boilerplate but never changes Flutter's "constraints down, sizes | **`truncate` requires bounded width** | `WText('long...', className: 'truncate')` inside a Row | wrap in `WDiv(className: 'flex-1', child: WText(..., className: 'truncate'))` | | **Nested flex with truncate needs `min-w-0`** | `WDiv(className: 'flex-1 flex flex-col', children: [WText(..., className: 'truncate')])` | `WDiv(className: 'flex-1 flex flex-col min-w-0', children: [WText(..., className: 'truncate')])` | | **Icon buttons need ≥48 dp touch target** | `WButton(child: WIcon(Icons.close_outlined))` (visual size 24 dp) | `WButton(className: 'p-3 rounded-lg', child: WIcon(Icons.close_outlined))` (48 dp tap target) | +| **Icon-only buttons need `semanticLabel`** | `WButton(child: WIcon(Icons.close_outlined))` (nameless to screen readers / `getByRole`) | `WButton(semanticLabel: 'Close', child: WIcon(Icons.close_outlined))` | +| **`semanticLabel` overrides child text** | `WButton(semanticLabel: 'Save', child: WText('Save'))` (label set alongside visible text; the child's text is excluded from semantics) | omit it when the child has readable text; prefer it only for icon-only controls where no text is present | +| **`semanticLabel` excludes the word "button"** | `semanticLabel: 'Close button'` (announced as "Close button button") | `semanticLabel: 'Close'` (the role appends "button") | `items-stretch` inside a `SingleChildScrollView` needs an `IntrinsicHeight` wrapper from native Flutter; Wind has no token for it. Rare; reach for it when row children inside a scroll must match heights. @@ -298,7 +301,7 @@ Full Form patterns + validation recipes + async error flow: `${CLAUDE_SKILL_DIR} ## 10. Definition of done for a Wind change -Per change (every UI edit), verify all six: +Per change (every UI edit), verify all seven: 1. **Every color token has a `dark:` peer** in the same className block. Grep your diff for `bg-`, `text-`, `border-`, `ring-`, `fill-`, `shadow-` and confirm a `dark:` peer on each. 2. **Every multi-concern className (3+ token families) is triple-quoted**, one concern per line, dark pairs grouped beside their light variant. @@ -306,6 +309,7 @@ Per change (every UI edit), verify all six: 4. **Every `child` XOR `children`** — never both. Construction-time assertion. 5. **Every scrollable root `WDiv` has `scrollPrimary: true`** on at least one ancestor in the scroll chain so iOS status-bar-tap scrolls to top. 6. **No Dart string interpolation inside className**, no inline `BoxDecoration` / `TextStyle` / `EdgeInsets` when a token covers it, no `Icons.*` (non-outlined) without a deliberate reason, no `WIcon` without `Icons.*_outlined`. +7. **Every icon-only `WButton` / `WAnchor` has a `semanticLabel`** — without it the control is nameless to screen readers and `getByRole('button', { name })`. When set, the child subtree is excluded from semantics, so `semanticLabel` overrides any child text rather than concatenating with it. Prefer it for icon-only controls; omit it when the child already carries readable text. Do not put the word "button" in the label; the role appends it. Run `dart analyze` on the touched files and visually verify the change in light AND dark mode (toggle via `context.windTheme.toggleTheme()` if there is no UI for it yet) before reporting done. Missing dark pairs only surface when the app is actually in dark mode. @@ -390,7 +394,7 @@ class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return WindTheme( - data: WindThemeData(/* 20 optional fields; defaults applied otherwise */), + data: WindThemeData(/* 23 optional fields; defaults applied otherwise */), builder: (context, controller) => MaterialApp( theme: controller.toThemeData(), home: const Scaffold(body: HomePage()), diff --git a/skills/wind-ui/references/debug.md b/skills/wind-ui/references/debug.md index 39e21b0..3d01f6d 100644 --- a/skills/wind-ui/references/debug.md +++ b/skills/wind-ui/references/debug.md @@ -39,7 +39,7 @@ Replaces the alpha-9 era `WindDuskIntegration.install()` and the `lib/dusk_integ ## 2. What the resolver exposes -For each W-prefix widget with a non-empty `className`, the resolver returns a `Map` with 6 fields: +For each W-prefix widget with a non-empty `className`, the resolver returns a `Map` with up to 7 fields (5 always present, plus `bgColor` / `textColor` when non-null): | Field | Type | Source | |---|---|---| @@ -51,7 +51,7 @@ For each W-prefix widget with a non-empty `className`, the resolver returns a `M | `bgColor` | `String?` | Resolved background color as uppercase hex RGB (`#3B82F6`); present only when non-null | | `textColor` | `String?` | Resolved text color as uppercase hex RGB; present only when non-null | -Non-Wind elements (a Material `Container`, a `ListTile`) return an empty map. The resolver walks `Element` (which IS-A `BuildContext`) and parses the className through `WindParser.parse(className, element)`. +Non-Wind elements (a Material `Container`, a `ListTile`) return an empty map. So do W-widgets that have no `className` field (`WAnchor`, `WBreakpoint`, `WindAnimationWrapper`, `WKeyboardActions`): the resolver skips them safely instead of throwing, so a bare `WAnchor` in the tree never aborts a dusk / telescope snapshot. The resolver walks `Element` (which IS-A `BuildContext`) and parses the className through `WindParser.parse(className, element)`. --- @@ -219,4 +219,4 @@ Discipline: For testing dark mode, pass `WindThemeData(brightness: Brightness.dark, syncWithSystem: false)` to `wrapWithTheme`. For testing breakpoints, set the viewport size to bracket each breakpoint. -For E2E testing inside a running app (Dusk / Telescope / Playwright), `Wind.installDebugResolver()` exposes the 6-field snapshot per W-widget; consumers read via `WindDebugRegistry.current?.resolve(element)` without needing Wind as a direct dep. +For E2E testing inside a running app (Dusk / Telescope / Playwright), `Wind.installDebugResolver()` exposes the 7-field snapshot per W-widget; consumers read via `WindDebugRegistry.current?.resolve(element)` without needing Wind as a direct dep. diff --git a/skills/wind-ui/references/dynamic.md b/skills/wind-ui/references/dynamic.md index cfb60db..97aad37 100644 --- a/skills/wind-ui/references/dynamic.md +++ b/skills/wind-ui/references/dynamic.md @@ -243,7 +243,7 @@ Custom builders BYPASS the whitelist entirely. If you register `MyChart`, it ren ### Custom icons -Override or extend the 25 built-in icon names: +Override or extend the 24 built-in icon names: ```dart WDynamic( @@ -260,7 +260,7 @@ In JSON: {"type": "WIcon", "props": {"icon": "app-logo", "className": "w-8 h-8 text-blue-500"}} ``` -Built-in icon names (default fallback): `star`, `home`, `person`, `check`, `close`, `settings`, `search`, `add`, `edit`, `delete`, `favorite`, `favorite_outline`, `mail`, `menu`, `info`, `warning`, `error`, `help`, `code`, `chevron_left`, `chevron_right`, `arrow_back`, `arrow_forward`, plus 2 more. Unknown names fall back to `Icons.help_outline`. +Built-in icon names (default fallback, 24 total): `star`, `star_outline`, `home`, `person`, `check`, `close`, `settings`, `search`, `add`, `edit`, `delete`, `favorite`, `favorite_outline`, `mail`, `menu`, `info`, `warning`, `error`, `help`, `code`, `chevron_left`, `chevron_right`, `arrow_back`, `arrow_forward`. Names are matched case-insensitively (lowercased). Unknown names fall back to `Icons.help_outline`. The `customIcons:` map merges with the built-in defaults; user keys override. diff --git a/skills/wind-ui/references/forms.md b/skills/wind-ui/references/forms.md index 56a14cd..e21a818 100644 --- a/skills/wind-ui/references/forms.md +++ b/skills/wind-ui/references/forms.md @@ -313,7 +313,7 @@ DateRange? _range; String? _rangeError; WFormDatePicker( - mode: DatePickerMode.range, + mode: WDatePickerMode.range, initialRange: _range, onRangeChanged: (range) { setState(() { diff --git a/skills/wind-ui/references/tailwind-divergence.md b/skills/wind-ui/references/tailwind-divergence.md index 57f3ef8..91ad6df 100644 --- a/skills/wind-ui/references/tailwind-divergence.md +++ b/skills/wind-ui/references/tailwind-divergence.md @@ -55,6 +55,8 @@ The base rule: Wind aims for syntactic familiarity, not semantic equivalence. Mo | `rounded-{sm|md|lg|...}` | Match Tailwind v3 scale | Identical to v3; Wind has NOT adopted the v4 rename shift | | `shadow-{sm|md|lg|...}` | Match Tailwind v3 scale | Identical to v3 | | `ring` (bare) | v3 default 3 px; v4 default 1 px | Wind matches v3 (default 3 px) | +| `tracking-{tighter..widest}` | em fractions (`tracking-wide` = 0.025em, scales with font size) | Fixed px (`tighter` -2, `tight` -1, `wide` 1, `wider` 2, `widest` 4) because `TextStyle.letterSpacing` is pixel-based; NOT proportional across font sizes | +| `max-w-prose` | `65ch`, font-relative (≈ 520-624 px depending on font) | Fixed `512 px`; a divergence, not an equivalent | --- @@ -154,6 +156,9 @@ Wind adds a small set of tokens for Flutter-specific scenarios. - `mobile:` (iOS OR Android) - `web:` (web build) +### Order scale starts at zero +- `order-0` — Wind supports `order-0` through `order-12`; Tailwind's default scale starts at `order-1` (`order-0` is a Wind addition) + ### Main-axis size (Flutter `MainAxisSize`) - `axis-min` — sets `MainAxisSize.min` on the parent flex - `axis-max` — sets `MainAxisSize.max` @@ -173,7 +178,7 @@ Wind adds a small set of tokens for Flutter-specific scenarios. ### Debug instrumentation - `debug` token — triggers `WindLogger` to log composition tree + final styles -- `Wind.installDebugResolver()` (Dart API) — exposes 6 fields per widget to external tooling +- `Wind.installDebugResolver()` (Dart API) — exposes 7 fields per widget to external tooling --- @@ -315,3 +320,20 @@ Notes on the port: - `truncate` on the `

` became `truncate` inside a `flex-1 min-w-0` wrapper. - Every color paired with `dark:`. - Outer `shadow-sm hover:shadow-md` kept; would auto-wrap in `WAnchor` because of the `hover:` prefix. + +## Unsupported Tailwind classes (use the wind equivalent) + +These canonical Tailwind classes are silently ignored by wind (unknown tokens are dropped). Use the listed equivalent: + +| Tailwind | Status | wind equivalent | +|----------|--------|-----------------| +| `inline-flex` | unsupported (Flutter has no inline layout) | `flex` + `self-start` | +| `rounded-s-*` / `rounded-e-*` | unsupported (logical start/end radius) | `rounded-l-*` / `rounded-r-*` | +| `ms-*` / `me-*` | unsupported (logical inline margin) | `ml-*` / `mr-*` | +| `start-*` / `end-*` | unsupported (logical inset) | `left-*` / `right-*` | +| `-space-x-*` / `-space-y-*` | unsupported (negative gap; no overlap primitive) | none | +| `self-*` | supported (alias of `align-self-*`) | `self-center` etc. work directly | +| `shrink-0` | supported (preserves intrinsic size, no Flexible wrap) | works directly | +| `text-7xl` / `8xl` / `9xl` | silently capped (max is `text-6xl`) | `text-6xl` or arbitrary `text-[96px]` | + +Reminder: a wind page needs a Material ancestor (a `Scaffold`) for `WText` to inherit a default text style; without one, Flutter renders the yellow-underline fallback. Real apps always have a `Scaffold`, so this only bites bare `WDiv > WText` route bodies. diff --git a/skills/wind-ui/references/theme.md b/skills/wind-ui/references/theme.md index 92e4b96..2b7fd09 100644 --- a/skills/wind-ui/references/theme.md +++ b/skills/wind-ui/references/theme.md @@ -62,7 +62,7 @@ Defaults applied automatically: 22 color families, 5 responsive breakpoints (640 ## 2. WindThemeData fields -All 20 fields are nullable; pass only what you want to override. +23 fields; pass only what you want to override. All are nullable except `brightness`, which defaults to `Brightness.light` rather than null, so they are not literally all nullable. ```dart WindThemeData({ @@ -187,6 +187,8 @@ class WindThemeController extends ChangeNotifier { `toggleTheme()` is the user-driven dark-mode switch. It deliberately disables `syncWithSystem` so a user choice does not get overridden the next time the OS theme changes. `resetToSystem()` re-enables sync. +`WindThemeData` implements value `==` / `hashCode`, so passing a fresh default `WindThemeData()` on a rebuild no longer clobbers a `toggleTheme()` choice or forces a spurious rebuild: an equal value is a no-op. + --- ## 5. Dark mode discipline @@ -240,6 +242,8 @@ Default behavior (`syncWithSystem: true`): - Listens to `WidgetsBindingObserver.didChangePlatformBrightness` and updates controller when OS changes. - `onThemeChanged` callback fires for user-initiated changes only (system changes are flagged internally). +GOTCHA: because `syncWithSystem` is `true` by default, a declarative `WindThemeData(brightness: Brightness.dark)` is OVERRIDDEN on mount by the OS brightness, so `dark:` classes stay inert on a light OS. To force a fixed brightness, pass `WindThemeData(brightness: Brightness.dark, syncWithSystem: false)`, or drive it at runtime via `controller.toggleTheme()` / `setTheme(...)`. + Manual toggle: ```dart diff --git a/skills/wind-ui/references/tokens.md b/skills/wind-ui/references/tokens.md index f3c0a17..b2bf808 100644 --- a/skills/wind-ui/references/tokens.md +++ b/skills/wind-ui/references/tokens.md @@ -69,7 +69,7 @@ Inline color escape hatches that bypass the cache key: | `justify-start` / `-end` / `-center` / `-between` / `-around` / `-evenly` | `MainAxisAlignment` | | `items-start` / `-end` / `-center` / `-baseline` / `-stretch` | `CrossAxisAlignment` | | `align-content-start` / `-end` / `-center` / `-between` / `-around` / `-evenly` / `-stretch` | Wrap-only, `WrapAlignment` for runs | -| `align-self-start` / `-end` / `-center` / `-stretch` / `-auto` | Per-child cross-axis override | +| `align-self-start` / `-end` / `-center` / `-stretch` / `-auto` (or the `self-*` shorthand) | Per-child cross-axis override | | `axis-min` / `axis-max` | Wind-only: `MainAxisSize.min` / `.max` on the parent flex | | `grid-cols-N` | N columns (any integer); renders as `Wrap` with computed column widths | | `order-0` through `order-12` | Child order index | @@ -120,7 +120,7 @@ NOT supported (silently no-op): | `w-[300px]` / `h-[50%]` | Arbitrary (px OR % both supported for sizing; unique among parsers) | | `min-w-0` / `min-w-full` / `min-w-screen` | Min width | | `max-w-xs` / `max-w-sm` / `max-w-md` / `max-w-lg` / `max-w-xl` / `max-w-2xl` ... `max-w-7xl` | Named max-widths (320 / 384 / 448 / 512 / 576 / 672 / ... 1280 px) | -| `max-w-prose` | 1040 px (≈ 65ch reading width) | +| `max-w-prose` | 512 px | | `max-w-full` / `max-w-screen` | No max OR viewport-wide | | `min-h-0` / `min-h-full` / `min-h-screen` | Min height | | `max-h-full` / `max-h-screen` | Max height | @@ -189,7 +189,6 @@ Custom families via `WindThemeData.colors`: `{'primary': MaterialColor(...)}` ma | `border-N` | Numeric width (`border-2`, `border-4`, `border-8`) | | `border-[3px]` | Arbitrary width | | `border-t` / `border-r` / `border-b` / `border-l` | Directional default-width | -| `border-x` / `border-y` | Axis pair | | `border-t-N` / `border-r-N` / `border-b-N` / `border-l-N` | Directional with width | | `border-{family}-{shade}` / `border-[#hex]` / `border-{family}-{shade}/{N}` | Color (with `dark:` peer required) | | `border-solid` / `border-none` | Border style (only these two wired; `border-dashed` / `border-dotted` recognised but not rendered) | @@ -460,6 +459,7 @@ If a token from Tailwind v3 / v4 muscle memory does not seem to do anything, it - `top-[50%]` and any `%` for positioning **Border:** +- `border-x` / `border-y` (axis shortcuts NOT wired; only `border-t` / `border-r` / `border-b` / `border-l` directional, plus bare `border` uniform). Set the two physical sides explicitly. - `border-dashed` / `border-dotted` (parser recognises, no visual) - `divide-x-N` / `divide-y-N` (use explicit spacing instead) diff --git a/skills/wind-ui/references/widgets.md b/skills/wind-ui/references/widgets.md index 0ade387..d56b7ae 100644 --- a/skills/wind-ui/references/widgets.md +++ b/skills/wind-ui/references/widgets.md @@ -18,9 +18,11 @@ No sub-barrels (`lib/dusk_integration.dart` and similar were removed in 1.0 alph 4. [Interactive: WAnchor, WButton](#4-interactive-wanchor-wbutton) 5. [Form (raw): WInput, WCheckbox, WSelect, WDatePicker](#5-form-raw-winput-wcheckbox-wselect-wdatepicker) 6. [Form (FormField): WFormInput, WFormSelect, WFormMultiSelect, WFormCheckbox, WFormDatePicker](#6-form-formfield-wforminput-wformselect-wformmultiselect-wformcheckbox-wformdatepicker) -7. [Overlay: WPopover](#7-overlay-wpopover) -8. [Structural: WDynamic](#8-structural-wdynamic) -9. [Supporting types: SelectOption, DateRange, InputType, DatePickerMode, PopoverAlignment, WindAnimationType](#9-supporting-types) +7. [Keyboard: WKeyboardActions](#7-keyboard-wkeyboardactions) +8. [Animation: WindAnimationWrapper](#8-animation-windanimationwrapper) +9. [Overlay: WPopover](#9-overlay-wpopover) +10. [Structural: WDynamic](#10-structural-wdynamic) +11. [Supporting types: SelectOption, DateRange, InputType, WDatePickerMode, PopoverAlignment, WindAnimationType](#11-supporting-types) --- @@ -30,6 +32,7 @@ No sub-barrels (`lib/dusk_integration.dart` and similar were removed in 1.0 alph - **`states: Set?`** — consumer-passed state strings (e.g. `{'selected'}`). Merges with the widget's automatic states (`hover`, `focus`, `loading`, `disabled`, `checked`, `error`). - **`child` XOR `children`** — for widgets that accept both, the assertion fires at construction. Pass exactly one. - **Outlined icon convention** — when using `Icons.*`, prefer the `_outlined` variant. `Icons.settings_outlined`, not `Icons.settings`. +- **`semanticLabel` for icon-only controls** — `WButton` / `WAnchor` accept `semanticLabel: String?`. An icon-only button (no text child) is nameless to screen readers and Playwright `getByRole('button', { name })` without it; always set it when the child carries no readable text. When set, the child subtree is excluded from semantics, so the label overrides any child text rather than concatenating with it. Prefer it for icon-only controls; omit it when the child already exposes readable text. The label must NOT contain the word "button"; the role appends it (`'Close button'` becomes "Close button button"). - **Inline color escape hatches**: - `WDiv(backgroundColor: Color)` overrides any `bg-*` / `dark:bg-*`. - `WText(foregroundColor: Color)` overrides any `text-*` / `dark:text-*`. @@ -236,6 +239,7 @@ const WAnchor({ bool isDisabled = false, Set? states, MouseCursor? mouseCursor, // defaults to SystemMouseCursors.click when gestures exist + String? semanticLabel, // accessible name for icon-only anchors (no child text for Semantics to absorb) }) ``` @@ -266,6 +270,7 @@ const WButton({ double loadingSize = 16, Color? loadingColor, // explicit override; otherwise contrast-detected Set? states, + String? semanticLabel, // accessible name for icon-only buttons (no child text for Semantics to absorb) }) ``` @@ -429,7 +434,7 @@ Calendar popover with single OR range mode. ```dart const WDatePicker({ Key? key, - DatePickerMode mode = DatePickerMode.single, + WDatePickerMode mode = WDatePickerMode.single, // Single mode: DateTime? value, ValueChanged? onChanged, @@ -599,7 +604,7 @@ const WFormDatePicker({ Key? key, DateTime? initialValue, DateRange? initialRange, - DatePickerMode mode = DatePickerMode.single, + WDatePickerMode mode = WDatePickerMode.single, ValueChanged? onChanged, ValueChanged? onRangeChanged, // FormField: @@ -628,7 +633,105 @@ const WFormDatePicker({ --- -## 7. Overlay: WPopover +## 7. Keyboard: WKeyboardActions + +Above-keyboard toolbar that adds a Done button and Previous/Next field-navigation controls. Most common use case is iOS numeric keyboards that have no built-in Done button. + +```dart +const WKeyboardActions({ + Key? key, + required Widget child, + required List focusNodes, + String platform = 'all', // 'all' | 'ios' | 'android' + bool nextFocus = true, // show Previous/Next arrows + String? toolbarClassName, // Wind bg-* classes for toolbar background + Widget Function(FocusNode)? closeWidgetBuilder, // replaces default "Done" button +}) +``` + +Usage: +- Create one `FocusNode` per input field. Pass them in focus-navigation order via `focusNodes`. +- Set `platform: 'ios'` to limit the toolbar to iOS (most common pattern for numeric keyboards). +- Set `nextFocus: false` for single-field forms — shows only the Done button. +- `toolbarClassName` accepts any `bg-*` Wind class; always pair with `dark:`. When null the toolbar uses `Theme.of(context).colorScheme.surfaceContainerHighest`. +- `closeWidgetBuilder(node)` receives the focused `FocusNode`; call `node.unfocus()` to dismiss. + +```dart +WKeyboardActions( + platform: 'ios', + focusNodes: [_nameFocus, _amountFocus], + toolbarClassName: 'bg-gray-100 dark:bg-gray-800', + child: Column( + children: [ + WInput(focusNode: _nameFocus, placeholder: 'Jane Doe'), + WInput( + focusNode: _amountFocus, + placeholder: '0.00', + type: InputType.number, + ), + ], + ), +) +``` + +Dispose every `FocusNode` you own in `State.dispose()`. `WKeyboardActions` attaches and removes listeners automatically when the widget updates or disposes. + +--- + +## 8. Animation: WindAnimationWrapper + +Low-level stateful wrapper that runs a looping `AnimationController` and applies one of four visual effects to its `child`. It is the +engine behind `animate-*` className tokens; use it directly when you need programmatic control over type, duration, or curve. + +```dart +const WindAnimationWrapper({ + Key? key, + required Widget child, + required WindAnimationType animationType, + Duration duration = const Duration(milliseconds: 1000), + Curve curve = Curves.linear, +}) +``` + +| Prop | Type | Default | Description | +|:-----|:-----|:--------|:------------| +| `child` | `Widget` | **Required** | The widget to animate. | +| `animationType` | `WindAnimationType` | **Required** | `spin`, `ping`, `pulse`, `bounce`, or `none`. | +| `duration` | `Duration` | `Duration(milliseconds: 1000)` | Full cycle length for all types. | +| `curve` | `Curve` | `Curves.linear` | Easing curve passed to the controller. `ping` and `pulse` apply their own `CurvedAnimation` internally. | + +Animation mechanics per type: + +| Type | Effect | Flutter primitive | +|:-----|:-------|:------------------| +| `spin` | 360-degree continuous rotation. | `RotationTransition` | +| `ping` | Scale 1.0 to 1.5 with matching opacity fade. | `AnimatedBuilder` + `Transform.scale` + `Opacity` | +| `pulse` | Opacity oscillates 1.0 to 0.5, reversing. | `FadeTransition` + `repeat(reverse: true)` | +| `bounce` | Vertical offset 0 to -5 px, reversing. | `AnimatedBuilder` + `Transform.translate` | +| `none` | No-op; returns child unchanged. | Direct child pass-through | + +Lifecycle: `didUpdateWidget` stops and restarts the controller when `animationType` or `duration` changes. No manual controller +management is needed by the caller. + +Typical usage via className (preferred): + +```dart +WIcon(Icons.refresh_outlined, className: 'text-blue-500 dark:text-blue-400 animate-spin') +``` + +Programmatic usage (needed when child has no `className`): + +```dart +WindAnimationWrapper( + animationType: WindAnimationType.pulse, + duration: const Duration(milliseconds: 2000), + child: WDiv(className: 'h-4 w-48 bg-gray-200 dark:bg-gray-700 rounded'), +) +``` + +--- + +## 9. Overlay: WPopover OverlayPortal-based popover with controller + auto-flip + close-on-tap-outside. @@ -669,7 +772,7 @@ class PopoverController extends ChangeNotifier { --- -## 8. Structural: WDynamic +## 10. Structural: WDynamic Server-driven UI. JSON tree → Wind widget tree. @@ -715,7 +818,7 @@ Full JSON contract + state binding + security model: `${CLAUDE_SKILL_DIR}/refere --- -## 9. Supporting types +## 11. Supporting types ### `SelectOption` @@ -749,10 +852,10 @@ class DateRange { enum InputType { text, password, email, number, multiline } ``` -### `DatePickerMode` enum +### `WDatePickerMode` enum ```dart -enum DatePickerMode { single, range } +enum WDatePickerMode { single, range } ``` ### `PopoverAlignment` enum diff --git a/test/core/platform_service_test.dart b/test/core/platform_service_test.dart index a32a7da..843d393 100644 --- a/test/core/platform_service_test.dart +++ b/test/core/platform_service_test.dart @@ -1,5 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; -import 'package:fluttersdk_wind/fluttersdk_wind.dart'; +import 'package:fluttersdk_wind/src/core/platform_service.dart'; /// Tests for [WindPlatformService]. /// diff --git a/test/debug_resolver_test.dart b/test/debug_resolver_test.dart index 9022b37..4736e82 100644 --- a/test/debug_resolver_test.dart +++ b/test/debug_resolver_test.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:fluttersdk_wind/fluttersdk_wind.dart'; +import 'package:fluttersdk_wind/src/debug_resolver.dart'; import 'package:fluttersdk_wind_diagnostics_contracts/fluttersdk_wind_diagnostics_contracts.dart'; /// Tests for the Wind v1.0 debug resolver. @@ -101,6 +102,41 @@ void main() { expect(data.containsKey('className'), isTrue); }, ); + + testWidgets( + 'returns const {} for a className-less W-widget (WAnchor) without throwing', + (tester) async { + // WAnchor is W-prefixed but interaction-only: it has no `className` + // field. The resolver must treat it as a miss, not crash with a + // NoSuchMethodError (which would break every dusk/telescope snapshot + // that contains a bare WAnchor). + await tester.pumpWidget( + wrapWithTheme(WAnchor(onTap: () {}, child: const Text('x'))), + ); + final element = _firstElementOf(tester); + + final data = const WindDebugResolverImpl().resolve(element); + expect(data, equals(const {})); + }, + ); + + testWidgets( + 'returns const {} for a className-less W-widget (WindAnimationWrapper)', + (tester) async { + await tester.pumpWidget( + wrapWithTheme( + const WindAnimationWrapper( + animationType: WindAnimationType.pulse, + child: SizedBox(width: 10), + ), + ), + ); + final element = _firstElementOf(tester); + + final data = const WindDebugResolverImpl().resolve(element); + expect(data, equals(const {})); + }, + ); }); // --------------------------------------------------------------------------- diff --git a/test/dynamic/w_dynamic_renderer_test.dart b/test/dynamic/w_dynamic_renderer_test.dart index 90c6a96..b2e2357 100644 --- a/test/dynamic/w_dynamic_renderer_test.dart +++ b/test/dynamic/w_dynamic_renderer_test.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:fluttersdk_wind/fluttersdk_wind.dart'; +import 'package:fluttersdk_wind/src/dynamic/w_dynamic_renderer.dart'; /// Helper to wrap widget in MaterialApp with WindTheme Widget wrapWithTheme(Widget child) { @@ -527,6 +528,30 @@ void main() { expect(onErrorCalled, isTrue); expect(find.text('Error in BrokenWidget'), findsOneWidget); }); + + testWidgets('degrades gracefully when type is not a string', + (tester) async { + // Untrusted JSON must never throw past build(); a non-string type + // routes through the whitelist and emits the denied error widget. + await tester.pumpWidget( + wrapWithTheme(renderer.build({'type': 123})), + ); + + expect(tester.takeException(), isNull); + expect(find.textContaining('Error: Denied'), findsOneWidget); + }); + + testWidgets('degrades gracefully when children is not a list', + (tester) async { + await tester.pumpWidget( + wrapWithTheme( + renderer.build({'type': 'WDiv', 'children': 'oops'}), + ), + ); + + expect(tester.takeException(), isNull); + expect(find.byType(WDiv), findsOneWidget); + }); }); // ============================================================ diff --git a/test/parser/parsers/aspectratio_parser_test.dart b/test/parser/parsers/aspectratio_parser_test.dart index c4ca4dc..e1b4f25 100644 --- a/test/parser/parsers/aspectratio_parser_test.dart +++ b/test/parser/parsers/aspectratio_parser_test.dart @@ -1,5 +1,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:fluttersdk_wind/fluttersdk_wind.dart'; +import 'package:fluttersdk_wind/src/parser/parsers/aspectratio_parser.dart'; /// Helper function to create a test WindContext WindContext createTestContext() { diff --git a/test/parser/parsers/background_parser_test.dart b/test/parser/parsers/background_parser_test.dart index 6d64403..e187fb8 100644 --- a/test/parser/parsers/background_parser_test.dart +++ b/test/parser/parsers/background_parser_test.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:fluttersdk_wind/src/parser/parsers/background_parser.dart'; +import 'package:fluttersdk_wind/src/parser/wind_parser.dart'; import 'package:fluttersdk_wind/src/parser/wind_context.dart'; import 'package:fluttersdk_wind/src/parser/wind_style.dart'; import 'package:fluttersdk_wind/src/theme/defaults/colors.dart' @@ -104,6 +105,37 @@ void main() { }); group('BackgroundParser.parseImage', () { + setUp(() { + WindParser.clearCache(); + }); + + // Locks the non-web image-provider branch the kIsWeb guard must preserve: + // the flutter_test host runs as non-web, so kIsWeb is false here and a + // `/`-leading path must stay a FileImage while asset-style paths resolve to + // AssetImage. The kIsWeb-true fall-through is verified by `flutter build web`. + group('non-web image-provider selection (kIsWeb == false)', () { + test('absolute /-leading path resolves to a FileImage', () { + final image = BackgroundParser.parseImage(['bg-[/abs/x.png]']); + expect(image, isA()); + expect(image!.image, isA()); + expect((image.image as FileImage).file.path, '/abs/x.png'); + }); + + test('~/-prefixed path resolves to an AssetImage', () { + final image = BackgroundParser.parseImage(['bg-[~/x.png]']); + expect(image, isA()); + expect(image!.image, isA()); + expect((image.image as AssetImage).assetName, 'assets/x.png'); + }); + + test('bare path resolves to an AssetImage', () { + final image = BackgroundParser.parseImage(['bg-[x.png]']); + expect(image, isA()); + expect(image!.image, isA()); + expect((image.image as AssetImage).assetName, 'assets/x.png'); + }); + }); + test('returns null for empty classes', () { final image = BackgroundParser.parseImage([]); expect(image, isNull); @@ -114,6 +146,12 @@ void main() { expect(image, isNull); }); + test('ignores arbitrary hex color, which is not an image source', () { + // bg-[#FF0000] is a color token; it must not be misread as an asset path. + expect(BackgroundParser.parseImage(['bg-[#FF0000]']), isNull); + expect(BackgroundParser.parseImage(['bg-[#abc]']), isNull); + }); + test('parses network image correctly', () { final classes = ['bg-[url(https://example.com/image.png)]']; final image = BackgroundParser.parseImage(classes); diff --git a/test/parser/parsers/flexbox_grid_parser_test.dart b/test/parser/parsers/flexbox_grid_parser_test.dart index 25c5700..3083ba2 100644 --- a/test/parser/parsers/flexbox_grid_parser_test.dart +++ b/test/parser/parsers/flexbox_grid_parser_test.dart @@ -153,7 +153,12 @@ void main() { expect(styles.flexFit, FlexFit.loose); }); - test('parses shrink-0 class', () { + test('shrink-0 sets no flexFit (no-shrink is a widget-level concern)', + () { + // shrink-0 must NOT set flexFit: a non-null flexFit self-wraps the + // widget in Flexible(fit: ...) and forces a fill (the opposite of + // shrink-0), and asserts outside a Flex. WDiv._hasShrinkZero reads the + // className and skips the wrap to keep the child at intrinsic size. final styles = parser.parse(WindStyle(), ['shrink-0'], context); expect(styles.flexFit, isNull); }); @@ -164,7 +169,9 @@ void main() { expect(styles.textBaseline, TextBaseline.alphabetic); }); - test('applies last-class-wins for shrink', () { + test('shrink contributes FlexFit.loose; shrink-0 contributes none', () { + // Only `shrink` maps to a flexFit; shrink-0 sets none, so the resolved + // flexFit is loose. shrink-0's no-shrink effect lives in the widget. final styles = parser.parse(WindStyle(), ['shrink', 'shrink-0'], context); expect(styles.flexFit, FlexFit.loose); @@ -183,7 +190,7 @@ void main() { expect(styles.flexFit, FlexFit.loose); }); - test('shrink-0 does not set flexFit', () { + test('shrink-0 -> no flexFit (does not shrink via widget guard)', () { final styles = parser.parse(WindStyle(), ['shrink-0'], context); expect(styles.flexFit, isNull); }); @@ -196,8 +203,11 @@ void main() { expect(styles.textBaseline, TextBaseline.alphabetic); }); - test('last-class-wins override logic', () { - // Flex shrink overrides — shrink-0 no longer sets flexFit + test('shrink contributes FlexFit.loose regardless of shrink-0 position', + () { + // shrink-0 sets no flexFit; `shrink` is the only token that does, so + // both orders resolve to FlexFit.loose at the parser level. The + // no-shrink effect of shrink-0 is applied by WDiv._hasShrinkZero. expect( parser.parse(WindStyle(), ['shrink', 'shrink-0'], context).flexFit, FlexFit.loose, @@ -226,6 +236,63 @@ void main() { }); }); + group('self-* align-self alias', () { + test('self-start matches align-self-start', () { + final selfStyles = parser.parse(WindStyle(), ['self-start'], context); + final aliasStyles = + parser.parse(WindStyle(), ['align-self-start'], context); + expect(selfStyles.alignment, aliasStyles.alignment); + expect(selfStyles.alignment, Alignment.topCenter); + }); + + test('self-end matches align-self-end', () { + final selfStyles = parser.parse(WindStyle(), ['self-end'], context); + final aliasStyles = + parser.parse(WindStyle(), ['align-self-end'], context); + expect(selfStyles.alignment, aliasStyles.alignment); + expect(selfStyles.alignment, Alignment.bottomCenter); + }); + + test('self-center matches align-self-center', () { + final selfStyles = parser.parse(WindStyle(), ['self-center'], context); + final aliasStyles = + parser.parse(WindStyle(), ['align-self-center'], context); + expect(selfStyles.alignment, aliasStyles.alignment); + expect(selfStyles.alignment, Alignment.center); + }); + + test('self-stretch matches align-self-stretch', () { + final selfStyles = parser.parse(WindStyle(), ['self-stretch'], context); + final aliasStyles = + parser.parse(WindStyle(), ['align-self-stretch'], context); + expect(selfStyles.alignment, aliasStyles.alignment); + expect(selfStyles.alignment, Alignment.center); + }); + + test('self-auto matches align-self-auto', () { + final selfStyles = parser.parse(WindStyle(), ['self-auto'], context); + final aliasStyles = + parser.parse(WindStyle(), ['align-self-auto'], context); + expect(selfStyles.alignment, aliasStyles.alignment); + expect(selfStyles.alignment, Alignment.center); + }); + + test('self-baseline matches align-self-baseline (both unmapped)', () { + final selfStyles = + parser.parse(WindStyle(), ['self-baseline'], context); + final aliasStyles = + parser.parse(WindStyle(), ['align-self-baseline'], context); + expect(selfStyles.alignment, aliasStyles.alignment); + expect(selfStyles.alignment, isNull); + }); + + test('self-* alias does not break align-self-*', () { + final styles = + parser.parse(WindStyle(), ['align-self-center'], context); + expect(styles.alignment, Alignment.center); + }); + }); + group('canParse', () { test('returns true for flex related classes', () { expect(parser.canParse('flex'), isTrue); @@ -237,6 +304,9 @@ void main() { expect(parser.canParse('shrink-0'), isTrue); expect(parser.canParse('gap-4'), isTrue); expect(parser.canParse('axis-min'), isTrue); + expect(parser.canParse('self-center'), isTrue); + expect(parser.canParse('self-start'), isTrue); + expect(parser.canParse('align-self-center'), isTrue); }); test('returns true for grid related classes', () { diff --git a/test/parser/parsers/overflow_parser_test.dart b/test/parser/parsers/overflow_parser_test.dart index ab6f85b..0d03877 100644 --- a/test/parser/parsers/overflow_parser_test.dart +++ b/test/parser/parsers/overflow_parser_test.dart @@ -1,6 +1,7 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:fluttersdk_wind/fluttersdk_wind.dart'; +import 'package:fluttersdk_wind/src/parser/parsers/overflow_parser.dart'; /// Helper function to create a test WindContext WindContext createTestContext() { diff --git a/test/parser/parsers/ring_parser_test.dart b/test/parser/parsers/ring_parser_test.dart index ece6be9..00e410f 100644 --- a/test/parser/parsers/ring_parser_test.dart +++ b/test/parser/parsers/ring_parser_test.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:fluttersdk_wind/fluttersdk_wind.dart'; +import 'package:fluttersdk_wind/src/parser/parsers/ring_parser.dart'; /// Helper function to create a test WindContext WindContext createTestContext({Color? ringColor}) { diff --git a/test/parser/parsers/shadow_parser_test.dart b/test/parser/parsers/shadow_parser_test.dart index 694c30c..c1f6c2d 100644 --- a/test/parser/parsers/shadow_parser_test.dart +++ b/test/parser/parsers/shadow_parser_test.dart @@ -37,10 +37,15 @@ void main() { ), ); - final container = tester.widget(find.byType(Container)); - final decoration = container.decoration as BoxDecoration; - if (decoration.boxShadow != null) { - expect(decoration.boxShadow, isEmpty); + // shadow-none carries no visual, so it wraps no shadow-bearing + // Container. The child renders and no decoration applies a box shadow. + expect(find.text('Shadow'), findsOneWidget); + for (final container + in tester.widgetList(find.byType(Container))) { + final decoration = container.decoration; + if (decoration is BoxDecoration) { + expect(decoration.boxShadow ?? const [], isEmpty); + } } }); diff --git a/test/parser/parsers/sizing_parser_test.dart b/test/parser/parsers/sizing_parser_test.dart index 3207098..72f0aa0 100644 --- a/test/parser/parsers/sizing_parser_test.dart +++ b/test/parser/parsers/sizing_parser_test.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:fluttersdk_wind/src/parser/parsers/sizing_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'; @@ -36,6 +37,7 @@ void main() { late WindContext context; setUp(() { + WindParser.clearCache(); parser = const SizingParser(); context = createTestContext(); }); @@ -90,6 +92,15 @@ void main() { expect(styles2xl.constraints?.maxWidth, 672); }); + test( + 'max-w-prose resolves to 512px fixed, not Tailwind font-relative 65ch', + () { + // Deliberate fixed-px divergence from Tailwind: 512px matches max-w-lg + // and avoids font-size dependency; Tailwind's 65ch ≈ 1040px at 16px/ch. + final styles = parser.parse(WindStyle(), ['max-w-prose'], context); + expect(styles.constraints?.maxWidth, 512); + }); + test('parses min-height classes correctly', () { final styles = WindStyle(); final classes = ['min-h-150', 'min-h-[10%]']; diff --git a/test/parser/parsers/text_parser_font_family_test.dart b/test/parser/parsers/text_parser_font_family_test.dart index f86bf63..2313a54 100644 --- a/test/parser/parsers/text_parser_font_family_test.dart +++ b/test/parser/parsers/text_parser_font_family_test.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:fluttersdk_wind/fluttersdk_wind.dart'; +import 'package:fluttersdk_wind/src/parser/parsers/text_parser.dart'; WindContext createTestContext({Map? fontFamilies}) { return WindContext( diff --git a/test/parser/parsers/transition_parser_test.dart b/test/parser/parsers/transition_parser_test.dart index 5a92ba8..3d84acf 100644 --- a/test/parser/parsers/transition_parser_test.dart +++ b/test/parser/parsers/transition_parser_test.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:fluttersdk_wind/fluttersdk_wind.dart'; +import 'package:fluttersdk_wind/src/parser/parsers/transition_parser.dart'; WindContext createTestContext() { return WindContext( diff --git a/test/parser/parsers/zindex_parser_test.dart b/test/parser/parsers/zindex_parser_test.dart index d09b9cc..5d7ca35 100644 --- a/test/parser/parsers/zindex_parser_test.dart +++ b/test/parser/parsers/zindex_parser_test.dart @@ -1,5 +1,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:fluttersdk_wind/fluttersdk_wind.dart'; +import 'package:fluttersdk_wind/src/parser/parsers/zindex_parser.dart'; /// Helper function to create a test WindContext WindContext createTestContext() { diff --git a/test/parser/wind_style_test.dart b/test/parser/wind_style_test.dart new file mode 100644 index 0000000..6384751 --- /dev/null +++ b/test/parser/wind_style_test.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fluttersdk_wind/src/parser/wind_style.dart'; + +void main() { + group('WindStyle.copyWith decoration handling', () { + test('keeps decoration null when neither side sets one', () { + // A padding/margin/text-only copyWith must not fabricate an empty + // BoxDecoration; otherwise every such widget wraps a needless Container. + const style = WindStyle(); + final updated = style.copyWith(padding: const EdgeInsets.all(16)); + + expect(updated.padding, isNotNull); + expect(updated.decoration, isNull); + }); + + test('preserves an existing decoration when copyWith omits it', () { + const style = WindStyle(decoration: BoxDecoration(color: Colors.red)); + final updated = style.copyWith(width: 100); + + expect(updated.width, 100); + expect(updated.decoration?.color, Colors.red); + }); + + test('merges an incoming decoration onto the existing one', () { + const style = WindStyle(decoration: BoxDecoration(color: Colors.red)); + final updated = style.copyWith( + decoration: const BoxDecoration( + borderRadius: BorderRadius.all(Radius.circular(8)), + ), + ); + + expect(updated.decoration?.color, Colors.red); + expect(updated.decoration?.borderRadius, + const BorderRadius.all(Radius.circular(8))); + }); + }); +} diff --git a/test/theme/wind_theme_data_test.dart b/test/theme/wind_theme_data_test.dart index c421bc5..59d0c21 100644 --- a/test/theme/wind_theme_data_test.dart +++ b/test/theme/wind_theme_data_test.dart @@ -155,4 +155,40 @@ void main() { expect(theme.getSpacing('full'), double.infinity); }); }); + + group('WindThemeData value equality', () { + test('two default instances compare equal and share a hashCode', () { + // The equality guard in WindThemeController.setTheme and + // _WindThemeState.didUpdateWidget is load-bearing: identity-only + // equality would let a fresh default WindThemeData() on a parent + // rebuild clobber a prior toggleTheme() choice. + final a = WindThemeData(); + final b = WindThemeData(); + + expect(a, equals(b)); + expect(a.hashCode, equals(b.hashCode)); + }); + + test('instances differing by a scalar field are not equal', () { + final light = WindThemeData(); + final dark = WindThemeData(brightness: Brightness.dark); + + expect(light, isNot(equals(dark))); + }); + + test('instances differing by baseSpacingUnit are not equal', () { + final a = WindThemeData(); + final b = WindThemeData(baseSpacingUnit: 8); + + expect(a, isNot(equals(b))); + }); + + test('copyWith that changes nothing stays equal', () { + final a = WindThemeData(); + final b = a.copyWith(); + + expect(a, equals(b)); + expect(a.hashCode, equals(b.hashCode)); + }); + }); } diff --git a/test/utils/wind_logger_test.dart b/test/utils/wind_logger_test.dart index 26c807d..179bd97 100644 --- a/test/utils/wind_logger_test.dart +++ b/test/utils/wind_logger_test.dart @@ -1,6 +1,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:fluttersdk_wind/fluttersdk_wind.dart'; +import 'package:fluttersdk_wind/src/utils/wind_logger.dart'; void main() { late List printLog; diff --git a/test/widgets/w_anchor_test.dart b/test/widgets/w_anchor_test.dart index 39075db..56489de 100644 --- a/test/widgets/w_anchor_test.dart +++ b/test/widgets/w_anchor_test.dart @@ -479,6 +479,87 @@ void main() { // mapping table). // ------------------------------------------------------------------------- group('Semantics', () { + setUp(() { + WindParser.clearCache(); + }); + + testWidgets('semanticLabel does not double the label when child has text', + (tester) async { + // Branch: semanticLabel != null with a visible Text child. The label + // must be the explicit semanticLabel exactly once, never the doubled + // "Save\nSave" produced by merging the explicit label with the child + // text. Activation must still fire because onTap is lifted onto the + // Semantics node alongside excludeSemantics: true. + var tapped = false; + await tester.pumpWidget( + wrapWithTheme( + WAnchor( + onTap: () => tapped = true, + semanticLabel: 'Save', + child: const Text('Save'), + ), + ), + ); + + final SemanticsNode node = tester.getSemantics(find.byType(WAnchor)); + expect(node.flagsCollection.isButton, isTrue); + expect(node.flagsCollection.isEnabled, Tristate.isTrue); + expect(node.label, 'Save'); + expect( + node.getSemanticsData().hasAction(SemanticsAction.tap), + isTrue, + ); + + await tester.tap(find.byType(WAnchor)); + await tester.pump(); + expect(tapped, isTrue); + }); + + testWidgets( + 'disabled anchor with semanticLabel announces disabled and has no tap action', + (tester) async { + // Branch: semanticLabel != null AND isDisabled. The node keeps the + // explicit label but must announce as disabled and expose no tap + // SemanticsAction, since onTap is null-gated when disabled. + await tester.pumpWidget( + wrapWithTheme( + const WAnchor( + isDisabled: true, + onTap: null, + semanticLabel: 'Save', + child: Text('Save'), + ), + ), + ); + + final SemanticsNode node = tester.getSemantics(find.byType(WAnchor)); + expect(node.flagsCollection.isButton, isTrue); + expect(node.flagsCollection.isEnabled, Tristate.isFalse); + expect(node.label, 'Save'); + expect( + node.getSemanticsData().hasAction(SemanticsAction.tap), + isFalse, + ); + }); + + testWidgets('label resolves from child text when semanticLabel is null', + (tester) async { + // Branch: semanticLabel == null keeps the MergeSemantics path so the + // descendant Text merges in as the accessible name. + await tester.pumpWidget( + wrapWithTheme( + WAnchor( + onTap: () {}, + child: const Text('Continue'), + ), + ), + ); + + final SemanticsNode node = tester.getSemantics(find.byType(WAnchor)); + expect(node.flagsCollection.isButton, isTrue); + expect(node.label, 'Continue'); + }); + testWidgets('emits button role with label resolved from child text', (tester) async { await tester.pumpWidget( diff --git a/test/widgets/w_button_test.dart b/test/widgets/w_button_test.dart index 5ab3b81..03c9440 100644 --- a/test/widgets/w_button_test.dart +++ b/test/widgets/w_button_test.dart @@ -1,5 +1,6 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/semantics.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:fluttersdk_wind/fluttersdk_wind.dart'; @@ -39,6 +40,62 @@ void main() { }); }); + group('Semantics', () { + setUp(() { + WindParser.clearCache(); + }); + + testWidgets('icon-only button exposes semanticLabel as its label', + (tester) async { + // An icon-only button has no text child for MergeSemantics to absorb, + // so without an explicit label it would be a nameless button. The + // semanticLabel must surface as the button's accessible name. + await tester.pumpWidget( + wrapWithTheme( + WButton( + onTap: () {}, + semanticLabel: 'Add item', + child: const Icon(Icons.add), + ), + ), + ); + + final SemanticsNode node = tester.getSemantics(find.byType(WButton)); + expect(node.flagsCollection.isButton, isTrue); + expect(node.label, 'Add item'); + }); + + testWidgets( + 'icon-only button with semanticLabel still fires onTap (Builder path)', + (tester) async { + // WButton routes through WAnchor via a Builder indirection. The + // excludeSemantics: true branch drops the descendant GestureDetector + // tap action, so onTap must be lifted onto the Semantics node for + // activation to survive across the Builder boundary. + var tapped = false; + await tester.pumpWidget( + wrapWithTheme( + WButton( + onTap: () => tapped = true, + semanticLabel: 'Add item', + child: const Icon(Icons.add), + ), + ), + ); + + final SemanticsNode node = tester.getSemantics(find.byType(WButton)); + expect(node.label, 'Add item'); + expect( + node.getSemanticsData().hasAction(SemanticsAction.tap), + isTrue, + ); + + await tester.tap(find.byType(WButton)); + await tester.pump(); + expect(tapped, isTrue); + }); + }); + group('Tap Interaction', () { testWidgets('calls onTap when pressed', (tester) async { bool wasTapped = false; diff --git a/test/widgets/w_date_picker_test.dart b/test/widgets/w_date_picker_test.dart index ba907c2..2a784b2 100644 --- a/test/widgets/w_date_picker_test.dart +++ b/test/widgets/w_date_picker_test.dart @@ -1,5 +1,5 @@ import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart' hide DatePickerMode; +import 'package:flutter/material.dart'; import 'package:flutter/semantics.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:fluttersdk_wind/fluttersdk_wind.dart'; @@ -140,7 +140,7 @@ void main() { testWidgets('displays range placeholder', (tester) async { await tester.pumpWidget(wrapWithTheme( const WDatePicker( - mode: DatePickerMode.range, + mode: WDatePickerMode.range, placeholder: 'Select range', ), )); @@ -156,7 +156,7 @@ void main() { await tester.pumpWidget(wrapWithTheme( WDatePicker( - mode: DatePickerMode.range, + mode: WDatePickerMode.range, range: range, ), )); @@ -171,7 +171,7 @@ void main() { await tester.pumpWidget(wrapWithTheme( WDatePicker( - mode: DatePickerMode.range, + mode: WDatePickerMode.range, range: range, ), )); @@ -186,7 +186,7 @@ void main() { StatefulBuilder( builder: (context, setState) { return WDatePicker( - mode: DatePickerMode.range, + mode: WDatePickerMode.range, value: testDate, // Initial focus month onRangeChanged: (r) { selectedRange = r; @@ -211,7 +211,7 @@ void main() { StatefulBuilder( builder: (context, setState) { return WDatePicker( - mode: DatePickerMode.range, + mode: WDatePickerMode.range, range: DateRange(start: testDate), // Focuses Jan 2025 onRangeChanged: (r) => selectedRange = r, ); @@ -256,6 +256,43 @@ void main() { }); }); + group('WDatePickerMode public name', () { + // Regression for the DatePickerMode -> WDatePickerMode rename: the public + // enum must drive single vs range behavior under its renamed identifier, + // proving the barrel-collision fix kept the API usable. + testWidgets('WDatePickerMode.single drives single-date display', + (tester) async { + await tester.pumpWidget(wrapWithTheme( + WDatePicker( + mode: WDatePickerMode.single, + value: testDate, + ), + )); + + expect(find.text('Jan 15, 2025'), findsOneWidget); + }); + + testWidgets('WDatePickerMode.range drives range display', (tester) async { + await tester.pumpWidget(wrapWithTheme( + WDatePicker( + mode: WDatePickerMode.range, + range: DateRange( + start: DateTime(2025, 1, 10), + end: DateTime(2025, 1, 15), + ), + ), + )); + + expect(find.text('Jan 10, 2025 - Jan 15, 2025'), findsOneWidget); + }); + + test('WDatePickerMode exposes single and range values', () { + expect(WDatePickerMode.values, hasLength(2)); + expect(WDatePickerMode.values, contains(WDatePickerMode.single)); + expect(WDatePickerMode.values, contains(WDatePickerMode.range)); + }); + }); + group('Constraints & Formatting', () { testWidgets('respects minDate and maxDate', (tester) async { await tester.pumpWidget(wrapWithTheme( @@ -330,7 +367,7 @@ void main() { StatefulBuilder( builder: (context, setState) { return WDatePicker( - mode: DatePickerMode.range, + mode: WDatePickerMode.range, range: DateRange(start: DateTime(2025, 1, 15)), onRangeChanged: (r) { selectedRange = r; @@ -374,7 +411,7 @@ void main() { StatefulBuilder( builder: (context, setState) { return WDatePicker( - mode: DatePickerMode.range, + mode: WDatePickerMode.range, range: initialRange, onRangeChanged: (r) { selectedRange = r; @@ -543,7 +580,7 @@ void main() { child: StatefulBuilder( builder: (context, setState) { return WDatePicker( - mode: DatePickerMode.range, + mode: WDatePickerMode.range, range: DateRange(start: DateTime(2025, 1, 10)), minDate: DateTime(2025, 1, 10), maxDate: DateTime(2025, 1, 20), diff --git a/test/widgets/w_div/flex_shrink_test.dart b/test/widgets/w_div/flex_shrink_test.dart index aed9916..d10e889 100644 --- a/test/widgets/w_div/flex_shrink_test.dart +++ b/test/widgets/w_div/flex_shrink_test.dart @@ -397,4 +397,65 @@ void main() { }, ); }); + + group('shrink-0 does not force-fill or assert (regression)', () { + setUp(WindParser.clearCache); + + testWidgets( + 'standalone shrink-0 WDiv builds with no Flexible self-wrap', + (tester) async { + // Regression: shrink-0 must NOT set flexFit. A non-null flexFit would + // self-wrap the WDiv in Flexible(fit: tight), forcing a fill and + // asserting outside a Flex. + await tester.pumpWidget( + MaterialApp( + home: WindTheme( + data: WindThemeData(), + child: Scaffold( + body: WDiv( + className: 'shrink-0 w-16 h-16 bg-gray-200', + child: SizedBox(), + ), + ), + ), + ), + ); + + expect(tester.takeException(), isNull); + expect(find.byType(Flexible), findsNothing); + }, + ); + + testWidgets( + 'shrink-0 child in a Row keeps its intrinsic width', + (tester) async { + await tester.pumpWidget( + MaterialApp( + home: WindTheme( + data: WindThemeData(), + child: Scaffold( + body: WDiv( + className: 'flex flex-row', + children: [ + WDiv( + className: 'shrink-0 w-16 h-16 bg-gray-200', + child: SizedBox(), + ), + WDiv( + className: 'flex-1 h-16 bg-blue-200', + child: SizedBox(), + ), + ], + ), + ), + ), + ), + ); + + expect(tester.takeException(), isNull); + // w-16 = 16 * 4px = 64px; the sidebar holds its width, not forced fill. + expect(tester.getSize(find.byType(WDiv).at(1)).width, 64); + }, + ); + }); } diff --git a/test/widgets/w_form_date_picker_test.dart b/test/widgets/w_form_date_picker_test.dart index 7864ed5..0acbb6d 100644 --- a/test/widgets/w_form_date_picker_test.dart +++ b/test/widgets/w_form_date_picker_test.dart @@ -1,4 +1,4 @@ -import 'package:flutter/material.dart' hide DatePickerMode; +import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:fluttersdk_wind/fluttersdk_wind.dart'; @@ -235,7 +235,7 @@ void main() { Form( key: formKey, child: WFormDatePicker( - mode: DatePickerMode.range, + mode: WDatePickerMode.range, validator: (value) => value == null ? 'Required' : null, ), ), @@ -270,7 +270,7 @@ void main() { wrapWithTheme( Form( child: WFormDatePicker( - mode: DatePickerMode.range, + mode: WDatePickerMode.range, onRangeChanged: (r) => changedRange = r, ), ), @@ -298,7 +298,7 @@ void main() { Form( key: formKey, child: WFormDatePicker( - mode: DatePickerMode.range, + mode: WDatePickerMode.range, ), ), ), diff --git a/test/wind_facade_test.dart b/test/wind_facade_test.dart index 1de3597..2fb55d0 100644 --- a/test/wind_facade_test.dart +++ b/test/wind_facade_test.dart @@ -1,5 +1,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:fluttersdk_wind/fluttersdk_wind.dart'; +import 'package:fluttersdk_wind/src/debug_resolver.dart'; import 'package:fluttersdk_wind_diagnostics_contracts/fluttersdk_wind_diagnostics_contracts.dart'; /// Tests for `Wind` facade — install flag management and registry integration.