use chrono::{Datelike, Months, NaiveDate, Utc};
use leptos::prelude::*;
use orbital_base_components::{
build_month_grid, format_unix, is_day_disabled, DatetimeFormat, DatetimeTimezone, GridDayKind,
OrbitalDateTime,
};
use orbital_macros::component_doc;
use orbital_style::inject_style;
use crate::button::{Button, ButtonAppearance};
use crate::forms::button_group::ButtonGroup;
use crate::forms::calendar::bind::CalendarBind;
use crate::forms::calendar::day::{
default_calendar_day, CalendarDayProps, CalendarDayRenderer, CalendarWeekdayHeader,
};
use super::styles::calendar_styles;
#[component_doc(
category = "Calendar & Time",
preview_slug = "calendar",
preview_label = "Calendar",
preview_icon = icondata::AiCalendarOutlined,
)]
#[component]
pub fn Calendar(
#[prop(optional, into)]
bind: CalendarBind,
#[prop(optional, into)]
appearance: CalendarAppearance,
#[prop(default = None)]
day: Option<CalendarDayRenderer>,
#[prop(optional, into)]
class: MaybeProp<String>,
#[prop(optional)]
chrome_labels: Option<CalendarChromeLabels>,
) -> impl IntoView {
inject_style("orbital-calendar", calendar_styles());
let CalendarBind { value } = bind;
let CalendarAppearance {
timezone,
min_date,
max_date,
} = appearance;
let value = StoredValue::new(value);
let timezone = StoredValue::new(timezone);
let min_date = StoredValue::new(min_date);
let max_date = StoredValue::new(max_date);
let day_renderer = StoredValue::new(day);
let chrome_labels = StoredValue::new(chrome_labels.unwrap_or_default());
let initial_date = value
.get_value()
.get_untracked()
.and_then(|dt| dt.wall_date())
.unwrap_or_else(|| today_for_timezone(timezone.get_value()));
let show_date = RwSignal::new(initial_date);
let focused_date = RwSignal::new(initial_date);
Effect::new(move |_| {
if let Some(selected) = value.get_value().get() {
if let Some(selected_date) = selected.wall_date() {
show_date.update(|current| {
if current.year() != selected_date.year()
|| current.month() != selected_date.month()
{
*current = selected_date;
}
});
focused_date.set(selected_date);
}
}
});
let month_grid = Memo::new(move |_| {
let current = show_date.get();
build_month_grid(current.year(), current.month())
});
let previous_month = move |_| {
show_date.update(|date| {
if let Some(prev) = date.checked_sub_months(Months::new(1)) {
*date = prev;
}
});
};
let next_month = move |_| {
show_date.update(|date| {
if let Some(next) = date.checked_add_months(Months::new(1)) {
*date = next;
}
});
};
let jump_to_today = move |_| {
let tz = timezone.get_value();
let now = today_for_timezone(tz);
show_date.set(now);
focused_date.set(now);
value.with_value(|v| v.set(Some(OrbitalDateTime::utc_now(tz).start_of_day())));
};
let select_date = move |date: NaiveDate,
tz: DatetimeTimezone,
min: Option<OrbitalDateTime>,
max: Option<OrbitalDateTime>| {
if is_day_disabled(date, min, max) {
return;
}
show_date.set(date);
focused_date.set(date);
if let Some(dt) = orbital_from_date(date, tz) {
value.with_value(|v| v.set(Some(dt)));
}
};
view! {
<div class=move || {
let mut parts = vec!["orbital-calendar".to_string()];
if let Some(extra) = class.get() {
if !extra.is_empty() {
parts.push(extra);
}
}
parts.join(" ")
}>
<div class="orbital-calendar__header">
<span class="orbital-calendar__header-title" data-testid="calendar-header-title">
{move || show_date.with(|date| date.format("%B %Y").to_string())}
</span>
<ButtonGroup>
<Button
appearance=ButtonAppearance::Secondary
on_click=Callback::new(previous_month)
>
{move || chrome_labels.get_value().previous_month.clone()}
</Button>
<Button
appearance=ButtonAppearance::Secondary
on_click=Callback::new(jump_to_today)
>
{move || chrome_labels.get_value().today.clone()}
</Button>
<Button
appearance=ButtonAppearance::Secondary
on_click=Callback::new(next_month)
>
{move || chrome_labels.get_value().next_month.clone()}
</Button>
</ButtonGroup>
</div>
<CalendarWeekdayHeader labels=chrome_labels.get_value().weekday_short.clone() />
<div
class="orbital-calendar__dates"
role="grid"
aria-label="Calendar days"
tabindex="-1"
on:keydown=move |ev| {
let tz = timezone.get_value();
let min = min_date.get_value();
let max = max_date.get_value();
handle_calendar_grid_keydown(
ev,
focused_date,
show_date,
move |date| select_date(date, tz, min, max),
);
}
>
{move || {
let tz = timezone.get_value();
let min = min_date.get_value();
let max = max_date.get_value();
let today = today_for_timezone(tz);
let selected = value.get_value().get();
let focus = focused_date.get();
let renderer = day_renderer.get_value();
month_grid
.get()
.into_iter()
.map(|day| {
let selected_date = selected.and_then(|dt| dt.wall_date());
let is_selected =
selected_date.map(|d| d == day.date).unwrap_or(false);
let is_today = day.date == today;
let is_other_month = day.kind != GridDayKind::Current;
let is_disabled = is_day_disabled(day.date, min, max);
let day_label = format_unix(
day.unix_secs,
DatetimeFormat::IsoDate,
DatetimeTimezone::Utc,
);
let tabindex = if is_disabled {
"-1"
} else if focus == day.date {
"0"
} else {
"-1"
};
let date = day.date;
let on_select = Callback::new(move |_| {
select_date(date, tz, min, max);
});
let on_focus = Callback::new(move |_| focused_date.set(date));
let props = CalendarDayProps {
date,
day_number: day.date.day(),
weekday_label: None,
selected: is_selected,
today: is_today,
other_month: is_other_month,
disabled: is_disabled,
aria_label: day_label,
tabindex,
on_select,
on_focus,
};
if let Some(render) = renderer.clone() {
render(props).into_any()
} else {
default_calendar_day(props).into_any()
}
})
.collect_view()
}}
</div>
</div>
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CalendarChromeLabels {
pub weekday_short: [String; 7],
pub today: String,
pub previous_month: String,
pub next_month: String,
}
impl Default for CalendarChromeLabels {
fn default() -> Self {
Self {
weekday_short: [
"Sun".into(),
"Mon".into(),
"Tue".into(),
"Wed".into(),
"Thu".into(),
"Fri".into(),
"Sat".into(),
],
today: "Today".into(),
previous_month: "Previous".into(),
next_month: "Next".into(),
}
}
}
#[derive(Clone, Copy, Debug)]
pub struct CalendarAppearance {
pub timezone: DatetimeTimezone,
pub min_date: Option<OrbitalDateTime>,
pub max_date: Option<OrbitalDateTime>,
}
impl Default for CalendarAppearance {
fn default() -> Self {
Self {
timezone: DatetimeTimezone::Local,
min_date: None,
max_date: None,
}
}
}
impl CalendarAppearance {
pub fn new(timezone: DatetimeTimezone) -> Self {
Self {
timezone,
min_date: None,
max_date: None,
}
}
}
impl From<DatetimeTimezone> for CalendarAppearance {
fn from(timezone: DatetimeTimezone) -> Self {
Self::new(timezone)
}
}
fn handle_calendar_grid_keydown(
ev: leptos::ev::KeyboardEvent,
focused_date: RwSignal<NaiveDate>,
show_date: RwSignal<NaiveDate>,
select_date: impl Fn(NaiveDate) + Copy + 'static,
) {
let key = ev.key();
let current = focused_date.get_untracked();
let next = match key.as_str() {
"ArrowLeft" => current.pred_opt(),
"ArrowRight" => current.succ_opt(),
"ArrowUp" => current.checked_sub_days(chrono::Days::new(7)),
"ArrowDown" => current.checked_add_days(chrono::Days::new(7)),
"Home" => Some(start_of_week(current)),
"End" => Some(end_of_week(current)),
"PageUp" => current.checked_sub_months(Months::new(1)),
"PageDown" => current.checked_add_months(Months::new(1)),
"Enter" | " " => {
ev.prevent_default();
select_date(current);
return;
}
_ => None,
};
if let Some(next_date) = next {
ev.prevent_default();
focused_date.set(next_date);
if next_date.year() != show_date.get_untracked().year()
|| next_date.month() != show_date.get_untracked().month()
{
show_date.set(next_date);
}
}
}
fn start_of_week(date: NaiveDate) -> NaiveDate {
let weekday = date.weekday().num_days_from_sunday();
date.checked_sub_days(chrono::Days::new(weekday as u64))
.unwrap_or(date)
}
fn end_of_week(date: NaiveDate) -> NaiveDate {
let weekday = date.weekday().num_days_from_sunday();
date.checked_add_days(chrono::Days::new(6 - weekday as u64))
.unwrap_or(date)
}
fn orbital_from_date(date: NaiveDate, timezone: DatetimeTimezone) -> Option<OrbitalDateTime> {
OrbitalDateTime::from_naive_date(date, timezone)
}
fn today_for_timezone(timezone: DatetimeTimezone) -> NaiveDate {
use chrono::{FixedOffset, Local};
match timezone {
DatetimeTimezone::Local => Local::now().date_naive(),
DatetimeTimezone::Utc => Utc::now().date_naive(),
DatetimeTimezone::FixedOffset(offset_secs) => FixedOffset::east_opt(offset_secs)
.map(|offset| Utc::now().with_timezone(&offset).date_naive())
.unwrap_or_else(|| Utc::now().date_naive()),
}
}