diff --git a/Cargo.lock b/Cargo.lock index 893db865..fbe19887 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1667,9 +1667,9 @@ checksum = "da220af51a1a335e9a930beaaef53d261e41ea9eecfb3d973a3ddae1a7284b9c" [[package]] name = "deranged" -version = "0.4.0" +version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e" +checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" dependencies = [ "powerfmt", ] @@ -7630,9 +7630,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.41" +version = "0.3.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40" +checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" dependencies = [ "deranged", "itoa", @@ -7646,15 +7646,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.4" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c" +checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" [[package]] name = "time-macros" -version = "0.2.22" +version = "0.2.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3526739392ec93fd8b359c8e98514cb3e8e021beb4e5f597b00a0221f8ed8a49" +checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" dependencies = [ "num-conv", "time-core", diff --git a/preview/Cargo.toml b/preview/Cargo.toml index 331853f4..24c45db0 100644 --- a/preview/Cargo.toml +++ b/preview/Cargo.toml @@ -11,7 +11,7 @@ dioxus-i18n = { git = "https://github.com/ealmloff/dioxus-i18n", branch = "bump- unic-langid = { version = "0.9", features = ["macros"] } strum = { version = "0.27.2", features = ["derive"] } tracing.workspace = true -time = { version = "0.3.41", features = ["std", "macros"] } +time = { version = "0.3.44", features = ["std", "macros"] } [build-dependencies] syntect = "5.2" diff --git a/preview/src/components/calendar/component.rs b/preview/src/components/calendar/component.rs index a989f70a..a923b612 100644 --- a/preview/src/components/calendar/component.rs +++ b/preview/src/components/calendar/component.rs @@ -22,6 +22,7 @@ pub fn Calendar(props: CalendarProps) -> Element { first_day_of_week: props.first_day_of_week, min_date: props.min_date, max_date: props.max_date, + month_count: props.month_count, disabled_ranges: props.disabled_ranges, attributes: props.attributes, {props.children} @@ -47,6 +48,7 @@ pub fn RangeCalendar(props: RangeCalendarProps) -> Element { first_day_of_week: props.first_day_of_week, min_date: props.min_date, max_date: props.max_date, + month_count: props.month_count, disabled_ranges: props.disabled_ranges, attributes: props.attributes, {props.children} diff --git a/preview/src/components/calendar/style.css b/preview/src/components/calendar/style.css index ebc0f8be..39e3b574 100644 --- a/preview/src/components/calendar/style.css +++ b/preview/src/components/calendar/style.css @@ -59,7 +59,20 @@ cursor: not-allowed; } +.calendar-month-title { + display: flex; + width: 100%; + height: 1.75rem; + align-items: center; + justify-content: center; +} + /* Calendar Grid */ +.calendar-div { + display: flex; + flex-direction: row; +} + .calendar-grid { width: 100%; padding: 0.5rem; @@ -72,7 +85,7 @@ } .calendar-grid-day-header { - width: 2rem; + flex: 1; color: var(--secondary-color-5); font-size: 12px; font-weight: 300; @@ -266,4 +279,4 @@ td:has(.calendar-grid-cell[data-selection-end="true"]) { stroke-linecap: round; stroke-linejoin: round; stroke-width: 2; -} +} \ No newline at end of file diff --git a/preview/src/components/calendar/variants/multi_month/mod.rs b/preview/src/components/calendar/variants/multi_month/mod.rs new file mode 100644 index 00000000..3b57e3ee --- /dev/null +++ b/preview/src/components/calendar/variants/multi_month/mod.rs @@ -0,0 +1,38 @@ +use super::super::component::*; +use dioxus::prelude::*; +use time::{macros::date, Date, UtcDateTime}; + +use dioxus_primitives::calendar::DateRange; + +#[component] +pub fn Demo() -> Element { + let mut selected_range = use_signal(|| None::); + let mut view_date = use_signal(|| UtcDateTime::now().date()); + rsx! { + div { class: "calendar-example", style: "padding: 20px;", + RangeCalendar { + selected_range: selected_range(), + on_range_change: move |range| { + tracing::info!("Selected range: {:?}", range); + selected_range.set(range); + }, + view_date: view_date(), + on_view_change: move |new_view: Date| { + tracing::info!("View changed to: {}-{}", new_view.year(), new_view.month()); + view_date.set(new_view); + }, + min_date: date!(1995 - 07 - 21), + max_date: date!(2035 - 09 - 11), + month_count: 3, + CalendarHeader { + CalendarNavigation { + CalendarPreviousMonthButton {} + CalendarMonthTitle {} + CalendarNextMonthButton {} + } + } + CalendarGrid {} + } + } + } +} diff --git a/preview/src/components/date_picker/component.rs b/preview/src/components/date_picker/component.rs index 2ce5410e..39285681 100644 --- a/preview/src/components/date_picker/component.rs +++ b/preview/src/components/date_picker/component.rs @@ -1,7 +1,7 @@ use dioxus::prelude::*; use dioxus_primitives::{ - date_picker::{self, DatePickerInputProps, DatePickerProps}, + date_picker::{self, DatePickerInputProps, DatePickerProps, DateRangePickerProps}, popover::{PopoverContentProps, PopoverTriggerProps}, ContentAlign, }; @@ -20,6 +20,37 @@ pub fn DatePicker(props: DatePickerProps) -> Element { selected_date: props.selected_date, disabled: props.disabled, read_only: props.read_only, + min_date: props.min_date, + max_date: props.max_date, + month_count: props.month_count, + disabled_ranges: props.disabled_ranges, + roving_loop: props.roving_loop, + attributes: props.attributes, + date_picker::DatePickerPopover { + popover_root: PopoverRoot, + {props.children} + } + } + } + } +} + +#[component] +pub fn DateRangePicker(props: DateRangePickerProps) -> Element { + rsx! { + document::Link { rel: "stylesheet", href: asset!("./style.css") } + div { + date_picker::DateRangePicker { + class: "date-picker", + on_range_change: props.on_range_change, + selected_range: props.selected_range, + disabled: props.disabled, + read_only: props.read_only, + min_date: props.min_date, + max_date: props.max_date, + month_count: props.month_count, + disabled_ranges: props.disabled_ranges, + roving_loop: props.roving_loop, attributes: props.attributes, date_picker::DatePickerPopover { popover_root: PopoverRoot, {props.children} } } @@ -54,6 +85,35 @@ pub fn DatePickerInput(props: DatePickerInputProps) -> Element { } } +#[component] +pub fn DateRangePickerInput(props: DatePickerInputProps) -> Element { + rsx! { + date_picker::DateRangePickerInput { + on_format_day_placeholder: props.on_format_day_placeholder, + on_format_month_placeholder: props.on_format_month_placeholder, + on_format_year_placeholder: props.on_format_year_placeholder, + attributes: props.attributes, + {props.children} + DatePickerPopoverTrigger {} + DatePickerPopoverContent { + align: ContentAlign::Center, + date_picker::DateRangePickerCalendar { + calendar: RangeCalendar, + CalendarHeader { + CalendarNavigation { + CalendarPreviousMonthButton {} + CalendarSelectMonth {} + CalendarSelectYear {} + CalendarNextMonthButton {} + } + } + CalendarGrid {} + } + } + } + } +} + #[component] pub fn DatePickerPopoverTrigger(props: PopoverTriggerProps) -> Element { rsx! { diff --git a/preview/src/components/date_picker/variants/internationalized/mod.rs b/preview/src/components/date_picker/variants/internationalized/mod.rs new file mode 100644 index 00000000..265c40a5 --- /dev/null +++ b/preview/src/components/date_picker/variants/internationalized/mod.rs @@ -0,0 +1,27 @@ +use super::super::component::*; +use dioxus::prelude::*; + +use dioxus_i18n::tid; +use time::Date; + +#[component] +pub fn Demo() -> Element { + let mut selected_date = use_signal(|| None::); + + rsx! { + div { + DatePicker { + selected_date: selected_date(), + on_value_change: move |v| { + tracing::info!("Selected date changed: {:?}", v); + selected_date.set(v); + }, + DatePickerInput { + on_format_day_placeholder: || tid!("D_Abbr"), + on_format_month_placeholder: || tid!("M_Abbr"), + on_format_year_placeholder: || tid!("Y_Abbr"), + } + } + } + } +} diff --git a/preview/src/components/date_picker/variants/main/mod.rs b/preview/src/components/date_picker/variants/main/mod.rs index 265c40a5..86d635df 100644 --- a/preview/src/components/date_picker/variants/main/mod.rs +++ b/preview/src/components/date_picker/variants/main/mod.rs @@ -1,7 +1,6 @@ use super::super::component::*; use dioxus::prelude::*; -use dioxus_i18n::tid; use time::Date; #[component] @@ -16,11 +15,7 @@ pub fn Demo() -> Element { tracing::info!("Selected date changed: {:?}", v); selected_date.set(v); }, - DatePickerInput { - on_format_day_placeholder: || tid!("D_Abbr"), - on_format_month_placeholder: || tid!("M_Abbr"), - on_format_year_placeholder: || tid!("Y_Abbr"), - } + DatePickerInput {} } } } diff --git a/preview/src/components/date_picker/variants/multi_month/mod.rs b/preview/src/components/date_picker/variants/multi_month/mod.rs new file mode 100644 index 00000000..fc605cc4 --- /dev/null +++ b/preview/src/components/date_picker/variants/multi_month/mod.rs @@ -0,0 +1,23 @@ +use super::super::component::*; +use dioxus::prelude::*; + +use dioxus_primitives::calendar::DateRange; + +#[component] +pub fn Demo() -> Element { + let mut selected_range = use_signal(|| None::); + + rsx! { + div { + DateRangePicker { + selected_range: selected_range(), + on_range_change: move |range| { + tracing::info!("Selected range: {:?}", range); + selected_range.set(range); + }, + month_count: 2, + DateRangePickerInput {} + } + } + } +} diff --git a/preview/src/components/date_picker/variants/range/mod.rs b/preview/src/components/date_picker/variants/range/mod.rs new file mode 100644 index 00000000..fae2e156 --- /dev/null +++ b/preview/src/components/date_picker/variants/range/mod.rs @@ -0,0 +1,22 @@ +use super::super::component::*; +use dioxus::prelude::*; + +use dioxus_primitives::calendar::DateRange; + +#[component] +pub fn Demo() -> Element { + let mut selected_range = use_signal(|| None::); + + rsx! { + div { + DateRangePicker { + selected_range: selected_range(), + on_range_change: move |range| { + tracing::info!("Selected range: {:?}", range); + selected_range.set(range); + }, + DateRangePickerInput {} + } + } + } +} diff --git a/preview/src/components/date_picker/variants/unavailable_dates/mod.rs b/preview/src/components/date_picker/variants/unavailable_dates/mod.rs new file mode 100644 index 00000000..5634f074 --- /dev/null +++ b/preview/src/components/date_picker/variants/unavailable_dates/mod.rs @@ -0,0 +1,33 @@ +use super::super::component::*; +use dioxus::prelude::*; + +use dioxus_primitives::calendar::DateRange; +use time::{ext::NumericalDuration, UtcDateTime}; + +#[component] +pub fn Demo() -> Element { + let mut selected_range = use_signal(|| None::); + + let now = UtcDateTime::now().date(); + let disabled_ranges = use_signal(|| { + vec![ + DateRange::new(now, now.saturating_add(3.days())), + DateRange::new(now.saturating_add(15.days()), now.saturating_add(18.days())), + DateRange::new(now.saturating_add(22.days()), now.saturating_add(23.days())), + ] + }); + + rsx! { + div { + DateRangePicker { + selected_range: selected_range(), + on_range_change: move |range| { + tracing::info!("Selected range: {:?}", range); + selected_range.set(range); + }, + disabled_ranges: disabled_ranges, + DateRangePickerInput {} + } + } + } +} diff --git a/preview/src/components/mod.rs b/preview/src/components/mod.rs index 8fb5d326..a2195d26 100644 --- a/preview/src/components/mod.rs +++ b/preview/src/components/mod.rs @@ -62,12 +62,11 @@ examples!( aspect_ratio, avatar, button, - calendar[simple, internationalized, range, unavailable_dates], - card, + calendar[simple, internationalized, range, multi_month, unavailable_dates], checkbox, collapsible, context_menu, - date_picker, + date_picker[internationalized, range, multi_month, unavailable_dates], dialog, dropdown_menu, hover_card, diff --git a/primitives/Cargo.toml b/primitives/Cargo.toml index 182ee314..687ae1e8 100644 --- a/primitives/Cargo.toml +++ b/primitives/Cargo.toml @@ -14,7 +14,7 @@ repository = "https://github.com/DioxusLabs/components" [dependencies] dioxus.workspace = true dioxus-sdk-time = "0.7.0" -time = { version = "0.3.41", features = ["std", "macros", "parsing"] } +time = { version = "0.3.44", features = ["std", "macros", "parsing"] } num-integer = "0.1.46" tracing.workspace = true diff --git a/primitives/src/calendar.rs b/primitives/src/calendar.rs index 0fad46d6..0dcb5790 100644 --- a/primitives/src/calendar.rs +++ b/primitives/src/calendar.rs @@ -154,13 +154,26 @@ fn replace_month(date: Date, month: Month) -> Date { .expect("invalid or out-of-range date") } +fn nth_month_next(date: Date, n: u8) -> Option { + match n { + 0 => Some(date), + n => { + let month = date.month(); + let nth_month = month.nth_next(n); + let year = date.year() + if month > nth_month { 1 } else { 0 }; + let max_day = nth_month.length(year); + Date::from_calendar_date(year, nth_month, date.day().min(max_day)).ok() + } + } +} + /// Calendar date range -#[derive(Copy, Clone, Debug)] +#[derive(Copy, Clone, PartialEq, Debug)] pub struct DateRange { /// The start date of the range - start: Date, + pub start: Date, /// The end date of the range - end: Date, + pub end: Date, } impl DateRange { @@ -176,7 +189,8 @@ impl DateRange { } } - fn contains(&self, date: Date) -> bool { + /// Returns true if date is contained in the range. + pub fn contains(&self, date: Date) -> bool { self.start <= date && date <= self.end } @@ -187,6 +201,11 @@ impl DateRange { fn clamp(&self, date: Date) -> Date { date.clamp(self.start, self.end) } + + /// Get minimum and maximum values + pub fn to_min_max(&self) -> (Date, Date) { + (self.start, self.end) + } } impl Display for DateRange { @@ -195,14 +214,16 @@ impl Display for DateRange { } } +/// Calendar available dates #[derive(Debug, Clone, PartialEq)] -struct AvailibleRanges { +pub struct AvailableRanges { /// A sorted list of dates. Values after an odd number of elements are disabled. changes: Vec, } -impl AvailibleRanges { - fn new(disabled_ranges: &[DateRange]) -> Self { +impl AvailableRanges { + /// Create a new available dates + pub fn new(disabled_ranges: &[DateRange]) -> Self { let mut sorted_range: Vec<_> = disabled_ranges .iter() .enumerate() @@ -230,15 +251,18 @@ impl AvailibleRanges { } } - fn valid_interval(&self, date: Date) -> bool { + /// Check the availability of given date + pub fn valid_interval(&self, date: Date) -> bool { match self.changes.binary_search(&date) { Ok(_) => false, Err(index) => index % 2 == 0, } } - fn available_range(&self, date: Date, min_date: Date, max_date: Date) -> Option { + /// Get the available range of given date + pub fn available_range(&self, date: Date, date_range: DateRange) -> Option { let date_index = self.changes.binary_search(&date).err()?; + let (min_date, max_date) = date_range.to_min_max(); let valid = date_index % 2 == 0; if !valid { @@ -259,6 +283,14 @@ impl AvailibleRanges { Some(DateRange::new(start, end)) } + + /// Get disabled ranges + pub fn to_disabled_ranges(&self) -> Vec { + self.changes + .chunks(2) + .map(|d| DateRange::new(d[0], d[1])) + .collect() + } } /// The base context provided by the [`Calendar`] and the [`RangeCalendar`] component to its children. @@ -267,7 +299,7 @@ pub struct BaseCalendarContext { // State focused_date: Signal>, view_date: ReadSignal, - available_ranges: Memo, + available_ranges: Memo, set_view_date: Callback, format_weekday: Callback, format_month: Callback, @@ -276,8 +308,8 @@ pub struct BaseCalendarContext { disabled: ReadSignal, today: Date, first_day_of_week: Weekday, - min_date: Date, - max_date: Date, + enabled_date_range: DateRange, + month_count: u8, } impl BaseCalendarContext { @@ -298,7 +330,7 @@ impl BaseCalendarContext { /// Set the view date pub fn set_view_date(&self, date: Date) { - (self.set_view_date)(date.clamp(self.min_date, self.max_date)); + (self.set_view_date)(self.enabled_date_range.clamp(date)); } /// Check if the calendar is disabled @@ -322,7 +354,7 @@ impl BaseCalendarContext { ctx.anchor_date.cloned().and_then(|date| { self.available_ranges .read() - .available_range(date, self.min_date, self.max_date) + .available_range(date, self.enabled_date_range) }) }) } @@ -394,6 +426,10 @@ pub struct CalendarProps { #[props(default = date!(2050-12-31))] pub max_date: Date, + /// Specify how many months are visible at once + #[props(default = 1)] + pub month_count: u8, + /// Unavailable dates #[props(default)] pub disabled_ranges: ReadSignal>, @@ -456,7 +492,7 @@ pub struct CalendarProps { /// - `data-disabled`: Indicates if the calendar is disabled. Possible values are `true` or `false`. #[component] pub fn Calendar(props: CalendarProps) -> Element { - let available_ranges = use_memo(move || AvailibleRanges::new(&props.disabled_ranges.read())); + let available_ranges = use_memo(move || AvailableRanges::new(&props.disabled_ranges.read())); // Create base context provider for child components let mut base_ctx = use_context_provider(|| BaseCalendarContext { @@ -469,8 +505,8 @@ pub fn Calendar(props: CalendarProps) -> Element { disabled: props.disabled, today: props.today, first_day_of_week: props.first_day_of_week, - min_date: props.min_date, - max_date: props.max_date, + enabled_date_range: DateRange::new(props.min_date, props.max_date), + month_count: props.month_count, }); // Create Calendar context provider for child components use_context_provider(|| CalendarContext { @@ -480,6 +516,7 @@ pub fn Calendar(props: CalendarProps) -> Element { rsx! { div { + class: "calendar-div", role: "application", aria_label: "Calendar", "data-disabled": (props.disabled)(), @@ -488,22 +525,27 @@ pub fn Calendar(props: CalendarProps) -> Element { return; }; let mut set_focused_date = |new_date: Option| { - // Make sure the view date month is the same as the focused date - let mut view_date = (base_ctx.view_date)(); if let Some(date) = new_date { - if date.month() != view_date.month() { - view_date = date.replace_day(1).unwrap(); + let min_date = (base_ctx.view_date)().replace_day(1).unwrap(); + if date < min_date { + let view_date = previous_month(min_date).unwrap_or(min_date); (base_ctx.set_view_date)(view_date); + } else { + let max_date = nth_month_next(min_date, props.month_count) + .unwrap_or(min_date); + if date >= max_date { + let view_date = next_month(min_date).unwrap_or(min_date); + (base_ctx.set_view_date)(view_date); + } } } - match new_date { Some(date) => { - if base_ctx.min_date <= date && date <= base_ctx.max_date { + if base_ctx.enabled_date_range.contains(date) { base_ctx.focused_date.set(new_date); } - }, - None => base_ctx.focused_date.set(None) + } + None => base_ctx.focused_date.set(None), } }; match e.key() { @@ -522,7 +564,6 @@ pub fn Calendar(props: CalendarProps) -> Element { set_focused_date(Some(date)); } } else { - // Otherwise, move to the previous week set_focused_date(Some(focused_date.saturating_sub(7.days()))); } } @@ -533,16 +574,17 @@ pub fn Calendar(props: CalendarProps) -> Element { set_focused_date(Some(date)); } } else { - // Otherwise, move to the next week set_focused_date(Some(focused_date.saturating_add(7.days()))); } } _ => {} } }, - ..props.attributes, - - {props.children} + for offset in 0..props.month_count { + CalendarView { offset, + div { ..props.attributes.clone(),{props.children.clone()} } + } + } } } } @@ -641,6 +683,10 @@ pub struct RangeCalendarProps { #[props(default = date!(2050-12-31))] pub max_date: Date, + /// Specify how many months are visible at once + #[props(default = 1)] + pub month_count: u8, + /// Unavailable dates #[props(default)] pub disabled_ranges: ReadSignal>, @@ -707,7 +753,7 @@ pub fn RangeCalendar(props: RangeCalendarProps) -> Element { }); let anchor_date = use_signal(|| None::); let highlighted_range = use_signal(|| (props.selected_range)()); - let available_ranges = use_memo(move || AvailibleRanges::new(&props.disabled_ranges.read())); + let available_ranges = use_memo(move || AvailableRanges::new(&props.disabled_ranges.read())); // Create base context provider for child components let mut base_ctx = use_context_provider(|| BaseCalendarContext { @@ -720,8 +766,8 @@ pub fn RangeCalendar(props: RangeCalendarProps) -> Element { disabled: props.disabled, today: props.today, first_day_of_week: props.first_day_of_week, - min_date: props.min_date, - max_date: props.max_date, + enabled_date_range: DateRange::new(props.min_date, props.max_date), + month_count: props.month_count, }); // Create RangeCalendar context provider for child components @@ -733,6 +779,7 @@ pub fn RangeCalendar(props: RangeCalendarProps) -> Element { rsx! { div { + class: "calendar-div", role: "application", aria_label: "Calendar", "data-disabled": (props.disabled)(), @@ -740,29 +787,34 @@ pub fn RangeCalendar(props: RangeCalendarProps) -> Element { let Some(mut focused_date) = (base_ctx.focused_date)() else { return; }; - - // force hover day as focus - if let (Some(range), Some(date)) = ((ctx.highlighted_range)(), (ctx.anchor_date)()) { - if date != range.start { - focused_date = range.start - } else { - focused_date = range.end - } - }; - + if let (Some(range), Some(date)) = ( + (ctx.highlighted_range)(), + (ctx.anchor_date)(), + ) { + if date != range.start { + focused_date = range.start + } else { + focused_date = range.end + } + } let mut set_focused_date = |new_date: Option| { - // Make sure the view date month is the same as the focused date - let mut view_date = (base_ctx.view_date)(); if let Some(date) = new_date { - if date.month() != view_date.month() { - view_date = date.replace_day(1).unwrap(); + let min_date = (base_ctx.view_date)().replace_day(1).unwrap(); + if date < min_date { + let view_date = previous_month(min_date).unwrap_or(min_date); (base_ctx.set_view_date)(view_date); + } else { + let max_date = nth_month_next(min_date, props.month_count) + .unwrap_or(min_date); + if date >= max_date { + let view_date = next_month(min_date).unwrap_or(min_date); + (base_ctx.set_view_date)(view_date); + } } } - match new_date { Some(date) => { - if base_ctx.min_date <= date && date <= base_ctx.max_date { + if base_ctx.enabled_date_range.contains(date) { base_ctx.focused_date.set(new_date); let date = match base_ctx.available_range() { Some(range) => range.clamp(date), @@ -770,8 +822,8 @@ pub fn RangeCalendar(props: RangeCalendarProps) -> Element { }; ctx.set_hovered_date(date); } - }, - None => base_ctx.focused_date.set(None) + } + None => base_ctx.focused_date.set(None), } }; match e.key() { @@ -790,7 +842,6 @@ pub fn RangeCalendar(props: RangeCalendarProps) -> Element { set_focused_date(Some(date)); } } else { - // Otherwise, move to the previous week set_focused_date(Some(focused_date.saturating_sub(7.days()))); } } @@ -801,7 +852,6 @@ pub fn RangeCalendar(props: RangeCalendarProps) -> Element { set_focused_date(Some(date)); } } else { - // Otherwise, move to the next week set_focused_date(Some(focused_date.saturating_add(7.days()))); } } @@ -811,13 +861,76 @@ pub fn RangeCalendar(props: RangeCalendarProps) -> Element { _ => {} } }, - ..props.attributes, - - {props.children} + for offset in 0..props.month_count { + CalendarView { offset, + div { ..props.attributes.clone(),{props.children.clone()} } + } + } } } } +#[derive(Props, Clone, PartialEq)] +struct CalendarViewProps { + /// An offset from the beginning of the view date that this should display + #[props(default = 0)] + pub offset: u8, + + /// The children of the calendar element + pub children: Element, +} + +#[derive(Copy, Clone, PartialEq)] +struct CalendarViewContext { + offset: u8, + view_date: Signal, +} + +impl CalendarViewContext { + fn set_view_date(&mut self, view_date: Date) { + let new_date = nth_month_next(view_date, self.offset).unwrap_or(view_date); + self.view_date.set(new_date); + } + + fn replace_year(&self, date: Date, year: i32) -> Date { + let month = date.month(); + let view_month = (self.view_date)().month(); + let year = year - if month > view_month { 1 } else { 0 }; + date.replace_year(year).unwrap_or(date) + } + + fn replace_month(&self, date: Date, month: Month) -> Date { + let view_date = (self.view_date)(); + let new_month = month.nth_prev(self.offset); + let year = view_date.year() - if month > view_date.month() { 1 } else { 0 }; + Date::from_calendar_date(year, new_month, 1).unwrap_or(date) + } + + // Get the current view date + fn view_date(&self) -> Date { + self.view_date.cloned() + } +} + +#[component] +fn CalendarView(props: CalendarViewProps) -> Element { + let ctx: BaseCalendarContext = use_context(); + + let view_date = use_signal(|| (ctx.view_date)()); + let mut view_ctx = use_context_provider(|| CalendarViewContext { + offset: props.offset, + view_date, + }); + + use_effect(move || { + view_ctx.set_view_date((ctx.view_date)()); + }); + + rsx! { + {props.children} + } +} + /// The props for the [`CalendarHeader`] component. #[derive(Props, Clone, PartialEq)] pub struct CalendarHeaderProps { @@ -1014,19 +1127,25 @@ pub struct CalendarPreviousMonthButtonProps { #[component] pub fn CalendarPreviousMonthButton(props: CalendarPreviousMonthButtonProps) -> Element { let ctx: BaseCalendarContext = use_context(); + let view_ctx: CalendarViewContext = use_context(); + + if view_ctx.offset != 0 { + return rsx! {}; + } + // disable previous button when we reach the limit let button_disabled = use_memo(move || { // Get the current view date from context - let view_date = (ctx.view_date)(); + let view_date = (view_ctx.view_date)(); match previous_month(view_date) { - Some(date) => ctx.min_date.replace_day(1).unwrap() > date, + Some(date) => ctx.enabled_date_range.start.replace_day(1).unwrap() > date, None => true, } }); // disable previous button when the current selection range does not include the previous month let navigate_disabled = use_memo(move || { // Get the current view date from context - let view_date = (ctx.view_date)(); + let view_date = (view_ctx.view_date)(); ctx.available_range() .is_some_and(|range| range.start.month() == view_date.month()) }); @@ -1044,7 +1163,7 @@ pub fn CalendarPreviousMonthButton(props: CalendarPreviousMonthButtonProps) -> E button { class: "calendar-nav-prev", aria_label: "Previous month", - type: "button", + r#type: "button", onclick: handle_prev_month, disabled: (ctx.disabled)() || button_disabled() || navigate_disabled(), ..props.attributes, @@ -1113,14 +1232,21 @@ pub struct CalendarNextMonthButtonProps { #[component] pub fn CalendarNextMonthButton(props: CalendarNextMonthButtonProps) -> Element { let ctx: BaseCalendarContext = use_context(); + let view_ctx: CalendarViewContext = use_context(); + + if view_ctx.offset + 1 != ctx.month_count { + return rsx! {}; + } + // disable next button when we reach the limit let button_disabled = use_memo(move || { // Get the current view date from context - let view_date = (ctx.view_date)(); + let view_date = (view_ctx.view_date)(); match next_month(view_date) { Some(date) => { - let last_day = ctx.max_date.month().length(ctx.max_date.year()); - ctx.max_date.replace_day(last_day).unwrap() < date + let max = ctx.enabled_date_range.end; + let last_day = max.month().length(max.year()); + max.replace_day(last_day).unwrap() < date } None => true, } @@ -1128,7 +1254,7 @@ pub fn CalendarNextMonthButton(props: CalendarNextMonthButtonProps) -> Element { // disable next button when the current selection range does not include the next month let navigate_disabled = use_memo(move || { // Get the current view date from context - let view_date = (ctx.view_date)(); + let view_date = (view_ctx.view_date)(); ctx.available_range() .is_some_and(|range| range.end.month() == view_date.month()) }); @@ -1146,7 +1272,7 @@ pub fn CalendarNextMonthButton(props: CalendarNextMonthButtonProps) -> Element { button { class: "calendar-nav-next", aria_label: "Next month", - type: "button", + r#type: "button", onclick: handle_next_month, disabled: (ctx.disabled)() || button_disabled() || navigate_disabled(), ..props.attributes, @@ -1212,7 +1338,7 @@ pub struct CalendarMonthTitleProps { /// ``` #[component] pub fn CalendarMonthTitle(props: CalendarMonthTitleProps) -> Element { - let ctx: BaseCalendarContext = use_context(); + let ctx: CalendarViewContext = use_context(); // Format the current month and year let month_year = use_memo(move || { let view_date = (ctx.view_date)(); @@ -1306,6 +1432,7 @@ pub struct CalendarGridProps { #[component] pub fn CalendarGrid(props: CalendarGridProps) -> Element { let ctx: BaseCalendarContext = use_context(); + let view_ctx: CalendarViewContext = use_context(); // We'll use the view_date from context in the memo below @@ -1313,8 +1440,7 @@ pub fn CalendarGrid(props: CalendarGridProps) -> Element { // Use the view_date as a dependency to ensure the grid updates when the view changes let days_grid = use_memo(move || { // Get the current view date from context - let view_date = (ctx.view_date)(); - + let view_date = (view_ctx.view_date)(); // Create a grid with empty cells for padding and actual days let mut grid = Vec::new(); @@ -1329,7 +1455,7 @@ pub fn CalendarGrid(props: CalendarGridProps) -> Element { date = date.next_day().expect("invalid or out-of-range date"); } - let mut date = view_date; + date = view_date; // Add days of the month let num_days_in_month = view_date.month().length(view_date.year()); for day in 1..=num_days_in_month { @@ -1375,7 +1501,7 @@ pub fn CalendarGrid(props: CalendarGridProps) -> Element { // Day name headers for (weekday, label) in weekday_headers() { th { - key: "{weekday:?}", // Add key for efficient diffing + key: "{weekday:?}", // Add key for efficient diffing class: "calendar-grid-day-header", {label} } @@ -1457,20 +1583,23 @@ pub struct CalendarSelectMonthProps { /// ``` #[component] pub fn CalendarSelectMonth(props: CalendarSelectMonthProps) -> Element { - let calendar: BaseCalendarContext = use_context(); - let view_date = calendar.view_date(); + let base_ctx: BaseCalendarContext = use_context(); + let view_ctx: CalendarViewContext = use_context(); + + let view_date = view_ctx.view_date(); let month = view_date.month(); let months = use_memo(move || { // Get the current view date from context - let view_date = (calendar.view_date)(); + let view_date = (view_ctx.view_date)(); + let (min_date, max_date) = base_ctx.enabled_date_range.to_min_max(); let mut min_month = Month::January; - if replace_month(view_date, min_month) < calendar.min_date { - min_month = calendar.min_date.month(); + if replace_month(view_date, min_month) < min_date { + min_month = min_date.month(); } let mut max_month = Month::December; - if replace_month(view_date, max_month) > calendar.max_date { - max_month = calendar.max_date.month(); + if replace_month(view_date, max_month) > max_date { + max_month = max_date.month(); } let mut month = min_month; @@ -1490,23 +1619,23 @@ pub fn CalendarSelectMonth(props: CalendarSelectMonthProps) -> Element { select { aria_label: "Month", onchange: move |e| { - let mut view_date = calendar.view_date(); + let mut view_date = (base_ctx.view_date)(); let number = e.value().parse().unwrap_or(view_date.month() as u8); let cur_month = Month::try_from(number).expect("Month out-of-range"); - view_date = view_date.replace_month(cur_month).unwrap_or(view_date); - calendar.set_view_date(view_date); + view_date = view_ctx.replace_month(view_date, cur_month); + base_ctx.set_view_date(view_date); }, ..props.attributes, for month in months() { option { value: month as u8, - selected: calendar.view_date().month() == month, - {calendar.format_month.call(month)} + selected: view_ctx.view_date().month() == month, + {base_ctx.format_month.call(month)} } } } span { class: "calendar-month-select-value", - {calendar.format_month.call(month)} + {base_ctx.format_month.call(month)} svg { class: "select-expand-icon", view_box: "0 0 24 24", @@ -1573,20 +1702,23 @@ pub struct CalendarSelectYearProps { /// ``` #[component] pub fn CalendarSelectYear(props: CalendarSelectYearProps) -> Element { - let calendar: BaseCalendarContext = use_context(); - let view_date = calendar.view_date(); + let base_ctx: BaseCalendarContext = use_context(); + let view_ctx: CalendarViewContext = use_context(); + + let view_date = view_ctx.view_date(); let year = view_date.year(); let years = use_memo(move || { // Get the current view date from context - let view_date = (calendar.view_date)(); + let view_date = (view_ctx.view_date)(); + let (min_date, max_date) = base_ctx.enabled_date_range.to_min_max(); let month = view_date.month(); - let mut min_year = calendar.min_date.year(); - if replace_month(calendar.min_date, month) < calendar.min_date { + let mut min_year = min_date.year(); + if replace_month(min_date, month) < min_date { min_year += 1; } - let mut max_year = calendar.max_date.year(); - if replace_month(calendar.max_date, month) > calendar.max_date { + let mut max_year = max_date.year(); + if replace_month(max_date, month) > max_date { max_year -= 1; } @@ -1598,16 +1730,16 @@ pub fn CalendarSelectYear(props: CalendarSelectYearProps) -> Element { select { aria_label: "Year", onchange: move |e| { - let mut view_date = calendar.view_date(); + let mut view_date = (base_ctx.view_date)(); let year = e.value().parse().unwrap_or(view_date.year()); - view_date = view_date.replace_year(year).unwrap_or(view_date); - calendar.set_view_date(view_date); + view_date = view_ctx.replace_year(view_date, year); + base_ctx.set_view_date(view_date); }, ..props.attributes, for year in years() { option { value: year, - selected: calendar.view_date().year() == year, + selected: base_ctx.view_date().year() == year, "{year}" } } @@ -1741,16 +1873,14 @@ pub fn CalendarDay(props: CalendarDayProps) -> Element { fn relative_calendar_month( date: Date, base_ctx: &BaseCalendarContext, - view_date: Date, + current_month: Month, ) -> RelativeMonth { - if date < base_ctx.min_date { + if date < base_ctx.enabled_date_range.start { RelativeMonth::Last - } else if date > base_ctx.max_date { + } else if date > base_ctx.enabled_date_range.end { RelativeMonth::Next } else { - let lhs = date.month() as u8; - let rhs = view_date.month() as u8; - match lhs.cmp(&rhs) { + match date.month().cmp(¤t_month) { std::cmp::Ordering::Less => RelativeMonth::Last, std::cmp::Ordering::Equal => RelativeMonth::Current, std::cmp::Ordering::Greater => RelativeMonth::Next, @@ -1790,11 +1920,12 @@ fn use_day_mounted_ref( fn SingleCalendarDay(props: CalendarDayProps) -> Element { let CalendarDayProps { date, attributes } = props; let mut base_ctx: BaseCalendarContext = use_context(); + let view_ctx: CalendarViewContext = use_context(); let day = date.day(); - let view_date = (base_ctx.view_date)(); - let month = relative_calendar_month(date, &base_ctx, view_date); + let view_date = (view_ctx.view_date)(); + let month = relative_calendar_month(date, &base_ctx, view_date.month()); let in_current_month = month.current_month(); - let is_focused = move || base_ctx.is_focused(date); + let is_focused = move || in_current_month && base_ctx.is_focused(date); let is_today = date == base_ctx.today; let is_unavailable = base_ctx.is_unavailable(date); @@ -1815,7 +1946,7 @@ fn SingleCalendarDay(props: CalendarDayProps) -> Element { if (base_ctx.disabled)() || is_unavailable { return; } - let view_date = (base_ctx.view_date)(); + let view_date = (view_ctx.view_date)(); let date = view_date.replace_day(day).unwrap(); ctx.set_selected_date.call((!is_selected()).then_some(date)); base_ctx.focused_date.set(Some(date)); @@ -1833,12 +1964,8 @@ fn SingleCalendarDay(props: CalendarDayProps) -> Element { rsx! { button { class: "calendar-grid-cell", - type: "button", - tabindex: if date == focusable_date { - "0" - } else { - "-1" - }, + r#type: "button", + tabindex: if date == focusable_date { "0" } else { "-1" }, aria_label: aria_label(&props.date), "data-today": is_today, "data-selected": is_selected(), @@ -1868,10 +1995,11 @@ fn RangeCalendarDay(props: CalendarDayProps) -> Element { let CalendarDayProps { date, attributes } = props; let mut base_ctx: BaseCalendarContext = use_context(); let day = date.day(); - let view_date = (base_ctx.view_date)(); - let month = relative_calendar_month(date, &base_ctx, view_date); + let view_ctx: CalendarViewContext = use_context(); + let view_date = (view_ctx.view_date)(); + let month = relative_calendar_month(date, &base_ctx, view_date.month()); let in_current_month = month.current_month(); - let is_focused = move || base_ctx.is_focused(date); + let is_focused = move || in_current_month && base_ctx.is_focused(date); let is_today = date == base_ctx.today; let is_unavailable = base_ctx.is_unavailable(date); @@ -1901,7 +2029,7 @@ fn RangeCalendarDay(props: CalendarDayProps) -> Element { return; } - let view_date = (base_ctx.view_date)(); + let view_date = (view_ctx.view_date)(); let date = view_date .replace_day(day) .ok() @@ -1922,12 +2050,8 @@ fn RangeCalendarDay(props: CalendarDayProps) -> Element { rsx! { button { class: "calendar-grid-cell", - type: "button", - tabindex: if date == focusable_date { - "0" - } else { - "-1" - }, + r#type: "button", + tabindex: if date == focusable_date { "0" } else { "-1" }, aria_label: aria_label(&props.date), "data-disabled": is_disabled(), "data-today": if is_today { true }, diff --git a/primitives/src/date_picker.rs b/primitives/src/date_picker.rs index 83090e0c..7e34cfbb 100644 --- a/primitives/src/date_picker.rs +++ b/primitives/src/date_picker.rs @@ -1,7 +1,10 @@ -//! Defines the [`DatePicker`] component and its subcomponents, which allowing users to enter or select a date value +//! Defines the [`DatePicker`] and [`DateRangePicker`] components and its subcomponents, which allowing users to enter or select a date value use crate::{ - calendar::{weekday_abbreviation, Calendar, CalendarProps}, + calendar::{ + weekday_abbreviation, AvailableRanges, CalendarProps, DateRange, RangeCalendarProps, + }, + dioxus_core::Properties, focus::{use_focus_controlled_item, use_focus_provider, FocusState}, popover::*, use_unique_id, @@ -14,18 +17,24 @@ use time::{macros::date, Date, Month, UtcDateTime, Weekday}; /// The context provided by the [`DatePicker`] component to its children. #[derive(Copy, Clone)] -struct DatePickerContext { +struct BaseDatePickerContext { // State - on_value_change: Callback>, - selected_date: ReadSignal>, open: Signal, read_only: ReadSignal, // Configuration disabled: ReadSignal, focus: FocusState, - min_date: Date, - max_date: Date, + enabled_date_range: DateRange, + available_ranges: Memo, + month_count: u8, +} + +/// The context provided by the [`DatePicker`] component to its children. +#[derive(Copy, Clone)] +struct DatePickerContext { + on_value_change: Callback>, + selected_date: ReadSignal>, } impl DatePickerContext { @@ -34,8 +43,6 @@ impl DatePickerContext { if value != date { self.on_value_change.call(date); } - - self.open.set(false); } } @@ -66,6 +73,14 @@ pub struct DatePickerProps { #[props(default = date!(2050-12-31))] pub max_date: Date, + /// Specify how many months are visible at once + #[props(default = 1)] + pub month_count: u8, + + /// Unavailable dates + #[props(default)] + pub disabled_ranges: ReadSignal>, + /// Whether focus should loop around when reaching the end. #[props(default = ReadSignal::new(Signal::new(false)))] pub roving_loop: ReadSignal, @@ -85,7 +100,7 @@ pub struct DatePickerProps { /// ## Example /// ```rust /// use dioxus::prelude::*; -/// use dioxus_primitives::{date_picker::*, popover::*, ContentAlign}; +/// use dioxus_primitives::{calendar::Calendar, date_picker::*, popover::*, ContentAlign}; /// use time::Date; /// #[component] /// pub fn Demo() -> Element { @@ -106,8 +121,7 @@ pub struct DatePickerProps { /// PopoverContent { /// align: ContentAlign::End, /// DatePickerCalendar { -/// selected_date: selected_date(), -/// on_date_change: move |date| selected_date.set(date), +/// calendar: Calendar, /// } /// } /// } @@ -126,23 +140,168 @@ pub struct DatePickerProps { pub fn DatePicker(props: DatePickerProps) -> Element { let open = use_signal(|| false); let focus = use_focus_provider(props.roving_loop); + let available_ranges = use_memo(move || AvailableRanges::new(&props.disabled_ranges.read())); // Create context provider for child components + use_context_provider(|| BaseDatePickerContext { + open, + read_only: props.read_only, + disabled: props.disabled, + focus, + enabled_date_range: DateRange::new(props.min_date, props.max_date), + available_ranges, + month_count: props.month_count, + }); + use_context_provider(|| DatePickerContext { on_value_change: props.on_value_change, selected_date: props.selected_date, + }); + + rsx! { + div { + role: "group", + aria_label: "Date", + "data-disabled": (props.disabled)(), + ..props.attributes, + {props.children} + } + } +} + +/// The context provided by the [`DateRangePicker`] component to its children. +#[derive(Copy, Clone)] +pub struct DateRangePickerContext { + // Currently selected date range + date_range: ReadSignal>, + set_selected_range: Callback>, +} + +impl DateRangePickerContext { + /// Set the selected date + pub fn set_range(&mut self, range: Option) { + if (self.date_range)() != range { + self.set_selected_range.call(range); + } + } +} + +/// The props for the [`DatePicker`] component. +#[derive(Props, Clone, PartialEq)] +pub struct DateRangePickerProps { + /// Callback when value changes + #[props(default)] + pub on_range_change: Callback>, + + /// The selected date + #[props(default)] + pub selected_range: ReadSignal>, + + /// Whether the date picker is disabled + #[props(default)] + pub disabled: ReadSignal, + + /// Whether the date picker is enable user input + #[props(default = ReadSignal::new(Signal::new(false)))] + pub read_only: ReadSignal, + + /// Lower limit of the range of available dates + #[props(default = date!(1925-01-01))] + pub min_date: Date, + + /// Upper limit of the range of available dates + #[props(default = date!(2050-12-31))] + pub max_date: Date, + + /// Specify how many months are visible at once + #[props(default = 1)] + pub month_count: u8, + + /// Unavailable dates + #[props(default)] + pub disabled_ranges: ReadSignal>, + + /// Whether focus should loop around when reaching the end. + #[props(default = ReadSignal::new(Signal::new(false)))] + pub roving_loop: ReadSignal, + + /// Additional attributes to extend the date picker element + #[props(extends = GlobalAttributes)] + pub attributes: Vec, + + /// The children of the date picker element + pub children: Element, +} + +/// # DateRangePicker +/// +/// The [`DateRangePicker`] component provides an accessible date range input interface. +/// +/// ## Example +/// ```rust +/// use dioxus::prelude::*; +/// use dioxus_primitives::{calendar::{DateRange, RangeCalendar}, date_picker::*, popover::*, ContentAlign}; +/// #[component] +/// pub fn Demo() -> Element { +/// let mut selected_range = use_signal(|| None::); +/// rsx! { +/// div { +/// DateRangePicker { +/// selected_range: selected_range(), +/// on_range_change: move |range| { +/// tracing::info!("Selected range: {:?}", range); +/// selected_range.set(range); +/// }, +/// DatePickerPopover { +/// DatePickerInput { +/// PopoverTrigger { +/// "Select date" +/// } +/// PopoverContent { +/// align: ContentAlign::End, +/// DateRangePickerCalendar { +/// calendar: RangeCalendar, +/// } +/// } +/// } +/// } +/// } +/// } +/// } +///} +/// ``` +/// +/// # Styling +/// +/// The [`DateRangePicker`] component defines the following data attributes you can use to control styling: +/// - `data-disabled`: Indicates if the DateRangePicker is disabled. Possible values are `true` or `false`. +#[component] +pub fn DateRangePicker(props: DateRangePickerProps) -> Element { + let open = use_signal(|| false); + let focus = use_focus_provider(props.roving_loop); + + let available_ranges = use_memo(move || AvailableRanges::new(&props.disabled_ranges.read())); + + // Create context provider for child components + use_context_provider(|| BaseDatePickerContext { open, read_only: props.read_only, disabled: props.disabled, focus, - min_date: props.min_date, - max_date: props.max_date, + enabled_date_range: DateRange::new(props.min_date, props.max_date), + available_ranges, + month_count: props.month_count, + }); + + use_context_provider(|| DateRangePickerContext { + date_range: props.selected_range, + set_selected_range: props.on_range_change, }); rsx! { div { role: "group", - aria_label: "Date", + aria_label: "Date Range", "data-disabled": (props.disabled)(), ..props.attributes, {props.children} @@ -188,7 +347,7 @@ pub struct DatePickerPopoverProps { /// ## Example /// ```rust /// use dioxus::prelude::*; -/// use dioxus_primitives::{date_picker::*, popover::*, ContentAlign}; +/// use dioxus_primitives::{calendar::Calendar, date_picker::*, popover::*, ContentAlign}; /// use time::Date; /// #[component] /// pub fn Demo() -> Element { @@ -209,8 +368,7 @@ pub struct DatePickerPopoverProps { /// PopoverContent { /// align: ContentAlign::End, /// DatePickerCalendar { -/// selected_date: selected_date(), -/// on_date_change: move |date| selected_date.set(date), +/// calendar: Calendar, /// } /// } /// } @@ -222,7 +380,7 @@ pub struct DatePickerPopoverProps { /// ``` #[component] pub fn DatePickerPopover(props: DatePickerPopoverProps) -> Element { - let ctx = use_context::(); + let ctx = use_context::(); let mut open = ctx.open; let PopoverRoot = props.popover_root; @@ -237,18 +395,10 @@ pub fn DatePickerPopover(props: DatePickerPopoverProps) -> Element { } } -/// The props for the [`Calendar`] component. +/// The props for the Calendar component. #[allow(unpredictable_function_pointer_comparisons)] #[derive(Props, Clone, PartialEq)] -pub struct DatePickerCalendarProps { - /// The selected date - #[props(default)] - pub selected_date: ReadSignal>, - - /// Callback when selected date changes - #[props(default)] - pub on_date_change: Callback>, - +pub struct DatePickerCalendarProps { /// Callback when display weekday #[props(default = Callback::new(|weekday: Weekday| weekday_abbreviation(weekday).to_string()))] pub on_format_weekday: Callback, @@ -285,6 +435,14 @@ pub struct DatePickerCalendarProps { #[props(default = date!(2050-12-31))] pub max_date: Date, + /// Specify how many months are visible at once + #[props(default = 1)] + pub month_count: u8, + + /// Unavailable dates + #[props(default)] + pub disabled_ranges: ReadSignal>, + /// Additional attributes to extend the calendar element #[props(extends = GlobalAttributes)] pub attributes: Vec, @@ -293,8 +451,7 @@ pub struct DatePickerCalendarProps { pub children: Element, /// The calendar to render with - #[props(default = Calendar)] - pub calendar: fn(CalendarProps) -> Element, + pub calendar: fn(T) -> Element, } /// # DatePickerCalendar @@ -305,7 +462,7 @@ pub struct DatePickerCalendarProps { /// ## Example /// ```rust /// use dioxus::prelude::*; -/// use dioxus_primitives::{date_picker::*, popover::*, ContentAlign}; +/// use dioxus_primitives::{calendar::Calendar, date_picker::*, popover::*, ContentAlign}; /// use time::Date; /// #[component] /// pub fn Demo() -> Element { @@ -326,8 +483,7 @@ pub struct DatePickerCalendarProps { /// PopoverContent { /// align: ContentAlign::End, /// DatePickerCalendar { -/// selected_date: selected_date(), -/// on_date_change: move |date| selected_date.set(date), +/// calendar: Calendar, /// } /// } /// } @@ -338,24 +494,30 @@ pub struct DatePickerCalendarProps { ///} /// ``` #[component] -pub fn DatePickerCalendar(props: DatePickerCalendarProps) -> Element { +pub fn DatePickerCalendar(props: DatePickerCalendarProps) -> Element { + let mut base_ctx = use_context::(); let mut ctx = use_context::(); + #[allow(non_snake_case)] let Calendar = props.calendar; let mut view_date = use_signal(|| UtcDateTime::now().date()); use_effect(move || { - if let Some(date) = (props.selected_date)() { + if let Some(date) = (ctx.selected_date)() { view_date.set(date); } }); + let (min_date, max_date) = base_ctx.enabled_date_range.to_min_max(); + rsx! { Calendar { selected_date: ctx.selected_date, on_date_change: move |date| { tracing::info!("calendar selected date {date:?}"); - ctx.set_date(date) + ctx.set_date(date); + base_ctx.open.set(false); }, + disabled_ranges: base_ctx.available_ranges.read().to_disabled_ranges(), on_format_weekday: props.on_format_weekday, on_format_month: props.on_format_month, view_date: view_date(), @@ -363,8 +525,88 @@ pub fn DatePickerCalendar(props: DatePickerCalendarProps) -> Element { today: props.today, disabled: props.disabled, first_day_of_week: props.first_day_of_week, - min_date: ctx.min_date, - max_date: ctx.max_date, + min_date, + max_date, + month_count: base_ctx.month_count, + attributes: props.attributes, + {props.children} + } + } +} + +/// # DateRangePickerCalendar +/// +/// The [`DateRangePickerCalendar`] component provides an accessible calendar interface with arrow key navigation, month switching, and date range selection. +/// Used as date picker popover component +/// +/// ## Example +/// ```rust +/// use dioxus::prelude::*; +/// use dioxus_primitives::{calendar::{DateRange, RangeCalendar}, date_picker::*, popover::*, ContentAlign}; +/// #[component] +/// pub fn Demo() -> Element { +/// let mut selected_range = use_signal(|| None::); +/// rsx! { +/// div { +/// DateRangePicker { +/// selected_range: selected_range(), +/// on_range_change: move |range| { +/// tracing::info!("Selected range: {:?}", range); +/// selected_range.set(range); +/// }, +/// DatePickerPopover { +/// DateRangePickerInput { +/// PopoverTrigger { +/// "Select date" +/// } +/// PopoverContent { +/// align: ContentAlign::End, +/// DateRangePickerCalendar { +/// calendar: RangeCalendar, +/// } +/// } +/// } +/// } +/// } +/// } +/// } +///} +/// ``` +#[component] +pub fn DateRangePickerCalendar(props: DatePickerCalendarProps) -> Element { + let mut base_ctx = use_context::(); + let mut ctx = use_context::(); + + #[allow(non_snake_case)] + let RangeCalendar = props.calendar; + let mut view_date = use_signal(|| UtcDateTime::now().date()); + use_effect(move || { + if let Some(r) = (ctx.date_range)() { + view_date.set(r.start); + } + }); + + let (min_date, max_date) = base_ctx.enabled_date_range.to_min_max(); + + rsx! { + RangeCalendar { + selected_range: ctx.date_range, + on_range_change: move |range| { + tracing::info!("calendar selected range {range:?}"); + ctx.set_range(range); + base_ctx.open.set(false); + }, + disabled_ranges: base_ctx.available_ranges.read().to_disabled_ranges(), + on_format_weekday: props.on_format_weekday, + on_format_month: props.on_format_month, + view_date: view_date(), + on_view_change: move |date| view_date.set(date), + today: props.today, + disabled: props.disabled, + first_day_of_week: props.first_day_of_week, + min_date, + max_date, + month_count: base_ctx.month_count, attributes: props.attributes, {props.children} } @@ -433,7 +675,7 @@ fn DateSegment( let now_value = use_memo(move || (props.value)().unwrap_or(props.default)); - let mut ctx = use_context::(); + let mut ctx = use_context::(); let mut set_value = move |text: String| { if text.is_empty() { @@ -588,7 +830,7 @@ fn DateSegment( } #[component] -fn DateSeparator() -> Element { +fn DateSeparator(#[props(default = '-')] symbol: char) -> Element { rsx! { span { class: "date-segment", @@ -596,7 +838,137 @@ fn DateSeparator() -> Element { tabindex: "-1", "is-separator": true, "no-date": true, - {"-"} + {format!("{symbol}")} + } + } +} + +#[derive(Props, Clone, PartialEq)] +struct DateElementProps { + /// The start index (used for focus) + #[props(default = 0)] + pub start_index: usize, + + /// The selected date + pub selected_date: ReadSignal>, + + /// Callback when selected date changes + #[props(default)] + pub on_date_change: Callback>, + + /// Callback when display day placeholder + #[props(default = Callback::new(|_| "D".to_string()))] + pub on_format_day_placeholder: Callback<(), String>, + + /// Callback when display month placeholder + #[props(default = Callback::new(|_| "M".to_string()))] + pub on_format_month_placeholder: Callback<(), String>, + + /// Callback when display year placeholder + #[props(default = Callback::new(|_| "Y".to_string()))] + pub on_format_year_placeholder: Callback<(), String>, +} + +#[component] +fn DateElement(props: DateElementProps) -> Element { + let ctx = use_context::(); + + let mut day_value = use_signal(|| None); + let mut month_value = use_signal(|| None); + let mut year_value = use_signal(|| None); + + use_effect(move || { + let date = (props.selected_date)(); + year_value.set(date.map(|d| d.year())); + month_value.set(date.map(|d| d.month() as u8)); + day_value.set(date.map(|d| d.day())); + }); + + use_effect(move || { + if let (Some(year), Some(month), Some(day)) = ( + year_value(), + month_value().and_then(|m| Month::try_from(m).ok()), + day_value(), + ) { + if let Some(date) = Date::from_calendar_date(year, month, day) + .ok() + .filter(|date| ctx.enabled_date_range.contains(*date)) + .filter(|date| ctx.available_ranges.read().valid_interval(*date)) + { + tracing::info!("Parsed date: {date:?}"); + props.on_date_change.call(Some(date)); + } + } + }); + + let today = UtcDateTime::now().date(); + + let (min_date, max_date) = ctx.enabled_date_range.to_min_max(); + let min_year = min_date.year(); + let max_year = max_date.year(); + let min_month = match year_value() { + Some(year) if year == min_year => min_date.month(), + _ => Month::January, + }; + let max_month = match year_value() { + Some(year) if year == max_year => max_date.month(), + _ => Month::December, + }; + let min_day = match (year_value(), month_value()) { + (Some(year), Some(month)) if year == min_year && month == min_date.month() as u8 => { + min_date.day() + } + _ => 1, + }; + let max_day = match (year_value(), month_value()) { + (Some(year), Some(month)) if year == max_year && month == max_date.month() as u8 => { + max_date.day() + } + (Some(year), Some(month)) => { + if let Ok(month) = Month::try_from(month) { + month.length(year) + } else { + 31 + } + } + _ => 31, + }; + + rsx! { + DateSegment { + aria_label: "year", + index: props.start_index, + value: year_value, + default: today.year(), + on_value_change: move |value: Option| year_value.set(value), + min: min_year, + max: max_year, + max_length: 4, + on_format_placeholder: props.on_format_year_placeholder, + } + DateSeparator {} + DateSegment { + aria_label: "month", + index: props.start_index + 1usize, + value: month_value, + default: today.month() as u8, + on_value_change: move |value: Option| month_value.set(value), + min: min_month as u8, + max: max_month as u8, + max_length: 2, + on_format_placeholder: props.on_format_month_placeholder, + } + DateSeparator {} + DateSegment { + aria_label: "day", + index: props.start_index + 2usize, + value: day_value, + default: today.day(), + on_value_change: move |value: Option| day_value.set(value), + min: min_day, + max: max_day, + max_length: 2, + on_format_placeholder: props.on_format_day_placeholder, } } } @@ -631,7 +1003,7 @@ pub struct DatePickerInputProps { /// ## Example /// ```rust /// use dioxus::prelude::*; -/// use dioxus_primitives::{date_picker::*, popover::*, ContentAlign}; +/// use dioxus_primitives::{calendar::Calendar, date_picker::*, popover::*, ContentAlign}; /// use time::Date; /// #[component] /// pub fn Demo() -> Element { @@ -652,8 +1024,7 @@ pub struct DatePickerInputProps { /// PopoverContent { /// align: ContentAlign::End, /// DatePickerCalendar { -/// selected_date: selected_date(), -/// on_date_change: move |date| selected_date.set(date), +/// calendar: Calendar, /// } /// } /// } @@ -665,105 +1036,113 @@ pub struct DatePickerInputProps { /// ``` #[component] pub fn DatePickerInput(props: DatePickerInputProps) -> Element { + let mut base_ctx = use_context::(); let mut ctx = use_context::(); - let mut day_value = use_signal(|| None); - let mut month_value = use_signal(|| None); - let mut year_value = use_signal(|| None); + rsx! { + div { class: "date-picker-group", ..props.attributes, + DateElement { + selected_date: ctx.selected_date, + on_date_change: move |date| { + ctx.set_date(date); + base_ctx.open.set(false); + }, + on_format_day_placeholder: props.on_format_day_placeholder, + on_format_month_placeholder: props.on_format_month_placeholder, + on_format_year_placeholder: props.on_format_year_placeholder, + } + {props.children} + } + } +} + +/// # DateRangePickerInput +/// +/// The input element for the [`DateRangePicker`] component which allow users to enter a date range. +/// +/// ## Example +/// ```rust +/// use dioxus::prelude::*; +/// use dioxus_primitives::{calendar::{DateRange, RangeCalendar}, date_picker::*, popover::*, ContentAlign}; +/// #[component] +/// pub fn Demo() -> Element { +/// let mut selected_range = use_signal(|| None::); +/// rsx! { +/// div { +/// DateRangePicker { +/// selected_range: selected_range(), +/// on_range_change: move |range| { +/// tracing::info!("Selected range: {:?}", range); +/// selected_range.set(range); +/// }, +/// DatePickerPopover { +/// DateRangePickerInput { +/// PopoverTrigger { +/// "Select date" +/// } +/// PopoverContent { +/// align: ContentAlign::End, +/// DateRangePickerCalendar { +/// calendar: RangeCalendar, +/// } +/// } +/// } +/// } +/// } +/// } +/// } +///} +/// ``` +#[component] +pub fn DateRangePickerInput(props: DatePickerInputProps) -> Element { + let base_ctx = use_context::(); + let mut ctx = use_context::(); + + let mut start_date = use_signal(|| None); + let mut end_date = use_signal(|| None); use_effect(move || { - let date = (ctx.selected_date)(); - year_value.set(date.map(|d| d.year())); - month_value.set(date.map(|d| d.month() as u8)); - day_value.set(date.map(|d| d.day())); + start_date.set((ctx.date_range)().map(|r| r.start)); + end_date.set((ctx.date_range)().map(|r| r.end)); }); use_effect(move || { - if let (Some(year), Some(month), Some(day)) = ( - year_value(), - month_value().and_then(|m| Month::try_from(m).ok()), - day_value(), - ) { - if let Some(date) = Date::from_calendar_date(year, month, day) - .ok() - .filter(|date| ctx.min_date <= *date && *date <= ctx.max_date) - { - tracing::info!("Parsed date: {date:?}"); - ctx.set_date(Some(date)); + if let (Some(start), Some(end)) = (start_date(), end_date()) { + // force auto validation for input range + if end < start { + return; } - } - }); - - let today = UtcDateTime::now().date(); - let min_year = ctx.min_date.year(); - let max_year = ctx.max_date.year(); - let min_month = match year_value() { - Some(year) if year == min_year => ctx.min_date.month(), - _ => Month::January, - }; - let max_month = match year_value() { - Some(year) if year == max_year => ctx.max_date.month(), - _ => Month::December, - }; - let min_day = match (year_value(), month_value()) { - (Some(year), Some(month)) if year == min_year && month == ctx.min_date.month() as u8 => { - ctx.min_date.day() - } - _ => 1, - }; - let max_day = match (year_value(), month_value()) { - (Some(year), Some(month)) if year == max_year && month == ctx.max_date.month() as u8 => { - ctx.max_date.day() - } - (Some(year), Some(month)) => { - if let Ok(month) = Month::try_from(month) { - month.length(year) - } else { - 31 + // checking non-contiguous ranges + if base_ctx + .available_ranges + .read() + .available_range(start, base_ctx.enabled_date_range) + .is_some_and(|r| r.contains(end)) + { + let range = Some(DateRange::new(start, end)); + ctx.set_range(range); } - } - _ => 31, - }; + }; + }); rsx! { - div { - class: "date-picker-group", - ..props.attributes, - DateSegment { - aria_label: "year", - index: 0usize, - value: year_value, - default: today.year(), - on_value_change: move |value: Option| year_value.set(value), - min: min_year, - max: max_year, - max_length: 4, - on_format_placeholder: props.on_format_year_placeholder, - } - DateSeparator {} - DateSegment { - aria_label: "month", - index: 1usize, - value: month_value, - default: today.month() as u8, - on_value_change: move |value: Option| month_value.set(value), - min: min_month as u8, - max: max_month as u8, - max_length: 2, - on_format_placeholder: props.on_format_month_placeholder, + div { class: "date-picker-group", ..props.attributes, + DateElement { + selected_date: start_date(), + on_date_change: move |date| start_date.set(date), + on_format_day_placeholder: props.on_format_day_placeholder, + on_format_month_placeholder: props.on_format_month_placeholder, + on_format_year_placeholder: props.on_format_year_placeholder, } - DateSeparator {} - DateSegment { - aria_label: "day", - index: 2usize, - value: day_value, - default: today.day(), - on_value_change: move |value: Option| day_value.set(value), - min: min_day, - max: max_day, - max_length: 2, - on_format_placeholder: props.on_format_day_placeholder, + DateSeparator { symbol: '—' } + DateElement { + start_index: 3, + selected_date: end_date(), + on_date_change: move |date| end_date.set(date), + on_format_day_placeholder: props.on_format_day_placeholder, + on_format_month_placeholder: props.on_format_month_placeholder, + on_format_year_placeholder: props.on_format_year_placeholder, } {props.children} }