Skip to main content

fret_ui_kit/primitives/
collapsible.rs

1//! Collapsible primitives (Radix-aligned outcomes).
2//!
3//! This module provides a stable, Radix-named surface for composing collapsible behavior in
4//! recipes. It intentionally models outcomes rather than React/DOM APIs.
5//!
6//! Upstream reference:
7//! - `repo-ref/primitives/packages/react/collapsible/src/collapsible.tsx`
8
9use std::sync::Arc;
10
11use fret_core::{Px, SemanticsRole, Size};
12use fret_runtime::Model;
13use fret_ui::element::{AnyElement, PressableA11y, SemanticsProps};
14use fret_ui::elements::GlobalElementId;
15use fret_ui::theme::CubicBezier;
16use fret_ui::{ElementContext, UiHost};
17
18use crate::declarative::ModelWatchExt;
19use crate::primitives::trigger_a11y;
20
21/// Returns an open-state model that behaves like Radix `useControllableState` (`open` /
22/// `defaultOpen`).
23pub fn collapsible_use_open_model<H: UiHost>(
24    cx: &mut ElementContext<'_, H>,
25    controlled_open: Option<Model<bool>>,
26    default_open: impl FnOnce() -> bool,
27) -> crate::primitives::controllable_state::ControllableModel<bool> {
28    crate::primitives::open_state::open_use_model(cx, controlled_open, default_open)
29}
30
31/// A Radix-shaped `Collapsible` root configuration surface.
32///
33/// Upstream supports a controlled/uncontrolled `open` state (`open` + `defaultOpen`). In Fret this
34/// maps to either:
35/// - a caller-provided `Model<bool>` (controlled), or
36/// - an internal `Model<bool>` stored in element state (uncontrolled).
37#[derive(Debug, Clone, Default)]
38pub struct CollapsibleRoot {
39    open: Option<Model<bool>>,
40    default_open: bool,
41}
42
43impl CollapsibleRoot {
44    pub fn new() -> Self {
45        Self::default()
46    }
47
48    /// Sets the controlled `open` model (`Some`) or selects uncontrolled mode (`None`).
49    pub fn open(mut self, open: Option<Model<bool>>) -> Self {
50        self.open = open;
51        self
52    }
53
54    /// Sets the uncontrolled initial open value (Radix `defaultOpen`).
55    pub fn default_open(mut self, default_open: bool) -> Self {
56        self.default_open = default_open;
57        self
58    }
59
60    /// Returns a `Model<bool>` that behaves like Radix `useControllableState` for `open`.
61    pub fn use_open_model<H: UiHost>(
62        &self,
63        cx: &mut ElementContext<'_, H>,
64    ) -> crate::primitives::controllable_state::ControllableModel<bool> {
65        collapsible_use_open_model(cx, self.open.clone(), || self.default_open)
66    }
67
68    /// Reads the current open value from the derived open model.
69    pub fn is_open<H: UiHost>(&self, cx: &mut ElementContext<'_, H>) -> bool {
70        let open_model = self.use_open_model(cx).model();
71        cx.watch_model(&open_model)
72            .layout()
73            .copied()
74            .unwrap_or(false)
75    }
76}
77
78/// Semantics wrapper props for a collapsible root container.
79pub fn collapsible_root_semantics(disabled: bool, open: bool) -> SemanticsProps {
80    SemanticsProps {
81        role: SemanticsRole::Generic,
82        disabled,
83        expanded: Some(open),
84        ..Default::default()
85    }
86}
87
88/// A11y metadata for a collapsible trigger pressable.
89pub fn collapsible_trigger_a11y(label: Option<Arc<str>>, open: bool) -> PressableA11y {
90    PressableA11y {
91        role: Some(SemanticsRole::Button),
92        label,
93        expanded: Some(open),
94        ..Default::default()
95    }
96}
97
98/// Stamps Radix-like trigger relationships:
99/// - `controls_element` mirrors `aria-controls` (by element id).
100///
101/// In Radix Collapsible, the trigger points at the content by id. In Fret we model this via a
102/// portable element-id relationship that resolves into `SemanticsNode.controls` when the content
103/// is mounted.
104pub fn apply_collapsible_trigger_controls(
105    trigger: AnyElement,
106    content_element: GlobalElementId,
107) -> AnyElement {
108    trigger_a11y::apply_trigger_controls(trigger, Some(content_element))
109}
110
111/// Stamps Radix-like trigger relationships:
112/// - `expanded` mirrors `aria-expanded`.
113/// - `controls_element` mirrors `aria-controls` (by element id).
114pub fn apply_collapsible_trigger_controls_expanded(
115    trigger: AnyElement,
116    content_element: GlobalElementId,
117    open: bool,
118) -> AnyElement {
119    trigger_a11y::apply_trigger_controls_expanded(trigger, Some(open), Some(content_element))
120}
121
122/// Read the last cached open height for a collapsible content subtree.
123///
124/// This is a Radix-aligned outcome for Collapsible/Accordion height animations: Radix measures the
125/// content and exposes it to styling via CSS variables. Fret caches the value in per-element state.
126pub fn last_measured_height_for<H: UiHost>(
127    cx: &mut ElementContext<'_, H>,
128    state_id: GlobalElementId,
129) -> Px {
130    crate::declarative::collapsible_motion::last_measured_height_for(cx, state_id)
131}
132
133/// Read the last cached open size for a collapsible content subtree.
134pub fn last_measured_size_for<H: UiHost>(
135    cx: &mut ElementContext<'_, H>,
136    state_id: GlobalElementId,
137) -> Size {
138    crate::declarative::collapsible_motion::last_measured_size_for(cx, state_id)
139}
140
141/// Update the cached open height from the previously-laid-out bounds of `wrapper_element_id`.
142pub fn update_measured_height_if_open_for<H: UiHost>(
143    cx: &mut ElementContext<'_, H>,
144    state_id: GlobalElementId,
145    wrapper_element_id: GlobalElementId,
146    open: bool,
147    animating: bool,
148) -> Px {
149    crate::declarative::collapsible_motion::update_measured_height_if_open_for(
150        cx,
151        state_id,
152        wrapper_element_id,
153        open,
154        animating,
155    )
156}
157
158/// Update the cached open size from a "measurement element" that is laid out off-flow.
159pub fn update_measured_size_from_element_if_open_for<H: UiHost>(
160    cx: &mut ElementContext<'_, H>,
161    state_id: GlobalElementId,
162    measure_element_id: GlobalElementId,
163    open: bool,
164) -> Size {
165    crate::declarative::collapsible_motion::update_measured_size_from_element_if_open_for(
166        cx,
167        state_id,
168        measure_element_id,
169        open,
170    )
171}
172
173/// Layout refinement for an off-flow measurement wrapper.
174pub fn collapsible_measurement_wrapper_refinement() -> crate::LayoutRefinement {
175    crate::declarative::collapsible_motion::collapsible_measurement_wrapper_refinement()
176}
177
178/// Compute wrapper mounting and layout patches for a collapsible content subtree.
179#[allow(clippy::too_many_arguments)]
180pub fn collapsible_height_wrapper_refinement(
181    open: bool,
182    force_mount: bool,
183    require_measurement_for_close: bool,
184    transition: crate::headless::transition::TransitionOutput,
185    measured_height: Px,
186) -> (bool, crate::LayoutRefinement) {
187    crate::declarative::collapsible_motion::collapsible_height_wrapper_refinement(
188        open,
189        force_mount,
190        require_measurement_for_close,
191        transition,
192        measured_height,
193    )
194}
195
196pub use crate::declarative::collapsible_motion::MeasuredHeightMotionOutput;
197
198/// Computes a measured-height motion plan for the current element root.
199///
200/// This is a Radix-aligned outcome for components that animate open/close using measured content
201/// height (Collapsible, Accordion items, etc.).
202#[allow(clippy::too_many_arguments)]
203pub fn measured_height_motion_for_root<H: UiHost>(
204    cx: &mut ElementContext<'_, H>,
205    open: bool,
206    force_mount: bool,
207    require_measurement_for_close: bool,
208    open_ticks: u64,
209    close_ticks: u64,
210    ease: fn(f32) -> f32,
211) -> MeasuredHeightMotionOutput {
212    crate::declarative::collapsible_motion::measured_height_motion_for_root(
213        cx,
214        open,
215        force_mount,
216        require_measurement_for_close,
217        open_ticks,
218        close_ticks,
219        ease,
220    )
221}
222
223/// Like [`measured_height_motion_for_root`], but uses a cubic-bezier easing curve.
224#[allow(clippy::too_many_arguments)]
225pub fn measured_height_motion_for_root_with_cubic_bezier<H: UiHost>(
226    cx: &mut ElementContext<'_, H>,
227    open: bool,
228    force_mount: bool,
229    require_measurement_for_close: bool,
230    open_ticks: u64,
231    close_ticks: u64,
232    bezier: CubicBezier,
233) -> MeasuredHeightMotionOutput {
234    crate::declarative::collapsible_motion::measured_height_motion_for_root_with_cubic_bezier(
235        cx,
236        open,
237        force_mount,
238        require_measurement_for_close,
239        open_ticks,
240        close_ticks,
241        bezier,
242    )
243}
244
245/// Updates cached measured size/height for a motion plan based on the wrapper element id.
246pub fn update_measured_for_motion<H: UiHost>(
247    cx: &mut ElementContext<'_, H>,
248    motion: MeasuredHeightMotionOutput,
249    wrapper_element_id: GlobalElementId,
250) -> Size {
251    crate::declarative::collapsible_motion::update_measured_for_motion(
252        cx,
253        motion,
254        wrapper_element_id,
255    )
256}
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261
262    use std::cell::Cell;
263
264    use fret_app::App;
265    use fret_core::{AppWindowId, Point, Px, Rect, Size};
266    use fret_ui::element::{ElementKind, LayoutStyle, PressableProps};
267
268    fn bounds() -> Rect {
269        Rect::new(
270            Point::new(Px(0.0), Px(0.0)),
271            Size::new(Px(200.0), Px(120.0)),
272        )
273    }
274
275    #[test]
276    fn collapsible_use_open_model_prefers_controlled_and_does_not_call_default() {
277        let window = AppWindowId::default();
278        let mut app = App::new();
279        let b = bounds();
280
281        let controlled = app.models_mut().insert(true);
282        let called = Cell::new(0);
283
284        fret_ui::elements::with_element_cx(&mut app, window, b, "test", |cx| {
285            let out = collapsible_use_open_model(cx, Some(controlled.clone()), || {
286                called.set(called.get() + 1);
287                false
288            });
289            assert!(out.is_controlled());
290            assert_eq!(out.model(), controlled);
291        });
292
293        assert_eq!(called.get(), 0);
294    }
295
296    #[test]
297    fn apply_collapsible_trigger_controls_sets_controls_on_pressable() {
298        let window = AppWindowId::default();
299        let mut app = App::new();
300        let b = bounds();
301
302        fret_ui::elements::with_element_cx(&mut app, window, b, "test", |cx| {
303            let trigger = cx.pressable(
304                PressableProps {
305                    layout: LayoutStyle::default(),
306                    enabled: true,
307                    focusable: true,
308                    ..Default::default()
309                },
310                |_cx, _st| Vec::new(),
311            );
312            let content = GlobalElementId(0xbeef);
313            let trigger = apply_collapsible_trigger_controls(trigger, content);
314            let ElementKind::Pressable(PressableProps { a11y, .. }) = &trigger.kind else {
315                panic!("expected pressable");
316            };
317            assert_eq!(a11y.controls_element, Some(content.0));
318        });
319    }
320
321    #[test]
322    fn apply_collapsible_trigger_controls_expanded_sets_expanded_and_controls() {
323        let window = AppWindowId::default();
324        let mut app = App::new();
325        let b = bounds();
326
327        fret_ui::elements::with_element_cx(&mut app, window, b, "test", |cx| {
328            let trigger = cx.pressable(
329                PressableProps {
330                    layout: LayoutStyle::default(),
331                    enabled: true,
332                    focusable: true,
333                    ..Default::default()
334                },
335                |_cx, _st| Vec::new(),
336            );
337            let content = GlobalElementId(0xbeef);
338            let trigger = apply_collapsible_trigger_controls_expanded(trigger, content, true);
339            let ElementKind::Pressable(PressableProps { a11y, .. }) = &trigger.kind else {
340                panic!("expected pressable");
341            };
342            assert_eq!(a11y.expanded, Some(true));
343            assert_eq!(a11y.controls_element, Some(content.0));
344        });
345    }
346}