use crate::components::input::calendar_date::CalendarDate;
use crate::mixins::labelled_rect_mixin::labelled_rect_mixin;
use crate::prelude::ValidationResult;
use crate::theme::prelude::*;
use crate::utils::component_id;
use dominator::{clone, events, html, svg, Dom, EventOptions};
use dwind::prelude::*;
use futures_signals::map_ref;
use futures_signals::signal::{Mutable, SignalExt};
use futures_signals_component_macro::component;
use std::rc::Rc;
#[component(render_fn = date_picker)]
struct DatePicker {
#[signal]
#[default(None)]
value: Option<CalendarDate>,
#[signal]
#[default("".to_string())]
label: String,
#[signal]
#[default(false)]
disabled: bool,
#[default(Box::new(|_| {}))]
on_change: dyn Fn(CalendarDate) + 'static,
}
pub fn date_picker(props: DatePickerProps) -> Dom {
let DatePickerProps {
value,
label,
disabled,
on_change,
apply,
} = props;
let value = value.broadcast();
let label = label.broadcast();
let disabled = disabled.broadcast();
let on_change = Rc::new(on_change);
let open = Mutable::new(false);
let value_state: Mutable<Option<CalendarDate>> = Mutable::new(None);
let view = Mutable::new((CalendarDate::today().year, CalendarDate::today().month));
let focused_day = Mutable::new(CalendarDate::today());
let field_id = component_id("date-picker");
let open_picker = Rc::new(clone!(open, value_state, view, focused_day => move || {
let initial = value_state.get().unwrap_or_else(CalendarDate::today);
view.set((initial.year, initial.month));
focused_day.set(initial);
open.set(true);
}));
let has_value = value.signal_ref(|v| v.is_some()).broadcast();
html!("div", {
.dwclass!("grid")
.future(value.signal().for_each(clone!(value_state => move |v| {
value_state.set(v);
async {}
})))
.child(html!("button", {
.attr("type", "button")
.attr("id", &field_id)
.attr("aria-haspopup", "dialog")
.attr_signal("aria-expanded", open.signal().map(|v| if v { "true" } else { "false" }))
.attr_signal("disabled", disabled.signal().map(|v| if v { Some("disabled") } else { None }))
.dwclass!("dwui-bg-void-900 is(.light *):dwui-bg-void-300 text-base h-10 p-l-2 p-r-2")
.dwclass!("grid-col-1 grid-row-1 cursor-pointer rounded-t-sm transition-all border-none text-left")
.dwclass!("dwui-text-on-primary-300 is(.light *):dwui-text-on-primary-900")
.dwclass!("disabled:cursor-not-allowed disabled:opacity-60")
.dwclass!("flex flex-row align-items-center justify-between gap-2")
.style("outline", "none")
.child(html!("span", {
.text_signal(value.signal().map(|v| {
v.map(|v| v.to_string()).unwrap_or_default()
}))
}))
.child(svg!("svg", {
.attr("viewBox", "0 0 16 16")
.attr("width", "16")
.attr("height", "16")
.attr("fill", "none")
.attr("aria-hidden", "true")
.child(svg!("rect", {
.attr("x", "2")
.attr("y", "3")
.attr("width", "12")
.attr("height", "11")
.attr("rx", "1.5")
.attr("stroke", "currentColor")
.attr("stroke-width", "1.2")
}))
.child(svg!("path", {
.attr("d", "M2 6.5 H14 M5.5 1.5 V4 M10.5 1.5 V4")
.attr("stroke", "currentColor")
.attr("stroke-width", "1.2")
.attr("stroke-linecap", "round")
}))
}))
.event(clone!(open, open_picker => move |_: events::Click| {
if open.get() {
open.set(false);
} else {
(open_picker)();
}
}))
}))
.child(html!("div", {
.dwclass!("grid-col-1 grid-row-1")
.style("position", "relative")
.style("pointer-events", "none")
.child_signal(open.signal().map(clone!(view, focused_day, value_state, on_change, open, field_id => move |is_open| {
if !is_open {
return None;
}
Some(html!("div", {
.child(html!("div", {
.style("position", "fixed")
.style("inset", "0")
.style("z-index", "40")
.style("pointer-events", "auto")
.event(clone!(open => move |_: events::Click| {
open.set(false);
}))
}))
.child(calendar_popup(
&field_id,
view.clone(),
focused_day.clone(),
value_state.clone(),
on_change.clone(),
open.clone(),
))
}))
})))
}))
.apply(labelled_rect_mixin(
label.signal_cloned(),
map_ref! {
let has_value = has_value.signal(),
let open = open.signal() => *has_value || *open
},
futures_signals::signal::always(ValidationResult::Valid),
field_id.clone(),
))
.apply_if(apply.is_some(), |b| b.apply(apply.unwrap()))
})
}
fn calendar_popup(
field_id: &str,
view: Mutable<(i32, u32)>,
focused_day: Mutable<CalendarDate>,
value_state: Mutable<Option<CalendarDate>>,
on_change: Rc<Box<dyn Fn(CalendarDate) + 'static>>,
open: Mutable<bool>,
) -> Dom {
let picker_id = field_id.to_string();
let today = CalendarDate::today();
let pick = Rc::new(clone!(on_change, open => move |date: CalendarDate| {
(on_change)(date);
open.set(false);
}));
let move_focus = Rc::new(clone!(focused_day, view => move |target: CalendarDate| {
view.set((target.year, target.month));
focused_day.set(target);
}));
html!("div", {
.attr("role", "dialog")
.attr("aria-label", "Choose date")
.dwclass!("rounded-lg border shadow-2xl p-4 w-72")
.dwclass!("dwui-bg-void-900 dwui-border-void-700")
.dwclass!("is(.light *):dwui-bg-void-100 is(.light *):dwui-border-void-300")
.style("position", "absolute")
.style("top", "calc(100% + 6px)")
.style("left", "0")
.style("z-index", "50")
.style("pointer-events", "auto")
.style("animation", "dwui-modal-in 150ms ease-out")
.global_event(clone!(open => move |e: events::KeyDown| {
if e.key() == "Escape" {
open.set(false);
}
}))
.child(html!("div", {
.dwclass!("flex flex-row align-items-center justify-between m-b-2")
.child(month_nav_button("Previous month", "M9 3 L5 7 L9 11", clone!(view => move || {
let (y, m) = view.get();
view.set(CalendarDate::prev_month(y, m));
})))
.child(html!("div", {
.attr("aria-live", "polite")
.dwclass!("font-bold text-sm")
.dwclass!("dwui-text-on-primary-100 is(.light *):dwui-text-on-primary-900")
.text_signal(view.signal().map(|(y, m)| {
format!("{} {}", CalendarDate::month_name(m), y)
}))
}))
.child(month_nav_button("Next month", "M5 3 L9 7 L5 11", clone!(view => move || {
let (y, m) = view.get();
view.set(CalendarDate::next_month(y, m));
})))
}))
.child(html!("div", {
.dwclass!("grid grid-cols-7 m-b-1")
.children(["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"].map(|day| {
html!("div", {
.attr("aria-hidden", "true")
.class("font-code")
.dwclass!("text-xs text-center p-1 select-none")
.dwclass!("dwui-text-on-primary-500 is(.light *):dwui-text-on-primary-600")
.text(day)
})
}))
}))
.child(html!("div", {
.attr("role", "grid")
.attr("aria-label", "Calendar")
.children_signal_vec(view.signal().map(clone!(picker_id, focused_day, value_state, pick, move_focus => move |(year, month)| {
let first = CalendarDate::new(year, month, 1);
let grid_start = first.add_days(-(first.day_of_week() as i32));
(0..6).map(|week| {
html!("div", {
.attr("role", "row")
.dwclass!("grid grid-cols-7")
.children((0..7).map(|weekday| {
let date = grid_start.add_days(week * 7 + weekday);
let in_month = date.month == month;
day_cell(
&picker_id,
date,
in_month,
today,
focused_day.clone(),
value_state.clone(),
pick.clone(),
move_focus.clone(),
)
}).collect::<Vec<_>>())
})
}).collect::<Vec<_>>()
})).to_signal_vec())
}))
})
}
fn month_nav_button(label: &str, path: &str, on_click: impl Fn() + 'static) -> Dom {
html!("button", {
.attr("type", "button")
.attr("aria-label", label)
.dwclass!("w-8 h-8 flex align-items-center justify-center rounded-md cursor-pointer")
.dwclass!("bg-transparent border-none transition-colors")
.dwclass!("dwui-text-on-primary-300 hover:dwui-bg-void-800 hover:dwui-text-on-primary-50")
.dwclass!("is(.light *):dwui-text-on-primary-700 is(.light *):hover:dwui-bg-void-200")
.dwclass!("focus-visible:ring-2 focus-visible:dwui-ring-primary-400")
.style("outline", "none")
.child(svg!("svg", {
.attr("viewBox", "0 0 14 14")
.attr("width", "14")
.attr("height", "14")
.attr("fill", "none")
.attr("aria-hidden", "true")
.child(svg!("path", {
.attr("d", path)
.attr("stroke", "currentColor")
.attr("stroke-width", "1.5")
.attr("stroke-linecap", "round")
.attr("stroke-linejoin", "round")
}))
}))
.event(move |_: events::Click| {
on_click();
})
})
}
#[allow(clippy::too_many_arguments)]
fn day_cell(
picker_id: &str,
date: CalendarDate,
in_month: bool,
today: CalendarDate,
focused_day: Mutable<CalendarDate>,
value_state: Mutable<Option<CalendarDate>>,
pick: Rc<impl Fn(CalendarDate) + 'static>,
move_focus: Rc<impl Fn(CalendarDate) + 'static>,
) -> Dom {
let is_selected = value_state
.signal()
.map(move |v| v == Some(date))
.broadcast();
let is_focused = focused_day.signal().map(move |v| v == date).broadcast();
html!("div", {
.attr("role", "gridcell")
.child(html!("button", {
.attr("type", "button")
.attr("id", &format!("{}-day-{}", picker_id, date))
.attr("aria-label", &date.to_string())
.attr_signal("aria-selected", is_selected.signal().map(|v| if v { "true" } else { "false" }))
.attr_signal("tabindex", is_focused.signal().map(|v| if v { "0" } else { "-1" }))
.focused_signal(is_focused.signal())
.dwclass!("w-9 h-9 flex align-items-center justify-center rounded-md cursor-pointer text-sm")
.dwclass!("bg-transparent border-none transition-colors")
.dwclass!("focus-visible:ring-2 focus-visible:dwui-ring-primary-400")
.style("outline", "none")
.apply(move |b| {
if in_month {
dwclass!(b, "dwui-text-on-primary-200 is(.light *):dwui-text-on-primary-800")
} else {
dwclass!(b, "dwui-text-on-primary-600 is(.light *):dwui-text-on-primary-500")
}
})
.dwclass!("hover:dwui-bg-void-700 is(.light *):hover:dwui-bg-void-200")
.dwclass_signal!("dwui-bg-primary-600 dwui-text-on-primary-50 font-bold is(.light *):dwui-bg-primary-400 is(.light *):dwui-text-on-primary-950", is_selected.signal())
.apply_if(date == today, |b| {
dwclass!(b, "dwui-border-primary-500 border")
})
.text(&date.day.to_string())
.event(clone!(pick => move |_: events::Click| {
(pick)(date);
}))
.event_with_options(&EventOptions::preventable(), clone!(move_focus => move |e: events::KeyDown| {
let target = match e.key().as_str() {
"ArrowLeft" => date.add_days(-1),
"ArrowRight" => date.add_days(1),
"ArrowUp" => date.add_days(-7),
"ArrowDown" => date.add_days(7),
"PageUp" => date.add_months(-1),
"PageDown" => date.add_months(1),
"Home" => date.add_days(-(date.day_of_week() as i32)),
"End" => date.add_days(6 - date.day_of_week() as i32),
_ => return,
};
e.prevent_default();
(move_focus)(target);
}))
}))
})
}