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