Skip to main content

iced_shadcn/
combobox.rs

1use iced::border::Border;
2use iced::widget::{column, container, row, rule, text};
3use iced::{Alignment, Background, Element, Length};
4use std::hash::Hash;
5
6use crate::button::{ButtonProps, ButtonSize, ButtonVariant, button_content};
7use crate::input::{InputProps, InputSize, InputVariant, input};
8use crate::popover::{PopoverProps, PopoverSize, popover};
9use crate::theme::Theme;
10use crate::tokens::{accent_soft, accent_text};
11
12#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
13pub enum ComboboxSize {
14    Size1,
15    #[default]
16    Size2,
17    Size3,
18}
19
20impl From<ComboboxSize> for ButtonSize {
21    fn from(size: ComboboxSize) -> Self {
22        match size {
23            ComboboxSize::Size1 => ButtonSize::Size1,
24            ComboboxSize::Size2 => ButtonSize::Size2,
25            ComboboxSize::Size3 => ButtonSize::Size3,
26        }
27    }
28}
29
30impl From<ComboboxSize> for InputSize {
31    fn from(size: ComboboxSize) -> Self {
32        match size {
33            ComboboxSize::Size1 => InputSize::Size1,
34            ComboboxSize::Size2 => InputSize::Size2,
35            ComboboxSize::Size3 => InputSize::Size3,
36        }
37    }
38}
39
40#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
41pub enum ButtonJustify {
42    Start,
43    Center,
44    #[default]
45    Between,
46}
47
48#[derive(Clone, Debug)]
49pub enum SelectItem {
50    Option {
51        value: String,
52        label: String,
53        disabled: bool,
54        text_value: Option<String>,
55    },
56    Group {
57        label: String,
58        items: Vec<SelectItem>,
59    },
60    Separator,
61    Label(String),
62}
63
64impl SelectItem {
65    pub fn option(value: impl Into<String>, label: impl Into<String>) -> Self {
66        Self::Option {
67            value: value.into(),
68            label: label.into(),
69            disabled: false,
70            text_value: None,
71        }
72    }
73
74    pub fn option_disabled(value: impl Into<String>, label: impl Into<String>) -> Self {
75        Self::Option {
76            value: value.into(),
77            label: label.into(),
78            disabled: true,
79            text_value: None,
80        }
81    }
82
83    pub fn option_with_text_value(
84        value: impl Into<String>,
85        label: impl Into<String>,
86        text_value: impl Into<String>,
87    ) -> Self {
88        Self::Option {
89            value: value.into(),
90            label: label.into(),
91            disabled: false,
92            text_value: Some(text_value.into()),
93        }
94    }
95
96    pub fn option_disabled_with_text_value(
97        value: impl Into<String>,
98        label: impl Into<String>,
99        text_value: impl Into<String>,
100    ) -> Self {
101        Self::Option {
102            value: value.into(),
103            label: label.into(),
104            disabled: true,
105            text_value: Some(text_value.into()),
106        }
107    }
108
109    pub fn group(label: impl Into<String>, items: Vec<SelectItem>) -> Self {
110        Self::Group {
111            label: label.into(),
112            items,
113        }
114    }
115
116    pub fn separator() -> Self {
117        Self::Separator
118    }
119
120    pub fn label(text: impl Into<String>) -> Self {
121        Self::Label(text.into())
122    }
123}
124
125pub struct ComboboxProps<'a, Id> {
126    pub id_source: Id,
127    pub value: &'a Option<String>,
128    pub search_value: &'a str,
129    pub items: &'a [SelectItem],
130    pub placeholder: &'a str,
131    pub search_placeholder: &'a str,
132    pub empty_text: &'a str,
133    pub size: ComboboxSize,
134    pub variant: InputVariant,
135    pub trigger_variant: ButtonVariant,
136    pub trigger_justify: ButtonJustify,
137    pub disabled: bool,
138    pub width: Option<f32>,
139}
140
141impl<'a, Id: Hash> ComboboxProps<'a, Id> {
142    pub fn new(
143        id_source: Id,
144        value: &'a Option<String>,
145        items: &'a [SelectItem],
146        search_value: &'a str,
147    ) -> Self {
148        Self {
149            id_source,
150            value,
151            search_value,
152            items,
153            placeholder: "Select option...",
154            search_placeholder: "Search...",
155            empty_text: "No option found.",
156            size: ComboboxSize::Size2,
157            variant: InputVariant::Surface,
158            trigger_variant: ButtonVariant::Outline,
159            trigger_justify: ButtonJustify::Between,
160            disabled: false,
161            width: None,
162        }
163    }
164
165    pub fn placeholder(mut self, placeholder: &'a str) -> Self {
166        self.placeholder = placeholder;
167        self
168    }
169
170    pub fn search_placeholder(mut self, placeholder: &'a str) -> Self {
171        self.search_placeholder = placeholder;
172        self
173    }
174
175    pub fn empty_text(mut self, empty_text: &'a str) -> Self {
176        self.empty_text = empty_text;
177        self
178    }
179
180    pub fn size(mut self, size: ComboboxSize) -> Self {
181        self.size = size;
182        self
183    }
184
185    pub fn variant(mut self, variant: InputVariant) -> Self {
186        self.variant = variant;
187        self
188    }
189
190    pub fn trigger_variant(mut self, variant: ButtonVariant) -> Self {
191        self.trigger_variant = variant;
192        self
193    }
194
195    pub fn trigger_justify(mut self, justify: ButtonJustify) -> Self {
196        self.trigger_justify = justify;
197        self
198    }
199
200    pub fn disabled(mut self, disabled: bool) -> Self {
201        self.disabled = disabled;
202        self
203    }
204
205    pub fn width(mut self, width: f32) -> Self {
206        self.width = Some(width);
207        self
208    }
209}
210
211fn get_selected_label(items: &[SelectItem], value: &Option<String>) -> Option<String> {
212    if let Some(val) = value {
213        for item in items {
214            match item {
215                SelectItem::Option { value, label, .. } => {
216                    if value == val {
217                        return Some(label.clone());
218                    }
219                }
220                SelectItem::Group { items, .. } => {
221                    if let Some(label) = get_selected_label(items, value) {
222                        return Some(label);
223                    }
224                }
225                _ => {}
226            }
227        }
228    }
229    None
230}
231
232fn filter_items(items: &[SelectItem], search: &str) -> Vec<SelectItem> {
233    if search.trim().is_empty() {
234        return items.to_vec();
235    }
236
237    let search_lower = search.to_lowercase();
238    let mut filtered = Vec::new();
239
240    for item in items {
241        match item {
242            SelectItem::Option {
243                value,
244                label,
245                text_value,
246                disabled,
247            } => {
248                let searchable = text_value.as_deref().unwrap_or(label);
249                if searchable.to_lowercase().contains(&search_lower)
250                    || value.to_lowercase().contains(&search_lower)
251                {
252                    filtered.push(SelectItem::Option {
253                        value: value.clone(),
254                        label: label.clone(),
255                        disabled: *disabled,
256                        text_value: text_value.clone(),
257                    });
258                }
259            }
260            SelectItem::Group { label, items } => {
261                let filtered_items = filter_items(items, search);
262                if !filtered_items.is_empty() {
263                    filtered.push(SelectItem::Group {
264                        label: label.clone(),
265                        items: filtered_items,
266                    });
267                }
268            }
269            SelectItem::Separator => filtered.push(SelectItem::Separator),
270            SelectItem::Label(text) => filtered.push(SelectItem::Label(text.clone())),
271        }
272    }
273
274    filtered
275}
276
277pub fn combobox<'a, Message: Clone + 'a, Id: Hash, F, G>(
278    props: ComboboxProps<'a, Id>,
279    on_value_change: Option<F>,
280    on_search_change: Option<G>,
281    theme: &'a Theme,
282) -> Element<'a, Message>
283where
284    F: Fn(Option<String>) -> Message + 'a,
285    G: Fn(String) -> Message + 'a,
286{
287    let selected_label = get_selected_label(props.items, props.value)
288        .unwrap_or_else(|| props.placeholder.to_string());
289    let size = ButtonSize::from(props.size);
290
291    let label = text(selected_label).size(13);
292    let chevron = text("▾").size(12);
293
294    let trigger_content: Element<'a, Message> = match props.trigger_justify {
295        ButtonJustify::Between => row![label, iced::widget::space().width(Length::Fill), chevron]
296            .align_y(Alignment::Center)
297            .width(Length::Fill)
298            .into(),
299        ButtonJustify::Center => {
300            container(row![label, chevron].spacing(6).align_y(Alignment::Center))
301                .width(Length::Fill)
302                .center_x(Length::Fill)
303                .into()
304        }
305        ButtonJustify::Start => row![label, chevron]
306            .spacing(6)
307            .align_y(Alignment::Center)
308            .into(),
309    };
310
311    let mut trigger = button_content(
312        trigger_content,
313        None::<Message>,
314        ButtonProps::new()
315            .variant(props.trigger_variant)
316            .size(size)
317            .disabled(props.disabled),
318        theme,
319    );
320
321    if let Some(width) = props.width {
322        trigger = trigger.width(Length::Fixed(width));
323    }
324
325    let items = filter_items(props.items, props.search_value);
326    let on_value_change = on_value_change.as_ref();
327    let search_enabled = on_search_change.is_some();
328    let on_search_change = on_search_change.map(|f| move |value| f(value));
329
330    let mut list: Vec<Element<'a, Message>> = Vec::new();
331    for item in items {
332        match item {
333            SelectItem::Option {
334                value,
335                label,
336                disabled,
337                ..
338            } => {
339                let enabled = !disabled && on_value_change.is_some();
340                let on_press = on_value_change
341                    .map(|f| f(Some(value.clone())))
342                    .filter(|_| enabled);
343                let element = button_content(
344                    text(label),
345                    on_press,
346                    ButtonProps::new()
347                        .variant(ButtonVariant::Ghost)
348                        .size(ButtonSize::Size1)
349                        .disabled(!enabled),
350                    theme,
351                )
352                .width(Length::Fill)
353                .into();
354                list.push(element);
355            }
356            SelectItem::Group { label, items } => {
357                list.push(
358                    text(label)
359                        .size(11)
360                        .style(move |_t| iced::widget::text::Style {
361                            color: Some(theme.palette.muted_foreground),
362                        })
363                        .into(),
364                );
365                for child in items {
366                    if let SelectItem::Option {
367                        value,
368                        label,
369                        disabled,
370                        ..
371                    } = child
372                    {
373                        let enabled = !disabled && on_value_change.is_some();
374                        let on_press = on_value_change
375                            .map(|f| f(Some(value.clone())))
376                            .filter(|_| enabled);
377                        let element = button_content(
378                            text(label),
379                            on_press,
380                            ButtonProps::new()
381                                .variant(ButtonVariant::Ghost)
382                                .size(ButtonSize::Size1)
383                                .disabled(!enabled),
384                            theme,
385                        )
386                        .width(Length::Fill)
387                        .into();
388                        list.push(element);
389                    }
390                }
391            }
392            SelectItem::Separator => {
393                list.push(rule::horizontal(1).into());
394            }
395            SelectItem::Label(text_value) => {
396                list.push(text(text_value).size(11).into());
397            }
398        }
399    }
400
401    if list.is_empty() {
402        list.push(
403            text(props.empty_text)
404                .size(12)
405                .style(move |_t| iced::widget::text::Style {
406                    color: Some(theme.palette.muted_foreground),
407                })
408                .into(),
409        );
410    }
411
412    let search_disabled = props.disabled || !search_enabled;
413    let search_input = input(
414        props.search_value,
415        props.search_placeholder,
416        on_search_change,
417        InputProps::new()
418            .size(InputSize::from(props.size))
419            .variant(props.variant)
420            .disabled(search_disabled),
421        theme,
422    )
423    .width(Length::Fill);
424
425    let content: Element<'a, Message> =
426        container(column![search_input, column(list).spacing(4)].spacing(8))
427            .padding(8)
428            .width(Length::Shrink)
429            .style(move |_t| iced::widget::container::Style {
430                background: Some(Background::Color(theme.palette.popover)),
431                text_color: Some(theme.palette.popover_foreground),
432                border: Border {
433                    radius: theme.radius.md.into(),
434                    width: 1.0,
435                    color: theme.palette.border,
436                },
437                ..Default::default()
438            })
439            .into();
440
441    let trigger: Element<'a, Message> = container(trigger)
442        .width(props.width.map(Length::Fixed).unwrap_or(Length::Shrink))
443        .style(move |_t| combobox_trigger_style(theme, props.variant))
444        .into();
445
446    popover(
447        trigger,
448        content,
449        PopoverProps::new().size(PopoverSize::Size2).offset(6.0),
450        theme,
451    )
452    .into()
453}
454
455fn combobox_trigger_style(theme: &Theme, variant: InputVariant) -> iced::widget::container::Style {
456    let palette = theme.palette;
457    let (background, border_color, text_color, border_width) = match variant {
458        InputVariant::Surface => (palette.background, palette.border, palette.foreground, 1.0),
459        InputVariant::Classic => (palette.background, palette.border, palette.foreground, 1.0),
460        InputVariant::Soft => (
461            accent_soft(&palette, crate::tokens::AccentColor::Gray),
462            palette.border,
463            accent_text(&palette, crate::tokens::AccentColor::Gray),
464            1.0,
465        ),
466        InputVariant::Ghost => (
467            iced::Color::TRANSPARENT,
468            iced::Color::TRANSPARENT,
469            palette.foreground,
470            0.0,
471        ),
472    };
473
474    iced::widget::container::Style {
475        background: Some(Background::Color(background)),
476        text_color: Some(text_color),
477        border: Border {
478            radius: theme.radius.sm.into(),
479            width: border_width,
480            color: border_color,
481        },
482        ..Default::default()
483    }
484}