nightshade_api/web/
search_list.rs1use leptos::prelude::*;
4
5#[derive(Clone)]
8pub struct SearchItem {
9 pub id: String,
11 pub title: String,
13 pub subtitle: String,
15 pub detail: String,
17}
18
19impl SearchItem {
20 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 pub fn with_subtitle(mut self, subtitle: impl Into<String>) -> Self {
32 self.subtitle = subtitle.into();
33 self
34 }
35
36 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#[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}