Skip to main content

yggdrasil/components/ui/
segmented.rs

1//! 分段控件:胶囊轨道 + 弹簧滑块。
2use super::icons::{Icon, IconName};
3use leptos::prelude::*;
4
5/// 分段控件的一项:值、文字,可选图标与数量。
6#[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/// 语义:Tabs = tablist/tab + aria-selected(切换视图、筛选);
36/// Radio = radiogroup/radio + aria-checked(设置取值)。
37#[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
51/// 循环移动到相邻项(←/→ 用);没有当前项时从第 0 项起算。
52fn 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/// 分段控件。
61///
62/// 胶囊轨道里放一组选项,当前项下是浮起的滑块,切换时以 snappy 弹簧滑到新位置。
63/// ←/→ 在选项间循环并立即切换,Home/End 跳到首尾,焦点随之移动(roving tabindex,
64/// Tab 只停在当前项)。滑块测量完成前(含服务端渲染与 hydration 首帧),当前项直接
65/// 画浮起底色,首屏不闪。
66///
67/// Props:
68/// - `items`:选项列表,见 [`SegmentedItem`];既可传 `Vec`,也可传信号或闭包(数量变化时
69///   选项按序号原地更新、不重建按钮,焦点不丢)
70/// - `value`:当前值(值、信号或闭包);没有匹配项时没有当前项
71/// - `on_change`:切换时的回调
72/// - `aria_label`:控件组的无障碍名称
73/// - `kind`:语义,Tabs(默认)或 Radio,见 [`SegmentedKind`]
74/// - `size`:Md(默认)或 Sm
75/// - `icon_only`:只显示图标,文字作为 aria-label 与 title
76/// - `class`:外层容器附加类(间距、页面变体)
77///
78/// 带图标且未设 `icon_only` 时,容器窄于 16rem 自动只留图标(文字仍供读屏)。
79/// 容器对宽度有尺寸包含,放进 flex 行时调用方必须给它宽度。
80///
81/// 选项按钮不再带 `seg-N-i` 形式的 `id`:该序号来自全局计数器,服务端(进程级)与
82/// 水合后的客户端计数不一致;焦点移动改为按序号在轨道内查询 `.segmented-item`。
83#[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    // 数量变化会改变按钮宽度,需要重新测量滑块。
105    let count_key = Memo::new(move |_| {
106        items.with(|items| items.iter().map(|item| item.count).collect::<Vec<_>>())
107    });
108
109    // 测量当前项的位置与尺寸;轨道被隐藏(宽度为 0)或没有当前项时回到未测量态。
110    #[cfg(target_arch = "wasm32")]
111    let measure = move || {
112        use wasm_bindgen::JsCast;
113        // 延迟测量与 ResizeObserver 回调可能晚于组件卸载:信号已释放时直接放弃。
114        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                // 显式写回不透明:样式字符串更新时,未测量时的 opacity 不会被移除。
126                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    // 轨道尺寸变化(容器查询切换文字显隐、从隐藏变为显示)时重新测量。
144    #[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    // 选中项或数量变化(宽度随之变化)时重新测量滑块。
170    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            // 焦点随选中项移动:按序号取轨道里的第 next 个选项。
217            #[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        // 没有当前项时从第 0 项起算
307        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}