1use 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 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 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}