Skip to main content

teksilo_widgets/
radio_tile.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! RadioTile — a "selectable card" radio option.
5//!
6//! A `RadioTile` behaves as a single radio button (`Role::RadioButton`,
7//! `set_toggled`) rendered as a bordered, rounded card: a leading icon, a
8//! bold title, an inline radio indicator, and a muted, wrapping description.
9//! Multiple tiles share a `Signal<usize>` — selecting one writes its `value`,
10//! which deselects every sibling observing the same signal (the `RadioButton`
11//! model). Group them with
12//! [`RadioTileGroup`](crate::radio_tile_group::RadioTileGroup) for layout,
13//! roving keyboard navigation, and the AT "N of M" positional announcement.
14//!
15//! ## Content model
16//!
17//! Typed slots cover the common case (matching the reference design):
18//! `.icon(..)`, `.title(..)`, `.description(..)`. For arbitrary content, the
19//! `.body(..)` slot replaces the description column with any widget subtree.
20//!
21//! ## Accessibility
22//!
23//! Reports `Role::RadioButton` with `set_toggled` mirroring selection, the
24//! title as the accessible name, and the description as the accessible
25//! description. When grouped, each tile emits
26//! `push_to_radio_group([sibling_ids])` plus `set_position_in_set` /
27//! `set_size_of_set` for "N of M". Inside a `RadioTileGroup` the tile is not
28//! individually focusable — focus roves on the group (WAI-ARIA radiogroup),
29//! and the group publishes `active_descendant`. A standalone tile is
30//! focusable and responds to `Space` / `Action::Click`.
31//!
32//! ```ignore
33//! let selected = ctx.signal(0_usize);
34//! RadioTileGroup::new(selected)
35//!     .tile(RadioTile::new().icon(icon).title(tr!(single_file())).description(tr!(single_file_desc())))
36//!     .tile(RadioTile::new().icon(icon2).title(tr!(bundle())).description(tr!(bundle_desc())))
37//! ```
38
39use std::cell::RefCell;
40use std::rc::Rc;
41
42use teksilo_canvas::{Rect, SizeProposal};
43use teksilo_core::accessibility::AccessNodeBuilder;
44use teksilo_core::binding::BindingLevel;
45use teksilo_core::build_context::BuildContext;
46use teksilo_core::color_prop::{ColorProp, TextStyleProp};
47use teksilo_core::event::{EventResponse, Key, WidgetEvent};
48use teksilo_core::signal::{Prop, Signal};
49use teksilo_core::styles::{
50    RadioStyleConfig, RadioTileStyle, RadioTileStyleConfig, RadioTileVariant, RadioVariant,
51    SharedRadioStyle, SharedRadioTileStyle,
52};
53use teksilo_core::widget::{CursorIcon, EventContext, LayoutContext, Widget, WidgetPlacement};
54use teksilo_core::widget_builder::HandlerSet;
55use teksilo_core::widget_id::WidgetId;
56use teksilo_tokens::{HAlignment, TextRole, TextStyleRole, VAlignment};
57
58use crate::button::InteractionState;
59use crate::primitives::{HStack, Spacer, TextWidget, VStack};
60use crate::styles::{RecipeRadioStyle, RecipeRadioTileStyle};
61use teksilo_i18n::LocalizedString;
62
63/// Horizontal gap between the icon / title / indicator on a tile's top row.
64const TILE_ROW_GAP: f32 = 10.0;
65/// Vertical gap between the tile's title row and its description.
66const TILE_TITLE_DESC_GAP: f32 = 6.0;
67
68/// Which side of the top row the radio indicator sits on. Defaults to
69/// `Trailing` (top-right in LTR), matching the reference design.
70#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash, Default)]
71pub enum RadioTileIndicatorSide {
72    /// Trailing edge of the row — top-right in LTR, top-left in RTL.
73    #[default]
74    Trailing,
75    /// Leading edge of the row — top-left in LTR, top-right in RTL.
76    Leading,
77}
78
79/// A single selectable-card radio option. See the [module docs](self).
80pub struct RadioTile {
81    value: usize,
82    selected: Signal<usize>,
83    icon: Option<Box<dyn Widget>>,
84    title: Option<LocalizedString>,
85    description: Option<LocalizedString>,
86    body: Option<Box<dyn Widget>>,
87    /// Right-aligned trailing meta text (e.g. "20 chapters") — tints to accent
88    /// when selected. Ignored when a `trailing_slot` is set.
89    trailing: Option<LocalizedString>,
90    trailing_slot: Option<Box<dyn Widget>>,
91    /// Compact single-line arrangement: `[indicator] [icon] [title] [Spacer]
92    /// [trailing]`, no description row (the vertical-list look). Set by
93    /// `RadioTileGroup::layout(TileLayout::Vertical)` or `.compact(true)`.
94    compact: bool,
95    title_style: Option<TextStyleProp>,
96    title_color: Option<ColorProp>,
97    description_style: Option<TextStyleProp>,
98    description_color: Option<ColorProp>,
99    /// Enabled state, static or reactive; forwarded to the arena at
100    /// build time.
101    enabled: Prop<bool>,
102    variant: RadioTileVariant,
103    show_indicator: bool,
104    indicator_side: RadioTileIndicatorSide,
105    tooltip_text: Option<LocalizedString>,
106    rich_tooltip_source: Option<crate::tooltip::RichTooltipSource>,
107    composite_tooltip_content: Option<Box<dyn Widget>>,
108    /// Where the tooltip opens relative to the tile. `Below` (default) suits
109    /// horizontal (`Row`) and 2-D (`Grid`) group layouts; a vertical group
110    /// (`Column` / `Vertical`) sets this to `Side` via
111    /// [`set_tooltip_placement`](Self::set_tooltip_placement) so the tooltip
112    /// doesn't cover the tile below.
113    tooltip_placement: crate::tooltip::TooltipPlacement,
114    style_override: Option<SharedRadioTileStyle>,
115    /// Set by `RadioTileGroup`: the tile is part of a roving radiogroup, so it
116    /// is not individually focusable and its focus ring follows the group.
117    grouped: bool,
118    group_focused: Option<Signal<bool>>,
119    group_ids: Option<Rc<RefCell<Vec<WidgetId>>>>,
120    pos_in_set: Option<usize>,
121    size_of_set: Option<usize>,
122    root_child_id: Option<WidgetId>,
123}
124
125impl RadioTile {
126    /// Create a tile with no selection binding. The enclosing
127    /// [`RadioTileGroup`](crate::radio_tile_group::RadioTileGroup) assigns
128    /// this tile's `value` (its position) and shared selection signal. Use
129    /// [`selection`](Self::selection) for a standalone tile.
130    pub fn new() -> Self {
131        Self {
132            value: 0,
133            selected: Signal::new(0),
134            icon: None,
135            title: None,
136            description: None,
137            body: None,
138            trailing: None,
139            trailing_slot: None,
140            compact: false,
141            title_style: None,
142            title_color: None,
143            description_style: None,
144            description_color: None,
145            enabled: Prop::Static(true),
146            variant: RadioTileVariant::default(),
147            show_indicator: true,
148            indicator_side: RadioTileIndicatorSide::default(),
149            tooltip_text: None,
150            rich_tooltip_source: None,
151            composite_tooltip_content: None,
152            tooltip_placement: crate::tooltip::TooltipPlacement::Below,
153            style_override: None,
154            grouped: false,
155            group_focused: None,
156            group_ids: None,
157            pos_in_set: None,
158            size_of_set: None,
159            root_child_id: None,
160        }
161    }
162
163    /// Bind this tile to an explicit `value` + shared `Signal<usize>` for use
164    /// **outside** a `RadioTileGroup`. Inside a group this is set automatically.
165    pub fn selection(mut self, value: usize, selected: Signal<usize>) -> Self {
166        self.value = value;
167        self.selected = selected;
168        self
169    }
170
171    /// Leading icon slot (top-left of the tile). Any widget — typically an
172    /// [`IconWidget`](crate::primitives::IconWidget).
173    pub fn icon(mut self, widget: impl Widget + 'static) -> Self {
174        self.icon = Some(Box::new(widget));
175        self
176    }
177
178    /// Leading icon slot, pre-boxed.
179    pub fn icon_boxed(mut self, widget: Box<dyn Widget>) -> Self {
180        self.icon = Some(widget);
181        self
182    }
183
184    /// Bold title text (the tile's accessible name).
185    pub fn title(mut self, title: impl Into<LocalizedString>) -> Self {
186        self.title = Some(title.into());
187        self
188    }
189
190    /// Muted, multi-line description (the tile's accessible description).
191    /// Ignored when a [`body`](Self::body) is set.
192    pub fn description(mut self, text: impl Into<LocalizedString>) -> Self {
193        self.description = Some(text.into());
194        self
195    }
196
197    /// Replace the description column with an arbitrary widget subtree. Takes
198    /// precedence over [`description`](Self::description). Note: a body's own
199    /// content is exposed to assistive technology as-is (unlike the typed
200    /// description, which is folded into the tile's accessible description).
201    pub fn body(mut self, widget: impl Widget + 'static) -> Self {
202        self.body = Some(Box::new(widget));
203        self
204    }
205
206    /// Custom body slot, pre-boxed.
207    pub fn body_boxed(mut self, widget: Box<dyn Widget>) -> Self {
208        self.body = Some(widget);
209        self
210    }
211
212    /// Right-aligned trailing meta text (e.g. "20 chapters", "free-form
213    /// notes"). Tints to the accent color when the tile is selected. Most
214    /// useful with the compact vertical arrangement. Ignored when a
215    /// [`trailing_slot`](Self::trailing_slot) is set.
216    pub fn trailing(mut self, text: impl Into<LocalizedString>) -> Self {
217        self.trailing = Some(text.into());
218        self
219    }
220
221    /// Arbitrary right-aligned trailing widget (badge, count, chevron, …).
222    /// Takes precedence over [`trailing`](Self::trailing).
223    pub fn trailing_slot(mut self, widget: impl Widget + 'static) -> Self {
224        self.trailing_slot = Some(Box::new(widget));
225        self
226    }
227
228    /// Compact single-line arrangement: `[indicator] [icon] [title] [Spacer]
229    /// [trailing]` with no description row — the vertical settings-list look.
230    /// `RadioTileGroup::layout(TileLayout::Vertical)` sets this automatically
231    /// (and moves the indicator to the leading edge).
232    pub fn compact(mut self, compact: bool) -> Self {
233        self.compact = compact;
234        self
235    }
236
237    /// Override the title text style (default `TextStyleRole::BodyBold`).
238    pub fn title_style(mut self, style: impl Into<TextStyleProp>) -> Self {
239        self.title_style = Some(style.into());
240        self
241    }
242
243    /// Override the title text color (default `TextRole::Primary`).
244    pub fn title_color(mut self, color: impl Into<ColorProp>) -> Self {
245        self.title_color = Some(color.into());
246        self
247    }
248
249    /// Override the description text style (default `TextStyleRole::Small`).
250    pub fn description_style(mut self, style: impl Into<TextStyleProp>) -> Self {
251        self.description_style = Some(style.into());
252        self
253    }
254
255    /// Override the description text color (default `TextRole::Secondary`).
256    pub fn description_color(mut self, color: impl Into<ColorProp>) -> Self {
257        self.description_color = Some(color.into());
258        self
259    }
260
261    /// Set the enabled state, statically or reactively. A disabled tile
262    /// is skipped by the group's keyboard navigation and cannot be
263    /// selected.
264    pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
265        self.enabled = enabled.into();
266        self
267    }
268
269    /// Pick the card variant (default `Outlined`).
270    pub fn variant(mut self, variant: RadioTileVariant) -> Self {
271        self.variant = variant;
272        self
273    }
274
275    /// Whether to render the inline radio indicator (default `true`). When
276    /// `false`, the selection cue is the card highlight alone.
277    pub fn show_indicator(mut self, show: bool) -> Self {
278        self.show_indicator = show;
279        self
280    }
281
282    /// Which side of the top row the radio indicator sits on (default `Trailing`).
283    pub fn indicator_side(mut self, side: RadioTileIndicatorSide) -> Self {
284        self.indicator_side = side;
285        self
286    }
287
288    /// Per-call style override — replaces the theme-wide `RadioTileStyle`
289    /// for just this tile.
290    pub fn style(mut self, style: impl RadioTileStyle) -> Self {
291        self.style_override = Some(Rc::new(style));
292        self
293    }
294
295    /// Attach a plain single-line tooltip shown on hover.
296    pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self {
297        self.tooltip_text = Some(text.into());
298        self.rich_tooltip_source = None;
299        self.composite_tooltip_content = None;
300        self
301    }
302
303    /// Attach a rich tooltip resolved from the app-wide tooltip registry.
304    pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self {
305        self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Key(key.into()));
306        self.tooltip_text = None;
307        self.composite_tooltip_content = None;
308        self
309    }
310
311    /// Attach a rich tooltip driven by inline `TooltipContent`.
312    pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self {
313        self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Content(content));
314        self.tooltip_text = None;
315        self.composite_tooltip_content = None;
316        self
317    }
318
319    /// Attach a composite tooltip hosting an arbitrary widget tree.
320    pub fn composite_tooltip(mut self, content: impl Widget + 'static) -> Self {
321        self.composite_tooltip_content = Some(Box::new(content));
322        self.tooltip_text = None;
323        self.rich_tooltip_source = None;
324        self
325    }
326
327    // --- Injected by RadioTileGroup at build time (not public API) ---
328
329    pub(crate) fn set_selection(&mut self, value: usize, selected: Signal<usize>) {
330        self.value = value;
331        self.selected = selected;
332    }
333
334    pub(crate) fn set_grouped(
335        &mut self,
336        group_focused: Signal<bool>,
337        group_ids: Rc<RefCell<Vec<WidgetId>>>,
338        pos: usize,
339        size: usize,
340    ) {
341        self.grouped = true;
342        self.group_focused = Some(group_focused);
343        self.group_ids = Some(group_ids);
344        self.pos_in_set = Some(pos);
345        self.size_of_set = Some(size);
346    }
347
348    pub(crate) fn is_enabled(&self) -> bool {
349        self.enabled.get()
350    }
351
352    /// Switch this tile to the compact vertical-list arrangement with a
353    /// leading radio indicator. Called by `RadioTileGroup` for
354    /// [`TileLayout::Vertical`](crate::radio_tile_group::TileLayout::Vertical).
355    pub(crate) fn set_vertical_arrangement(&mut self) {
356        self.compact = true;
357        self.indicator_side = RadioTileIndicatorSide::Leading;
358    }
359
360    /// Set where this tile's tooltip opens. Called by `RadioTileGroup` — a
361    /// vertical group (`Column` / `Vertical`) passes `Side` so the tooltip
362    /// opens beside the tile instead of covering the tile below.
363    pub(crate) fn set_tooltip_placement(&mut self, placement: crate::tooltip::TooltipPlacement) {
364        self.tooltip_placement = placement;
365    }
366
367    /// Apply a group-level style only when this tile has no per-call style of
368    /// its own (the tile's own `.style(...)` wins). Called by `RadioTileGroup`.
369    pub(crate) fn set_style_if_unset(&mut self, style: SharedRadioTileStyle) {
370        if self.style_override.is_none() {
371            self.style_override = Some(style);
372        }
373    }
374
375    fn is_selected(&self) -> bool {
376        self.selected.get() == self.value
377    }
378}
379
380impl Default for RadioTile {
381    fn default() -> Self {
382        Self::new()
383    }
384}
385
386impl std::fmt::Debug for RadioTile {
387    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
388        f.debug_struct("RadioTile")
389            .field("value", &self.value)
390            .field("title", &self.title)
391            .field("grouped", &self.grouped)
392            .finish()
393    }
394}
395
396impl Widget for RadioTile {
397    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
398        let selected = self.selected.clone();
399        let value = self.value;
400        let variant = self.variant;
401        let self_id = ctx.self_id();
402
403        ctx.enabled_when(self_id, self.enabled.clone());
404        let effective_enabled = ctx.effective_enabled_signal(self_id);
405
406        // Re-walk the AT tree when selection changes so `set_toggled` (and the
407        // group's `active_descendant`) stay current — selection is otherwise a
408        // repaint-only change. Matches GridView's selection binding.
409        {
410            let registry = ctx.binding_registry();
411            self.selected
412                .bind_to(self_id, registry, BindingLevel::AccessibilityOnly);
413        }
414
415        let interaction = ctx.signal(InteractionState::Idle);
416
417        let is_selected = selected.map(move |s| *s == value);
418        let is_hovered = interaction.map(|s| matches!(s, InteractionState::Hovered));
419        let is_pressed = interaction.map(|s| matches!(s, InteractionState::Pressed));
420        let is_disabled = effective_enabled.map(|on| !*on);
421        // Focus source: the group's focus when grouped (roving radiogroup),
422        // else this tile's own focus.
423        let is_focused = if let Some(gf) = &self.group_focused {
424            gf.clone()
425        } else {
426            interaction.map(|s| matches!(s, InteractionState::Focused))
427        };
428        let is_focus_visible = ctx.focus_visible();
429        let is_window_active = ctx.window_active_signal();
430
431        // --- Radio indicator: reuse the theme's RadioStyle so the glyph
432        // matches a standalone RadioButton. The glyph never draws its own
433        // focus ring (the tile owns the ring), so pass a constant `false`.
434        let indicator_id = if self.show_indicator {
435            let radio_style: SharedRadioStyle = ctx
436                .theme()
437                .style_slots
438                .radio
439                .clone()
440                .unwrap_or_else(|| Rc::new(RecipeRadioStyle::default()));
441            let radio_cfg = RadioStyleConfig {
442                is_selected: is_selected.clone(),
443                is_hovered: is_hovered.clone(),
444                is_pressed: is_pressed.clone(),
445                is_focused: Signal::new(false),
446                is_disabled: is_disabled.clone(),
447                variant: RadioVariant::Circle,
448            };
449            Some(radio_style.make_body(&radio_cfg, ctx))
450        } else {
451            None
452        };
453
454        // --- Top row: [icon?] [title] [Spacer] [indicator?] (indicator side
455        // configurable; RTL handled by HStack + Spacer).
456        let mut top_row = HStack::new()
457            .spacing(TILE_ROW_GAP)
458            .alignment(VAlignment::Center);
459
460        if self.indicator_side == RadioTileIndicatorSide::Leading
461            && let Some(id) = indicator_id
462        {
463            top_row = top_row.add_child(id);
464        }
465        if let Some(icon) = self.icon.take() {
466            let icon_id = ctx.add_boxed(icon);
467            top_row = top_row.add_child(icon_id);
468        }
469        if let Some(title) = &self.title {
470            let title_widget = TextWidget::new(title.clone())
471                .style(
472                    self.title_style
473                        .clone()
474                        .unwrap_or(TextStyleProp::Role(TextStyleRole::BodyBold)),
475                )
476                .color(
477                    self.title_color
478                        .clone()
479                        .unwrap_or(ColorProp::TextRole(TextRole::Primary)),
480                )
481                .single_line()
482                .a11y_hidden();
483            let title_id = ctx.add(title_widget);
484            top_row = top_row.add_child(title_id);
485        }
486        top_row = top_row.add_child(ctx.add(Spacer::new()));
487        // Trailing meta (right-aligned). Typed text tints to accent when
488        // selected (the "20 chapters" cue); a custom slot is used as-is.
489        if let Some(slot) = self.trailing_slot.take() {
490            top_row = top_row.add_child(ctx.add_boxed(slot));
491        } else if let Some(trailing) = &self.trailing {
492            let trailing_color = is_selected.map(|s| {
493                if *s {
494                    TextRole::Accent
495                } else {
496                    TextRole::Secondary
497                }
498            });
499            let trailing_widget = TextWidget::new(trailing.clone())
500                .style(TextStyleProp::Role(TextStyleRole::Small))
501                .color(trailing_color)
502                .single_line()
503                .a11y_hidden();
504            top_row = top_row.add_child(ctx.add(trailing_widget));
505        }
506        if self.indicator_side == RadioTileIndicatorSide::Trailing
507            && let Some(id) = indicator_id
508        {
509            top_row = top_row.add_child(id);
510        }
511        let top_row_id = ctx.add(top_row);
512
513        // --- Content column: top row + (description|body, unless compact).
514        let mut content_col = VStack::new()
515            .spacing(TILE_TITLE_DESC_GAP)
516            .alignment(HAlignment::Leading)
517            .add_child(top_row_id);
518
519        if !self.compact {
520            if let Some(body) = self.body.take() {
521                let body_id = ctx.add_boxed(body);
522                content_col = content_col.add_child(body_id);
523            } else if let Some(description) = &self.description {
524                let desc_widget = TextWidget::new(description.clone())
525                    .style(
526                        self.description_style
527                            .clone()
528                            .unwrap_or(TextStyleProp::Role(TextStyleRole::Small)),
529                    )
530                    .color(
531                        self.description_color
532                            .clone()
533                            .unwrap_or(ColorProp::TextRole(TextRole::Secondary)),
534                    )
535                    .a11y_hidden();
536                let desc_id = ctx.add(desc_widget);
537                content_col = content_col.add_child(desc_id);
538            }
539        }
540        let content_id = ctx.add(content_col);
541
542        // --- Card chrome via the resolved RadioTileStyle.
543        let style: SharedRadioTileStyle = self
544            .style_override
545            .clone()
546            .or_else(|| ctx.theme().style_slots.radio_tile.clone())
547            .unwrap_or_else(|| Rc::new(RecipeRadioTileStyle::default()));
548        let cfg = RadioTileStyleConfig {
549            content: content_id,
550            is_selected: is_selected.clone(),
551            is_hovered: is_hovered.clone(),
552            is_pressed: is_pressed.clone(),
553            is_focused,
554            is_focus_visible,
555            is_disabled,
556            is_window_active,
557            variant,
558            is_compact: self.compact,
559        };
560        let root_id = style.make_body(&cfg, ctx);
561
562        // Placement is `Below` by default; a vertical group (`Column` /
563        // `Vertical`) injects `Side` via `set_tooltip_placement` so a tile's
564        // tooltip doesn't cover the tile below.
565        let tip_placement = self.tooltip_placement;
566        if let Some(content) = self.composite_tooltip_content.take() {
567            let delay = ctx.theme().motion.tooltip_delay_heavy;
568            crate::tooltip::attach_composite_tooltip_boxed_with_placement(
569                ctx,
570                root_id,
571                content,
572                delay,
573                tip_placement,
574            );
575        } else if let Some(source) = self.rich_tooltip_source.take() {
576            let delay = ctx.theme().motion.tooltip_delay;
577            crate::tooltip::attach_rich_tooltip_source_with_placement(
578                ctx,
579                root_id,
580                source,
581                delay,
582                tip_placement,
583            );
584        } else if let Some(tooltip_text) = self.tooltip_text.clone() {
585            let delay = ctx.theme().motion.tooltip_delay;
586            crate::tooltip::attach_plain_tooltip_with_placement(
587                ctx,
588                root_id,
589                tooltip_text,
590                delay,
591                tip_placement,
592            );
593        }
594
595        self.root_child_id = Some(root_id);
596
597        // --- Handlers. A grouped tile is not individually focusable (focus
598        // roves on the group); a standalone tile is focusable and takes Space.
599        let sel_tap = self.selected.clone();
600        let sel_access = self.selected.clone();
601        let int_tap = interaction.clone();
602        let int_hover = interaction.clone();
603
604        let mut handler_set = HandlerSet::new()
605            .on_tap(move |_pos, _ctx: &mut EventContext| {
606                sel_tap.set(value);
607                int_tap.set(InteractionState::Hovered);
608            })
609            .on_hover(move |entered: bool, _ctx: &mut EventContext| {
610                if entered {
611                    int_hover.set(InteractionState::Hovered);
612                } else {
613                    int_hover.set(InteractionState::Idle);
614                }
615            })
616            .on_access_action(
617                move |action: teksilo_core::accesskit::Action, _ctx: &mut EventContext| {
618                    if action == teksilo_core::accesskit::Action::Click {
619                        sel_access.set(value);
620                        EventResponse::Handled
621                    } else {
622                        EventResponse::Ignored
623                    }
624                },
625            )
626            .cursor(CursorIcon::Pointer);
627
628        if !self.grouped {
629            let sel_key = self.selected.clone();
630            let int_key = interaction.clone();
631            let int_focus = interaction.clone();
632            handler_set = handler_set
633                .focusable(true)
634                .on_key(
635                    move |event: &WidgetEvent, _ctx: &mut EventContext| match event {
636                        WidgetEvent::KeyDown {
637                            key: Key::Space, ..
638                        } => {
639                            int_key.set(InteractionState::Pressed);
640                            EventResponse::Handled
641                        }
642                        WidgetEvent::KeyUp {
643                            key: Key::Space, ..
644                        } => {
645                            // Lone-KeyUp guard (see RadioButton).
646                            if int_key.get() != InteractionState::Pressed {
647                                return EventResponse::Ignored;
648                            }
649                            sel_key.set(value);
650                            int_key.set(InteractionState::Focused);
651                            EventResponse::Handled
652                        }
653                        _ => EventResponse::Ignored,
654                    },
655                )
656                .on_focus(move |gained: bool, _ctx: &mut EventContext| {
657                    if gained {
658                        if int_focus.get() == InteractionState::Idle {
659                            int_focus.set(InteractionState::Focused);
660                        }
661                    } else {
662                        int_focus.set(InteractionState::Idle);
663                    }
664                });
665        }
666
667        ctx.apply_self_handlers(handler_set);
668
669        vec![root_id]
670    }
671
672    fn layout_response(
673        &self,
674        proposal: SizeProposal,
675        ctx: &LayoutContext,
676    ) -> teksilo_core::widget::LayoutResponse {
677        if let Some(root) = self.root_child_id
678            && let Some(size) = ctx.child_size(root, proposal)
679        {
680            return size.into();
681        }
682        proposal.resolve(0.0, 0.0).into()
683    }
684
685    fn place_children(
686        &self,
687        bounds: Rect,
688        _proposal: SizeProposal,
689        children: &mut [WidgetPlacement],
690        _ctx: &LayoutContext,
691    ) {
692        for child in children.iter_mut() {
693            child.origin = bounds.origin();
694            child.size = bounds.size();
695        }
696    }
697
698    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
699        builder.set_role(teksilo_core::accesskit::Role::RadioButton);
700        if let Some(ref title) = self.title {
701            builder.set_name(title.resolve_now());
702        }
703        if let Some(ref description) = self.description {
704            builder.set_description(description.resolve_now());
705        } else if let Some(ref trailing) = self.trailing {
706            // In the compact arrangement the trailing meta carries the
707            // secondary info, so expose it as the accessible description.
708            builder.set_description(trailing.resolve_now());
709        }
710        // ARIA role="radio" uses aria-checked (→ AccessKit `toggled`).
711        builder.set_toggled(self.is_selected());
712        // "N of M" positional info (set by the group).
713        if let Some(pos) = self.pos_in_set {
714            builder.set_position_in_set(pos);
715        }
716        if let Some(size) = self.size_of_set {
717            builder.set_size_of_set(size);
718        }
719        // Radio-group membership — each tile declares every sibling (incl.
720        // itself) so AT can announce positional info.
721        if let Some(group_ids) = &self.group_ids {
722            for &id in group_ids.borrow().iter() {
723                builder.push_to_radio_group(teksilo_core::accessibility::widget_id_to_node_id(id));
724            }
725        }
726        builder.add_action(teksilo_core::accesskit::Action::Click);
727        // Only a standalone tile is a direct focus target; a grouped tile is
728        // reached via the group's roving `active_descendant`.
729        if !self.grouped {
730            builder.add_action(teksilo_core::accesskit::Action::Focus);
731        }
732    }
733
734    fn children(&self) -> Vec<WidgetId> {
735        self.root_child_id.into_iter().collect()
736    }
737}
738
739#[cfg(test)]
740mod tests {
741    use super::*;
742    use teksilo_core::event::Modifiers;
743    use teksilo_core::widget_tree::WidgetTree;
744    use teksilo_i18n::lit;
745    use teksilo_tokens::Color;
746
747    #[test]
748    fn standalone_tap_and_space_select() {
749        let selected = Signal::new(0_usize);
750        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
751        let t0 = tree.add(
752            RadioTile::new()
753                .selection(0, selected.clone())
754                .title(lit!("A")),
755        );
756        let t1 = tree.add(
757            RadioTile::new()
758                .selection(1, selected.clone())
759                .title(lit!("B")),
760        );
761        let _root = tree.add(crate::primitives::VStack::new().add_child(t0).add_child(t1));
762        tree.layout(SizeProposal::exact(300.0, 300.0));
763
764        assert_eq!(selected.get(), 0);
765        tree.click(t1);
766        assert_eq!(selected.get(), 1);
767
768        // A standalone tile is focusable and Space-selectable.
769        tree.focus(t0);
770        tree.press_key(Key::Space, Modifiers::NONE);
771        assert_eq!(selected.get(), 0);
772    }
773
774    #[test]
775    fn accessibility_role_and_toggled() {
776        let selected = Signal::new(1_usize);
777        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
778        let t0 = tree.add(
779            RadioTile::new()
780                .selection(0, selected.clone())
781                .title(lit!("A"))
782                .description(lit!("first choice")),
783        );
784        tree.layout(SizeProposal::exact(300.0, 200.0));
785        let info = tree.accessibility_node(t0);
786        assert_eq!(info.role(), teksilo_core::accesskit::Role::RadioButton);
787        assert_eq!(info.name(), Some("A"));
788        assert!(!info.is_toggled());
789    }
790
791    #[test]
792    fn compact_tile_omits_description_and_is_shorter() {
793        use crate::primitives::{FixedSize, VStack};
794        let long = "a long description that would wrap across several lines inside the tile body";
795        let selected = Signal::new(0_usize);
796        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
797        let compact = tree.add(
798            FixedSize::new().width(300.0).child(
799                RadioTile::new()
800                    .selection(0, selected.clone())
801                    .title(lit!("A"))
802                    .description(lit!(long))
803                    .compact(true),
804            ),
805        );
806        let card = tree.add(
807            FixedSize::new().width(300.0).child(
808                RadioTile::new()
809                    .selection(0, selected.clone())
810                    .title(lit!("B"))
811                    .description(lit!(long)),
812            ),
813        );
814        let _root = tree.add(VStack::new().add_child(compact).add_child(card));
815        tree.layout(SizeProposal::exact(320.0, 600.0));
816        let a = tree.find_by_label("A").unwrap();
817        let b = tree.find_by_label("B").unwrap();
818        assert!(
819            tree.bounds(a).height < tree.bounds(b).height,
820            "compact tile drops the wrapping description row, so it is shorter"
821        );
822    }
823
824    // Sentinel style painting a distinctive fill, to exercise Tier-3 precedence.
825    #[derive(Debug)]
826    struct SentinelTile(Color);
827    impl RadioTileStyle for SentinelTile {
828        fn make_body(&self, cfg: &RadioTileStyleConfig, ctx: &mut BuildContext) -> WidgetId {
829            let rect = ctx.add(crate::primitives::RectWidget::new().background(self.0));
830            ctx.add(
831                crate::primitives::ZStack::new()
832                    .add_child(rect)
833                    .add_child(cfg.content),
834            )
835        }
836    }
837
838    fn renders_color(tree: &mut WidgetTree, color: Color) -> bool {
839        tree.layout(SizeProposal::exact(200.0, 100.0));
840        let frame = tree.render();
841        frame.shapes.iter().any(|s| s.color == color.to_array())
842    }
843
844    #[test]
845    fn theme_slot_supplies_style_when_no_override() {
846        let mut theme = teksilo_core::presets::intui::light();
847        theme.style_slots.radio_tile =
848            Some(Rc::new(SentinelTile(Color::from_rgba(1.0, 0.0, 1.0, 1.0))));
849        let selected = Signal::new(0_usize);
850        let mut tree = WidgetTree::new().with_theme(theme);
851        tree.add(RadioTile::new().selection(0, selected).title(lit!("X")));
852        assert!(
853            renders_color(&mut tree, Color::from_rgba(1.0, 0.0, 1.0, 1.0)),
854            "theme slot style should paint the sentinel fill"
855        );
856    }
857
858    #[test]
859    fn per_call_style_override_wins_over_theme_slot() {
860        let mut theme = teksilo_core::presets::intui::light();
861        theme.style_slots.radio_tile =
862            Some(Rc::new(SentinelTile(Color::from_rgba(1.0, 0.0, 1.0, 1.0))));
863        let per_call = Color::from_rgba(0.0, 1.0, 0.0, 1.0);
864        let selected = Signal::new(0_usize);
865        let mut tree = WidgetTree::new().with_theme(theme);
866        tree.add(
867            RadioTile::new()
868                .selection(0, selected)
869                .title(lit!("X"))
870                .style(SentinelTile(per_call)),
871        );
872        assert!(
873            renders_color(&mut tree, per_call),
874            "per-call .style() should win over the theme slot"
875        );
876        assert!(
877            !renders_color(&mut tree, Color::from_rgba(1.0, 0.0, 1.0, 1.0)),
878            "theme-slot fill must not appear when overridden per-call"
879        );
880    }
881}