diff --git a/packages/naked_ui/CHANGELOG.md b/packages/naked_ui/CHANGELOG.md index 6bec47c..cac0cb5 100644 --- a/packages/naked_ui/CHANGELOG.md +++ b/packages/naked_ui/CHANGELOG.md @@ -1,3 +1,20 @@ +## Unreleased + +### Features + +- Add `NakedButton.semanticHint` on the same Semantics node as the button + role, label, enabled state, and tap/long-press actions. +- Add `NakedSelect.semanticValue` so the trigger can announce a + human-readable selection instead of `T.toString()`. + `SemanticsRole.comboBox` exists on Flutter 3.41+ but debug semantics + still throw `Missing checks for role` (flutter/flutter#172918), so the + trigger keeps the merged button + expanded + value contract. +- Add `NakedRadioGroup`, a thin wrapper over Flutter's `RadioGroup` that + supplies what it lacks: a nullable `onChanged` (null means disabled), a + group `enabled` state radios inherit, and an optional accessible group + label. Flutter's `RadioGroup` keeps the single `SemanticsRole.radioGroup` + node; the label is a plain container around it, never a second role node. + ## 1.0.0-beta.11 ### Features diff --git a/packages/naked_ui/lib/src/naked_button.dart b/packages/naked_ui/lib/src/naked_button.dart index f3d9bf7..2704deb 100644 --- a/packages/naked_ui/lib/src/naked_button.dart +++ b/packages/naked_ui/lib/src/naked_button.dart @@ -64,6 +64,7 @@ class NakedButton extends StatefulWidget { this.focusOnPress = false, this.tooltip, this.semanticLabel, + this.semanticHint, this.excludeSemantics = false, }); @@ -112,6 +113,13 @@ class NakedButton extends StatefulWidget { /// Semantic label for the button. final String? semanticLabel; + /// Additional context announced with the button's accessible name. + /// + /// Lives on the same Semantics node as the button role, label, enabled + /// state, and tap/long-press actions. Do not wrap the button in another + /// Semantics node just to attach a hint. + final String? semanticHint; + /// Whether to exclude this widget from the semantic tree. /// /// When true, the widget and its children are hidden from accessibility services. @@ -252,6 +260,7 @@ class _NakedButtonState extends State enabled: _isInteractive, button: true, label: widget.semanticLabel, + hint: widget.semanticHint, tooltip: widget.tooltip, onTap: widget.onPressed != null ? _handleTap : null, onLongPress: widget.onLongPress != null ? _handleLongPress : null, diff --git a/packages/naked_ui/lib/src/naked_radio.dart b/packages/naked_ui/lib/src/naked_radio.dart index 7c57e41..369bb7f 100644 --- a/packages/naked_ui/lib/src/naked_radio.dart +++ b/packages/naked_ui/lib/src/naked_radio.dart @@ -170,18 +170,27 @@ class _NakedRadioState extends State> ); } + // Typed to match the registry lookup above: with nested groups of + // different value types, this radio must read the enabled state of the + // same group that registered it, not merely the nearest one. + final groupEnabled = + NakedRadioGroupScope.maybeOf(context)?.enabled ?? true; + final effectiveEnabled = widget.enabled && groupEnabled; + final effectiveCursor = widget.mouseCursor ?? - (widget.enabled ? SystemMouseCursors.click : SystemMouseCursors.basic); + (effectiveEnabled + ? SystemMouseCursors.click + : SystemMouseCursors.basic); final radio = RawRadio( value: widget.value, mouseCursor: WidgetStateMouseCursor.resolveWith((_) => effectiveCursor), toggleable: widget.toggleable, focusNode: effectiveFocusNode, // FocusNodeMixin guarantees non-null - autofocus: widget.autofocus && widget.enabled, + autofocus: widget.autofocus && effectiveEnabled, groupRegistry: registry, - enabled: widget.enabled, + enabled: effectiveEnabled, builder: (context, radioState) { // Derive "pressed" from RawRadio's internal down position to avoid // intercepting gestures with an external Listener. @@ -190,7 +199,7 @@ class _NakedRadioState extends State> // Notify hover changes only when interactive, without setState in build final hovered = states.contains(WidgetState.hovered); - if (widget.enabled && _lastReportedHover != hovered) { + if (effectiveEnabled && _lastReportedHover != hovered) { _lastReportedHover = hovered; WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) widget.onHoverChange?.call(hovered); @@ -198,7 +207,7 @@ class _NakedRadioState extends State> } // Notify press changes only when interactive - if (widget.enabled && _lastReportedPressed != pressed) { + if (effectiveEnabled && _lastReportedPressed != pressed) { _lastReportedPressed = pressed; WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) widget.onPressChange?.call(pressed); @@ -240,3 +249,102 @@ class _NakedRadioState extends State> return widget.excludeSemantics ? ExcludeSemantics(child: result) : result; } } + +/// Groups [NakedRadio] children under Flutter's [RadioGroup]. +/// +/// Owns the Flutter radio registry, group enabled state, the disabled +/// callback adaptation [RadioGroup] requires, and optional group +/// semantics. A null [onChanged] is a genuinely disabled group. +/// +/// Do not nest a plain [RadioGroup] of the same value type inside this +/// group: its radios would register with the inner registry while still +/// inheriting this group's enabled state. Nest another [NakedRadioGroup] +/// instead, which keeps both aligned. +class NakedRadioGroup extends StatelessWidget { + /// Creates a radio group. + const NakedRadioGroup({ + super.key, + required this.groupValue, + this.onChanged, + this.enabled = true, + this.semanticLabel, + required this.child, + }); + + /// The currently selected value. + final T? groupValue; + + /// Called when a radio in the group is selected. + /// + /// When null, the group is disabled. Flutter's [RadioGroup] requires a + /// non-null callback, so a no-op is supplied only as that adapter. + final ValueChanged? onChanged; + + /// Whether the group is enabled. + /// + /// Combined with [onChanged] != null to produce the interactive state. + final bool enabled; + + /// Accessible name for the radio group. + final String? semanticLabel; + + /// Radios that participate in this group. + final Widget child; + + bool get _interactive => enabled && onChanged != null; + + @override + Widget build(BuildContext context) { + Widget group = RadioGroup( + groupValue: groupValue, + onChanged: onChanged ?? _disabledRadioGroupOnChanged, + child: NakedRadioGroupScope(enabled: _interactive, child: child), + ); + + final label = semanticLabel; + if (label != null && label.isNotEmpty) { + // No role here: Flutter's RadioGroup already publishes the single + // SemanticsRole.radioGroup node (radio_group.dart), and it accepts no + // label. Adding the role again would announce the group twice, so the + // label lives on a plain container around Flutter's role node. + group = Semantics( + container: true, + explicitChildNodes: true, + label: label, + child: group, + ); + } + + return group; + } +} + +void _disabledRadioGroupOnChanged(T? _) {} + +/// Enabled state published by [NakedRadioGroup]. +/// +/// Typed by the group's value type so the lookup stays aligned with +/// Flutter's typed [RadioGroup.maybeOf] registry lookup under nested +/// groups of different value types. +class NakedRadioGroupScope extends InheritedWidget { + /// Creates a group-enabled scope. + const NakedRadioGroupScope({ + super.key, + required this.enabled, + required super.child, + }); + + /// Whether radios in this group are interactive. + final bool enabled; + + /// The nearest group scope for value type [T], if any. + static NakedRadioGroupScope? maybeOf(BuildContext context) { + return context + .dependOnInheritedWidgetOfExactType>(); + } + + @override + bool updateShouldNotify(NakedRadioGroupScope oldWidget) { + return enabled != oldWidget.enabled; + } +} diff --git a/packages/naked_ui/lib/src/naked_select.dart b/packages/naked_ui/lib/src/naked_select.dart index 94edb8d..16255b3 100644 --- a/packages/naked_ui/lib/src/naked_select.dart +++ b/packages/naked_ui/lib/src/naked_select.dart @@ -236,6 +236,7 @@ class NakedSelect extends StatefulWidget { this.mouseCursor = SystemMouseCursors.click, this.triggerFocusNode, this.semanticLabel, + this.semanticValue, this.positioning = const OverlayPositionConfig( alignment: OverlayAlignment.center, ), @@ -296,6 +297,11 @@ class NakedSelect extends StatefulWidget { /// Optional semantics label for the trigger. final String? semanticLabel; + /// Human-readable value announced for the current selection. + /// + /// When null, the trigger falls back to [value]?.toString(). + final String? semanticValue; + /// Overlay positioning configuration. final OverlayPositionConfig positioning; @@ -439,7 +445,7 @@ class _NakedSelectState extends State> @override Widget build(BuildContext context) { _scheduleControlledSync(); - final semanticsValue = _effectiveValue?.toString(); + final semanticsValue = widget.semanticValue ?? _effectiveValue?.toString(); Widget selectWidget = AnchoredOverlayShell( controller: _menuController, @@ -504,6 +510,10 @@ class _NakedSelectState extends State> Widget result = widget.excludeSemantics ? ExcludeSemantics(child: selectWidget) + // Flutter >=3.41 exposes SemanticsRole.comboBox, but debug semantics + // still throw "Missing checks for role SemanticsRole.comboBox" + // (flutter/flutter#172918). The supported trigger contract on this + // floor is the merged button + expanded + value node. : MergeSemantics( child: Semantics( container: true, diff --git a/packages/naked_ui/lib/src/naked_widgets.dart b/packages/naked_ui/lib/src/naked_widgets.dart index 7bec820..fbc24a0 100644 --- a/packages/naked_ui/lib/src/naked_widgets.dart +++ b/packages/naked_ui/lib/src/naked_widgets.dart @@ -5,7 +5,7 @@ export 'naked_dialog.dart'; export 'naked_link.dart'; export 'naked_menu.dart'; export 'naked_popover.dart'; -export 'naked_radio.dart'; +export 'naked_radio.dart' hide NakedRadioGroupScope; export 'naked_select.dart'; export 'naked_slider.dart'; export 'naked_tabs.dart'; diff --git a/packages/naked_ui/test/semantics/naked_button_semantics_test.dart b/packages/naked_ui/test/semantics/naked_button_semantics_test.dart index 3597e02..3543df0 100644 --- a/packages/naked_ui/test/semantics/naked_button_semantics_test.dart +++ b/packages/naked_ui/test/semantics/naked_button_semantics_test.dart @@ -673,5 +673,33 @@ void main() { fn.dispose(); handle.dispose(); }); + + testWidgets('semanticHint lives on the same button node', (tester) async { + final handle = tester.ensureSemantics(); + await tester.pumpWidget( + _buildTestApp( + NakedButton( + onPressed: () {}, + semanticLabel: 'Save', + semanticHint: 'Saves the current document', + child: const SizedBox.square(dimension: 24), + ), + ), + ); + + final root = tester.getSemantics(find.byType(Scaffold)); + final buttons = collectSemanticsNodes( + root, + (node) => node.getSemanticsData().flagsCollection.isButton, + ); + expect(buttons, hasLength(1)); + + final data = buttons.single.getSemanticsData(); + expect(data.label, 'Save'); + expect(data.hint, 'Saves the current document'); + expect(data.hasAction(SemanticsAction.tap), isTrue); + + handle.dispose(); + }); }); } diff --git a/packages/naked_ui/test/semantics/naked_radio_semantics_test.dart b/packages/naked_ui/test/semantics/naked_radio_semantics_test.dart index 66fe779..eaafa90 100644 --- a/packages/naked_ui/test/semantics/naked_radio_semantics_test.dart +++ b/packages/naked_ui/test/semantics/naked_radio_semantics_test.dart @@ -1,3 +1,5 @@ +import 'dart:ui' show Tristate; + import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; // Material exports widgets; no separate widgets import needed. @@ -252,4 +254,228 @@ void main() { handle.dispose(); }); }); + + group('NakedRadioGroup', () { + Widget buildGroup({ + required String? groupValue, + ValueChanged? onChanged, + bool enabled = true, + String? semanticLabel, + }) { + return _buildTestApp( + NakedRadioGroup( + groupValue: groupValue, + onChanged: onChanged, + enabled: enabled, + semanticLabel: semanticLabel, + child: const Column( + mainAxisSize: MainAxisSize.min, + children: [ + NakedRadio( + value: 'a', + child: SizedBox.square(dimension: 20), + ), + NakedRadio( + value: 'b', + child: SizedBox.square(dimension: 20), + ), + ], + ), + ), + ); + } + + testWidgets('tap selects through the group onChanged', (tester) async { + String? selected = 'a'; + await tester.pumpWidget( + buildGroup(groupValue: selected, onChanged: (v) => selected = v), + ); + + await tester.tap(find.byType(NakedRadio).last); + await tester.pump(); + + expect(selected, 'b'); + }); + + testWidgets('null onChanged disables the whole group', (tester) async { + final handle = tester.ensureSemantics(); + await tester.pumpWidget(buildGroup(groupValue: 'a', onChanged: null)); + + await tester.tap( + find.byType(NakedRadio).last, + warnIfMissed: false, + ); + await tester.pump(); + + final radios = collectSemanticsNodes( + tester.getSemantics(find.byType(Scaffold)), + (n) => n.getSemanticsData().flagsCollection.isInMutuallyExclusiveGroup, + ); + expect(radios, hasLength(2)); + for (final node in radios) { + expect( + node.getSemanticsData().flagsCollection.isEnabled, + Tristate.isFalse, + ); + } + + handle.dispose(); + }); + + testWidgets('enabled: false disables radios that are enabled themselves', ( + tester, + ) async { + String? selected = 'a'; + await tester.pumpWidget( + buildGroup( + groupValue: selected, + onChanged: (v) => selected = v, + enabled: false, + ), + ); + + await tester.tap( + find.byType(NakedRadio).last, + warnIfMissed: false, + ); + await tester.pump(); + + expect(selected, 'a'); + }); + + testWidgets('semanticLabel labels the group without a second role node', ( + tester, + ) async { + final handle = tester.ensureSemantics(); + await tester.pumpWidget( + buildGroup( + groupValue: 'a', + onChanged: (_) {}, + semanticLabel: 'Shipping speed', + ), + ); + + final root = tester.getSemantics(find.byType(Scaffold)); + // Flutter's RadioGroup publishes the single radioGroup role node. + // The label must not add a second one. + final roleNodes = collectSemanticsNodes( + root, + (n) => n.getSemanticsData().role == SemanticsRole.radioGroup, + ); + expect(roleNodes, hasLength(1)); + + final labeled = collectSemanticsNodes( + root, + (n) => n.getSemanticsData().label == 'Shipping speed', + ); + expect(labeled, hasLength(1)); + + // Pin the deliberate structure: the label is a plain container + // (no role) with Flutter's role node inside it — not a second + // role node and not an unrelated sibling. + final labeledNode = labeled.single; + expect( + labeledNode.getSemanticsData().role, + isNot(SemanticsRole.radioGroup), + ); + SemanticsNode? ancestor = roleNodes.single.parent; + while (ancestor != null && ancestor != labeledNode) { + ancestor = ancestor.parent; + } + expect( + ancestor, + same(labeledNode), + reason: "Flutter's role node must sit inside the labeled container", + ); + + handle.dispose(); + }); + + testWidgets('without semanticLabel only Flutter\'s role node exists', ( + tester, + ) async { + final handle = tester.ensureSemantics(); + await tester.pumpWidget(buildGroup(groupValue: 'a', onChanged: (_) {})); + + final groups = collectSemanticsNodes( + tester.getSemantics(find.byType(Scaffold)), + (n) => n.getSemanticsData().role == SemanticsRole.radioGroup, + ); + expect(groups, hasLength(1)); + expect(groups.single.getSemanticsData().label, isEmpty); + + handle.dispose(); + }); + + testWidgets('nested groups of different types keep enabled state aligned', ( + tester, + ) async { + int? selectedInner = 1; + await tester.pumpWidget( + _buildTestApp( + NakedRadioGroup( + groupValue: 'a', + onChanged: null, // outer group disabled + child: NakedRadioGroup( + groupValue: selectedInner, + onChanged: (v) => selectedInner = v, + child: const Column( + mainAxisSize: MainAxisSize.min, + children: [ + NakedRadio( + value: 1, + child: SizedBox.square(dimension: 20), + ), + NakedRadio( + value: 2, + child: SizedBox.square(dimension: 20), + ), + ], + ), + ), + ), + ), + ); + + // The int radios belong to the enabled inner group; the disabled + // outer String group must not leak its state onto them. + await tester.tap(find.byType(NakedRadio).last); + await tester.pump(); + + expect(selectedInner, 2); + }); + + testWidgets( + 'radio skips a mismatched-type group to read its own group enabled ' + 'state', + (tester) async { + int? selectedOuter = 1; + await tester.pumpWidget( + _buildTestApp( + NakedRadioGroup( + groupValue: selectedOuter, + enabled: false, // the radio's real group is disabled + onChanged: (v) => selectedOuter = v, + child: NakedRadioGroup( + groupValue: 'a', + onChanged: (_) {}, // enabled, but the wrong value type + child: const NakedRadio( + value: 2, + child: SizedBox.square(dimension: 20), + ), + ), + ), + ), + ); + + // An untyped nearest-scope lookup would read the nearer, enabled + // String group and let the tap through; the typed lookup must + // bind to the outer, disabled int group instead. + await tester.tap(find.byType(NakedRadio), warnIfMissed: false); + await tester.pump(); + + expect(selectedOuter, 1); + }, + ); + }); } diff --git a/packages/naked_ui/test/semantics/naked_select_semantics_test.dart b/packages/naked_ui/test/semantics/naked_select_semantics_test.dart index 0230e43..2f36e6a 100644 --- a/packages/naked_ui/test/semantics/naked_select_semantics_test.dart +++ b/packages/naked_ui/test/semantics/naked_select_semantics_test.dart @@ -409,5 +409,39 @@ void main() { handle.dispose(); }); + + testWidgets('semanticValue is announced instead of T.toString()', ( + tester, + ) async { + final handle = tester.ensureSemantics(); + + await tester.pumpWidget( + _buildTestApp( + NakedSelect( + value: Object(), + onChanged: (_) {}, + semanticValue: 'Apple', + builder: (context, state, child) => const Text('Fruit'), + overlayBuilder: (context, info) => const SizedBox.shrink(), + ), + ), + ); + + final root = tester.getSemantics(find.byType(Scaffold)); + final triggers = collectSemanticsNodes( + root, + (node) => + node.getSemanticsData().flagsCollection.isButton || + node.getSemanticsData().flagsCollection.isExpanded != Tristate.none, + ); + expect(triggers, hasLength(1)); + expect(triggers.single.getSemanticsData().value, 'Apple'); + expect( + triggers.single.getSemanticsData().value, + isNot(contains('Instance of')), + ); + + handle.dispose(); + }); }); }