Skip to main content

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}