radix_leptos_primitives/components/
list.rs

1use leptos::children::Children;
2use leptos::context::use_context;
3use leptos::prelude::*;
4
5/// List item information
6#[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/// List size variants
43#[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/// List variant styles
61#[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/// List context for state management
81#[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
95/// Generate a simple unique ID for components
96fn 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
102/// Merge CSS classes
103fn 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/// Main List component
113#[component]
114pub fn List<T: Clone + Send + Sync + 'static>(
115    /// List items
116    #[prop(optional)]
117    items: Option<Vec<ListItem<T>>>,
118    /// Selected item IDs
119    #[prop(optional)]
120    selected_items: Option<Vec<String>>,
121    /// Currently focused item ID
122    #[prop(optional)]
123    focused_item: Option<String>,
124    /// List size
125    #[prop(optional, default = ListSize::Medium)]
126    size: ListSize,
127    /// List variant
128    #[prop(optional, default = ListVariant::Default)]
129    variant: ListVariant,
130    /// Whether to allow multiple selection
131    #[prop(optional, default = false)]
132    multi_select: bool,
133    /// Selection change event handler
134    #[prop(optional)]
135    on_selection_change: Option<Callback<Vec<String>>>,
136    /// Item click event handler
137    #[prop(optional)]
138    on_item_click: Option<Callback<ListItem<T>>>,
139    /// Item focus event handler
140    #[prop(optional)]
141    on_item_focus: Option<Callback<ListItem<T>>>,
142    /// CSS classes
143    #[prop(optional)]
144    class: Option<String>,
145    /// Child content (list items, etc.)
146    children: Children,
147) -> impl IntoView {
148    let list_id = generate_id("list");
149
150    // Reactive state
151    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    // Create context
157    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    // Build base classes
171    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 the context
176    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/// ListItem component for individual list items
194#[component]
195pub fn ListItem<T: Clone + Send + Sync + 'static>(
196    /// The list item this component represents
197    #[prop(optional)]
198    item: Option<ListItem<T>>,
199    /// Whether this item is disabled
200    #[prop(optional)]
201    disabled: Option<bool>,
202    /// Whether this item is selected
203    #[prop(optional)]
204    selected: Option<bool>,
205    /// Whether this item is focused
206    #[prop(optional)]
207    focused: Option<bool>,
208    /// CSS classes
209    #[prop(optional)]
210    class: Option<String>,
211    /// CSS styles
212    #[prop(optional)]
213    style: Option<String>,
214    /// Child content
215    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                // Handle selection
227                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                // Call the selection change handler
241                if let Some(callback) = context.on_selection_change {
242                    callback.run(currentselected);
243                }
244
245                // Call the item click handler
246                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    // Determine if this item is current
267    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    // Determine if this item is disabled
278    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    // Determine if this item is selected
289    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    // Build base classes
300    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/// ListHeader component for list headers
322#[component]
323pub fn ListHeader(
324    /// CSS classes
325    #[prop(optional)]
326    class: Option<String>,
327    /// CSS styles
328    #[prop(optional)]
329    style: Option<String>,
330    /// Child content
331    children: Children,
332) -> impl IntoView {
333    let header_id = generate_id("list-header");
334
335    // Build base classes
336    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/// ListFooter component for list footers
353#[component]
354pub fn ListFooter(
355    /// CSS classes
356    #[prop(optional)]
357    class: Option<String>,
358    /// CSS styles
359    #[prop(optional)]
360    style: Option<String>,
361    /// Child content
362    children: Children,
363) -> impl IntoView {
364    let footer_id = generate_id("list-footer");
365
366    // Build base classes
367    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/// ListEmpty component for empty state
384#[component]
385pub fn ListEmpty(
386    /// Empty state message
387    #[prop(optional)]
388    message: Option<String>,
389    /// CSS classes
390    #[prop(optional)]
391    class: Option<String>,
392    /// CSS styles
393    #[prop(optional)]
394    style: Option<String>,
395    /// Child content
396    children: Children,
397) -> impl IntoView {
398    let empty_id = generate_id("list-empty");
399
400    // Build base classes
401    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/// ListLoading component for loading state
426#[component]
427pub fn ListLoading(
428    /// Loading message
429    #[prop(optional)]
430    message: Option<String>,
431    /// CSS classes
432    #[prop(optional)]
433    class: Option<String>,
434    /// CSS styles
435    #[prop(optional)]
436    style: Option<String>,
437    /// Child content
438    children: Children,
439) -> impl IntoView {
440    let loading_id = generate_id("list-loading");
441
442    // Build base classes
443    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
468/// Helper function to create a simple list item
469pub fn create_list_item<T: Send + Sync + 'static>(id: &str, data: T) -> ListItem<T> {
470    ListItem::new(id.to_string(), data)
471}
472
473/// Helper function to create a disabled list item
474pub 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
478/// Helper function to create a selected list item
479pub fn createselected_list_item<T: Send + Sync + 'static>(id: &str, data: T) -> ListItem<T> {
480    ListItem::new(id.to_string(), data).withselected(true)
481}