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