Skip to main content

fret_ui_kit/primitives/
toggle_group.rs

1//! ToggleGroup primitives (Radix-aligned outcomes).
2//!
3//! This module provides a stable, Radix-named surface for composing toggle group behavior in
4//! recipes. It intentionally models outcomes rather than React/DOM APIs.
5//!
6//! Upstream reference:
7//! - `repo-ref/primitives/packages/react/toggle-group/src/toggle-group.tsx`
8
9use std::sync::Arc;
10
11use fret_core::SemanticsRole;
12use fret_runtime::Model;
13use fret_ui::element::PressableA11y;
14use fret_ui::{ElementContext, UiHost};
15
16/// Matches Radix ToggleGroup `type` outcome.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum ToggleGroupKind {
19    Single,
20    Multiple,
21}
22
23/// Matches Radix ToggleGroup `orientation` outcome.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
25pub enum ToggleGroupOrientation {
26    #[default]
27    Horizontal,
28    Vertical,
29}
30
31/// A11y metadata for a toggle-group item.
32///
33/// Radix uses `aria-pressed` in multiple mode and `role="radio" + aria-checked` in single mode.
34/// Fret models this by switching the item role and using `pressed_state` for multiple mode and
35/// the `checked` flag for single mode.
36pub fn toggle_group_item_a11y_multiple(label: Arc<str>, pressed: bool) -> PressableA11y {
37    PressableA11y {
38        role: Some(SemanticsRole::Button),
39        label: Some(label),
40        pressed_state: Some(if pressed {
41            fret_core::SemanticsPressedState::True
42        } else {
43            fret_core::SemanticsPressedState::False
44        }),
45        ..Default::default()
46    }
47}
48
49/// A11y metadata for a single-select toggle-group item (Radix `role="radio"`).
50pub fn toggle_group_item_a11y_single(label: Arc<str>, checked: bool) -> PressableA11y {
51    PressableA11y {
52        role: Some(SemanticsRole::RadioButton),
53        label: Some(label),
54        checked: Some(checked),
55        ..Default::default()
56    }
57}
58
59/// Back-compat shim: treated as the multiple-select button-like outcome.
60pub fn toggle_group_item_a11y(label: Arc<str>, pressed: bool) -> PressableA11y {
61    toggle_group_item_a11y_multiple(label, pressed)
62}
63
64/// Returns a selection model for a single-select toggle group that behaves like Radix
65/// `useControllableState` (`value` / `defaultValue`).
66pub fn toggle_group_use_single_model<H: UiHost>(
67    cx: &mut ElementContext<'_, H>,
68    controlled: Option<Model<Option<Arc<str>>>>,
69    default_value: impl FnOnce() -> Option<Arc<str>>,
70) -> crate::primitives::controllable_state::ControllableModel<Option<Arc<str>>> {
71    crate::primitives::controllable_state::use_controllable_model(cx, controlled, default_value)
72}
73
74/// Returns a selection model for a multi-select toggle group that behaves like Radix
75/// `useControllableState` (`value` / `defaultValue`).
76pub fn toggle_group_use_multiple_model<H: UiHost>(
77    cx: &mut ElementContext<'_, H>,
78    controlled: Option<Model<Vec<Arc<str>>>>,
79    default_value: impl FnOnce() -> Vec<Arc<str>>,
80) -> crate::primitives::controllable_state::ControllableModel<Vec<Arc<str>>> {
81    crate::primitives::controllable_state::use_controllable_model(cx, controlled, default_value)
82}
83
84/// Derive the "tab stop" index for a single-select toggle group:
85/// the selected enabled item, or the first enabled item.
86pub fn tab_stop_index_single(
87    values: &[Arc<str>],
88    selected: Option<&str>,
89    disabled: &[bool],
90) -> Option<usize> {
91    if let Some(selected) = selected {
92        if let Some(active) = crate::headless::roving_focus::active_index_from_str_keys(
93            values,
94            Some(selected),
95            disabled,
96        ) {
97            return Some(active);
98        }
99    }
100    crate::headless::roving_focus::first_enabled(disabled)
101}
102
103/// Derive the "tab stop" index for a multi-select toggle group:
104/// the first selected+enabled item, or the first enabled item.
105pub fn tab_stop_index_multiple(
106    values: &[Arc<str>],
107    selected: &[Arc<str>],
108    disabled: &[bool],
109) -> Option<usize> {
110    let first_selected_enabled = values.iter().enumerate().find_map(|(idx, v)| {
111        let enabled = !disabled.get(idx).copied().unwrap_or(true);
112        let on = selected.iter().any(|s| s.as_ref() == v.as_ref());
113        (enabled && on).then_some(idx)
114    });
115    first_selected_enabled.or_else(|| crate::headless::roving_focus::first_enabled(disabled))
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121
122    #[test]
123    fn toggle_group_item_a11y_single_uses_radio_role_and_checked() {
124        let a11y = toggle_group_item_a11y_single(Arc::from("A"), true);
125        assert_eq!(a11y.role, Some(SemanticsRole::RadioButton));
126        assert_eq!(a11y.checked, Some(true));
127        assert!(!a11y.selected);
128    }
129
130    #[test]
131    fn toggle_group_item_a11y_multiple_uses_button_role_and_pressed_state() {
132        let a11y = toggle_group_item_a11y_multiple(Arc::from("A"), true);
133        assert_eq!(a11y.role, Some(SemanticsRole::Button));
134        assert_eq!(
135            a11y.pressed_state,
136            Some(fret_core::SemanticsPressedState::True)
137        );
138        assert_eq!(a11y.checked, None);
139    }
140}