Skip to main content

sequoia_elements/
form.rs

1use std::rc::Rc;
2
3use sequoia_core::Length;
4use sequoia_reactive::Signal;
5use sequoia_style::{FORM_ERROR, FormStyle};
6use sequoia_view::{AnyView, Column, Row, Text, Unit, View};
7
8use crate::button::Button;
9use crate::drag_value::DragValue;
10use crate::floating_menu::FloatingMenu;
11use crate::text_field::TextField;
12
13pub trait ValidField {
14    fn is_valid(&self) -> bool;
15    fn touch(&self);
16
17    fn error_text(&self) -> Option<String> {
18        None
19    }
20
21    fn track(&self) {}
22}
23
24type RuleFn = Rc<dyn Fn(&str) -> bool>;
25
26#[derive(Clone)]
27struct Rule {
28    message: String,
29    check: RuleFn,
30}
31
32#[derive(Clone)]
33pub struct Field {
34    value: Signal<String>,
35    label: String,
36    placeholder: String,
37    rules: Vec<Rule>,
38    touched: Signal<bool>,
39    editing: Signal<bool>,
40    style: Option<FormStyle>,
41}
42
43impl Field {
44    pub fn new(value: Signal<String>, label: impl Into<String>) -> Self {
45        Self {
46            value,
47            label: label.into(),
48            placeholder: String::new(),
49            rules: Vec::new(),
50            touched: Signal::new(false),
51            editing: Signal::new(false),
52            style: None,
53        }
54    }
55
56    pub fn style(mut self, style: FormStyle) -> Self {
57        self.style = Some(style);
58        self
59    }
60
61    pub fn placeholder(mut self, placeholder: impl Into<String>) -> Self {
62        self.placeholder = placeholder.into();
63        self
64    }
65
66    pub fn rule(
67        mut self,
68        message: impl Into<String>,
69        check: impl Fn(&str) -> bool + 'static,
70    ) -> Self {
71        self.rules.push(Rule {
72            message: message.into(),
73            check: Rc::new(check),
74        });
75        self
76    }
77
78    pub fn error(&self) -> Option<String> {
79        let v = self.value.get_untracked();
80        self.rules
81            .iter()
82            .find(|r| !(r.check)(&v))
83            .map(|r| r.message.clone())
84    }
85
86    pub fn is_valid(&self) -> bool {
87        let v = self.value.get_untracked();
88        self.rules.iter().all(|r| (r.check)(&v))
89    }
90
91    pub fn touch(&self) {
92        self.touched.set(true);
93    }
94
95    pub fn value_signal(&self) -> Signal<String> {
96        self.value.clone()
97    }
98
99    pub fn editing_signal(&self) -> Signal<bool> {
100        self.editing.clone()
101    }
102
103    pub fn label_text(&self) -> &str {
104        &self.label
105    }
106}
107
108impl ValidField for Field {
109    fn is_valid(&self) -> bool {
110        Field::is_valid(self)
111    }
112
113    fn touch(&self) {
114        Field::touch(self)
115    }
116
117    fn error_text(&self) -> Option<String> {
118        if self.touched.get_untracked() {
119            self.error()
120        } else {
121            None
122        }
123    }
124
125    fn track(&self) {
126        let _ = self.value.get();
127        let _ = self.touched.get();
128    }
129}
130
131impl View for Field {
132    fn body(&self) -> impl View + 'static {
133        Unit
134    }
135
136    fn children_override(&self) -> Option<Vec<AnyView>> {
137        let f = self.clone();
138
139        let _ = f.value.get();
140        let touched = f.touched.get();
141        let error = if touched { f.error() } else { None };
142        let label = Text::new(f.label.clone())
143            .size(label_size_of(f.style))
144            .bold();
145        let editing = f.editing.clone();
146        let touched_sig = f.touched.clone();
147        let editing_end = f.editing.clone();
148        let touched_submit = f.touched.clone();
149        let editing_submit = f.editing.clone();
150        let input = TextField::new(f.value.clone())
151            .placeholder(f.placeholder.clone())
152            .on_edit_start(move || editing.set(true))
153            .on_edit_end(move |_| {
154                editing_end.set(false);
155                touched_sig.set(true);
156            })
157            .on_submit(move |_| {
158                editing_submit.set(false);
159                touched_submit.set(true);
160            });
161        let mut col = Column::new().gap(4.0).child(label).child(input);
162        if let Some(msg) = error {
163            col = col.child(
164                Text::new(msg)
165                    .size(error_size_of(f.style))
166                    .color(error_color_of(f.style)),
167            );
168        }
169        Some(vec![AnyView::new(col)])
170    }
171
172    fn layout_style(&self) -> sequoia_layout::LayoutStyle {
173        sequoia_layout::LayoutStyle {
174            width: Length::Fill(1.0),
175            height: Length::Fit,
176            ..sequoia_layout::LayoutStyle::default()
177        }
178    }
179}
180
181const ERROR_RED: sequoia_core::Color = FORM_ERROR;
182
183fn label_size_of(style: Option<FormStyle>) -> f32 {
184    style.and_then(|s| s.label_size).unwrap_or(12.0)
185}
186
187fn error_size_of(style: Option<FormStyle>) -> f32 {
188    style.and_then(|s| s.error_size).unwrap_or(11.0)
189}
190
191fn error_color_of(style: Option<FormStyle>) -> sequoia_core::Color {
192    style.and_then(|s| s.error).unwrap_or(ERROR_RED)
193}
194
195#[derive(Clone, Default)]
196pub struct Form {
197    fields: Rc<std::cell::RefCell<Vec<Rc<dyn ValidField>>>>,
198}
199
200impl Form {
201    pub fn new() -> Self {
202        Self::default()
203    }
204
205    pub fn register<F: ValidField + Clone + 'static>(&self, field: &F) {
206        struct Wrap<F>(F);
207        impl<F: ValidField> ValidField for Wrap<F> {
208            fn is_valid(&self) -> bool {
209                self.0.is_valid()
210            }
211            fn touch(&self) {
212                self.0.touch();
213            }
214            fn error_text(&self) -> Option<String> {
215                self.0.error_text()
216            }
217            fn track(&self) {
218                self.0.track();
219            }
220        }
221        self.fields.borrow_mut().push(Rc::new(Wrap(field.clone())));
222    }
223
224    pub fn is_valid(&self) -> bool {
225        self.fields.borrow().iter().all(|f| f.is_valid())
226    }
227
228    pub fn touch_all(&self) {
229        for f in self.fields.borrow().iter() {
230            f.touch();
231        }
232    }
233
234    pub fn len(&self) -> usize {
235        self.fields.borrow().len()
236    }
237
238    pub fn is_empty(&self) -> bool {
239        self.fields.borrow().is_empty()
240    }
241
242    pub fn summary(&self) -> AnyView {
243        let fields = self.fields.clone();
244        AnyView::new(Column::new().child(Text::with(move || {
245            let mut first: Option<String> = None;
246            for f in fields.borrow().iter() {
247                f.track();
248                if first.is_none() {
249                    first = f.error_text();
250                }
251            }
252            first.unwrap_or_default()
253        })))
254    }
255}
256
257#[derive(Clone)]
258pub struct NumberField {
259    value: Signal<f32>,
260    label: String,
261    min: f32,
262    max: f32,
263    step: f32,
264    precision: usize,
265    rules: Vec<NumRule>,
266    touched: Signal<bool>,
267    editing: Signal<bool>,
268    style: Option<FormStyle>,
269}
270
271type NumRuleFn = Rc<dyn Fn(f32) -> bool>;
272
273#[derive(Clone)]
274struct NumRule {
275    message: String,
276    check: NumRuleFn,
277}
278
279impl NumberField {
280    pub fn new(value: Signal<f32>, label: impl Into<String>) -> Self {
281        Self {
282            value,
283            label: label.into(),
284            min: f32::NEG_INFINITY,
285            max: f32::INFINITY,
286            step: 1.0,
287            precision: 0,
288            rules: Vec::new(),
289            touched: Signal::new(false),
290            editing: Signal::new(false),
291            style: None,
292        }
293    }
294
295    pub fn style(mut self, style: FormStyle) -> Self {
296        self.style = Some(style);
297        self
298    }
299
300    pub fn min(mut self, min: f32) -> Self {
301        self.min = min;
302        self
303    }
304
305    pub fn max(mut self, max: f32) -> Self {
306        self.max = max;
307        self
308    }
309
310    pub fn step(mut self, step: f32) -> Self {
311        if step.is_finite() && step > 0.0 {
312            self.step = step;
313        }
314        self
315    }
316
317    pub fn precision(mut self, precision: usize) -> Self {
318        self.precision = precision;
319        self
320    }
321
322    pub fn rule(
323        mut self,
324        message: impl Into<String>,
325        check: impl Fn(f32) -> bool + 'static,
326    ) -> Self {
327        self.rules.push(NumRule {
328            message: message.into(),
329            check: Rc::new(check),
330        });
331        self
332    }
333
334    pub fn error(&self) -> Option<String> {
335        let v = self.value.get_untracked();
336        self.rules
337            .iter()
338            .find(|r| !(r.check)(v))
339            .map(|r| r.message.clone())
340    }
341
342    pub fn is_valid(&self) -> bool {
343        let v = self.value.get_untracked();
344        self.rules.iter().all(|r| (r.check)(v))
345    }
346
347    pub fn touch(&self) {
348        self.touched.set(true);
349    }
350
351    pub fn value_signal(&self) -> Signal<f32> {
352        self.value.clone()
353    }
354
355    pub fn editing_signal(&self) -> Signal<bool> {
356        self.editing.clone()
357    }
358
359    fn clamp(&self, v: f32) -> f32 {
360        v.clamp(self.min, self.max)
361    }
362
363    fn nudge(&self, dir: f32) {
364        let v = self.value.get_untracked();
365        self.value.set(self.clamp(v + dir * self.step));
366        self.touch();
367    }
368}
369
370impl ValidField for NumberField {
371    fn is_valid(&self) -> bool {
372        NumberField::is_valid(self)
373    }
374
375    fn touch(&self) {
376        NumberField::touch(self)
377    }
378
379    fn error_text(&self) -> Option<String> {
380        if self.touched.get_untracked() {
381            self.error()
382        } else {
383            None
384        }
385    }
386
387    fn track(&self) {
388        let _ = self.value.get();
389        let _ = self.touched.get();
390    }
391}
392
393impl View for NumberField {
394    fn body(&self) -> impl View + 'static {
395        Unit
396    }
397
398    fn children_override(&self) -> Option<Vec<AnyView>> {
399        let f = self.clone();
400        let _ = f.value.get();
401        let touched = f.touched.get();
402        let error = if touched { f.error() } else { None };
403        let editing = f.editing.clone();
404        let editing_end = f.editing.clone();
405        let touched_end = f.touched.clone();
406        let drag = DragValue::new(f.value.clone())
407            .min(f.min)
408            .max(f.max)
409            .precision(f.precision)
410            .on_change_start(move || editing.set(true))
411            .on_commit(move |_| {
412                editing_end.set(false);
413                touched_end.set(true);
414            });
415        let minus = f.clone();
416        let plus = f.clone();
417        let mut col = Column::new()
418            .gap(4.0)
419            .child(
420                Text::new(f.label.clone())
421                    .size(label_size_of(f.style))
422                    .bold(),
423            )
424            .child(
425                Row::new()
426                    .gap(4.0)
427                    .child(Button::new("−").on_click(move || minus.nudge(-1.0)))
428                    .child_flex(drag, 1.0)
429                    .child(Button::new("+").on_click(move || plus.nudge(1.0))),
430            );
431        if let Some(msg) = error {
432            col = col.child(
433                Text::new(msg)
434                    .size(error_size_of(f.style))
435                    .color(error_color_of(f.style)),
436            );
437        }
438        Some(vec![AnyView::new(col)])
439    }
440
441    fn layout_style(&self) -> sequoia_layout::LayoutStyle {
442        sequoia_layout::LayoutStyle {
443            width: Length::Fill(1.0),
444            height: Length::Fit,
445            ..sequoia_layout::LayoutStyle::default()
446        }
447    }
448}
449
450#[derive(Clone)]
451pub struct DateField {
452    value: Signal<String>,
453    label: String,
454    touched: Signal<bool>,
455    editing: Signal<bool>,
456    style: Option<FormStyle>,
457}
458
459impl DateField {
460    pub fn new(value: Signal<String>, label: impl Into<String>) -> Self {
461        Self {
462            value,
463            label: label.into(),
464            touched: Signal::new(false),
465            editing: Signal::new(false),
466            style: None,
467        }
468    }
469
470    pub fn style(mut self, style: FormStyle) -> Self {
471        self.style = Some(style);
472        self
473    }
474
475    pub fn error(&self) -> Option<String> {
476        match parse_iso_date(&self.value.get_untracked()) {
477            Some(_) => None,
478            None => Some("data w formacie RRRR-MM-DD".to_string()),
479        }
480    }
481
482    pub fn is_valid(&self) -> bool {
483        self.error().is_none()
484    }
485
486    pub fn touch(&self) {
487        self.touched.set(true);
488    }
489
490    pub fn value_signal(&self) -> Signal<String> {
491        self.value.clone()
492    }
493
494    pub fn editing_signal(&self) -> Signal<bool> {
495        self.editing.clone()
496    }
497
498    fn shift_days(&self, delta: i64) {
499        let cur = self.value.get_untracked();
500        let shifted = parse_iso_date(&cur)
501            .map(|d| days_to_iso(date_to_days(d) + delta))
502            .unwrap_or_else(today_iso_fallback);
503        self.value.set(shifted);
504        self.touch();
505    }
506}
507
508impl ValidField for DateField {
509    fn is_valid(&self) -> bool {
510        DateField::is_valid(self)
511    }
512
513    fn touch(&self) {
514        DateField::touch(self)
515    }
516
517    fn error_text(&self) -> Option<String> {
518        if self.touched.get_untracked() {
519            self.error()
520        } else {
521            None
522        }
523    }
524
525    fn track(&self) {
526        let _ = self.value.get();
527        let _ = self.touched.get();
528    }
529}
530
531impl View for DateField {
532    fn body(&self) -> impl View + 'static {
533        Unit
534    }
535
536    fn children_override(&self) -> Option<Vec<AnyView>> {
537        let f = self.clone();
538        let _ = f.value.get();
539        let touched = f.touched.get();
540        let error = if touched { f.error() } else { None };
541        let editing = f.editing.clone();
542        let editing_end = f.editing.clone();
543        let touched_end = f.touched.clone();
544        let editing_submit = f.editing.clone();
545        let touched_submit = f.touched.clone();
546        let input = TextField::new(f.value.clone())
547            .placeholder("RRRR-MM-DD")
548            .max_length(10)
549            .on_edit_start(move || editing.set(true))
550            .on_edit_end(move |_| {
551                editing_end.set(false);
552                touched_end.set(true);
553            })
554            .on_submit(move |_| {
555                editing_submit.set(false);
556                touched_submit.set(true);
557            });
558        let prev = f.clone();
559        let next = f.clone();
560        let mut col = Column::new()
561            .gap(4.0)
562            .child(
563                Text::new(f.label.clone())
564                    .size(label_size_of(f.style))
565                    .bold(),
566            )
567            .child(
568                Row::new()
569                    .gap(4.0)
570                    .child(Button::new("‹").on_click(move || prev.shift_days(-1)))
571                    .child_flex(input, 1.0)
572                    .child(Button::new("›").on_click(move || next.shift_days(1))),
573            );
574        if let Some(msg) = error {
575            col = col.child(
576                Text::new(msg)
577                    .size(error_size_of(f.style))
578                    .color(error_color_of(f.style)),
579            );
580        }
581        Some(vec![AnyView::new(col)])
582    }
583
584    fn layout_style(&self) -> sequoia_layout::LayoutStyle {
585        sequoia_layout::LayoutStyle {
586            width: Length::Fill(1.0),
587            height: Length::Fit,
588            ..sequoia_layout::LayoutStyle::default()
589        }
590    }
591}
592
593type IsoDate = (i32, u32, u32);
594
595pub fn parse_iso_date(s: &str) -> Option<IsoDate> {
596    let b = s.as_bytes();
597    if b.len() != 10 || b[4] != b'-' || b[7] != b'-' {
598        return None;
599    }
600    let y: i32 = s[0..4].parse().ok()?;
601    let m: u32 = s[5..7].parse().ok()?;
602    let d: u32 = s[8..10].parse().ok()?;
603    if !(1..=12).contains(&m) {
604        return None;
605    }
606    if d < 1 || d > days_in_month(y, m) {
607        return None;
608    }
609    Some((y, m, d))
610}
611
612fn is_leap(y: i32) -> bool {
613    (y % 4 == 0 && y % 100 != 0) || y % 400 == 0
614}
615
616fn days_in_month(y: i32, m: u32) -> u32 {
617    match m {
618        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
619        4 | 6 | 9 | 11 => 30,
620        2 if is_leap(y) => 29,
621        2 => 28,
622        _ => 0,
623    }
624}
625
626fn date_to_days((y, m, d): IsoDate) -> i64 {
627    let y = if m <= 2 { y as i64 - 1 } else { y as i64 };
628    let era = y.div_euclid(400);
629    let yoe = y.rem_euclid(400);
630    let mp = (m as i64 + 9).rem_euclid(12);
631    let doy = (153 * mp + 2) / 5 + d as i64 - 1;
632    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
633    era * 146097 + doe - 719468
634}
635
636fn days_to_iso(z: i64) -> String {
637    let z = z + 719468;
638    let era = z.div_euclid(146097);
639    let doe = z.rem_euclid(146097);
640    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
641    let y = yoe + era * 400;
642    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
643    let mp = (5 * doy + 2) / 153;
644    let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
645    let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
646    format!("{:04}-{:02}-{:02}", if m <= 2 { y + 1 } else { y }, m, d)
647}
648
649fn today_iso_fallback() -> String {
650    "1970-01-01".to_string()
651}
652
653#[derive(Clone)]
654pub struct MultiSelect {
655    options: Vec<String>,
656    selected: Signal<Vec<usize>>,
657    label: String,
658    min_selected: usize,
659    touched: Signal<bool>,
660    style: Option<FormStyle>,
661}
662
663impl MultiSelect {
664    pub fn new(options: Vec<String>, label: impl Into<String>) -> Self {
665        Self {
666            options,
667            selected: Signal::new(Vec::new()),
668            label: label.into(),
669            min_selected: 0,
670            touched: Signal::new(false),
671            style: None,
672        }
673    }
674
675    pub fn style(mut self, style: FormStyle) -> Self {
676        self.style = Some(style);
677        self
678    }
679
680    pub fn min_selected(mut self, n: usize) -> Self {
681        self.min_selected = n;
682        self
683    }
684
685    pub fn toggle(&self, index: usize) {
686        if index >= self.options.len() {
687            return;
688        }
689        self.selected.update(|sel| {
690            if let Some(pos) = sel.iter().position(|i| *i == index) {
691                sel.remove(pos);
692            } else {
693                sel.push(index);
694                sel.sort_unstable();
695            }
696        });
697        self.touch();
698    }
699
700    pub fn selected_indices(&self) -> Vec<usize> {
701        self.selected.get_untracked()
702    }
703
704    pub fn selected_values(&self) -> Vec<String> {
705        let sel = self.selected.get_untracked();
706        sel.into_iter()
707            .filter_map(|i| self.options.get(i).cloned())
708            .collect()
709    }
710
711    pub fn summary_text(&self) -> String {
712        let values = self.selected_values();
713        if values.is_empty() {
714            "—".to_string()
715        } else if values.len() <= 2 {
716            values.join(", ")
717        } else {
718            format!("{} wybrane", values.len())
719        }
720    }
721
722    pub fn error(&self) -> Option<String> {
723        if self.selected.get_untracked().len() < self.min_selected {
724            Some(format!("wybierz co najmniej {}", self.min_selected))
725        } else {
726            None
727        }
728    }
729
730    pub fn is_valid(&self) -> bool {
731        self.error().is_none()
732    }
733
734    pub fn touch(&self) {
735        self.touched.set(true);
736    }
737
738    pub fn selected_signal(&self) -> Signal<Vec<usize>> {
739        self.selected.clone()
740    }
741}
742
743impl ValidField for MultiSelect {
744    fn is_valid(&self) -> bool {
745        MultiSelect::is_valid(self)
746    }
747
748    fn touch(&self) {
749        MultiSelect::touch(self)
750    }
751
752    fn error_text(&self) -> Option<String> {
753        if self.touched.get_untracked() {
754            self.error()
755        } else {
756            None
757        }
758    }
759
760    fn track(&self) {
761        let _ = self.selected.get();
762        let _ = self.touched.get();
763    }
764}
765
766impl View for MultiSelect {
767    fn body(&self) -> impl View + 'static {
768        Unit
769    }
770
771    fn children_override(&self) -> Option<Vec<AnyView>> {
772        let me = self.clone();
773        let _ = me.selected.get();
774        let touched = me.touched.get();
775        let error = if touched { me.error() } else { None };
776        let summary = me.summary_text();
777        let mut menu = FloatingMenu::wrap(Button::new(summary))
778            .below_invoker()
779            .open_on_click()
780            .popup_width(220.0);
781        for (i, opt) in me.options.iter().enumerate() {
782            let on = me.selected.get_untracked().contains(&i);
783            let toggler = me.clone();
784            let mark = if on { "✓ " } else { "○ " };
785            menu = menu.row("", format!("{mark}{opt}"), move || toggler.toggle(i));
786        }
787        let mut col = Column::new()
788            .gap(4.0)
789            .child(Text::new(me.label.clone()).size(12.0).bold())
790            .child(menu);
791        if let Some(msg) = error {
792            col = col.child(
793                Text::new(msg)
794                    .size(error_size_of(me.style))
795                    .color(error_color_of(me.style)),
796            );
797        }
798        Some(vec![AnyView::new(col)])
799    }
800
801    fn layout_style(&self) -> sequoia_layout::LayoutStyle {
802        sequoia_layout::LayoutStyle {
803            width: Length::Fill(1.0),
804            height: Length::Fit,
805            ..sequoia_layout::LayoutStyle::default()
806        }
807    }
808}
809
810#[cfg(test)]
811mod tests {
812    use super::*;
813    #[test]
814    fn invalid_until_touched_and_filled() {
815        let form = Form::new();
816        let v = Signal::new(String::new());
817        let f = Field::new(v.clone(), "Nazwa")
818            .rule("cannot be empty", |s: &str| !s.trim().is_empty())
819            .rule("min. 3 znaki", |s: &str| s.chars().count() >= 3);
820        form.register(&f);
821        assert!(!form.is_valid());
822        assert_eq!(f.error().as_deref(), Some("cannot be empty"));
823        v.set("ab".to_string());
824        assert_eq!(f.error().as_deref(), Some("min. 3 znaki"));
825        v.set("abc".to_string());
826        assert!(form.is_valid());
827        assert!(f.error().is_none());
828    }
829
830    #[test]
831    fn number_clamps_and_validates() {
832        let v = Signal::new(5.0f32);
833        let n = NumberField::new(v.clone(), "Quantity")
834            .min(0.0)
835            .max(10.0)
836            .step(2.0)
837            .rule("max 8", |x| x <= 8.0);
838        n.nudge(1.0);
839        assert_eq!(v.get_untracked(), 7.0);
840        n.nudge(1.0);
841        assert_eq!(v.get_untracked(), 9.0, "step 2 od 7");
842        assert!(!n.is_valid());
843        assert_eq!(n.error().as_deref(), Some("max 8"));
844        v.set(4.0);
845        assert!(n.is_valid());
846    }
847
848    #[test]
849    fn iso_date_rejects_bad_calendar_dates() {
850        assert!(parse_iso_date("2026-02-29").is_none(), "2026 unleap");
851        assert!(parse_iso_date("2024-02-29").is_some(), "2024 Leap");
852        assert!(parse_iso_date("2026-13-01").is_none());
853        assert!(parse_iso_date("2026-04-31").is_none());
854        assert!(parse_iso_date("26-04-01").is_none());
855        assert!(parse_iso_date("2026/04/01").is_none());
856
857        let d = parse_iso_date("2026-03-01").unwrap();
858        assert_eq!(days_to_iso(date_to_days(d) - 1), "2026-02-28");
859        assert_eq!(days_to_iso(date_to_days(d) + 365), "2027-03-01");
860        let f = DateField::new(Signal::new("2026-02-30".to_string()), "D");
861        assert!(!f.is_valid());
862        f.shift_days(0);
863        assert_eq!(f.value_signal().get_untracked(), "1970-01-01");
864        let g = DateField::new(Signal::new("2026-03-01".to_string()), "D");
865        g.shift_days(-1);
866        assert_eq!(g.value_signal().get_untracked(), "2026-02-28");
867        assert!(g.is_valid());
868    }
869
870    #[test]
871    fn multi_select_toggles_sorted_and_enforces_min() {
872        let m = MultiSelect::new(vec!["a".into(), "b".into(), "c".into()], "Tagi").min_selected(1);
873        assert!(!m.is_valid());
874        m.toggle(2);
875        m.toggle(0);
876        assert_eq!(m.selected_indices(), vec![0, 2], "posortowane");
877        assert_eq!(m.selected_values(), vec!["a".to_string(), "c".to_string()]);
878        assert!(m.is_valid());
879        m.toggle(0);
880        m.toggle(2);
881        assert!(m.selected_indices().is_empty());
882        assert!(!m.is_valid());
883        m.toggle(99);
884        assert!(m.selected_indices().is_empty());
885
886        let form = Form::new();
887        form.register(&m);
888        assert_eq!(form.len(), 1);
889        assert!(!form.is_valid());
890    }
891}