radix_leptos_primitives/components/
list.rs

1use leptos::*;
2use leptos::prelude::*;
3
4/// List item information
5#[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/// List size variants
42#[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/// List variant styles
60#[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/// List context for state management
80#[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
94/// Generate a simple unique ID for components
95fn 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
101/// Merge CSS classes
102fn 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/// Main List component
112#[component]
113pub fn List<T: Clone + Send + Sync + 'static>(
114    /// List items
115    #[prop(optional)]
116    items: Option<Vec<ListItem<T>>>,
117    /// Selected item IDs
118    #[prop(optional)]
119    selected_items: Option<Vec<String>>,
120    /// Currently focused item ID
121    #[prop(optional)]
122    focused_item: Option<String>,
123    /// List size
124    #[prop(optional, default = ListSize::Medium)]
125    size: ListSize,
126    /// List variant
127    #[prop(optional, default = ListVariant::Default)]
128    variant: ListVariant,
129    /// Whether to allow multiple selection
130    #[prop(optional, default = false)]
131    multi_select: bool,
132    /// Selection change event handler
133    #[prop(optional)]
134    on_selection_change: Option<Callback<Vec<String>>>,
135    /// Item click event handler
136    #[prop(optional)]
137    on_item_click: Option<Callback<ListItem<T>>>,
138    /// Item focus event handler
139    #[prop(optional)]
140    on_item_focus: Option<Callback<ListItem<T>>>,
141    /// CSS classes
142    #[prop(optional)]
143    class: Option<String>,
144    /// Child content (list items, etc.)
145    children: Children,
146) -> impl IntoView {
147    let list_id = generate_id("list");
148    
149    // Reactive state
150    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    // Create context
155    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    // Build base classes
169    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 the context
174    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/// ListItem component for individual list items
192#[component]
193pub fn ListItem<T: Clone + Send + Sync + 'static>(
194    /// The list item this component represents
195    #[prop(optional)]
196    item: Option<ListItem<T>>,
197    /// Whether this item is disabled
198    #[prop(optional)]
199    disabled: Option<bool>,
200    /// Whether this item is selected
201    #[prop(optional)]
202    selected: Option<bool>,
203    /// Whether this item is focused
204    #[prop(optional)]
205    focused: Option<bool>,
206    /// CSS classes
207    #[prop(optional)]
208    class: Option<String>,
209    /// CSS styles
210    #[prop(optional)]
211    style: Option<String>,
212    /// Child content
213    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                // Handle selection
225                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                // Call the selection change handler
239                if let Some(callback) = context.on_selection_change.clone() {
240                    callback.run(current_selected);
241                }
242                
243                // Call the item click handler
244                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    // Determine if this item is current
265    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    // Determine if this item is disabled
276    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    // Determine if this item is selected
287    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    // Build base classes
298    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/// ListHeader component for list headers
324#[component]
325pub fn ListHeader(
326    /// CSS classes
327    #[prop(optional)]
328    class: Option<String>,
329    /// CSS styles
330    #[prop(optional)]
331    style: Option<String>,
332    /// Child content
333    children: Children,
334) -> impl IntoView {
335    let header_id = generate_id("list-header");
336    
337    // Build base classes
338    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/// ListFooter component for list footers
355#[component]
356pub fn ListFooter(
357    /// CSS classes
358    #[prop(optional)]
359    class: Option<String>,
360    /// CSS styles
361    #[prop(optional)]
362    style: Option<String>,
363    /// Child content
364    children: Children,
365) -> impl IntoView {
366    let footer_id = generate_id("list-footer");
367    
368    // Build base classes
369    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/// ListEmpty component for empty state
386#[component]
387pub fn ListEmpty(
388    /// Empty state message
389    #[prop(optional)]
390    message: Option<String>,
391    /// CSS classes
392    #[prop(optional)]
393    class: Option<String>,
394    /// CSS styles
395    #[prop(optional)]
396    style: Option<String>,
397    /// Child content
398    children: Children,
399) -> impl IntoView {
400    let empty_id = generate_id("list-empty");
401    
402    // Build base classes
403    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/// ListLoading component for loading state
430#[component]
431pub fn ListLoading(
432    /// Loading message
433    #[prop(optional)]
434    message: Option<String>,
435    /// CSS classes
436    #[prop(optional)]
437    class: Option<String>,
438    /// CSS styles
439    #[prop(optional)]
440    style: Option<String>,
441    /// Child content
442    children: Children,
443) -> impl IntoView {
444    let loading_id = generate_id("list-loading");
445    
446    // Build base classes
447    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
474/// Helper function to create a simple list item
475pub fn create_list_item<T: Send + Sync + 'static>(id: &str, data: T) -> ListItem<T> {
476    ListItem::new(id.to_string(), data)
477}
478
479/// Helper function to create a disabled list item
480pub 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
484/// Helper function to create a selected list item
485pub 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}