1use crate::{
10 core::{
11 assets,
12 attrs::{active_attribute, component_attributes, placeholder_attribute, value_attribute},
13 platform::DomBridge,
14 },
15 models::select as model,
16 prelude::*,
17 theme::use_stylesheets,
18};
19const BEM: Bem = Bem::new("fc-select");
20
21#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct SelectChoice {
24 pub value: String,
26 pub label: String,
28 pub group: Option<String>,
30 pub description: Option<String>,
32 pub meta: Option<String>,
34 pub icon: Option<String>,
36 pub disabled: bool,
38}
39impl SelectChoice {
40 #[must_use]
42 pub fn new(value: impl Into<String>, label: impl Into<String>) -> Self {
43 Self {
44 value: value.into(),
45 label: label.into(),
46 group: None,
47 description: None,
48 meta: None,
49 icon: None,
50 disabled: false,
51 }
52 }
53 #[must_use]
55 pub fn in_group(mut self, group: impl Into<String>) -> Self {
56 self.group = Some(group.into());
57 self
58 }
59 #[must_use]
61 pub fn describe(mut self, description: impl Into<String>) -> Self {
62 self.description = Some(description.into());
63 self
64 }
65 #[must_use]
67 pub fn meta(mut self, meta: impl Into<String>) -> Self {
68 self.meta = Some(meta.into());
69 self
70 }
71 #[must_use]
73 pub fn icon(mut self, icon: impl Into<String>) -> Self {
74 self.icon = Some(icon.into());
75 self
76 }
77 #[must_use]
79 pub fn disabled(mut self) -> Self {
80 self.disabled = true;
81 self
82 }
83}
84
85fn build(choices: &[SelectChoice], committed: Option<&str>) -> model::Select {
86 let options = choices
87 .iter()
88 .map(|c| {
89 let option = model::Option_::new(&c.value, &c.label);
90 let option = match &c.group {
91 Some(group) => option.in_group(group),
92 None => option,
93 };
94 if c.disabled {
95 option.disabled()
96 } else {
97 option
98 }
99 })
100 .collect();
101 let mut select = model::Select::new(options);
102 select.open(committed);
104 select.close(false);
105 select
106}
107
108#[component]
110pub fn Select(
111 label: AccessibleName,
112 options: Vec<SelectChoice>,
113 #[props(default)] value: Option<ReadSignal<Option<String>>>,
114 #[props(default)] default_value: Option<String>,
115 #[props(default)] onchange: Option<EventHandler<String>>,
116 #[props(default = "Select…".to_owned())]
118 placeholder: String,
119 #[props(default)] availability: Availability,
120 #[props(default)] class: Option<String>,
121 #[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
122) -> Element {
123 let state = use_controllable(
124 value,
125 || default_value,
126 onchange.map(|onchange| {
127 EventHandler::new(move |v: Option<String>| {
128 if let Some(v) = v {
129 onchange.call(v);
130 }
131 })
132 }),
133 );
134 let committed = state.get();
135 let mut select = use_signal(|| build(&options, committed.as_deref()));
136 let mut seen = use_signal(|| options.clone());
138 if *seen.peek() != options {
139 seen.set(options.clone());
140 select.set(build(&options, committed.as_deref()));
141 }
142 let mut open = use_signal(|| false);
143 let mut typing = use_signal(|| 0_u64);
144 let invoker = use_id();
145 let list = invoker.suffixed("listbox");
146 let blocked = availability != Availability::Enabled || options.is_empty();
147 let model = select.read().clone();
148 let active = model.active();
149 let current = committed
150 .as_deref()
151 .and_then(|v| options.iter().find(|o| o.value == v));
152 let option_id = |index: usize| format!("{list}-opt-{index}");
153
154 let ids = use_signal(|| (list.clone(), invoker.clone()));
156 let show = move |visible: bool| {
157 let (list, invoker) = ids.peek().clone();
158 spawn(async move {
159 if visible {
160 let _ = DomBridge.toggle_popover(&list, true).await;
161 let _ = DomBridge.place_below(&list, &invoker).await;
162 } else {
163 let _ = DomBridge.toggle_popover(&list, false).await;
164 let _ = DomBridge.focus(&invoker).await;
165 }
166 });
167 };
168 let mut open_list = move || {
169 if open() {
170 return;
171 }
172 select.write().open(state.get().as_deref());
173 open.set(true);
174 show(true);
175 };
176 let mut close_list = move |commit: bool| {
177 if !open() {
178 return;
179 }
180 select.write().close(commit);
181 open.set(false);
182 let after = select.peek().committed().map(ToOwned::to_owned);
183 if commit && after != state.get() {
184 state.set(after);
185 }
186 show(false);
187 };
188 let mut attributes = component_attributes(
189 attributes,
190 ClassList::new(BEM.block()).extend(class.as_deref()),
191 &[
192 "role",
193 "aria-expanded",
194 "aria-controls",
195 "aria-activedescendant",
196 "aria-label",
197 "aria-disabled",
198 ],
199 );
200 if let Some(value) = &committed {
201 attributes.push(value_attribute(value));
202 }
203 let mut groups_seen: Option<String> = None;
204 let mut rows = Vec::new();
205 for &index in model.visible() {
206 let Some(choice) = options.get(index) else {
207 continue;
208 };
209 if choice.group.is_some() && choice.group != groups_seen {
210 groups_seen.clone_from(&choice.group);
211 rows.push((None, Some(choice.group.clone().unwrap_or_default())));
212 }
213 rows.push((Some(index), None));
214 }
215 rsx! {
216 if use_stylesheets() { document::Stylesheet { href: assets::COMPONENTS_SELECT_CSS } }
217 button { r#type: "button", id: "{invoker}", role: "combobox", aria_label: label.to_string(),
218 aria_expanded: open().to_string(),
219 aria_controls: open().then(|| list.to_string()),
220 aria_activedescendant: if open() { active.map(option_id) } else { None },
221 aria_disabled: blocked.then_some("true"),
222 onclick: move |_| if !blocked { if open() { close_list(true) } else { open_list() } },
223 onfocusout: move |_| close_list(false),
224 onkeydown: move |event| {
225 if blocked { return; }
226 let key = event.key();
227 match key {
228 Key::ArrowDown | Key::ArrowUp => {
229 event.prevent_default();
230 if open() {
231 select.write().move_by(if key == Key::ArrowDown { 1 } else { -1 });
232 } else {
233 open_list();
234 }
235 }
236 Key::Home | Key::End if open() => {
237 event.prevent_default();
238 if key == Key::Home { select.write().focus_first(); } else { select.write().focus_last(); }
239 }
240 Key::Enter => {
241 event.prevent_default();
242 if open() { close_list(true) } else { open_list() }
243 }
244 Key::Escape if open() => {
245 event.prevent_default();
246 close_list(false);
247 }
248 Key::Tab => close_list(true),
249 Key::Character(text) if open() && !event.modifiers().ctrl() && !event.modifiers().meta() && !event.modifiers().alt() => {
250 event.prevent_default();
251 if text == " " && select.peek().active().is_some() {
252 close_list(true);
253 return;
254 }
255 select.write().typeahead(&text);
256 let generation = typing() + 1;
258 typing.set(generation);
259 spawn(async move {
260 if DomBridge.delay(600).await.is_ok() && typing() == generation {
261 select.write().reset_typeahead();
262 }
263 });
264 }
265 Key::Character(text) if !open() && text == " " => {
266 event.prevent_default();
267 open_list();
268 }
269 _ => {}
270 }
271 },
272 ..attributes,
273 span { class: BEM.element("value"), ..placeholder_attribute(current.is_none()),
274 "{current.map_or(placeholder.as_str(), |c| c.label.as_str())}"
275 }
276 span { class: BEM.element("chevron"), aria_hidden: "true", "▾" }
277 }
278 div { class: BEM.element("list"), id: "{list}", role: "listbox", aria_label: label.to_string(), popover: "manual",
279 for (option, heading) in rows {
280 if let Some(heading) = heading {
281 div { class: BEM.element("group-label"), role: "presentation", "{heading}" }
282 }
283 if let Some(index) = option {
284 if let Some(choice) = options.get(index) {
285 {option_view(choice, index, option_id(index), committed.as_deref() == Some(choice.value.as_str()), open() && active == Some(index), select, close_list)}
286 }
287 }
288 }
289 if model.visible().is_empty() {
290 div { class: BEM.element("empty"), role: "option", aria_disabled: "true", "No matches" }
291 }
292 }
293 }
294}
295
296fn option_view(
297 choice: &SelectChoice,
298 index: usize,
299 id: String,
300 selected: bool,
301 active: bool,
302 mut select: Signal<model::Select>,
303 mut close: impl FnMut(bool) + 'static,
304) -> Element {
305 let disabled = choice.disabled;
306 let mut data = crate::spread![value_attribute(&choice.value)];
307 data.extend(active_attribute(active));
308 rsx! {
309 div { class: BEM.element("option"), role: "option", id, aria_selected: selected.then_some("true"),
310 aria_disabled: disabled.then_some("true"),
311 onmousedown: move |event| event.prevent_default(),
313 onclick: move |_| if !disabled {
314 let delta = {
315 let model = select.peek();
316 let from = model.active().and_then(|a| model.visible().iter().position(|&i| i == a));
317 let to = model.visible().iter().position(|&i| i == index);
318 match (from, to) {
319 (Some(from), Some(to)) => isize::try_from(to).unwrap_or(0) - isize::try_from(from).unwrap_or(0),
320 _ => 0,
321 }
322 };
323 select.write().move_by(delta);
324 close(true);
325 },
326 ..data,
327 span { class: BEM.element("option-check"), aria_hidden: "true", if selected { "✓" } }
328 if let Some(icon) = &choice.icon { span { class: BEM.element("option-icon"), aria_hidden: "true", "{icon}" } }
329 if let Some(description) = &choice.description {
330 span { class: BEM.element("option-content"),
331 span { "{choice.label}" }
332 span { class: BEM.element("option-description"), "{description}" }
333 }
334 } else {
335 span { "{choice.label}" }
336 }
337 if let Some(meta) = &choice.meta { span { class: BEM.element("option-meta"), "{meta}" } }
338 }
339 }
340}