radix_leptos_primitives/components/
list.rs1use leptos::children::Children;
2use leptos::context::use_context;
3use leptos::prelude::*;
4
5#[derive(Clone, Debug, PartialEq)]
7pub struct ListItem<T: Send + Sync + 'static> {
8 pub id: String,
9 pub data: T,
10 pub _disabled: bool,
11 pub _selected: bool,
12 pub _focused: bool,
13}
14
15impl<T: Send + Sync + 'static> ListItem<T> {
16 pub fn new(id: String, data: T) -> Self {
17 Self {
18 id,
19 data,
20 _disabled: false,
21 _selected: false,
22 _focused: false,
23 }
24 }
25
26 pub fn withdisabled(mut self, disabled: bool) -> Self {
27 self._disabled = disabled;
28 self
29 }
30
31 pub fn withselected(mut self, selected: bool) -> Self {
32 self._selected = selected;
33 self
34 }
35
36 pub fn withfocused(mut self, focused: bool) -> Self {
37 self._focused = focused;
38 self
39 }
40}
41
42#[derive(Clone, Debug, PartialEq)]
44pub enum ListSize {
45 Small,
46 Medium,
47 Large,
48}
49
50impl ListSize {
51 pub fn as_str(&self) -> &'static str {
52 match self {
53 ListSize::Small => "small",
54 ListSize::Medium => "medium",
55 ListSize::Large => "large",
56 }
57 }
58}
59
60#[derive(Clone, Debug, PartialEq)]
62pub enum ListVariant {
63 Default,
64 Bordered,
65 Striped,
66 Compact,
67}
68
69impl ListVariant {
70 pub fn as_str(&self) -> &'static str {
71 match self {
72 ListVariant::Default => "default",
73 ListVariant::Bordered => "bordered",
74 ListVariant::Striped => "striped",
75 ListVariant::Compact => "compact",
76 }
77 }
78}
79
80#[derive(Clone)]
82pub struct ListContext<T: Send + Sync + 'static> {
83 pub items: Signal<Vec<ListItem<T>>>,
84 pub selected_items: Signal<Vec<String>>,
85 pub focused_item: Signal<Option<String>>,
86 pub size: ListSize,
87 pub variant: ListVariant,
88 pub _multi_select: bool,
89 pub list_id: String,
90 pub on_selection_change: Option<Callback<Vec<String>>>,
91 pub on_item_click: Option<Callback<ListItem<T>>>,
92 pub on_item_focus: Option<Callback<ListItem<T>>>,
93}
94
95fn generate_id(prefix: &str) -> String {
97 static COUNTER: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
98 let id = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
99 format!("{}-{}", prefix, id)
100}
101
102fn merge_classes(existing: Option<&str>, additional: Option<&str>) -> Option<String> {
104 match (existing, additional) {
105 (Some(a), Some(b)) => Some(format!("{} {}", a, b)),
106 (Some(a), None) => Some(a.to_string()),
107 (None, Some(b)) => Some(b.to_string()),
108 (None, None) => None,
109 }
110}
111
112#[component]
114pub fn List<T: Clone + Send + Sync + 'static>(
115 #[prop(optional)]
117 items: Option<Vec<ListItem<T>>>,
118 #[prop(optional)]
120 selected_items: Option<Vec<String>>,
121 #[prop(optional)]
123 focused_item: Option<String>,
124 #[prop(optional, default = ListSize::Medium)]
126 size: ListSize,
127 #[prop(optional, default = ListVariant::Default)]
129 variant: ListVariant,
130 #[prop(optional, default = false)]
132 multi_select: bool,
133 #[prop(optional)]
135 on_selection_change: Option<Callback<Vec<String>>>,
136 #[prop(optional)]
138 on_item_click: Option<Callback<ListItem<T>>>,
139 #[prop(optional)]
141 on_item_focus: Option<Callback<ListItem<T>>>,
142 #[prop(optional)]
144 class: Option<String>,
145 children: Children,
147) -> impl IntoView {
148 let list_id = generate_id("list");
149
150 let (items_signal, _set_items_signal) = signal(items.unwrap_or_default());
152 let (selected_items_signal, _setselected_items_signal) =
153 signal(selected_items.unwrap_or_default());
154 let (focused_item_signal, _setfocused_item_signal) = signal(focused_item);
155
156 let context = ListContext {
158 items: items_signal.into(),
159 selected_items: selected_items_signal.into(),
160 focused_item: focused_item_signal.into(),
161 size: size.clone(),
162 variant: variant.clone(),
163 _multi_select: multi_select,
164 list_id: list_id.clone(),
165 on_selection_change,
166 on_item_click,
167 on_item_focus,
168 };
169
170 let base_classes = "radix-list";
172 let combined_class = merge_classes(Some(base_classes), class.as_deref())
173 .unwrap_or_else(|| base_classes.to_string());
174
175 provide_context(context);
177
178 view! {
179 <div
180 id=list_id
181 class=combined_class
182 data-size=size.as_str()
183 data-variant=variant.as_str()
184 data-multi-select=multi_select
185 role="listbox"
186 aria-multiselectable=multi_select
187 >
188 {children()}
189 </div>
190 }
191}
192
193#[component]
195pub fn ListItem<T: Clone + Send + Sync + 'static>(
196 #[prop(optional)]
198 item: Option<ListItem<T>>,
199 #[prop(optional)]
201 disabled: Option<bool>,
202 #[prop(optional)]
204 selected: Option<bool>,
205 #[prop(optional)]
207 focused: Option<bool>,
208 #[prop(optional)]
210 class: Option<String>,
211 #[prop(optional)]
213 style: Option<String>,
214 children: Children,
216) -> impl IntoView {
217 let context = use_context::<ListContext<T>>().expect("ListItem must be used within List");
218 let item_id = generate_id("list-item");
219
220 let item_clone = item.clone();
221 let handle_click = move |event: web_sys::MouseEvent| {
222 event.prevent_default();
223
224 if let Some(item) = item_clone.clone() {
225 if !item._disabled {
226 let mut currentselected = context.selected_items.get();
228 let item_id = item.id.clone();
229
230 if context._multi_select {
231 if currentselected.contains(&item_id) {
232 currentselected.retain(|id| id != &item_id);
233 } else {
234 currentselected.push(item_id);
235 }
236 } else {
237 currentselected = vec![item_id];
238 }
239
240 if let Some(callback) = context.on_selection_change {
242 callback.run(currentselected);
243 }
244
245 if let Some(callback) = context.on_item_click {
247 callback.run(item);
248 }
249 }
250 }
251 };
252
253 let item_for_focus = item.clone();
254 let handle_focus = move |_event: web_sys::FocusEvent| {
255 if let Some(item) = item_for_focus.clone() {
256 if let Some(callback) = context.on_item_focus {
257 callback.run(item);
258 }
259 }
260 };
261
262 let item_forcurrent = item.clone();
263 let item_fordisabled = item.clone();
264 let item_forselected = item.clone();
265
266 let iscurrent = Memo::new(move |_| {
268 if let Some(focused) = focused {
269 focused
270 } else if let Some(item) = item_forcurrent.as_ref() {
271 item._focused
272 } else {
273 false
274 }
275 });
276
277 let isdisabled = Memo::new(move |_| {
279 if let Some(disabled) = disabled {
280 disabled
281 } else if let Some(item) = item_fordisabled.as_ref() {
282 item._disabled
283 } else {
284 false
285 }
286 });
287
288 let isselected = Memo::new(move |_| {
290 if let Some(selected) = selected {
291 selected
292 } else if let Some(item) = item_forselected.as_ref() {
293 item._selected
294 } else {
295 false
296 }
297 });
298
299 let base_classes = "radix-list-item";
301 let combined_class = merge_classes(Some(base_classes), class.as_deref())
302 .unwrap_or_else(|| base_classes.to_string());
303
304 view! {
305 <div
306 id=item_id
307 class=combined_class
308 style=style.unwrap_or_default()
309 data-disabled=isdisabled.get()
310 data-selected=isselected.get()
311 data-current=iscurrent.get()
312 role="option"
313 on:click=handle_click
314 on:focus=handle_focus
315 >
316 {children()}
317 </div>
318 }
319}
320
321#[component]
323pub fn ListHeader(
324 #[prop(optional)]
326 class: Option<String>,
327 #[prop(optional)]
329 style: Option<String>,
330 children: Children,
332) -> impl IntoView {
333 let header_id = generate_id("list-header");
334
335 let base_classes = "radix-list-header";
337 let combined_class = merge_classes(Some(base_classes), class.as_deref())
338 .unwrap_or_else(|| base_classes.to_string());
339
340 view! {
341 <div
342 id=header_id
343 class=combined_class
344 style=style.unwrap_or_default()
345 role="presentation"
346 >
347 {children()}
348 </div>
349 }
350}
351
352#[component]
354pub fn ListFooter(
355 #[prop(optional)]
357 class: Option<String>,
358 #[prop(optional)]
360 style: Option<String>,
361 children: Children,
363) -> impl IntoView {
364 let footer_id = generate_id("list-footer");
365
366 let base_classes = "radix-list-footer";
368 let combined_class = merge_classes(Some(base_classes), class.as_deref())
369 .unwrap_or_else(|| base_classes.to_string());
370
371 view! {
372 <div
373 id=footer_id
374 class=combined_class
375 style=style.unwrap_or_default()
376 role="presentation"
377 >
378 {children()}
379 </div>
380 }
381}
382
383#[component]
385pub fn ListEmpty(
386 #[prop(optional)]
388 message: Option<String>,
389 #[prop(optional)]
391 class: Option<String>,
392 #[prop(optional)]
394 style: Option<String>,
395 children: Children,
397) -> impl IntoView {
398 let empty_id = generate_id("list-empty");
399
400 let base_classes = "radix-list-empty";
402 let combined_class = merge_classes(Some(base_classes), class.as_deref())
403 .unwrap_or_else(|| base_classes.to_string());
404
405 view! {
406 <div
407 id=empty_id
408 class=combined_class
409 style=style.unwrap_or_default()
410 role="status"
411 aria-live="polite"
412 >
413 {if let Some(msg) = message {
414 view! {
415 <span class="radix-list-empty-message">{msg}</span>
416 }
417 } else {
418 view! { <span class="radix-list-empty-message">{String::new()}</span> }
419 }}
420 {children()}
421 </div>
422 }
423}
424
425#[component]
427pub fn ListLoading(
428 #[prop(optional)]
430 message: Option<String>,
431 #[prop(optional)]
433 class: Option<String>,
434 #[prop(optional)]
436 style: Option<String>,
437 children: Children,
439) -> impl IntoView {
440 let loading_id = generate_id("list-loading");
441
442 let base_classes = "radix-list-loading";
444 let combined_class = merge_classes(Some(base_classes), class.as_deref())
445 .unwrap_or_else(|| base_classes.to_string());
446
447 view! {
448 <div
449 id=loading_id
450 class=combined_class
451 style=style.unwrap_or_default()
452 role="status"
453 aria-live="polite"
454 aria-label="Loading"
455 >
456 {if let Some(msg) = message {
457 view! {
458 <span class="radix-list-loading-message">{msg}</span>
459 }
460 } else {
461 view! { <span class="radix-list-empty-message">{String::new()}</span> }
462 }}
463 {children()}
464 </div>
465 }
466}
467
468pub fn create_list_item<T: Send + Sync + 'static>(id: &str, data: T) -> ListItem<T> {
470 ListItem::new(id.to_string(), data)
471}
472
473pub fn createdisabled_list_item<T: Send + Sync + 'static>(id: &str, data: T) -> ListItem<T> {
475 ListItem::new(id.to_string(), data).withdisabled(true)
476}
477
478pub fn createselected_list_item<T: Send + Sync + 'static>(id: &str, data: T) -> ListItem<T> {
480 ListItem::new(id.to_string(), data).withselected(true)
481}