Skip to main content

teksilo_widgets/
radio_button.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! RadioButton — mutually exclusive selection control.
5//!
6//! Multiple `RadioButton`s share a `Signal<usize>`; selecting one writes its
7//! `value` to the signal, which automatically deselects every sibling that
8//! observes the same signal. The widget is non-generic: values are `usize`
9//! indices into the caller's choice list. Wrap related buttons in a
10//! [`RadioGroup`](crate::radio_group::RadioGroup) to provide the AT "2 of 3"
11//! positional announcement required by ARIA.
12//!
13//! ## Accessibility
14//!
15//! Reports `Role::RadioButton` with `set_toggled` mirroring the selected
16//! state. Responds to `Action::Click` from assistive technology. The focus
17//! ring is keyboard-only (`:focus-visible` gated by the input-modality
18//! signal). When wrapped in `RadioGroup`, each button emits
19//! `push_to_radio_group([sibling_ids])` so screen readers can announce
20//! positional membership.
21//!
22//! ```rust
23//! # use teksilo_widgets::RadioButton;
24//! # use teksilo_core::signal::Signal;
25//! # use teksilo_i18n::lit;
26//! let selected = Signal::new(0_usize);
27//! let _r0 = RadioButton::new(0, selected.clone()).label(lit!("Light"));
28//! let _r1 = RadioButton::new(1, selected.clone()).label(lit!("Dark"));
29//! let _r2 = RadioButton::new(2, selected.clone()).label(lit!("System"));
30//! ```
31
32use std::cell::RefCell;
33use std::rc::Rc;
34
35use teksilo_canvas::{Rect, Size, SizeProposal};
36use teksilo_core::accessibility::AccessNodeBuilder;
37use teksilo_core::build_context::BuildContext;
38use teksilo_core::event::{EventResponse, Key, WidgetEvent};
39use teksilo_core::signal::{Prop, Signal};
40use teksilo_core::styles::{RadioStyleConfig, RadioVariant, SharedRadioStyle};
41use teksilo_core::widget::{CursorIcon, EventContext, LayoutContext, Widget, WidgetPlacement};
42use teksilo_core::widget_builder::HandlerSet;
43use teksilo_core::widget_id::WidgetId;
44use teksilo_tokens::{TextRole, TextStyleRole, VAlignment};
45
46use crate::button::InteractionState;
47use crate::primitives::{HStack, MinSize, TextWidget, VStack};
48use teksilo_i18n::LocalizedString;
49
50/// A single radio button option that writes `value` into a shared `Signal<usize>` on selection.
51pub struct RadioButton {
52    label: Option<LocalizedString>,
53    caption: Option<LocalizedString>,
54    value: usize,
55    selected: Signal<usize>,
56    /// Enabled state, static or reactive; forwarded to the arena at
57    /// build time.
58    enabled: Prop<bool>,
59    tooltip_text: Option<LocalizedString>,
60    rich_tooltip_source: Option<crate::tooltip::RichTooltipSource>,
61    composite_tooltip_content: Option<Box<dyn teksilo_core::widget::Widget>>,
62    variant: RadioVariant,
63    style_override: Option<SharedRadioStyle>,
64    root_child_id: Option<WidgetId>,
65    /// Shared radio-group sibling id buffer populated by an enclosing
66    /// `RadioGroup`. When set, `accessibility()` emits
67    /// `push_to_radio_group(sibling_id)` for every id in the buffer
68    /// so screen readers can announce "2 of 3" positional info.
69    /// Loose radios not wrapped in a RadioGroup leave this `None`
70    /// and drop the group membership metadata.
71    group_ids: Option<Rc<RefCell<Vec<WidgetId>>>>,
72}
73
74impl RadioButton {
75    /// Create a radio button with the given `value` and shared selection signal.
76    pub fn new(value: usize, selected: Signal<usize>) -> Self {
77        Self {
78            label: None,
79            caption: None,
80            value,
81            selected,
82            enabled: Prop::Static(true),
83            tooltip_text: None,
84            rich_tooltip_source: None,
85            composite_tooltip_content: None,
86            variant: RadioVariant::default(),
87            style_override: None,
88            root_child_id: None,
89            group_ids: None,
90        }
91    }
92
93    /// Called by `RadioGroup` at build time to install the shared
94    /// sibling-id buffer. Not part of the public fluent API —
95    /// users wrap radios in `RadioGroup::new().radio(...)` rather
96    /// than threading the buffer manually.
97    pub(crate) fn set_group_ids(&mut self, ids: Rc<RefCell<Vec<WidgetId>>>) {
98        self.group_ids = Some(ids);
99    }
100
101    /// Set the visible label text displayed to the right of the radio circle.
102    pub fn label(mut self, label: impl Into<LocalizedString>) -> Self {
103        let ls: LocalizedString = label.into();
104        self.label = Some(ls);
105        self
106    }
107
108    /// Secondary explanatory text rendered below the label, left-aligned
109    /// with the label (not the radio circle). Uses the `small` /
110    /// `text_secondary` style. Has no effect unless `label(...)` is also set.
111    pub fn caption(mut self, text: impl Into<LocalizedString>) -> Self {
112        let ls: LocalizedString = text.into();
113        self.caption = Some(ls);
114        self
115    }
116
117    /// Set the enabled state, statically or reactively. Forwarded to
118    /// the arena via `ctx.enabled_when(self_id, self.enabled.clone())`
119    /// at build time.
120    pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
121        self.enabled = enabled.into();
122        self
123    }
124
125    /// Pick the design-language variant. Default `Circle`. The active
126    /// `RadioStyle` impl decides what the variant means visually.
127    pub fn variant(mut self, variant: RadioVariant) -> Self {
128        self.variant = variant;
129        self
130    }
131
132    /// Per-call style override. Replaces the theme-wide default
133    /// `RadioStyle` for just this RadioButton instance.
134    pub fn style(mut self, style: impl teksilo_core::styles::RadioStyle) -> Self {
135        self.style_override = Some(Rc::new(style));
136        self
137    }
138
139    /// Attach a plain single-line tooltip shown on hover.
140    pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self {
141        self.tooltip_text = Some(text.into());
142        self.rich_tooltip_source = None;
143        self.composite_tooltip_content = None;
144        self
145    }
146
147    /// Attach a rich tooltip resolved from the app-wide tooltip
148    /// registry. See [`Button::rich_tooltip`](crate::button::Button::rich_tooltip).
149    pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self {
150        self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Key(key.into()));
151        self.tooltip_text = None;
152        self.composite_tooltip_content = None;
153        self
154    }
155
156    /// Attach a rich tooltip driven by inline `TooltipContent`.
157    pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self {
158        self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Content(content));
159        self.tooltip_text = None;
160        self.composite_tooltip_content = None;
161        self
162    }
163
164    /// Attach a composite tooltip — third tier, hosting an arbitrary
165    /// widget tree. See [`Button::composite_tooltip`](crate::button::Button::composite_tooltip).
166    pub fn composite_tooltip(
167        mut self,
168        content: impl teksilo_core::widget::Widget + 'static,
169    ) -> Self {
170        self.composite_tooltip_content = Some(Box::new(content));
171        self.tooltip_text = None;
172        self.rich_tooltip_source = None;
173        self
174    }
175
176    fn is_selected(&self) -> bool {
177        self.selected.get() == self.value
178    }
179}
180
181impl std::fmt::Debug for RadioButton {
182    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
183        f.debug_struct("RadioButton")
184            .field("label", &self.label)
185            .field("caption", &self.caption)
186            .field("value", &self.value)
187            .finish()
188    }
189}
190
191/// Internal interaction state — local to this widget's handlers; the
192/// active `RadioStyle` only sees the four derived boolean signals
193impl Widget for RadioButton {
194    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
195        use crate::styles::recipe_radio_style as radio_dims;
196        let selected = self.selected.clone();
197        let value = self.value;
198        let variant = self.variant;
199        let self_id = ctx.self_id();
200
201        // Forward the enabled state into the arena; see IconButton.
202        ctx.enabled_when(self_id, self.enabled.clone());
203        let effective_enabled = ctx.effective_enabled_signal(self_id);
204
205        let interaction = ctx.signal(InteractionState::Idle);
206
207        let is_selected = selected.map(move |s| *s == value);
208        let is_hovered = interaction.map(|s| matches!(s, InteractionState::Hovered));
209        let is_pressed = interaction.map(|s| matches!(s, InteractionState::Pressed));
210        // `:focus-visible`: reveal the focus ring during keyboard navigation
211        // only, not on a mouse click. Gate raw focus on the input-modality
212        // signal (true after a key event, false after pointer-down).
213        let is_focused = interaction
214            .map(|s| matches!(s, InteractionState::Focused))
215            .and(&ctx.focus_visible());
216        // is_disabled derives from the arena.
217        let is_disabled = effective_enabled.map(|on| !*on);
218
219        let style: SharedRadioStyle = self
220            .style_override
221            .clone()
222            .or_else(|| ctx.theme().style_slots.radio.clone())
223            .unwrap_or_else(|| Rc::new(crate::styles::RecipeRadioStyle::default()));
224        let cfg = RadioStyleConfig {
225            is_selected,
226            is_hovered,
227            is_pressed,
228            is_focused,
229            is_disabled,
230            variant,
231        };
232        let body_id = style.make_body(&cfg, ctx);
233
234        let mut row = HStack::new()
235            .spacing(radio_dims::RADIO_LABEL_GAP)
236            .add_child(body_id);
237        if let Some(ref label) = self.label {
238            let label_widget = TextWidget::new(label.clone())
239                .style(TextStyleRole::Body)
240                .color(TextRole::Primary)
241                .single_line()
242                .a11y_hidden();
243            let label_id = ctx.add(label_widget);
244
245            let label_column_id = if let Some(ref caption) = self.caption {
246                let caption_widget = TextWidget::new(caption.clone())
247                    .style(TextStyleRole::Small)
248                    .color(TextRole::Secondary)
249                    .a11y_hidden();
250                let caption_id = ctx.add(caption_widget);
251                ctx.add(
252                    VStack::new()
253                        .spacing(2.0)
254                        .add_child(label_id)
255                        .add_child(caption_id),
256                )
257            } else {
258                label_id
259            };
260            row = row.add_child(label_column_id);
261        }
262        // Top-align so the radio circle sits next to the label's first line
263        // instead of the vertical center of the label+caption column.
264        if self.caption.is_some() && self.label.is_some() {
265            row = row.alignment(VAlignment::Top);
266        }
267
268        let row_id = ctx.add(row);
269        let root_id = ctx.add(
270            MinSize::new(radio_dims::RADIO_HIT_AREA, radio_dims::RADIO_HIT_AREA).child_id(row_id),
271        );
272
273        if let Some(content) = self.composite_tooltip_content.take() {
274            let delay = ctx.theme().motion.tooltip_delay_heavy;
275            crate::tooltip::attach_composite_tooltip_boxed(ctx, root_id, content, delay);
276        } else if let Some(source) = self.rich_tooltip_source.take() {
277            let delay = ctx.theme().motion.tooltip_delay;
278            crate::tooltip::attach_rich_tooltip_source(ctx, root_id, source, delay);
279        } else if let Some(tooltip_text) = self.tooltip_text.clone() {
280            let delay = ctx.theme().motion.tooltip_delay;
281            crate::tooltip::attach_plain_tooltip(ctx, root_id, tooltip_text, delay);
282        }
283
284        self.root_child_id = Some(root_id);
285
286        // --- V2 attached handlers ---
287        let sel_tap = self.selected.clone();
288        let sel_key = self.selected.clone();
289        let sel_access = self.selected.clone();
290        let int_tap = interaction.clone();
291        let int_hover = interaction.clone();
292        let int_key = interaction.clone();
293        let int_focus = interaction.clone();
294
295        // Framework gates events on arena.is_enabled; no per-handler
296        // snapshot guards anymore.
297        let handler_set = HandlerSet::new()
298            .on_tap({
299                move |_pos, _ctx: &mut EventContext| {
300                    sel_tap.set(value);
301                    int_tap.set(InteractionState::Hovered);
302                }
303            })
304            .on_hover({
305                move |entered: bool, _ctx: &mut EventContext| {
306                    if entered {
307                        int_hover.set(InteractionState::Hovered);
308                    } else {
309                        int_hover.set(InteractionState::Idle);
310                    }
311                }
312            })
313            .on_key({
314                move |event: &WidgetEvent, _ctx: &mut EventContext| -> EventResponse {
315                    match event {
316                        WidgetEvent::KeyDown {
317                            key: Key::Space, ..
318                        } => {
319                            int_key.set(InteractionState::Pressed);
320                            EventResponse::Handled
321                        }
322                        WidgetEvent::KeyUp {
323                            key: Key::Space, ..
324                        } => {
325                            // Lone-KeyUp guard: only select if we saw the
326                            // matching KeyDown (state is Pressed). A stray KeyUp
327                            // — e.g. a shortcut consumed the KeyDown and focus
328                            // returned here — must NOT select.
329                            if int_key.get() != InteractionState::Pressed {
330                                return EventResponse::Ignored;
331                            }
332                            sel_key.set(value);
333                            int_key.set(InteractionState::Focused);
334                            EventResponse::Handled
335                        }
336                        _ => EventResponse::Ignored,
337                    }
338                }
339            })
340            .on_focus({
341                move |gained: bool, _ctx: &mut EventContext| {
342                    if gained {
343                        if int_focus.get() == InteractionState::Idle {
344                            int_focus.set(InteractionState::Focused);
345                        }
346                    } else {
347                        int_focus.set(InteractionState::Idle);
348                    }
349                }
350            })
351            .on_access_action({
352                move |action: teksilo_core::accesskit::Action,
353                      _ctx: &mut EventContext|
354                      -> EventResponse {
355                    if action == teksilo_core::accesskit::Action::Click {
356                        sel_access.set(value);
357                        EventResponse::Handled
358                    } else {
359                        EventResponse::Ignored
360                    }
361                }
362            })
363            .focusable(true)
364            .cursor(CursorIcon::Pointer);
365
366        ctx.apply_self_handlers(handler_set);
367
368        vec![root_id]
369    }
370
371    fn layout_response(
372        &self,
373        proposal: SizeProposal,
374        ctx: &LayoutContext,
375    ) -> teksilo_core::widget::LayoutResponse {
376        if let Some(root) = self.root_child_id
377            && let Some(size) = ctx.child_size(root, proposal)
378        {
379            return (size).into();
380        }
381        proposal.resolve(0.0, 0.0).into()
382    }
383
384    fn place_children(
385        &self,
386        bounds: Rect,
387        _proposal: SizeProposal,
388        children: &mut [WidgetPlacement],
389        _ctx: &LayoutContext,
390    ) {
391        for child in children.iter_mut() {
392            child.origin = teksilo_canvas::Point::new(bounds.x, bounds.y);
393            child.size = Size::new(bounds.width, bounds.height);
394        }
395    }
396
397    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
398        builder.set_role(teksilo_core::accesskit::Role::RadioButton);
399        if let Some(ref label) = self.label {
400            builder.set_name(label.resolve_now());
401        }
402        if let Some(ref caption) = self.caption {
403            builder.set_description(caption.resolve_now());
404        }
405        // ARIA role="radio" uses aria-checked (→ AccessKit `toggled`),
406        // not aria-selected. `selected` is for options, tabs, and grid cells.
407        builder.set_toggled(self.is_selected());
408        // Publish radio-group membership if this button was wrapped
409        // in a `RadioGroup`. Each button declares every sibling
410        // (including itself) so AT can announce "2 of 3".
411        if let Some(group_ids) = &self.group_ids {
412            for &id in group_ids.borrow().iter() {
413                builder.push_to_radio_group(teksilo_core::accessibility::widget_id_to_node_id(id));
414            }
415        }
416        // Framework a11y walker sets `set_disabled` from arena state.
417        builder.add_action(teksilo_core::accesskit::Action::Click);
418        builder.add_action(teksilo_core::accesskit::Action::Focus);
419    }
420
421    fn children(&self) -> Vec<WidgetId> {
422        self.root_child_id.into_iter().collect()
423    }
424}
425
426#[cfg(test)]
427mod tests {
428    use super::*;
429    use teksilo_core::event::Modifiers;
430    use teksilo_core::widget_tree::WidgetTree;
431    use teksilo_i18n::lit;
432
433    #[test]
434    fn selecting_one_deselects_others() {
435        use crate::primitives::VStack;
436        let selected = Signal::new(0_usize);
437        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
438        let r0 = tree.add(RadioButton::new(0, selected.clone()).label(lit!("A")));
439        let r1 = tree.add(RadioButton::new(1, selected.clone()).label(lit!("B")));
440        let r2 = tree.add(RadioButton::new(2, selected.clone()).label(lit!("C")));
441        let _root = tree.add(VStack::new().add_child(r0).add_child(r1).add_child(r2));
442        tree.layout(SizeProposal::exact(200.0, 300.0));
443
444        assert_eq!(selected.get(), 0);
445        tree.click(r1);
446        assert_eq!(selected.get(), 1);
447        tree.click(r2);
448        assert_eq!(selected.get(), 2);
449        tree.click(r0);
450        assert_eq!(selected.get(), 0);
451    }
452
453    #[test]
454    fn space_selects() {
455        let selected = Signal::new(0_usize);
456        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
457        let _r0 = tree.add(RadioButton::new(0, selected.clone()).label(lit!("A")));
458        let r1 = tree.add(RadioButton::new(1, selected.clone()).label(lit!("B")));
459        tree.layout(SizeProposal::exact(200.0, 200.0));
460
461        tree.focus(r1);
462        tree.press_key(Key::Space, Modifiers::NONE);
463        assert_eq!(selected.get(), 1);
464    }
465
466    #[test]
467    fn lone_keyup_does_not_select() {
468        // Lone-KeyUp guard: a KeyUp with no matching KeyDown must NOT select.
469        let selected = Signal::new(0_usize);
470        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
471        let _r0 = tree.add(RadioButton::new(0, selected.clone()).label(lit!("A")));
472        let r1 = tree.add(RadioButton::new(1, selected.clone()).label(lit!("B")));
473        tree.layout(SizeProposal::exact(200.0, 200.0));
474
475        tree.focus(r1);
476        tree.dispatch_event(WidgetEvent::KeyUp {
477            key: Key::Space,
478            modifiers: Modifiers::NONE,
479        });
480        assert_eq!(selected.get(), 0, "a lone KeyUp must not select the radio");
481
482        tree.press_key(Key::Space, Modifiers::NONE);
483        assert_eq!(selected.get(), 1);
484    }
485
486    #[test]
487    fn accessibility() {
488        let selected = Signal::new(1_usize);
489        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
490        let r0 = tree.add(RadioButton::new(0, selected.clone()).label(lit!("A")));
491        let r1 = tree.add(RadioButton::new(1, selected.clone()).label(lit!("B")));
492        tree.layout(SizeProposal::exact(200.0, 200.0));
493
494        let info0 = tree.accessibility_node(r0);
495        assert_eq!(info0.role(), teksilo_core::accesskit::Role::RadioButton);
496        assert!(!info0.is_toggled());
497
498        let info1 = tree.accessibility_node(r1);
499        assert!(info1.is_toggled());
500    }
501
502    #[test]
503    fn accessibility_has_actions() {
504        let selected = Signal::new(0_usize);
505        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
506        let r0 = tree.add(RadioButton::new(0, selected).label(lit!("A")));
507        tree.layout(SizeProposal::exact(200.0, 200.0));
508        let info = tree.accessibility_node(r0);
509        assert!(
510            info.actions()
511                .contains(&teksilo_core::accesskit::Action::Click)
512        );
513    }
514}