1use 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
23pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
35pub enum TabsOrientation {
36 #[default]
37 Horizontal,
38 Vertical,
39}
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
45pub enum TabsActivationMode {
46 #[default]
47 Automatic,
48 Manual,
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55pub enum TabsTriggerPointerDownAction {
56 Select,
57 PreventFocus,
58 Ignore,
59}
60
61pub 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
88pub 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
98pub 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
118pub 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
134pub 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
145pub 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 focusable: true,
159 ..Default::default()
160 }
161}
162
163#[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#[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 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 #[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 pub fn index(mut self, index: usize) -> Self {
386 self.index = Some(index);
387 self
388 }
389
390 pub fn tab_stop(mut self, tab_stop: bool) -> Self {
394 self.tab_stop = tab_stop;
395 self
396 }
397
398 pub fn set_size(mut self, set_size: Option<u32>) -> Self {
400 self.set_size = set_size;
401 self
402 }
403
404 #[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 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 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 #[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}