Skip to main content

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}