///|
/// A normalized proleptic-Gregorian date used by `calendar`.
///
/// Construction is intentionally opaque so every value stays inside the
/// supported year range and always names a real day.
struct CalendarDate {
year : Int
month : Int
day : Int
} derive(Debug, Eq)
///|
priv struct CalendarModel {
visible : CalendarDate
selected : CalendarDate?
focused : CalendarDate
} derive(Eq)
///|
priv enum CalendarSelectionMode {
CalendarSingleSelection
CalendarRangeSelection
CalendarMultipleSelection
}
///|
#cfg(target="js")
priv enum CalendarMsg {
CalendarNavigate(Int)
CalendarChoose(CalendarDate)
CalendarSetFocused(CalendarDate)
CalendarMoveFocus(CalendarDate)
}
///|
const CalendarRootStyle : String = "display:block;width:15.5rem;max-width:100%;border-radius:var(--rui-radius,0.625rem);background:var(--rui-background,oklch(1 0 0));padding:0.75rem;color:var(--rui-foreground,oklch(0.145 0 0));--rui-calendar-cell-radius:calc(var(--rui-radius,0.625rem) - 0.125rem)"
///|
const CalendarHeaderStyle : String = "position:relative;display:flex;height:2rem;width:100%;align-items:center;justify-content:center;margin-bottom:1rem"
///|
const CalendarCaptionStyle : String = "display:flex;height:2rem;align-items:center;justify-content:center;padding-inline:2rem;font-size:0.875rem;line-height:1.25rem;font-weight:500;user-select:none"
///|
const CalendarNavStyle : String = "position:absolute;inset:0;display:flex;align-items:center;justify-content:space-between;gap:0.25rem;pointer-events:none"
///|
const CalendarNavButtonStyle : String = "display:inline-flex;width:2rem;height:2rem;align-items:center;justify-content:center;--rui-calendar-base-bg:transparent;--rui-calendar-base-fg:var(--rui-foreground,oklch(0.145 0 0));--rui-calendar-base-border:transparent;--rui-calendar-base-shadow:none;border:1px solid transparent;border-color:var(--rui-calendar-border,var(--rui-calendar-base-border,transparent));border-radius:calc(var(--rui-radius,0.625rem) - 0.25rem);margin:0;padding:0;background:var(--rui-calendar-bg,var(--rui-calendar-base-bg,transparent));color:var(--rui-calendar-fg,var(--rui-calendar-base-fg,var(--rui-foreground,oklch(0.145 0 0))));box-shadow:var(--rui-calendar-shadow,var(--rui-calendar-base-shadow,none));cursor:pointer;appearance:none;-webkit-appearance:none;pointer-events:auto;outline-offset:2px"
///|
const CalendarGridStyle : String = "width:100%;border-collapse:separate;border-spacing:0 0.5rem;table-layout:fixed;margin-block:-0.5rem"
///|
const CalendarWeekdayStyle : String = "width:2rem;height:2rem;padding:0;color:var(--rui-muted-foreground,oklch(0.556 0 0));font-size:0.8rem;line-height:1rem;font-weight:400;text-align:center;vertical-align:middle;user-select:none"
///|
const CalendarCellStyle : String = "position:relative;width:2rem;height:2rem;padding:0;text-align:center;vertical-align:middle"
///|
const CalendarCellRangeStartStyle : String = "background:linear-gradient(var(--rui-calendar-range-start-direction,to right),transparent 50%,var(--rui-accent,oklch(0.97 0 0)) 50%)"
///|
const CalendarCellRangeMiddleStyle : String = "background:var(--rui-accent,oklch(0.97 0 0))"
///|
const CalendarCellRangeEndStyle : String = "background:linear-gradient(var(--rui-calendar-range-end-direction,to right),var(--rui-accent,oklch(0.97 0 0)) 50%,transparent 50%)"
///|
const CalendarCellRangeRowStartStyle : String = "border-start-start-radius:var(--rui-calendar-cell-radius);border-end-start-radius:var(--rui-calendar-cell-radius)"
///|
const CalendarCellRangeRowEndStyle : String = "border-start-end-radius:var(--rui-calendar-cell-radius);border-end-end-radius:var(--rui-calendar-cell-radius)"
///|
const CalendarDayStyle : String = "display:inline-flex;width:2rem;height:2rem;align-items:center;justify-content:center;--rui-calendar-base-bg:transparent;--rui-calendar-base-fg:var(--rui-foreground,oklch(0.145 0 0));--rui-calendar-base-border:transparent;--rui-calendar-base-shadow:none;border:1px solid transparent;border-color:var(--rui-calendar-border,var(--rui-calendar-base-border,transparent));border-radius:var(--rui-calendar-cell-radius,calc(var(--rui-radius,0.625rem) - 0.125rem));margin:0;padding:0;background:var(--rui-calendar-bg,var(--rui-calendar-base-bg,transparent));color:var(--rui-calendar-fg,var(--rui-calendar-base-fg,var(--rui-foreground,oklch(0.145 0 0))));box-shadow:var(--rui-calendar-shadow,var(--rui-calendar-base-shadow,none));font-size:0.875rem;line-height:1rem;font-weight:400;text-align:center;cursor:pointer;user-select:none;appearance:none;-webkit-appearance:none;outline-offset:2px"
///|
const CalendarDaySelectedStyle : String = "--rui-calendar-base-border:var(--rui-primary,oklch(0.205 0 0));--rui-calendar-base-bg:var(--rui-primary,oklch(0.205 0 0));--rui-calendar-base-fg:var(--rui-primary-foreground,oklch(0.985 0 0))"
///|
const CalendarDayRangeMiddleStyle : String = "--rui-calendar-base-border:var(--rui-accent,oklch(0.97 0 0));--rui-calendar-base-bg:var(--rui-accent,oklch(0.97 0 0));--rui-calendar-base-fg:var(--rui-accent-foreground,oklch(0.205 0 0));border-radius:0"
///|
const CalendarDayTodayStyle : String = "--rui-calendar-base-bg:var(--rui-accent,oklch(0.97 0 0));--rui-calendar-base-fg:var(--rui-accent-foreground,oklch(0.205 0 0));font-weight:500"
///|
const CalendarDayOutsideStyle : String = "--rui-calendar-base-fg:var(--rui-muted-foreground,oklch(0.556 0 0));opacity:0.55"
///|
const CalendarOutsidePlaceholderStyle : String = "display:block;width:2rem;height:2rem;visibility:hidden"
///|
fn calendar_clamp(value : Int, minimum : Int, maximum : Int) -> Int {
if value < minimum {
minimum
} else if value > maximum {
maximum
} else {
value
}
}
///|
fn calendar_is_leap_year(year : Int) -> Bool {
year % 400 == 0 || (year % 4 == 0 && year % 100 != 0)
}
///|
fn calendar_days_in_month(year : Int, month : Int) -> Int {
match month {
2 => if calendar_is_leap_year(year) { 29 } else { 28 }
4 | 6 | 9 | 11 => 30
_ => 31
}
}
///|
fn calendar_first_of_month(date : CalendarDate) -> CalendarDate {
{ year: date.year, month: date.month, day: 1 }
}
///|
/// Construct a valid date. Years are clamped to `1..9999`, months to
/// `1..12`, and the day to the selected month's valid range.
pub fn calendar_date(year~ : Int, month~ : Int, day~ : Int) -> CalendarDate {
let year = calendar_clamp(year, 1, 9999)
let month = calendar_clamp(month, 1, 12)
let day = calendar_clamp(day, 1, calendar_days_in_month(year, month))
{ year, month, day }
}
///|
pub fn CalendarDate::year(self : CalendarDate) -> Int {
self.year
}
///|
pub fn CalendarDate::month(self : CalendarDate) -> Int {
self.month
}
///|
pub fn CalendarDate::day(self : CalendarDate) -> Int {
self.day
}
///|
fn calendar_pad_two(value : Int) -> String {
if value < 10 {
"0\{value}"
} else {
value.to_string()
}
}
///|
fn calendar_pad_year(value : Int) -> String {
if value < 10 {
"000\{value}"
} else if value < 100 {
"00\{value}"
} else if value < 1000 {
"0\{value}"
} else {
value.to_string()
}
}
///|
/// Return the stable `YYYY-MM-DD` representation used by data attributes.
pub fn CalendarDate::to_iso_string(self : CalendarDate) -> String {
let year = calendar_pad_year(self.year)
let month = calendar_pad_two(self.month)
let day = calendar_pad_two(self.day)
"\{year}-\{month}-\{day}"
}
///|
fn calendar_month_name(month : Int) -> String {
match month {
1 => "January"
2 => "February"
3 => "March"
4 => "April"
5 => "May"
6 => "June"
7 => "July"
8 => "August"
9 => "September"
10 => "October"
11 => "November"
_ => "December"
}
}
///|
fn calendar_weekday_short(weekday : Int) -> String {
match weekday {
0 => "Su"
1 => "Mo"
2 => "Tu"
3 => "We"
4 => "Th"
5 => "Fr"
_ => "Sa"
}
}
///|
fn calendar_weekday_long(weekday : Int) -> String {
match weekday {
0 => "Sunday"
1 => "Monday"
2 => "Tuesday"
3 => "Wednesday"
4 => "Thursday"
5 => "Friday"
_ => "Saturday"
}
}
///|
fn calendar_positive_mod(value : Int, divisor : Int) -> Int {
let remainder = value % divisor
if remainder < 0 {
remainder + divisor
} else {
remainder
}
}
///|
fn calendar_weekday(date : CalendarDate) -> Int {
let month_offset = match date.month {
1 => 0
2 => 3
3 => 2
4 => 5
5 => 0
6 => 3
7 => 5
8 => 1
9 => 4
10 => 6
11 => 2
_ => 4
}
let year = if date.month < 3 { date.year - 1 } else { date.year }
calendar_positive_mod(
year + year / 4 - year / 100 + year / 400 + month_offset + date.day,
7,
)
}
///|
fn calendar_shift_month(date : CalendarDate, delta : Int) -> CalendarDate {
if delta < 0 {
if date.month == 1 {
calendar_date(year=date.year - 1, month=12, day=1)
} else {
calendar_date(year=date.year, month=date.month - 1, day=1)
}
} else if date.month == 12 {
calendar_date(year=date.year + 1, month=1, day=1)
} else {
calendar_date(year=date.year, month=date.month + 1, day=1)
}
}
///|
fn calendar_same_month(left : CalendarDate, right : CalendarDate) -> Bool {
left.year == right.year && left.month == right.month
}
///|
fn calendar_compare_dates(left : CalendarDate, right : CalendarDate) -> Int {
if left.year != right.year {
left.year.compare(right.year)
} else if left.month != right.month {
left.month.compare(right.month)
} else {
left.day.compare(right.day)
}
}
///|
fn calendar_previous_day(date : CalendarDate) -> CalendarDate {
if date.day > 1 {
calendar_date(year=date.year, month=date.month, day=date.day - 1)
} else if date.year == 1 && date.month == 1 {
date
} else {
let previous = calendar_shift_month(date, -1)
calendar_date(
year=previous.year,
month=previous.month,
day=calendar_days_in_month(previous.year, previous.month),
)
}
}
///|
fn calendar_next_day(date : CalendarDate) -> CalendarDate {
let last = calendar_days_in_month(date.year, date.month)
if date.day < last {
calendar_date(year=date.year, month=date.month, day=date.day + 1)
} else if date.year == 9999 && date.month == 12 {
date
} else {
calendar_shift_month(date, 1)
}
}
///|
fn calendar_shift_days(date : CalendarDate, delta : Int) -> CalendarDate {
let mut next = date
let mut remaining = if delta < 0 { -delta } else { delta }
while remaining > 0 {
next = if delta < 0 {
calendar_previous_day(next)
} else {
calendar_next_day(next)
}
remaining = remaining - 1
}
next
}
///|
fn calendar_shift_month_preserving_day(
date : CalendarDate,
delta : Int,
) -> CalendarDate {
let shifted = calendar_shift_month(date, delta)
calendar_date(year=shifted.year, month=shifted.month, day=date.day)
}
///|
fn calendar_shift_year(date : CalendarDate, delta : Int) -> CalendarDate {
calendar_date(year=date.year + delta, month=date.month, day=date.day)
}
///|
fn calendar_cell_date(month : CalendarDate, cell : Int) -> (CalendarDate, Bool) {
let first_weekday = calendar_weekday(calendar_first_of_month(month))
let current_days = calendar_days_in_month(month.year, month.month)
let day = cell - first_weekday + 1
if day < 1 {
let previous = calendar_shift_month(month, -1)
let previous_days = calendar_days_in_month(previous.year, previous.month)
(
calendar_date(
year=previous.year,
month=previous.month,
day=previous_days + day,
),
true,
)
} else if day > current_days {
let next = calendar_shift_month(month, 1)
(
calendar_date(year=next.year, month=next.month, day=day - current_days),
true,
)
} else {
(calendar_date(year=month.year, month=month.month, day~), false)
}
}
///|
fn calendar_date_label(date : CalendarDate) -> String {
let month = calendar_month_name(date.month)
"\{month} \{date.day}, \{date.year}"
}
///|
fn calendar_initial_model(
default_month : CalendarDate?,
default_selected : CalendarDate?,
today : CalendarDate?,
) -> CalendarModel {
let visible = if default_month is Some(date) {
calendar_first_of_month(date)
} else if default_selected is Some(date) {
calendar_first_of_month(date)
} else if today is Some(date) {
calendar_first_of_month(date)
} else {
calendar_date(year=1970, month=1, day=1)
}
let focused = calendar_focus_target(visible, default_selected, today)
{ visible, selected: default_selected, focused }
}
///|
fn calendar_focus_target(
visible : CalendarDate,
selected : CalendarDate?,
today : CalendarDate?,
) -> CalendarDate {
if selected is Some(date) && calendar_same_month(date, visible) {
date
} else if today is Some(date) && calendar_same_month(date, visible) {
date
} else {
calendar_first_of_month(visible)
}
}
///|
#cfg(target="js")
fn[T] calendar_emit_command(emit : @cmd.Emit[T]?, value : T) -> @cmd.Cmd {
if emit is Some(emit) {
emit(value)
} else {
@cmd.none
}
}
///|
#cfg(target="js")
fn calendar_event_is_rtl(event : @dom.KeyboardEvent) -> Bool {
if event.target().to_element() is Some(target) {
ui_element_is_rtl(target)
} else {
false
}
}
///|
#cfg(target="js")
fn focus_calendar_day(root_id : String, iso_date : String) -> Unit {
let calendar_selector = "[data-slot=\"calendar\"]"
let day_selector = "[data-slot=\"calendar-day-button\"]"
fn is_calendar(root : @dom.Element) -> Bool {
root.get_attribute("data-slot").unwrap_or("") == "calendar" &&
root.get_is_connected()
}
fn days_in(root : @dom.Element) -> Array[@dom.Element] {
root.query_selector_all(day_selector)
}
fn target_in(root : @dom.Element) -> @dom.Element? {
for day in days_in(root) {
if day.get_attribute("data-day").unwrap_or("") == iso_date &&
ui_element_is_enabled(day) {
return Some(day)
}
}
None
}
fn focus_in(root : @dom.Element) -> Bool {
let days = days_in(root)
guard target_in(root) is Some(target) else { return false }
for day in days {
ui_set_element_tab_index(
day,
if day.is_same_node(target.as_node()) {
0
} else {
-1
},
)
}
ui_focus_element(target)
true
}
if root_id != "" {
if ui_element_by_id(root_id) is Some(root) && is_calendar(root) {
ignore(focus_in(root))
}
return
}
let document = @dom.document()
if document.get_active_element() is Some(active) &&
active.closest(calendar_selector) is Some(root) &&
is_calendar(root) &&
focus_in(root) {
return
}
let matching : Array[@dom.Element] = []
let roots = document.query_selector_all(calendar_selector)
for index in 0.. @cmd.Cmd {
@cmd.custom_cmd(kind=@cmd.AfterRender, _ => {
focus_calendar_day(root_id.unwrap_or(""), date.to_iso_string())
})
}
///|
#cfg(not(target="js"))
fn calendar_focus_day_cmd(root_id : String?, date : CalendarDate) -> @cmd.Cmd {
ignore((root_id, date))
@cmd.none
}
///|
#cfg(target="js")
fn calendar_key_target(
date : CalendarDate,
event : @dom.KeyboardEvent,
) -> CalendarDate? {
let key = event.key()
let shift = event.shift_key()
let rtl = calendar_event_is_rtl(event)
match key {
"ArrowLeft" =>
Some(
if shift {
calendar_shift_month_preserving_day(date, if rtl { 1 } else { -1 })
} else {
calendar_shift_days(date, if rtl { 1 } else { -1 })
},
)
"ArrowRight" =>
Some(
if shift {
calendar_shift_month_preserving_day(date, if rtl { -1 } else { 1 })
} else {
calendar_shift_days(date, if rtl { -1 } else { 1 })
},
)
"ArrowUp" =>
Some(
if shift {
calendar_shift_year(date, -1)
} else {
calendar_shift_days(date, -7)
},
)
"ArrowDown" =>
Some(
if shift {
calendar_shift_year(date, 1)
} else {
calendar_shift_days(date, 7)
},
)
"PageUp" =>
Some(
if shift {
calendar_shift_year(date, -1)
} else {
calendar_shift_month_preserving_day(date, -1)
},
)
"PageDown" =>
Some(
if shift {
calendar_shift_year(date, 1)
} else {
calendar_shift_month_preserving_day(date, 1)
},
)
"Home" => Some(calendar_shift_days(date, -calendar_weekday(date)))
"End" => Some(calendar_shift_days(date, 6 - calendar_weekday(date)))
_ => None
}
}
///|
#cfg(target="js")
fn calendar_bind_day_interaction(
attrs : @html.Attrs,
root_id : String?,
date : CalendarDate,
focused : Bool,
on_focus_change : @cmd.Emit[CalendarDate]?,
on_focus_move : @cmd.Emit[CalendarDate]?,
) -> Unit {
ignore(attrs.tabindex(if focused { 0 } else { -1 }))
if on_focus_change is Some(emit) {
ignore(attrs.on_focus(_ => emit(date)))
}
ignore(
attrs.on_keydown(event => {
if calendar_key_target(date, event) is Some(next) {
event.prevent_default()
event.stop_propagation()
if on_focus_move is Some(emit) {
@cmd.batch([emit(next), calendar_focus_day_cmd(root_id, next)])
} else {
calendar_focus_day_cmd(root_id, next)
}
} else {
@cmd.none
}
}),
)
}
///|
#cfg(not(target="js"))
fn calendar_bind_day_interaction(
attrs : @html.Attrs,
root_id : String?,
date : CalendarDate,
focused : Bool,
on_focus_change : @cmd.Emit[CalendarDate]?,
on_focus_move : @cmd.Emit[CalendarDate]?,
) -> Unit {
ignore((root_id, date, on_focus_change, on_focus_move))
ignore(attrs.tabindex(if focused { 0 } else { -1 }))
}
///|
fn calendar_nav_button(
direction : String,
label : String,
disabled : Bool,
command : @cmd.Cmd?,
attrs : @html.Attrs?,
style : Array[String],
) -> @html.Html {
let element_attrs = ui_attrs(attrs)
.data_set("slot", "calendar-\{direction}")
.data_set("direction", direction)
.aria_label(label)
.aria_disabled(ui_bool(disabled))
if disabled {
ignore(element_attrs.data_set("disabled", ""))
} else if command is Some(command) {
ignore(element_attrs.on_click(_ => command))
}
let chevron = if direction == "previous" {
ui_chevron_left_icon()
} else {
ui_chevron_right_icon()
}
@html.button(
style=ui_styles(
[
UiBoxSizing,
UiFontSans,
UiTextRendering,
UiTransition,
CalendarNavButtonStyle,
ui_disabled_style(disabled),
],
style,
),
type_="button",
disabled~,
attrs=element_attrs,
chevron,
)
}
///|
fn calendar_day_button_impl(
root_id : String?,
date~ : CalendarDate,
selected? : Bool = false,
multiple? : Bool = false,
range_start? : Bool = false,
range_end? : Bool = false,
range_middle? : Bool = false,
today? : Bool = false,
outside? : Bool = false,
disabled? : Bool = false,
focused? : Bool = false,
on_select? : @cmd.Emit[CalendarDate],
on_focus_change? : @cmd.Emit[CalendarDate],
on_focus_move? : @cmd.Emit[CalendarDate],
id? : String,
class? : String,
title? : String,
attrs? : @html.Attrs,
style? : Array[String] = [],
) -> @html.Html {
let iso_date = date.to_iso_string()
let element_attrs = ui_attrs(attrs)
.data_set("slot", "calendar-day-button")
.data_set("day", iso_date)
.data_set("selected", ui_bool(selected))
.data_set("outside", ui_bool(outside))
.aria_label(calendar_date_label(date))
.aria_selected(ui_bool(selected))
.aria_disabled(ui_bool(disabled))
if selected && !range_start && !range_end && !range_middle {
if multiple {
ignore(element_attrs.data_set("selected-multiple", ""))
} else {
ignore(element_attrs.data_set("selected-single", "true"))
}
}
if range_start {
ignore(element_attrs.data_set("range-start", ""))
}
if range_end {
ignore(element_attrs.data_set("range-end", ""))
}
if range_middle {
ignore(element_attrs.data_set("range-middle", ""))
}
if today {
ignore(element_attrs.data_set("today", "").aria_current("date"))
}
if focused && !disabled {
ignore(element_attrs.data_set("focused", ""))
}
if outside {
ignore(element_attrs.data_set("outside-day", ""))
}
if disabled {
ignore(element_attrs.data_set("disabled", ""))
} else if on_select is Some(emit) {
ignore(element_attrs.on_click(_ => emit(date)))
}
calendar_bind_day_interaction(
element_attrs,
root_id,
date,
focused && !disabled,
if disabled {
None
} else {
on_focus_change
},
if disabled {
None
} else {
on_focus_move
},
)
@html.button(
style=ui_styles(
[
UiBoxSizing,
UiFontSans,
UiTextRendering,
UiTransition,
CalendarDayStyle,
if outside {
CalendarDayOutsideStyle
} else {
""
},
if today && !selected {
CalendarDayTodayStyle
} else {
""
},
if selected {
if range_middle {
CalendarDayRangeMiddleStyle
} else {
CalendarDaySelectedStyle
}
} else {
""
},
ui_disabled_style(disabled),
],
style,
),
id?,
class?,
title?,
type_="button",
disabled~,
attrs=element_attrs,
date.day.to_string(),
)
}
///|
/// Render the native day button used by the Calendar state renderer.
///
/// Dates remain native buttons while the component supplies the roving tab
/// stop and DayPicker-compatible arrow, Home/End, and PageUp/PageDown movement.
pub fn calendar_day_button(
date~ : CalendarDate,
selected? : Bool = false,
today? : Bool = false,
outside? : Bool = false,
disabled? : Bool = false,
focused? : Bool = false,
on_select? : @cmd.Emit[CalendarDate],
on_focus_change? : @cmd.Emit[CalendarDate],
on_focus_move? : @cmd.Emit[CalendarDate],
id? : String,
class? : String,
title? : String,
attrs? : @html.Attrs,
style? : Array[String] = [],
) -> @html.Html {
calendar_day_button_impl(
None,
date~,
selected~,
multiple=false,
range_start=false,
range_end=false,
range_middle=false,
today~,
outside~,
disabled~,
focused~,
on_select?,
on_focus_change?,
on_focus_move?,
id?,
class?,
title?,
attrs?,
style~,
)
}
///|
fn calendar_weekday_header(weekday : Int, style : Array[String]) -> @html.Html {
@html.th(
style=ui_styles([UiBoxSizing, CalendarWeekdayStyle], style),
abbr=calendar_weekday_long(weekday),
scope=Col,
attrs=@html.Attrs::build()
.data_set("slot", "calendar-weekday")
.data_set("weekday", weekday.to_string()),
calendar_weekday_short(weekday),
)
}
///|
fn calendar_day_cell(
root_id : String?,
month : CalendarDate,
cell : Int,
selection_mode : CalendarSelectionMode,
selected_date : CalendarDate?,
selected_dates : Array[CalendarDate]?,
range_start : CalendarDate?,
range_end : CalendarDate?,
today_date : CalendarDate?,
focused_date : CalendarDate,
show_outside_days : Bool,
hide_outside_before : Bool,
hide_outside_after : Bool,
disabled : Bool,
on_select : @cmd.Emit[CalendarDate]?,
on_focus_change : @cmd.Emit[CalendarDate]?,
on_focus_move : @cmd.Emit[CalendarDate]?,
day_attrs : @html.Attrs?,
cell_style : Array[String],
day_style : Array[String],
) -> @html.Html {
let (date, outside) = calendar_cell_date(month, cell)
let outside_before = outside && calendar_compare_dates(date, month) < 0
let outside_visible = show_outside_days &&
!(outside_before && hide_outside_before) &&
!(!outside_before && outside && hide_outside_after)
let date_visible = !outside || outside_visible
let is_range_start = date_visible && range_start == Some(date)
let is_range_end = date_visible && range_end == Some(date)
let is_range_middle = date_visible &&
(if range_start is Some(start) && range_end is Some(end) {
calendar_compare_dates(date, start) > 0 &&
calendar_compare_dates(date, end) < 0
} else {
false
})
let is_multiple_selected = if selected_dates is Some(dates) {
calendar_contains_date(dates, date)
} else {
false
}
let is_selected = date_visible &&
(
selected_date == Some(date) ||
is_multiple_selected ||
is_range_start ||
is_range_end ||
is_range_middle
)
let is_today = today_date == Some(date)
let is_focused = focused_date == date
let cell_attrs = @html.Attrs::build()
.data_set("slot", "calendar-day")
.data_set("outside", ui_bool(outside))
.data_set("selected", ui_bool(is_selected))
if is_range_start {
ignore(cell_attrs.data_set("range-start", ""))
}
if is_range_end {
ignore(cell_attrs.data_set("range-end", ""))
}
if is_range_middle {
ignore(cell_attrs.data_set("range-middle", ""))
}
let range_cell_style = if is_range_middle {
if cell % 7 == 0 {
CalendarCellRangeMiddleStyle + ";" + CalendarCellRangeRowStartStyle
} else if cell % 7 == 6 {
CalendarCellRangeMiddleStyle + ";" + CalendarCellRangeRowEndStyle
} else {
CalendarCellRangeMiddleStyle
}
} else if is_range_start && !is_range_end && range_end is Some(_) {
CalendarCellRangeStartStyle
} else if is_range_end && !is_range_start {
CalendarCellRangeEndStyle
} else {
""
}
@html.td(
style=ui_styles(
[UiBoxSizing, CalendarCellStyle, range_cell_style],
cell_style,
),
attrs=cell_attrs,
if outside && !outside_visible {
@html.span(
style=[UiBoxSizing, CalendarOutsidePlaceholderStyle],
attrs=@html.Attrs::build().aria_hidden("true"),
@html.nothing,
)
} else {
let attrs = day_attrs
calendar_day_button_impl(
root_id,
date~,
selected=is_selected,
multiple=selection_mode is CalendarMultipleSelection,
range_start=is_range_start,
range_end=is_range_end,
range_middle=is_range_middle,
today=is_today,
outside~,
disabled~,
focused=is_focused,
on_select?,
on_focus_change?,
on_focus_move?,
attrs?,
style=day_style,
)
},
)
}
///|
/// Render one fully composed Calendar state for the incremental wrapper.
///
/// The table and button semantics work without a client runtime. `attrs` and
/// every part-level attribute set are cloned before component data is added.
fn render_calendar(
month~ : CalendarDate,
selection_mode? : CalendarSelectionMode = CalendarSingleSelection,
selected? : CalendarDate,
selected_dates? : Array[CalendarDate],
range_start? : CalendarDate,
range_end? : CalendarDate,
today? : CalendarDate,
focused? : CalendarDate,
show_outside_days? : Bool = true,
hide_outside_before? : Bool = false,
hide_outside_after? : Bool = false,
disabled? : Bool = false,
aria_label? : String = "Calendar",
on_previous? : @cmd.Cmd,
on_next? : @cmd.Cmd,
on_select? : @cmd.Emit[CalendarDate],
on_focus_change? : @cmd.Emit[CalendarDate],
on_focus_move? : @cmd.Emit[CalendarDate],
show_previous? : Bool = true,
show_next? : Bool = true,
id? : String,
class? : String,
title? : String,
hidden? : Bool,
attrs? : @html.Attrs,
previous_attrs? : @html.Attrs,
next_attrs? : @html.Attrs,
grid_attrs? : @html.Attrs,
day_attrs? : @html.Attrs,
style? : Array[String] = [],
header_style? : Array[String] = [],
caption_style? : Array[String] = [],
nav_style? : Array[String] = [],
previous_style? : Array[String] = [],
next_style? : Array[String] = [],
grid_style? : Array[String] = [],
weekday_style? : Array[String] = [],
cell_style? : Array[String] = [],
day_style? : Array[String] = [],
) -> @html.Html {
let month = calendar_first_of_month(month)
let focused = focused.unwrap_or(calendar_focus_target(month, selected, today))
let caption = "\{calendar_month_name(month.month)} \{month.year}"
let caption_id = id.map(id => "\{id}-caption")
let previous_disabled = disabled || (month.year == 1 && month.month == 1)
let next_disabled = disabled || (month.year == 9999 && month.month == 12)
let root_attrs = ui_attrs(attrs)
.data_set("slot", "calendar")
.data_set("mode", calendar_selection_mode_name(selection_mode))
.data_set("month", month.to_iso_string())
.role("group")
.aria_label(aria_label)
.aria_disabled(ui_bool(disabled))
if range_start is Some(start) {
ignore(root_attrs.data_set("range-start", start.to_iso_string()))
}
if range_end is Some(end) {
ignore(root_attrs.data_set("range-end", end.to_iso_string()))
}
if selected_dates is Some(dates) {
ignore(root_attrs.data_set("selected-count", dates.length().to_string()))
}
if disabled {
ignore(root_attrs.data_set("disabled", ""))
}
let table_attrs = ui_attrs(grid_attrs)
.data_set("slot", "calendar-grid")
.role("grid")
if selection_mode is CalendarRangeSelection ||
selection_mode is CalendarMultipleSelection {
ignore(table_attrs.aria_multiselectable("true"))
}
if caption_id is Some(caption_id) {
ignore(table_attrs.aria_labelledby(caption_id))
} else {
ignore(table_attrs.aria_label(caption))
}
let weekdays = Array::makei(7, weekday => {
calendar_weekday_header(weekday, weekday_style)
})
let weeks = Array::makei(6, week => {
let days = Array::makei(7, weekday => {
calendar_day_cell(
id,
month,
week * 7 + weekday,
selection_mode,
selected,
selected_dates,
range_start,
range_end,
today,
focused,
show_outside_days,
hide_outside_before,
hide_outside_after,
disabled,
on_select,
on_focus_change,
on_focus_move,
day_attrs,
cell_style,
day_style,
)
})
@html.tr(
attrs=@html.Attrs::build()
.data_set("slot", "calendar-week")
.data_set("week", week.to_string()),
days,
)
})
let caption_node = {
let id = caption_id
@html.span(
style=ui_styles([UiBoxSizing, CalendarCaptionStyle], caption_style),
id?,
attrs=@html.Attrs::build().data_set("slot", "calendar-caption"),
caption,
)
}
let nav_items : Array[@html.Html] = []
if show_previous {
nav_items.push(
calendar_nav_button(
"previous", "Go to previous month", previous_disabled, on_previous, previous_attrs,
previous_style,
),
)
}
if show_next {
nav_items.push(
calendar_nav_button(
"next", "Go to next month", next_disabled, on_next, next_attrs, next_style,
),
)
}
@html.div(
style=ui_styles(
[UiBoxSizing, UiFontSans, UiTextRendering, CalendarRootStyle],
style,
),
id?,
class?,
title?,
hidden?,
attrs=root_attrs,
[
@html.div(
style=ui_styles([UiBoxSizing, CalendarHeaderStyle], header_style),
attrs=@html.Attrs::build().data_set("slot", "calendar-header"),
[
caption_node,
@html.div(
style=ui_styles([UiBoxSizing, CalendarNavStyle], nav_style),
attrs=@html.Attrs::build()
.data_set("slot", "calendar-nav")
.aria_label("Month navigation"),
nav_items,
),
],
),
@html.table(
style=ui_styles([UiBoxSizing, CalendarGridStyle], grid_style),
attrs=table_attrs,
[@html.thead(@html.tr(weekdays)), @html.tbody(weeks)],
),
],
)
}
///|
#cfg(target="js")
fn calendar_update(
root_id : String?,
on_select : @cmd.Emit[CalendarDate]?,
on_month_change : @cmd.Emit[CalendarDate]?,
today : CalendarDate?,
msg : CalendarMsg,
model : CalendarModel,
) -> (CalendarModel, @cmd.Cmd) {
match msg {
CalendarNavigate(delta) => {
let visible = calendar_shift_month(model.visible, delta)
let focused = calendar_focus_target(visible, model.selected, today)
(
{ ..model, visible, focused },
calendar_emit_command(on_month_change, visible),
)
}
CalendarChoose(date) => {
let visible = calendar_first_of_month(date)
let commands : Array[@cmd.Cmd] = [calendar_emit_command(on_select, date)]
if visible != model.visible {
commands.push(calendar_emit_command(on_month_change, visible))
}
({ visible, selected: Some(date), focused: date }, @cmd.batch(commands))
}
CalendarSetFocused(date) => ({ ..model, focused: date }, @cmd.none)
CalendarMoveFocus(date) => {
let visible = calendar_first_of_month(date)
let commands : Array[@cmd.Cmd] = [calendar_focus_day_cmd(root_id, date)]
if visible != model.visible {
commands.push(calendar_emit_command(on_month_change, visible))
}
({ ..model, visible, focused: date }, @cmd.batch(commands))
}
}
}
///|
/// Stateful single-date calendar with Rabbita-owned visible-month and
/// selection state.
///
/// When no initial month, selected date, or `today` is supplied, the stable
/// SSR fallback is January 1970. Supplying `today` both selects the initial
/// visible month and enables the current-day treatment.
#cfg(target="js")
pub fn calendar(
default_month? : CalendarDate,
default_selected? : CalendarDate,
today? : CalendarDate,
show_outside_days? : Bool = true,
disabled? : Bool = false,
aria_label? : String = "Calendar",
on_select? : @cmd.Emit[CalendarDate],
on_month_change? : @cmd.Emit[CalendarDate],
id? : String,
class? : String,
title? : String,
hidden? : Bool,
attrs? : @html.Attrs,
previous_attrs? : @html.Attrs,
next_attrs? : @html.Attrs,
grid_attrs? : @html.Attrs,
day_attrs? : @html.Attrs,
style? : Array[String] = [],
header_style? : Array[String] = [],
caption_style? : Array[String] = [],
nav_style? : Array[String] = [],
previous_style? : Array[String] = [],
next_style? : Array[String] = [],
grid_style? : Array[String] = [],
weekday_style? : Array[String] = [],
cell_style? : Array[String] = [],
day_style? : Array[String] = [],
) -> @rabbita.Val[@html.Html] {
let initial = calendar_initial_model(default_month, default_selected, today)
let (model, emit) = @rabbita.create_state(initial, update=fn(_, msg, model) {
calendar_update(id, on_select, on_month_change, today, msg, model)
})
let select = emit.map(date => CalendarChoose(date))
let set_focused = emit.map(date => CalendarSetFocused(date))
let move_focus = emit.map(date => CalendarMoveFocus(date))
model.view(model => {
let selected = model.selected
render_calendar(
month=model.visible,
selected?,
today?,
focused=model.focused,
show_outside_days~,
disabled~,
aria_label~,
on_previous=emit(CalendarNavigate(-1)),
on_next=emit(CalendarNavigate(1)),
on_select=select,
on_focus_change=set_focused,
on_focus_move=move_focus,
id?,
class?,
title?,
hidden?,
attrs?,
previous_attrs?,
next_attrs?,
grid_attrs?,
day_attrs?,
style~,
header_style~,
caption_style~,
nav_style~,
previous_style~,
next_style~,
grid_style~,
weekday_style~,
cell_style~,
day_style~,
)
})
}
///|
/// Native/SSR fallback for `calendar`; renders the same requested initial
/// month and selection as a constant incremental value.
#cfg(not(target="js"))
pub fn calendar(
default_month? : CalendarDate,
default_selected? : CalendarDate,
today? : CalendarDate,
show_outside_days? : Bool = true,
disabled? : Bool = false,
aria_label? : String = "Calendar",
on_select? : @cmd.Emit[CalendarDate],
on_month_change? : @cmd.Emit[CalendarDate],
id? : String,
class? : String,
title? : String,
hidden? : Bool,
attrs? : @html.Attrs,
previous_attrs? : @html.Attrs,
next_attrs? : @html.Attrs,
grid_attrs? : @html.Attrs,
day_attrs? : @html.Attrs,
style? : Array[String] = [],
header_style? : Array[String] = [],
caption_style? : Array[String] = [],
nav_style? : Array[String] = [],
previous_style? : Array[String] = [],
next_style? : Array[String] = [],
grid_style? : Array[String] = [],
weekday_style? : Array[String] = [],
cell_style? : Array[String] = [],
day_style? : Array[String] = [],
) -> @rabbita.Val[@html.Html] {
ignore((on_select, on_month_change))
let model = calendar_initial_model(default_month, default_selected, today)
let select : @cmd.Emit[CalendarDate] = Emit(_ => @cmd.none)
let selected = model.selected
@rabbita.Val::constant(
render_calendar(
month=model.visible,
selected?,
today?,
focused=model.focused,
show_outside_days~,
disabled~,
aria_label~,
on_previous=@cmd.none,
on_next=@cmd.none,
on_select=select,
id?,
class?,
title?,
hidden?,
attrs?,
previous_attrs?,
next_attrs?,
grid_attrs?,
day_attrs?,
style~,
header_style~,
caption_style~,
nav_style~,
previous_style~,
next_style~,
grid_style~,
weekday_style~,
cell_style~,
day_style~,
),
)
}