Skip to main content

fc_dioxus/components/
field.rs

1//! Labelled form fields, inputs, input groups, choice cards, and the error summary, from
2//! field.css and form-variants.css.
3use crate::{
4    core::{
5        assets,
6        attrs::{availability_attributes, component_attributes},
7    },
8    prelude::*,
9    theme::use_stylesheets,
10};
11const FIELD: Bem = Bem::new("fc-field");
12const INPUT: Bem = Bem::new("fc-input");
13const GROUP: Bem = Bem::new("fc-input-group");
14const SUMMARY: Bem = Bem::new("fc-error-summary");
15const CHOICE: Bem = Bem::new("fc-choice");
16const CHECK: Bem = Bem::new("fc-check");
17fc_modifier! { enum FieldFlow for FIELD { Stacked => None, Inline => "inline" } }
18fc_modifier! {
19    /// Input sizes.
20    pub enum InputSize for INPUT {
21        /// Default.
22        Md => None,
23        /// Small.
24        Sm => "sm",
25        /// Large.
26        Lg => "lg",
27    }
28}
29fc_modifier! {
30    /// Input surface treatments.
31    pub enum InputVariant for INPUT {
32        /// A bordered box.
33        Outlined => None,
34        /// A tinted fill.
35        Filled => "filled",
36        /// A soft, borderless tint.
37        Soft => "soft",
38        /// A single underline.
39        Underlined => "underlined",
40    }
41}
42fc_modifier! { enum InputGrowth for INPUT { Fixed => None, Autosize => "autosize" } }
43
44fn stylesheets() -> Element {
45    rsx! {
46        if use_stylesheets() {
47            document::Stylesheet { href: assets::COMPONENTS_FIELD_CSS }
48            document::Stylesheet { href: assets::COMPONENTS_FORM_VARIANTS_CSS }
49        }
50    }
51}
52
53/// Whether a field is marked as required or optional.
54#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
55pub enum Necessity {
56    /// No marker.
57    #[default]
58    Unmarked,
59    /// Marked with an asterisk, and the control gets `required`.
60    Required,
61    /// Marked "(optional)".
62    Optional,
63}
64
65/// What a [`Field`] tells the control inside it, so labels and descriptions are wired
66/// by construction rather than by hand.
67#[derive(Debug, Clone, PartialEq)]
68struct FieldContext {
69    control: Id,
70    hint: Option<Id>,
71    error: Option<Id>,
72    required: bool,
73}
74
75/// Control wiring from the enclosing field, or none outside a field.
76struct Wiring {
77    id: Option<String>,
78    describedby: Option<String>,
79    invalid: bool,
80    required: bool,
81}
82impl Wiring {
83    /// Inside a field the field owns the control's id; elsewhere the author's id stands,
84    /// so a [`ControlLabel`](super::button_group::ControlLabel) can target it.
85    fn protected(&self, names: &[&'static str]) -> Vec<&'static str> {
86        let mut names = names.to_vec();
87        if self.id.is_some() {
88            names.push("id");
89        }
90        names
91    }
92}
93fn use_wiring(invalid: bool) -> Wiring {
94    let context = try_consume_context::<Signal<FieldContext>>().map(|c| c.read().clone());
95    let describedby = context.as_ref().and_then(|c| {
96        let ids: Vec<&str> = [c.hint.as_ref(), c.error.as_ref()]
97            .into_iter()
98            .flatten()
99            .map(Id::as_str)
100            .collect();
101        (!ids.is_empty()).then(|| ids.join(" "))
102    });
103    Wiring {
104        id: context.as_ref().map(|c| c.control.to_string()),
105        describedby,
106        invalid: invalid || context.as_ref().is_some_and(|c| c.error.is_some()),
107        required: context.is_some_and(|c| c.required),
108    }
109}
110
111/// A visible label, an optional hint and error, and one control, wired together.
112///
113/// The control inside ([`Input`], [`Textarea`], [`NativeSelect`]) takes its `id`,
114/// `aria-describedby`, and `aria-invalid` from the field, so the label is always
115/// associated and the error is always announced with it.
116#[component]
117pub fn Field(
118    label: Element,
119    #[props(default)] hint: Option<Element>,
120    #[props(default)] error: Option<Element>,
121    #[props(default)] necessity: Necessity,
122    /// Label beside the control instead of above it.
123    #[props(default)]
124    inline: bool,
125    /// The control's id, when something else links to it, such as an [`ErrorSummary`].
126    /// Generated when omitted.
127    #[props(default)]
128    control: Option<Id>,
129    #[props(default)] class: Option<String>,
130    #[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
131    children: Element,
132) -> Element {
133    let generated = use_id();
134    let control = control.unwrap_or(generated);
135    let context = FieldContext {
136        hint: hint.is_some().then(|| control.suffixed("hint")),
137        error: error.is_some().then(|| control.suffixed("error")),
138        required: necessity == Necessity::Required,
139        control,
140    };
141    let mut shared = use_context_provider(|| Signal::new(context.clone()));
142    if *shared.peek() != context {
143        shared.set(context.clone());
144    }
145    let attributes = component_attributes(
146        attributes,
147        ClassList::new(FIELD.block())
148            .with_modifier(
149                FIELD,
150                if inline {
151                    FieldFlow::Inline
152                } else {
153                    FieldFlow::Stacked
154                },
155            )
156            .extend(class.as_deref()),
157        &[],
158    );
159    rsx! {
160        {stylesheets()}
161        div { ..attributes,
162            label { class: FIELD.element("label"), r#for: "{context.control}",
163                {label}
164                match necessity {
165                    Necessity::Required => rsx! { span { class: FIELD.element("required"), aria_hidden: "true", "*" } },
166                    Necessity::Optional => rsx! { span { class: FIELD.element("optional"), "(optional)" } },
167                    Necessity::Unmarked => rsx! {},
168                }
169            }
170            if let (Some(hint), Some(id)) = (hint, context.hint.clone()) { p { class: FIELD.element("hint"), id: "{id}", {hint} } }
171            {children}
172            if let (Some(error), Some(id)) = (error, context.error.clone()) { p { class: FIELD.element("error"), id: "{id}", {error} } }
173        }
174    }
175}
176
177/// The HTML input types this library styles. Text is the default and is not written,
178/// as in upstream's markup.
179#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
180#[non_exhaustive]
181pub enum InputType {
182    /// Free text.
183    #[default]
184    Text,
185    /// An email address; mobile keyboards adapt.
186    Email,
187    /// A password, masked.
188    Password,
189    /// A search query.
190    Search,
191    /// A URL.
192    Url,
193    /// A telephone number.
194    Tel,
195    /// A number.
196    Number,
197    /// A calendar date; the native picker is the fastest way to type a known date.
198    Date,
199    /// A time of day.
200    Time,
201}
202impl InputType {
203    const fn attribute(self) -> Option<&'static str> {
204        match self {
205            Self::Text => None,
206            Self::Email => Some("email"),
207            Self::Password => Some("password"),
208            Self::Search => Some("search"),
209            Self::Url => Some("url"),
210            Self::Tel => Some("tel"),
211            Self::Number => Some("number"),
212            Self::Date => Some("date"),
213            Self::Time => Some("time"),
214        }
215    }
216}
217
218struct InGroup;
219
220/// A single-line text input. Inside a [`Field`] it is labelled automatically; anywhere
221/// else, give it an `aria_label`.
222#[component]
223pub fn Input(
224    #[props(default)] r#type: InputType,
225    #[props(default)] size: InputSize,
226    #[props(default)] variant: InputVariant,
227    #[props(default)] value: Option<ReadSignal<String>>,
228    #[props(default)] default_value: String,
229    #[props(default)] oninput: Option<EventHandler<String>>,
230    #[props(default)] invalid: bool,
231    #[props(default)] readonly: bool,
232    #[props(default)] availability: Availability,
233    #[props(default)] class: Option<String>,
234    #[props(extends = GlobalAttributes, extends = input)] attributes: Vec<Attribute>,
235) -> Element {
236    let state = use_controllable(value, || default_value, oninput);
237    let wiring = use_wiring(invalid);
238    let grouped = try_consume_context::<std::rc::Rc<InGroup>>().is_some();
239    // In a group the group draws the box, so the control drops the input styling.
240    let classes = if grouped {
241        ClassList::new(&GROUP.element("control"))
242    } else {
243        ClassList::new(INPUT.block())
244            .with_modifier(INPUT, variant)
245            .with_modifier(INPUT, size)
246            .extend(super::group_control_class().as_deref())
247    }
248    .extend(class.as_deref());
249    let mut attributes = component_attributes(
250        attributes,
251        classes,
252        &wiring.protected(&["type", "aria-invalid", "aria-describedby", "value"]),
253    );
254    attributes.extend(availability_attributes(&availability));
255    rsx! {
256        {stylesheets()}
257        input { r#type: r#type.attribute(), id: wiring.id, aria_describedby: wiring.describedby,
258            aria_invalid: wiring.invalid.then_some("true"), required: wiring.required, readonly,
259            value: state.get(), oninput: move |event| state.set(event.value()), ..attributes }
260    }
261}
262
263/// A multi-line text input; `autosize` grows it with its content where supported.
264#[component]
265pub fn Textarea(
266    #[props(default)] size: InputSize,
267    #[props(default)] variant: InputVariant,
268    #[props(default)] autosize: bool,
269    #[props(default)] value: Option<ReadSignal<String>>,
270    #[props(default)] default_value: String,
271    #[props(default)] oninput: Option<EventHandler<String>>,
272    #[props(default)] invalid: bool,
273    #[props(default)] readonly: bool,
274    #[props(default)] availability: Availability,
275    #[props(default)] class: Option<String>,
276    #[props(extends = GlobalAttributes, extends = textarea)] attributes: Vec<Attribute>,
277) -> Element {
278    let state = use_controllable(value, || default_value, oninput);
279    let wiring = use_wiring(invalid);
280    let classes = ClassList::new(INPUT.block())
281        .with_modifier(INPUT, variant)
282        .with_modifier(INPUT, size)
283        .with_modifier(
284            INPUT,
285            if autosize {
286                InputGrowth::Autosize
287            } else {
288                InputGrowth::Fixed
289            },
290        )
291        .extend(class.as_deref());
292    let mut attributes = component_attributes(
293        attributes,
294        classes,
295        &wiring.protected(&["aria-invalid", "aria-describedby", "value"]),
296    );
297    attributes.extend(availability_attributes(&availability));
298    rsx! {
299        {stylesheets()}
300        textarea { id: wiring.id, aria_describedby: wiring.describedby,
301            aria_invalid: wiring.invalid.then_some("true"), required: wiring.required, readonly,
302            value: state.get(), oninput: move |event| state.set(event.value()), ..attributes }
303    }
304}
305
306/// A native `<select>` styled as an input, for short fixed lists. Children are `option`s.
307#[component]
308pub fn NativeSelect(
309    #[props(default)] size: InputSize,
310    #[props(default)] variant: InputVariant,
311    #[props(default)] onchange: Option<EventHandler<String>>,
312    #[props(default)] invalid: bool,
313    #[props(default)] availability: Availability,
314    #[props(default)] class: Option<String>,
315    #[props(extends = GlobalAttributes, extends = select)] attributes: Vec<Attribute>,
316    children: Element,
317) -> Element {
318    let wiring = use_wiring(invalid);
319    let classes = ClassList::new(INPUT.block())
320        .with_modifier(INPUT, variant)
321        .with_modifier(INPUT, size)
322        .extend(super::group_item_class().as_deref())
323        .extend(class.as_deref());
324    let mut attributes = component_attributes(
325        attributes,
326        classes,
327        &wiring.protected(&["aria-invalid", "aria-describedby"]),
328    );
329    attributes.extend(availability_attributes(&availability));
330    rsx! {
331        {stylesheets()}
332        select { id: wiring.id, aria_describedby: wiring.describedby,
333            aria_invalid: wiring.invalid.then_some("true"), required: wiring.required,
334            onchange: move |event| if let Some(onchange) = onchange { onchange.call(event.value()); },
335            ..attributes, {children}
336        }
337    }
338}
339
340/// One box holding an [`Input`] with [`InputAddon`]s before or after it.
341#[component]
342pub fn InputGroup(
343    #[props(default)] class: Option<String>,
344    #[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
345    children: Element,
346) -> Element {
347    use_context_provider(|| std::rc::Rc::new(InGroup));
348    let attributes = component_attributes(
349        attributes,
350        ClassList::new(GROUP.block()).extend(class.as_deref()),
351        &[],
352    );
353    rsx! {
354        {stylesheets()}
355        div { ..attributes, {children} }
356    }
357}
358
359/// Fixed text beside an input, such as a unit or a URL scheme.
360#[component]
361pub fn InputAddon(
362    /// Hide a symbol whose meaning the label already carries, such as "$" for "Budget".
363    #[props(default)]
364    decorative: bool,
365    children: Element,
366) -> Element {
367    rsx! { span { class: GROUP.element("addon"), aria_hidden: decorative.then_some("true"), {children} } }
368}
369
370/// One problem listed in an [`ErrorSummary`].
371#[derive(Debug, Clone, PartialEq, Eq)]
372pub struct FieldError {
373    /// The id of the control to jump to.
374    pub field: Id,
375    /// What is wrong, as an instruction.
376    pub message: String,
377}
378
379/// The list of problems after a submit, linking to each field. It takes focus whenever it
380/// appears with errors, so keyboard and screen reader users land on it.
381#[component]
382pub fn ErrorSummary(
383    title: Element,
384    #[props(default = HeadingLevel::H2)] heading: HeadingLevel,
385    errors: Vec<FieldError>,
386    #[props(default)] class: Option<String>,
387    #[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
388) -> Element {
389    let attributes = component_attributes(
390        attributes,
391        ClassList::new(SUMMARY.block()).extend(class.as_deref()),
392        &["tabindex"],
393    );
394    let focus = !errors.is_empty();
395    rsx! {
396        {stylesheets()}
397        div { tabindex: "-1",
398            onmounted: move |event| async move { if focus { let _ = event.set_focus(true).await; } },
399            ..attributes,
400            {super::heading(heading, SUMMARY.element("title"), title)}
401            ul { class: SUMMARY.element("list"),
402                for error in errors {
403                    li { key: "{error.field}", a { class: SUMMARY.element("link"), href: "#{error.field}", "{error.message}" } }
404                }
405            }
406        }
407    }
408}
409
410/// A radio drawn as a card with a title and description, inside a [`CheckGroup`] given
411/// `radios`.
412#[component]
413pub fn Choice(
414    title: Element,
415    #[props(default)] description: Option<Element>,
416    value: String,
417    #[props(default)] availability: Availability,
418    #[props(default)] class: Option<String>,
419    #[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
420) -> Element {
421    let choice = try_consume_context::<Signal<Option<RadioChoice>>>()
422        .and_then(|context| context.read().clone());
423    let name = choice.as_ref().map(|c| c.name.to_string());
424    let checked = choice
425        .as_ref()
426        .is_some_and(|c| c.value.as_deref() == Some(value.as_str()));
427    let onchange = choice.and_then(|c| availability.guard(c.onchange));
428    let blocked = availability != Availability::Enabled;
429    let attributes = component_attributes(
430        attributes,
431        ClassList::new(CHOICE.block()).extend(class.as_deref()),
432        &[],
433    );
434    let state_attrs = availability_attributes(&availability);
435    rsx! {
436        {stylesheets()}
437        if use_stylesheets() { document::Stylesheet { href: assets::COMPONENTS_CHECK_CSS } }
438        label { ..attributes,
439            input { class: CHECK.element("input"), r#type: "radio", name, value: value.clone(), checked,
440                onclick: move |event| if blocked { event.prevent_default(); },
441                onchange: move |_| if let Some(onchange) = onchange { onchange.call(value.clone()); },
442                ..state_attrs,
443            }
444            span { class: CHOICE.element("content"),
445                strong { {title} }
446                if let Some(description) = description { span { class: CHOICE.element("description"), {description} } }
447            }
448        }
449    }
450}