use crate::{
core::{
assets,
attrs::{availability_attributes, component_attributes},
},
prelude::*,
theme::use_stylesheets,
};
const FIELD: Bem = Bem::new("fc-field");
const INPUT: Bem = Bem::new("fc-input");
const GROUP: Bem = Bem::new("fc-input-group");
const SUMMARY: Bem = Bem::new("fc-error-summary");
const CHOICE: Bem = Bem::new("fc-choice");
const CHECK: Bem = Bem::new("fc-check");
fc_modifier! { enum FieldFlow for FIELD { Stacked => None, Inline => "inline" } }
fc_modifier! {
pub enum InputSize for INPUT {
Md => None,
Sm => "sm",
Lg => "lg",
}
}
fc_modifier! {
pub enum InputVariant for INPUT {
Outlined => None,
Filled => "filled",
Soft => "soft",
Underlined => "underlined",
}
}
fc_modifier! { enum InputGrowth for INPUT { Fixed => None, Autosize => "autosize" } }
fn stylesheets() -> Element {
rsx! {
if use_stylesheets() {
document::Stylesheet { href: assets::COMPONENTS_FIELD_CSS }
document::Stylesheet { href: assets::COMPONENTS_FORM_VARIANTS_CSS }
}
}
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Necessity {
#[default]
Unmarked,
Required,
Optional,
}
#[derive(Debug, Clone, PartialEq)]
struct FieldContext {
control: Id,
hint: Option<Id>,
error: Option<Id>,
required: bool,
}
struct Wiring {
id: Option<String>,
describedby: Option<String>,
invalid: bool,
required: bool,
}
impl Wiring {
fn protected(&self, names: &[&'static str]) -> Vec<&'static str> {
let mut names = names.to_vec();
if self.id.is_some() {
names.push("id");
}
names
}
}
fn use_wiring(invalid: bool) -> Wiring {
let context = try_consume_context::<Signal<FieldContext>>().map(|c| c.read().clone());
let describedby = context.as_ref().and_then(|c| {
let ids: Vec<&str> = [c.hint.as_ref(), c.error.as_ref()]
.into_iter()
.flatten()
.map(Id::as_str)
.collect();
(!ids.is_empty()).then(|| ids.join(" "))
});
Wiring {
id: context.as_ref().map(|c| c.control.to_string()),
describedby,
invalid: invalid || context.as_ref().is_some_and(|c| c.error.is_some()),
required: context.is_some_and(|c| c.required),
}
}
#[component]
pub fn Field(
label: Element,
#[props(default)] hint: Option<Element>,
#[props(default)] error: Option<Element>,
#[props(default)] necessity: Necessity,
#[props(default)]
inline: bool,
#[props(default)]
control: Option<Id>,
#[props(default)] class: Option<String>,
#[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
children: Element,
) -> Element {
let generated = use_id();
let control = control.unwrap_or(generated);
let context = FieldContext {
hint: hint.is_some().then(|| control.suffixed("hint")),
error: error.is_some().then(|| control.suffixed("error")),
required: necessity == Necessity::Required,
control,
};
let mut shared = use_context_provider(|| Signal::new(context.clone()));
if *shared.peek() != context {
shared.set(context.clone());
}
let attributes = component_attributes(
attributes,
ClassList::new(FIELD.block())
.with_modifier(
FIELD,
if inline {
FieldFlow::Inline
} else {
FieldFlow::Stacked
},
)
.extend(class.as_deref()),
&[],
);
rsx! {
{stylesheets()}
div { ..attributes,
label { class: FIELD.element("label"), r#for: "{context.control}",
{label}
match necessity {
Necessity::Required => rsx! { span { class: FIELD.element("required"), aria_hidden: "true", "*" } },
Necessity::Optional => rsx! { span { class: FIELD.element("optional"), "(optional)" } },
Necessity::Unmarked => rsx! {},
}
}
if let (Some(hint), Some(id)) = (hint, context.hint.clone()) { p { class: FIELD.element("hint"), id: "{id}", {hint} } }
{children}
if let (Some(error), Some(id)) = (error, context.error.clone()) { p { class: FIELD.element("error"), id: "{id}", {error} } }
}
}
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum InputType {
#[default]
Text,
Email,
Password,
Search,
Url,
Tel,
Number,
Date,
Time,
}
impl InputType {
const fn attribute(self) -> Option<&'static str> {
match self {
Self::Text => None,
Self::Email => Some("email"),
Self::Password => Some("password"),
Self::Search => Some("search"),
Self::Url => Some("url"),
Self::Tel => Some("tel"),
Self::Number => Some("number"),
Self::Date => Some("date"),
Self::Time => Some("time"),
}
}
}
struct InGroup;
#[component]
pub fn Input(
#[props(default)] r#type: InputType,
#[props(default)] size: InputSize,
#[props(default)] variant: InputVariant,
#[props(default)] value: Option<ReadSignal<String>>,
#[props(default)] default_value: String,
#[props(default)] oninput: Option<EventHandler<String>>,
#[props(default)] invalid: bool,
#[props(default)] readonly: bool,
#[props(default)] availability: Availability,
#[props(default)] class: Option<String>,
#[props(extends = GlobalAttributes, extends = input)] attributes: Vec<Attribute>,
) -> Element {
let state = use_controllable(value, || default_value, oninput);
let wiring = use_wiring(invalid);
let grouped = try_consume_context::<std::rc::Rc<InGroup>>().is_some();
let classes = if grouped {
ClassList::new(&GROUP.element("control"))
} else {
ClassList::new(INPUT.block())
.with_modifier(INPUT, variant)
.with_modifier(INPUT, size)
.extend(super::group_control_class().as_deref())
}
.extend(class.as_deref());
let mut attributes = component_attributes(
attributes,
classes,
&wiring.protected(&["type", "aria-invalid", "aria-describedby", "value"]),
);
attributes.extend(availability_attributes(&availability));
rsx! {
{stylesheets()}
input { r#type: r#type.attribute(), id: wiring.id, aria_describedby: wiring.describedby,
aria_invalid: wiring.invalid.then_some("true"), required: wiring.required, readonly,
value: state.get(), oninput: move |event| state.set(event.value()), ..attributes }
}
}
#[component]
pub fn Textarea(
#[props(default)] size: InputSize,
#[props(default)] variant: InputVariant,
#[props(default)] autosize: bool,
#[props(default)] value: Option<ReadSignal<String>>,
#[props(default)] default_value: String,
#[props(default)] oninput: Option<EventHandler<String>>,
#[props(default)] invalid: bool,
#[props(default)] readonly: bool,
#[props(default)] availability: Availability,
#[props(default)] class: Option<String>,
#[props(extends = GlobalAttributes, extends = textarea)] attributes: Vec<Attribute>,
) -> Element {
let state = use_controllable(value, || default_value, oninput);
let wiring = use_wiring(invalid);
let classes = ClassList::new(INPUT.block())
.with_modifier(INPUT, variant)
.with_modifier(INPUT, size)
.with_modifier(
INPUT,
if autosize {
InputGrowth::Autosize
} else {
InputGrowth::Fixed
},
)
.extend(class.as_deref());
let mut attributes = component_attributes(
attributes,
classes,
&wiring.protected(&["aria-invalid", "aria-describedby", "value"]),
);
attributes.extend(availability_attributes(&availability));
rsx! {
{stylesheets()}
textarea { id: wiring.id, aria_describedby: wiring.describedby,
aria_invalid: wiring.invalid.then_some("true"), required: wiring.required, readonly,
value: state.get(), oninput: move |event| state.set(event.value()), ..attributes }
}
}
#[component]
pub fn NativeSelect(
#[props(default)] size: InputSize,
#[props(default)] variant: InputVariant,
#[props(default)] onchange: Option<EventHandler<String>>,
#[props(default)] invalid: bool,
#[props(default)] availability: Availability,
#[props(default)] class: Option<String>,
#[props(extends = GlobalAttributes, extends = select)] attributes: Vec<Attribute>,
children: Element,
) -> Element {
let wiring = use_wiring(invalid);
let classes = ClassList::new(INPUT.block())
.with_modifier(INPUT, variant)
.with_modifier(INPUT, size)
.extend(super::group_item_class().as_deref())
.extend(class.as_deref());
let mut attributes = component_attributes(
attributes,
classes,
&wiring.protected(&["aria-invalid", "aria-describedby"]),
);
attributes.extend(availability_attributes(&availability));
rsx! {
{stylesheets()}
select { id: wiring.id, aria_describedby: wiring.describedby,
aria_invalid: wiring.invalid.then_some("true"), required: wiring.required,
onchange: move |event| if let Some(onchange) = onchange { onchange.call(event.value()); },
..attributes, {children}
}
}
}
#[component]
pub fn InputGroup(
#[props(default)] class: Option<String>,
#[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
children: Element,
) -> Element {
use_context_provider(|| std::rc::Rc::new(InGroup));
let attributes = component_attributes(
attributes,
ClassList::new(GROUP.block()).extend(class.as_deref()),
&[],
);
rsx! {
{stylesheets()}
div { ..attributes, {children} }
}
}
#[component]
pub fn InputAddon(
#[props(default)]
decorative: bool,
children: Element,
) -> Element {
rsx! { span { class: GROUP.element("addon"), aria_hidden: decorative.then_some("true"), {children} } }
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FieldError {
pub field: Id,
pub message: String,
}
#[component]
pub fn ErrorSummary(
title: Element,
#[props(default = HeadingLevel::H2)] heading: HeadingLevel,
errors: Vec<FieldError>,
#[props(default)] class: Option<String>,
#[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
) -> Element {
let attributes = component_attributes(
attributes,
ClassList::new(SUMMARY.block()).extend(class.as_deref()),
&["tabindex"],
);
let focus = !errors.is_empty();
rsx! {
{stylesheets()}
div { tabindex: "-1",
onmounted: move |event| async move { if focus { let _ = event.set_focus(true).await; } },
..attributes,
{super::heading(heading, SUMMARY.element("title"), title)}
ul { class: SUMMARY.element("list"),
for error in errors {
li { key: "{error.field}", a { class: SUMMARY.element("link"), href: "#{error.field}", "{error.message}" } }
}
}
}
}
}
#[component]
pub fn Choice(
title: Element,
#[props(default)] description: Option<Element>,
value: String,
#[props(default)] availability: Availability,
#[props(default)] class: Option<String>,
#[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
) -> Element {
let choice = try_consume_context::<Signal<Option<RadioChoice>>>()
.and_then(|context| context.read().clone());
let name = choice.as_ref().map(|c| c.name.to_string());
let checked = choice
.as_ref()
.is_some_and(|c| c.value.as_deref() == Some(value.as_str()));
let onchange = choice.and_then(|c| availability.guard(c.onchange));
let blocked = availability != Availability::Enabled;
let attributes = component_attributes(
attributes,
ClassList::new(CHOICE.block()).extend(class.as_deref()),
&[],
);
let state_attrs = availability_attributes(&availability);
rsx! {
{stylesheets()}
if use_stylesheets() { document::Stylesheet { href: assets::COMPONENTS_CHECK_CSS } }
label { ..attributes,
input { class: CHECK.element("input"), r#type: "radio", name, value: value.clone(), checked,
onclick: move |event| if blocked { event.prevent_default(); },
onchange: move |_| if let Some(onchange) = onchange { onchange.call(value.clone()); },
..state_attrs,
}
span { class: CHOICE.element("content"),
strong { {title} }
if let Some(description) = description { span { class: CHOICE.element("description"), {description} } }
}
}
}
}