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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions packages/naked_ui/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
9 changes: 9 additions & 0 deletions packages/naked_ui/lib/src/naked_button.dart
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ class NakedButton extends StatefulWidget {
this.focusOnPress = false,
this.tooltip,
this.semanticLabel,
this.semanticHint,
this.excludeSemantics = false,
});

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -252,6 +260,7 @@ class _NakedButtonState extends State<NakedButton>
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,
Expand Down
118 changes: 113 additions & 5 deletions packages/naked_ui/lib/src/naked_radio.dart
Original file line number Diff line number Diff line change
Expand Up @@ -170,18 +170,27 @@ class _NakedRadioState<T> extends State<NakedRadio<T>>
);
}

// 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<T>(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<T>(
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.
Expand All @@ -190,15 +199,15 @@ class _NakedRadioState<T> extends State<NakedRadio<T>>

// 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);
});
}

// 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);
Expand Down Expand Up @@ -240,3 +249,102 @@ class _NakedRadioState<T> extends State<NakedRadio<T>>
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<T> 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<T?>? 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<T>(
groupValue: groupValue,
onChanged: onChanged ?? _disabledRadioGroupOnChanged,
child: NakedRadioGroupScope<T>(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>(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<T> 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<T>? maybeOf<T>(BuildContext context) {
return context
.dependOnInheritedWidgetOfExactType<NakedRadioGroupScope<T>>();
}

@override
bool updateShouldNotify(NakedRadioGroupScope<T> oldWidget) {
return enabled != oldWidget.enabled;
}
}
12 changes: 11 additions & 1 deletion packages/naked_ui/lib/src/naked_select.dart
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,7 @@ class NakedSelect<T> extends StatefulWidget {
this.mouseCursor = SystemMouseCursors.click,
this.triggerFocusNode,
this.semanticLabel,
this.semanticValue,
this.positioning = const OverlayPositionConfig(
alignment: OverlayAlignment.center,
),
Expand Down Expand Up @@ -296,6 +297,11 @@ class NakedSelect<T> 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;

Expand Down Expand Up @@ -439,7 +445,7 @@ class _NakedSelectState<T> extends State<NakedSelect<T>>
@override
Widget build(BuildContext context) {
_scheduleControlledSync();
final semanticsValue = _effectiveValue?.toString();
final semanticsValue = widget.semanticValue ?? _effectiveValue?.toString();

Widget selectWidget = AnchoredOverlayShell(
controller: _menuController,
Expand Down Expand Up @@ -504,6 +510,10 @@ class _NakedSelectState<T> extends State<NakedSelect<T>>

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,
Expand Down
2 changes: 1 addition & 1 deletion packages/naked_ui/lib/src/naked_widgets.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
28 changes: 28 additions & 0 deletions packages/naked_ui/test/semantics/naked_button_semantics_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
}
Loading
Loading