hypersig_ui/choice.rs
1//! Checkboxes, switches, and radio groups, drawn over native inputs.
2//!
3//! Each control is a native `<input>` the browser draws nothing for, laid
4//! over a box drawn by the elements after it. The input keeps its role, its
5//! keyboard handling, and its place in a form, and the drawn box follows its
6//! state through `Style::peer`.
7
8use hypersig::html::{El, div, fieldset, input, label, legend, span};
9use hypersig::style::pattern::{hstack, stack};
10use hypersig::style::value::{
11 Appearance, BorderStyle, Color, Cursor, Display, Opacity, Position, TextAlign, px, rem,
12};
13use hypersig::style::{Condition, PseudoClass, Style};
14use hypersig::{Form, Sig, Validate};
15use serde::Serialize;
16
17use crate::field::{Feedback, label_style};
18use crate::look::{self, icon};
19use crate::token;
20
21/// The native input, covering the box drawn after it and invisible, so that
22/// a click anywhere on the box reaches it.
23fn hidden_input() -> Style {
24 Style::new()
25 .appearance(Appearance::None)
26 .position(Position::Absolute)
27 .inset(px(0))
28 .margin(px(0))
29 .opacity(Opacity::Number(0.0))
30 .cursor(Cursor::Pointer)
31 .peer()
32 .on(
33 PseudoClass::Disabled,
34 Style::new().cursor(Cursor::NotAllowed),
35 )
36}
37
38/// The box around the input and what draws it.
39fn holder(size: hypersig::style::value::Length) -> Style {
40 Style::new()
41 .position(Position::Relative)
42 .display(Display::InlineFlex)
43 .flex_shrink(0.0)
44 .width(size.clone())
45 .height(size)
46}
47
48/// Dims what a disabled input draws.
49fn peer_disabled() -> Style {
50 Style::new().on(
51 Condition::Peer(PseudoClass::Disabled),
52 Style::new().opacity(Opacity::Number(0.5)),
53 )
54}
55
56/// The `<label>` that holds a control and its text, so that the text names
57/// the control and a click on it toggles the control.
58fn row<S>(text: String) -> (El<S>, El<S>) {
59 let wrapper = label().css(
60 hstack(rem(0.5))
61 .merge(label_style())
62 .cursor(Cursor::Pointer),
63 );
64 (wrapper, span().text(text))
65}
66
67/// A checkbox labeled `text`, bound to `checked`.
68///
69/// It is a native `<input type="checkbox">` inside the `<label>` that holds
70/// the text, so it needs no id, and it works on a field of a list item.
71///
72/// ```
73/// use hypersig::Signals;
74/// use hypersig_ui::checkbox;
75/// use serde::{Deserialize, Serialize};
76///
77/// #[derive(Signals, Serialize, Deserialize)]
78/// struct Terms {
79/// accepted: bool,
80/// }
81///
82/// let accept = checkbox::<Terms, _>("I accept the terms", Terms::accepted());
83/// assert!(accept.render().contains(r#"type="checkbox""#));
84/// ```
85#[must_use]
86pub fn checkbox<S, R>(text: impl Into<String>, checked: Sig<S, bool, R>) -> El<S> {
87 let (wrapper, text) = row(text.into());
88 wrapper.child(check_control(input(), checked)).child(text)
89}
90
91/// A checkbox labeled `text`, bound to `field` of `form`.
92///
93/// It is [`checkbox`] with the form's [`error`](Sig::error) for `field`
94/// under it, tied to the input by `aria-describedby` and `aria-invalid`, and
95/// it marks the field touched when the visitor leaves the checkbox.
96///
97/// ```
98/// use hypersig::{Form, Rules, Sig, Signals, Validate};
99/// use hypersig_ui::checkbox_in_form;
100/// use serde::{Deserialize, Serialize};
101///
102/// #[derive(Signals, Serialize, Deserialize, Clone)]
103/// struct Signup {
104/// terms: bool,
105/// }
106///
107/// impl Validate for Signup {
108/// fn rules<S>(form: Sig<S, Self>, rules: &mut Rules<S, Self>) {
109/// rules.check(Signup::terms(), form.at(Signup::terms()), "Accept the terms.");
110/// }
111/// }
112///
113/// #[derive(Signals, Serialize, Deserialize)]
114/// struct Join {
115/// signup: Form<Signup>,
116/// }
117///
118/// let terms = checkbox_in_form("I accept the terms", Join::signup(), Signup::terms());
119/// assert!(terms.render().contains(r#"aria-describedby="ui-signup-value-terms-error""#));
120/// ```
121#[must_use]
122pub fn checkbox_in_form<S, T: Validate>(
123 text: impl Into<String>,
124 form: Sig<S, Form<T>>,
125 field: Sig<T, bool>,
126) -> El<S> {
127 let feedback = Feedback::of(form, field);
128 let control = feedback.describe(input()).on_blur(feedback.touch());
129 let (wrapper, text) = row(text.into());
130 with_message(
131 wrapper
132 .child(check_control(control, form.field(field)))
133 .child(text),
134 feedback,
135 )
136}
137
138/// `control` with the message of `feedback` under it.
139fn with_message<S>(control: El<S>, feedback: Feedback<S>) -> El<S> {
140 div()
141 .css(stack(rem(0.5)))
142 .child(control)
143 .child(feedback.message())
144}
145
146/// The box of a checkbox with `input` laid over it, bound to `checked`.
147///
148/// `input` comes from the caller, which names it where no `<label>` holds it.
149pub(crate) fn check_control<S, R>(input: El<S>, checked: Sig<S, bool, R>) -> El<S> {
150 check_mark(input.bind(checked))
151}
152
153/// `input` as a checkbox under a drawn box, for an input whose checked
154/// state its caller binds.
155pub(crate) fn check_mark<S>(input: El<S>) -> El<S> {
156 let mark = span()
157 .attr("aria-hidden", "true")
158 .css(check_box().on(Condition::Peer(PseudoClass::Checked), check_box_checked()))
159 .child(icon(icon::CHECK, rem(0.875)));
160 span()
161 .css(holder(rem(1)))
162 .child(input.attr("type", "checkbox").css(hidden_input()))
163 .child(mark)
164}
165
166/// The drawn box of an unchecked checkbox.
167pub(crate) fn check_box() -> Style {
168 Style::new()
169 .display(Display::InlineFlex)
170 .align_items(hypersig::style::value::AlignItems::Center)
171 .justify_content(hypersig::style::value::JustifyContent::Center)
172 .width(rem(1))
173 .height(rem(1))
174 .border_width(px(1))
175 .border_style(BorderStyle::Solid)
176 .border_color(token::input().var())
177 .border_radius(px(4))
178 .color(Color::TRANSPARENT)
179 .merge(look::transition())
180 .merge(look::peer_focus_ring())
181 .merge(peer_disabled())
182}
183
184/// The colors the box takes once checked.
185pub(crate) fn check_box_checked() -> Style {
186 Style::new()
187 .border_color(token::primary().var())
188 .background_color(token::primary().var())
189 .color(token::primary_foreground().var())
190}
191
192/// An on and off switch labeled `text`, bound to `on`.
193///
194/// It is a native checkbox with `role="switch"`, so a screen reader says
195/// "switch, on" rather than "checkbox, checked", and Space toggles it.
196///
197/// ```
198/// use hypersig::Signals;
199/// use hypersig_ui::switch;
200/// use serde::{Deserialize, Serialize};
201///
202/// #[derive(Signals, Serialize, Deserialize)]
203/// struct Settings {
204/// airplane: bool,
205/// }
206///
207/// let airplane = switch::<Settings, _>("Airplane mode", Settings::airplane());
208/// assert!(airplane.render().contains(r#"role="switch""#));
209/// ```
210#[must_use]
211pub fn switch<S, R>(text: impl Into<String>, on: Sig<S, bool, R>) -> El<S> {
212 switch_with(text.into(), on, input())
213}
214
215/// An on and off switch labeled `text`, bound to `field` of `form`.
216///
217/// It is [`switch`] with the form's [`error`](Sig::error) for `field` under
218/// it, tied to the input by `aria-describedby` and `aria-invalid`, and it
219/// marks the field touched when the visitor leaves the switch.
220///
221/// ```
222/// use hypersig::{Form, Rules, Sig, Signals, Validate};
223/// use hypersig_ui::switch_in_form;
224/// use serde::{Deserialize, Serialize};
225///
226/// #[derive(Signals, Serialize, Deserialize, Clone)]
227/// struct Settings {
228/// backups: bool,
229/// }
230///
231/// impl Validate for Settings {
232/// fn rules<S>(form: Sig<S, Self>, rules: &mut Rules<S, Self>) {
233/// rules.check(Settings::backups(), form.at(Settings::backups()), "Keep backups on.");
234/// }
235/// }
236///
237/// #[derive(Signals, Serialize, Deserialize)]
238/// struct Page {
239/// settings: Form<Settings>,
240/// }
241///
242/// let backups = switch_in_form("Backups", Page::settings(), Settings::backups());
243/// assert!(backups.render().contains(r#"role="switch""#));
244/// ```
245#[must_use]
246pub fn switch_in_form<S, T: Validate>(
247 text: impl Into<String>,
248 form: Sig<S, Form<T>>,
249 field: Sig<T, bool>,
250) -> El<S> {
251 let feedback = Feedback::of(form, field);
252 let control = feedback.describe(input()).on_blur(feedback.touch());
253 with_message(
254 switch_with(text.into(), form.field(field), control),
255 feedback,
256 )
257}
258
259/// A switch labeled `text` with `input` as its checkbox, bound to `on`.
260fn switch_with<S, R>(text: String, on: Sig<S, bool, R>, input: El<S>) -> El<S> {
261 let (wrapper, text) = row(text);
262 let track = span().attr("aria-hidden", "true").css(
263 Style::new()
264 .display(Display::Block)
265 .width(rem(2))
266 .height(rem(1.15))
267 .border_width(px(1))
268 .border_style(BorderStyle::Solid)
269 .border_color(Color::TRANSPARENT)
270 .border_radius(px(999))
271 .background_color(token::input().var())
272 .merge(look::transition())
273 .on(
274 Condition::Peer(PseudoClass::Checked),
275 Style::new().background_color(token::primary().var()),
276 )
277 .merge(look::peer_focus_ring())
278 .merge(peer_disabled()),
279 );
280 let thumb = span().attr("aria-hidden", "true").css(
281 Style::new()
282 .position(Position::Absolute)
283 .top(rem(0.15))
284 .left(rem(0.15))
285 .width(rem(0.85))
286 .height(rem(0.85))
287 .border_radius(px(999))
288 .background_color(token::background().var())
289 .pointer_events(hypersig::style::value::PointerEvents::None)
290 .merge(look::transition())
291 .on(
292 Condition::Peer(PseudoClass::Checked),
293 Style::new().translate(hypersig::style::value::raw("0.85rem 0")),
294 ),
295 );
296 wrapper
297 .child(
298 span()
299 .css(
300 holder(rem(2))
301 .height(rem(1.15))
302 .text_align(TextAlign::Start),
303 )
304 .child(
305 input
306 .attr("type", "checkbox")
307 .attr("role", "switch")
308 .css(hidden_input())
309 .bind(on),
310 )
311 .child(track)
312 .child(thumb),
313 )
314 .child(text)
315}
316
317/// A group of radio buttons with `title` in its `<legend>`, one per option,
318/// that sets `selected` to the option's value.
319///
320/// The radios share a `name` derived from the signal's path, so the browser
321/// gives the group one Tab stop and moves between its radios with the arrow
322/// keys. The `<fieldset>` and `<legend>` name the group for a screen reader.
323///
324/// ```
325/// use hypersig::Signals;
326/// use hypersig_ui::radio_group;
327/// use serde::{Deserialize, Serialize};
328///
329/// #[derive(Serialize, Deserialize, Clone, Copy, PartialEq)]
330/// enum Plan {
331/// Free,
332/// Pro,
333/// }
334///
335/// #[derive(Signals, Serialize, Deserialize)]
336/// struct Billing {
337/// plan: Plan,
338/// }
339///
340/// let plans = radio_group("Plan", Billing::plan(), [(Plan::Free, "Free"), (Plan::Pro, "Pro")]);
341/// assert!(plans.render().contains(r#"name="ui-plan-radio""#));
342/// ```
343#[must_use]
344pub fn radio_group<S, T: Serialize + PartialEq, L: Into<String>>(
345 title: impl Into<String>,
346 selected: Sig<S, T>,
347 options: impl IntoIterator<Item = (T, L)>,
348) -> El<S> {
349 radio_with(title.into(), selected, options, None)
350}
351
352/// A group of radio buttons with `title` in its `<legend>`, bound to `field`
353/// of `form`.
354///
355/// It is [`radio_group`] with the form's [`error`](Sig::error) for `field`
356/// under it. ARIA allows `aria-invalid` on a radio group but not on a plain
357/// group, so the `<fieldset>` takes `role="radiogroup"`, is named by its
358/// legend through `aria-labelledby`, and carries `aria-describedby` and
359/// `aria-invalid`. The field is marked touched when focus leaves the group,
360/// and not while the arrow keys move it between the radios.
361///
362/// ```
363/// use hypersig::{Form, Rules, Sig, Signals, Validate};
364/// use hypersig_ui::radio_group_in_form;
365/// use serde::{Deserialize, Serialize};
366///
367/// #[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Default)]
368/// enum Plan {
369/// #[default]
370/// Unset,
371/// Free,
372/// Pro,
373/// }
374///
375/// #[derive(Signals, Serialize, Deserialize, Clone)]
376/// struct Signup {
377/// plan: Plan,
378/// }
379///
380/// impl Validate for Signup {
381/// fn rules<S>(form: Sig<S, Self>, rules: &mut Rules<S, Self>) {
382/// let chosen = !form.at(Signup::plan()).equals(&Plan::Unset);
383/// rules.check(Signup::plan(), chosen, "Choose a plan.");
384/// }
385/// }
386///
387/// #[derive(Signals, Serialize, Deserialize)]
388/// struct Join {
389/// signup: Form<Signup>,
390/// }
391///
392/// let plans = radio_group_in_form(
393/// "Plan",
394/// Join::signup(),
395/// Signup::plan(),
396/// [(Plan::Free, "Free"), (Plan::Pro, "Pro")],
397/// );
398/// let html = plans.render();
399/// assert!(html.contains(r#"role="radiogroup""#));
400/// assert!(html.contains(r#"aria-describedby="ui-signup-value-plan-error""#));
401/// ```
402#[must_use]
403pub fn radio_group_in_form<S, T: Validate, U: Serialize + PartialEq, L: Into<String>>(
404 title: impl Into<String>,
405 form: Sig<S, Form<T>>,
406 field: Sig<T, U>,
407 options: impl IntoIterator<Item = (U, L)>,
408) -> El<S> {
409 radio_with(
410 title.into(),
411 form.field(field),
412 options,
413 Some(Feedback::of(form, field)),
414 )
415}
416
417fn radio_with<S, T: Serialize + PartialEq, L: Into<String>>(
418 title: String,
419 selected: Sig<S, T>,
420 options: impl IntoIterator<Item = (T, L)>,
421 feedback: Option<Feedback<S>>,
422) -> El<S> {
423 let name = crate::id::of(selected, "radio");
424 let radio = |value: T, text: L| {
425 let (wrapper, text) = row(text.into());
426 let dot = span().attr("aria-hidden", "true").css(
427 Style::new()
428 .display(Display::Block)
429 .width(rem(1))
430 .height(rem(1))
431 .border_width(px(1))
432 .border_style(BorderStyle::Solid)
433 .border_color(token::input().var())
434 .border_radius(px(999))
435 .merge(look::transition())
436 .on(
437 Condition::Peer(PseudoClass::Checked),
438 Style::new()
439 .border_color(token::primary().var())
440 .border_width(px(5)),
441 )
442 .merge(look::peer_focus_ring())
443 .merge(peer_disabled()),
444 );
445 wrapper
446 .child(
447 span()
448 .css(holder(rem(1)))
449 .child(
450 input()
451 .attr("name", name.clone())
452 .css(hidden_input())
453 .bind_radio(selected, &value),
454 )
455 .child(dot),
456 )
457 .child(text)
458 };
459 let mut caption = legend()
460 .css(label_style().padding(px(0)).margin_bottom(rem(0.75)))
461 .text(title);
462 let mut group = fieldset().css(
463 stack(rem(0.75))
464 .border_style(BorderStyle::None)
465 .padding(px(0))
466 .min_width(hypersig::style::value::MinWidth::from(px(0))),
467 );
468 if let Some(feedback) = &feedback {
469 let legend_id = crate::id::of(selected, "legend");
470 caption = caption.id(legend_id.clone());
471 group = feedback
472 .describe(group.attr("role", "radiogroup"))
473 .attr("aria-labelledby", legend_id)
474 .on_blur(feedback.touch());
475 }
476 group
477 .child(caption)
478 .children(options.into_iter().map(|(value, text)| radio(value, text)))
479 .children(feedback.map(Feedback::message))
480}