Make Radio Button behaviour modular and consistent with other widgets (#21294)

# Objective

Fixes https://github.com/bevyengine/bevy/issues/21261

## Solution

Changes:
- Detect events directly on radio buttons,
- Make RadioGroup optional,
- ValueChange events are triggered on checked radio button and
RadioGroup.

This makes radio button behavior:
- similar to other widgets, where we can observe triggered change
directly on widget,
- radio button widget can function separately,
- modular, users can decide if they want to use RadioGroup or want to
roll out their own solution.

Current behavior in examples doesn't change with this PR. 

## Testing

Tested using existing examples. See `feathers` example, behavior doesn't
change.
Additionally, tested in bevy_immediate where widget consistency is
useful.

---------

Co-authored-by: Alice Cecile <alice.i.cecile@gmail.com>
This commit is contained in:
Pēteris Pakalns
2025-10-13 21:20:16 +03:00
committed by GitHub
parent d5e450c74d
commit 0d6cb16e52
2 changed files with 110 additions and 60 deletions
+90 -60
View File
@@ -39,12 +39,18 @@ use crate::ValueChange;
#[require(AccessibilityNode(accesskit::Node::new(Role::RadioGroup)))]
pub struct RadioGroup;
/// Headless widget implementation for radio buttons. These should be enclosed within a
/// [`RadioGroup`] widget, which is responsible for the mutual exclusion logic.
/// Headless widget implementation for radio buttons. They can be used independently,
/// but enclosing them in a [`RadioGroup`] widget allows them to behave as a single,
/// mutually exclusive unit.
///
/// According to the WAI-ARIA best practices document, radio buttons should not be focusable,
/// but rather the enclosing group should be focusable.
/// See <https://www.w3.org/WAI/ARIA/apg/patterns/radio>/
///
/// The widget emits a [`ValueChange<bool>`] event with the value `true` whenever it becomes checked,
/// either through a mouse click or when a [`RadioGroup`] checks the widget.
/// If the [`RadioButton`] is focusable, it can also be checked using the `Enter` or `Space` keys,
/// in which case the event will likewise be emitted.
#[derive(Component, Debug)]
#[require(AccessibilityNode(accesskit::Node::new(Role::RadioButton)), Checkable)]
#[derive(Reflect)]
@@ -132,7 +138,12 @@ fn radio_group_on_key_input(
let (next_id, _) = radio_buttons[next_index];
// Trigger the on_change event for the newly checked radio button
// Trigger the value change event on the radio button
commands.trigger(ValueChange::<bool> {
source: next_id,
value: true,
});
// Trigger the on_change event for the newly checked radio button on radio group
commands.trigger(ValueChange::<Entity> {
source: ev.focused_entity,
value: next_id,
@@ -141,82 +152,101 @@ fn radio_group_on_key_input(
}
}
fn radio_group_on_button_click(
mut ev: On<Pointer<Click>>,
// Provides functionality for standalone focusable [`RadioButton`] to react
// on `Space` or `Enter` key press.
fn radio_button_on_key_input(
mut ev: On<FocusedInput<KeyboardInput>>,
q_radio_button: Query<(Has<InteractionDisabled>, Has<Checked>), With<RadioButton>>,
q_group: Query<(), With<RadioGroup>>,
q_radio: Query<(Has<Checked>, Has<InteractionDisabled>), With<RadioButton>>,
q_parents: Query<&ChildOf>,
q_children: Query<&Children>,
mut commands: Commands,
) {
if q_group.contains(ev.entity) {
// Starting with the original target, search upward for a radio button.
let radio_id = if q_radio.contains(ev.original_event_target()) {
ev.original_event_target()
} else {
// Search ancestors for the first radio button
let mut found_radio = None;
for ancestor in q_parents.iter_ancestors(ev.original_event_target()) {
if q_group.contains(ancestor) {
// We reached a radio group before finding a radio button, bail out
return;
}
if q_radio.contains(ancestor) {
found_radio = Some(ancestor);
break;
}
}
let Ok((disabled, checked)) = q_radio_button.get(ev.focused_entity) else {
// Not a radio button
return;
};
match found_radio {
Some(radio) => radio,
None => return, // No radio button found in the ancestor chain
}
};
// Radio button is disabled.
if q_radio.get(radio_id).unwrap().1 {
return;
}
// Gather all the enabled radio group descendants for exclusion.
let radio_buttons = q_children
.iter_descendants(ev.entity)
.filter_map(|child_id| match q_radio.get(child_id) {
Ok((checked, false)) => Some((child_id, checked)),
Ok((_, true)) | Err(_) => None,
})
.collect::<Vec<_>>();
if radio_buttons.is_empty() {
return; // No enabled radio buttons in the group
}
// Pick out the radio button that is currently checked.
let event = &ev.event().input;
if event.state == ButtonState::Pressed
&& !event.repeat
&& (event.key_code == KeyCode::Enter || event.key_code == KeyCode::Space)
{
ev.propagate(false);
let current_radio = radio_buttons
.iter()
.find(|(_, checked)| *checked)
.map(|(id, _)| *id);
if current_radio == Some(radio_id) {
// If they clicked the currently checked radio button, do nothing
// Radio button is disabled or already checked
if disabled || checked {
return;
}
// Trigger the on_change event for the newly checked radio button
trigger_radio_button_and_radio_group_value_change(
ev.focused_entity,
&q_group,
&q_parents,
&mut commands,
);
}
}
fn radio_button_on_click(
mut ev: On<Pointer<Click>>,
q_group: Query<(), With<RadioGroup>>,
q_radio: Query<(Has<InteractionDisabled>, Has<Checked>), With<RadioButton>>,
q_parents: Query<&ChildOf>,
mut commands: Commands,
) {
let Ok((disabled, checked)) = q_radio.get(ev.entity) else {
// Not a radio button
return;
};
ev.propagate(false);
// Radio button is disabled or already checked
if disabled || checked {
return;
}
trigger_radio_button_and_radio_group_value_change(
ev.entity,
&q_group,
&q_parents,
&mut commands,
);
}
fn trigger_radio_button_and_radio_group_value_change(
radio_button: Entity,
q_group: &Query<(), With<RadioGroup>>,
q_parents: &Query<&ChildOf>,
commands: &mut Commands,
) {
commands.trigger(ValueChange::<bool> {
source: radio_button,
value: true,
});
// Find if radio button is inside radio group
let radio_group = q_parents
.iter_ancestors(radio_button)
.find(|ancestor| q_group.contains(*ancestor));
// If is inside radio group
if let Some(radio_group) = radio_group {
// Trigger event for radio group
commands.trigger(ValueChange::<Entity> {
source: ev.entity,
value: radio_id,
source: radio_group,
value: radio_button,
});
}
}
/// Plugin that adds the observers for the [`RadioGroup`] widget.
/// Plugin that adds the observers for [`RadioButton`] and [`RadioGroup`] widget.
pub struct RadioGroupPlugin;
impl Plugin for RadioGroupPlugin {
fn build(&self, app: &mut App) {
app.add_observer(radio_group_on_key_input)
.add_observer(radio_group_on_button_click);
.add_observer(radio_button_on_click)
.add_observer(radio_button_on_key_input);
}
}
@@ -0,0 +1,20 @@
---
title: "`RadioButton`, `RadioGroup` widget minor improvements"
authors: ["@PPakalns"]
pull_requests: [21294]
---
`RadioButton` and `RadioGroup` usage remains fully backward compatible.
Improvements:
- Event propagation from user interactions will now be canceled even if
widgets are disabled. Previously, some relevant event propagation
was not properly canceled.
- `RadioButton` now emits a `ValueChange<bool>` entity event when checked,
even when checked via a `RadioGroup`. Consistent with other `Checkable` widgets.
As a `RadioButton` cannot be unchecked through direct user interaction with this widget,
a `ValueChange` event with value `false` can not be triggered for `RadioButton`.
- If a `RadioButton` is focusable, a value change event can be triggered
using the **Space** or **Enter** keys when focused.
- `RadioGroup` is now optional and can be replaced with a custom implementation.