1use 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#[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 #[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 #[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 #[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}