Skip to main content

fret_ui_kit/primitives/
tabs.rs

1//! Tabs primitives (Radix-aligned outcomes).
2//!
3//! This module provides a stable, Radix-named surface for composing tabs behavior in recipes.
4//! It intentionally models outcomes rather than React/DOM APIs.
5//!
6//! Upstream reference:
7//! - `repo-ref/primitives/packages/react/tabs/src/tabs.tsx`
8
9use std::sync::Arc;
10
11use fret_core::{Modifiers, MouseButton, PointerType, SemanticsOrientation, SemanticsRole};
12use fret_runtime::Model;
13use fret_ui::element::{
14    AnyElement, LayoutStyle, PressableA11y, PressableProps, RovingFlexProps, RovingFocusProps,
15    SemanticsProps,
16};
17use fret_ui::{ElementContext, UiHost};
18
19use crate::declarative::ModelWatchExt;
20use crate::declarative::action_hooks::ActionHooksExt as _;
21use crate::{IntoUiElement, collect_children};
22
23/// Returns a selected-value model that behaves like Radix `useControllableState` (`value` /
24/// `defaultValue`).
25pub fn tabs_use_value_model<H: UiHost>(
26    cx: &mut ElementContext<'_, H>,
27    controlled: Option<Model<Option<Arc<str>>>>,
28    default_value: impl FnOnce() -> Option<Arc<str>>,
29) -> crate::primitives::controllable_state::ControllableModel<Option<Arc<str>>> {
30    crate::primitives::controllable_state::use_controllable_model(cx, controlled, default_value)
31}
32
33/// Matches Radix Tabs `orientation` outcome: horizontal (default) vs vertical layout.
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
35pub enum TabsOrientation {
36    #[default]
37    Horizontal,
38    Vertical,
39}
40
41/// Matches Radix Tabs `activationMode` outcome:
42/// - `Automatic`: moving focus (arrow keys) activates the tab.
43/// - `Manual`: moving focus does not activate; activation happens on click/Enter/Space.
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
45pub enum TabsActivationMode {
46    #[default]
47    Automatic,
48    Manual,
49}
50
51/// Mirrors the Radix `TabsTrigger` `onMouseDown` behavior:
52/// - left mouse down selects the tab,
53/// - other mouse buttons / ctrl-click do not select and should avoid focusing the trigger.
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55pub enum TabsTriggerPointerDownAction {
56    Select,
57    PreventFocus,
58    Ignore,
59}
60
61/// Decide what to do with a pointer down event on a tabs trigger.
62///
63/// Radix selects on `onMouseDown` (left button only, no ctrl key) and prevents focus for other
64/// mouse downs. Touch/pen are ignored here so they can keep the click-like "activate on up"
65/// behavior.
66pub fn tabs_trigger_pointer_down_action(
67    pointer_type: PointerType,
68    button: MouseButton,
69    modifiers: Modifiers,
70    disabled: bool,
71) -> TabsTriggerPointerDownAction {
72    match pointer_type {
73        PointerType::Touch | PointerType::Pen => TabsTriggerPointerDownAction::Ignore,
74        PointerType::Mouse | PointerType::Unknown => {
75            if disabled {
76                return TabsTriggerPointerDownAction::PreventFocus;
77            }
78
79            if button == MouseButton::Left && !modifiers.ctrl {
80                TabsTriggerPointerDownAction::Select
81            } else {
82                TabsTriggerPointerDownAction::PreventFocus
83            }
84        }
85    }
86}
87
88/// A11y metadata for a tab-like pressable.
89pub fn tab_a11y(label: Option<Arc<str>>, selected: bool) -> PressableA11y {
90    PressableA11y {
91        role: Some(SemanticsRole::Tab),
92        label,
93        selected,
94        ..Default::default()
95    }
96}
97
98/// A11y metadata for a tab-like pressable with collection position metadata.
99///
100/// This aligns with the APG/Radix expectation that tabs participate in a logical set and can
101/// expose their 1-based position within that set.
102pub fn tab_a11y_with_collection(
103    label: Option<Arc<str>>,
104    selected: bool,
105    pos_in_set: Option<u32>,
106    set_size: Option<u32>,
107) -> PressableA11y {
108    PressableA11y {
109        role: Some(SemanticsRole::Tab),
110        label,
111        selected,
112        pos_in_set,
113        set_size,
114        ..Default::default()
115    }
116}
117
118/// A11y metadata for a `TabsList` container.
119pub fn tab_list_semantics_props(
120    layout: LayoutStyle,
121    orientation: TabsOrientation,
122) -> SemanticsProps {
123    SemanticsProps {
124        layout,
125        role: SemanticsRole::TabList,
126        orientation: Some(match orientation {
127            TabsOrientation::Horizontal => SemanticsOrientation::Horizontal,
128            TabsOrientation::Vertical => SemanticsOrientation::Vertical,
129        }),
130        ..Default::default()
131    }
132}
133
134/// Maps a selected `value` (string key) to the active index, skipping disabled items.
135///
136/// This is the Radix outcome "value controls which trigger is active", expressed in Fret terms.
137pub fn active_index_from_values(
138    values: &[Arc<str>],
139    selected: Option<&str>,
140    disabled: &[bool],
141) -> Option<usize> {
142    crate::headless::roving_focus::active_index_from_str_keys(values, selected, disabled)
143}
144
145/// Builds semantics props for a `TabPanel` node.
146pub fn tab_panel_semantics_props(
147    layout: LayoutStyle,
148    label: Option<Arc<str>>,
149    labelled_by_element: Option<u64>,
150) -> SemanticsProps {
151    SemanticsProps {
152        layout,
153        role: SemanticsRole::TabPanel,
154        label,
155        labelled_by_element,
156        // Radix `TabsContent` uses `tabIndex={0}` on the active tab panel. Express that outcome as a
157        // focusable semantics node in Fret.
158        focusable: true,
159        ..Default::default()
160    }
161}
162
163/// Builds a tab panel subtree, optionally force-mounting it behind an interactivity gate.
164///
165/// This is a Radix-aligned outcome wrapper for `TabsContent forceMount`:
166/// - When `force_mount=false`, inactive panels are not mounted.
167/// - When `force_mount=true`, inactive panels remain mounted but are not present/interactive.
168#[track_caller]
169pub fn tab_panel_with_gate<H: UiHost, I, T>(
170    cx: &mut ElementContext<'_, H>,
171    active: bool,
172    force_mount: bool,
173    layout: LayoutStyle,
174    label: Option<Arc<str>>,
175    labelled_by_element: Option<u64>,
176    children: impl FnOnce(&mut ElementContext<'_, H>) -> I,
177) -> Option<AnyElement>
178where
179    I: IntoIterator<Item = T>,
180    T: IntoUiElement<H>,
181{
182    if !active && !force_mount {
183        return None;
184    }
185
186    let panel = |cx: &mut ElementContext<'_, H>| {
187        cx.semantics(
188            tab_panel_semantics_props(layout, label, labelled_by_element),
189            move |cx| {
190                let items = children(cx);
191                collect_children(cx, items)
192            },
193        )
194    };
195
196    if force_mount {
197        Some(cx.interactivity_gate(active, active, |cx| vec![panel(cx)]))
198    } else {
199        Some(panel(cx))
200    }
201}
202
203/// A composable, Radix-shaped tabs configuration surface (`TabsRoot` / `TabsList` / `TabsTrigger` /
204/// `TabsContent`).
205///
206/// Unlike Radix React, Fret does not use context objects; the "composition" surface is expressed as
207/// small Rust builders that thread the shared models and option values through closures.
208#[derive(Debug, Clone)]
209pub struct TabsRoot {
210    model: Model<Option<Arc<str>>>,
211    disabled: bool,
212    orientation: TabsOrientation,
213    activation_mode: TabsActivationMode,
214    loop_navigation: bool,
215}
216
217impl TabsRoot {
218    pub fn new(model: Model<Option<Arc<str>>>) -> Self {
219        Self {
220            model,
221            disabled: false,
222            orientation: TabsOrientation::default(),
223            activation_mode: TabsActivationMode::default(),
224            loop_navigation: true,
225        }
226    }
227
228    pub fn model(&self) -> Model<Option<Arc<str>>> {
229        self.model.clone()
230    }
231
232    /// Creates a tabs root with a controlled/uncontrolled selection model (Radix `value` /
233    /// `defaultValue`).
234    ///
235    /// Notes:
236    /// - The internal model (uncontrolled mode) is stored in element state at the call site.
237    /// - Call this from a stable subtree (key the root node if you need state to survive reordering).
238    pub fn new_controllable<H: UiHost>(
239        cx: &mut ElementContext<'_, H>,
240        controlled: Option<Model<Option<Arc<str>>>>,
241        default_value: impl FnOnce() -> Option<Arc<str>>,
242    ) -> Self {
243        let model = tabs_use_value_model(cx, controlled, default_value).model();
244        Self::new(model)
245    }
246
247    pub fn disabled(mut self, disabled: bool) -> Self {
248        self.disabled = disabled;
249        self
250    }
251
252    pub fn orientation(mut self, orientation: TabsOrientation) -> Self {
253        self.orientation = orientation;
254        self
255    }
256
257    pub fn activation_mode(mut self, activation_mode: TabsActivationMode) -> Self {
258        self.activation_mode = activation_mode;
259        self
260    }
261
262    pub fn loop_navigation(mut self, loop_navigation: bool) -> Self {
263        self.loop_navigation = loop_navigation;
264        self
265    }
266
267    pub fn list(self, values: Arc<[Arc<str>]>, disabled: Arc<[bool]>) -> TabsList {
268        TabsList::new(self, values, disabled)
269    }
270
271    pub fn trigger(&self, value: impl Into<Arc<str>>) -> TabsTrigger {
272        TabsTrigger::new(value)
273    }
274
275    pub fn content(&self, value: impl Into<Arc<str>>) -> TabsContent {
276        TabsContent::new(value)
277    }
278}
279
280#[derive(Debug, Clone)]
281pub struct TabsList {
282    root: TabsRoot,
283    values: Arc<[Arc<str>]>,
284    disabled: Arc<[bool]>,
285    layout: LayoutStyle,
286}
287
288impl TabsList {
289    pub fn new(root: TabsRoot, values: Arc<[Arc<str>]>, disabled: Arc<[bool]>) -> Self {
290        Self {
291            root,
292            values,
293            disabled,
294            layout: LayoutStyle::default(),
295        }
296    }
297
298    pub fn layout(mut self, layout: LayoutStyle) -> Self {
299        self.layout = layout;
300        self
301    }
302
303    /// Renders a tabs list semantics root containing a roving-focus group.
304    ///
305    /// Notes:
306    /// - This does not apply any visual skin. Pass `flex` / `layout` via `RovingFlexProps`.
307    /// - This installs APG navigation and, in automatic mode, updates `TabsRoot.model` when the
308    ///   active tab changes.
309    #[track_caller]
310    pub fn into_element<H: UiHost, I, T>(
311        self,
312        cx: &mut ElementContext<'_, H>,
313        mut props: RovingFlexProps,
314        f: impl FnOnce(&mut ElementContext<'_, H>) -> I,
315    ) -> AnyElement
316    where
317        I: IntoIterator<Item = T>,
318        T: IntoUiElement<H>,
319    {
320        let model = self.root.model.clone();
321        let activation_mode = self.root.activation_mode;
322        let disabled_for_roving = self.disabled.clone();
323        let values_for_roving = self.values.clone();
324
325        props.flex.direction = match self.root.orientation {
326            TabsOrientation::Horizontal => fret_core::Axis::Horizontal,
327            TabsOrientation::Vertical => fret_core::Axis::Vertical,
328        };
329        props.roving = RovingFocusProps {
330            enabled: props.roving.enabled && !self.root.disabled,
331            wrap: self.root.loop_navigation,
332            disabled: disabled_for_roving,
333        };
334
335        let layout = self.layout;
336        cx.semantics(
337            tab_list_semantics_props(layout, self.root.orientation),
338            move |cx| {
339                vec![cx.roving_flex(props, move |cx| {
340                    cx.roving_nav_apg();
341                    if activation_mode == TabsActivationMode::Automatic {
342                        cx.roving_select_option_arc_str(&model, values_for_roving.clone());
343                    }
344                    let items = f(cx);
345                    collect_children(cx, items)
346                })]
347            },
348        )
349    }
350}
351
352#[derive(Debug, Clone)]
353pub struct TabsTrigger {
354    value: Arc<str>,
355    label: Option<Arc<str>>,
356    disabled: bool,
357    index: Option<usize>,
358    tab_stop: bool,
359    set_size: Option<u32>,
360}
361
362impl TabsTrigger {
363    pub fn new(value: impl Into<Arc<str>>) -> Self {
364        Self {
365            value: value.into(),
366            label: None,
367            disabled: false,
368            index: None,
369            tab_stop: false,
370            set_size: None,
371        }
372    }
373
374    pub fn label(mut self, label: impl Into<Arc<str>>) -> Self {
375        self.label = Some(label.into());
376        self
377    }
378
379    pub fn disabled(mut self, disabled: bool) -> Self {
380        self.disabled = disabled;
381        self
382    }
383
384    /// Optional 0-based index used to populate collection metadata (`pos_in_set`).
385    pub fn index(mut self, index: usize) -> Self {
386        self.index = Some(index);
387        self
388    }
389
390    /// Whether this trigger is the current "tab stop" in roving focus terms.
391    ///
392    /// When `true`, `PressableProps.focusable` will be enabled even when the element isn't focused.
393    pub fn tab_stop(mut self, tab_stop: bool) -> Self {
394        self.tab_stop = tab_stop;
395        self
396    }
397
398    /// Optional set size used to populate collection metadata (`set_size`).
399    pub fn set_size(mut self, set_size: Option<u32>) -> Self {
400        self.set_size = set_size;
401        self
402    }
403
404    /// Renders a `TabsTrigger` as a pressable, wiring Radix-like pointer and activation behavior.
405    ///
406    /// - Selects the tab on left mouse down (no ctrl key), matching Radix's `onMouseDown`.
407    /// - Activates selection on pressable "activate" as well (Enter/Space and click-like pointer up).
408    #[track_caller]
409    pub fn into_element<H: UiHost, I, T>(
410        self,
411        cx: &mut ElementContext<'_, H>,
412        root: &TabsRoot,
413        mut props: PressableProps,
414        f: impl FnOnce(&mut ElementContext<'_, H>) -> I,
415    ) -> AnyElement
416    where
417        I: IntoIterator<Item = T>,
418        T: IntoUiElement<H>,
419    {
420        let model = root.model.clone();
421        let value = self.value.clone();
422        let label = self.label.clone();
423        let disabled = self.disabled || root.disabled;
424        let tab_stop = self.tab_stop;
425        let pos_in_set = self
426            .index
427            .and_then(|idx| u32::try_from(idx.saturating_add(1)).ok());
428        let set_size = self.set_size;
429
430        cx.pressable_with_id_props(move |cx, st, _id| {
431            let value_for_pointer = value.clone();
432            let model_for_pointer = model.clone();
433
434            cx.pressable_add_on_pointer_down(Arc::new(move |host, _cx, down| {
435                use fret_ui::action::PressablePointerDownResult as R;
436
437                match tabs_trigger_pointer_down_action(
438                    down.pointer_type,
439                    down.button,
440                    down.modifiers,
441                    disabled,
442                ) {
443                    TabsTriggerPointerDownAction::Select => {
444                        let _ = host
445                            .models_mut()
446                            .update(&model_for_pointer, |v| *v = Some(value_for_pointer.clone()));
447                        R::Continue
448                    }
449                    TabsTriggerPointerDownAction::PreventFocus => {
450                        host.prevent_default(fret_runtime::DefaultAction::FocusOnPointerDown);
451                        R::SkipDefault
452                    }
453                    TabsTriggerPointerDownAction::Ignore => R::Continue,
454                }
455            }));
456
457            // Ensure Enter/Space activation (and click-like pointer-up) also selects this tab.
458            cx.pressable_set_option_arc_str(&model, value.clone());
459
460            let selected_value = cx.watch_model(&model).layout().cloned().flatten();
461            let selected = selected_value.as_deref() == Some(value.as_ref());
462
463            props.enabled = !disabled;
464            // Roving focus: only the tab stop participates in the default tab order.
465            props.focusable = (!disabled) && (tab_stop || st.focused);
466            props.a11y = tab_a11y_with_collection(label.clone(), selected, pos_in_set, set_size);
467
468            let items = f(cx);
469            (props, collect_children(cx, items))
470        })
471    }
472}
473
474#[derive(Debug, Clone)]
475pub struct TabsContent {
476    value: Arc<str>,
477    label: Option<Arc<str>>,
478    labelled_by_element: Option<u64>,
479    force_mount: bool,
480    layout: LayoutStyle,
481}
482
483impl TabsContent {
484    pub fn new(value: impl Into<Arc<str>>) -> Self {
485        Self {
486            value: value.into(),
487            label: None,
488            labelled_by_element: None,
489            force_mount: false,
490            layout: LayoutStyle::default(),
491        }
492    }
493
494    pub fn label(mut self, label: impl Into<Arc<str>>) -> Self {
495        self.label = Some(label.into());
496        self
497    }
498
499    pub fn labelled_by_element(mut self, labelled_by_element: Option<u64>) -> Self {
500        self.labelled_by_element = labelled_by_element;
501        self
502    }
503
504    pub fn force_mount(mut self, force_mount: bool) -> Self {
505        self.force_mount = force_mount;
506        self
507    }
508
509    pub fn layout(mut self, layout: LayoutStyle) -> Self {
510        self.layout = layout;
511        self
512    }
513
514    /// Renders a `TabsContent` (tab panel) subtree if it is active or force-mounted.
515    #[track_caller]
516    pub fn into_element<H: UiHost, I, T>(
517        self,
518        cx: &mut ElementContext<'_, H>,
519        root: &TabsRoot,
520        f: impl FnOnce(&mut ElementContext<'_, H>) -> I,
521    ) -> Option<AnyElement>
522    where
523        I: IntoIterator<Item = T>,
524        T: IntoUiElement<H>,
525    {
526        let selected_value: Option<Arc<str>> =
527            cx.watch_model(&root.model).layout().cloned().flatten();
528        let active = selected_value.as_deref() == Some(self.value.as_ref());
529        tab_panel_with_gate(
530            cx,
531            active,
532            self.force_mount,
533            self.layout,
534            self.label,
535            self.labelled_by_element,
536            f,
537        )
538    }
539}
540
541#[cfg(test)]
542mod tests {
543    use super::*;
544
545    use std::cell::Cell;
546
547    use fret_app::App;
548    use fret_core::{AppWindowId, Point, Px, Rect, Size};
549
550    fn bounds() -> Rect {
551        Rect::new(
552            Point::new(Px(0.0), Px(0.0)),
553            Size::new(Px(200.0), Px(120.0)),
554        )
555    }
556
557    #[test]
558    fn tabs_use_value_model_prefers_controlled_and_does_not_call_default() {
559        let window = AppWindowId::default();
560        let mut app = App::new();
561        let b = bounds();
562
563        let controlled = app.models_mut().insert(Some(Arc::from("a")));
564        let called = Cell::new(0);
565
566        fret_ui::elements::with_element_cx(&mut app, window, b, "test", |cx| {
567            let out = tabs_use_value_model(cx, Some(controlled.clone()), || {
568                called.set(called.get() + 1);
569                None
570            });
571            assert!(out.is_controlled());
572            assert_eq!(out.model(), controlled);
573        });
574
575        assert_eq!(called.get(), 0);
576    }
577
578    #[test]
579    fn tabs_trigger_pointer_down_selects_on_left_mouse_down() {
580        let action = tabs_trigger_pointer_down_action(
581            PointerType::Mouse,
582            MouseButton::Left,
583            Modifiers::default(),
584            false,
585        );
586        assert_eq!(action, TabsTriggerPointerDownAction::Select);
587    }
588
589    #[test]
590    fn tabs_trigger_pointer_down_prevents_focus_on_ctrl_click() {
591        let mut modifiers = Modifiers::default();
592        modifiers.ctrl = true;
593
594        let action = tabs_trigger_pointer_down_action(
595            PointerType::Mouse,
596            MouseButton::Left,
597            modifiers,
598            false,
599        );
600        assert_eq!(action, TabsTriggerPointerDownAction::PreventFocus);
601    }
602
603    #[test]
604    fn tabs_trigger_pointer_down_ignores_touch_to_preserve_click_like_activation() {
605        let action = tabs_trigger_pointer_down_action(
606            PointerType::Touch,
607            MouseButton::Left,
608            Modifiers::default(),
609            false,
610        );
611        assert_eq!(action, TabsTriggerPointerDownAction::Ignore);
612    }
613
614    #[test]
615    fn tab_panel_semantics_props_sets_role_and_labelled_by() {
616        let props =
617            tab_panel_semantics_props(LayoutStyle::default(), Some(Arc::from("Panel")), Some(123));
618        assert_eq!(props.role, SemanticsRole::TabPanel);
619        assert_eq!(props.label.as_deref(), Some("Panel"));
620        assert_eq!(props.labelled_by_element, Some(123));
621        assert!(
622            props.focusable,
623            "tabpanel should be focusable like Radix tabIndex=0"
624        );
625    }
626
627    #[test]
628    fn tab_list_semantics_props_sets_role_and_orientation() {
629        let props = tab_list_semantics_props(LayoutStyle::default(), TabsOrientation::Vertical);
630        assert_eq!(props.role, SemanticsRole::TabList);
631        assert_eq!(
632            props.orientation,
633            Some(fret_core::SemanticsOrientation::Vertical)
634        );
635    }
636}