diff --git a/crates/bevy_ui_widgets/src/radio.rs b/crates/bevy_ui_widgets/src/radio.rs index 1dac539782..ec625bc0a7 100644 --- a/crates/bevy_ui_widgets/src/radio.rs +++ b/crates/bevy_ui_widgets/src/radio.rs @@ -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 / +/// +/// The widget emits a [`ValueChange`] 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:: { + source: next_id, + value: true, + }); + // Trigger the on_change event for the newly checked radio button on radio group commands.trigger(ValueChange:: { 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>, +// Provides functionality for standalone focusable [`RadioButton`] to react +// on `Space` or `Enter` key press. +fn radio_button_on_key_input( + mut ev: On>, + q_radio_button: Query<(Has, Has), With>, q_group: Query<(), With>, - q_radio: Query<(Has, Has), With>, 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::>(); - - 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>, + q_group: Query<(), With>, + q_radio: Query<(Has, Has), With>, + 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>, + q_parents: &Query<&ChildOf>, + commands: &mut Commands, +) { + commands.trigger(ValueChange:: { + 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:: { - 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); } } diff --git a/release-content/release-notes/radio_group_and_button_widget_improvements.md b/release-content/release-notes/radio_group_and_button_widget_improvements.md new file mode 100644 index 0000000000..6fe62ec232 --- /dev/null +++ b/release-content/release-notes/radio_group_and_button_widget_improvements.md @@ -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` 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.