From 3bba304d92b731866cfdc59385dd4ef11465cae3 Mon Sep 17 00:00:00 2001 From: Dmitry Date: Fri, 14 Nov 2025 18:17:39 +0300 Subject: [PATCH] [*] DatePicker: date range selection - Add DatePicker variants - Draft implementation DateRange for DatePicker - Extend DateRange struct - Use min and max dates in props as DateRange - Spell check fix for Calendar --- .../src/components/date_picker/component.rs | 52 +- .../variants/internationalized/mod.rs | 27 + .../date_picker/variants/main/mod.rs | 7 +- .../date_picker/variants/range/mod.rs | 22 + preview/src/components/mod.rs | 2 +- primitives/src/calendar.rs | 82 ++- primitives/src/date_picker.rs | 600 ++++++++++++++---- 7 files changed, 615 insertions(+), 177 deletions(-) create mode 100644 preview/src/components/date_picker/variants/internationalized/mod.rs create mode 100644 preview/src/components/date_picker/variants/range/mod.rs diff --git a/preview/src/components/date_picker/component.rs b/preview/src/components/date_picker/component.rs index f82bac33..50520e40 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, }; @@ -30,6 +30,27 @@ pub fn DatePicker(props: DatePickerProps) -> Element { } } +#[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, + attributes: props.attributes, + date_picker::DatePickerPopover { + popover_root: PopoverRoot, + {props.children} + } + } + } + } +} + #[component] pub fn DatePickerInput(props: DatePickerInputProps) -> Element { rsx! { @@ -59,6 +80,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/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/mod.rs b/preview/src/components/mod.rs index 887b2bbe..85628fc2 100644 --- a/preview/src/components/mod.rs +++ b/preview/src/components/mod.rs @@ -66,7 +66,7 @@ examples!( checkbox, collapsible, context_menu, - date_picker, + date_picker[internationalized, range], dialog, dropdown_menu, hover_card, diff --git a/primitives/src/calendar.rs b/primitives/src/calendar.rs index 0fad46d6..cc018ac4 100644 --- a/primitives/src/calendar.rs +++ b/primitives/src/calendar.rs @@ -155,12 +155,12 @@ fn replace_month(date: Date, month: Month) -> Date { } /// 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 +176,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 +188,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 { @@ -196,12 +202,12 @@ impl Display for DateRange { } #[derive(Debug, Clone, PartialEq)] -struct AvailibleRanges { +struct AvailableRanges { /// A sorted list of dates. Values after an odd number of elements are disabled. changes: Vec, } -impl AvailibleRanges { +impl AvailableRanges { fn new(disabled_ranges: &[DateRange]) -> Self { let mut sorted_range: Vec<_> = disabled_ranges .iter() @@ -237,8 +243,9 @@ impl AvailibleRanges { } } - fn available_range(&self, date: Date, min_date: Date, max_date: Date) -> Option { + 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 { @@ -267,7 +274,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 +283,7 @@ pub struct BaseCalendarContext { disabled: ReadSignal, today: Date, first_day_of_week: Weekday, - min_date: Date, - max_date: Date, + enabled_date_range: DateRange, } impl BaseCalendarContext { @@ -298,7 +304,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 +328,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) }) }) } @@ -456,7 +462,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 +475,7 @@ 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), }); // Create Calendar context provider for child components use_context_provider(|| CalendarContext { @@ -499,7 +504,7 @@ pub fn Calendar(props: CalendarProps) -> Element { 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); } }, @@ -552,6 +557,7 @@ pub fn Calendar(props: CalendarProps) -> Element { pub struct RangeCalendarContext { // The date that the user clicked on to begin range selection anchor_date: Signal>, + set_anchor_date: Callback>, // Currently highlighted date range highlighted_range: Signal>, set_selected_range: Callback>, @@ -564,6 +570,7 @@ impl RangeCalendarContext { Some(anchor) => { if let Some(date) = date { self.anchor_date.set(None); + self.set_anchor_date.call(None); let range = DateRange::new(date, anchor); self.set_selected_range.call(Some(range)); @@ -572,6 +579,7 @@ impl RangeCalendarContext { } None => { self.anchor_date.set(date); + self.set_anchor_date.call(date); let range = date.map(|d| DateRange::new(d, d)); self.highlighted_range.set(range); @@ -590,6 +598,7 @@ impl RangeCalendarContext { /// Set previous selected range pub fn reset_selection(&mut self, range: Option) { self.anchor_date.set(None); + self.set_anchor_date.call(None); self.highlighted_range.set(range); } } @@ -605,6 +614,10 @@ pub struct RangeCalendarProps { #[props(default)] pub on_range_change: Callback>, + /// Callback when anchor date changes + #[props(default)] + pub(crate) on_anchor_change: Callback>, + /// Callback when display weekday #[props(default = Callback::new(|weekday: Weekday| weekday_abbreviation(weekday).to_string()))] pub on_format_weekday: Callback, @@ -707,7 +720,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,13 +733,13 @@ 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), }); // Create RangeCalendar context provider for child components let mut ctx = use_context_provider(|| RangeCalendarContext { anchor_date, + set_anchor_date: props.on_anchor_change, highlighted_range, set_selected_range: props.on_range_change, }); @@ -762,7 +775,7 @@ pub fn RangeCalendar(props: RangeCalendarProps) -> Element { 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), @@ -1019,7 +1032,7 @@ pub fn CalendarPreviousMonthButton(props: CalendarPreviousMonthButtonProps) -> E // Get the current view date from context let view_date = (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, } }); @@ -1119,8 +1132,9 @@ pub fn CalendarNextMonthButton(props: CalendarNextMonthButtonProps) -> Element { let view_date = (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, } @@ -1464,13 +1478,14 @@ pub fn CalendarSelectMonth(props: CalendarSelectMonthProps) -> Element { let months = use_memo(move || { // Get the current view date from context let view_date = (calendar.view_date)(); + let (min_date, max_date) = calendar.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; @@ -1580,13 +1595,14 @@ pub fn CalendarSelectYear(props: CalendarSelectYearProps) -> Element { let years = use_memo(move || { // Get the current view date from context let view_date = (calendar.view_date)(); + let (min_date, max_date) = calendar.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; } @@ -1743,9 +1759,9 @@ fn relative_calendar_month( base_ctx: &BaseCalendarContext, view_date: Date, ) -> 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; diff --git a/primitives/src/date_picker.rs b/primitives/src/date_picker.rs index 83090e0c..8dae9ddd 100644 --- a/primitives/src/date_picker.rs +++ b/primitives/src/date_picker.rs @@ -1,7 +1,8 @@ -//! 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, CalendarProps, DateRange, RangeCalendarProps}, + dioxus_core::Properties, focus::{use_focus_controlled_item, use_focus_provider, FocusState}, popover::*, use_unique_id, @@ -14,18 +15,22 @@ 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, +} + +/// 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 +39,6 @@ impl DatePickerContext { if value != date { self.on_value_change.call(date); } - - self.open.set(false); } } @@ -85,7 +88,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 +109,7 @@ pub struct DatePickerProps { /// PopoverContent { /// align: ContentAlign::End, /// DatePickerCalendar { -/// selected_date: selected_date(), -/// on_date_change: move |date| selected_date.set(date), +/// calendar: Calendar, /// } /// } /// } @@ -128,21 +130,168 @@ pub fn DatePicker(props: DatePickerProps) -> Element { let focus = use_focus_provider(props.roving_loop); // 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), + }); + 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 [`RangeCalendar`] component to its children. +#[derive(Copy, Clone)] +pub struct DateRangePickerContext { + anchor_date: Signal>, + // Currently highlighted date range + highlighted_range: Signal>, + set_selected_range: Callback>, +} + +impl DateRangePickerContext { + /// Set the selected date + pub fn set_selected_date(&mut self, date: Option) { + match (self.anchor_date)() { + Some(anchor) => { + if let Some(date) = date { + self.anchor_date.set(None); + + let range = DateRange::new(date, anchor); + tracing::info!("new range {range}"); + self.set_selected_range.call(Some(range)); + self.highlighted_range.set(Some(range)); + } + } + None => { + self.anchor_date.set(date); + tracing::info!("anchor {date:?}"); + } + } + } +} + +/// 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, + + /// 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); + + // 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), + }); + + let anchor_date = use_signal(|| None); + let highlighted_range = use_signal(|| (props.selected_range)()); + use_context_provider(|| DateRangePickerContext { + anchor_date, + highlighted_range, + set_selected_range: props.on_range_change, }); rsx! { div { role: "group", - aria_label: "Date", + aria_label: "DateRange", "data-disabled": (props.disabled)(), ..props.attributes, {props.children} @@ -188,7 +337,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 +358,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 +370,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 +385,10 @@ pub fn DatePickerPopover(props: DatePickerPopoverProps) -> Element { } } -/// The props for the [`Calendar`] component. +/// The props for the [`Calendar`] and [`RangeCalendar`] 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, @@ -293,8 +433,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 +444,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 +465,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 +476,107 @@ 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); + }, + 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, + 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.highlighted_range)() { + view_date.set(r.end); + } + }); + + let (min_date, max_date) = base_ctx.enabled_date_range.to_min_max(); + + rsx! { + RangeCalendar { + selected_range: ctx.highlighted_range, + on_range_change: move |range| { + tracing::info!("calendar selected date {range:?}"); + ctx.highlighted_range.set(range); + base_ctx.open.set(false); }, + on_anchor_change: move |date| ctx.set_selected_date(date), on_format_weekday: props.on_format_weekday, on_format_month: props.on_format_month, view_date: view_date(), @@ -363,8 +584,8 @@ 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, attributes: props.attributes, {props.children} } @@ -433,7 +654,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 +809,7 @@ fn DateSegment( } #[component] -fn DateSeparator() -> Element { +fn DateSeparator(#[props(default = '-')] symbol: char) -> Element { rsx! { span { class: "date-segment", @@ -596,7 +817,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 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); + + 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)) + { + tracing::info!("Parsed date: {date:?}"); + props.on_date_change.call(Some(date)); + ctx.open.set(false); + } + } + }); + + 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 +982,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 +1003,7 @@ pub struct DatePickerInputProps { /// PopoverContent { /// align: ContentAlign::End, /// DatePickerCalendar { -/// selected_date: selected_date(), -/// on_date_change: move |date| selected_date.set(date), +/// calendar: Calendar, /// } /// } /// } @@ -667,103 +1017,81 @@ pub struct DatePickerInputProps { pub fn DatePickerInput(props: DatePickerInputProps) -> Element { 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); - - 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())); - }); - - 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)); + rsx! { + div { class: "date-picker-group", ..props.attributes, + DateElement { + selected_date: ctx.selected_date, + on_date_change: move |date| ctx.set_date(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} } - }); - - 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 - } - } - _ => 31, - }; +/// # 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 mut ctx = use_context::(); + let start_date = + use_signal(|| (ctx.anchor_date)().or((ctx.highlighted_range)().map(|r| r.start))); + let end_date = use_signal(|| (ctx.highlighted_range)().map(|r| r.end)); 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| ctx.set_selected_date(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| ctx.set_selected_date(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} }