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}