list_item.rs

  1use std::sync::Arc;
  2
  3use component::{Component, ComponentScope, example_group_with_title, single_example};
  4use gpui::{AnyElement, AnyView, ClickEvent, MouseButton, MouseDownEvent, Pixels, px};
  5use smallvec::SmallVec;
  6
  7use crate::{Disclosure, prelude::*};
  8
  9#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy, Default)]
 10pub enum ListItemSpacing {
 11    #[default]
 12    Dense,
 13    ExtraDense,
 14    Sparse,
 15}
 16
 17#[derive(IntoElement, RegisterComponent)]
 18pub struct ListItem {
 19    id: ElementId,
 20    group_name: Option<SharedString>,
 21    disabled: bool,
 22    selected: bool,
 23    spacing: ListItemSpacing,
 24    indent_level: usize,
 25    indent_step_size: Pixels,
 26    /// A slot for content that appears before the children, like an icon or avatar.
 27    start_slot: Option<AnyElement>,
 28    /// A slot for content that appears after the children, usually on the other side of the header.
 29    /// This might be a button, a disclosure arrow, a face pile, etc.
 30    end_slot: Option<AnyElement>,
 31    /// A slot for content that appears on hover after the children
 32    /// It will obscure the `end_slot` when visible.
 33    end_hover_slot: Option<AnyElement>,
 34    toggle: Option<bool>,
 35    inset: bool,
 36    on_click: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
 37    on_hover: Option<Box<dyn Fn(&bool, &mut Window, &mut App) + 'static>>,
 38    on_toggle: Option<Arc<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
 39    tooltip: Option<Box<dyn Fn(&mut Window, &mut App) -> AnyView + 'static>>,
 40    on_secondary_mouse_down: Option<Box<dyn Fn(&MouseDownEvent, &mut Window, &mut App) + 'static>>,
 41    children: SmallVec<[AnyElement; 2]>,
 42    selectable: bool,
 43    always_show_disclosure_icon: bool,
 44    outlined: bool,
 45    rounded: bool,
 46    overflow_x: bool,
 47    focused: Option<bool>,
 48}
 49
 50impl ListItem {
 51    pub fn new(id: impl Into<ElementId>) -> Self {
 52        Self {
 53            id: id.into(),
 54            group_name: None,
 55            disabled: false,
 56            selected: false,
 57            spacing: ListItemSpacing::Dense,
 58            indent_level: 0,
 59            indent_step_size: px(12.),
 60            start_slot: None,
 61            end_slot: None,
 62            end_hover_slot: None,
 63            toggle: None,
 64            inset: false,
 65            on_click: None,
 66            on_secondary_mouse_down: None,
 67            on_toggle: None,
 68            on_hover: None,
 69            tooltip: None,
 70            children: SmallVec::new(),
 71            selectable: true,
 72            always_show_disclosure_icon: false,
 73            outlined: false,
 74            rounded: false,
 75            overflow_x: false,
 76            focused: None,
 77        }
 78    }
 79
 80    pub fn group_name(mut self, group_name: impl Into<SharedString>) -> Self {
 81        self.group_name = Some(group_name.into());
 82        self
 83    }
 84
 85    pub fn spacing(mut self, spacing: ListItemSpacing) -> Self {
 86        self.spacing = spacing;
 87        self
 88    }
 89
 90    pub fn selectable(mut self, has_hover: bool) -> Self {
 91        self.selectable = has_hover;
 92        self
 93    }
 94
 95    pub fn always_show_disclosure_icon(mut self, show: bool) -> Self {
 96        self.always_show_disclosure_icon = show;
 97        self
 98    }
 99
100    pub fn on_click(
101        mut self,
102        handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
103    ) -> Self {
104        self.on_click = Some(Box::new(handler));
105        self
106    }
107
108    pub fn on_hover(mut self, handler: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self {
109        self.on_hover = Some(Box::new(handler));
110        self
111    }
112
113    pub fn on_secondary_mouse_down(
114        mut self,
115        handler: impl Fn(&MouseDownEvent, &mut Window, &mut App) + 'static,
116    ) -> Self {
117        self.on_secondary_mouse_down = Some(Box::new(handler));
118        self
119    }
120
121    pub fn tooltip(mut self, tooltip: impl Fn(&mut Window, &mut App) -> AnyView + 'static) -> Self {
122        self.tooltip = Some(Box::new(tooltip));
123        self
124    }
125
126    pub fn inset(mut self, inset: bool) -> Self {
127        self.inset = inset;
128        self
129    }
130
131    pub fn indent_level(mut self, indent_level: usize) -> Self {
132        self.indent_level = indent_level;
133        self
134    }
135
136    pub fn indent_step_size(mut self, indent_step_size: Pixels) -> Self {
137        self.indent_step_size = indent_step_size;
138        self
139    }
140
141    pub fn toggle(mut self, toggle: impl Into<Option<bool>>) -> Self {
142        self.toggle = toggle.into();
143        self
144    }
145
146    pub fn on_toggle(
147        mut self,
148        on_toggle: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
149    ) -> Self {
150        self.on_toggle = Some(Arc::new(on_toggle));
151        self
152    }
153
154    pub fn start_slot<E: IntoElement>(mut self, start_slot: impl Into<Option<E>>) -> Self {
155        self.start_slot = start_slot.into().map(IntoElement::into_any_element);
156        self
157    }
158
159    pub fn end_slot<E: IntoElement>(mut self, end_slot: impl Into<Option<E>>) -> Self {
160        self.end_slot = end_slot.into().map(IntoElement::into_any_element);
161        self
162    }
163
164    pub fn end_hover_slot<E: IntoElement>(mut self, end_hover_slot: impl Into<Option<E>>) -> Self {
165        self.end_hover_slot = end_hover_slot.into().map(IntoElement::into_any_element);
166        self
167    }
168
169    pub fn outlined(mut self) -> Self {
170        self.outlined = true;
171        self
172    }
173
174    pub fn rounded(mut self) -> Self {
175        self.rounded = true;
176        self
177    }
178
179    pub fn overflow_x(mut self) -> Self {
180        self.overflow_x = true;
181        self
182    }
183
184    pub fn focused(mut self, focused: bool) -> Self {
185        self.focused = Some(focused);
186        self
187    }
188}
189
190impl Disableable for ListItem {
191    fn disabled(mut self, disabled: bool) -> Self {
192        self.disabled = disabled;
193        self
194    }
195}
196
197impl Toggleable for ListItem {
198    fn toggle_state(mut self, selected: bool) -> Self {
199        self.selected = selected;
200        self
201    }
202}
203
204impl ParentElement for ListItem {
205    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
206        self.children.extend(elements)
207    }
208}
209
210impl RenderOnce for ListItem {
211    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
212        h_flex()
213            .id(self.id)
214            .when_some(self.group_name, |this, group| this.group(group))
215            .w_full()
216            .relative()
217            // When an item is inset draw the indent spacing outside of the item
218            .when(self.inset, |this| {
219                this.ml(self.indent_level as f32 * self.indent_step_size)
220                    .px(DynamicSpacing::Base04.rems(cx))
221            })
222            .when(!self.inset && !self.disabled, |this| {
223                this
224                    // TODO: Add focus state
225                    // .when(self.state == InteractionState::Focused, |this| {
226                    .when_some(self.focused, |this, focused| {
227                        if focused {
228                            this.border_1()
229                                .border_color(cx.theme().colors().border_focused)
230                        } else {
231                            this.border_1()
232                        }
233                    })
234                    .when(self.selectable, |this| {
235                        this.hover(|style| style.bg(cx.theme().colors().ghost_element_hover))
236                            .active(|style| style.bg(cx.theme().colors().ghost_element_active))
237                            .when(self.outlined, |this| this.rounded_sm())
238                            .when(self.selected, |this| {
239                                this.bg(cx.theme().colors().ghost_element_selected)
240                            })
241                    })
242            })
243            .when(self.rounded, |this| this.rounded_sm())
244            .when_some(self.on_hover, |this, on_hover| this.on_hover(on_hover))
245            .child(
246                h_flex()
247                    .id("inner_list_item")
248                    .group("list_item")
249                    .w_full()
250                    .relative()
251                    .gap_1()
252                    .px(DynamicSpacing::Base06.rems(cx))
253                    .map(|this| match self.spacing {
254                        ListItemSpacing::Dense => this,
255                        ListItemSpacing::ExtraDense => this.py_neg_px(),
256                        ListItemSpacing::Sparse => this.py_1(),
257                    })
258                    .when(self.inset && !self.disabled, |this| {
259                        this
260                            // TODO: Add focus state
261                            //.when(self.state == InteractionState::Focused, |this| {
262                            .when_some(self.focused, |this, focused| {
263                                if focused {
264                                    this.border_1()
265                                        .border_color(cx.theme().colors().border_focused)
266                                } else {
267                                    this.border_1()
268                                }
269                            })
270                            .when(self.selectable, |this| {
271                                this.hover(|style| {
272                                    style.bg(cx.theme().colors().ghost_element_hover)
273                                })
274                                .active(|style| style.bg(cx.theme().colors().ghost_element_active))
275                                .when(self.selected, |this| {
276                                    this.bg(cx.theme().colors().ghost_element_selected)
277                                })
278                            })
279                    })
280                    .when_some(
281                        self.on_click.filter(|_| !self.disabled),
282                        |this, on_click| this.cursor_pointer().on_click(on_click),
283                    )
284                    .when(self.outlined, |this| {
285                        this.border_1()
286                            .border_color(cx.theme().colors().border)
287                            .rounded_sm()
288                            .overflow_hidden()
289                    })
290                    .when_some(self.on_secondary_mouse_down, |this, on_mouse_down| {
291                        this.on_mouse_down(MouseButton::Right, move |event, window, cx| {
292                            (on_mouse_down)(event, window, cx)
293                        })
294                    })
295                    .when_some(self.tooltip, |this, tooltip| this.tooltip(tooltip))
296                    .map(|this| {
297                        if self.inset {
298                            this.rounded_sm()
299                        } else {
300                            // When an item is not inset draw the indent spacing inside of the item
301                            this.ml(self.indent_level as f32 * self.indent_step_size)
302                        }
303                    })
304                    .children(self.toggle.map(|is_open| {
305                        div()
306                            .flex()
307                            .absolute()
308                            .left(rems(-1.))
309                            .when(is_open && !self.always_show_disclosure_icon, |this| {
310                                this.visible_on_hover("")
311                            })
312                            .child(
313                                Disclosure::new("toggle", is_open)
314                                    .on_toggle_expanded(self.on_toggle),
315                            )
316                    }))
317                    .child(
318                        h_flex()
319                            .flex_grow()
320                            .flex_shrink_0()
321                            .flex_basis(relative(0.25))
322                            .gap(DynamicSpacing::Base06.rems(cx))
323                            .map(|list_content| {
324                                if self.overflow_x {
325                                    list_content
326                                } else {
327                                    list_content.overflow_hidden()
328                                }
329                            })
330                            .children(self.start_slot)
331                            .children(self.children),
332                    )
333                    .when_some(self.end_slot, |this, end_slot| {
334                        this.justify_between().child(
335                            h_flex()
336                                .flex_shrink()
337                                .overflow_hidden()
338                                .when(self.end_hover_slot.is_some(), |this| {
339                                    this.visible()
340                                        .group_hover("list_item", |this| this.invisible())
341                                })
342                                .child(end_slot),
343                        )
344                    })
345                    .when_some(self.end_hover_slot, |this, end_hover_slot| {
346                        this.child(
347                            h_flex()
348                                .h_full()
349                                .absolute()
350                                .right(DynamicSpacing::Base06.rems(cx))
351                                .top_0()
352                                .visible_on_hover("list_item")
353                                .child(end_hover_slot),
354                        )
355                    }),
356            )
357    }
358}
359
360impl Component for ListItem {
361    fn scope() -> ComponentScope {
362        ComponentScope::DataDisplay
363    }
364
365    fn description() -> Option<&'static str> {
366        Some(
367            "A flexible list item component with support for icons, actions, disclosure toggles, and hierarchical display.",
368        )
369    }
370
371    fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
372        Some(
373            v_flex()
374                .gap_6()
375                .children(vec![
376                    example_group_with_title(
377                        "Basic List Items",
378                        vec![
379                            single_example(
380                                "Simple",
381                                ListItem::new("simple")
382                                    .child(Label::new("Simple list item"))
383                                    .into_any_element(),
384                            ),
385                            single_example(
386                                "With Icon",
387                                ListItem::new("with_icon")
388                                    .start_slot(Icon::new(IconName::File))
389                                    .child(Label::new("List item with icon"))
390                                    .into_any_element(),
391                            ),
392                            single_example(
393                                "Selected",
394                                ListItem::new("selected")
395                                    .toggle_state(true)
396                                    .start_slot(Icon::new(IconName::Check))
397                                    .child(Label::new("Selected item"))
398                                    .into_any_element(),
399                            ),
400                        ],
401                    ),
402                    example_group_with_title(
403                        "List Item Spacing",
404                        vec![
405                            single_example(
406                                "Dense",
407                                ListItem::new("dense")
408                                    .spacing(ListItemSpacing::Dense)
409                                    .child(Label::new("Dense spacing"))
410                                    .into_any_element(),
411                            ),
412                            single_example(
413                                "Extra Dense",
414                                ListItem::new("extra_dense")
415                                    .spacing(ListItemSpacing::ExtraDense)
416                                    .child(Label::new("Extra dense spacing"))
417                                    .into_any_element(),
418                            ),
419                            single_example(
420                                "Sparse",
421                                ListItem::new("sparse")
422                                    .spacing(ListItemSpacing::Sparse)
423                                    .child(Label::new("Sparse spacing"))
424                                    .into_any_element(),
425                            ),
426                        ],
427                    ),
428                    example_group_with_title(
429                        "With Slots",
430                        vec![
431                            single_example(
432                                "End Slot",
433                                ListItem::new("end_slot")
434                                    .child(Label::new("Item with end slot"))
435                                    .end_slot(Icon::new(IconName::ChevronRight))
436                                    .into_any_element(),
437                            ),
438                            single_example(
439                                "With Toggle",
440                                ListItem::new("with_toggle")
441                                    .toggle(Some(true))
442                                    .child(Label::new("Expandable item"))
443                                    .into_any_element(),
444                            ),
445                        ],
446                    ),
447                    example_group_with_title(
448                        "States",
449                        vec![
450                            single_example(
451                                "Disabled",
452                                ListItem::new("disabled")
453                                    .disabled(true)
454                                    .child(Label::new("Disabled item"))
455                                    .into_any_element(),
456                            ),
457                            single_example(
458                                "Non-selectable",
459                                ListItem::new("non_selectable")
460                                    .selectable(false)
461                                    .child(Label::new("Non-selectable item"))
462                                    .into_any_element(),
463                            ),
464                        ],
465                    ),
466                ])
467                .into_any_element(),
468        )
469    }
470}