Skip to main content

hypersig_ui/
select.rs

1//! Selects: a labeled native `<select>` bound to a signal.
2
3use hypersig::html::{El, div, label, select};
4use hypersig::style::pattern::stack;
5use hypersig::style::value::{
6    AlignItems, Appearance, BorderStyle, Cursor, Display, Position, px, raw, rem,
7};
8use hypersig::style::{PseudoElement, Style};
9use hypersig::{Expr, Form, Sig, Validate};
10use serde::Serialize;
11
12use crate::field::{Feedback, field_style, label_style};
13use crate::look::{self, icon};
14use crate::token;
15
16/// A select labeled `title`, offering `options`, that sets `selected` to the
17/// chosen option's value.
18///
19/// It is a native `<select>`, which a screen reader, a keyboard, and a
20/// phone's picker wheel already handle. The closed control is drawn like a
21/// text field with a chevron. Where the browser supports
22/// `appearance: base-select` and `::picker(select)`, which
23/// browser-compat-data 8.1.6 lists for Chrome 135 and Safari 27, the open
24/// list is drawn as a popover card too. Elsewhere it is the browser's own
25/// picker. The label's `for` is derived from the signal's path.
26///
27/// ```
28/// use hypersig::Signals;
29/// use hypersig_ui::select_field;
30/// use serde::{Deserialize, Serialize};
31///
32/// #[derive(Serialize, Deserialize, Clone, Copy, PartialEq)]
33/// enum Region {
34///     Tokyo,
35///     Osaka,
36/// }
37///
38/// #[derive(Signals, Serialize, Deserialize)]
39/// struct Deploy {
40///     region: Region,
41/// }
42///
43/// let region = select_field(
44///     "Region",
45///     Deploy::region(),
46///     [(Region::Tokyo, "Tokyo"), (Region::Osaka, "Osaka")],
47/// );
48/// assert!(region.render().contains(r#"for="ui-region-select""#));
49/// ```
50#[must_use]
51pub fn select_field<S, T: Serialize + PartialEq, L: Into<String>>(
52    title: impl Into<String>,
53    selected: Sig<S, T>,
54    options: impl IntoIterator<Item = (T, L)>,
55) -> El<S> {
56    select_with(title.into(), selected, options, None)
57}
58
59/// A select labeled `title`, offering `options`, bound to `field` of `form`.
60///
61/// It is [`select_field`] with the form's [`error`](Sig::error) for `field`
62/// under it, tied to the `<select>` by `aria-describedby` and
63/// `aria-invalid`, and it marks the field touched when the visitor leaves
64/// the select.
65///
66/// ```
67/// use hypersig::{Form, Rules, Sig, Signals, Validate};
68/// use hypersig_ui::select_field_in_form;
69/// use serde::{Deserialize, Serialize};
70///
71/// #[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Default)]
72/// enum Plan {
73///     #[default]
74///     Unset,
75///     Pro,
76/// }
77///
78/// #[derive(Signals, Serialize, Deserialize, Clone)]
79/// struct Signup {
80///     plan: Plan,
81/// }
82///
83/// impl Validate for Signup {
84///     fn rules<S>(form: Sig<S, Self>, rules: &mut Rules<S, Self>) {
85///         let chosen = !form.at(Signup::plan()).equals(&Plan::Unset);
86///         rules.check(Signup::plan(), chosen, "Choose a plan.");
87///     }
88/// }
89///
90/// #[derive(Signals, Serialize, Deserialize)]
91/// struct Join {
92///     signup: Form<Signup>,
93/// }
94///
95/// let plan = select_field_in_form(
96///     "Plan",
97///     Join::signup(),
98///     Signup::plan(),
99///     [(Plan::Unset, "Choose…"), (Plan::Pro, "Pro")],
100/// );
101/// let html = plan.render();
102/// assert!(html.contains(r#"aria-describedby="ui-signup-value-plan-error""#));
103/// assert!(html.contains("signup.touched.plan"));
104/// ```
105#[must_use]
106pub fn select_field_in_form<S, T: Validate, U: Serialize + PartialEq, L: Into<String>>(
107    title: impl Into<String>,
108    form: Sig<S, Form<T>>,
109    field: Sig<T, U>,
110    options: impl IntoIterator<Item = (U, L)>,
111) -> El<S> {
112    select_with(
113        title.into(),
114        form.field(field),
115        options,
116        Some(Feedback::of(form, field)),
117    )
118}
119
120fn select_with<S, T: Serialize + PartialEq, L: Into<String>>(
121    title: String,
122    selected: Sig<S, T>,
123    options: impl IntoIterator<Item = (T, L)>,
124    feedback: Option<Feedback<S>>,
125) -> El<S> {
126    let id = crate::id::of(selected, "select");
127    let mut control = select()
128        .id(id.clone())
129        .css(
130            field_style()
131                .height(rem(2.25))
132                .padding_inline_end(rem(2))
133                .appearance(Appearance::None)
134                // A `base-select` select is an inline flex box whose items
135                // stretch, which puts the text at the top of the space the
136                // padding leaves, above the middle of the fixed height.
137                .align_items(AlignItems::Center)
138                .background_color(token::background().var())
139                .cursor(Cursor::Pointer)
140                .pseudo(
141                    PseudoElement::PickerIcon,
142                    Style::new().display(Display::None),
143                )
144                .pseudo(PseudoElement::PickerSelect, picker_style()),
145        )
146        // `base-select` sits in the toggled layer above `none`, so a browser
147        // that rejects the value keeps `appearance: none` from the atoms
148        // layer.
149        .css_when(
150            Style::new().appearance(Appearance::BaseSelect),
151            Expr::lit(&true),
152        )
153        .bind_select(selected, options);
154    if let Some(feedback) = &feedback {
155        control = feedback
156            .describe(control)
157            .on_blur(feedback.touch())
158            .css_when(
159                Style::new().border_color(token::destructive().var()),
160                feedback.invalid(),
161            );
162    }
163    div()
164        .css(stack(rem(0.5)))
165        .child(label().attr("for", id).css(label_style()).text(title))
166        .child(
167            div()
168                .css(Style::new().position(Position::Relative))
169                .child(control)
170                .child(
171                    icon(look::icon::CHEVRON_DOWN, rem(1)).css(
172                        Style::new()
173                            .position(Position::Absolute)
174                            .right(rem(0.625))
175                            .top(rem(0.625))
176                            .color(token::muted_foreground().var()),
177                    ),
178                ),
179        )
180        .children(feedback.map(Feedback::message))
181}
182
183/// The open list of a `base-select` select, drawn like a menu.
184fn picker_style() -> Style {
185    Style::new()
186        .appearance(Appearance::BaseSelect)
187        .margin_block(rem(0.25))
188        .padding(rem(0.25))
189        .border_width(px(1))
190        .border_style(BorderStyle::Solid)
191        .border_color(token::border().var())
192        .border_radius(px(6))
193        .background_color(token::popover().var())
194        .color(token::popover_foreground().var())
195        .box_shadow(raw(
196            "0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)",
197        ))
198}