Skip to main content

tessera_components/
radio_button.rs

1//! Material Design 3 radio button with animated selection feedback.
2//!
3//! ## Usage
4//!
5//! Add single-choice selectors to forms, filters, and settings panes.
6
7use std::time::Duration;
8
9use tessera_foundation::gesture::TapRecognizer;
10use tessera_ui::{
11    AccessibilityActionHandler, AccessibilityNode, AxisConstraint, Callback, CallbackWith, Color,
12    Dp, FocusDirection, FocusManager, FocusRequester, FocusState, FocusTraversalPolicy, Modifier,
13    Px, PxSize, RenderSlotWith, SemanticsModifierNode, State,
14    accesskit::{Orientation, Role, Toggled},
15    current_frame_nanos,
16    layout::layout,
17    modifier::{FocusModifierExt as _, ModifierCapabilityExt as _},
18    receive_frame_nanos, remember, tessera, use_context,
19};
20
21use crate::{
22    alignment::Alignment,
23    animation,
24    boxed::boxed,
25    modifier::{InteractionState, ModifierExt as _, PointerEventContext, SelectableArgs},
26    ripple_state::{RippleSpec, RippleState},
27    shape_def::Shape,
28    surface::{SurfaceStyle, surface},
29    theme::{MaterialAlpha, MaterialTheme},
30};
31
32const RADIO_ANIMATION_DURATION: Duration = Duration::from_millis(200);
33
34#[cfg(all(test, not(target_family = "wasm")))]
35mod tests;
36
37struct RadioSemantics {
38    group: bool,
39    selected: bool,
40    label: Option<String>,
41    description: Option<String>,
42    orientation: Option<Orientation>,
43}
44
45impl SemanticsModifierNode for RadioSemantics {
46    fn apply(
47        &self,
48        node: &mut AccessibilityNode,
49        handler: &mut Option<AccessibilityActionHandler>,
50    ) {
51        node.role = Some(if self.group {
52            Role::RadioGroup
53        } else {
54            Role::RadioButton
55        });
56        if let Some(label) = &self.label {
57            node.label = Some(label.clone());
58        }
59        if let Some(description) = &self.description {
60            node.description = Some(description.clone());
61        }
62        if let Some(orientation) = self.orientation {
63            node.orientation = Some(orientation);
64        }
65        if !self.group {
66            node.toggled = Some(if self.selected {
67                Toggled::True
68            } else {
69                Toggled::False
70            });
71            node.disabled = true;
72            node.focusable = false;
73            node.actions.clear();
74            *handler = None;
75        }
76    }
77}
78
79/// Selection coordination shared explicitly by the choices of a radio group.
80#[derive(Clone, Copy, PartialEq)]
81pub struct RadioGroupSelection {
82    selected: State<Option<State<RadioButtonController>>>,
83}
84
85fn claim_group_selection(
86    group: State<Option<State<RadioButtonController>>>,
87    controller: State<RadioButtonController>,
88) {
89    if group.with(|selected| *selected == Some(controller)) {
90        return;
91    }
92    let previous = group.with_mut(|selected| selected.replace(controller));
93    if let Some(previous) = previous
94        && previous.is_alive()
95    {
96        previous.with_mut(|controller| controller.set_selected(false));
97    }
98}
99
100/// Physical orientation announced for [`radio_group`].
101#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
102pub enum RadioGroupOrientation {
103    /// Announce a vertical group; all four arrows navigate the choices.
104    #[default]
105    Vertical,
106    /// Announce a horizontal group; all four arrows navigate the choices.
107    Horizontal,
108}
109
110/// Material Design 3 defaults for [`radio_button`].
111pub struct RadioButtonDefaults;
112
113impl RadioButtonDefaults {
114    /// State-layer size used for hover/press feedback.
115    pub const STATE_LAYER_SIZE: Dp = Dp(40.0);
116}
117
118/// Shared state for the `radio_button` component, including selection
119/// animation.
120#[derive(Clone, PartialEq)]
121pub struct RadioButtonController {
122    selected: bool,
123    progress: f32,
124    start_progress: f32,
125    last_change_frame_nanos: Option<u64>,
126}
127
128impl Default for RadioButtonController {
129    fn default() -> Self {
130        Self::new(false)
131    }
132}
133
134impl RadioButtonController {
135    /// Creates a new radio button state with the given initial selection.
136    pub fn new(selected: bool) -> Self {
137        let progress = if selected { 1.0 } else { 0.0 };
138        Self {
139            selected,
140            progress,
141            start_progress: progress,
142            last_change_frame_nanos: None,
143        }
144    }
145
146    /// Returns whether the radio button is currently selected.
147    pub fn is_selected(&self) -> bool {
148        self.selected
149    }
150
151    /// Sets the selection state, starting an animation when the value changes.
152    pub fn set_selected(&mut self, selected: bool) {
153        if self.selected != selected {
154            self.selected = selected;
155            self.start_progress = self.progress;
156            self.last_change_frame_nanos = Some(current_frame_nanos());
157        }
158    }
159
160    /// Marks the radio button as selected, returning `true` if this triggered a
161    /// state change.
162    pub fn select(&mut self) -> bool {
163        if self.selected {
164            return false;
165        }
166        self.set_selected(true);
167        true
168    }
169
170    fn update_animation(&mut self, frame_nanos: u64) {
171        if let Some(start_frame_nanos) = self.last_change_frame_nanos {
172            let elapsed_nanos = frame_nanos.saturating_sub(start_frame_nanos);
173            let animation_nanos = RADIO_ANIMATION_DURATION.as_nanos().min(u64::MAX as u128) as u64;
174            let fraction = (elapsed_nanos as f32 / animation_nanos as f32).min(1.0);
175            let target = if self.selected { 1.0 } else { 0.0 };
176            self.progress = self.start_progress + (target - self.start_progress) * fraction;
177            if fraction >= 1.0 {
178                self.last_change_frame_nanos = None;
179                self.progress = target;
180                self.start_progress = target;
181            }
182        }
183    }
184
185    fn animation_progress(&self) -> f32 {
186        self.progress
187    }
188
189    fn is_animating(&self) -> bool {
190        self.last_change_frame_nanos.is_some()
191    }
192}
193
194fn interpolate_color(a: Color, b: Color, t: f32) -> Color {
195    let factor = t.clamp(0.0, 1.0);
196    Color {
197        r: a.r + (b.r - a.r) * factor,
198        g: a.g + (b.g - a.g) * factor,
199        b: a.b + (b.b - a.b) * factor,
200        a: a.a + (b.a - a.a) * factor,
201    }
202}
203
204/// # radio_group
205///
206/// Provide directional keyboard navigation for a single-choice group of radio
207/// buttons.
208///
209/// ## Usage
210///
211/// Use around related radio buttons so arrow keys move and select within the
212/// group while pointer selection waits for a completed click. Tab enters at the
213/// selected enabled choice, or the first available choice, and then leaves the
214/// whole group.
215/// Omit `selected` on every choice for automatic mutual exclusion, or control
216/// every choice from the same application selection value.
217///
218/// ## Parameters
219///
220/// - `modifier` — modifier chain applied to the group container.
221/// - `orientation` — physical direction announced to assistive technology;
222///   defaults to vertical. All four arrows navigate in declaration order.
223/// - `wrap` — whether arrow traversal wraps at either end; defaults to `true`.
224/// - `accessibility_label` — optional name for the related choices.
225/// - `accessibility_description` — optional additional group instructions.
226/// - `content` — content receiving the selection handle for its choices.
227///
228/// ## Examples
229///
230/// ```rust
231/// use tessera_components::radio_button::{radio_button, radio_group};
232/// use tessera_ui::{remember, tessera};
233///
234/// #[tessera]
235/// fn radio_group_demo() {
236///     let selected = remember(|| 0usize);
237///     radio_group().content(move |group| {
238///         radio_button()
239///             .group(group)
240///             .selected(selected.get() == 0)
241///             .on_select({ move |_| selected.set(0) });
242///         radio_button()
243///             .group(group)
244///             .selected(selected.get() == 1)
245///             .on_select({ move |_| selected.set(1) });
246///     });
247/// }
248/// ```
249#[tessera]
250pub fn radio_group(
251    modifier: Option<Modifier>,
252    orientation: Option<RadioGroupOrientation>,
253    wrap: Option<bool>,
254    #[prop(into)] accessibility_label: Option<String>,
255    #[prop(into)] accessibility_description: Option<String>,
256    content: Option<RenderSlotWith<RadioGroupSelection>>,
257) {
258    let modifier = modifier.unwrap_or_default();
259    let wrap = wrap.unwrap_or(true);
260    let selected = remember(|| None::<State<RadioButtonController>>);
261    let orientation = match orientation.unwrap_or_default() {
262        RadioGroupOrientation::Horizontal => Orientation::Horizontal,
263        RadioGroupOrientation::Vertical => Orientation::Vertical,
264    };
265    let modifier = modifier.focus_group().focus_traversal_policy(
266        FocusTraversalPolicy::linear()
267            .wrap(wrap)
268            .single_tab_stop(true),
269    );
270    let modifier = modifier.push_semantics(RadioSemantics {
271        group: true,
272        selected: false,
273        label: accessibility_label,
274        description: accessibility_description,
275        orientation: Some(orientation),
276    });
277    layout().modifier(modifier).child(move || {
278        if let Some(content) = content {
279            content.render(RadioGroupSelection { selected });
280        }
281    });
282}
283
284/// # radio_button
285///
286/// Render a Material Design 3 radio button with a smooth animated selection
287/// dot.
288///
289/// ## Usage
290///
291/// Use in single-choice groups where exactly one option should be active.
292///
293/// ## Parameters
294///
295/// - `modifier` — optional modifier chain applied to the radio button subtree.
296/// - `focus_requester` — optional handle targeting this option for focus
297///   requests.
298/// - `on_select` — optional callback invoked when the radio transitions to the
299///   selected state.
300/// - `group` — selection handle received from the enclosing radio group.
301/// - `selected` — externally controlled selection; omit on every choice to let
302///   its radio group coordinate the controllers.
303/// - `size` — optional visual diameter of the radio glyph.
304/// - `touch_target_size` — optional minimum interactive touch target.
305/// - `stroke_width` — optional stroke width applied to the outer ring.
306/// - `dot_size` — optional diameter of the inner dot when fully selected.
307/// - `selected_color` — optional ring and dot color when selected.
308/// - `unselected_color` — optional ring color override for all unselected
309///   states.
310/// - `disabled_selected_color` — optional ring and dot color when disabled but
311///   selected.
312/// - `disabled_unselected_color` — optional ring color when disabled and not
313///   selected.
314/// - `enabled` — whether the control is interactive.
315/// - `accessibility_label` — optional accessibility label.
316/// - `accessibility_description` — optional accessibility description.
317/// - `controller` — optional external controller for selection state and
318///   animation.
319///
320/// ## Examples
321///
322/// ```rust
323/// use tessera_components::radio_button::radio_button;
324/// use tessera_ui::tessera;
325///
326/// #[tessera]
327/// fn radio_demo() {
328///     radio_button().selected(true);
329/// }
330/// ```
331#[tessera]
332pub fn radio_button(
333    modifier: Option<Modifier>,
334    focus_requester: Option<FocusRequester>,
335    on_select: Option<CallbackWith<bool, ()>>,
336    selected: Option<bool>,
337    group: Option<RadioGroupSelection>,
338    size: Option<Dp>,
339    touch_target_size: Option<Dp>,
340    stroke_width: Option<Dp>,
341    dot_size: Option<Dp>,
342    selected_color: Option<Color>,
343    unselected_color: Option<Color>,
344    disabled_selected_color: Option<Color>,
345    disabled_unselected_color: Option<Color>,
346    enabled: Option<bool>,
347    #[prop(into)] accessibility_label: Option<String>,
348    #[prop(into)] accessibility_description: Option<String>,
349    controller: Option<State<RadioButtonController>>,
350) {
351    let modifier = modifier.unwrap_or_default();
352    let enabled = enabled.unwrap_or(true);
353    let scheme = use_context::<MaterialTheme>()
354        .expect("MaterialTheme must be provided")
355        .get()
356        .color_scheme;
357    let size = size.unwrap_or(Dp(20.0));
358    let touch_target_size = touch_target_size.unwrap_or(Dp(48.0));
359    let stroke_width = stroke_width.unwrap_or(Dp(2.0));
360    let dot_size = dot_size.unwrap_or(Dp(10.0));
361    let selected_color = selected_color.unwrap_or(scheme.primary);
362    let unselected_interaction_color = unselected_color.unwrap_or(scheme.on_surface);
363    let unselected_color = unselected_color.unwrap_or(scheme.on_surface_variant);
364    let disabled_selected_color = disabled_selected_color.unwrap_or(
365        scheme
366            .on_surface
367            .with_alpha(MaterialAlpha::DISABLED_CONTENT),
368    );
369    let disabled_unselected_color = disabled_unselected_color.unwrap_or(
370        scheme
371            .on_surface
372            .with_alpha(MaterialAlpha::DISABLED_CONTENT),
373    );
374    let on_select = on_select.unwrap_or_else(CallbackWith::default_value);
375
376    let controller = if let Some(controller) = controller {
377        controller
378    } else {
379        remember(|| RadioButtonController::new(selected.unwrap_or(false)))
380    };
381
382    if let Some(selected) = selected
383        && controller.with(|c| c.is_selected()) != selected
384    {
385        controller.with_mut(|c| c.set_selected(selected));
386    }
387
388    radio_button_inner()
389        .focus_requester_optional(focus_requester)
390        .modifier(modifier)
391        .on_select_shared(on_select)
392        .selected_optional(selected)
393        .group_optional(group)
394        .size(size)
395        .touch_target_size(touch_target_size)
396        .stroke_width(stroke_width)
397        .dot_size(dot_size)
398        .selected_color(selected_color)
399        .unselected_color(unselected_color)
400        .unselected_interaction_color(unselected_interaction_color)
401        .disabled_selected_color(disabled_selected_color)
402        .disabled_unselected_color(disabled_unselected_color)
403        .enabled(enabled)
404        .controller(controller)
405        .accessibility_label_optional(accessibility_label)
406        .accessibility_description_optional(accessibility_description);
407}
408
409#[tessera]
410fn radio_button_inner(
411    modifier: Option<Modifier>,
412    focus_requester: Option<FocusRequester>,
413    on_select: Option<CallbackWith<bool, ()>>,
414    selected: Option<bool>,
415    group: Option<RadioGroupSelection>,
416    size: Option<Dp>,
417    touch_target_size: Option<Dp>,
418    stroke_width: Option<Dp>,
419    dot_size: Option<Dp>,
420    selected_color: Option<Color>,
421    unselected_color: Option<Color>,
422    unselected_interaction_color: Option<Color>,
423    disabled_selected_color: Option<Color>,
424    disabled_unselected_color: Option<Color>,
425    enabled: Option<bool>,
426    accessibility_label: Option<String>,
427    accessibility_description: Option<String>,
428    controller: Option<State<RadioButtonController>>,
429) {
430    let modifier = modifier.unwrap_or_default();
431    let size = size.unwrap_or(Dp(20.0));
432    let touch_target_size = touch_target_size.unwrap_or(Dp(48.0));
433    let stroke_width = stroke_width.unwrap_or(Dp(2.0));
434    let dot_size = dot_size.unwrap_or(Dp(10.0));
435    let selected_color = selected_color.unwrap_or(Color::TRANSPARENT);
436    let unselected_color = unselected_color.unwrap_or(Color::TRANSPARENT);
437    let unselected_interaction_color = unselected_interaction_color.unwrap_or(unselected_color);
438    let disabled_selected_color = disabled_selected_color.unwrap_or(Color::TRANSPARENT);
439    let disabled_unselected_color = disabled_unselected_color.unwrap_or(Color::TRANSPARENT);
440    let enabled = enabled.unwrap_or(true);
441    let radio_group = group;
442    let controller = controller.expect("radio_button_inner requires controller to be set");
443    let group_selection = if selected.is_none() {
444        radio_group.map(|group| group.selected)
445    } else {
446        None
447    };
448    if let Some(group_selection) = group_selection
449        && controller.with(|controller| controller.is_selected())
450    {
451        claim_group_selection(group_selection, controller);
452    }
453    let interaction_state = if enabled {
454        Some(remember(InteractionState::new))
455    } else {
456        None
457    };
458    let ripple_state = if enabled {
459        Some(remember(RippleState::new))
460    } else {
461        None
462    };
463    let tap_recognizer = if enabled {
464        Some(remember(TapRecognizer::default))
465    } else {
466        None
467    };
468    if controller.with(|c| c.is_animating()) {
469        receive_frame_nanos(move |frame_nanos| {
470            let is_animating = controller.with_mut(|controller| {
471                controller.update_animation(frame_nanos);
472                controller.is_animating()
473            });
474            if is_animating {
475                tessera_ui::FrameNanosControl::Continue
476            } else {
477                tessera_ui::FrameNanosControl::Stop
478            }
479        });
480    }
481    let progress = controller.with(|c| c.animation_progress());
482    let eased_progress = animation::easing(progress);
483    let is_selected = controller.with(|c| c.is_selected());
484    let on_select = on_select.unwrap_or_else(CallbackWith::default_value);
485
486    let target_size = Dp(touch_target_size.0.max(size.0));
487
488    let unselected_color = if enabled
489        && interaction_state.is_some_and(|state| {
490            state.with(|state| state.is_hovered() || state.is_focused() || state.is_pressed())
491        }) {
492        unselected_interaction_color
493    } else {
494        unselected_color
495    };
496    let ring_color = if enabled {
497        interpolate_color(unselected_color, selected_color, progress)
498    } else if is_selected {
499        disabled_selected_color
500    } else {
501        disabled_unselected_color
502    };
503
504    let ripple_color = ring_color;
505
506    let target_dot_color = if enabled {
507        selected_color
508    } else {
509        disabled_selected_color
510    };
511    let active_dot_color = interpolate_color(Color::TRANSPARENT, target_dot_color, eased_progress);
512
513    let on_click = Callback::new(move || {
514        if let Some(group_selection) = group_selection {
515            claim_group_selection(group_selection, controller);
516        }
517        if controller.with_mut(|c| c.select()) {
518            on_select.call(true);
519        }
520    });
521
522    let state_layer_size = RadioButtonDefaults::STATE_LAYER_SIZE;
523    let state_layer_radius = Dp(state_layer_size.0 / 2.0);
524
525    let mut modifier = modifier.size(target_size, target_size);
526    if enabled {
527        let ripple_spec = RippleSpec {
528            bounded: false,
529            radius: Some(state_layer_radius),
530        };
531        let ripple_size = PxSize::new(state_layer_size.to_px(), state_layer_size.to_px());
532        let press_handler = ripple_state.map(|state| {
533            let spec = ripple_spec;
534            let size = ripple_size;
535            move |ctx: PointerEventContext| {
536                state.with_mut(|s| s.start_animation_with_spec(ctx.normalized_pos, size, spec));
537            }
538        });
539        let release_handler = ripple_state
540            .map(|state| move |_ctx: PointerEventContext| state.with_mut(|s| s.release()));
541        let selectable_args = SelectableArgs {
542            focus_requester,
543            selected: is_selected,
544            on_click,
545            enabled: true,
546            role: Some(Role::RadioButton),
547            label: accessibility_label.clone(),
548            description: accessibility_description.clone(),
549            interaction_state,
550            on_press: press_handler.map(Into::into),
551            on_release: release_handler.map(Into::into),
552            tap_recognizer,
553            ..Default::default()
554        };
555        modifier = modifier.selectable_with(selectable_args);
556        if radio_group.is_some() {
557            modifier = modifier.preferred_tab_stop(is_selected).on_focus_changed(
558                move |focus_state: FocusState| {
559                    let pointer_pressed = interaction_state
560                        .is_some_and(|state| state.with(|state| state.is_pressed()));
561                    if let Some(interaction_state) = interaction_state {
562                        interaction_state
563                            .with_mut(|state| state.set_focused(focus_state.has_focus()));
564                    }
565                    let directional = matches!(
566                        FocusManager::current().focus_change_direction(),
567                        Some(
568                            FocusDirection::Left
569                                | FocusDirection::Right
570                                | FocusDirection::Up
571                                | FocusDirection::Down
572                        )
573                    );
574                    if focus_state.is_focused() && directional && !pointer_pressed {
575                        on_click.call();
576                    }
577                },
578            );
579        }
580    } else {
581        modifier = modifier.push_semantics(RadioSemantics {
582            group: false,
583            selected: is_selected,
584            label: accessibility_label,
585            description: accessibility_description,
586            orientation: None,
587        });
588    }
589
590    boxed()
591        .modifier(modifier)
592        .alignment(Alignment::Center)
593        .children(move || {
594            surface()
595                .modifier(Modifier::new().size(state_layer_size, state_layer_size))
596                .shape(Shape::Ellipse)
597                .enabled(enabled)
598                .style(SurfaceStyle::Filled {
599                    color: Color::TRANSPARENT,
600                })
601                .ripple_bounded(false)
602                .ripple_radius(state_layer_radius)
603                .ripple_color(ripple_color)
604                .interaction_state_optional(interaction_state)
605                .ripple_state_optional(ripple_state)
606                .child({
607                    move || {
608                        boxed()
609                            .alignment(Alignment::Center)
610                            .modifier(Modifier::new().fill_max_size())
611                            .children(move || {
612                                surface()
613                                    .modifier(Modifier::new().size(size, size))
614                                    .shape(Shape::Ellipse)
615                                    .style(SurfaceStyle::Outlined {
616                                        color: ring_color,
617                                        width: stroke_width,
618                                    })
619                                    .child({
620                                        move || {
621                                            let animated_size = (dot_size.to_px().0 as f32
622                                                * eased_progress)
623                                                .round()
624                                                as i32;
625                                            if animated_size > 0 {
626                                                boxed()
627                                                    .alignment(Alignment::Center)
628                                                    .modifier(Modifier::new().size(size, size))
629                                                    .children(move || {
630                                                        surface()
631                                                            .modifier(Modifier::new().constrain(
632                                                                Some(AxisConstraint::exact(Px(
633                                                                    animated_size,
634                                                                ))),
635                                                                Some(AxisConstraint::exact(Px(
636                                                                    animated_size,
637                                                                ))),
638                                                            ))
639                                                            .shape(Shape::Ellipse)
640                                                            .style(SurfaceStyle::Filled {
641                                                                color: active_dot_color,
642                                                            })
643                                                            .child(|| {});
644                                                    });
645                                            }
646                                        }
647                                    });
648                            });
649                    }
650                });
651        });
652}