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}