Skip to content

Commit dbc1432

Browse files
committed
Make Radio Button behaviour modular and consistent with other widgets
1 parent abdf657 commit dbc1432

File tree

1 file changed

+92
-62
lines changed

1 file changed

+92
-62
lines changed

crates/bevy_ui_widgets/src/radio.rs

Lines changed: 92 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ use bevy_ecs::{
66
entity::Entity,
77
hierarchy::{ChildOf, Children},
88
observer::On,
9-
query::{Has, With},
9+
query::{Has, With, Without},
1010
reflect::ReflectComponent,
1111
system::{Commands, Query},
1212
};
@@ -39,12 +39,18 @@ use crate::ValueChange;
3939
#[require(AccessibilityNode(accesskit::Node::new(Role::RadioGroup)))]
4040
pub struct RadioGroup;
4141

42-
/// Headless widget implementation for radio buttons. These should be enclosed within a
43-
/// [`RadioGroup`] widget, which is responsible for the mutual exclusion logic.
42+
/// Headless widget implementation for radio buttons. They can be used independently,
43+
/// but enclosing them in a [`RadioGroup`] widget allows them to behave as a single,
44+
/// mutually exclusive unit.
4445
///
4546
/// According to the WAI-ARIA best practices document, radio buttons should not be focusable,
4647
/// but rather the enclosing group should be focusable.
4748
/// See <https://www.w3.org/WAI/ARIA/apg/patterns/radio>/
49+
///
50+
/// The widget emits a [`ValueChange<bool>`] event with the value `true` whenever it becomes checked,
51+
/// either through a mouse click or when a [`RadioGroup`] checks the widget.
52+
/// If the [`RadioButton`] is focusable, it can also be checked using the `Enter` or `Space` keys,
53+
/// in which case the event will likewise be emitted.
4854
#[derive(Component, Debug)]
4955
#[require(AccessibilityNode(accesskit::Node::new(Role::RadioButton)), Checkable)]
5056
#[derive(Reflect)]
@@ -132,7 +138,12 @@ fn radio_group_on_key_input(
132138

133139
let (next_id, _) = radio_buttons[next_index];
134140

135-
// Trigger the on_change event for the newly checked radio button
141+
// Trigger the value change event on the radio button
142+
commands.trigger(ValueChange::<bool> {
143+
source: next_id,
144+
value: true,
145+
});
146+
// Trigger the on_change event for the newly checked radio button on radio group
136147
commands.trigger(ValueChange::<Entity> {
137148
source: ev.focused_entity,
138149
value: next_id,
@@ -141,82 +152,101 @@ fn radio_group_on_key_input(
141152
}
142153
}
143154

144-
fn radio_group_on_button_click(
145-
mut ev: On<Pointer<Click>>,
155+
// Provides functionality for standalone focusable [`RadioButton`] to react
156+
// on `Space` or `Enter` key press.
157+
fn radio_button_on_key_input(
158+
mut ev: On<FocusedInput<KeyboardInput>>,
159+
q_radio_button: Query<Has<Checked>, (With<RadioButton>, Without<InteractionDisabled>)>,
146160
q_group: Query<(), With<RadioGroup>>,
147-
q_radio: Query<(Has<Checked>, Has<InteractionDisabled>), With<RadioButton>>,
148161
q_parents: Query<&ChildOf>,
149-
q_children: Query<&Children>,
150162
mut commands: Commands,
151163
) {
152-
if q_group.contains(ev.entity) {
153-
// Starting with the original target, search upward for a radio button.
154-
let radio_id = if q_radio.contains(ev.original_event_target()) {
155-
ev.original_event_target()
156-
} else {
157-
// Search ancestors for the first radio button
158-
let mut found_radio = None;
159-
for ancestor in q_parents.iter_ancestors(ev.original_event_target()) {
160-
if q_group.contains(ancestor) {
161-
// We reached a radio group before finding a radio button, bail out
162-
return;
163-
}
164-
if q_radio.contains(ancestor) {
165-
found_radio = Some(ancestor);
166-
break;
167-
}
168-
}
164+
let Ok(checked) = q_radio_button.get(ev.focused_entity) else {
165+
// Not a radio button
166+
return;
167+
};
168+
169+
// Radio button already checked
170+
if checked {
171+
return;
172+
}
169173

170-
match found_radio {
171-
Some(radio) => radio,
172-
None => return, // No radio button found in the ancestor chain
173-
}
174-
};
174+
let event = &ev.event().input;
175+
if event.state == ButtonState::Pressed
176+
&& !event.repeat
177+
&& (event.key_code == KeyCode::Enter || event.key_code == KeyCode::Space)
178+
{
179+
ev.propagate(false);
175180

176-
// Radio button is disabled.
177-
if q_radio.get(radio_id).unwrap().1 {
178-
return;
179-
}
181+
trigger_radio_button_and_radio_group_value_change(
182+
ev.focused_entity,
183+
&q_group,
184+
&q_parents,
185+
&mut commands,
186+
);
187+
}
188+
}
180189

181-
// Gather all the enabled radio group descendants for exclusion.
182-
let radio_buttons = q_children
183-
.iter_descendants(ev.entity)
184-
.filter_map(|child_id| match q_radio.get(child_id) {
185-
Ok((checked, false)) => Some((child_id, checked)),
186-
Ok((_, true)) | Err(_) => None,
187-
})
188-
.collect::<Vec<_>>();
189-
190-
if radio_buttons.is_empty() {
191-
return; // No enabled radio buttons in the group
192-
}
190+
fn radio_button_on_click(
191+
mut ev: On<Pointer<Click>>,
192+
q_group: Query<(), With<RadioGroup>>,
193+
q_radio: Query<Has<Checked>, (With<RadioButton>, Without<InteractionDisabled>)>,
194+
q_parents: Query<&ChildOf>,
195+
mut commands: Commands,
196+
) {
197+
let Ok(checked) = q_radio.get(ev.entity) else {
198+
// Not a radio button
199+
return;
200+
};
193201

194-
// Pick out the radio button that is currently checked.
195-
ev.propagate(false);
196-
let current_radio = radio_buttons
197-
.iter()
198-
.find(|(_, checked)| *checked)
199-
.map(|(id, _)| *id);
200-
201-
if current_radio == Some(radio_id) {
202-
// If they clicked the currently checked radio button, do nothing
203-
return;
204-
}
202+
ev.propagate(false);
203+
204+
// Radio button is already checked
205+
if checked {
206+
return;
207+
}
205208

206-
// Trigger the on_change event for the newly checked radio button
209+
trigger_radio_button_and_radio_group_value_change(
210+
ev.entity,
211+
&q_group,
212+
&q_parents,
213+
&mut commands,
214+
);
215+
}
216+
217+
fn trigger_radio_button_and_radio_group_value_change(
218+
radio_button: Entity,
219+
q_group: &Query<(), With<RadioGroup>>,
220+
q_parents: &Query<&ChildOf>,
221+
commands: &mut Commands,
222+
) {
223+
commands.trigger(ValueChange::<bool> {
224+
source: radio_button,
225+
value: true,
226+
});
227+
228+
// Find if radio button is inside radio group
229+
let radio_group = q_parents
230+
.iter_ancestors(radio_button)
231+
.find(|ancestor| q_group.contains(*ancestor));
232+
233+
// If is inside radio group
234+
if let Some(radio_group) = radio_group {
235+
// Trigger event for radio group
207236
commands.trigger(ValueChange::<Entity> {
208-
source: ev.entity,
209-
value: radio_id,
237+
source: radio_group,
238+
value: radio_button,
210239
});
211240
}
212241
}
213242

214-
/// Plugin that adds the observers for the [`RadioGroup`] widget.
243+
/// Plugin that adds the observers for [`RadioButton`] and [`RadioGroup`] widget.
215244
pub struct RadioGroupPlugin;
216245

217246
impl Plugin for RadioGroupPlugin {
218247
fn build(&self, app: &mut App) {
219248
app.add_observer(radio_group_on_key_input)
220-
.add_observer(radio_group_on_button_click);
249+
.add_observer(radio_button_on_click)
250+
.add_observer(radio_button_on_key_input);
221251
}
222252
}

0 commit comments

Comments
 (0)