use crate::{
core::{
assets,
attrs::{active_attribute, component_attributes, placeholder_attribute, value_attribute},
platform::DomBridge,
},
models::select as model,
prelude::*,
theme::use_stylesheets,
};
const BEM: Bem = Bem::new("fc-select");
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SelectChoice {
pub value: String,
pub label: String,
pub group: Option<String>,
pub description: Option<String>,
pub meta: Option<String>,
pub icon: Option<String>,
pub disabled: bool,
}
impl SelectChoice {
#[must_use]
pub fn new(value: impl Into<String>, label: impl Into<String>) -> Self {
Self {
value: value.into(),
label: label.into(),
group: None,
description: None,
meta: None,
icon: None,
disabled: false,
}
}
#[must_use]
pub fn in_group(mut self, group: impl Into<String>) -> Self {
self.group = Some(group.into());
self
}
#[must_use]
pub fn describe(mut self, description: impl Into<String>) -> Self {
self.description = Some(description.into());
self
}
#[must_use]
pub fn meta(mut self, meta: impl Into<String>) -> Self {
self.meta = Some(meta.into());
self
}
#[must_use]
pub fn icon(mut self, icon: impl Into<String>) -> Self {
self.icon = Some(icon.into());
self
}
#[must_use]
pub fn disabled(mut self) -> Self {
self.disabled = true;
self
}
}
fn build(choices: &[SelectChoice], committed: Option<&str>) -> model::Select {
let options = choices
.iter()
.map(|c| {
let option = model::Option_::new(&c.value, &c.label);
let option = match &c.group {
Some(group) => option.in_group(group),
None => option,
};
if c.disabled {
option.disabled()
} else {
option
}
})
.collect();
let mut select = model::Select::new(options);
select.open(committed);
select.close(false);
select
}
#[component]
pub fn Select(
label: AccessibleName,
options: Vec<SelectChoice>,
#[props(default)] value: Option<ReadSignal<Option<String>>>,
#[props(default)] default_value: Option<String>,
#[props(default)] onchange: Option<EventHandler<String>>,
#[props(default = "Select…".to_owned())]
placeholder: String,
#[props(default)] availability: Availability,
#[props(default)] class: Option<String>,
#[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
) -> Element {
let state = use_controllable(
value,
|| default_value,
onchange.map(|onchange| {
EventHandler::new(move |v: Option<String>| {
if let Some(v) = v {
onchange.call(v);
}
})
}),
);
let committed = state.get();
let mut select = use_signal(|| build(&options, committed.as_deref()));
let mut seen = use_signal(|| options.clone());
if *seen.peek() != options {
seen.set(options.clone());
select.set(build(&options, committed.as_deref()));
}
let mut open = use_signal(|| false);
let mut typing = use_signal(|| 0_u64);
let invoker = use_id();
let list = invoker.suffixed("listbox");
let blocked = availability != Availability::Enabled || options.is_empty();
let model = select.read().clone();
let active = model.active();
let current = committed
.as_deref()
.and_then(|v| options.iter().find(|o| o.value == v));
let option_id = |index: usize| format!("{list}-opt-{index}");
let ids = use_signal(|| (list.clone(), invoker.clone()));
let show = move |visible: bool| {
let (list, invoker) = ids.peek().clone();
spawn(async move {
if visible {
let _ = DomBridge.toggle_popover(&list, true).await;
let _ = DomBridge.place_below(&list, &invoker).await;
} else {
let _ = DomBridge.toggle_popover(&list, false).await;
let _ = DomBridge.focus(&invoker).await;
}
});
};
let mut open_list = move || {
if open() {
return;
}
select.write().open(state.get().as_deref());
open.set(true);
show(true);
};
let mut close_list = move |commit: bool| {
if !open() {
return;
}
select.write().close(commit);
open.set(false);
let after = select.peek().committed().map(ToOwned::to_owned);
if commit && after != state.get() {
state.set(after);
}
show(false);
};
let mut attributes = component_attributes(
attributes,
ClassList::new(BEM.block()).extend(class.as_deref()),
&[
"role",
"aria-expanded",
"aria-controls",
"aria-activedescendant",
"aria-label",
"aria-disabled",
],
);
if let Some(value) = &committed {
attributes.push(value_attribute(value));
}
let mut groups_seen: Option<String> = None;
let mut rows = Vec::new();
for &index in model.visible() {
let Some(choice) = options.get(index) else {
continue;
};
if choice.group.is_some() && choice.group != groups_seen {
groups_seen.clone_from(&choice.group);
rows.push((None, Some(choice.group.clone().unwrap_or_default())));
}
rows.push((Some(index), None));
}
rsx! {
if use_stylesheets() { document::Stylesheet { href: assets::COMPONENTS_SELECT_CSS } }
button { r#type: "button", id: "{invoker}", role: "combobox", aria_label: label.to_string(),
aria_expanded: open().to_string(),
aria_controls: open().then(|| list.to_string()),
aria_activedescendant: if open() { active.map(option_id) } else { None },
aria_disabled: blocked.then_some("true"),
onclick: move |_| if !blocked { if open() { close_list(true) } else { open_list() } },
onfocusout: move |_| close_list(false),
onkeydown: move |event| {
if blocked { return; }
let key = event.key();
match key {
Key::ArrowDown | Key::ArrowUp => {
event.prevent_default();
if open() {
select.write().move_by(if key == Key::ArrowDown { 1 } else { -1 });
} else {
open_list();
}
}
Key::Home | Key::End if open() => {
event.prevent_default();
if key == Key::Home { select.write().focus_first(); } else { select.write().focus_last(); }
}
Key::Enter => {
event.prevent_default();
if open() { close_list(true) } else { open_list() }
}
Key::Escape if open() => {
event.prevent_default();
close_list(false);
}
Key::Tab => close_list(true),
Key::Character(text) if open() && !event.modifiers().ctrl() && !event.modifiers().meta() && !event.modifiers().alt() => {
event.prevent_default();
if text == " " && select.peek().active().is_some() {
close_list(true);
return;
}
select.write().typeahead(&text);
let generation = typing() + 1;
typing.set(generation);
spawn(async move {
if DomBridge.delay(600).await.is_ok() && typing() == generation {
select.write().reset_typeahead();
}
});
}
Key::Character(text) if !open() && text == " " => {
event.prevent_default();
open_list();
}
_ => {}
}
},
..attributes,
span { class: BEM.element("value"), ..placeholder_attribute(current.is_none()),
"{current.map_or(placeholder.as_str(), |c| c.label.as_str())}"
}
span { class: BEM.element("chevron"), aria_hidden: "true", "▾" }
}
div { class: BEM.element("list"), id: "{list}", role: "listbox", aria_label: label.to_string(), popover: "manual",
for (option, heading) in rows {
if let Some(heading) = heading {
div { class: BEM.element("group-label"), role: "presentation", "{heading}" }
}
if let Some(index) = option {
if let Some(choice) = options.get(index) {
{option_view(choice, index, option_id(index), committed.as_deref() == Some(choice.value.as_str()), open() && active == Some(index), select, close_list)}
}
}
}
if model.visible().is_empty() {
div { class: BEM.element("empty"), role: "option", aria_disabled: "true", "No matches" }
}
}
}
}
fn option_view(
choice: &SelectChoice,
index: usize,
id: String,
selected: bool,
active: bool,
mut select: Signal<model::Select>,
mut close: impl FnMut(bool) + 'static,
) -> Element {
let disabled = choice.disabled;
let mut data = crate::spread![value_attribute(&choice.value)];
data.extend(active_attribute(active));
rsx! {
div { class: BEM.element("option"), role: "option", id, aria_selected: selected.then_some("true"),
aria_disabled: disabled.then_some("true"),
onmousedown: move |event| event.prevent_default(),
onclick: move |_| if !disabled {
let delta = {
let model = select.peek();
let from = model.active().and_then(|a| model.visible().iter().position(|&i| i == a));
let to = model.visible().iter().position(|&i| i == index);
match (from, to) {
(Some(from), Some(to)) => isize::try_from(to).unwrap_or(0) - isize::try_from(from).unwrap_or(0),
_ => 0,
}
};
select.write().move_by(delta);
close(true);
},
..data,
span { class: BEM.element("option-check"), aria_hidden: "true", if selected { "✓" } }
if let Some(icon) = &choice.icon { span { class: BEM.element("option-icon"), aria_hidden: "true", "{icon}" } }
if let Some(description) = &choice.description {
span { class: BEM.element("option-content"),
span { "{choice.label}" }
span { class: BEM.element("option-description"), "{description}" }
}
} else {
span { "{choice.label}" }
}
if let Some(meta) = &choice.meta { span { class: BEM.element("option-meta"), "{meta}" } }
}
}
}