hypersig_ui/field.rs
1//! Text fields with a label, a description, and an error message.
2
3use hypersig::html::{El, div, input, label, p, span, textarea};
4use hypersig::style::pattern::stack;
5use hypersig::style::value::{
6 BorderStyle, Color, FontWeight, LineHeight, MinWidth, Resize, pct, px, rem,
7};
8use hypersig::style::{PseudoClass, PseudoElement, Style};
9use hypersig::{Expr, Form, IntoExpr, Sig, Stmt, Validate, if_else};
10
11use crate::look;
12use crate::token;
13
14/// The `type` of a single-line [`TextField`], which picks the on-screen
15/// keyboard and what the browser checks.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
17#[non_exhaustive]
18pub enum InputKind {
19 /// `text`.
20 Text,
21 /// `email`.
22 Email,
23 /// `password`.
24 Password,
25 /// `search`.
26 Search,
27 /// `tel`.
28 Tel,
29 /// `url`.
30 Url,
31}
32
33impl InputKind {
34 fn name(self) -> &'static str {
35 match self {
36 Self::Text => "text",
37 Self::Email => "email",
38 Self::Password => "password",
39 Self::Search => "search",
40 Self::Tel => "tel",
41 Self::Url => "url",
42 }
43 }
44}
45
46/// The on-screen keyboard a [`TextField`] asks for, as `inputmode` names it.
47///
48/// It changes only the keyboard. What the browser accepts and checks stays
49/// with the field's [`InputKind`].
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
51#[non_exhaustive]
52pub enum InputMode {
53 /// `none`: no on-screen keyboard, for a field with its own picker.
54 None,
55 /// `text`: the keyboard of the visitor's language.
56 Text,
57 /// `decimal`: digits and the decimal separator.
58 Decimal,
59 /// `numeric`: digits only, for a code or a card number.
60 Numeric,
61 /// `tel`: digits, `*`, and `#`.
62 Tel,
63 /// `search`: a keyboard whose enter key reads "Search".
64 Search,
65 /// `email`: a keyboard with `@`.
66 Email,
67 /// `url`: a keyboard with `/` and `.`.
68 Url,
69}
70
71impl InputMode {
72 fn name(self) -> &'static str {
73 match self {
74 Self::None => "none",
75 Self::Text => "text",
76 Self::Decimal => "decimal",
77 Self::Numeric => "numeric",
78 Self::Tel => "tel",
79 Self::Search => "search",
80 Self::Email => "email",
81 Self::Url => "url",
82 }
83 }
84}
85
86/// What a [`TextField`] holds, as `autocomplete` names it for the browser
87/// and for WCAG 1.3.5.
88///
89/// The variants are the WHATWG autofill field names a sign-up form and a
90/// checkout ask for. A form that asks for a shipping and a billing address
91/// on one page has no way here to mark which is which.
92///
93/// ```
94/// use hypersig::Signals;
95/// use hypersig_ui::{Autocomplete, TextField};
96/// use serde::{Deserialize, Serialize};
97///
98/// #[derive(Signals, Serialize, Deserialize)]
99/// struct Address {
100/// city: String,
101/// }
102///
103/// let city = TextField::new("City", Address::city()).autocomplete(Autocomplete::AddressLevel2);
104/// assert!(city.render().render().contains(r#"autocomplete="address-level2""#));
105/// ```
106#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
107#[non_exhaustive]
108pub enum Autocomplete {
109 /// `off`: the browser offers nothing.
110 Off,
111 /// `name`.
112 Name,
113 /// `email`.
114 Email,
115 /// `username`.
116 Username,
117 /// `current-password`.
118 CurrentPassword,
119 /// `new-password`.
120 NewPassword,
121 /// `one-time-code`.
122 OneTimeCode,
123 /// `given-name`: the first name in Western order.
124 GivenName,
125 /// `additional-name`: a middle name.
126 AdditionalName,
127 /// `family-name`: the last name in Western order.
128 FamilyName,
129 /// `honorific-prefix`, such as "Dr.".
130 HonorificPrefix,
131 /// `nickname`.
132 Nickname,
133 /// `organization`.
134 Organization,
135 /// `organization-title`: a job title.
136 OrganizationTitle,
137 /// `street-address`, for a field that takes the whole street address.
138 StreetAddress,
139 /// `address-line1`.
140 AddressLine1,
141 /// `address-line2`.
142 AddressLine2,
143 /// `address-line3`.
144 AddressLine3,
145 /// `address-level1`: a state, a province, or a prefecture.
146 AddressLevel1,
147 /// `address-level2`: a city or a town.
148 AddressLevel2,
149 /// `address-level3`: a district within a city.
150 AddressLevel3,
151 /// `postal-code`.
152 PostalCode,
153 /// `country-name`.
154 Country,
155 /// `country`: an ISO 3166-1 alpha-2 country code.
156 CountryCode,
157 /// `cc-name`: the name on a payment card.
158 CcName,
159 /// `cc-number`.
160 CcNumber,
161 /// `cc-exp`: the expiry date of a payment card.
162 CcExp,
163 /// `cc-exp-month`.
164 CcExpMonth,
165 /// `cc-exp-year`.
166 CcExpYear,
167 /// `cc-csc`: the security code of a payment card.
168 CcCsc,
169 /// `bday`: a birthday.
170 Bday,
171 /// `bday-day`.
172 BdayDay,
173 /// `bday-month`.
174 BdayMonth,
175 /// `bday-year`.
176 BdayYear,
177 /// `sex`: a gender identity, as free text.
178 Sex,
179 /// `language`: a preferred language, as a BCP 47 tag.
180 Language,
181 /// `tel`.
182 Tel,
183 /// `tel-national`: a phone number without the country code.
184 TelNational,
185 /// `url`.
186 Url,
187}
188
189impl Autocomplete {
190 fn name(self) -> &'static str {
191 match self {
192 Self::Off => "off",
193 Self::Name => "name",
194 Self::Email => "email",
195 Self::Username => "username",
196 Self::CurrentPassword => "current-password",
197 Self::NewPassword => "new-password",
198 Self::OneTimeCode => "one-time-code",
199 Self::GivenName => "given-name",
200 Self::AdditionalName => "additional-name",
201 Self::FamilyName => "family-name",
202 Self::HonorificPrefix => "honorific-prefix",
203 Self::Nickname => "nickname",
204 Self::Organization => "organization",
205 Self::OrganizationTitle => "organization-title",
206 Self::StreetAddress => "street-address",
207 Self::AddressLine1 => "address-line1",
208 Self::AddressLine2 => "address-line2",
209 Self::AddressLine3 => "address-line3",
210 Self::AddressLevel1 => "address-level1",
211 Self::AddressLevel2 => "address-level2",
212 Self::AddressLevel3 => "address-level3",
213 Self::PostalCode => "postal-code",
214 Self::Country => "country-name",
215 Self::CountryCode => "country",
216 Self::CcName => "cc-name",
217 Self::CcNumber => "cc-number",
218 Self::CcExp => "cc-exp",
219 Self::CcExpMonth => "cc-exp-month",
220 Self::CcExpYear => "cc-exp-year",
221 Self::CcCsc => "cc-csc",
222 Self::Bday => "bday",
223 Self::BdayDay => "bday-day",
224 Self::BdayMonth => "bday-month",
225 Self::BdayYear => "bday-year",
226 Self::Sex => "sex",
227 Self::Language => "language",
228 Self::Tel => "tel",
229 Self::TelNational => "tel-national",
230 Self::Url => "url",
231 }
232 }
233}
234
235/// A text field bound to a `String` signal, with its label and, where given,
236/// a description and an error message tied to it.
237///
238/// The ids that tie them together are derived from the signal's path, so a
239/// label always names its own field. `aria-describedby` points at the
240/// description and the error, and `aria-invalid` is `true` while the error is
241/// not empty. The error sits in an `aria-live="polite"` region, so a screen
242/// reader reads a new message when it appears.
243///
244/// Two fields bound to the same signal on one page get the same ids, and
245/// the second field's label and messages then point at the first field, so
246/// bind each field to its own signal.
247///
248/// ```
249/// use hypersig::{Signals, if_else};
250/// use hypersig_ui::{Autocomplete, InputKind, TextField};
251/// use serde::{Deserialize, Serialize};
252///
253/// #[derive(Signals, Serialize, Deserialize)]
254/// struct Signup {
255/// email: String,
256/// }
257///
258/// let email = Signup::email();
259/// let field = TextField::new("Email", email)
260/// .kind(InputKind::Email)
261/// .autocomplete(Autocomplete::Email)
262/// .description("We send the receipt here.")
263/// .error(if_else(email.contains("@").or(email.is_empty()), "", "Enter an email address."))
264/// .required();
265/// let html = field.render().render();
266/// assert!(html.contains(r#"for="ui-email-input""#));
267/// assert!(html.contains(r#"aria-describedby="ui-email-description ui-email-error""#));
268/// ```
269#[derive(Debug)]
270pub struct TextField<S> {
271 value: Sig<S, String>,
272 kind: InputKind,
273 multiline: bool,
274 autocomplete: Option<Autocomplete>,
275 input_mode: Option<InputMode>,
276 min_length: Option<u32>,
277 max_length: Option<u32>,
278 placeholder: Option<String>,
279 frame: Frame<S>,
280}
281
282impl<S> TextField<S> {
283 /// A single-line text field labeled `label`, bound to `value`.
284 #[must_use]
285 pub fn new(label: impl Into<String>, value: Sig<S, String>) -> Self {
286 Self {
287 value,
288 kind: InputKind::Text,
289 multiline: false,
290 autocomplete: None,
291 input_mode: None,
292 min_length: None,
293 max_length: None,
294 placeholder: None,
295 frame: Frame::new(label.into()),
296 }
297 }
298
299 /// A single-line text field labeled `label`, bound to `field` of `form`.
300 ///
301 /// It shows [`error`](Sig::error) of the form for `field` and marks the
302 /// field touched when the visitor leaves it, so a rule's message waits
303 /// until the visitor has finished typing.
304 ///
305 /// ```
306 /// use hypersig::{Form, Rules, Sig, Signals, Validate};
307 /// use hypersig_ui::TextField;
308 /// use serde::{Deserialize, Serialize};
309 ///
310 /// #[derive(Signals, Serialize, Deserialize, Clone)]
311 /// struct Signup {
312 /// email: String,
313 /// }
314 ///
315 /// impl Validate for Signup {
316 /// fn rules<S>(form: Sig<S, Self>, rules: &mut Rules<S, Self>) {
317 /// let email = form.at(Signup::email());
318 /// rules.check(Signup::email(), email.contains("@"), "Enter an email address.");
319 /// }
320 /// }
321 ///
322 /// #[derive(Signals, Serialize, Deserialize)]
323 /// struct Join {
324 /// signup: Form<Signup>,
325 /// }
326 ///
327 /// let html = TextField::in_form("Email", Join::signup(), Signup::email()).render().render();
328 /// assert!(html.contains("signup.touched.email"));
329 /// ```
330 #[must_use]
331 pub fn in_form<T: Validate>(
332 label: impl Into<String>,
333 form: Sig<S, Form<T>>,
334 field: Sig<T, String>,
335 ) -> Self {
336 let mut out = Self::new(label, form.field(field));
337 out.frame = out.frame.in_form(form, field);
338 out
339 }
340
341 /// Sets the `type` of the input. A multiline field ignores it.
342 #[must_use]
343 pub fn kind(mut self, kind: InputKind) -> Self {
344 self.kind = kind;
345 self
346 }
347
348 /// Makes the field a `<textarea>` that the visitor can resize vertically.
349 #[must_use]
350 pub fn multiline(mut self) -> Self {
351 self.multiline = true;
352 self
353 }
354
355 /// Tells the browser what the field holds, so that it can fill it in.
356 #[must_use]
357 pub fn autocomplete(mut self, autocomplete: Autocomplete) -> Self {
358 self.autocomplete = Some(autocomplete);
359 self
360 }
361
362 /// Asks for the on-screen keyboard `mode`, as `inputmode` does.
363 ///
364 /// ```
365 /// use hypersig::Signals;
366 /// use hypersig_ui::{Autocomplete, InputMode, TextField};
367 /// use serde::{Deserialize, Serialize};
368 ///
369 /// #[derive(Signals, Serialize, Deserialize)]
370 /// struct Login {
371 /// code: String,
372 /// }
373 ///
374 /// let code = TextField::new("Code", Login::code())
375 /// .input_mode(InputMode::Numeric)
376 /// .autocomplete(Autocomplete::OneTimeCode)
377 /// .max_length(6);
378 /// let html = code.render().render();
379 /// assert!(html.contains(r#"inputmode="numeric""#));
380 /// assert!(html.contains(r#"maxlength="6""#));
381 /// ```
382 #[must_use]
383 pub fn input_mode(mut self, mode: InputMode) -> Self {
384 self.input_mode = Some(mode);
385 self
386 }
387
388 /// Stops the visitor from typing more than `count` characters, as
389 /// `maxlength` does.
390 #[must_use]
391 pub fn max_length(mut self, count: u32) -> Self {
392 self.max_length = Some(count);
393 self
394 }
395
396 /// Makes the browser refuse to submit fewer than `count` characters, as
397 /// `minlength` does.
398 ///
399 /// An empty field passes unless it is [`required`](Self::required).
400 #[must_use]
401 pub fn min_length(mut self, count: u32) -> Self {
402 self.min_length = Some(count);
403 self
404 }
405
406 /// Shows `text` in the empty field.
407 ///
408 /// A placeholder disappears as the visitor types, so the label and the
409 /// description carry what the visitor needs to keep.
410 #[must_use]
411 pub fn placeholder(mut self, text: impl Into<String>) -> Self {
412 self.placeholder = Some(text.into());
413 self
414 }
415
416 /// Adds a line under the field that `aria-describedby` points at.
417 #[must_use]
418 pub fn description(mut self, text: impl Into<String>) -> Self {
419 self.frame.description = Some(text.into());
420 self
421 }
422
423 /// Shows `message` under the field and marks the field invalid while
424 /// `message` is not empty.
425 ///
426 /// A signal that an action call fills with
427 /// [`Call::error`](hypersig::Call::error) works here, as does an
428 /// expression that checks the value in the browser.
429 #[must_use]
430 pub fn error(mut self, message: impl IntoExpr<S, String>) -> Self {
431 self.frame.error = Some(message.into_expr());
432 self
433 }
434
435 /// Marks the field `required`, which the label shows with an asterisk
436 /// that a screen reader skips, since it reads "required" from the field.
437 #[must_use]
438 pub fn required(mut self) -> Self {
439 self.frame.required = true;
440 self
441 }
442
443 /// Disables the field while `when` is true.
444 #[must_use]
445 pub fn disabled(mut self, when: impl IntoExpr<S, bool>) -> Self {
446 self.frame.disabled = Some(when.into_expr());
447 self
448 }
449
450 /// The label, the field, the description, and the error, in a `<div>`.
451 #[must_use]
452 pub fn render(self) -> El<S> {
453 let mut control = if self.multiline {
454 textarea().css(field_style().min_height(rem(4)).resize(Resize::Vertical))
455 } else {
456 input()
457 .attr("type", self.kind.name())
458 .css(field_style().height(rem(2.25)))
459 }
460 .bind(self.value);
461 if let Some(autocomplete) = self.autocomplete {
462 control = control.attr("autocomplete", autocomplete.name());
463 }
464 if let Some(mode) = self.input_mode {
465 control = control.attr("inputmode", mode.name());
466 }
467 if let Some(count) = self.min_length {
468 control = control.attr("minlength", count.to_string());
469 }
470 if let Some(count) = self.max_length {
471 control = control.attr("maxlength", count.to_string());
472 }
473 if let Some(placeholder) = self.placeholder {
474 control = control.attr("placeholder", placeholder);
475 }
476 self.frame.render(&crate::id::of(self.value, ""), control)
477 }
478}
479
480/// The label, the description, and the error around a text or number
481/// field, and the attributes of the control that tie them to it.
482#[derive(Debug)]
483pub(crate) struct Frame<S> {
484 label: String,
485 pub(crate) description: Option<String>,
486 pub(crate) error: Option<Expr<S, String>>,
487 pub(crate) required: bool,
488 pub(crate) disabled: Option<Expr<S, bool>>,
489 on_blur: Option<Stmt<S>>,
490}
491
492impl<S> Frame<S> {
493 pub(crate) fn new(label: String) -> Self {
494 Self {
495 label,
496 description: None,
497 error: None,
498 required: false,
499 disabled: None,
500 on_blur: None,
501 }
502 }
503
504 /// The frame with the form's message for `field`, and the statement that
505 /// marks `field` touched when the visitor leaves the control.
506 pub(crate) fn in_form<T: Validate, U>(self, form: Sig<S, Form<T>>, field: Sig<T, U>) -> Self {
507 Self {
508 error: Some(form.error(field)),
509 on_blur: Some(form.touch(field)),
510 ..self
511 }
512 }
513
514 /// `control` with its id and its ties to the label, the description, and
515 /// the error, in a `<div>` with them. The ids start with `base`.
516 pub(crate) fn render(self, base: &str, control: El<S>) -> El<S> {
517 let field_id = format!("{base}input");
518 let description_id = format!("{base}description");
519 let error_id = format!("{base}error");
520
521 let mut described = Vec::new();
522 if self.description.is_some() {
523 described.push(description_id.as_str());
524 }
525 if self.error.is_some() {
526 described.push(error_id.as_str());
527 }
528
529 let mut control = control.id(field_id.clone());
530 if !described.is_empty() {
531 control = control.attr("aria-describedby", described.join(" "));
532 }
533 if self.required {
534 control = control.attr("required", "");
535 }
536 if let Some(disabled) = self.disabled {
537 control = control.disabled(disabled);
538 }
539 if let Some(touch) = self.on_blur {
540 control = control.on_blur(touch);
541 }
542 if let Some(error) = &self.error {
543 let invalid = !error.clone().is_empty();
544 control = control
545 .bind_attr("aria-invalid", if_else(invalid.clone(), "true", "false"))
546 .css_when(
547 Style::new().border_color(token::destructive().var()),
548 invalid,
549 );
550 }
551
552 let mut caption = label()
553 .attr("for", field_id)
554 .css(label_style())
555 .text(self.label);
556 if self.required {
557 caption = caption.child(
558 span()
559 .attr("aria-hidden", "true")
560 .css(Style::new().color(token::destructive().var()))
561 .text(" *"),
562 );
563 }
564
565 div()
566 .css(stack(rem(0.5)))
567 .child(caption)
568 .child(control)
569 .children(self.description.map(|text| {
570 p().id(description_id)
571 .css(look::small_text().color(token::muted_foreground().var()))
572 .text(text)
573 }))
574 .children(self.error.map(|error| {
575 p().id(error_id)
576 .attr("aria-live", "polite")
577 .css(look::small_text().color(token::destructive().var()))
578 .bind_text(error)
579 }))
580 }
581}
582
583/// What a control bound to a field of a [`Form`] adds: the form's message
584/// for the field, under the control and tied to it, and the statement that
585/// marks the field touched.
586pub(crate) struct Feedback<S> {
587 error: Expr<S, String>,
588 touch: Stmt<S>,
589 error_id: String,
590}
591
592impl<S> Feedback<S> {
593 /// The message and the touch for `field` of `form`.
594 pub(crate) fn of<T: Validate, U>(form: Sig<S, Form<T>>, field: Sig<T, U>) -> Self {
595 Self {
596 error: form.error(field),
597 touch: form.touch(field),
598 error_id: crate::id::of(form.field(field), "error"),
599 }
600 }
601
602 /// The statement that marks the field touched.
603 pub(crate) fn touch(&self) -> Stmt<S> {
604 self.touch.clone()
605 }
606
607 /// Whether the message is not empty.
608 pub(crate) fn invalid(&self) -> Expr<S, bool> {
609 !self.error.clone().is_empty()
610 }
611
612 /// `control` with `aria-describedby` pointing at the message, and
613 /// `aria-invalid` true while the message is not empty.
614 pub(crate) fn describe(&self, control: El<S>) -> El<S> {
615 control
616 .attr("aria-describedby", self.error_id.clone())
617 .bind_attr("aria-invalid", if_else(self.invalid(), "true", "false"))
618 }
619
620 /// The message, in an `aria-live="polite"` region as [`TextField`] shows it.
621 pub(crate) fn message(self) -> El<S> {
622 p().id(self.error_id)
623 .attr("aria-live", "polite")
624 .css(look::small_text().color(token::destructive().var()))
625 .bind_text(self.error)
626 }
627}
628
629impl<S> From<TextField<S>> for El<S> {
630 fn from(field: TextField<S>) -> Self {
631 field.render()
632 }
633}
634
635impl<S> From<TextField<S>> for hypersig::html::Node<S> {
636 fn from(field: TextField<S>) -> Self {
637 field.render().into()
638 }
639}
640
641/// The style of a label above a control.
642pub(crate) fn label_style() -> Style {
643 look::small_text().font_weight(FontWeight::Number(500.0))
644}
645
646/// The box of a text field or a select.
647pub(crate) fn field_style() -> Style {
648 Style::new()
649 .width(pct(100))
650 .min_width(MinWidth::from(px(0)))
651 .padding_block(rem(0.25))
652 .padding_inline(rem(0.75))
653 .border_width(px(1))
654 .border_style(BorderStyle::Solid)
655 .border_color(token::input().var())
656 .border_radius(px(6))
657 .background_color(Color::TRANSPARENT)
658 .color(token::foreground().var())
659 .font_size(rem(0.875))
660 .line_height(LineHeight::Length(rem(1.25)))
661 .box_shadow(hypersig::style::value::raw("0 1px 2px 0 rgb(0 0 0 / 0.05)"))
662 .pseudo(
663 PseudoElement::Placeholder,
664 Style::new().color(token::muted_foreground().var()),
665 )
666 .merge(look::transition())
667 .on(PseudoClass::FocusVisible, look::ring(px(0)))
668 .merge(look::disabled())
669}