yggdrasil/components/ui/
segmented.rs1use super::icons::{Icon, IconName};
3use leptos::prelude::*;
4
5#[derive(Clone, PartialEq)]
7pub struct SegmentedItem<T> {
8 pub value: T,
9 pub label: String,
10 pub icon: Option<IconName>,
11 pub count: Option<i64>,
12}
13
14impl<T> SegmentedItem<T> {
15 pub fn new(value: T, label: impl Into<String>) -> Self {
16 Self {
17 value,
18 label: label.into(),
19 icon: None,
20 count: None,
21 }
22 }
23
24 pub fn icon(mut self, icon: IconName) -> Self {
25 self.icon = Some(icon);
26 self
27 }
28
29 pub fn count(mut self, count: i64) -> Self {
30 self.count = Some(count);
31 self
32 }
33}
34
35#[derive(Clone, Copy, PartialEq, Default)]
38pub enum SegmentedKind {
39 #[default]
40 Tabs,
41 Radio,
42}
43
44#[derive(Clone, Copy, PartialEq, Default)]
45pub enum SegmentedSize {
46 #[default]
47 Md,
48 Sm,
49}
50
51fn neighbor_index(len: usize, current: Option<usize>, delta: i32) -> Option<usize> {
53 if len == 0 {
54 return None;
55 }
56 let cur = current.unwrap_or(0) as i32;
57 Some((cur + delta).rem_euclid(len as i32) as usize)
58}
59
60#[component]
84pub fn SegmentedControl<T>(
85 #[prop(into)] items: Signal<Vec<SegmentedItem<T>>>,
86 #[prop(into)] value: Signal<T>,
87 #[prop(into)] on_change: Callback<T>,
88 #[prop(into)] aria_label: String,
89 #[prop(optional)] kind: SegmentedKind,
90 #[prop(optional)] size: SegmentedSize,
91 #[prop(optional)] icon_only: bool,
92 #[prop(optional, into)] class: String,
93) -> impl IntoView
94where
95 T: Clone + PartialEq + Send + Sync + 'static,
96{
97 let thumb_style = RwSignal::new(None::<String>);
98 let track = NodeRef::<leptos::html::Div>::new();
99
100 let len = Memo::new(move |_| items.with(|items| items.len()));
101 let active_index = Memo::new(move |_| {
102 value.with(|v| items.with(|items| items.iter().position(|item| &item.value == v)))
103 });
104 let count_key = Memo::new(move |_| {
106 items.with(|items| items.iter().map(|item| item.count).collect::<Vec<_>>())
107 });
108
109 #[cfg(target_arch = "wasm32")]
111 let measure = move || {
112 use wasm_bindgen::JsCast;
113 let Some(track_el) = track.try_get_untracked() else {
115 return;
116 };
117 let style = track_el.and_then(|el| {
118 let active = el
119 .query_selector("[data-active]")
120 .ok()
121 .flatten()?
122 .dyn_into::<web_sys::HtmlElement>()
123 .ok()?;
124 (active.offset_width() > 0).then(|| {
125 format!(
127 "transform: translate({}px, {}px); width: {}px; height: {}px; opacity: 1;",
128 active.offset_left(),
129 active.offset_top(),
130 active.offset_width(),
131 active.offset_height()
132 )
133 })
134 });
135 if thumb_style
136 .try_get_untracked()
137 .is_some_and(|current| current != style)
138 {
139 thumb_style.try_set(style);
140 }
141 };
142
143 #[cfg(target_arch = "wasm32")]
145 {
146 use wasm_bindgen::prelude::Closure;
147 use wasm_bindgen::JsCast;
148 type ObserverSlot = Option<(web_sys::ResizeObserver, Closure<dyn FnMut()>)>;
149 let observer: StoredValue<ObserverSlot, LocalStorage> = StoredValue::new_local(None);
150 Effect::new(move |_| {
151 let Some(el) = track.get() else { return };
152 if observer.with_value(|o| o.is_some()) {
153 return;
154 }
155 let closure = Closure::wrap(Box::new(measure) as Box<dyn FnMut()>);
156 if let Ok(ro) = web_sys::ResizeObserver::new(closure.as_ref().unchecked_ref()) {
157 ro.observe(&el);
158 observer.set_value(Some((ro, closure)));
159 }
160 measure();
161 });
162 on_cleanup(move || {
163 if let Some(Some((ro, _closure))) = observer.try_update_value(|o| o.take()) {
164 ro.disconnect();
165 }
166 });
167 }
168
169 Effect::new(move |_| {
171 let _ = (active_index.get(), count_key.get());
172 #[cfg(target_arch = "wasm32")]
173 leptos::task::spawn_local(async move {
174 crate::utils::time::sleep_ms(30).await;
175 measure();
176 });
177 });
178
179 let is_tabs = kind == SegmentedKind::Tabs;
180 let wrap_class = move || {
181 let adaptive =
182 !icon_only && items.with(|items| items.iter().any(|item| item.icon.is_some()));
183 format!(
184 "segmented-wrap{}{}{} {class}",
185 if size == SegmentedSize::Sm {
186 " segmented-sm"
187 } else {
188 ""
189 },
190 if icon_only {
191 " segmented-icon-only"
192 } else {
193 ""
194 },
195 if adaptive { " segmented-adaptive" } else { "" },
196 )
197 };
198
199 let on_keydown = move |e: leptos::ev::KeyboardEvent| {
200 let len = len.get_untracked();
201 let current = active_index.get_untracked();
202 let next = match e.key().as_str() {
203 "ArrowRight" => neighbor_index(len, current, 1),
204 "ArrowLeft" => neighbor_index(len, current, -1),
205 "Home" => (len > 0).then_some(0),
206 "End" => len.checked_sub(1),
207 _ => None,
208 };
209 if let Some(next) = next {
210 e.prevent_default();
211 if let Some(value) =
212 items.with_untracked(|items| items.get(next).map(|item| item.value.clone()))
213 {
214 on_change.run(value);
215 }
216 #[cfg(target_arch = "wasm32")]
218 {
219 use wasm_bindgen::JsCast;
220 if let Some(el) = track
221 .get_untracked()
222 .and_then(|t| t.query_selector_all(".segmented-item").ok())
223 .and_then(|list| list.get(next as u32))
224 .and_then(|node| node.dyn_into::<web_sys::HtmlElement>().ok())
225 {
226 let _ = el.focus();
227 }
228 }
229 }
230 };
231
232 view! {
233 <div class=wrap_class>
234 <div
235 node_ref=track
236 class="segmented"
237 role=if is_tabs { "tablist" } else { "radiogroup" }
238 aria-label=aria_label
239 data-ready=move || thumb_style.with(|s| s.is_some()).then_some("true")
240 on:keydown=on_keydown
241 >
242 <span
243 class="segmented-thumb"
244 aria-hidden="true"
245 style=move || thumb_style.get().unwrap_or_else(|| "opacity: 0;".to_string())
246 ></span>
247 <For
248 each=move || 0..len.get()
249 key=|index| *index
250 children=move |index| {
251 let active = move || active_index.get() == Some(index);
252 let label = move || {
253 items
254 .with(|items| items.get(index).map(|item| item.label.clone()))
255 .unwrap_or_default()
256 };
257 let icon = move || {
258 items.with(|items| items.get(index).and_then(|item| item.icon))
259 };
260 let count = move || {
261 items.with(|items| items.get(index).and_then(|item| item.count))
262 };
263 view! {
264 <button
265 type="button"
266 class="segmented-item"
267 role=if is_tabs { "tab" } else { "radio" }
268 aria-selected=move || is_tabs.then(|| active().to_string())
269 aria-checked=move || (!is_tabs).then(|| active().to_string())
270 data-active=move || active().then_some("true")
271 tabindex=move || if active() { "0" } else { "-1" }
272 aria-label=move || icon_only.then(label)
273 title=move || icon_only.then(label)
274 on:click=move |_| {
275 if let Some(value) = items
276 .with_untracked(|items| {
277 items.get(index).map(|item| item.value.clone())
278 })
279 {
280 on_change.run(value);
281 }
282 }
283 >
284 {move || icon().map(|icon| view! { <Icon name=icon size=14/> })}
285 {(!icon_only)
286 .then(|| view! { <span class="segmented-label">{label}</span> })}
287 {move || count().map(|n| view! { <span class="segmented-count">{n}</span> })}
288 </button>
289 }
290 }
291 />
292 </div>
293 </div>
294 }
295}
296
297#[cfg(test)]
298mod tests {
299 use super::*;
300
301 #[test]
302 fn neighbor_index_wraps_in_both_directions() {
303 assert_eq!(neighbor_index(3, Some(0), 1), Some(1));
304 assert_eq!(neighbor_index(3, Some(2), 1), Some(0));
305 assert_eq!(neighbor_index(3, Some(0), -1), Some(2));
306 assert_eq!(neighbor_index(3, None, 1), Some(1));
308 assert_eq!(neighbor_index(0, None, 1), None);
309 }
310
311 #[test]
312 fn renders_tablist_with_active_item_and_counts() {
313 let html = Owner::new().with(|| {
314 view! {
315 <SegmentedControl
316 items=vec![
317 SegmentedItem::new("a".to_string(), "全部").count(3),
318 SegmentedItem::new("b".to_string(), "已发布"),
319 ]
320 value="b".to_string()
321 on_change=|_| {}
322 aria_label="筛选"
323 />
324 }
325 .to_html()
326 });
327 assert!(html.contains("role=\"tablist\""), "{html}");
328 assert!(html.contains("aria-label=\"筛选\""), "{html}");
329 assert_eq!(html.matches("data-active=\"true\"").count(), 1, "{html}");
330 assert!(html.contains("segmented-count"), "{html}");
331 }
332}