Skip to main content

hypersig_ui/
combobox.rs

1//! Comboboxes: a text field that suggests options from a list as the visitor
2//! types.
3
4use std::fmt;
5use std::marker::PhantomData;
6
7use hypersig::html::{El, Event, Key, div, input, label, p};
8use hypersig::style::Style;
9use hypersig::style::pattern::stack;
10use hypersig::style::value::{Display, FlexDirection, OverflowY, Position, ZIndex, pct, raw, rem};
11use hypersig::{Expr, NodeRef, Sig, Signals, Stmt, if_else};
12use serde::{Deserialize, Serialize};
13
14use crate::field::{field_style, label_style};
15use crate::listbox::{list_style, option_style};
16use crate::look::{self, icon};
17use crate::token;
18
19/// The signals a [`Combobox`] keeps: the text, whether its list is open,
20/// which option the arrow keys reached, and whether the pointer is on the
21/// list.
22///
23/// Put one in the page's signals for each combobox and read the chosen text
24/// from [`ComboboxState::text`].
25///
26/// ```
27/// use hypersig::Signals;
28/// use hypersig_ui::ComboboxState;
29/// use serde::{Deserialize, Serialize};
30///
31/// #[derive(Signals, Serialize, Deserialize)]
32/// struct Form {
33///     country: ComboboxState,
34/// }
35///
36/// let form = Form { country: ComboboxState::new("Japan") };
37/// assert_eq!(form.country.text, "Japan");
38/// ```
39#[derive(Signals, Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq)]
40pub struct ComboboxState {
41    /// What the text field holds: what the visitor typed, or the option they
42    /// chose.
43    pub text: String,
44    open: bool,
45    active: String,
46    pointer: bool,
47}
48
49impl ComboboxState {
50    /// A closed combobox whose text field holds `text`.
51    #[must_use]
52    pub fn new(text: impl Into<String>) -> Self {
53        Self {
54            text: text.into(),
55            ..Self::default()
56        }
57    }
58}
59
60/// A combobox on a page whose signals are `S`: a text field with a list of
61/// options that the text filters.
62///
63/// Declare one as a `const` naming its text field and its list, since the
64/// runtime finds both by name. It follows the WAI-ARIA combobox pattern with
65/// list autocomplete. The text field has `role="combobox"`,
66/// `aria-expanded`, `aria-controls`, and `aria-activedescendant`, and focus
67/// stays in it while the up and down arrows move through the options shown,
68/// through [`El::active_descendant`](hypersig::html::El::active_descendant).
69/// Typing opens the list and shows the options that contain the text,
70/// ignoring case, the down arrow opens it, Enter chooses the option the
71/// arrows reached, and Escape closes the list or, when it is closed, clears
72/// the text. Choosing an option puts its label in the text field and closes
73/// the list, and so does leaving the field, unless the pointer is on the
74/// list.
75///
76/// Escape is taken by the combobox while focus is in its text field, so a
77/// dialog around it closes on Escape only from elsewhere. The visitor can
78/// also leave text that matches no option, which the page
79/// checks where it needs one of the options. The text field and the list
80/// carry node refs with their names, so two comboboxes with one name on a page
81/// make [`Page::render`](hypersig::Page::render) panic.
82///
83/// ```
84/// use hypersig::Signals;
85/// use hypersig_ui::{Combobox, ComboboxState};
86/// use serde::{Deserialize, Serialize};
87///
88/// #[derive(Signals, Serialize, Deserialize)]
89/// struct Form {
90///     country: ComboboxState,
91/// }
92///
93/// const COUNTRY: Combobox<Form> = Combobox::new("country", "country-options");
94///
95/// let field = COUNTRY.render("Country", Form::country(), ["France", "Japan", "Kenya"]);
96/// let html = field.render();
97/// assert!(html.contains(r#"role="combobox""#));
98/// assert!(html.contains(r#"aria-controls="country-options""#));
99/// ```
100pub struct Combobox<S> {
101    field: &'static str,
102    list: &'static str,
103    marker: PhantomData<fn() -> S>,
104}
105
106impl<S> Combobox<S> {
107    /// A combobox whose text field has the id `field` and whose list has the
108    /// id `list`.
109    ///
110    /// # Panics
111    ///
112    /// Panics if either name is empty or holds a character other than an
113    /// ASCII letter, digit, `-`, or `_`, or if the two are equal. In a
114    /// `const`, that is a compile error.
115    #[must_use]
116    pub const fn new(field: &'static str, list: &'static str) -> Self {
117        crate::id::check_name(field);
118        crate::id::check_name(list);
119        assert!(
120            !crate::id::same(field, list),
121            "a combobox's field and list have different names"
122        );
123        Self {
124            field,
125            list,
126            marker: PhantomData,
127        }
128    }
129
130    /// The label, the text field, and the list of `options`, in a `<div>`,
131    /// keeping their state in `state`.
132    #[must_use]
133    pub fn render<L: Into<String>>(
134        self,
135        label_text: impl Into<String>,
136        state: Sig<S, ComboboxState>,
137        options: impl IntoIterator<Item = L>,
138    ) -> El<S> {
139        let text = state.at(ComboboxState::text());
140        let open = state.at(ComboboxState::open());
141        let active = state.at(ComboboxState::active());
142        let pointer = state.at(ComboboxState::pointer());
143        let field_ref = NodeRef::new(self.field);
144        let list_ref = NodeRef::new(self.list);
145        let query = text.to_lowercase();
146
147        let close: Stmt<S> = open.set(false).then(active.set(""));
148        let mut none_shown: Expr<S, bool> = Expr::lit(&true);
149        let mut items = Vec::new();
150        for (index, title) in options.into_iter().enumerate() {
151            let title: String = title.into();
152            let id = format!("{}-{index}", self.list);
153            let shown = Expr::lit(&title.to_lowercase()).contains(query.clone());
154            none_shown = none_shown.and(!shown.clone());
155            let reached = active.equals(id.clone());
156            items.push(
157                div()
158                    .attr("role", "option")
159                    .id(id)
160                    .bind_attr("aria-selected", if_else(reached.clone(), "true", "false"))
161                    .show(shown)
162                    .on_click(
163                        text.set(title.clone())
164                            .then(close.clone())
165                            .then(pointer.set(false))
166                            .then(field_ref.focus()),
167                    )
168                    .css(option_style())
169                    .css_when(
170                        Style::new().background_color(token::accent().var()),
171                        reached,
172                    )
173                    .text(title.clone())
174                    .child(icon(look::icon::CHECK, rem(1)).show(text.equals(title))),
175            );
176        }
177
178        let field = input()
179            .attr("type", "text")
180            .id(self.field)
181            .attr("role", "combobox")
182            .attr("aria-autocomplete", "list")
183            .attr("aria-controls", self.list)
184            .attr("autocomplete", "off")
185            .bind_attr("aria-expanded", if_else(open, "true", "false"))
186            .bind_attr("aria-activedescendant", active)
187            .bind(text)
188            .node_ref(field_ref)
189            .active_descendant(list_ref, active)
190            .on_input(open.set(true).then(active.set("")))
191            .on_key(Key::ArrowDown, open.set(true))
192            .on_key(
193                Key::Escape,
194                Stmt::if_else(open, close.clone(), text.set("")),
195            )
196            .on(Event::Blur, Stmt::when(!pointer, close))
197            .css(field_style().height(rem(2.25)));
198
199        // The message sits beside the listbox rather than in it, because a
200        // listbox holds only options.
201        let list = div()
202            .show(open)
203            .on(Event::MouseEnter, pointer.set(true))
204            .on(Event::MouseLeave, pointer.set(false))
205            .css(drop_down_style())
206            .child(
207                div()
208                    .id(self.list)
209                    .attr("role", "listbox")
210                    .attr("aria-label", "Suggestions")
211                    .node_ref(list_ref)
212                    .show(!none_shown.clone())
213                    .css(
214                        Style::new()
215                            .display(Display::Flex)
216                            .flex_direction(FlexDirection::Column),
217                    )
218                    .children(items),
219            )
220            .child(
221                p().css(
222                    look::small_text()
223                        .padding_block(rem(0.375))
224                        .padding_inline(rem(0.5))
225                        .color(token::muted_foreground().var()),
226                )
227                .show(none_shown)
228                .text("No matches"),
229            );
230
231        div()
232            .css(stack(rem(0.5)))
233            .child(
234                label()
235                    .attr("for", self.field)
236                    .css(label_style())
237                    .text(label_text),
238            )
239            .child(
240                div()
241                    .css(Style::new().position(Position::Relative))
242                    .child(field)
243                    .child(list),
244            )
245    }
246}
247
248impl<S> Clone for Combobox<S> {
249    fn clone(&self) -> Self {
250        *self
251    }
252}
253
254impl<S> Copy for Combobox<S> {}
255
256impl<S> fmt::Debug for Combobox<S> {
257    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
258        f.debug_struct("Combobox")
259            .field("field", &self.field)
260            .field("list", &self.list)
261            .finish()
262    }
263}
264
265fn drop_down_style() -> Style {
266    list_style()
267        .position(Position::Absolute)
268        .top(raw("calc(100% + 0.25rem)"))
269        .left(rem(0))
270        .width(pct(100))
271        .max_height(rem(15))
272        .overflow_y(OverflowY::Auto)
273        .z_index(ZIndex::Integer(50))
274        .box_shadow(raw(
275            "0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)",
276        ))
277}