Skip to main content

nightshade_api/web/
search_list.rs

1//! A filterable list of titled entries with an expandable detail pane.
2
3use leptos::prelude::*;
4
5/// An entry in a [`SearchList`], with a stable `id`, a `title` and `subtitle`
6/// (both searched), and a `detail` body shown when the entry is selected.
7#[derive(Clone)]
8pub struct SearchItem {
9    /// Stable identifier used for selection and callbacks.
10    pub id: String,
11    /// Primary text, matched against the search query.
12    pub title: String,
13    /// Secondary text, matched against the search query.
14    pub subtitle: String,
15    /// Longer body shown in a detail pane when the entry is active.
16    pub detail: String,
17}
18
19impl SearchItem {
20    /// Builds an item from an `id` and `title`, leaving subtitle and detail empty.
21    pub fn new(id: impl Into<String>, title: impl Into<String>) -> Self {
22        Self {
23            id: id.into(),
24            title: title.into(),
25            subtitle: String::new(),
26            detail: String::new(),
27        }
28    }
29
30    /// Sets the item's subtitle, returning the updated item.
31    pub fn with_subtitle(mut self, subtitle: impl Into<String>) -> Self {
32        self.subtitle = subtitle.into();
33        self
34    }
35
36    /// Sets the item's detail body, returning the updated item.
37    pub fn with_detail(mut self, detail: impl Into<String>) -> Self {
38        self.detail = detail.into();
39        self
40    }
41
42    fn matches(&self, needle: &str) -> bool {
43        needle.is_empty()
44            || self.title.to_lowercase().contains(needle)
45            || self.subtitle.to_lowercase().contains(needle)
46    }
47}
48
49fn dom_id(id: &str) -> String {
50    let sanitized: String = id
51        .chars()
52        .map(|character| {
53            if character.is_alphanumeric() {
54                character
55            } else {
56                '-'
57            }
58        })
59        .collect();
60    format!("nightshade-sl-{sanitized}")
61}
62
63/// A searchable list of `items` with a text input that filters by title and
64/// subtitle. The active entry expands to show its detail body and scrolls into
65/// view; `selected` and `on_select` track the current selection and
66/// `placeholder` customizes the search box.
67#[component]
68pub fn SearchList(
69    #[prop(into)] items: Signal<Vec<SearchItem>>,
70    #[prop(optional)] selected: Option<RwSignal<Option<String>>>,
71    #[prop(optional)] on_select: Option<Callback<String>>,
72    #[prop(into, optional)] placeholder: String,
73) -> impl IntoView {
74    let query = RwSignal::new(String::new());
75    let selected = selected.unwrap_or_else(|| RwSignal::new(None));
76    let placeholder = if placeholder.is_empty() {
77        "Search…".to_string()
78    } else {
79        placeholder
80    };
81
82    let filtered = move || {
83        let needle = query.get().to_lowercase();
84        items
85            .get()
86            .into_iter()
87            .filter(|item| item.matches(&needle))
88            .collect::<Vec<_>>()
89    };
90
91    Effect::new(move |_| {
92        if let Some(id) = selected.get()
93            && let Some(element) = web_sys::window()
94                .and_then(|window| window.document())
95                .and_then(|document| document.get_element_by_id(&dom_id(&id)))
96        {
97            element.scroll_into_view_with_bool(false);
98        }
99    });
100
101    view! {
102        <div class="nightshade-search-list">
103            <input
104                class="nightshade-search-list-input"
105                type="search"
106                placeholder=placeholder
107                prop:value=move || query.get()
108                on:input=move |event| query.set(event_target_value(&event))
109            />
110            <div class="nightshade-search-list-items">
111                {move || {
112                    let rows = filtered();
113                    if rows.is_empty() {
114                        return view! {
115                            <div class="nightshade-search-list-empty">"No matches"</div>
116                        }
117                            .into_any();
118                    }
119                    rows.into_iter()
120                        .map(|item| {
121                            let row_id = StoredValue::new(item.id.clone());
122                            let select_id = item.id.clone();
123                            let is_active = move || {
124                                row_id.with_value(|value| {
125                                    selected.get().as_deref() == Some(value.as_str())
126                                })
127                            };
128                            let subtitle = item.subtitle.clone();
129                            let detail = item.detail.clone();
130                            let on_row = move |_| {
131                                selected.set(Some(select_id.clone()));
132                                if let Some(callback) = on_select {
133                                    callback.run(select_id.clone());
134                                }
135                            };
136                            view! {
137                                <div id=dom_id(&item.id) class="nightshade-search-list-row">
138                                    <button
139                                        class="nightshade-search-list-head"
140                                        class:active=is_active
141                                        on:click=on_row
142                                    >
143                                        <span class="nightshade-search-list-title">{item.title}</span>
144                                        {(!subtitle.is_empty())
145                                            .then(|| {
146                                                view! {
147                                                    <span class="nightshade-search-list-subtitle">
148                                                        {subtitle}
149                                                    </span>
150                                                }
151                                            })}
152                                    </button>
153                                    {(!detail.is_empty())
154                                        .then(move || {
155                                            view! {
156                                                <Show when=is_active fallback=|| ()>
157                                                    <pre class="nightshade-search-list-detail">
158                                                        {detail.clone()}
159                                                    </pre>
160                                                </Show>
161                                            }
162                                        })}
163                                </div>
164                            }
165                        })
166                        .collect_view()
167                        .into_any()
168                }}
169            </div>
170        </div>
171    }
172}