From b8070f638b21207fae334cbc17e3149d7f4bbdad Mon Sep 17 00:00:00 2001 From: Anilcan Cakir Date: Fri, 21 Aug 2026 22:37:32 +0300 Subject: [PATCH 1/3] fix(a11y): stop a gestureless anchor announcing itself as a button WDiv auto-wraps into a WAnchor whenever its className carries `hover:`, `focus:` or `active:`, purely to get the state WAnchor tracks. WAnchor published `Semantics(button: true)` unconditionally, so that styling wrap became a claim the widget cannot keep. Measured on `WDiv(className: 'px-4 py-3 hover:bg-slate-100', child: WText('Latency'))`: one button node labelled "Latency" whose action set was `focus` alone, with no `tap`. A screen reader offers it as a button, the user activates it, and nothing happens, because there is no gesture in the tree to run. After the change the same card keeps its "Latency" label and loses the role. A gestureless anchor now publishes no node of its own and lets its descendants speak. An anchor with onTap, onLongPress or onDoubleTap is untouched, so WButton and every real control keep their single named button node with its tap action, and an explicit semanticLabel still wins for the icon-only case that has no child text to merge. One claim retired rather than shipped: the nesting `WAnchor(onTap:) > WDiv(hover:...)` did NOT announce twice. It did put two button nodes in the widget tree, which is what an earlier reading counted, but the inner one carried `isMergedIntoParent`, so Flutter folded it into the real tap surface and never sent it to the platform. The test helper now filters merged nodes, and the test for that shape is labelled a regression guard rather than a reproducer: it passes both with and without the fix. The two gestureless tests fail with the guard disabled and pass with it. Full suite 1700 green at 94.8% coverage. --- CHANGELOG.md | 6 ++ doc/widgets/w-anchor.md | 14 ++++ doc/widgets/w-div.md | 2 + lib/src/widgets/w_anchor.dart | 27 +++++++- skills/wind-ui/SKILL.md | 6 +- skills/wind-ui/references/widgets.md | 4 +- test/widgets/w_anchor_test.dart | 98 ++++++++++++++++++++++++++++ 7 files changed, 150 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a3dd0cb..581019e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ This project follows [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.0. --- +## [Unreleased] + +### Fixed + +- **Every hoverable `WDiv` announced itself as a button that cannot be pressed.** `WDiv` auto-wraps into a `WAnchor` whenever its className carries `hover:`, `focus:` or `active:`, purely to get the state this widget tracks, and `WAnchor` published `Semantics(button: true)` unconditionally: with no gesture behind it. So a decorative card styled `hover:bg-slate-100` reached assistive technology as a control. Measured on `WDiv(className: 'px-4 py-3 hover:bg-slate-100', child: WText('Latency'))`: one button node labelled `Latency` whose action set was `focus` alone, with no `tap`. A screen reader offers it as a button, the user activates it, and nothing happens, because there is no gesture in the tree to run. A gestureless `WAnchor` now publishes no semantics node of its own and lets its descendants speak, which is what a state propagator should do: the same card keeps its `Latency` label and loses the role. An anchor with `onTap`, `onLongPress` or `onDoubleTap` is unchanged, so `WButton` and every real control keep their single named button node with its tap action, and an explicit `semanticLabel` still wins for the icon-only case that has no child text to merge. One claim to retire with this: the nesting `WAnchor(onTap:) > WDiv(hover:...)` did NOT announce twice, though it published two button nodes in the widget tree. The inner one carried `isMergedIntoParent`, so Flutter folded it into the real tap surface and never sent it to the platform. A reading that counts raw tree nodes rather than platform nodes will report a duplicate announcement that no screen reader ever made. (`lib/src/widgets/w_anchor.dart`, `doc/widgets/w-anchor.md`, `doc/widgets/w-div.md`, `skills/wind-ui/SKILL.md`, `skills/wind-ui/references/widgets.md`) + ## [1.4.0] - 2026-08-21 ### Added diff --git a/doc/widgets/w-anchor.md b/doc/widgets/w-anchor.md index e45bf561..fa0ca256 100644 --- a/doc/widgets/w-anchor.md +++ b/doc/widgets/w-anchor.md @@ -175,6 +175,20 @@ WindTheme( ) ``` +## Accessibility + +A `WAnchor` that carries a gesture (`onTap`, `onLongPress` or `onDoubleTap`) publishes one `Semantics(button: true)` node, and `MergeSemantics` folds the child's text into it so the control gets its name from what it renders. Pass `semanticLabel` when the child has no readable text, such as an icon-only anchor; that label replaces the child subtree rather than concatenating with it. + +A `WAnchor` with no gesture publishes nothing of its own. It is the state propagator in that mode, which is how `WDiv` uses it to serve `hover:`, `focus:` and `active:` classes, and a decorative surface must not announce itself as a button with no action behind it. + +```dart +// One button node named "Save". +WAnchor(onTap: save, child: const WText('Save')) + +// No button node: hover styling only. +const WAnchor(child: WDiv(className: 'hover:bg-slate-100', child: WText('Card'))) +``` + ## Related Documentation - [WButton](./w-button.md) - High-level button widget built on WAnchor. diff --git a/doc/widgets/w-div.md b/doc/widgets/w-div.md index 77a97765..c2176324 100644 --- a/doc/widgets/w-div.md +++ b/doc/widgets/w-div.md @@ -114,6 +114,8 @@ 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. +That wrap is styling only: the auto-wrapped `WAnchor` carries no gesture, so it publishes no `button` node to assistive technology. A `WDiv` with `hover:bg-slate-100` styles on hover and still announces as whatever its content is, rather than as a control that does nothing when activated. + For direct gesture support (taps, long presses) or to create semantic buttons, use [WAnchor](w-anchor.md) or [WButton](w-button.md). diff --git a/lib/src/widgets/w_anchor.dart b/lib/src/widgets/w_anchor.dart index 1db7f157..c1f40560 100644 --- a/lib/src/widgets/w_anchor.dart +++ b/lib/src/widgets/w_anchor.dart @@ -259,8 +259,31 @@ class _WAnchorState extends State { ); } - // 2. No explicit label: keep the MergeSemantics path so the descendant - // Text/WText nodes collapse into this node and supply the name. + // 2. No gestures and no explicit label: publish nothing of our own and let + // the child's descendants speak for themselves. + // + // `WDiv` auto-wraps itself in a gestureless `WAnchor` whenever its + // className carries `hover:`, `focus:` or `active:`, purely to get the + // hover and focus state this widget tracks (see `w_div.dart`'s + // `isInteractive` branch). Announcing that as a button made a claim the + // widget cannot keep: `WDiv(className: 'hover:bg-slate-100', child: + // WText('Latency'))` published a button node labelled "Latency" whose + // action set was `focus` alone, with no `tap`, so a screen reader offered + // a control that does nothing when activated. The real tap surface still + // reaches this method with its gestures and keeps its single button node. + // + // Note for anyone re-measuring: the nesting `WAnchor(onTap:) > + // WDiv(hover:...)` did NOT announce twice, though it did put two button + // nodes in the widget tree. The inner one carried `isMergedIntoParent`, + // so it was folded into the real tap surface and never sent to the + // platform. Count platform nodes, not tree nodes. + if (!hasGestures) { + return result; + } + + // 3. Gestures, 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/skills/wind-ui/SKILL.md b/skills/wind-ui/SKILL.md index 2ef0db4d..3e30eed2 100644 --- a/skills/wind-ui/SKILL.md +++ b/skills/wind-ui/SKILL.md @@ -2,10 +2,10 @@ name: wind-ui description: "fluttersdk_wind 1.4: utility-first Flutter styling with Tailwind-syntax className strings. 27 W-prefix widgets (WDiv, WText, WButton, WInput, WSelect, WDatePicker, WPopover, WCard, WTabs, plus five WForm* wrappers) parse className into a cached immutable WindStyle; WindRecipe and WindSlotRecipe compose variant classNames. Prefixes stack freely (dark: / hover: / focus: / md: / ios: / selected: / disabled: / custom), the last class in a family wins, an unrecognized token drops with a one-time kDebugMode hint, and every color token carries a dark: peer in the same className. TRIGGER when: writing or editing UI in a Flutter app that depends on fluttersdk_wind; any className string; any W-prefix widget; any WindTheme or WindThemeData reference; the user mentions Tailwind for Flutter, utility-first, className, or wind-ui. DO NOT TRIGGER when: backend, API, or state-management work that never touches a widget tree; a Flutter project without fluttersdk_wind in pubspec.yaml; Material-only widgets (Scaffold, AppBar, Dialog) with no Wind content inside." when_to_use: "Any task that produces, modifies, or audits Wind-styled UI: composing a className, picking the right W-widget, wiring a Form field, customizing WindThemeData, pairing dark-mode classes, debugging a layout or a RenderFlex overflow, building a popover, rendering a JSON tree via WDynamic, or composing a WindRecipe. Load it before the first line of new UI, and equally when auditing UI that already exists." -version: 2.12.0 +version: 2.12.1 --- - + # Wind UI 1.3 @@ -78,7 +78,7 @@ The headline 25 (table below) are the ones an agent reaches for daily. Two more | `WIcon` | Display | `icon: IconData` | Material icons; use `Icons.*_outlined` variants by convention. Reads `text-*` for size AND color (overloaded). Inherits from `DefaultTextStyle` when className is absent. Inline color prop: `foregroundColor`. | | `WImage` | Display | none (requires `src` or `image`) | Network (URL) or asset (prefix `asset://path`) or `ImageProvider`. `object-cover` default. | | `WSvg` / `WSvg.string` | Display | `src` / `svg` | Vector graphics. `fill-*` / `stroke-*` for color. `preserve-colors` token disables tint for multi-color SVGs (QR codes, logos). | -| `WAnchor` | Interactive | `child: Widget` | Low-level gesture + focus + hover propagator. Emits `Semantics(button: true)`. | +| `WAnchor` | Interactive | `child: Widget` | Low-level gesture + focus + hover propagator. Emits `Semantics(button: true)` only when it carries a gesture. | | `WButton` | Interactive | `child: Widget` | Wraps `WAnchor` + `WDiv` + built-in spinner. `isLoading: true` injects `loading:` state. `disabled: true` injects `disabled:` state and blocks taps. | | `WPopover` | Overlay | none (requires builders) | `OverlayPortal`-based; `triggerBuilder(ctx, isOpen, isHovering)` + `contentBuilder(ctx, close)` + optional `PopoverController`. Auto-flips alignment when bottom space is insufficient. | | `WInput` | Form (raw) | none | Material-free text input (EditableText core); works under Material, Cupertino, custom, or bare WidgetsApp (no Material ancestor required). `value` + `onChanged` for controlled binding, or `controller` for imperative needs; passing both throws `AssertionError` in debug. `InputType` enum: `text` / `password` / `email` / `number` / `multiline` (`number` restricts to a signed decimal on every platform incl. web; pass `inputFormatters` to override). `readOnly: true` activates a `readonly:` state like `enabled: false` activates `disabled:`. Native text selection: mouse-drag selects a substring, double-click/double-tap selects a word, tapping the box moves the cursor; selection handles are Cupertino-style on all platforms (keeps WInput cupertino-only, no `material.dart` import). An `Overlay` ancestor is required for interactive selection; without one, typing and focus still work but all interactive selection (drag-select, double-tap, long-press, handles, and toolbar) is suppressed. Emits exactly one typeable textbox semantics node carrying `semanticLabel ?? placeholder`; password reports obscured. | diff --git a/skills/wind-ui/references/widgets.md b/skills/wind-ui/references/widgets.md index c97eb591..eb0af7bb 100644 --- a/skills/wind-ui/references/widgets.md +++ b/skills/wind-ui/references/widgets.md @@ -228,7 +228,7 @@ Apply via `ColorFilter.mode(color, BlendMode.srcIn)`. The `preserve-colors` toke ### `WAnchor` -Low-level state propagator. Tracks hover and focus; provides `WindAnchorStateProvider` to descendants. Emits `Semantics(button: true)` for accessibility / E2E. +Low-level state propagator. Tracks hover and focus; provides `WindAnchorStateProvider` to descendants. Emits `Semantics(button: true)` for accessibility / E2E **when it carries a gesture**; a gestureless anchor (what `WDiv` auto-wraps into for `hover:` / `focus:` / `active:`) publishes no node of its own, so a hoverable card announces as its content instead of as a button with no tap action behind it. ```dart const WAnchor({ @@ -245,7 +245,7 @@ const WAnchor({ ``` Structure (outermost → innermost): -`MergeSemantics` → `Semantics(button: true, enabled: !isDisabled)` → `MouseRegion(onEnter/onExit)` → `WindAnchorStateProvider` (broadcasts hover/focus/disabled state) → `Focus(canRequestFocus: !isDisabled)` → optional `GestureDetector` (only if any callback is non-null) → `child`. +`MergeSemantics` → `Semantics(button: true, enabled: !isDisabled)` (both present only when a gesture callback is non-null) → `MouseRegion(onEnter/onExit)` → `WindAnchorStateProvider` (broadcasts hover/focus/disabled state) → `Focus(canRequestFocus: !isDisabled)` → optional `GestureDetector` (only if any callback is non-null) → `child`. State tracking: - Hover: `MouseRegion.onEnter` / `onExit` set `_isHovering`; calls `setState` only on change. diff --git a/test/widgets/w_anchor_test.dart b/test/widgets/w_anchor_test.dart index bc1beacc..4af576c1 100644 --- a/test/widgets/w_anchor_test.dart +++ b/test/widgets/w_anchor_test.dart @@ -666,6 +666,104 @@ void main() { final SemanticsNode node = tester.getSemantics(find.byType(WAnchor)); expect(node.flagsCollection.isButton, isTrue); }); + + testWidgets('a hoverable WDiv keeps its name and drops the button role', + (tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + + // `WDiv` auto-wraps itself in a gestureless `WAnchor` whenever its + // className carries `hover:`, purely to track the hover state. That + // used to publish a button node with `focus` as its only action, so a + // decorative card was offered as a control that cannot be activated. + await tester.pumpWidget( + wrapWithTheme( + const WDiv( + className: 'px-4 py-3 rounded-lg hover:bg-slate-100', + child: WText('Latency'), + ), + ), + ); + + expect(_buttonNodes(tester), isEmpty); + // The content still announces; only the false role is gone. + expect(find.text('Latency'), findsOneWidget); + handle.dispose(); + }); + + testWidgets('a gestureless anchor is not announced as a button', + (tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + + await tester.pumpWidget( + wrapWithTheme( + const WAnchor( + child: WText('Decoration'), + ), + ), + ); + + expect(_buttonNodes(tester), isEmpty); + handle.dispose(); + }); + + testWidgets( + 'a tappable anchor around a hover: WDiv still announces once, named', + (tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + + // A regression guard, not a reproducer: this shape published one + // platform node before the change too, because the inner gestureless + // anchor's node was `isMergedIntoParent` and folded into the real tap + // surface. It is here so the early return cannot take the name or the + // tap action with it. + await tester.pumpWidget( + wrapWithTheme( + WAnchor( + onTap: () {}, + child: const WDiv( + className: 'px-4 py-3 rounded-lg hover:bg-slate-100', + child: WText('Monitors'), + ), + ), + ), + ); + + final List buttons = _buttonNodes(tester); + expect(buttons, hasLength(1)); + // The merged data, not `node.label`: a merging node's own label stays + // empty and the absorbed descendants supply the name. + final SemanticsData data = buttons.single.getSemanticsData(); + expect(data.label, contains('Monitors')); + expect(data.hasAction(SemanticsAction.tap), isTrue); + handle.dispose(); + }); }); }); } + +/// Every button node the platform would actually receive. +/// +/// Two filters matter. `tester.getSemantics` resolves the node for one widget, +/// so the walk covers the whole tree; and a node with `isMergedIntoParent` is +/// folded into its parent's data and never sent to assistive technology, so +/// counting one reports a duplicate that does not exist. Getting that second +/// filter wrong is what made an earlier reading of this bug claim a double +/// announcement. +List _buttonNodes(WidgetTester tester) { + final List found = []; + + void walk(SemanticsNode node) { + if (!node.isMergedIntoParent && + node.getSemanticsData().flagsCollection.isButton) { + found.add(node); + } + node.visitChildren((SemanticsNode child) { + walk(child); + return true; + }); + } + + walk(tester.getSemantics(find.byType(MaterialApp))); + + return found; +} From ba69b15c18934d143ef45986b69785cdbc0bdbe0 Mon Sep 17 00:00:00 2001 From: Anilcan Cakir Date: Fri, 21 Aug 2026 23:32:54 +0300 Subject: [PATCH 2/3] fix(a11y): stop WCheckbox offering a tap for a null onChanged Second half of the same defect class as the anchor change, found by the consumer audit that produced it. `WCheckbox`'s anchor callback was gated on `disabled` alone, so a caller passing `onChanged: null` (a read-only summary row, or a tile whose own tap drives the toggle) still installed `() => onChanged?.call(!value)`: a control that announces itself as pressable and runs a no-op when pressed. Found in a consumer's region picker, where `IgnorePointer(child: WCheckbox(value: selected, onChanged: null))` published a 16x16 nameless node carrying a tap action inside a tile that was already doing the work, because `IgnorePointer` blocks pointers and leaves the semantics node in place. The gate is now `disabled || onChanged == null`. The `checked` state is still reported either way, so a read-only checkbox still says whether it is ticked. Also lands the measurement that settled the anchor change's shape. Keeping `MergeSemantics` on the gestureless path looked tidier and was wrong: it made a styling-only wrapper ABSORB a descendant control's role and actions, so the same locked region tile published itself as "US West, button" with the checkbox's tap. That is the identical bogus claim one level up, so the wrapper publishes nothing at all. Two new tests, one per direction: a null onChanged offers no tap in the checkbox's subtree, a non-null one still does. The first fails without the gate. Suite 1703 green at 94.8%. --- CHANGELOG.md | 4 ++- lib/src/widgets/w_anchor.dart | 8 +++++ lib/src/widgets/w_checkbox.dart | 11 ++++++- test/widgets/w_checkbox_test.dart | 53 +++++++++++++++++++++++++++++++ 4 files changed, 74 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 581019e8..49356224 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,9 @@ This project follows [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.0. ### Fixed -- **Every hoverable `WDiv` announced itself as a button that cannot be pressed.** `WDiv` auto-wraps into a `WAnchor` whenever its className carries `hover:`, `focus:` or `active:`, purely to get the state this widget tracks, and `WAnchor` published `Semantics(button: true)` unconditionally: with no gesture behind it. So a decorative card styled `hover:bg-slate-100` reached assistive technology as a control. Measured on `WDiv(className: 'px-4 py-3 hover:bg-slate-100', child: WText('Latency'))`: one button node labelled `Latency` whose action set was `focus` alone, with no `tap`. A screen reader offers it as a button, the user activates it, and nothing happens, because there is no gesture in the tree to run. A gestureless `WAnchor` now publishes no semantics node of its own and lets its descendants speak, which is what a state propagator should do: the same card keeps its `Latency` label and loses the role. An anchor with `onTap`, `onLongPress` or `onDoubleTap` is unchanged, so `WButton` and every real control keep their single named button node with its tap action, and an explicit `semanticLabel` still wins for the icon-only case that has no child text to merge. One claim to retire with this: the nesting `WAnchor(onTap:) > WDiv(hover:...)` did NOT announce twice, though it published two button nodes in the widget tree. The inner one carried `isMergedIntoParent`, so Flutter folded it into the real tap surface and never sent it to the platform. A reading that counts raw tree nodes rather than platform nodes will report a duplicate announcement that no screen reader ever made. (`lib/src/widgets/w_anchor.dart`, `doc/widgets/w-anchor.md`, `doc/widgets/w-div.md`, `skills/wind-ui/SKILL.md`, `skills/wind-ui/references/widgets.md`) +- **Every hoverable `WDiv` announced itself as a button that cannot be pressed.** `WDiv` auto-wraps into a `WAnchor` whenever its className carries `hover:`, `focus:` or `active:`, purely to get the state this widget tracks, and `WAnchor` published `Semantics(button: true)` unconditionally: with no gesture behind it. So a decorative card styled `hover:bg-slate-100` reached assistive technology as a control. Measured on `WDiv(className: 'px-4 py-3 hover:bg-slate-100', child: WText('Latency'))`: one button node labelled `Latency` whose action set was `focus` alone, with no `tap`. A screen reader offers it as a button, the user activates it, and nothing happens, because there is no gesture in the tree to run. A gestureless `WAnchor` now publishes no semantics node of its own and lets its descendants speak, which is what a state propagator should do: the same card keeps its `Latency` label and loses the role. An anchor with `onTap`, `onLongPress` or `onDoubleTap` is unchanged, so `WButton` and every real control keep their single named button node with its tap action, and an explicit `semanticLabel` still wins for the icon-only case that has no child text to merge. One claim to retire with this: the nesting `WAnchor(onTap:) > WDiv(hover:...)` did NOT announce twice, though it published two button nodes in the widget tree. The inner one carried `isMergedIntoParent`, so Flutter folded it into the real tap surface and never sent it to the platform. A reading that counts raw tree nodes rather than platform nodes will report a duplicate announcement that no screen reader ever made. `MergeSemantics` goes with the role rather than staying, and that was measured too: keeping it made a gestureless wrapper ABSORB a descendant control's role and actions, so a locked region tile in a consumer app swallowed the display-only `WCheckbox` inside it and published itself as "US West, button" carrying the checkbox's tap. That is the same bogus claim in a new place, so a styling-only wrapper now publishes nothing at all. (`lib/src/widgets/w_anchor.dart`, `doc/widgets/w-anchor.md`, `doc/widgets/w-div.md`, `skills/wind-ui/SKILL.md`, `skills/wind-ui/references/widgets.md`) + +- **`WCheckbox` offered a tap action for `onChanged: null`.** The anchor's callback was gated on `disabled` alone, so a caller rendering a display-only checkbox (a read-only summary row, or a tile whose own tap drives the toggle) still installed `() => onChanged?.call(!value)`: a control that announces itself as pressable and runs a no-op when pressed. Found in a consumer's region picker, where `IgnorePointer(child: WCheckbox(value: selected, onChanged: null))` published a 16x16 nameless node with a tap action inside a tile that was already doing the work; `IgnorePointer` blocks pointers and leaves the semantics node in place. The gate is now `disabled || onChanged == null`, and the `checked` state is still reported either way, so a read-only checkbox still tells assistive technology whether it is ticked. (`lib/src/widgets/w_checkbox.dart`) ## [1.4.0] - 2026-08-21 diff --git a/lib/src/widgets/w_anchor.dart b/lib/src/widgets/w_anchor.dart index c1f40560..8b6a5504 100644 --- a/lib/src/widgets/w_anchor.dart +++ b/lib/src/widgets/w_anchor.dart @@ -277,6 +277,14 @@ class _WAnchorState extends State { // nodes in the widget tree. The inner one carried `isMergedIntoParent`, // so it was folded into the real tap surface and never sent to the // platform. Count platform nodes, not tree nodes. + // + // `MergeSemantics` goes with the role, and the measurement is why. Keeping + // it made a gestureless wrapper ABSORB a descendant control's role and + // actions: a locked region tile in a consumer app, whose own anchor has no + // gesture, swallowed the display-only `WCheckbox` inside it and published + // itself as "US West, button" with the checkbox's tap. That is the same + // bogus claim in a new place. A styling-only wrapper publishes nothing and + // lets each descendant speak for itself. if (!hasGestures) { return result; } diff --git a/lib/src/widgets/w_checkbox.dart b/lib/src/widgets/w_checkbox.dart index 2f7a78d5..64312301 100644 --- a/lib/src/widgets/w_checkbox.dart +++ b/lib/src/widgets/w_checkbox.dart @@ -125,7 +125,16 @@ class WCheckbox extends StatelessWidget { // Flutter web accessibility tree. child: MergeSemantics( child: WAnchor( - onTap: disabled ? null : () => onChanged?.call(!value), + // Gated on `onChanged` as well as `disabled`. A caller passing + // `onChanged: null` is rendering a display-only checkbox (a read-only + // summary row, or a tile whose own tap drives the toggle), and + // installing `() => onChanged?.call(!value)` for it published a + // pressable control whose activation runs a no-op. Measured in a + // consumer's region picker: a 16x16 nameless node carrying a tap + // action, inside a tile that was already doing the work. + onTap: disabled || onChanged == null + ? null + : () => onChanged!.call(!value), isDisabled: disabled, states: activeStates, child: WDiv( diff --git a/test/widgets/w_checkbox_test.dart b/test/widgets/w_checkbox_test.dart index fac26185..05993509 100644 --- a/test/widgets/w_checkbox_test.dart +++ b/test/widgets/w_checkbox_test.dart @@ -244,6 +244,59 @@ void main() { ); expect(node.flagsCollection.isChecked, CheckedState.isFalse); }); + + testWidgets('a null onChanged publishes no tap action', (tester) async { + // `onChanged: null` is how a caller renders a display-only checkbox: a + // read-only summary row, or a tile whose own tap drives the toggle. The + // anchor's callback was gated on `disabled` alone, so such a checkbox + // still installed `() => onChanged?.call(!value)`, which announces a + // pressable control whose activation runs a no-op. Measured in a + // consumer's region picker: a 16x16 nameless node with a tap action + // inside a tile that was doing the work itself. + await tester.pumpWidget( + wrapWithTheme(const WCheckbox(value: false)), + ); + await tester.pump(); + + // The tap lives on the inner anchor's node, not on the outer + // `Semantics(container: true)` one that `getSemantics` resolves, so the + // question is whether ANY node in the checkbox's subtree offers it. + expect(_subtreeOffersTap(tester), isFalse); + // The state is still reported; only the false affordance is gone. + final SemanticsNode node = tester.getSemantics( + find.byType(WCheckbox), + ); + expect(node.flagsCollection.isChecked, CheckedState.isFalse); + }); + + testWidgets('a non-null onChanged still publishes a tap action', + (tester) async { + await tester.pumpWidget( + wrapWithTheme(WCheckbox(value: false, onChanged: (_) {})), + ); + await tester.pump(); + + expect(_subtreeOffersTap(tester), isTrue); + }); }); }); } + +/// Whether any node in the rendered checkbox's semantics subtree offers a tap. +bool _subtreeOffersTap(WidgetTester tester) { + bool found = false; + + void walk(SemanticsNode node) { + if (node.getSemanticsData().hasAction(SemanticsAction.tap)) { + found = true; + } + node.visitChildren((SemanticsNode child) { + walk(child); + return true; + }); + } + + walk(tester.getSemantics(find.byType(WCheckbox))); + + return found; +} From eb375922a18427dc32c37c217c6da26f1d5ecb52 Mon Sep 17 00:00:00 2001 From: Anilcan Cakir Date: Fri, 21 Aug 2026 23:41:18 +0300 Subject: [PATCH 3/3] docs(a11y): qualify the gestureless rule, and pin its one exception Review caught that the new text overstated the change. `semanticLabel` is checked BEFORE the gesture guard, so a labelled anchor publishes its button node whether or not it carries a gesture, and four places said a gestureless anchor publishes nothing full stop. Measured, to be sure which way to fix it: `WAnchor(semanticLabel: 'Decorative', child: Icon(...))` with no gesture publishes `btn=true tap=false label="Decorative"`. Documented rather than guarded, because the exception is load-bearing. An existing test pins `isDisabled: true, onTap: null, semanticLabel: 'Save'` as a button, and it is right: a disabled control has to tell assistive technology that a control is there and currently unavailable. Extending the guard would have taken that away. So the rule is that the ROLE follows the gesture, the LABEL is a statement of intent, and `WDiv`'s auto-wrap never passes one. A new test pins the exception from the enabled side, which nothing covered: named, one node, no tap action. --- CHANGELOG.md | 2 +- doc/widgets/w-anchor.md | 9 +++++++-- skills/wind-ui/SKILL.md | 2 +- skills/wind-ui/references/widgets.md | 10 ++++++++-- test/widgets/w_anchor_test.dart | 28 ++++++++++++++++++++++++++++ 5 files changed, 45 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 49356224..74b03706 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ This project follows [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.0. ### Fixed -- **Every hoverable `WDiv` announced itself as a button that cannot be pressed.** `WDiv` auto-wraps into a `WAnchor` whenever its className carries `hover:`, `focus:` or `active:`, purely to get the state this widget tracks, and `WAnchor` published `Semantics(button: true)` unconditionally: with no gesture behind it. So a decorative card styled `hover:bg-slate-100` reached assistive technology as a control. Measured on `WDiv(className: 'px-4 py-3 hover:bg-slate-100', child: WText('Latency'))`: one button node labelled `Latency` whose action set was `focus` alone, with no `tap`. A screen reader offers it as a button, the user activates it, and nothing happens, because there is no gesture in the tree to run. A gestureless `WAnchor` now publishes no semantics node of its own and lets its descendants speak, which is what a state propagator should do: the same card keeps its `Latency` label and loses the role. An anchor with `onTap`, `onLongPress` or `onDoubleTap` is unchanged, so `WButton` and every real control keep their single named button node with its tap action, and an explicit `semanticLabel` still wins for the icon-only case that has no child text to merge. One claim to retire with this: the nesting `WAnchor(onTap:) > WDiv(hover:...)` did NOT announce twice, though it published two button nodes in the widget tree. The inner one carried `isMergedIntoParent`, so Flutter folded it into the real tap surface and never sent it to the platform. A reading that counts raw tree nodes rather than platform nodes will report a duplicate announcement that no screen reader ever made. `MergeSemantics` goes with the role rather than staying, and that was measured too: keeping it made a gestureless wrapper ABSORB a descendant control's role and actions, so a locked region tile in a consumer app swallowed the display-only `WCheckbox` inside it and published itself as "US West, button" carrying the checkbox's tap. That is the same bogus claim in a new place, so a styling-only wrapper now publishes nothing at all. (`lib/src/widgets/w_anchor.dart`, `doc/widgets/w-anchor.md`, `doc/widgets/w-div.md`, `skills/wind-ui/SKILL.md`, `skills/wind-ui/references/widgets.md`) +- **Every hoverable `WDiv` announced itself as a button that cannot be pressed.** `WDiv` auto-wraps into a `WAnchor` whenever its className carries `hover:`, `focus:` or `active:`, purely to get the state this widget tracks, and `WAnchor` published `Semantics(button: true)` unconditionally: with no gesture behind it. So a decorative card styled `hover:bg-slate-100` reached assistive technology as a control. Measured on `WDiv(className: 'px-4 py-3 hover:bg-slate-100', child: WText('Latency'))`: one button node labelled `Latency` whose action set was `focus` alone, with no `tap`. A screen reader offers it as a button, the user activates it, and nothing happens, because there is no gesture in the tree to run. A gestureless, UNLABELLED `WAnchor` now publishes no semantics node of its own and lets its descendants speak, which is what a state propagator should do (an explicit `semanticLabel` still publishes the named node either way, which is how a disabled control reports that it exists and is unavailable; `WDiv`'s auto-wrap never passes one): the same card keeps its `Latency` label and loses the role. An anchor with `onTap`, `onLongPress` or `onDoubleTap` is unchanged, so `WButton` and every real control keep their single named button node with its tap action, and an explicit `semanticLabel` still wins for the icon-only case that has no child text to merge. One claim to retire with this: the nesting `WAnchor(onTap:) > WDiv(hover:...)` did NOT announce twice, though it published two button nodes in the widget tree. The inner one carried `isMergedIntoParent`, so Flutter folded it into the real tap surface and never sent it to the platform. A reading that counts raw tree nodes rather than platform nodes will report a duplicate announcement that no screen reader ever made. `MergeSemantics` goes with the role rather than staying, and that was measured too: keeping it made a gestureless wrapper ABSORB a descendant control's role and actions, so a locked region tile in a consumer app swallowed the display-only `WCheckbox` inside it and published itself as "US West, button" carrying the checkbox's tap. That is the same bogus claim in a new place, so a styling-only wrapper now publishes nothing at all. (`lib/src/widgets/w_anchor.dart`, `doc/widgets/w-anchor.md`, `doc/widgets/w-div.md`, `skills/wind-ui/SKILL.md`, `skills/wind-ui/references/widgets.md`) - **`WCheckbox` offered a tap action for `onChanged: null`.** The anchor's callback was gated on `disabled` alone, so a caller rendering a display-only checkbox (a read-only summary row, or a tile whose own tap drives the toggle) still installed `() => onChanged?.call(!value)`: a control that announces itself as pressable and runs a no-op when pressed. Found in a consumer's region picker, where `IgnorePointer(child: WCheckbox(value: selected, onChanged: null))` published a 16x16 nameless node with a tap action inside a tile that was already doing the work; `IgnorePointer` blocks pointers and leaves the semantics node in place. The gate is now `disabled || onChanged == null`, and the `checked` state is still reported either way, so a read-only checkbox still tells assistive technology whether it is ticked. (`lib/src/widgets/w_checkbox.dart`) diff --git a/doc/widgets/w-anchor.md b/doc/widgets/w-anchor.md index fa0ca256..7b083524 100644 --- a/doc/widgets/w-anchor.md +++ b/doc/widgets/w-anchor.md @@ -179,13 +179,18 @@ WindTheme( A `WAnchor` that carries a gesture (`onTap`, `onLongPress` or `onDoubleTap`) publishes one `Semantics(button: true)` node, and `MergeSemantics` folds the child's text into it so the control gets its name from what it renders. Pass `semanticLabel` when the child has no readable text, such as an icon-only anchor; that label replaces the child subtree rather than concatenating with it. -A `WAnchor` with no gesture publishes nothing of its own. It is the state propagator in that mode, which is how `WDiv` uses it to serve `hover:`, `focus:` and `active:` classes, and a decorative surface must not announce itself as a button with no action behind it. +A `WAnchor` with no gesture **and no `semanticLabel`** publishes nothing of its own. It is the state propagator in that mode, which is how `WDiv` uses it to serve `hover:`, `focus:` and `active:` classes (the auto-wrap never passes a label), and a decorative surface must not announce itself as a button with no action behind it. + +`semanticLabel` is the exception, and deliberately so: setting it always publishes the named button node, gestures or not. It is how a DISABLED control still tells assistive technology that a control is there and currently unavailable, which is information the user needs. That makes the label a statement of intent rather than a formatting choice: set it on a control, never on decoration. ```dart // One button node named "Save". WAnchor(onTap: save, child: const WText('Save')) -// No button node: hover styling only. +// One button node named "Save", announced disabled, with no tap action. +const WAnchor(isDisabled: true, semanticLabel: 'Save', child: WText('Save')) + +// No node at all: hover styling only. const WAnchor(child: WDiv(className: 'hover:bg-slate-100', child: WText('Card'))) ``` diff --git a/skills/wind-ui/SKILL.md b/skills/wind-ui/SKILL.md index 3e30eed2..a2c0e03a 100644 --- a/skills/wind-ui/SKILL.md +++ b/skills/wind-ui/SKILL.md @@ -78,7 +78,7 @@ The headline 25 (table below) are the ones an agent reaches for daily. Two more | `WIcon` | Display | `icon: IconData` | Material icons; use `Icons.*_outlined` variants by convention. Reads `text-*` for size AND color (overloaded). Inherits from `DefaultTextStyle` when className is absent. Inline color prop: `foregroundColor`. | | `WImage` | Display | none (requires `src` or `image`) | Network (URL) or asset (prefix `asset://path`) or `ImageProvider`. `object-cover` default. | | `WSvg` / `WSvg.string` | Display | `src` / `svg` | Vector graphics. `fill-*` / `stroke-*` for color. `preserve-colors` token disables tint for multi-color SVGs (QR codes, logos). | -| `WAnchor` | Interactive | `child: Widget` | Low-level gesture + focus + hover propagator. Emits `Semantics(button: true)` only when it carries a gesture. | +| `WAnchor` | Interactive | `child: Widget` | Low-level gesture + focus + hover propagator. Emits `Semantics(button: true)` only when it carries a gesture, or when `semanticLabel` is set. | | `WButton` | Interactive | `child: Widget` | Wraps `WAnchor` + `WDiv` + built-in spinner. `isLoading: true` injects `loading:` state. `disabled: true` injects `disabled:` state and blocks taps. | | `WPopover` | Overlay | none (requires builders) | `OverlayPortal`-based; `triggerBuilder(ctx, isOpen, isHovering)` + `contentBuilder(ctx, close)` + optional `PopoverController`. Auto-flips alignment when bottom space is insufficient. | | `WInput` | Form (raw) | none | Material-free text input (EditableText core); works under Material, Cupertino, custom, or bare WidgetsApp (no Material ancestor required). `value` + `onChanged` for controlled binding, or `controller` for imperative needs; passing both throws `AssertionError` in debug. `InputType` enum: `text` / `password` / `email` / `number` / `multiline` (`number` restricts to a signed decimal on every platform incl. web; pass `inputFormatters` to override). `readOnly: true` activates a `readonly:` state like `enabled: false` activates `disabled:`. Native text selection: mouse-drag selects a substring, double-click/double-tap selects a word, tapping the box moves the cursor; selection handles are Cupertino-style on all platforms (keeps WInput cupertino-only, no `material.dart` import). An `Overlay` ancestor is required for interactive selection; without one, typing and focus still work but all interactive selection (drag-select, double-tap, long-press, handles, and toolbar) is suppressed. Emits exactly one typeable textbox semantics node carrying `semanticLabel ?? placeholder`; password reports obscured. | diff --git a/skills/wind-ui/references/widgets.md b/skills/wind-ui/references/widgets.md index eb0af7bb..1e2c164d 100644 --- a/skills/wind-ui/references/widgets.md +++ b/skills/wind-ui/references/widgets.md @@ -228,7 +228,7 @@ Apply via `ColorFilter.mode(color, BlendMode.srcIn)`. The `preserve-colors` toke ### `WAnchor` -Low-level state propagator. Tracks hover and focus; provides `WindAnchorStateProvider` to descendants. Emits `Semantics(button: true)` for accessibility / E2E **when it carries a gesture**; a gestureless anchor (what `WDiv` auto-wraps into for `hover:` / `focus:` / `active:`) publishes no node of its own, so a hoverable card announces as its content instead of as a button with no tap action behind it. +Low-level state propagator. Tracks hover and focus; provides `WindAnchorStateProvider` to descendants. Emits `Semantics(button: true)` for accessibility / E2E **when it carries a gesture, or when `semanticLabel` is set**. A gestureless, unlabelled anchor (what `WDiv` auto-wraps into for `hover:` / `focus:` / `active:`) publishes no node of its own, so a hoverable card announces as its content instead of as a button with no tap action behind it. An explicit `semanticLabel` always publishes the node, which is how a DISABLED control still reports that it exists and is unavailable; set it on a control, never on decoration. ```dart const WAnchor({ @@ -245,7 +245,13 @@ const WAnchor({ ``` Structure (outermost → innermost): -`MergeSemantics` → `Semantics(button: true, enabled: !isDisabled)` (both present only when a gesture callback is non-null) → `MouseRegion(onEnter/onExit)` → `WindAnchorStateProvider` (broadcasts hover/focus/disabled state) → `Focus(canRequestFocus: !isDisabled)` → optional `GestureDetector` (only if any callback is non-null) → `child`. +Three accessibility paths, checked in this order: + +1. `semanticLabel != null`: `Semantics(button: true, enabled: !isDisabled, label: semanticLabel, onTap/onLongPress lifted, excludeSemantics: true)` → the rest below. The label replaces the child subtree rather than concatenating with it, and the node is published whether or not a gesture exists. +2. No label and no gesture: no node of its own. +3. No label, with a gesture: `MergeSemantics` → `Semantics(button: true, enabled: !isDisabled)` → the rest below. + +The rest, in all three: `MouseRegion(onEnter/onExit)` → `WindAnchorStateProvider` (broadcasts hover/focus/disabled state) → `Focus(canRequestFocus: !isDisabled)` → optional `GestureDetector` (only if any callback is non-null) → `child`. State tracking: - Hover: `MouseRegion.onEnter` / `onExit` set `_isHovering`; calls `setState` only on change. diff --git a/test/widgets/w_anchor_test.dart b/test/widgets/w_anchor_test.dart index 4af576c1..7558e4dd 100644 --- a/test/widgets/w_anchor_test.dart +++ b/test/widgets/w_anchor_test.dart @@ -706,6 +706,34 @@ void main() { handle.dispose(); }); + testWidgets('an explicit semanticLabel publishes the node either way', + (tester) async { + // The documented exception to the rule above, and the reason the guard + // is not extended to cover it: the label branch is checked FIRST, so a + // labelled anchor is announced as a button with or without a gesture. + // That is what lets a DISABLED control report that it exists and is + // unavailable, which the disabled test below pins from the other side. + // Setting the label is therefore a statement that this is a control. + final SemanticsHandle handle = tester.ensureSemantics(); + + await tester.pumpWidget( + wrapWithTheme( + const WAnchor( + semanticLabel: 'Close', + child: Icon(Icons.close), + ), + ), + ); + + final List buttons = _buttonNodes(tester); + expect(buttons, hasLength(1)); + final SemanticsData data = buttons.single.getSemanticsData(); + expect(data.label, 'Close'); + // Named, but honest about having nothing to activate. + expect(data.hasAction(SemanticsAction.tap), isFalse); + handle.dispose(); + }); + testWidgets( 'a tappable anchor around a hover: WDiv still announces once, named', (tester) async {