Skip to main content

fret_ui_kit/primitives/
toggle.rs

1//! Toggle primitives (Radix-aligned outcomes).
2//!
3//! This module provides a stable, Radix-named surface for composing toggle behavior in recipes.
4//! It intentionally models outcomes rather than React/DOM APIs.
5//!
6//! This file is part of the stable primitives surface (do not move without an ADR update).
7//!
8//! Upstream reference:
9//! - `repo-ref/primitives/packages/react/toggle/src/toggle.tsx`
10
11use std::sync::Arc;
12
13use fret_core::SemanticsRole;
14use fret_runtime::Model;
15use fret_ui::element::{AnyElement, PressableA11y, PressableProps, PressableState};
16use fret_ui::{ElementContext, UiHost};
17
18use crate::declarative::ModelWatchExt;
19use crate::declarative::action_hooks::ActionHooksExt as _;
20use crate::{IntoUiElement, collect_children};
21
22/// A11y metadata for a toggle-like pressable.
23///
24/// Note: Radix uses `aria-pressed` to represent the "on" state. Fret models this using a
25/// portable tri-state pressed semantics surface (`pressed_state`) on a button-like role.
26pub fn toggle_a11y(label: Option<Arc<str>>, pressed: bool) -> PressableA11y {
27    PressableA11y {
28        role: Some(SemanticsRole::Button),
29        label,
30        pressed_state: Some(if pressed {
31            fret_core::SemanticsPressedState::True
32        } else {
33            fret_core::SemanticsPressedState::False
34        }),
35        ..Default::default()
36    }
37}
38
39/// Returns a pressed-state model that behaves like Radix `useControllableState` (`pressed` /
40/// `defaultPressed`).
41pub fn toggle_use_model<H: UiHost>(
42    cx: &mut ElementContext<'_, H>,
43    controlled: Option<Model<bool>>,
44    default_pressed: impl FnOnce() -> bool,
45) -> crate::primitives::controllable_state::ControllableModel<bool> {
46    crate::primitives::controllable_state::use_controllable_model(cx, controlled, default_pressed)
47}
48
49/// A Radix-shaped `Toggle` root configuration surface.
50///
51/// Upstream supports a controlled/uncontrolled pressed state (`pressed` + `defaultPressed`). In
52/// Fret this maps to either:
53/// - a caller-provided `Model<bool>` (controlled), or
54/// - an internal `Model<bool>` stored in element state (uncontrolled).
55#[derive(Debug, Clone, Default)]
56pub struct ToggleRoot {
57    pressed: Option<Model<bool>>,
58    default_pressed: bool,
59    disabled: bool,
60    a11y_label: Option<Arc<str>>,
61}
62
63impl ToggleRoot {
64    pub fn new() -> Self {
65        Self::default()
66    }
67
68    /// Sets the controlled `pressed` model (`Some`) or selects uncontrolled mode (`None`).
69    pub fn pressed(mut self, pressed: Option<Model<bool>>) -> Self {
70        self.pressed = pressed;
71        self
72    }
73
74    /// Sets the uncontrolled initial pressed value (Radix `defaultPressed`).
75    pub fn default_pressed(mut self, default_pressed: bool) -> Self {
76        self.default_pressed = default_pressed;
77        self
78    }
79
80    pub fn disabled(mut self, disabled: bool) -> Self {
81        self.disabled = disabled;
82        self
83    }
84
85    pub fn a11y_label(mut self, label: impl Into<Arc<str>>) -> Self {
86        self.a11y_label = Some(label.into());
87        self
88    }
89
90    /// Creates a toggle root with a controlled/uncontrolled pressed model (Radix `pressed` /
91    /// `defaultPressed`).
92    ///
93    /// Notes:
94    /// - The internal model (uncontrolled mode) is stored in element state at the call site.
95    /// - Call this from a stable subtree (key the node if you need state to survive reordering).
96    pub fn new_controllable<H: UiHost>(
97        cx: &mut ElementContext<'_, H>,
98        pressed: Option<Model<bool>>,
99        default_pressed: impl FnOnce() -> bool,
100    ) -> Self {
101        let model = toggle_use_model(cx, pressed, default_pressed).model();
102        Self::new().pressed(Some(model))
103    }
104
105    /// Returns a `Model<bool>` that behaves like Radix `useControllableState` for `pressed`.
106    pub fn use_pressed_model<H: UiHost>(
107        &self,
108        cx: &mut ElementContext<'_, H>,
109    ) -> crate::primitives::controllable_state::ControllableModel<bool> {
110        toggle_use_model(cx, self.pressed.clone(), || self.default_pressed)
111    }
112
113    pub fn pressed_model<H: UiHost>(&self, cx: &mut ElementContext<'_, H>) -> Model<bool> {
114        self.use_pressed_model(cx).model()
115    }
116
117    /// Reads the current pressed value from the derived pressed model.
118    pub fn is_pressed<H: UiHost>(&self, cx: &mut ElementContext<'_, H>) -> bool {
119        let model = self.pressed_model(cx);
120        cx.watch_model(&model).copied_or_default()
121    }
122
123    /// Renders a toggle-like pressable, wiring Radix-like pressed state and a11y.
124    ///
125    /// Notes:
126    /// - Activation toggles the boolean model (disabled guards apply).
127    /// - This does not apply any visual skin. Pass the desired `PressableProps`.
128    #[track_caller]
129    pub fn into_element<H: UiHost, I, T>(
130        self,
131        cx: &mut ElementContext<'_, H>,
132        mut props: PressableProps,
133        f: impl FnOnce(&mut ElementContext<'_, H>, PressableState, bool) -> I,
134    ) -> AnyElement
135    where
136        I: IntoIterator<Item = T>,
137        T: IntoUiElement<H>,
138    {
139        let model = self.pressed_model(cx);
140        let disabled = self.disabled;
141        let label = self.a11y_label.clone();
142
143        cx.pressable_with_id_props(move |cx, st, _id| {
144            if !disabled {
145                cx.pressable_toggle_bool(&model);
146            }
147
148            let pressed = cx.watch_model(&model).copied_or_default();
149            props.enabled = props.enabled && !disabled;
150            props.a11y = toggle_a11y(label.clone(), pressed);
151
152            let items = f(cx, st, pressed);
153            (props, collect_children(cx, items))
154        })
155    }
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161
162    use std::cell::Cell;
163
164    use fret_app::App;
165    use fret_core::{AppWindowId, Px, Rect};
166
167    fn bounds() -> Rect {
168        Rect::new(
169            fret_core::Point::new(Px(0.0), Px(0.0)),
170            fret_core::Size::new(Px(200.0), Px(120.0)),
171        )
172    }
173
174    #[test]
175    fn toggle_root_prefers_controlled_model_and_does_not_call_default() {
176        let window = AppWindowId::default();
177        let mut app = App::new();
178        let b = bounds();
179
180        let controlled = app.models_mut().insert(true);
181        let called = Cell::new(0);
182
183        fret_ui::elements::with_element_cx(&mut app, window, b, "test", |cx| {
184            let root = ToggleRoot::new_controllable(cx, Some(controlled.clone()), || {
185                called.set(called.get() + 1);
186                false
187            });
188            assert_eq!(root.pressed_model(cx), controlled);
189        });
190
191        assert_eq!(called.get(), 0);
192    }
193}