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}