1use 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 pub enum InputSize for INPUT {
21 Md => None,
23 Sm => "sm",
25 Lg => "lg",
27 }
28}
29fc_modifier! {
30 pub enum InputVariant for INPUT {
32 Outlined => None,
34 Filled => "filled",
36 Soft => "soft",
38 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#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
55pub enum Necessity {
56 #[default]
58 Unmarked,
59 Required,
61 Optional,
63}
64
65#[derive(Debug, Clone, PartialEq)]
68struct FieldContext {
69 control: Id,
70 hint: Option<Id>,
71 error: Option<Id>,
72 required: bool,
73}
74
75struct Wiring {
77 id: Option<String>,
78 describedby: Option<String>,
79 invalid: bool,
80 required: bool,
81}
82impl Wiring {
83 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#[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 #[props(default)]
124 inline: bool,
125 #[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#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
180#[non_exhaustive]
181pub enum InputType {
182 #[default]
184 Text,
185 Email,
187 Password,
189 Search,
191 Url,
193 Tel,
195 Number,
197 Date,
199 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#[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 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#[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#[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#[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#[component]
361pub fn InputAddon(
362 #[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#[derive(Debug, Clone, PartialEq, Eq)]
372pub struct FieldError {
373 pub field: Id,
375 pub message: String,
377}
378
379#[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#[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}