use dioxus::prelude::*;
use crate::{
cn,
uikit::{
ButtonVariant, CALENDAR_CAPTION, CALENDAR_DAY, CALENDAR_DAY_CELL, CALENDAR_DAY_EMPTY, CALENDAR_DAY_SELECTED, CALENDAR_DAY_TODAY, CALENDAR_GRID, CALENDAR_NAV, CALENDAR_NAV_BUTTON,
CALENDAR_ROOT, CALENDAR_WEEK, CALENDAR_WEEKDAY, CALENDAR_WEEKDAY_ROW, Size, button::button_classes, primitives::use_controllable,
},
};
const MONTHS: [&str; 12] = [
"January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December",
];
const WEEKDAYS: [&str; 7] = ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"];
const CHEVRON_LEFT: &str = "m15 18-6-6 6-6";
const CHEVRON_RIGHT: &str = "m9 18 6-6-6-6";
const GRID_CELLS: usize = 6 * 7;
#[derive(Clone, Copy, Debug, Eq, PartialEq, PartialOrd, Ord)]
pub struct CalendarDate {
pub year: i32,
pub month: u32,
pub day: u32,
}
impl CalendarDate {
pub fn new(year: i32, month: u32, day: u32) -> Self {
Self { year, month, day }
}
fn is_leap(year: i32) -> bool {
(year % 4 == 0 && year % 100 != 0) || year % 400 == 0
}
fn days_in_month(year: i32, month: u32) -> u32 {
match month {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
2 if Self::is_leap(year) => 29,
2 => 28,
_ => 30,
}
}
fn first_weekday_monday0(year: i32, month: u32) -> u32 {
let (m, y) = if month < 3 { (month + 12, year - 1) } else { (month, year) };
let k = y.rem_euclid(100);
let j = y.div_euclid(100);
let q = 1i32; let h = (q + (13 * (m as i32 + 1)) / 5 + k + k / 4 + j / 4 + 5 * j).rem_euclid(7);
((h + 5).rem_euclid(7)) as u32
}
fn add_months(&self, delta: i32) -> Self {
let zero = (self.year as i64) * 12 + (self.month as i64 - 1) + delta as i64;
let year = zero.div_euclid(12) as i32;
let month = (zero.rem_euclid(12) + 1) as u32;
let day = self.day.min(Self::days_in_month(year, month));
Self { year, month, day }
}
}
#[component]
pub fn Calendar(
selected: Option<CalendarDate>,
on_select: Option<EventHandler<CalendarDate>>,
month: Option<CalendarDate>,
#[props(default = CalendarDate::new(2026, 6, 1))]
default_month: CalendarDate,
on_month_change: Option<EventHandler<CalendarDate>>,
today: Option<CalendarDate>,
min: Option<CalendarDate>,
max: Option<CalendarDate>,
#[props(default)]
disabled: bool,
previous_month_label: Option<String>,
next_month_label: Option<String>,
#[props(default)] class: String,
) -> Element {
let view = use_controllable(month, default_month, on_month_change);
let current = view.get();
let go = move |delta: i32| {
view.set(current.add_months(delta));
};
let nav_class = button_classes(&ButtonVariant::Ghost, Size::Md, true, None, CALENDAR_NAV_BUTTON);
let caption = format!("{} {}", MONTHS[(current.month - 1) as usize], current.year);
let previous_label = previous_month_label.unwrap_or_else(|| String::from("Previous month"));
let next_label = next_month_label.unwrap_or_else(|| String::from("Next month"));
let lead = CalendarDate::first_weekday_monday0(current.year, current.month);
let total = CalendarDate::days_in_month(current.year, current.month);
let mut cells: Vec<Option<u32>> = (0..lead).map(|_| None).collect();
cells.extend((1..=total).map(Some));
cells.resize(GRID_CELLS, None);
let weeks: Vec<Vec<Option<u32>>> = cells.chunks(7).map(<[Option<u32>]>::to_vec).collect();
let root = cn!(CALENDAR_ROOT, class);
rsx! {
div { class: root, "data-slot": "calendar", role: "application",
div { class: CALENDAR_NAV,
button {
r#type: "button",
class: nav_class.clone(),
"aria-label": previous_label,
disabled,
onclick: move |_| go(-1),
Chevron { d: CHEVRON_LEFT }
}
div { class: CALENDAR_CAPTION, "data-slot": "calendar-caption", {caption} }
button {
r#type: "button",
class: nav_class.clone(),
"aria-label": next_label,
disabled,
onclick: move |_| go(1),
Chevron { d: CHEVRON_RIGHT }
}
}
table { class: CALENDAR_GRID, role: "grid",
thead {
tr { class: CALENDAR_WEEKDAY_ROW,
for wd in WEEKDAYS {
th {
class: CALENDAR_WEEKDAY,
scope: "col",
{wd}
}
}
}
}
tbody {
for week in weeks {
tr { class: CALENDAR_WEEK,
for cell in week {
DayCell {
cell,
date: current,
selected,
today,
min,
max,
disabled,
on_select,
}
}
}
}
}
}
}
}
}
#[component]
fn DayCell(
cell: Option<u32>,
date: CalendarDate,
selected: Option<CalendarDate>,
today: Option<CalendarDate>,
min: Option<CalendarDate>,
max: Option<CalendarDate>,
disabled: bool,
on_select: Option<EventHandler<CalendarDate>>,
) -> Element {
let Some(day) = cell else {
return rsx! {
td { class: CALENDAR_DAY_EMPTY }
};
};
let this = CalendarDate::new(date.year, date.month, day);
let is_selected = selected == Some(this);
let is_today = today == Some(this);
let is_disabled = disabled || min.is_some_and(|lo| this < lo) || max.is_some_and(|hi| this > hi);
let aria_selected = if is_selected { "true" } else { "false" };
let mut day_class = button_classes(&ButtonVariant::Ghost, Size::Md, true, None, CALENDAR_DAY);
if is_selected {
day_class = cn!(day_class, CALENDAR_DAY_SELECTED);
} else if is_today {
day_class = cn!(day_class, CALENDAR_DAY_TODAY);
}
rsx! {
td {
class: CALENDAR_DAY_CELL,
role: "gridcell",
"aria-selected": aria_selected,
button {
r#type: "button",
class: day_class,
"data-slot": "calendar-day",
"data-selected": if is_selected { "true" } else { "false" },
"data-today": if is_today { "true" } else { "false" },
"data-disabled": if is_disabled { Some("true") } else { None },
disabled: is_disabled,
onclick: move |_| {
if is_disabled {
return;
}
if let Some(h) = on_select {
h.call(this);
}
},
"{day}"
}
}
}
}
#[component]
fn Chevron(d: &'static str) -> Element {
rsx! {
svg {
class: "size-4",
xmlns: "http://www.w3.org/2000/svg",
width: "24",
height: "24",
view_box: "0 0 24 24",
fill: "none",
stroke: "currentColor",
stroke_width: "2",
stroke_linecap: "round",
stroke_linejoin: "round",
path { d }
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::uikit::test_util::render;
#[test]
fn days_in_month_handles_leap_february() {
assert_eq!(CalendarDate::days_in_month(2024, 2), 29);
assert_eq!(CalendarDate::days_in_month(2023, 2), 28);
assert_eq!(CalendarDate::days_in_month(1900, 2), 28);
assert_eq!(CalendarDate::days_in_month(2000, 2), 29);
assert_eq!(CalendarDate::days_in_month(2026, 4), 30);
assert_eq!(CalendarDate::days_in_month(2026, 1), 31);
}
#[test]
fn first_weekday_is_monday_zero() {
assert_eq!(CalendarDate::first_weekday_monday0(2026, 6), 0);
assert_eq!(CalendarDate::first_weekday_monday0(2026, 1), 3);
assert_eq!(CalendarDate::first_weekday_monday0(2026, 2), 6);
}
#[test]
fn add_months_wraps_year_and_clamps_day() {
let dec = CalendarDate::new(2026, 12, 15);
assert_eq!(dec.add_months(1), CalendarDate::new(2027, 1, 15));
let jan = CalendarDate::new(2026, 1, 10);
assert_eq!(jan.add_months(-1), CalendarDate::new(2025, 12, 10));
let jan31 = CalendarDate::new(2026, 1, 31);
assert_eq!(jan31.add_months(1), CalendarDate::new(2026, 2, 28));
}
#[test]
fn renders_month_grid_with_slots_and_weekdays() {
fn app() -> Element {
rsx! {
Calendar { default_month: CalendarDate::new(2026, 6, 1) }
}
}
let html = render(app);
assert!(html.contains("data-slot=\"calendar\""), "{html}");
assert!(html.contains("June 2026"), "{html}");
assert!(html.contains("Mo"));
assert!(html.contains("role=\"gridcell\""), "{html}");
assert!(html.contains(">30<"), "{html}");
}
#[test]
fn selected_and_today_get_their_classes() {
fn app() -> Element {
rsx! {
Calendar {
default_month: CalendarDate::new(2026, 6, 1),
selected: CalendarDate::new(2026, 6, 10),
today: CalendarDate::new(2026, 6, 15),
}
}
}
let html = render(app);
assert!(html.contains("aria-selected=\"true\""), "{html}");
assert!(html.contains("bg-primary"), "{html}");
assert!(html.contains("bg-hover"), "{html}");
assert!(html.contains("data-selected=\"true\""), "{html}");
}
#[test]
fn unbounded_grid_has_no_disabled_days_and_english_nav_labels() {
fn app() -> Element {
rsx! {
Calendar { default_month: CalendarDate::new(2026, 6, 1) }
}
}
let html = render(app);
assert!(!html.contains(" disabled=true"), "{html}");
assert!(!html.contains("data-disabled"), "{html}");
assert!(html.contains("aria-label=\"Previous month\""), "{html}");
assert!(html.contains("aria-label=\"Next month\""), "{html}");
assert!(html.contains("data-slot=\"calendar-caption\""), "{html}");
}
#[test]
fn days_outside_min_max_render_disabled() {
fn app() -> Element {
rsx! {
Calendar {
default_month: CalendarDate::new(2026, 6, 1),
min: CalendarDate::new(2026, 6, 10),
max: CalendarDate::new(2026, 6, 20),
}
}
}
let html = render(app);
assert_eq!(html.matches("data-disabled=\"true\"").count(), 19, "{html}");
assert_eq!(html.matches(" disabled=true").count(), 19, "{html}");
let tenth = html.find(">10<").expect("day 10 rendered");
let tag_start = html[..tenth].rfind("<button").expect("day 10 button");
assert!(!html[tag_start..tenth].contains(" disabled=true"), "day at `min` stays enabled: {html}");
}
#[test]
fn disabled_freezes_every_day_and_the_nav() {
fn app() -> Element {
rsx! {
Calendar {
default_month: CalendarDate::new(2026, 6, 1),
disabled: true,
min: CalendarDate::new(2026, 6, 10),
}
}
}
let html = render(app);
assert_eq!(html.matches("data-disabled=\"true\"").count(), 30, "{html}");
assert_eq!(html.matches(" disabled=true").count(), 32, "{html}");
}
#[test]
fn nav_labels_are_overridable() {
fn app() -> Element {
rsx! {
Calendar {
default_month: CalendarDate::new(2026, 6, 1),
previous_month_label: "Предыдущий месяц",
next_month_label: "Следующий месяц",
}
}
}
let html = render(app);
assert!(html.contains("aria-label=\"Предыдущий месяц\""), "{html}");
assert!(html.contains("aria-label=\"Следующий месяц\""), "{html}");
assert!(!html.contains("Previous month"), "{html}");
}
#[test]
fn grid_always_renders_six_weeks() {
fn short_month() -> Element {
rsx! {
Calendar { default_month: CalendarDate::new(2027, 2, 1) }
}
}
fn long_month() -> Element {
rsx! {
Calendar { default_month: CalendarDate::new(2026, 8, 1) }
}
}
for (app, days) in [(short_month as fn() -> Element, 28usize), (long_month, 31)] {
let html = render(app);
assert_eq!(html.matches(CALENDAR_WEEK).count(), 6, "{html}");
assert_eq!(html.matches("role=\"gridcell\"").count(), days, "{html}");
assert_eq!(html.matches(CALENDAR_DAY_EMPTY).count(), GRID_CELLS - days, "{html}");
}
}
#[test]
fn calendar_date_orders_chronologically() {
assert!(CalendarDate::new(2026, 6, 10) < CalendarDate::new(2026, 6, 11));
assert!(CalendarDate::new(2026, 6, 30) < CalendarDate::new(2026, 7, 1));
assert!(CalendarDate::new(2025, 12, 31) < CalendarDate::new(2026, 1, 1));
}
}