Skip to main content

maud_ui/primitives/
select.rs

1//! Select component — maud-ui Wave 3
2use maud::{html, Markup};
3
4#[derive(Clone, Debug)]
5pub struct SelectOption {
6    pub value: String,
7    pub label: String,
8    pub disabled: bool,
9}
10
11#[derive(Clone, Debug)]
12pub struct Props {
13    pub name: String,
14    pub id: String,
15    pub options: Vec<SelectOption>,
16    pub selected: Option<String>,
17    pub placeholder: String,
18    pub disabled: bool,
19}
20
21impl Default for Props {
22    fn default() -> Self {
23        Self {
24            name: "select".to_string(),
25            id: "select".to_string(),
26            options: vec![],
27            selected: None,
28            placeholder: "Select…".to_string(),
29            disabled: false,
30        }
31    }
32}
33
34pub fn render(props: Props) -> Markup {
35    let selected_label = props
36        .selected
37        .as_ref()
38        .and_then(|sel| {
39            props
40                .options
41                .iter()
42                .find(|opt| &opt.value == sel)
43                .map(|opt| opt.label.clone())
44        })
45        .unwrap_or_else(|| props.placeholder.clone());
46
47    let hidden_value = props.selected.clone().unwrap_or_default();
48
49    html! {
50        div class="mui-select" data-mui="select" data-name=(props.name.clone()) {
51            @if props.disabled {
52                button type="button" class="mui-select__trigger" id=(props.id.clone())
53                        role="combobox" aria-expanded="false"
54                        aria-haspopup="listbox" aria-controls=(format!("{}-listbox", props.id))
55                        aria-activedescendant=""
56                        aria-label=(selected_label.clone())
57                        disabled {
58                    span class="mui-select__value" { (selected_label) }
59                    span class="mui-select__chevron" aria-hidden="true" { "▾" }
60                }
61            } @else {
62                button type="button" class="mui-select__trigger" id=(props.id.clone())
63                        role="combobox" aria-expanded="false"
64                        aria-haspopup="listbox" aria-controls=(format!("{}-listbox", props.id))
65                        aria-activedescendant=""
66                        aria-label=(selected_label.clone()) {
67                    span class="mui-select__value" { (selected_label) }
68                    span class="mui-select__chevron" aria-hidden="true" { "▾" }
69                }
70            }
71
72            div class="mui-select__dropdown" id=(format!("{}-listbox", props.id)) role="listbox"
73                    aria-labelledby=(props.id) hidden {
74                @for (idx, opt) in props.options.iter().enumerate() {
75                    @if opt.disabled {
76                        div class=(format!("mui-select__option{}", if props.selected.as_ref() == Some(&opt.value) { " mui-select__option--selected" } else { "" }))
77                            role="option" id=(format!("{}-opt-{}", props.id, idx))
78                            data-value=(opt.value.clone())
79                            aria-selected=(props.selected.as_ref() == Some(&opt.value))
80                            aria-disabled="true" {
81                            span class="mui-select__check" aria-hidden="true" { "\u{2713}" }
82                            span class="mui-select__option-label" { (opt.label.clone()) }
83                        }
84                    } @else {
85                        div class=(format!("mui-select__option{}", if props.selected.as_ref() == Some(&opt.value) { " mui-select__option--selected" } else { "" }))
86                            role="option" id=(format!("{}-opt-{}", props.id, idx))
87                            data-value=(opt.value.clone())
88                            aria-selected=(props.selected.as_ref() == Some(&opt.value))
89                            aria-disabled="false" {
90                            span class="mui-select__check" aria-hidden="true" { "\u{2713}" }
91                            span class="mui-select__option-label" { (opt.label.clone()) }
92                        }
93                    }
94                }
95            }
96
97            input type="hidden" name=(props.name.clone()) value=(hidden_value) class="mui-select__hidden";
98        }
99    }
100}
101
102pub fn showcase() -> Markup {
103    html! {
104        div.mui-showcase__grid {
105            // Primary demo: realistic Preferences form
106            section {
107                h2 { "Preferences" }
108                p.mui-showcase__caption { "A realistic settings form with theme, language, and a locked timezone field." }
109                div style="display:flex;flex-direction:column;gap:1rem;max-width:24rem;" {
110                    div class="mui-field" {
111                        label class="mui-field__label" for="pref-theme" { "Theme" }
112                        (render(Props {
113                            name: "theme".to_string(),
114                            id: "pref-theme".to_string(),
115                            options: vec![
116                                SelectOption { value: "light".to_string(), label: "Light".to_string(), disabled: false },
117                                SelectOption { value: "dark".to_string(), label: "Dark".to_string(), disabled: false },
118                                SelectOption { value: "system".to_string(), label: "System".to_string(), disabled: false },
119                            ],
120                            selected: Some("system".to_string()),
121                            placeholder: "Choose theme\u{2026}".to_string(),
122                            disabled: false,
123                        }))
124                        p class="mui-field__description" { "Controls the application appearance." }
125                    }
126                    div class="mui-field" {
127                        label class="mui-field__label" for="pref-language" { "Language" }
128                        (render(Props {
129                            name: "language".to_string(),
130                            id: "pref-language".to_string(),
131                            options: vec![
132                                SelectOption { value: "en".to_string(), label: "English".to_string(), disabled: false },
133                                SelectOption { value: "es".to_string(), label: "Spanish".to_string(), disabled: false },
134                                SelectOption { value: "fr".to_string(), label: "French".to_string(), disabled: false },
135                                SelectOption { value: "de".to_string(), label: "German".to_string(), disabled: false },
136                                SelectOption { value: "ja".to_string(), label: "Japanese".to_string(), disabled: false },
137                            ],
138                            selected: None,
139                            placeholder: "Select language\u{2026}".to_string(),
140                            disabled: false,
141                        }))
142                    }
143                    div class="mui-field" {
144                        label class="mui-field__label mui-label--disabled" for="pref-timezone" {
145                            "Timezone"
146                            span style="font-weight:400;color:var(--mui-muted-foreground);margin-left:0.5rem;font-size:0.75rem;" { "(locked by admin)" }
147                        }
148                        (render(Props {
149                            name: "timezone".to_string(),
150                            id: "pref-timezone".to_string(),
151                            options: vec![
152                                SelectOption { value: "utc".to_string(), label: "UTC".to_string(), disabled: false },
153                                SelectOption { value: "est".to_string(), label: "US Eastern (EST)".to_string(), disabled: false },
154                                SelectOption { value: "pst".to_string(), label: "US Pacific (PST)".to_string(), disabled: false },
155                                SelectOption { value: "cet".to_string(), label: "Central European (CET)".to_string(), disabled: false },
156                                SelectOption { value: "jst".to_string(), label: "Japan Standard (JST)".to_string(), disabled: false },
157                            ],
158                            selected: Some("utc".to_string()),
159                            placeholder: "Select timezone\u{2026}".to_string(),
160                            disabled: true,
161                        }))
162                    }
163                }
164            }
165
166            // Anatomy: individual select features
167            section {
168                h2 { "Select Anatomy" }
169                p.mui-showcase__caption { "Individual select states shown in isolation." }
170                div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(14rem,1fr));gap:1.5rem;" {
171                    div {
172                        h3 style="font-size:0.875rem;margin-bottom:0.5rem;" { "Pre-selected" }
173                        (render(Props {
174                            name: "status".to_string(),
175                            id: "anatomy-preselected".to_string(),
176                            options: vec![
177                                SelectOption { value: "active".to_string(), label: "Active".to_string(), disabled: false },
178                                SelectOption { value: "paused".to_string(), label: "Paused".to_string(), disabled: false },
179                                SelectOption { value: "archived".to_string(), label: "Archived".to_string(), disabled: false },
180                            ],
181                            selected: Some("active".to_string()),
182                            placeholder: "Select status\u{2026}".to_string(),
183                            disabled: false,
184                        }))
185                    }
186                    div {
187                        h3 style="font-size:0.875rem;margin-bottom:0.5rem;" { "Placeholder" }
188                        (render(Props {
189                            name: "priority".to_string(),
190                            id: "anatomy-placeholder".to_string(),
191                            options: vec![
192                                SelectOption { value: "low".to_string(), label: "Low".to_string(), disabled: false },
193                                SelectOption { value: "medium".to_string(), label: "Medium".to_string(), disabled: false },
194                                SelectOption { value: "high".to_string(), label: "High".to_string(), disabled: false },
195                                SelectOption { value: "critical".to_string(), label: "Critical".to_string(), disabled: false },
196                            ],
197                            selected: None,
198                            placeholder: "Set priority\u{2026}".to_string(),
199                            disabled: false,
200                        }))
201                    }
202                    div {
203                        h3 style="font-size:0.875rem;margin-bottom:0.5rem;" { "Disabled option" }
204                        (render(Props {
205                            name: "plan".to_string(),
206                            id: "anatomy-disabled-opt".to_string(),
207                            options: vec![
208                                SelectOption { value: "free".to_string(), label: "Free".to_string(), disabled: false },
209                                SelectOption { value: "pro".to_string(), label: "Pro".to_string(), disabled: false },
210                                SelectOption { value: "enterprise".to_string(), label: "Enterprise (contact sales)".to_string(), disabled: true },
211                            ],
212                            selected: Some("free".to_string()),
213                            placeholder: "Choose plan\u{2026}".to_string(),
214                            disabled: false,
215                        }))
216                    }
217                    div {
218                        h3 style="font-size:0.875rem;margin-bottom:0.5rem;" { "Fully disabled" }
219                        (render(Props {
220                            name: "role".to_string(),
221                            id: "anatomy-disabled".to_string(),
222                            options: vec![
223                                SelectOption { value: "viewer".to_string(), label: "Viewer".to_string(), disabled: false },
224                                SelectOption { value: "editor".to_string(), label: "Editor".to_string(), disabled: false },
225                                SelectOption { value: "admin".to_string(), label: "Admin".to_string(), disabled: false },
226                            ],
227                            selected: Some("editor".to_string()),
228                            placeholder: "Select role\u{2026}".to_string(),
229                            disabled: true,
230                        }))
231                    }
232                }
233            }
234        }
235    }
236}