Skip to main content

hypersig_ui/
tabs.rs

1//! Tabs: a tab list that shows one panel at a time.
2
3use hypersig::html::{El, Event, Orientation, button, div};
4use hypersig::style::value::{
5    AlignItems, BorderStyle, Color, Cursor, Display, FontWeight, JustifyContent, WhiteSpace, px,
6    rem,
7};
8use hypersig::style::{PseudoClass, Style};
9use hypersig::{Sig, if_else};
10use serde::Serialize;
11
12use crate::look;
13use crate::token;
14
15/// A tab list named `label` whose selection is the signal `selected`, with
16/// one panel per tab.
17///
18/// It follows the WAI-ARIA tabs pattern. The list has `role="tablist"`, each
19/// tab `role="tab"` with `aria-selected` and `aria-controls`, and each panel
20/// `role="tabpanel"` labeled by its tab. Tab enters the list at the selected
21/// tab, the arrow keys, Home, and End move between tabs, and a tab is
22/// selected as it gains focus, through [`El::roving_focus`]. The ids are
23/// derived from the signal's path and the tab's position.
24///
25/// ```
26/// use hypersig::Signals;
27/// use hypersig::html::p;
28/// use hypersig_ui::Tabs;
29/// use serde::{Deserialize, Serialize};
30///
31/// #[derive(Serialize, Deserialize, Clone, Copy, PartialEq)]
32/// enum Section {
33///     Account,
34///     Password,
35/// }
36///
37/// #[derive(Signals, Serialize, Deserialize)]
38/// struct Settings {
39///     section: Section,
40/// }
41///
42/// let tabs = Tabs::new("Settings", Settings::section())
43///     .tab(Section::Account, "Account", p().text("Your name and email."))
44///     .tab(Section::Password, "Password", p().text("Change your password."));
45/// let html = tabs.render().render();
46/// assert!(html.contains(r#"role="tablist""#));
47/// assert!(html.contains(r#"aria-controls="ui-section-panel-1""#));
48/// ```
49#[derive(Debug)]
50pub struct Tabs<S, T> {
51    label: String,
52    selected: Sig<S, T>,
53    entries: Vec<(T, String, El<S>)>,
54}
55
56impl<S, T: Serialize + PartialEq> Tabs<S, T> {
57    /// An empty tab list named `label` for assistive technology.
58    #[must_use]
59    pub fn new(label: impl Into<String>, selected: Sig<S, T>) -> Self {
60        Self {
61            label: label.into(),
62            selected,
63            entries: Vec::new(),
64        }
65    }
66
67    /// Adds a tab titled `title` that selects `value` and shows `panel`.
68    #[must_use]
69    pub fn tab(mut self, value: T, title: impl Into<String>, panel: El<S>) -> Self {
70        self.entries.push((value, title.into(), panel));
71        self
72    }
73
74    /// The tab list followed by the panels, in a `<div>`.
75    #[must_use]
76    pub fn render(self) -> El<S> {
77        let selected = self.selected;
78        let mut list = div()
79            .attr("role", "tablist")
80            .attr("aria-label", self.label)
81            .roving_focus(Orientation::Horizontal)
82            .css(list_style());
83        let mut panels = Vec::with_capacity(self.entries.len());
84        for (index, (value, title, panel)) in self.entries.into_iter().enumerate() {
85            let tab_id = crate::id::of(selected, &format!("tab-{index}"));
86            let panel_id = crate::id::of(selected, &format!("panel-{index}"));
87            let active = selected.equals(&value);
88            list = list.child(
89                button()
90                    .attr("type", "button")
91                    .attr("role", "tab")
92                    .id(tab_id.clone())
93                    .attr("aria-controls", panel_id.clone())
94                    .bind_attr("aria-selected", if_else(active.clone(), "true", "false"))
95                    .bind_attr("tabindex", if_else(active.clone(), "0", "-1"))
96                    .on(Event::Focus, selected.set(&value))
97                    .on_click(selected.set(&value))
98                    .css(tab_style())
99                    .css_when(active_style(), active.clone())
100                    .text(title),
101            );
102            panels.push(
103                div()
104                    .attr("role", "tabpanel")
105                    .id(panel_id)
106                    .attr("aria-labelledby", tab_id)
107                    .attr("tabindex", "0")
108                    .show(active)
109                    .css(Style::new().margin_top(rem(0.5)).merge(look::focus_ring()))
110                    .child(panel),
111            );
112        }
113        div().child(list).children(panels)
114    }
115}
116
117impl<S, T: Serialize + PartialEq> From<Tabs<S, T>> for El<S> {
118    fn from(tabs: Tabs<S, T>) -> Self {
119        tabs.render()
120    }
121}
122
123impl<S, T: Serialize + PartialEq> From<Tabs<S, T>> for hypersig::html::Node<S> {
124    fn from(tabs: Tabs<S, T>) -> Self {
125        tabs.render().into()
126    }
127}
128
129fn list_style() -> Style {
130    Style::new()
131        .display(Display::InlineFlex)
132        .align_items(AlignItems::Center)
133        .height(rem(2.25))
134        .padding(px(3))
135        .border_radius(token::radius().var())
136        .background_color(token::muted().var())
137        .color(token::muted_foreground().var())
138}
139
140fn tab_style() -> Style {
141    Style::new()
142        .display(Display::InlineFlex)
143        .align_items(AlignItems::Center)
144        .justify_content(JustifyContent::Center)
145        .height(hypersig::style::value::pct(100))
146        .padding_inline(rem(0.75))
147        .border_width(px(1))
148        .border_style(BorderStyle::Solid)
149        .border_color(Color::TRANSPARENT)
150        .border_radius(px(6))
151        .background_color(Color::TRANSPARENT)
152        .color(hypersig::style::value::Color::CURRENT_COLOR)
153        .font_weight(FontWeight::Number(500.0))
154        .white_space(WhiteSpace::Nowrap)
155        .cursor(Cursor::Pointer)
156        .merge(look::small_text())
157        .merge(look::transition())
158        .merge(look::focus_ring())
159        .on(
160            PseudoClass::Hover,
161            Style::new().color(token::foreground().var()),
162        )
163        .merge(look::disabled())
164}
165
166fn active_style() -> Style {
167    Style::new()
168        .background_color(token::background().var())
169        .color(token::foreground().var())
170        .box_shadow(hypersig::style::value::raw("0 1px 2px 0 rgb(0 0 0 / 0.05)"))
171}