mod styles;
use chrono::{Datelike, Months, NaiveDate};
use leptos::prelude::*;
use orbital_base_components::{
build_month_grid, format_unix, DatetimeFormat, DatetimeTimezone, GridDayKind,
};
use orbital_core_components::{
calendar_styles, Button, ButtonAppearance, ButtonGroup, CalendarDayProps, CalendarDayRenderer,
CalendarWeekdayHeader,
};
use orbital_macros::component_doc;
use orbital_theme::use_theme_options;
use crate::shared::{
apply_range_click, day_range_role, is_day_disabled, layout_root_classes, picker_style_sheet,
range_to_selection, selection_to_range, today_for_timezone, DayRangeRole, RangeSelection,
};
use super::field_types::{DateRangeCalendarAppearance, DateRangeCalendarBind};
use styles::date_range_calendar_styles;
use crate::DateTimeRange;
#[component_doc(
category = "Calendar & Time",
preview_slug = "date-range-calendar",
preview_label = "Date Range Calendar",
preview_icon = icondata::AiCalendarOutlined,
)]
#[component]
pub fn DateRangeCalendar(
#[prop(optional, into)]
bind: DateRangeCalendarBind,
#[prop(optional, into)]
appearance: DateRangeCalendarAppearance,
#[prop(optional, into)]
class: MaybeProp<String>,
) -> impl IntoView {
let DateRangeCalendarBind { value } = bind;
let DateRangeCalendarAppearance {
timezone,
min_date,
max_date,
disabled,
calendars,
day,
} = appearance;
let theme_options = use_theme_options();
let value = StoredValue::new(value);
let selection = RwSignal::new(RangeSelection::default());
let hover_date = RwSignal::new(None::<NaiveDate>);
let anchor_month = RwSignal::new(today_for_timezone(DatetimeTimezone::Local));
let last_external = RwSignal::new(None::<Option<DateTimeRange>>);
Effect::new(move |_| {
let current = value.with_value(|v| v.get());
if last_external.get_untracked() == Some(current.clone()) {
return;
}
last_external.set(Some(current.clone()));
selection.set(range_to_selection(current));
if let Some(start) = selection.get_untracked().start {
anchor_month.set(start);
}
});
let root_class = move || {
let mut parts = vec![layout_root_classes(theme_options.get().density)];
if let Some(extra) = class.get() {
if !extra.is_empty() {
parts.push(extra);
}
}
parts.join(" ")
};
let previous_month = move |_| {
anchor_month.update(|date| {
if let Some(prev) = date.checked_sub_months(Months::new(1)) {
*date = prev;
}
});
};
let next_month = move |_| {
anchor_month.update(|date| {
if let Some(next) = date.checked_add_months(Months::new(1)) {
*date = next;
}
});
};
view! {
<style>{date_range_calendar_styles()}</style>
<style>{calendar_styles()}</style>
<style>{picker_style_sheet()}</style>
<div class=root_class data-orbital-picker="">
<div class="orb-picker-range-calendar">
<div class="orb-picker-range-calendar__nav">
<ButtonGroup>
<Button
appearance=ButtonAppearance::Secondary
on_click=Callback::new(previous_month)
>
"Previous"
</Button>
<Button
appearance=ButtonAppearance::Secondary
on_click=Callback::new(next_month)
>
"Next"
</Button>
</ButtonGroup>
</div>
{move || {
let panel_count = calendars.get().clamp(1, 2) as usize;
let anchor = anchor_month.get();
(0..panel_count)
.map(|offset| {
let month_date = anchor
.checked_add_months(Months::new(offset as u32))
.unwrap_or(anchor);
view! {
<RangeMonthPanel
year=month_date.year()
month=month_date.month()
selection=selection
hover_date=hover_date
timezone=timezone
min_date=min_date
max_date=max_date
disabled=disabled
value=value
day=day.clone()
/>
}
})
.collect_view()
}}
</div>
</div>
}
}
#[component]
fn RangeMonthPanel(
year: i32,
month: u32,
selection: RwSignal<RangeSelection>,
hover_date: RwSignal<Option<NaiveDate>>,
timezone: Signal<DatetimeTimezone>,
min_date: Signal<Option<orbital_base_components::OrbitalDateTime>>,
max_date: Signal<Option<orbital_base_components::OrbitalDateTime>>,
disabled: Signal<bool>,
value: StoredValue<orbital_base_components::OptionBind<DateTimeRange>>,
day: Option<CalendarDayRenderer>,
) -> impl IntoView {
let month_grid = Memo::new(move |_| build_month_grid(year, month));
let day_renderer = StoredValue::new(day);
let focused_date = RwSignal::new(
NaiveDate::from_ymd_opt(year, month, 1)
.unwrap_or_else(|| today_for_timezone(DatetimeTimezone::Local)),
);
view! {
<div class="orb-picker-range-calendar__panel">
<div class="orbital-calendar">
<div class="orbital-calendar__header">
<span class="orbital-calendar__header-title">
{NaiveDate::from_ymd_opt(year, month, 1)
.map(|d| d.format("%B %Y").to_string())
.unwrap_or_default()}
</span>
</div>
<CalendarWeekdayHeader />
<div
class="orbital-calendar__dates"
role="grid"
aria-label="Date range calendar"
tabindex="-1"
>
{move || {
let tz = timezone.get();
let min = min_date.get();
let max = max_date.get();
let is_disabled_panel = disabled.get();
let current_selection = selection.get();
let hover = hover_date.get();
let today = today_for_timezone(tz);
let renderer = day_renderer.get_value();
let focus = focused_date.get();
month_grid
.get()
.into_iter()
.map(|day_cell| {
let is_other_month = day_cell.kind != GridDayKind::Current;
let is_today = day_cell.date == today;
let is_disabled =
is_disabled_panel || is_day_disabled(day_cell.date, min, max);
let role =
day_range_role(day_cell.date, current_selection, hover);
let is_selected = matches!(
role,
DayRangeRole::RangeStart
| DayRangeRole::RangeEnd
| DayRangeRole::InRange
);
let day_label = format_unix(
day_cell.unix_secs,
DatetimeFormat::IsoDate,
DatetimeTimezone::Utc,
);
let date = day_cell.date;
let on_select = Callback::new(move |_| {
if is_disabled {
return;
}
let next = apply_range_click(selection.get_untracked(), date);
selection.set(next);
focused_date.set(date);
if next.is_complete() {
if let Some(range) =
selection_to_range(next, timezone.get_untracked())
{
value.with_value(|v| v.set(Some(range)));
}
} else {
value.with_value(|v| v.set(None));
}
});
let on_focus = Callback::new(move |_| focused_date.set(date));
let on_mouse_enter = {
let date = day_cell.date;
move |_| {
if is_disabled {
return;
}
hover_date.set(Some(date));
}
};
let tabindex = if is_disabled {
"-1"
} else if focus == day_cell.date {
"0"
} else {
"-1"
};
let props = CalendarDayProps {
date,
day_number: day_cell.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 {
view! {
<div
class="orbital-calendar-item"
class=("orbital-calendar-item--other-month", is_other_month)
class=("orbital-calendar-item--today", is_today)
class=("orbital-calendar-item--disabled", is_disabled)
class=("orbital-calendar-item--range-start", role == DayRangeRole::RangeStart)
class=("orbital-calendar-item--range-end", role == DayRangeRole::RangeEnd)
class=("orbital-calendar-item--in-range", role == DayRangeRole::InRange)
class=("orbital-calendar-item--preview-range", role == DayRangeRole::PreviewRange)
role="gridcell"
tabindex=tabindex
aria-label=props.aria_label.clone()
aria-selected=is_selected
aria-disabled=is_disabled
on:click=move |_| props.on_select.run(())
on:focus=move |_| props.on_focus.run(())
on:mouseenter=on_mouse_enter
>
<div class="orbital-calendar-item__header">
<span class="orbital-calendar-item__header-day">
{day_cell.date.day()}
</span>
</div>
<div class="orbital-calendar-item__bar"></div>
</div>
}
.into_any()
}
})
.collect_view()
}}
</div>
</div>
</div>
}
}