Skip to main content

yggdrasil/components/
palette.rs

1//! ⌘K 面板公共部分:打开句柄、全局快捷键、文本匹配,以及前台搜索面板与后台命令面板
2//! 共用的状态、对话框外壳和行组件。两个面板只提供各自的数据、条目映射与选中动作。
3//! 样式见 styles/components/palette.css。
4
5use std::future::Future;
6
7use leptos::prelude::*;
8
9use crate::components::mascot::Mascot;
10use crate::components::ui::{Icon, IconName};
11
12/// 输入防抖与进度点出现的延迟(毫秒)。
13const DEBOUNCE_MS: u32 = 150;
14const PROGRESS_DELAY_MS: u32 = 300;
15/// 关闭淡出时长(毫秒),与 `palette.css` 的 `palette-out` 时长一致。
16const EXIT_MS: u32 = 120;
17
18/// 打开 ⌘K 面板的句柄。前台布局挂搜索面板,后台布局挂命令面板(`admin_palette`),
19/// 两边的触发按钮与快捷键共用这个句柄。
20#[derive(Clone, Copy, PartialEq)]
21pub struct SearchPaletteHandle {
22    pub(crate) open: RwSignal<bool>,
23}
24
25impl SearchPaletteHandle {
26    /// 打开面板。栏目/主题过渡进行中时先跳到终态:浏览器把过渡快照画在顶层之上,
27    /// 面板会被新正文盖住;跳过只结束动画,导航与换色照常完成。
28    pub fn open(self) {
29        #[cfg(target_arch = "wasm32")]
30        crate::bridges::globals::skip_view_transition();
31        self.open.set(true);
32    }
33}
34
35/// 在布局中提供面板句柄。
36pub fn use_search_palette_provider() -> SearchPaletteHandle {
37    let handle = SearchPaletteHandle {
38        open: RwSignal::new(false),
39    };
40    provide_context(handle);
41    handle
42}
43
44/// 读取面板句柄;图鉴等不在前台布局内的场景返回 `None`。
45pub fn use_search_palette() -> Option<SearchPaletteHandle> {
46    use_context::<SearchPaletteHandle>()
47}
48
49/// 忽略大小写的子串匹配,空查询视为匹配。
50pub fn contains_ci(text: &str, query: &str) -> bool {
51    let q = query.trim();
52    q.is_empty() || text.to_lowercase().contains(&q.to_lowercase())
53}
54
55/// 按忽略大小写的命中切分文本,`true` 段需要高亮。按字符比较,中文与西文都适用。
56pub fn highlight(text: &str, query: &str) -> Vec<(String, bool)> {
57    let q: Vec<char> = query.trim().to_lowercase().chars().collect();
58    let chars: Vec<char> = text.chars().collect();
59    if q.is_empty() {
60        return vec![(text.to_string(), false)];
61    }
62    let lower: Vec<char> = chars
63        .iter()
64        .map(|c| c.to_lowercase().next().unwrap_or(*c))
65        .collect();
66    let mut out = Vec::new();
67    let mut plain = String::new();
68    let mut i = 0;
69    while i < chars.len() {
70        if i + q.len() <= chars.len() && lower[i..i + q.len()] == q[..] {
71            if !plain.is_empty() {
72                out.push((std::mem::take(&mut plain), false));
73            }
74            out.push((chars[i..i + q.len()].iter().collect(), true));
75            i += q.len();
76        } else {
77            plain.push(chars[i]);
78            i += 1;
79        }
80    }
81    if !plain.is_empty() {
82        out.push((plain, false));
83    }
84    out
85}
86
87/// 摘要片段:截取第一处命中前后 `radius` 个字,两端被截断时加省略号;
88/// 没有命中时取开头。
89pub fn snippet(text: &str, query: &str, radius: usize) -> String {
90    let chars: Vec<char> = text.chars().collect();
91    let q: Vec<char> = query.trim().to_lowercase().chars().collect();
92    let lower: Vec<char> = chars
93        .iter()
94        .map(|c| c.to_lowercase().next().unwrap_or(*c))
95        .collect();
96    let hit = (!q.is_empty())
97        .then(|| {
98            (0..chars.len()).find(|&i| i + q.len() <= chars.len() && lower[i..i + q.len()] == q[..])
99        })
100        .flatten()
101        .unwrap_or(0);
102    let start = hit.saturating_sub(radius);
103    let end = (hit + q.len() + radius).min(chars.len());
104    let mut out = String::new();
105    if start > 0 {
106        out.push('…');
107    }
108    out.extend(&chars[start..end]);
109    if end < chars.len() {
110        out.push('…');
111    }
112    out
113}
114
115/// 视口中心:从面板切换外观时,圆形扩散以此为圆心。
116pub fn viewport_center() -> (f64, f64) {
117    #[cfg(target_arch = "wasm32")]
118    {
119        web_sys::window()
120            .map(|w| {
121                let width = w.inner_width().ok().and_then(|v| v.as_f64()).unwrap_or(0.0);
122                let height = w
123                    .inner_height()
124                    .ok()
125                    .and_then(|v| v.as_f64())
126                    .unwrap_or(0.0);
127                (width / 2.0, height / 2.0)
128            })
129            .unwrap_or((0.0, 0.0))
130    }
131    #[cfg(not(target_arch = "wasm32"))]
132    (0.0, 0.0)
133}
134
135/// 按 id 聚焦元素(仅 wasm)。
136#[cfg(target_arch = "wasm32")]
137pub fn focus_by_id(element_id: &str) {
138    use wasm_bindgen::JsCast;
139    if let Some(el) = web_sys::window()
140        .and_then(|w| w.document())
141        .and_then(|d| d.get_element_by_id(element_id))
142        .and_then(|el| el.dyn_into::<web_sys::HtmlElement>().ok())
143    {
144        let _ = el.focus();
145    }
146}
147
148/// 全局 ⌘K / Ctrl+K:焦点在可编辑区域时不拦截。
149///
150/// 须在组件函数体内调用;监听挂在 `window` 上,组件卸载时经 `on_cleanup` 移除。
151/// 服务端(非 wasm32)无 DOM,什么都不做,两端调用方代码一致。
152#[cfg_attr(not(target_arch = "wasm32"), allow(unused_variables))]
153pub fn use_palette_shortcut(handle: SearchPaletteHandle, enabled: bool) {
154    #[cfg(target_arch = "wasm32")]
155    {
156        use wasm_bindgen::JsCast;
157        let listener = window_event_listener(
158            leptos::ev::keydown,
159            move |event: leptos::ev::KeyboardEvent| {
160                if !enabled
161                    || !(event.meta_key() || event.ctrl_key())
162                    || event.key().to_lowercase() != "k"
163                {
164                    return;
165                }
166                let editable = event
167                    .target()
168                    .and_then(|t| t.dyn_into::<web_sys::HtmlElement>().ok())
169                    .is_some_and(|el| {
170                        el.is_content_editable()
171                            || matches!(el.tag_name().as_str(), "INPUT" | "TEXTAREA" | "SELECT")
172                    });
173                let palette_open = handle.open.get_untracked();
174                if editable && !palette_open {
175                    return;
176                }
177                event.prevent_default();
178                if !palette_open {
179                    handle.open();
180                }
181            },
182        );
183        on_cleanup(move || listener.remove());
184    }
185}
186
187/// 面板的公共状态:打开、输入、当前项、请求进度与焦点归还。由面板组件经
188/// [`use_palette_state`] 创建,交给 [`PaletteShell`] 渲染。
189#[derive(Clone, Copy, PartialEq)]
190pub struct PaletteState {
191    pub open: RwSignal<bool>,
192    pub query: RwSignal<String>,
193    pub active: RwSignal<usize>,
194    loading: RwSignal<bool>,
195    show_progress: RwSignal<bool>,
196    composing: RwSignal<bool>,
197    seq: RwSignal<u64>,
198    /// 已关闭但仍在淡出:面板继续渲染,输入与结果保持原样,淡出结束后复位。
199    closing: RwSignal<bool>,
200    /// DOM id 前缀:输入框 `{prefix}-input`、列表 `{prefix}-list`、选项 `{prefix}-opt-{i}`。
201    id_prefix: &'static str,
202    /// 打开前的焦点,关闭后还回去。
203    #[cfg(target_arch = "wasm32")]
204    previous_focus: StoredValue<Option<web_sys::HtmlElement>, LocalStorage>,
205}
206
207/// 创建面板状态:打开时记下原焦点并聚焦输入框。全局快捷键由各面板的生产入口
208/// 自己用 [`use_palette_shortcut`] 注册,这里不注册。
209///
210/// 须在已 [`use_search_palette_provider`] 的布局内调用。
211pub fn use_palette_state(id_prefix: &'static str) -> PaletteState {
212    let handle = expect_context::<SearchPaletteHandle>();
213    let state = PaletteState {
214        open: handle.open,
215        query: RwSignal::new(String::new()),
216        active: RwSignal::new(0_usize),
217        loading: RwSignal::new(false),
218        show_progress: RwSignal::new(false),
219        composing: RwSignal::new(false),
220        seq: RwSignal::new(0_u64),
221        closing: RwSignal::new(false),
222        id_prefix,
223        #[cfg(target_arch = "wasm32")]
224        previous_focus: StoredValue::new_local(None),
225    };
226
227    Effect::new(move |_| {
228        if !state.open.get() {
229            return;
230        }
231        state.closing.set(false);
232        #[cfg(target_arch = "wasm32")]
233        {
234            use wasm_bindgen::JsCast;
235            state.previous_focus.set_value(
236                web_sys::window()
237                    .and_then(|w| w.document())
238                    .and_then(|d| d.active_element())
239                    .and_then(|el| el.dyn_into::<web_sys::HtmlElement>().ok()),
240            );
241        }
242        leptos::task::spawn_local(async move {
243            crate::utils::time::sleep_ms(0).await;
244            state.focus_input();
245        });
246    });
247    state
248}
249
250impl PaletteState {
251    /// 聚焦输入框(仅 wasm)。
252    pub fn focus_input(self) {
253        #[cfg(target_arch = "wasm32")]
254        focus_by_id(&format!("{}-input", self.id_prefix));
255    }
256
257    /// 面板是否在屏上:打开,或正在淡出(关闭后保留 `EXIT_MS`,输入与结果原样不动)。
258    pub fn visible(self) -> bool {
259        self.open.get() || self.closing.get()
260    }
261
262    /// 关闭面板:丢弃未完成的请求与防抖,立即把焦点还给打开前的元素,面板淡出
263    /// `EXIT_MS` 后复位输入,并调用 `reset` 让调用方复位自己的状态。已关闭时什么都不做;
264    /// 淡出期间又被打开则保留上一次输入,不复位。
265    pub fn close(self, reset: impl FnOnce() + 'static) {
266        if !self.open.get_untracked() {
267            return;
268        }
269        self.seq.update(|seq| *seq += 1);
270        self.loading.set(false);
271        self.show_progress.set(false);
272        self.open.set(false);
273        self.closing.set(true);
274        #[cfg(target_arch = "wasm32")]
275        if let Some(Some(el)) = self.previous_focus.try_update_value(Option::take) {
276            let _ = el.focus();
277        }
278        leptos::task::spawn_local(async move {
279            crate::utils::time::sleep_ms(EXIT_MS).await;
280            // 淡出期间布局可能已卸载(信号被释放),此时直接放弃复位。
281            if self.open.try_get_untracked().unwrap_or(true) {
282                return;
283            }
284            self.query.set(String::new());
285            self.active.set(0);
286            reset();
287            self.closing.set(false);
288        });
289    }
290
291    /// 防抖后请求远端结果。空查询直接清空;`fetch` 返回 `None`(失败或被限流)时
292    /// 保留上一次结果,不闪空列表。
293    pub fn schedule_fetch<T, F, Fut>(
294        self,
295        results: RwSignal<Vec<T>>,
296        results_query: RwSignal<String>,
297        fetch: F,
298    ) where
299        T: Send + Sync + 'static,
300        F: FnOnce(String) -> Fut + 'static,
301        Fut: Future<Output = Option<Vec<T>>> + 'static,
302    {
303        self.seq.update(|seq| *seq += 1);
304        let id = self.seq.get_untracked();
305        let q = self.query.get_untracked().trim().to_string();
306        if q.is_empty() {
307            results.set(Vec::new());
308            results_query.set(String::new());
309            self.loading.set(false);
310            self.show_progress.set(false);
311            return;
312        }
313        leptos::task::spawn_local(async move {
314            crate::utils::time::sleep_ms(DEBOUNCE_MS).await;
315            if self.seq.try_get_untracked() != Some(id)
316                || self.composing.try_get_untracked().unwrap_or(true)
317            {
318                return;
319            }
320            self.loading.set(true);
321            leptos::task::spawn_local(async move {
322                crate::utils::time::sleep_ms(PROGRESS_DELAY_MS).await;
323                if self.seq.try_get_untracked() == Some(id)
324                    && self.loading.try_get_untracked().unwrap_or(false)
325                {
326                    self.show_progress.set(true);
327                }
328            });
329            let res = fetch(q.clone()).await;
330            if self.seq.try_get_untracked() != Some(id) {
331                return;
332            }
333            if let Some(items) = res {
334                results.set(items);
335                results_query.set(q);
336            }
337            self.loading.set(false);
338            self.show_progress.set(false);
339        });
340    }
341}
342
343/// 面板一行的展示内容。
344#[derive(Clone, Debug, PartialEq)]
345pub struct PaletteEntry {
346    pub icon: IconName,
347    pub title: String,
348    /// 标题下的摘要片段(只有前台文章有)。
349    pub summary: Option<String>,
350    /// 右侧元信息,空串不显示。
351    pub meta: String,
352    pub mono_meta: bool,
353    /// 是否用 `<mark>` 标出命中(最近搜索、外观切换不标)。
354    pub highlight_hits: bool,
355}
356
357/// 面板中的一组条目(组标签 + 若干行)。
358#[derive(Clone, Debug, PartialEq)]
359pub struct PaletteGroup {
360    pub label: &'static str,
361    pub entries: Vec<PaletteEntry>,
362}
363
364/// 把文本按命中切成纯文本与 `<mark>` 片段。
365fn highlighted(text: &str, query: &str) -> impl IntoView {
366    highlight(text, query)
367        .into_iter()
368        .map(|(text, hit)| {
369            if hit {
370                view! { <mark>{text}</mark> }.into_any()
371            } else {
372                text.into_any()
373            }
374        })
375        .collect_view()
376}
377
378/// 面板中的一行:左侧图标块,中间标题(可带摘要片段),右侧元信息或 ↵ 键帽。
379#[component]
380fn PaletteRow(
381    id: String,
382    entry: PaletteEntry,
383    query: String,
384    #[prop(into)] selected: Signal<bool>,
385    #[prop(into)] on_hover: Callback<()>,
386    #[prop(into)] on_pick: Callback<bool>,
387) -> impl IntoView {
388    let highlight_query = if entry.highlight_hits {
389        query
390    } else {
391        String::new()
392    };
393    let meta_class = if entry.mono_meta {
394        "palette-option-meta font-mono"
395    } else {
396        "palette-option-meta"
397    };
398    let title = highlighted(&entry.title, &highlight_query);
399    let summary = entry.summary.map(|summary| {
400        view! { <span class="palette-option-summary">{highlighted(&summary, &highlight_query)}</span> }
401    });
402    let meta = entry.meta;
403    view! {
404        <div
405            id=id
406            class="palette-option"
407            role="option"
408            aria-selected=move || selected.get().to_string()
409            // 只有鼠标真的移动才改变当前项,列表滚动时当前项不会乱跳。
410            on:mousemove=move |_| {
411                if !selected.get_untracked() {
412                    on_hover.run(());
413                }
414            }
415            on:mousedown=move |e| e.prevent_default()
416            on:click=move |e| on_pick.run(e.meta_key() || e.ctrl_key())
417        >
418            <span class="palette-option-tile">
419                <Icon name=entry.icon size=15/>
420            </span>
421            <span class="palette-option-text">
422                <span class="palette-option-title">{title}</span>
423                {summary}
424            </span>
425            {move || {
426                if selected.get() {
427                    view! { <kbd class="palette-enter">"↵"</kbd> }.into_any()
428                } else if !meta.is_empty() {
429                    view! { <span class=meta_class>{meta.clone()}</span> }.into_any()
430                } else {
431                    ().into_any()
432                }
433            }}
434        </div>
435    }
436}
437
438/// 面板对话框外壳:scrim、输入行、列表、空结果与页脚。只在面板打开时渲染。
439///
440/// 输入、键位与列表渲染都在这里;数据、条目选中动作和各插槽内容由面板提供。
441///
442/// Props:
443/// - `state`:[`use_palette_state`] 创建的公共状态
444/// - `label`:对话框 aria-label,同时作为 `trap_tab` 的名称("搜索" / "命令面板")
445/// - `input_label` / `placeholder` / `list_label`:输入框与列表的无障碍名称、占位文案
446/// - `enter_hint`:页脚 ↵ 的说明("打开" / "执行")
447/// - `empty_hint`:空结果提示文案
448/// - `groups`:当前要显示的分组(扁平序号按组顺序累计)
449/// - `waiting`:远端结果对应的查询与当前输入不同(与 loading 同时为真时不显示空结果)
450/// - `clearable`:有输入时在输入行显示清除按钮
451/// - `toolbar`:输入行与列表之间的插槽(前台范围标签)
452/// - `empty_action`:空结果提示下方的插槽(前台「在全文中搜索」)
453/// - `footer_action`:页脚快捷键说明右侧的插槽(前台「全文搜索」)
454/// - `on_query_change`:输入变化(非输入法组合期间)或清空后重新检索
455/// - `on_pick`:选中条目,参数 `(扁平序号, 是否按住 ⌘/Ctrl)`
456/// - `on_close`:点击遮罩、Esc 按钮或空输入时按 Escape
457#[component]
458pub(crate) fn PaletteShell(
459    state: PaletteState,
460    label: &'static str,
461    input_label: &'static str,
462    placeholder: &'static str,
463    list_label: &'static str,
464    enter_hint: &'static str,
465    empty_hint: &'static str,
466    #[prop(into)] groups: Signal<Vec<PaletteGroup>>,
467    #[prop(into)] waiting: Signal<bool>,
468    #[prop(optional)] clearable: bool,
469    #[prop(optional, into)] toolbar: ViewFn,
470    #[prop(optional, into)] empty_action: ViewFn,
471    #[prop(optional, into)] footer_action: ViewFn,
472    #[prop(into)] on_query_change: Callback<()>,
473    #[prop(into)] on_pick: Callback<(usize, bool)>,
474    #[prop(into)] on_close: Callback<()>,
475) -> impl IntoView {
476    let query = state.query;
477    let active = state.active;
478    let composing = state.composing;
479    let prefix = state.id_prefix;
480    let closing = move || !state.open.get();
481
482    let has_query = move || !query.read().trim().is_empty();
483    let total =
484        Memo::new(move |_| groups.with(|g| g.iter().map(|g| g.entries.len()).sum::<usize>()));
485    let active_idx = move || {
486        let total = total.get();
487        if total == 0 {
488            0
489        } else {
490            active.get().min(total - 1)
491        }
492    };
493    let no_results =
494        move || has_query() && total.get() == 0 && !(waiting.get() && state.loading.get());
495
496    // 清空输入并重新检索(Escape 与清除按钮共用)。
497    let reset_query = move || {
498        query.set(String::new());
499        active.set(0);
500        on_query_change.run(());
501    };
502
503    // 面板挂载(含首次打开时分包刚加载完)后聚焦输入框;淡出中的重建不抢焦点。
504    // NodeRef 在元素创建时就被填充,此时元素可能还没插入文档(异步分包渲染),
505    // 所以等下一帧再聚焦。
506    let input_ref = NodeRef::<leptos::html::Input>::new();
507    Effect::new(move |_| {
508        let input = input_ref.get();
509        #[cfg(target_arch = "wasm32")]
510        if let Some(input) = input {
511            request_animation_frame(move || {
512                if state.open.try_get_untracked().unwrap_or(false) {
513                    let _ = input.focus();
514                }
515            });
516        }
517        #[cfg(not(target_arch = "wasm32"))]
518        let _ = input;
519    });
520
521    view! {
522        <div
523            class=move || if closing() { "palette-scrim is-closing" } else { "palette-scrim" }
524            on:click=move |_| on_close.run(())
525        ></div>
526        <div
527            class=move || if closing() { "palette is-closing" } else { "palette" }
528            role="dialog"
529            aria-modal="true"
530            aria-label=label
531            // 复用弹窗的焦点锁定(Tab 只在面板内循环)。
532            data-ygg-modal-panel="true"
533            data-ygg-modal-open=move || if closing() { "false" } else { "true" }
534            inert=move || closing().then_some("true")
535            on:keydown=move |e| {
536                if e.key() == "Tab" {
537                    #[cfg(target_arch = "wasm32")]
538                    crate::components::ui::trap_tab(&e, label);
539                }
540            }
541        >
542            <div class="palette-input-row">
543                <Icon name=IconName::Search size=18 class="palette-input-icon"/>
544                <input
545                    id=format!("{prefix}-input")
546                    node_ref=input_ref
547                    class="palette-input"
548                    type="text"
549                    role="combobox"
550                    aria-label=input_label
551                    aria-expanded="true"
552                    aria-controls=format!("{prefix}-list")
553                    aria-activedescendant=move || {
554                        (total.get() > 0).then(|| format!("{prefix}-opt-{}", active_idx()))
555                    }
556                    aria-autocomplete="list"
557                    autocomplete="off"
558                    spellcheck="false"
559                    enterkeyhint="go"
560                    maxlength="100"
561                    placeholder=placeholder
562                    prop:value=move || query.get()
563                    on:input=move |e| {
564                        query.set(event_target_value(&e));
565                        active.set(0);
566                        if !composing.get_untracked() {
567                            on_query_change.run(());
568                        }
569                    }
570                    on:compositionstart=move |_| composing.set(true)
571                    on:compositionend=move |_| {
572                        composing.set(false);
573                        on_query_change.run(());
574                    }
575                    on:keydown=move |e| {
576                        // 输入法组合期间(含 keyCode 229 的 Process 键)不处理任何按键。
577                        if crate::components::ui::ime_composing(composing.get_untracked(), &e) {
578                            return;
579                        }
580                        match e.key().as_str() {
581                            key @ ("ArrowDown" | "ArrowUp") => {
582                                e.prevent_default();
583                                let total = total.get_untracked();
584                                if total > 0 {
585                                    let delta: i32 = if key == "ArrowDown" { 1 } else { -1 };
586                                    active.set(
587                                        (active_idx() as i32 + delta).rem_euclid(total as i32) as usize,
588                                    );
589                                }
590                            }
591                            "Enter" => {
592                                e.prevent_default();
593                                if total.get_untracked() > 0 {
594                                    on_pick.run((active_idx(), e.meta_key() || e.ctrl_key()));
595                                }
596                            }
597                            "Escape" => {
598                                e.prevent_default();
599                                if query.read_untracked().is_empty() {
600                                    on_close.run(());
601                                } else {
602                                    reset_query();
603                                }
604                            }
605                            _ => {}
606                        }
607                    }
608                />
609                {move || {
610                    state
611                        .show_progress
612                        .get()
613                        .then(|| {
614                            view! {
615                                <span class="palette-progress" role="status" aria-label="正在搜索">
616                                    <span></span>
617                                    <span></span>
618                                    <span></span>
619                                </span>
620                            }
621                        })
622                }}
623                {move || {
624                    (clearable && has_query())
625                        .then(|| {
626                            view! {
627                                <button
628                                    class="btn btn-xs btn-icon btn-ghost"
629                                    type="button"
630                                    aria-label="清除输入"
631                                    on:click=move |_| {
632                                        reset_query();
633                                        state.focus_input();
634                                    }
635                                >
636                                    <Icon name=IconName::X size=14/>
637                                </button>
638                            }
639                        })
640                }}
641                <button class="palette-esc" type="button" on:click=move |_| on_close.run(())>
642                    <span class="palette-esc-key">"Esc"</span>
643                    <span class="palette-esc-cancel">"取消"</span>
644                </button>
645            </div>
646            {toolbar.run()}
647            <div
648                id=format!("{prefix}-list")
649                class="palette-list"
650                role="listbox"
651                aria-label=list_label
652            >
653                {move || {
654                    let q = query.get();
655                    let mut index = 0_usize;
656                    groups
657                        .get()
658                        .into_iter()
659                        .map(|group| {
660                            let rows = group
661                                .entries
662                                .into_iter()
663                                .map(|entry| {
664                                    let i = index;
665                                    index += 1;
666                                    view! {
667                                        <PaletteRow
668                                            id=format!("{prefix}-opt-{i}")
669                                            entry=entry
670                                            query=q.clone()
671                                            selected=Signal::derive(move || i == active_idx())
672                                            on_hover=move |_| active.set(i)
673                                            on_pick=move |new_tab: bool| on_pick.run((i, new_tab))
674                                        />
675                                    }
676                                })
677                                .collect_view();
678                            view! {
679                                <div role="group" aria-label=group.label>
680                                    <p class="palette-group-label">{group.label}</p>
681                                    {rows}
682                                </div>
683                            }
684                        })
685                        .collect_view()
686                }}
687                {move || {
688                    no_results()
689                        .then(|| {
690                            view! {
691                                <div class="palette-empty">
692                                    {Mascot::Panic.img("mascot palette-empty-mascot")}
693                                    <p class="palette-empty-title">
694                                        {move || format!("没有找到「{}」", query.read().trim())}
695                                    </p>
696                                    <p class="palette-empty-hint">{empty_hint}</p>
697                                    {empty_action.run()}
698                                </div>
699                            }
700                        })
701                }}
702            </div>
703            <div class="palette-footer">
704                <div class="palette-hints">
705                    <span>
706                        <kbd>"↑"</kbd>
707                        <kbd>"↓"</kbd>
708                        "选择"
709                    </span>
710                    <span>
711                        <kbd>"↵"</kbd>
712                        {enter_hint}
713                    </span>
714                    <span>
715                        <kbd>"Esc"</kbd>
716                        "关闭"
717                    </span>
718                </div>
719                {footer_action.run()}
720            </div>
721        </div>
722    }
723}
724
725#[cfg(test)]
726mod tests {
727    use super::*;
728
729    #[test]
730    fn highlight_marks_every_case_insensitive_hit() {
731        assert_eq!(
732            highlight("Rust 与 rust", "RUST"),
733            vec![
734                ("Rust".into(), true),
735                (" 与 ".into(), false),
736                ("rust".into(), true)
737            ]
738        );
739        assert_eq!(
740            highlight("源码剖析", "剖析"),
741            vec![("源码".into(), false), ("剖析".into(), true)]
742        );
743        assert_eq!(highlight("无命中", ""), vec![("无命中".into(), false)]);
744    }
745
746    #[test]
747    fn snippet_centers_on_the_first_hit_with_ellipses() {
748        let text = "一二三四五六七八九十命中词十一十二十三十四十五";
749        assert_eq!(snippet(text, "命中", 3), "…八九十命中词十一…");
750        assert_eq!(snippet("命中在开头", "命中", 2), "命中在开…");
751        // 没有命中时取开头。
752        assert_eq!(snippet("没有命中的一段话", "zz", 2), "没有命中…");
753    }
754
755    fn entry(title: &str) -> PaletteEntry {
756        PaletteEntry {
757            icon: IconName::FileText,
758            title: title.to_string(),
759            summary: None,
760            meta: "/x".to_string(),
761            mono_meta: true,
762            highlight_hits: true,
763        }
764    }
765
766    #[test]
767    fn shell_renders_dialog_combobox_and_grouped_options() {
768        let html = Owner::new().with(|| {
769            let handle = use_search_palette_provider();
770            handle.open.set(true);
771            let state = use_palette_state("palette");
772            state.query.set("ru".to_string());
773            view! {
774                <PaletteShell
775                    state=state
776                    label="搜索"
777                    input_label="搜索文章、标签或页面"
778                    placeholder="搜索…"
779                    list_label="搜索结果"
780                    enter_hint="打开"
781                    empty_hint="无命中"
782                    groups=vec![PaletteGroup { label: "文章", entries: vec![entry("Rust 笔记"), entry("Other")] }]
783                    waiting=false
784                    on_query_change=|_| {}
785                    on_pick=|_| {}
786                    on_close=|_| {}
787                />
788            }
789            .to_html()
790        });
791        // erase_components 下 SSR 在动态文本后输出 `<!>` 水合标记;断言只看内容与属性。
792        let html = html.replace("<!>", "");
793        assert!(html.contains("role=\"dialog\""), "{html}");
794        assert!(html.contains("data-ygg-modal-open=\"true\""), "{html}");
795        assert!(html.contains("id=\"palette-input\""), "{html}");
796        assert!(html.contains("role=\"combobox\""), "{html}");
797        assert!(
798            html.contains("aria-activedescendant=\"palette-opt-0\""),
799            "{html}"
800        );
801        assert!(html.contains("id=\"palette-opt-0\""), "{html}");
802        assert!(html.contains("id=\"palette-opt-1\""), "{html}");
803        // 首项选中显示 ↵ 键帽,命中词用 <mark> 标出。
804        assert!(
805            html.contains("<kbd class=\"palette-enter\">↵</kbd>"),
806            "{html}"
807        );
808        assert!(html.contains("<mark>Ru</mark>"), "{html}");
809    }
810}