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}