mirror of
https://github.com/bevyengine/bevy.git
synced 2026-05-06 06:06:42 -04:00
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:
@@ -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.
|
||||
Reference in New Issue
Block a user