Skip to main content

fc_dioxus/components/
select.rs

1//! A value-selecting combobox and its listbox, from select.css, on upstream's
2//! `fc-select` model.
3//!
4//! Focus never leaves the invoker: the highlighted option is conveyed with
5//! `aria-activedescendant`, so the control stays one tab stop and reads as a combobox.
6//! Escape restores the value in effect before the list opened; Enter, Space, a click,
7//! or Tab commits. Navigation, typeahead, and the committed value come from
8//! `fc_select::Select`, the same model upstream's binding can run as WASM.
9use 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/// One choice.
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct SelectChoice {
24    /// Stable value, carried back on change. Not the label.
25    pub value: String,
26    /// What people read.
27    pub label: String,
28    /// The group heading it sits under.
29    pub group: Option<String>,
30    /// A second line.
31    pub description: Option<String>,
32    /// A short supplementary value, such as a price.
33    pub meta: Option<String>,
34    /// A decorative glyph before the label.
35    pub icon: Option<String>,
36    /// Unavailable: skipped by arrows and typeahead, never chosen, still announced.
37    pub disabled: bool,
38}
39impl SelectChoice {
40    /// A plain choice.
41    #[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    /// Under a group heading.
54    #[must_use]
55    pub fn in_group(mut self, group: impl Into<String>) -> Self {
56        self.group = Some(group.into());
57        self
58    }
59    /// With a second line.
60    #[must_use]
61    pub fn describe(mut self, description: impl Into<String>) -> Self {
62        self.description = Some(description.into());
63        self
64    }
65    /// With a short supplementary value, such as a price.
66    #[must_use]
67    pub fn meta(mut self, meta: impl Into<String>) -> Self {
68        self.meta = Some(meta.into());
69        self
70    }
71    /// With a decorative glyph.
72    #[must_use]
73    pub fn icon(mut self, icon: impl Into<String>) -> Self {
74        self.icon = Some(icon.into());
75        self
76    }
77    /// Unavailable.
78    #[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    // Seed the committed value; opening and closing without a highlight keeps it.
103    select.open(committed);
104    select.close(false);
105    select
106}
107
108/// A combobox that opens a listbox of [`SelectChoice`]s.
109#[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    /// Shown while nothing is chosen.
117    #[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    // A new option list rebuilds the model; the committed value carries over.
137    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    // Held in signals so the handlers below are `Copy` and can be shared.
155    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                        // A 600 ms window lets "son" find Sonnet instead of three searches.
257                        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            // Keep focus on the invoker while the pointer is on the list.
312            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}