radix_leptos_primitives/components/
list.rs1use leptos::*;
2use leptos::prelude::*;
3
4#[derive(Clone, Debug, PartialEq)]
6pub struct ListItem<T: Send + Sync + 'static> {
7 pub id: String,
8 pub data: T,
9 pub disabled: bool,
10 pub selected: bool,
11 pub focused: bool,
12}
13
14impl<T: Send + Sync + 'static> ListItem<T> {
15 pub fn new(id: String, data: T) -> Self {
16 Self {
17 id,
18 data,
19 disabled: false,
20 selected: false,
21 focused: false,
22 }
23 }
24
25 pub fn with_disabled(mut self, disabled: bool) -> Self {
26 self.disabled = disabled;
27 self
28 }
29
30 pub fn with_selected(mut self, selected: bool) -> Self {
31 self.selected = selected;
32 self
33 }
34
35 pub fn with_focused(mut self, focused: bool) -> Self {
36 self.focused = focused;
37 self
38 }
39}
40
41#[derive(Clone, Debug, PartialEq)]
43pub enum ListSize {
44 Small,
45 Medium,
46 Large,
47}
48
49impl ListSize {
50 pub fn as_str(&self) -> &'static str {
51 match self {
52 ListSize::Small => "small",
53 ListSize::Medium => "medium",
54 ListSize::Large => "large",
55 }
56 }
57}
58
59#[derive(Clone, Debug, PartialEq)]
61pub enum ListVariant {
62 Default,
63 Bordered,
64 Striped,
65 Compact,
66}
67
68impl ListVariant {
69 pub fn as_str(&self) -> &'static str {
70 match self {
71 ListVariant::Default => "default",
72 ListVariant::Bordered => "bordered",
73 ListVariant::Striped => "striped",
74 ListVariant::Compact => "compact",
75 }
76 }
77}
78
79#[derive(Clone)]
81pub struct ListContext<T: Send + Sync + 'static> {
82 pub items: Signal<Vec<ListItem<T>>>,
83 pub selected_items: Signal<Vec<String>>,
84 pub focused_item: Signal<Option<String>>,
85 pub size: ListSize,
86 pub variant: ListVariant,
87 pub multi_select: bool,
88 pub list_id: String,
89 pub on_selection_change: Option<Callback<Vec<String>>>,
90 pub on_item_click: Option<Callback<ListItem<T>>>,
91 pub on_item_focus: Option<Callback<ListItem<T>>>,
92}
93
94fn generate_id(prefix: &str) -> String {
96 static COUNTER: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
97 let id = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
98 format!("{}-{}", prefix, id)
99}
100
101fn merge_classes(existing: Option<&str>, additional: Option<&str>) -> Option<String> {
103 match (existing, additional) {
104 (Some(a), Some(b)) => Some(format!("{} {}", a, b)),
105 (Some(a), None) => Some(a.to_string()),
106 (None, Some(b)) => Some(b.to_string()),
107 (None, None) => None,
108 }
109}
110
111#[component]
113pub fn List<T: Clone + Send + Sync + 'static>(
114 #[prop(optional)]
116 items: Option<Vec<ListItem<T>>>,
117 #[prop(optional)]
119 selected_items: Option<Vec<String>>,
120 #[prop(optional)]
122 focused_item: Option<String>,
123 #[prop(optional, default = ListSize::Medium)]
125 size: ListSize,
126 #[prop(optional, default = ListVariant::Default)]
128 variant: ListVariant,
129 #[prop(optional, default = false)]
131 multi_select: bool,
132 #[prop(optional)]
134 on_selection_change: Option<Callback<Vec<String>>>,
135 #[prop(optional)]
137 on_item_click: Option<Callback<ListItem<T>>>,
138 #[prop(optional)]
140 on_item_focus: Option<Callback<ListItem<T>>>,
141 #[prop(optional)]
143 class: Option<String>,
144 children: Children,
146) -> impl IntoView {
147 let list_id = generate_id("list");
148
149 let (items_signal, _set_items_signal) = signal(items.unwrap_or_default());
151 let (selected_items_signal, _set_selected_items_signal) = signal(selected_items.unwrap_or_default());
152 let (focused_item_signal, _set_focused_item_signal) = signal(focused_item);
153
154 let context = ListContext {
156 items: items_signal.into(),
157 selected_items: selected_items_signal.into(),
158 focused_item: focused_item_signal.into(),
159 size: size.clone(),
160 variant: variant.clone(),
161 multi_select,
162 list_id: list_id.clone(),
163 on_selection_change,
164 on_item_click,
165 on_item_focus,
166 };
167
168 let base_classes = "radix-list";
170 let combined_class = merge_classes(Some(base_classes), class.as_deref())
171 .unwrap_or_else(|| base_classes.to_string());
172
173 provide_context(context);
175
176 view! {
177 <div
178 id=list_id
179 class=combined_class
180 data-size=size.as_str()
181 data-variant=variant.as_str()
182 data-multi-select=multi_select
183 role="listbox"
184 aria-multiselectable=multi_select
185 >
186 {children()}
187 </div>
188 }
189}
190
191#[component]
193pub fn ListItem<T: Clone + Send + Sync + 'static>(
194 #[prop(optional)]
196 item: Option<ListItem<T>>,
197 #[prop(optional)]
199 disabled: Option<bool>,
200 #[prop(optional)]
202 selected: Option<bool>,
203 #[prop(optional)]
205 focused: Option<bool>,
206 #[prop(optional)]
208 class: Option<String>,
209 #[prop(optional)]
211 style: Option<String>,
212 children: Children,
214) -> impl IntoView {
215 let context = use_context::<ListContext<T>>().expect("ListItem must be used within List");
216 let item_id = generate_id("list-item");
217
218 let item_clone = item.clone();
219 let handle_click = move |event: web_sys::MouseEvent| {
220 event.prevent_default();
221
222 if let Some(item) = item_clone.clone() {
223 if !item.disabled {
224 let mut current_selected = context.selected_items.get();
226 let item_id = item.id.clone();
227
228 if context.multi_select {
229 if current_selected.contains(&item_id) {
230 current_selected.retain(|id| id != &item_id);
231 } else {
232 current_selected.push(item_id);
233 }
234 } else {
235 current_selected = vec![item_id];
236 }
237
238 if let Some(callback) = context.on_selection_change.clone() {
240 callback.run(current_selected);
241 }
242
243 if let Some(callback) = context.on_item_click.clone() {
245 callback.run(item);
246 }
247 }
248 }
249 };
250
251 let item_for_focus = item.clone();
252 let handle_focus = move |_event: web_sys::FocusEvent| {
253 if let Some(item) = item_for_focus.clone() {
254 if let Some(callback) = context.on_item_focus.clone() {
255 callback.run(item);
256 }
257 }
258 };
259
260 let item_for_current = item.clone();
261 let item_for_disabled = item.clone();
262 let item_for_selected = item.clone();
263
264 let is_current = Memo::new(move |_| {
266 if let Some(focused) = focused {
267 focused
268 } else if let Some(item) = item_for_current.as_ref() {
269 item.focused
270 } else {
271 false
272 }
273 });
274
275 let is_disabled = Memo::new(move |_| {
277 if let Some(disabled) = disabled {
278 disabled
279 } else if let Some(item) = item_for_disabled.as_ref() {
280 item.disabled
281 } else {
282 false
283 }
284 });
285
286 let is_selected = Memo::new(move |_| {
288 if let Some(selected) = selected {
289 selected
290 } else if let Some(item) = item_for_selected.as_ref() {
291 item.selected
292 } else {
293 false
294 }
295 });
296
297 let base_classes = "radix-list-item";
299 let combined_class = merge_classes(Some(base_classes), class.as_deref())
300 .unwrap_or_else(|| base_classes.to_string());
301
302 view! {
303 <div
304 id=item_id
305 class=combined_class
306 style=style.unwrap_or_default()
307 data-disabled=is_disabled.get()
308 data-selected=is_selected.get()
309 data-current=is_current.get()
310 role="option"
311 tabindex=if is_disabled.get() { "-1" } else { "0" }
312 aria-disabled=is_disabled.get()
313 aria-selected=is_selected.get()
314 aria-current=if is_current.get() { "true" } else { "false" }
315 on:click=handle_click
316 on:focus=handle_focus
317 >
318 {children()}
319 </div>
320 }
321}
322
323#[component]
325pub fn ListHeader(
326 #[prop(optional)]
328 class: Option<String>,
329 #[prop(optional)]
331 style: Option<String>,
332 children: Children,
334) -> impl IntoView {
335 let header_id = generate_id("list-header");
336
337 let base_classes = "radix-list-header";
339 let combined_class = merge_classes(Some(base_classes), class.as_deref())
340 .unwrap_or_else(|| base_classes.to_string());
341
342 view! {
343 <div
344 id=header_id
345 class=combined_class
346 style=style.unwrap_or_default()
347 role="presentation"
348 >
349 {children()}
350 </div>
351 }
352}
353
354#[component]
356pub fn ListFooter(
357 #[prop(optional)]
359 class: Option<String>,
360 #[prop(optional)]
362 style: Option<String>,
363 children: Children,
365) -> impl IntoView {
366 let footer_id = generate_id("list-footer");
367
368 let base_classes = "radix-list-footer";
370 let combined_class = merge_classes(Some(base_classes), class.as_deref())
371 .unwrap_or_else(|| base_classes.to_string());
372
373 view! {
374 <div
375 id=footer_id
376 class=combined_class
377 style=style.unwrap_or_default()
378 role="presentation"
379 >
380 {children()}
381 </div>
382 }
383}
384
385#[component]
387pub fn ListEmpty(
388 #[prop(optional)]
390 message: Option<String>,
391 #[prop(optional)]
393 class: Option<String>,
394 #[prop(optional)]
396 style: Option<String>,
397 children: Children,
399) -> impl IntoView {
400 let empty_id = generate_id("list-empty");
401
402 let base_classes = "radix-list-empty";
404 let combined_class = merge_classes(Some(base_classes), class.as_deref())
405 .unwrap_or_else(|| base_classes.to_string());
406
407 view! {
408 <div
409 id=empty_id
410 class=combined_class
411 style=style.unwrap_or_default()
412 role="status"
413 aria-live="polite"
414 >
415 {if let Some(msg) = message {
416 view! {
417 <span class="radix-list-empty-message">{msg}</span>
418 }
419 } else {
420 view! {
421 <span class="radix-list-empty-message">{"No items found".to_string()}</span>
422 }
423 }}
424 {children()}
425 </div>
426 }
427}
428
429#[component]
431pub fn ListLoading(
432 #[prop(optional)]
434 message: Option<String>,
435 #[prop(optional)]
437 class: Option<String>,
438 #[prop(optional)]
440 style: Option<String>,
441 children: Children,
443) -> impl IntoView {
444 let loading_id = generate_id("list-loading");
445
446 let base_classes = "radix-list-loading";
448 let combined_class = merge_classes(Some(base_classes), class.as_deref())
449 .unwrap_or_else(|| base_classes.to_string());
450
451 view! {
452 <div
453 id=loading_id
454 class=combined_class
455 style=style.unwrap_or_default()
456 role="status"
457 aria-live="polite"
458 aria-label="Loading"
459 >
460 {if let Some(msg) = message {
461 view! {
462 <span class="radix-list-loading-message">{msg}</span>
463 }
464 } else {
465 view! {
466 <span class="radix-list-loading-message">{"Loading...".to_string()}</span>
467 }
468 }}
469 {children()}
470 </div>
471 }
472}
473
474pub fn create_list_item<T: Send + Sync + 'static>(id: &str, data: T) -> ListItem<T> {
476 ListItem::new(id.to_string(), data)
477}
478
479pub fn create_disabled_list_item<T: Send + Sync + 'static>(id: &str, data: T) -> ListItem<T> {
481 ListItem::new(id.to_string(), data).with_disabled(true)
482}
483
484pub fn create_selected_list_item<T: Send + Sync + 'static>(id: &str, data: T) -> ListItem<T> {
486 ListItem::new(id.to_string(), data).with_selected(true)
487}