adui_dioxus/components/
date_picker.rs

1use crate::components::config_provider::{Locale, use_config};
2use crate::components::floating::use_floating_close_handle;
3use crate::components::select_base::use_dropdown_layer;
4use dioxus::events::KeyboardEvent;
5use dioxus::prelude::*;
6use std::collections::HashMap;
7use std::rc::Rc;
8use time::Date;
9
10// Internal value used by RangePicker to represent a possibly-partial range.
11#[derive(Clone, Copy, Debug, PartialEq, Eq)]
12pub struct DateRangeValue {
13    pub start: Option<DateValue>,
14    pub end: Option<DateValue>,
15}
16
17impl DateRangeValue {
18    pub fn empty() -> Self {
19        Self {
20            start: None,
21            end: None,
22        }
23    }
24}
25
26/// Internal value type for DatePicker.
27///
28/// MVP 选择一个尽量简单且可序列化的模型:
29/// - 内部使用 `time::Date` 负责日期计算;
30/// - 对外可以通过辅助函数转换为 `YYYY-MM-DD` 字符串,便于与 Form 的 `serde_json::Value` 协作。
31#[derive(Clone, Copy, Debug, PartialEq, Eq)]
32pub struct DateValue {
33    pub inner: Date,
34}
35
36impl DateValue {
37    /// Convenience constructor from year, month, day.
38    pub fn from_ymd(year: i32, month: u8, day: u8) -> Option<Self> {
39        // We keep validation delegated to `time` to ensure correctness.
40        let month_enum = time::Month::try_from(month).ok()?;
41        Date::from_calendar_date(year, month_enum, day)
42            .ok()
43            .map(|d| DateValue { inner: d })
44    }
45
46    /// Format as `YYYY-MM-DD` string (MVP 默认格式)。
47    pub fn to_ymd_string(&self) -> String {
48        format!(
49            "{:04}-{:02}-{:02}",
50            self.inner.year(),
51            self.inner.month() as u8,
52            self.inner.day()
53        )
54    }
55}
56
57/// Compute the number of days in a given month of a given year.
58fn days_in_month(year: i32, month: u8) -> u8 {
59    match month {
60        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
61        4 | 6 | 9 | 11 => 30,
62        2 => {
63            // Gregorian leap year rules.
64            let is_leap = (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0);
65            if is_leap { 29 } else { 28 }
66        }
67        _ => 30,
68    }
69}
70
71/// Compute weekday index for a given date, with Monday = 0 .. Sunday = 6.
72fn weekday_index_monday(year: i32, month: u8, day: u8) -> u8 {
73    // Tomohiko Sakamoto's algorithm, returning 0 = Sunday .. 6 = Saturday.
74    let m = month as i32;
75    let d = day as i32;
76    let mut y = year;
77    let t = [0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4];
78    if m < 3 {
79        y -= 1;
80    }
81    let w = (y + y / 4 - y / 100 + y / 400 + t[(m - 1) as usize] + d) % 7;
82    let sunday_based = w as u8; // 0 = Sunday
83    // Convert to Monday-based index: Monday = 0 .. Sunday = 6.
84    (sunday_based + 6) % 7
85}
86
87/// Props for the DatePicker component (MVP subset for single date picker).
88#[derive(Props, Clone)]
89pub struct DatePickerProps {
90    /// Controlled value. When set, the component behaves as a controlled picker.
91    #[props(optional)]
92    pub value: Option<DateValue>,
93    /// Initial value in uncontrolled mode.
94    #[props(optional)]
95    pub default_value: Option<DateValue>,
96    /// Placeholder text shown when no value is selected.
97    #[props(optional)]
98    pub placeholder: Option<String>,
99    /// Display/parse format. MVP: primarily `YYYY-MM-DD`, reserved for future extension.
100    #[props(optional)]
101    pub format: Option<String>,
102    /// Whether the picker is disabled.
103    #[props(optional)]
104    pub disabled: Option<bool>,
105    /// Whether to show a clear icon which resets the current value.
106    #[props(optional)]
107    pub allow_clear: Option<bool>,
108    /// Extra class on the root element.
109    #[props(optional)]
110    pub class: Option<String>,
111    /// Inline style for the root element.
112    #[props(optional)]
113    pub style: Option<String>,
114    /// Change callback fired when the selected date changes.
115    #[props(optional)]
116    pub on_change: Option<EventHandler<Option<DateValue>>>,
117    /// Show time picker in addition to date picker.
118    #[props(optional)]
119    pub show_time: Option<ShowTimeConfig>,
120    /// Preset date ranges for quick selection.
121    #[props(optional)]
122    pub ranges: Option<HashMap<String, (DateValue, DateValue)>>,
123    /// Disable specific dates: (date) -> bool
124    #[props(optional)]
125    pub disabled_date: Option<Rc<dyn Fn(DateValue) -> bool>>,
126    /// Disable specific times: (date) -> bool (for showTime mode)
127    #[props(optional)]
128    pub disabled_time: Option<Rc<dyn Fn(DateValue) -> bool>>,
129    /// Custom footer render: () -> Element
130    #[props(optional)]
131    pub render_extra_footer: Option<Rc<dyn Fn() -> Element>>,
132    /// Custom date library configuration (for generateConfig).
133    #[props(optional)]
134    pub generate_config: Option<DateGenerateConfig>,
135}
136
137impl PartialEq for DatePickerProps {
138    fn eq(&self, other: &Self) -> bool {
139        // Compare all fields except function pointers
140        self.value == other.value
141            && self.default_value == other.default_value
142            && self.placeholder == other.placeholder
143            && self.format == other.format
144            && self.disabled == other.disabled
145            && self.allow_clear == other.allow_clear
146            && self.class == other.class
147            && self.style == other.style
148            && self.show_time == other.show_time
149            && self.ranges == other.ranges
150            && self.generate_config == other.generate_config
151        // Function pointers cannot be compared for equality
152    }
153}
154
155/// Show time configuration.
156#[derive(Clone, Debug, PartialEq)]
157pub struct ShowTimeConfig {
158    /// Format for time display.
159    pub format: Option<String>,
160    /// Default time value.
161    pub default_value: Option<String>,
162    /// Hour step.
163    pub hour_step: Option<u8>,
164    /// Minute step.
165    pub minute_step: Option<u8>,
166    /// Second step.
167    pub second_step: Option<u8>,
168}
169
170/// Date generation configuration for custom date libraries.
171#[derive(Clone)]
172pub struct DateGenerateConfig {
173    /// Generate date from year, month, day.
174    pub from_ymd: Rc<dyn Fn(i32, u8, u8) -> Option<DateValue>>,
175    /// Get current date.
176    pub now: Rc<dyn Fn() -> DateValue>,
177    /// Format date to string.
178    pub format: Rc<dyn Fn(DateValue, &str) -> String>,
179    /// Parse string to date.
180    pub parse: Rc<dyn Fn(&str, &str) -> Option<DateValue>>,
181}
182
183impl PartialEq for DateGenerateConfig {
184    fn eq(&self, _other: &Self) -> bool {
185        // Function pointers cannot be compared for equality
186        false
187    }
188}
189
190/// Ant Design flavored DatePicker (MVP: single-date picker with dropdown
191/// calendar). RangePicker and richer behaviours will be built on top in later
192/// steps.
193#[component]
194pub fn DatePicker(props: DatePickerProps) -> Element {
195    let DatePickerProps {
196        value,
197        default_value,
198        placeholder,
199        format: _format,
200        disabled,
201        allow_clear,
202        class,
203        style,
204        on_change,
205        ..
206    } = props;
207
208    let config = use_config();
209    let locale = config.locale;
210
211    let is_disabled = disabled.unwrap_or(false);
212    let allow_clear_flag = allow_clear.unwrap_or(false);
213
214    // Internal selection state used only when not controlled.
215    let selected_state: Signal<Option<DateValue>> = use_signal(|| default_value);
216    let current_value: Option<DateValue> = if let Some(v) = value {
217        Some(v)
218    } else {
219        *selected_state.read()
220    };
221
222    let display_text = current_value.map(|v| v.to_ymd_string()).unwrap_or_default();
223
224    let default_placeholder = match locale {
225        Locale::ZhCN => "请选择日期".to_string(),
226        Locale::EnUS => "Select date".to_string(),
227    };
228    let placeholder_str = placeholder.unwrap_or(default_placeholder);
229
230    let controlled = value.is_some();
231
232    // Dropdown open/close state.
233    let open_state: Signal<bool> = use_signal(|| false);
234    let open_flag = *open_state.read();
235
236    // Register dropdown layer to get a stable z-index when open and install
237    // click-outside handler.
238    let close_handle = use_floating_close_handle(open_state);
239    let dropdown_layer = use_dropdown_layer(open_flag);
240    let current_z = *dropdown_layer.z_index.read();
241
242    // Visible calendar month/year. Default to current value if present, else a
243    // fixed reference (2024-01) to keep behaviour deterministic across
244    // environments without relying on system time.
245    let initial_year = current_value.map(|v| v.inner.year()).unwrap_or(2024);
246    let initial_month = current_value.map(|v| v.inner.month() as u8).unwrap_or(1);
247    let view_year: Signal<i32> = use_signal(|| initial_year);
248    let view_month: Signal<u8> = use_signal(|| initial_month);
249
250    let year_now = *view_year.read();
251    let month_now = *view_month.read();
252    let days_in_month_now = days_in_month(year_now, month_now);
253    let first_weekday = weekday_index_monday(year_now, month_now, 1) as usize;
254    let total_cells = first_weekday + days_in_month_now as usize;
255    let padded_cells = total_cells.div_ceil(7) * 7; // round up to full weeks
256
257    let locale_for_header = locale;
258    let month_label = match locale_for_header {
259        Locale::ZhCN => format!("{year_now}年{month_now}月"),
260        Locale::EnUS => format!("{year_now}-{month_now:02}"),
261    };
262    let weekday_labels: [&str; 7] = match locale_for_header {
263        Locale::ZhCN => ["一", "二", "三", "四", "五", "六", "日"],
264        Locale::EnUS => ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"],
265    };
266
267    // Build root/control classes.
268    let mut control_classes = vec!["adui-date-picker".to_string()];
269    if is_disabled {
270        control_classes.push("adui-date-picker-disabled".to_string());
271    }
272    if let Some(extra) = class.clone() {
273        control_classes.push(extra);
274    }
275    let control_class_attr = control_classes.join(" ");
276    let style_attr = style.unwrap_or_default();
277
278    // Shared handles for handlers.
279    let selected_for_day_click = selected_state;
280    let on_change_cb = on_change;
281    let controlled_flag = controlled;
282    let open_for_toggle = open_state;
283    let open_for_keydown = open_for_toggle;
284
285    // Derived flags.
286    let has_value = current_value.is_some();
287
288    // Precompute calendar cells for the current view.
289    let mut date_cells: Vec<Element> = Vec::new();
290    for index in 0..padded_cells {
291        let cell_day =
292            if index < first_weekday || index >= first_weekday + days_in_month_now as usize {
293                None
294            } else {
295                Some((index - first_weekday + 1) as u8)
296            };
297
298        let is_outside = cell_day.is_none();
299        let is_selected = if let (Some(day), Some(current)) = (cell_day, current_value) {
300            current.inner.year() == year_now
301                && current.inner.month() as u8 == month_now
302                && current.inner.day() == day
303        } else {
304            false
305        };
306
307        let mut cell_classes = vec!["adui-date-picker-cell".to_string()];
308        if is_outside {
309            cell_classes.push("adui-date-picker-cell-empty".to_string());
310        } else {
311            cell_classes.push("adui-date-picker-cell-date".to_string());
312        }
313        if is_selected {
314            cell_classes.push("adui-date-picker-cell-selected".to_string());
315        }
316        let cell_class_attr = cell_classes.join(" ");
317
318        let selected_state_for_cell = selected_for_day_click;
319        let on_change_cb_for_cell = on_change_cb;
320        let controlled_flag_for_cell = controlled_flag;
321        let close_for_cell = close_handle;
322
323        let cell_node = rsx! {
324            span {
325                class: "{cell_class_attr}",
326                onclick: move |_| {
327                    close_for_cell.mark_internal_click();
328                    if let Some(day) = cell_day
329                        && let Some(value) = DateValue::from_ymd(year_now, month_now, day)
330                    {
331                        if controlled_flag_for_cell {
332                            if let Some(cb) = on_change_cb_for_cell {
333                                cb.call(Some(value));
334                            }
335                        } else {
336                            let mut state = selected_state_for_cell;
337                            state.set(Some(value));
338                            if let Some(cb) = on_change_cb_for_cell {
339                                cb.call(Some(value));
340                            }
341                        }
342                        // 选择后关闭面板。
343                        close_for_cell.close();
344                    }
345                },
346                match cell_day {
347                    Some(day) => rsx!{ "{day}" },
348                    None => rsx!{ "" },
349                }
350            }
351        };
352
353        date_cells.push(cell_node);
354    }
355
356    rsx! {
357        div {
358            class: "adui-date-picker-root",
359            style: "position: relative; display: inline-block;",
360            div {
361                class: "{control_class_attr}",
362                style: "{style_attr}",
363                role: "combobox",
364                tabindex: (!is_disabled).then_some(0),
365                "aria-expanded": open_flag,
366                "aria-disabled": is_disabled,
367                onclick: move |_| {
368                    if is_disabled {
369                        return;
370                    }
371                    close_handle.mark_internal_click();
372                    let mut open_signal = open_for_toggle;
373                    let next = !*open_signal.read();
374                    open_signal.set(next);
375                },
376                onkeydown: move |evt: KeyboardEvent| {
377                    if is_disabled {
378                        return;
379                    }
380                    use dioxus::prelude::Key;
381                    match evt.key() {
382                        Key::Enter => {
383                            evt.prevent_default();
384                            let mut open_signal = open_for_keydown;
385                            open_signal.set(true);
386                        }
387                        Key::Escape => {
388                            close_handle.close();
389                        }
390                        _ => {}
391                    }
392                },
393                input {
394                    class: "adui-date-picker-input",
395                    readonly: true,
396                    disabled: is_disabled,
397                    value: "{display_text}",
398                    placeholder: "{placeholder_str}",
399                }
400                if allow_clear_flag && has_value && !is_disabled {
401                    span {
402                        class: "adui-date-picker-clear",
403                        onclick: move |_| {
404                            if controlled_flag {
405                                if let Some(cb) = on_change_cb {
406                                    cb.call(None);
407                                }
408                            } else {
409                                let mut state = selected_for_day_click;
410                                state.set(None);
411                                if let Some(cb) = on_change_cb {
412                                    cb.call(None);
413                                }
414                            }
415                        },
416                        "×"
417                    }
418                }
419            }
420
421            if open_flag {
422                // Simple calendar dropdown.
423                div {
424                    class: "adui-date-picker-dropdown",
425                    style: "position: absolute; top: 100%; left: 0; min-width: 100%; z-index: {current_z};",
426                    // Header with month navigation.
427                    div { class: "adui-date-picker-header",
428                        button {
429                            class: "adui-date-picker-nav-btn adui-date-picker-prev-month",
430                            onclick: move |_| {
431                                close_handle.mark_internal_click();
432                                let mut year = *view_year.read();
433                                let mut month = *view_month.read();
434                                if month == 1 {
435                                    month = 12;
436                                    year -= 1;
437                                } else {
438                                    month -= 1;
439                                }
440                                let mut y = view_year;
441                                let mut m = view_month;
442                                y.set(year);
443                                m.set(month);
444                            },
445                            "<"
446                        }
447                        span { class: "adui-date-picker-header-view", "{month_label}" }
448                        button {
449                            class: "adui-date-picker-nav-btn adui-date-picker-next-month",
450                            onclick: move |_| {
451                                close_handle.mark_internal_click();
452                                let mut year = *view_year.read();
453                                let mut month = *view_month.read();
454                                if month == 12 {
455                                    month = 1;
456                                    year += 1;
457                                } else {
458                                    month += 1;
459                                }
460                                let mut y = view_year;
461                                let mut m = view_month;
462                                y.set(year);
463                                m.set(month);
464                            },
465                            ">"
466                        }
467                    }
468
469                    // Weekday header row.
470                    div { class: "adui-date-picker-week-row",
471                        for label in weekday_labels {
472                            span { class: "adui-date-picker-week-cell", "{label}" }
473                        }
474                    }
475
476                    // Date grid.
477                    div { class: "adui-date-picker-body",
478                        for cell in date_cells { {cell} }
479                    }
480                }
481            }
482        }
483    }
484}
485
486/// Props for the RangePicker component (MVP subset).
487#[derive(Props, Clone, PartialEq)]
488pub struct RangePickerProps {
489    /// Controlled range value. Each side may be `None` to represent a
490    /// partially selected range.
491    #[props(optional)]
492    pub value: Option<DateRangeValue>,
493    /// Initial value in uncontrolled mode.
494    #[props(optional)]
495    pub default_value: Option<DateRangeValue>,
496    /// Placeholders for the start and end inputs.
497    #[props(optional)]
498    pub placeholder: Option<(String, String)>,
499    /// Display/parse format. MVP: primarily `YYYY-MM-DD`.
500    #[props(optional)]
501    pub format: Option<String>,
502    /// Whether the picker is disabled.
503    #[props(optional)]
504    pub disabled: Option<bool>,
505    /// Whether to show a clear icon which resets the current range.
506    #[props(optional)]
507    pub allow_clear: Option<bool>,
508    /// Extra class on the root element.
509    #[props(optional)]
510    pub class: Option<String>,
511    /// Inline style for the root element.
512    #[props(optional)]
513    pub style: Option<String>,
514    /// Change callback fired when the range changes.
515    #[props(optional)]
516    pub on_change: Option<EventHandler<DateRangeValue>>,
517}
518
519/// MVP RangePicker: single-month range selection with basic highlighting.
520#[component]
521pub fn RangePicker(props: RangePickerProps) -> Element {
522    let RangePickerProps {
523        value,
524        default_value,
525        placeholder,
526        format: _format,
527        disabled,
528        allow_clear,
529        class,
530        style,
531        on_change,
532    } = props;
533
534    let config = use_config();
535    let locale = config.locale;
536
537    let is_disabled = disabled.unwrap_or(false);
538    let allow_clear_flag = allow_clear.unwrap_or(false);
539
540    let initial_range = default_value.unwrap_or_else(DateRangeValue::empty);
541    let range_state: Signal<DateRangeValue> = use_signal(|| initial_range);
542    let range = value.unwrap_or_else(|| *range_state.read());
543
544    let start_text = range.start.map(|d| d.to_ymd_string()).unwrap_or_default();
545    let end_text = range.end.map(|d| d.to_ymd_string()).unwrap_or_default();
546
547    let default_placeholder = match locale {
548        Locale::ZhCN => ("开始日期".to_string(), "结束日期".to_string()),
549        Locale::EnUS => ("Start date".to_string(), "End date".to_string()),
550    };
551    let (start_ph, end_ph) = placeholder.unwrap_or(default_placeholder);
552
553    let controlled = value.is_some();
554
555    let open_state: Signal<bool> = use_signal(|| false);
556    let open_flag = *open_state.read();
557    let dropdown_layer = use_dropdown_layer(open_flag);
558    let current_z = *dropdown_layer.z_index.read();
559
560    // Visible calendar month/year. Use start date if present, otherwise end,
561    // otherwise a fixed reference.
562    let initial_year = range
563        .start
564        .or(range.end)
565        .map(|v| v.inner.year())
566        .unwrap_or(2024);
567    let initial_month = range
568        .start
569        .or(range.end)
570        .map(|v| v.inner.month() as u8)
571        .unwrap_or(1);
572
573    let view_year: Signal<i32> = use_signal(|| initial_year);
574    let view_month: Signal<u8> = use_signal(|| initial_month);
575
576    let year_now = *view_year.read();
577    let month_now = *view_month.read();
578    let days_in_month_now = days_in_month(year_now, month_now);
579    let first_weekday = weekday_index_monday(year_now, month_now, 1) as usize;
580    let total_cells = first_weekday + days_in_month_now as usize;
581    let padded_cells = total_cells.div_ceil(7) * 7;
582
583    let locale_for_header = locale;
584    let month_label = match locale_for_header {
585        Locale::ZhCN => format!("{year_now}年{month_now}月"),
586        Locale::EnUS => format!("{year_now}-{month_now:02}"),
587    };
588    let weekday_labels: [&str; 7] = match locale_for_header {
589        Locale::ZhCN => ["一", "二", "三", "四", "五", "六", "日"],
590        Locale::EnUS => ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"],
591    };
592
593    let mut control_classes = vec!["adui-date-picker adui-date-picker-range".to_string()];
594    if is_disabled {
595        control_classes.push("adui-date-picker-disabled".to_string());
596    }
597    if let Some(extra) = class.clone() {
598        control_classes.push(extra);
599    }
600    let control_class_attr = control_classes.join(" ");
601    let style_attr = style.unwrap_or_default();
602
603    let open_for_toggle = open_state;
604
605    let on_change_cb = on_change;
606    let range_for_click = range_state;
607
608    // Shared floating close handle for range picker dropdown.
609    let close_handle = use_floating_close_handle(open_for_toggle);
610
611    // Precompute date cells with simple range highlighting.
612    let mut date_cells: Vec<Element> = Vec::new();
613    for index in 0..padded_cells {
614        let cell_day =
615            if index < first_weekday || index >= first_weekday + days_in_month_now as usize {
616                None
617            } else {
618                Some((index - first_weekday + 1) as u8)
619            };
620
621        let is_outside = cell_day.is_none();
622        let mut is_selected_start = false;
623        let mut is_selected_end = false;
624        let mut in_range = false;
625
626        if let Some(day) = cell_day {
627            if let Some(start) = range.start
628                && start.inner.year() == year_now
629                && start.inner.month() as u8 == month_now
630                && start.inner.day() == day
631            {
632                is_selected_start = true;
633            }
634            if let Some(end) = range.end
635                && end.inner.year() == year_now
636                && end.inner.month() as u8 == month_now
637                && end.inner.day() == day
638            {
639                is_selected_end = true;
640            }
641            if let (Some(start), Some(end)) = (range.start, range.end) {
642                let date = DateValue::from_ymd(year_now, month_now, day).unwrap();
643                if date.inner >= start.inner && date.inner <= end.inner {
644                    in_range = true;
645                }
646            }
647        }
648
649        let mut cell_classes = vec!["adui-date-picker-cell".to_string()];
650        if is_outside {
651            cell_classes.push("adui-date-picker-cell-empty".to_string());
652        } else {
653            cell_classes.push("adui-date-picker-cell-date".to_string());
654        }
655        if in_range {
656            cell_classes.push("adui-date-picker-cell-in-range".to_string());
657        }
658        if is_selected_start {
659            cell_classes.push("adui-date-picker-cell-range-start".to_string());
660        }
661        if is_selected_end {
662            cell_classes.push("adui-date-picker-cell-range-end".to_string());
663        }
664        let cell_class_attr = cell_classes.join(" ");
665
666        let on_change_for_cell = on_change_cb;
667        let controlled_for_cell = controlled;
668        let close_for_cell = close_handle;
669
670        let cell_node = rsx! {
671            span {
672                class: "{cell_class_attr}",
673                onclick: move |_| {
674                    if is_disabled {
675                        return;
676                    }
677                    close_for_cell.mark_internal_click();
678                    if let Some(day) = cell_day
679                        && let Some(clicked) = DateValue::from_ymd(year_now, month_now, day)
680                    {
681                        let mut next = range;
682                        match (next.start, next.end) {
683                            (None, _) => {
684                                next.start = Some(clicked);
685                                next.end = None;
686                            }
687                            (Some(start), None) => {
688                                if clicked.inner < start.inner {
689                                    next.start = Some(clicked);
690                                    next.end = Some(start);
691                                } else {
692                                    next.end = Some(clicked);
693                                }
694
695                                close_for_cell.close();
696                            }
697                            (Some(_), Some(_)) => {
698                                next.start = Some(clicked);
699                                next.end = None;
700                            }
701                        }
702
703                        if controlled_for_cell {
704                            if let Some(cb) = on_change_for_cell {
705                                cb.call(next);
706                            }
707                        } else {
708                            let mut state = range_for_click;
709                            state.set(next);
710                            if let Some(cb) = on_change_for_cell {
711                                cb.call(next);
712                            }
713                        }
714                    }
715                },
716                match cell_day {
717                    Some(day) => rsx!{ "{day}" },
718                    None => rsx!{ "" },
719                }
720            }
721        };
722
723        date_cells.push(cell_node);
724    }
725
726    let has_any_value = range.start.is_some() || range.end.is_some();
727
728    rsx! {
729        div {
730            class: "adui-date-picker-root",
731            style: "position: relative; display: inline-block;",
732            div {
733                class: "{control_class_attr}",
734                style: "{style_attr}",
735                role: "group",
736                tabindex: (!is_disabled).then_some(0),
737                onclick: move |_| {
738                    if is_disabled {
739                        return;
740                    }
741                    close_handle.mark_internal_click();
742                    let mut open_signal = open_for_toggle;
743                    let next = !*open_signal.read();
744                    open_signal.set(next);
745                },
746                onkeydown: move |evt: KeyboardEvent| {
747                    if is_disabled {
748                        return;
749                    }
750                    use dioxus::prelude::Key;
751                    match evt.key() {
752                        Key::Enter => {
753                            evt.prevent_default();
754                            let mut open_signal = open_for_toggle;
755                            open_signal.set(true);
756                        }
757                        Key::Escape => {
758                            close_handle.close();
759                        }
760                        _ => {}
761                    }
762                },
763                input {
764                    class: "adui-date-picker-input adui-date-picker-input-start",
765                    readonly: true,
766                    disabled: is_disabled,
767                    value: "{start_text}",
768                    placeholder: "{start_ph}",
769                }
770                span { class: "adui-date-picker-range-separator", " ~ " }
771                input {
772                    class: "adui-date-picker-input adui-date-picker-input-end",
773                    readonly: true,
774                    disabled: is_disabled,
775                    value: "{end_text}",
776                    placeholder: "{end_ph}",
777                }
778                if allow_clear_flag && has_any_value && !is_disabled {
779                    span {
780                        class: "adui-date-picker-clear",
781                        onclick: move |_| {
782                            if controlled {
783                                if let Some(cb) = on_change {
784                                    cb.call(DateRangeValue::empty());
785                                }
786                            } else {
787                                let mut state = range_state;
788                                state.set(DateRangeValue::empty());
789                                if let Some(cb) = on_change {
790                                    cb.call(DateRangeValue::empty());
791                                }
792                            }
793                        },
794                        "×"
795                    }
796                }
797            }
798
799            if open_flag {
800                div {
801                    class: "adui-date-picker-dropdown",
802                    style: "position: absolute; top: 100%; left: 0; min-width: 100%; z-index: {current_z};",
803                    div { class: "adui-date-picker-header",
804                        button {
805                            class: "adui-date-picker-nav-btn adui-date-picker-prev-month",
806                            onclick: move |_| {
807                                close_handle.mark_internal_click();
808                                let mut year = *view_year.read();
809                                let mut month = *view_month.read();
810                                if month == 1 {
811                                    month = 12;
812                                    year -= 1;
813                                } else {
814                                    month -= 1;
815                                }
816                                let mut y = view_year;
817                                let mut m = view_month;
818                                y.set(year);
819                                m.set(month);
820                            },
821                            "<"
822                        }
823                        span { class: "adui-date-picker-header-view", "{month_label}" }
824                        button {
825                            class: "adui-date-picker-nav-btn adui-date-picker-next-month",
826                            onclick: move |_| {
827                                close_handle.mark_internal_click();
828                                let mut year = *view_year.read();
829                                let mut month = *view_month.read();
830                                if month == 12 {
831                                    month = 1;
832                                    year += 1;
833                                } else {
834                                    month += 1;
835                                }
836                                let mut y = view_year;
837                                let mut m = view_month;
838                                y.set(year);
839                                m.set(month);
840                            },
841                            ">"
842                        }
843                    }
844
845                    div { class: "adui-date-picker-week-row",
846                        for label in weekday_labels {
847                            span { class: "adui-date-picker-week-cell", "{label}" }
848                        }
849                    }
850
851                    div { class: "adui-date-picker-body",
852                        for cell in date_cells { {cell} }
853                    }
854                }
855            }
856        }
857    }
858}
859
860#[cfg(test)]
861mod tests {
862    use super::*;
863
864    #[test]
865    fn date_value_from_ymd_and_to_string_are_consistent() {
866        let value = DateValue::from_ymd(2024, 5, 17).expect("valid date");
867        assert_eq!(value.to_ymd_string(), "2024-05-17");
868    }
869
870    #[test]
871    fn date_value_from_ymd_valid_dates() {
872        // Test various valid dates
873        assert!(DateValue::from_ymd(2024, 1, 1).is_some());
874        assert!(DateValue::from_ymd(2024, 12, 31).is_some());
875        assert!(DateValue::from_ymd(2000, 2, 29).is_some()); // Leap year
876        assert!(DateValue::from_ymd(2024, 2, 29).is_some()); // Leap year
877    }
878
879    #[test]
880    fn date_value_from_ymd_invalid_dates() {
881        // Test invalid dates
882        assert!(DateValue::from_ymd(2024, 2, 30).is_none()); // Invalid day
883        assert!(DateValue::from_ymd(2023, 2, 29).is_none()); // Not a leap year
884        assert!(DateValue::from_ymd(2024, 13, 1).is_none()); // Invalid month
885        assert!(DateValue::from_ymd(2024, 0, 1).is_none()); // Invalid month
886        assert!(DateValue::from_ymd(2024, 4, 31).is_none()); // April has 30 days
887    }
888
889    #[test]
890    fn date_value_to_ymd_string_formatting() {
891        let value1 = DateValue::from_ymd(2024, 1, 1).expect("valid date");
892        assert_eq!(value1.to_ymd_string(), "2024-01-01");
893
894        let value2 = DateValue::from_ymd(2024, 12, 31).expect("valid date");
895        assert_eq!(value2.to_ymd_string(), "2024-12-31");
896
897        let value3 = DateValue::from_ymd(2000, 2, 29).expect("valid date");
898        assert_eq!(value3.to_ymd_string(), "2000-02-29");
899
900        let value4 = DateValue::from_ymd(1999, 3, 15).expect("valid date");
901        assert_eq!(value4.to_ymd_string(), "1999-03-15");
902    }
903
904    #[test]
905    fn date_value_leap_year_handling() {
906        // Test leap years
907        assert!(DateValue::from_ymd(2000, 2, 29).is_some()); // 2000 is divisible by 400
908        assert!(DateValue::from_ymd(2004, 2, 29).is_some()); // 2004 is divisible by 4 but not 100
909        assert!(DateValue::from_ymd(1900, 2, 29).is_none()); // 1900 is divisible by 100 but not 400
910        assert!(DateValue::from_ymd(2024, 2, 29).is_some()); // 2024 is divisible by 4
911    }
912
913    #[test]
914    fn days_in_month_regular_months() {
915        assert_eq!(days_in_month(2024, 1), 31); // January
916        assert_eq!(days_in_month(2024, 3), 31); // March
917        assert_eq!(days_in_month(2024, 4), 30); // April
918        assert_eq!(days_in_month(2024, 5), 31); // May
919        assert_eq!(days_in_month(2024, 6), 30); // June
920        assert_eq!(days_in_month(2024, 7), 31); // July
921        assert_eq!(days_in_month(2024, 8), 31); // August
922        assert_eq!(days_in_month(2024, 9), 30); // September
923        assert_eq!(days_in_month(2024, 10), 31); // October
924        assert_eq!(days_in_month(2024, 11), 30); // November
925        assert_eq!(days_in_month(2024, 12), 31); // December
926    }
927
928    #[test]
929    fn days_in_month_february_leap_years() {
930        assert_eq!(days_in_month(2000, 2), 29); // Leap year (divisible by 400)
931        assert_eq!(days_in_month(2004, 2), 29); // Leap year (divisible by 4, not 100)
932        assert_eq!(days_in_month(2024, 2), 29); // Leap year
933        assert_eq!(days_in_month(2023, 2), 28); // Not a leap year
934        assert_eq!(days_in_month(1900, 2), 28); // Not a leap year (divisible by 100, not 400)
935        assert_eq!(days_in_month(2100, 2), 28); // Not a leap year (divisible by 100, not 400)
936    }
937
938    #[test]
939    fn weekday_index_monday_calculation() {
940        // Test known dates and their weekdays
941        // 2024-01-01 is a Monday (index 0)
942        assert_eq!(weekday_index_monday(2024, 1, 1), 0);
943
944        // 2024-01-02 is a Tuesday (index 1)
945        assert_eq!(weekday_index_monday(2024, 1, 2), 1);
946
947        // 2024-01-07 is a Sunday (index 6)
948        assert_eq!(weekday_index_monday(2024, 1, 7), 6);
949
950        // 2024-05-17 is a Friday (index 4)
951        assert_eq!(weekday_index_monday(2024, 5, 17), 4);
952
953        // 2000-02-29 is a Tuesday (index 1) - leap year
954        assert_eq!(weekday_index_monday(2000, 2, 29), 1);
955    }
956
957    #[test]
958    fn weekday_index_monday_consistency() {
959        // Test that consecutive days increment correctly
960        let base = weekday_index_monday(2024, 1, 1);
961        assert_eq!(weekday_index_monday(2024, 1, 2), (base + 1) % 7);
962        assert_eq!(weekday_index_monday(2024, 1, 3), (base + 2) % 7);
963        assert_eq!(weekday_index_monday(2024, 1, 8), base); // Same weekday next week
964    }
965
966    #[test]
967    fn date_range_value_empty() {
968        let range = DateRangeValue::empty();
969        assert_eq!(range.start, None);
970        assert_eq!(range.end, None);
971    }
972
973    #[test]
974    fn date_value_clone_and_copy() {
975        let value1 = DateValue::from_ymd(2024, 5, 17).expect("valid date");
976        let value2 = value1; // Copy
977        assert_eq!(value1, value2);
978        assert_eq!(value1.to_ymd_string(), value2.to_ymd_string());
979    }
980
981    #[test]
982    fn date_value_debug() {
983        let value = DateValue::from_ymd(2024, 5, 17).expect("valid date");
984        let debug_str = format!("{:?}", value);
985        assert!(debug_str.contains("DateValue"));
986    }
987}