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