diff --git a/Cargo.lock b/Cargo.lock index 63c727e3..1ae3c791 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2174,6 +2174,7 @@ dependencies = [ "dioxus", "dioxus-time", "lazy-js-bundle 0.6.2", + "num-integer", "time", "tracing", ] diff --git a/README.md b/README.md index f950e463..2dbf1b76 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ Building styled and more featured component libraries on top of Dioxus Primitive We're still in the early days - Many components are still being created and stabilized. -28/28 +28/29 - [x] Accordion - [x] Alert Dialog @@ -43,6 +43,7 @@ We're still in the early days - Many components are still being created and stab - [x] Checkbox - [x] Collapsible - [x] Context Menu +- [ ] Date Picker - [x] Dialog - [x] Dropdown Menu - [x] Hover Card diff --git a/component.json b/component.json index 8984b2b3..628343eb 100644 --- a/component.json +++ b/component.json @@ -32,6 +32,7 @@ "preview/src/components/toggle_group", "preview/src/components/context_menu", "preview/src/components/aspect_ratio", - "preview/src/components/scroll_area" + "preview/src/components/scroll_area", + "preview/src/components/date_picker" ] } diff --git a/preview/src/components/date_picker/component.json b/preview/src/components/date_picker/component.json new file mode 100644 index 00000000..583cd28f --- /dev/null +++ b/preview/src/components/date_picker/component.json @@ -0,0 +1,13 @@ +{ + "name": "date_picker", + "description": "A date picker component to select or input dates.", + "authors": ["Evan Almloff"], + "exclude": ["variants", "docs.md", "component.json"], + "cargoDependencies": [ + { + "name": "dioxus-primitives", + "git": "https://github.com/DioxusLabs/components" + } + ], + "globalAssets": ["../../../assets/dx-components-theme.css"] +} diff --git a/preview/src/components/date_picker/component.rs b/preview/src/components/date_picker/component.rs new file mode 100644 index 00000000..d9b5dc37 --- /dev/null +++ b/preview/src/components/date_picker/component.rs @@ -0,0 +1,115 @@ +use dioxus::prelude::*; + +use dioxus_primitives::{ + calendar::CalendarProps, + date_picker::{self, DatePickerInputProps, DatePickerProps}, + popover::{PopoverContentProps, PopoverRootProps, PopoverTriggerProps}, +}; + +use crate::components::calendar::component::*; + +#[component] +pub fn DatePicker(props: DatePickerProps) -> Element { + rsx! { + document::Link { rel: "stylesheet", href: asset!("./style.css") } + div { + date_picker::DatePicker { + class: "date-picker", + value: props.value, + on_value_change: props.on_value_change, + selected_date: props.selected_date, + disabled: props.disabled, + read_only: props.read_only, + attributes: props.attributes, + {props.children} + } + } + } +} + +#[component] +pub fn DatePickerInput(props: DatePickerInputProps) -> Element { + rsx! { + date_picker::DatePickerInput { + class: "date-picker-input", + 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} + } + } +} + +#[component] +pub fn DatePickerPopover(props: PopoverRootProps) -> Element { + rsx! { + date_picker::DatePickerPopover { + class: "popover", + is_modal: props.is_modal, + default_open: props.default_open, + attributes: props.attributes, + {props.children} + } + } +} + +#[component] +pub fn DatePickerPopoverTrigger(props: PopoverTriggerProps) -> Element { + rsx! { + date_picker::DatePickerPopoverTrigger { + class: "date-picker-trigger", + attributes: props.attributes, + svg { + class: "date-picker-expand-icon", + view_box: "0 0 24 24", + xmlns: "http://www.w3.org/2000/svg", + polyline { points: "6 9 12 15 18 9" } + } + } + } +} + +#[component] +pub fn DatePickerPopoverContent(props: PopoverContentProps) -> Element { + rsx! { + date_picker::DatePickerPopoverContent { + class: "popover-content", + id: props.id, + side: props.side, + align: props.align, + attributes: props.attributes, + {props.children} + } + } +} + +#[component] +pub fn DatePickerCalendar(props: CalendarProps) -> Element { + rsx! { + date_picker::DatePickerCalendar { + class: "calendar", + selected_date: props.selected_date, + on_date_change: props.on_date_change, + on_format_weekday: props.on_format_weekday, + on_format_month: props.on_format_month, + view_date: props.view_date, + today: props.today, + on_view_change: props.on_view_change, + disabled: props.disabled, + first_day_of_week: props.first_day_of_week, + min_date: props.min_date, + max_date: props.max_date, + attributes: props.attributes, + CalendarHeader { + CalendarNavigation { + CalendarPreviousMonthButton {} + CalendarSelectMonth {} + CalendarSelectYear {} + CalendarNextMonthButton {} + } + } + CalendarGrid {} + } + } +} diff --git a/preview/src/components/date_picker/docs.md b/preview/src/components/date_picker/docs.md new file mode 100644 index 00000000..e674f9a7 --- /dev/null +++ b/preview/src/components/date_picker/docs.md @@ -0,0 +1,36 @@ +The DatePicker component is used to display a date input and a Calendar popover, allowing users to enter or select a date value. + +## Component Structure + +```rust +DatePicker { + // The currently value in the date picker (type DatePickerValue). + value, + // The currently selected date in the date picker (if any). + selected_date, + on_value_change: move |v: DatePickerValue| { + // This callback is triggered when a date is selected in the + // calendar or the user entered it from the keyboard. + // The date parameter contains the selected date. + }, + // Allows the user to enter a date using the keyboard. + // The input field should contain a button to display the calendar and the calendar itself. + DatePickerInput { + // The DatePickerPopover is the root popover component that contains the trigger and Calendar. + DatePickerPopover { + // The DatePickerPopoverTrigger contains the elements that will trigger the popover + // to display Calendar when clicked. + DatePickerPopoverTrigger {} + // The DatePickerPopoverContent contains the Calendar that will be displayed when + // the user clicks on the trigger. + DatePickerPopoverContent { + // The alignment of the DatePickerPopoverContent relative to the DatePickerPopoverTrigger. + // Can be one of Start, Center, or End. Recommended use End for default value. + align: ContentAlign::End, + // Customized Calendar components + DatePickerCalendar {} + } + } + } +} +``` \ No newline at end of file diff --git a/preview/src/components/date_picker/mod.rs b/preview/src/components/date_picker/mod.rs new file mode 100644 index 00000000..9a8ae556 --- /dev/null +++ b/preview/src/components/date_picker/mod.rs @@ -0,0 +1,2 @@ +mod component; +pub use component::*; \ No newline at end of file diff --git a/preview/src/components/date_picker/style.css b/preview/src/components/date_picker/style.css new file mode 100644 index 00000000..955cd16f --- /dev/null +++ b/preview/src/components/date_picker/style.css @@ -0,0 +1,111 @@ +.date-picker { + position: relative; + display: inline-flex; + align-items: center; +} + +.date-picker-input { + position: relative; + display: flex; + box-sizing: border-box; + flex-direction: row; + align-items: center; + justify-content: space-between; + padding: 0.25rem; + padding: 8px 40px 8px 12px; + border: none; + border-radius: 0.5rem; + border-radius: calc(0.5rem); + background: none; + background: var(--light, var(--primary-color)) + var(--dark, var(--primary-color-3)); + box-shadow: inset 0 0 0 1px var(--light, var(--primary-color-6)) + var(--dark, var(--primary-color-7)); + color: var(--secondary-color-4); + gap: 0.25rem; + transition: background-color 100ms ease-out; +} + +.date-picker-trigger { + border: none; + background-color: transparent; + cursor: pointer; + position: relative; + margin-left: -35px; +} + +.date-picker-input { + color: var(--secondary-color-5); +} + +.date-picker[data-state="open"] .date-picker-input { + pointer-events: none; +} + +.date-picker-expand-icon { + width: 20px; + height: 20px; + fill: none; + stroke: var(--primary-color-7); + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 2; +} + +.date-picker[data-disabled="true"] .date-picker-input { + color: var(--secondary-color-5); + cursor: not-allowed; +} + +.date-picker-input:hover:not([data-disabled="true"]), +.date-picker-input:focus-visible { + background: var(--light, var(--primary-color-4)) + var(--dark, var(--primary-color-5)); + color: var(--secondary-color-1); + outline: none; +} + +[data-disabled="true"] { + cursor: not-allowed; + opacity: 0.5; +} + +.date-picker-group { + align-items: center; + width: fit-content; + display: flex; +} + +.date-picker-container { + display: inline; + box-sizing: border-box; + flex-direction: row; + align-items: center; + justify-content: space-between; + padding: 0.25rem; + padding: 8px 40px 8px 12px; + border: none; + border-radius: 0.5rem; + border-radius: calc(0.5rem); + background: none; + background: var(--light, var(--primary-color)) var(--dark, var(--primary-color-3)); + box-shadow: inset 0 0 0 1px var(--light, var(--primary-color-6)) var(--dark, var(--primary-color-7)); + color: var(--secondary-color-4); + gap: 0.25rem; + transition: background-color 100ms ease-out; + width: fit-content; + min-width: 150px; +} + +.date-segment { + padding: 0 2px; + caret-color: transparent; +} + +.date-segment[no-date="true"] { + color: var(--secondary-color-5); +} + +.date-segment[is-separator="true"] { + padding: 0; +} \ No newline at end of file diff --git a/preview/src/components/date_picker/variants/main/mod.rs b/preview/src/components/date_picker/variants/main/mod.rs new file mode 100644 index 00000000..0051e5ca --- /dev/null +++ b/preview/src/components/date_picker/variants/main/mod.rs @@ -0,0 +1,46 @@ +use super::super::component::*; +use dioxus::prelude::*; + +use dioxus_i18n::tid; +use dioxus_primitives::{date_picker::DatePickerValue, ContentAlign}; + +use time::{Date, Month, Weekday}; + +#[component] +pub fn Demo() -> Element { + let v = DatePickerValue::new_day(None); + let mut value = use_signal(|| v); + + let mut selected_date = use_signal(|| None::); + + rsx! { + div { + DatePicker { + value: value(), + selected_date: selected_date(), + on_value_change: move |v| { + tracing::info!("Date changed to: {v}"); + value.set(v); + selected_date.set(v.date()); + }, + DatePickerInput { + on_format_day_placeholder: || tid!("D_Abbr"), + on_format_month_placeholder: || tid!("M_Abbr"), + on_format_year_placeholder: || tid!("Y_Abbr"), + DatePickerPopover { + DatePickerPopoverTrigger {} + DatePickerPopoverContent { + align: ContentAlign::End, + DatePickerCalendar { + selected_date: selected_date(), + on_date_change: move |date| selected_date.set(date), + on_format_weekday: |weekday: Weekday| tid!(& weekday.to_string()), + on_format_month: |month: Month| tid!(& month.to_string()), + } + } + } + } + } + } + } +} diff --git a/preview/src/components/mod.rs b/preview/src/components/mod.rs index 6913e8b2..c28ca19d 100644 --- a/preview/src/components/mod.rs +++ b/preview/src/components/mod.rs @@ -64,6 +64,7 @@ examples!( checkbox, collapsible, context_menu, + date_picker, dialog, dropdown_menu, hover_card, diff --git a/preview/src/i18n/de-DE.ftl b/preview/src/i18n/de-DE.ftl index cea45f72..78f6c06a 100644 --- a/preview/src/i18n/de-DE.ftl +++ b/preview/src/i18n/de-DE.ftl @@ -19,4 +19,9 @@ Wednesday = Mi Thursday = Do Friday = Fr Saturday = Sa -Sunday = So \ No newline at end of file +Sunday = So + +## Date +D_Abbr = T +M_Abbr = M +Y_Abbr = J \ No newline at end of file diff --git a/preview/src/i18n/en-US.ftl b/preview/src/i18n/en-US.ftl index 198f3453..a9f96f35 100644 --- a/preview/src/i18n/en-US.ftl +++ b/preview/src/i18n/en-US.ftl @@ -19,4 +19,9 @@ Wednesday = We Thursday = Th Friday = Fr Saturday = Sa -Sunday = Su \ No newline at end of file +Sunday = Su + +## Date +D_Abbr = D +M_Abbr = M +Y_Abbr = Y \ No newline at end of file diff --git a/preview/src/i18n/es-ES.ftl b/preview/src/i18n/es-ES.ftl index 67393350..13e48c71 100644 --- a/preview/src/i18n/es-ES.ftl +++ b/preview/src/i18n/es-ES.ftl @@ -19,4 +19,9 @@ Wednesday = Mi Thursday = Ju Friday = Vi Saturday = Sa -Sunday = Do \ No newline at end of file +Sunday = Do + +## Date +D_Abbr = D +M_Abbr = M +Y_Abbr = Y \ No newline at end of file diff --git a/preview/src/i18n/fr-FR.ftl b/preview/src/i18n/fr-FR.ftl index 9d3e9417..7b040633 100644 --- a/preview/src/i18n/fr-FR.ftl +++ b/preview/src/i18n/fr-FR.ftl @@ -19,4 +19,9 @@ Wednesday = Me Thursday = Je Friday = Ve Saturday = Sa -Sunday = Di \ No newline at end of file +Sunday = Di + +## Date +D_Abbr = J +M_Abbr = M +Y_Abbr = A \ No newline at end of file diff --git a/primitives/Cargo.toml b/primitives/Cargo.toml index 3512968e..836358e6 100644 --- a/primitives/Cargo.toml +++ b/primitives/Cargo.toml @@ -14,7 +14,8 @@ repository = "https://github.com/DioxusLabs/components" [dependencies] dioxus.workspace = true dioxus-time = { git = "https://github.com/ealmloff/dioxus-std", branch = "0.7" } -time = { version = "0.3.41", features = ["std", "macros"] } +time = { version = "0.3.41", features = ["std", "macros", "parsing"] } +num-integer = "0.1.46" tracing.workspace = true [build-dependencies] diff --git a/primitives/src/calendar.rs b/primitives/src/calendar.rs index db0c3f25..ddb51ba1 100644 --- a/primitives/src/calendar.rs +++ b/primitives/src/calendar.rs @@ -134,6 +134,13 @@ fn previous_month(date: Date) -> Option { .ok() } +fn replace_month(date: Date, month: Month) -> Date { + let year = date.year(); + let num_days = month.length(year); + Date::from_calendar_date(year, month, std::cmp::min(date.day(), num_days)) + .expect("invalid or out-of-range date") +} + /// The context provided by the [`Calendar`] component to its children. #[derive(Copy, Clone)] pub struct CalendarContext { @@ -223,6 +230,7 @@ pub struct CalendarProps { pub on_format_month: Callback, /// The month being viewed + #[props(default = ReadSignal::new(Signal::new(UtcDateTime::now().date())))] pub view_date: ReadSignal, /// The current date (used for highlighting today) @@ -889,24 +897,22 @@ pub fn CalendarGrid(props: CalendarGridProps) -> Element { date = date.next_day().expect("invalid or out-of-range date"); } + let mut 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 { - grid.push( - view_date - .replace_day(day) - .expect("invalid or out-of-range date"), - ); + date = view_date + .replace_day(day) + .expect("invalid or out-of-range date"); + grid.push(date); } // Add empty cells to complete the grid (for a clean layout) let remainder = grid.len() % 7; if remainder > 0 { - if let Some(mut date) = next_month(view_date) { - for _ in 1..=(7 - remainder) { - grid.push(date); - date = date.next_day().expect("invalid or out-of-range date"); - } + for _ in 1..=(7 - remainder) { + date = date.next_day().expect("invalid or out-of-range date"); + grid.push(date); } } @@ -1027,11 +1033,11 @@ pub fn CalendarSelectMonth(props: CalendarSelectMonthProps) -> Element { // Get the current view date from context let view_date = (calendar.view_date)(); let mut min_month = Month::January; - if view_date.replace_month(min_month).unwrap() < calendar.min_date { + if replace_month(view_date, min_month) < calendar.min_date { min_month = calendar.min_date.month(); } let mut max_month = Month::December; - if view_date.replace_month(max_month).unwrap() > calendar.max_date { + if replace_month(view_date, max_month) > calendar.max_date { max_month = calendar.max_date.month(); } @@ -1144,11 +1150,11 @@ pub fn CalendarSelectYear(props: CalendarSelectYearProps) -> Element { let view_date = (calendar.view_date)(); let month = view_date.month(); let mut min_year = calendar.min_date.year(); - if calendar.min_date.replace_month(month).unwrap() < calendar.min_date { + if replace_month(calendar.min_date, month) < calendar.min_date { min_year += 1; } let mut max_year = calendar.max_date.year(); - if calendar.max_date.replace_month(month).unwrap() > calendar.max_date { + if replace_month(calendar.max_date, month) > calendar.max_date { max_year -= 1; } diff --git a/primitives/src/date_picker.rs b/primitives/src/date_picker.rs new file mode 100644 index 00000000..fddcea40 --- /dev/null +++ b/primitives/src/date_picker.rs @@ -0,0 +1,814 @@ +//! Defines the [`DatePicker`] component and its subcomponents, which allowing users to enter or select a date value + +use crate::{ + calendar::{Calendar, CalendarProps}, + focus::{use_focus_controlled_item, use_focus_provider, FocusState}, + popover::*, + use_unique_id, +}; + +use dioxus::prelude::*; +use num_integer::Integer; +use std::{fmt::Display, str::FromStr}; +use time::{macros::date, Date, Month, UtcDateTime}; + +/// The value of the [`DatePicker`] component. +/// Currently this can only be a single date, but support for ranges is planned. +#[derive(Copy, Clone)] +pub struct DatePickerValue { + /// Current date value + value: DateValue, +} + +impl DatePickerValue { + /// Create a single day value + pub fn new_day(date: Option) -> Self { + match date { + Some(date) => Self { + value: DateValue::Single { date }, + }, + None => Self { + value: DateValue::Empty, + }, + } + } + + /// Return current selected date + pub fn date(&self) -> Option { + match self.value { + DateValue::Single { date } => Some(date), + DateValue::Empty => None, + } + } + + // Returns `true` if the given date is selected + fn is_date_selected(&self, date: Option) -> bool { + self.date() == date + } +} + +impl std::fmt::Display for DatePickerValue { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.value) + } +} + +/// The value type of the [`DatePicker`] component. +#[derive(Debug, Copy, Clone, PartialEq)] +pub enum DateValue { + /// A single value for the date picker + Single { + /// The selected date + date: Date, + }, + /// None value for the date picker + Empty, +} + +impl std::fmt::Display for DateValue { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + DateValue::Single { date } => write!(f, "{date}"), + DateValue::Empty => write!(f, ""), + } + } +} + +/// The context provided by the [`DatePicker`] component to its children. +#[derive(Copy, Clone)] +struct DatePickerContext { + // State + value: ReadSignal, + on_value_change: Callback, + selected_date: ReadSignal>, + open: Signal, + read_only: ReadSignal, + + // Configuration + disabled: ReadSignal, + focus: FocusState, + min_date: Date, + max_date: Date, +} + +impl DatePickerContext { + fn set_date(&mut self, date: Option) { + let value = (self.value)(); + if value.is_date_selected(date) { + return; + } + + let value = DatePickerValue::new_day(date); + self.on_value_change.call(value); + + self.open.set(false); + } +} + +/// The props for the [`DatePicker`] component. +#[derive(Props, Clone, PartialEq)] +pub struct DatePickerProps { + /// The controlled value of the date picker + pub value: ReadSignal, + + /// Callback when value changes + #[props(default)] + pub on_value_change: Callback, + + /// The selected date + #[props(default)] + pub selected_date: 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, +} + +/// # DatePicker +/// +/// The [`DatePicker`] component provides an accessible date input interface. +/// +/// ## Example +/// ```rust +/// use dioxus::prelude::*; +/// use dioxus_primitives::{date_picker::*, ContentAlign}; +/// use time::Date; +/// #[component] +/// pub fn Demo() -> Element { +/// let v = DatePickerValue::new_day(None); +/// let mut value = use_signal(|| v); +/// let mut selected_date = use_signal(|| None::); +/// rsx! { +/// div { +/// DatePicker { +/// value: value(), +/// selected_date: selected_date(), +/// on_value_change: move |v| { +/// tracing::info!("Date changed to: {v}"); +/// value.set(v); +/// selected_date.set(v.date()); +/// }, +/// DatePickerInput { +/// DatePickerPopover { +/// DatePickerPopoverTrigger {} +/// DatePickerPopoverContent { +/// align: ContentAlign::End, +/// DatePickerCalendar { +/// selected_date: selected_date(), +/// on_date_change: move |date| selected_date.set(date), +/// } +/// } +/// } +/// } +/// } +/// } +/// } +///} +/// ``` +/// +/// # Styling +/// +/// The [`DatePicker`] component defines the following data attributes you can use to control styling: +/// - `data-disabled`: Indicates if the DatePicker is disabled. Possible values are `true` or `false`. +#[component] +pub fn DatePicker(props: DatePickerProps) -> Element { + let open = use_signal(|| false); + let focus = use_focus_provider(props.roving_loop); + + // Create context provider for child components + use_context_provider(|| DatePickerContext { + value: props.value, + on_value_change: props.on_value_change, + selected_date: props.selected_date, + open, + read_only: props.read_only, + disabled: props.disabled, + focus, + min_date: props.min_date, + max_date: props.max_date, + }); + + rsx! { + div { + role: "application", + "aria-label": "DatePicker", + "data-disabled": (props.disabled)(), + ..props.attributes, + {props.children} + } + } +} + +/// # DatePickerPopover +/// +/// The `DatePickerPopover` component wraps all the popover components and manages the state. +/// +/// ## Example +/// ```rust +/// use dioxus::prelude::*; +/// use dioxus_primitives::{date_picker::*, ContentAlign}; +/// use time::Date; +/// #[component] +/// pub fn Demo() -> Element { +/// let v = DatePickerValue::new_day(None); +/// let mut value = use_signal(|| v); +/// let mut selected_date = use_signal(|| None::); +/// rsx! { +/// div { +/// DatePicker { +/// value: value(), +/// selected_date: selected_date(), +/// on_value_change: move |v| { +/// tracing::info!("Date changed to: {v}"); +/// value.set(v); +/// selected_date.set(v.date()); +/// }, +/// DatePickerInput { +/// DatePickerPopover { +/// DatePickerPopoverTrigger {} +/// DatePickerPopoverContent { +/// align: ContentAlign::End, +/// DatePickerCalendar { +/// selected_date: selected_date(), +/// on_date_change: move |date| selected_date.set(date), +/// } +/// } +/// } +/// } +/// } +/// } +/// } +///} +/// ``` +#[component] +pub fn DatePickerPopover(props: PopoverRootProps) -> Element { + let ctx = use_context::(); + let mut open = ctx.open; + + rsx! { + PopoverRoot { + open: open(), + on_open_change: move |v| open.set(v), + attributes: props.attributes, + {props.children} + } + } +} + +/// # DatePickerPopoverTrigger +/// +/// The `DatePickerPopoverTrigger` is a button that toggles the visibility of the [`DatePickerPopoverContent`]. +/// +/// This must be used inside a [`DatePickerPopover`] component. +/// +/// ## Example +/// ```rust +/// use dioxus::prelude::*; +/// use dioxus_primitives::{date_picker::*, ContentAlign}; +/// use time::Date; +/// #[component] +/// pub fn Demo() -> Element { +/// let v = DatePickerValue::new_day(None); +/// let mut value = use_signal(|| v); +/// let mut selected_date = use_signal(|| None::); +/// rsx! { +/// div { +/// DatePicker { +/// value: value(), +/// selected_date: selected_date(), +/// on_value_change: move |v| { +/// tracing::info!("Date changed to: {v}"); +/// value.set(v); +/// selected_date.set(v.date()); +/// }, +/// DatePickerInput { +/// DatePickerPopover { +/// DatePickerPopoverTrigger {} +/// DatePickerPopoverContent { +/// align: ContentAlign::End, +/// DatePickerCalendar { +/// selected_date: selected_date(), +/// on_date_change: move |date| selected_date.set(date), +/// } +/// } +/// } +/// } +/// } +/// } +/// } +///} +/// ``` +#[component] +pub fn DatePickerPopoverTrigger(props: PopoverTriggerProps) -> Element { + rsx! { + PopoverTrigger { + aria_label: "Show Calendar", + attributes: props.attributes, + {props.children} + } + } +} + +/// # DatePickerPopoverContent +/// +/// The `DatePickerPopoverContent` component defines the content of the popover. This component will +/// only be rendered if the popover is open. +/// +/// This must be used inside a [`DatePickerPopover`] component. +/// +/// ## Example +/// ```rust +/// use dioxus::prelude::*; +/// use dioxus_primitives::{date_picker::*, ContentAlign}; +/// use time::Date; +/// #[component] +/// pub fn Demo() -> Element { +/// let v = DatePickerValue::new_day(None); +/// let mut value = use_signal(|| v); +/// let mut selected_date = use_signal(|| None::); +/// rsx! { +/// div { +/// DatePicker { +/// value: value(), +/// selected_date: selected_date(), +/// on_value_change: move |v| { +/// tracing::info!("Date changed to: {v}"); +/// value.set(v); +/// selected_date.set(v.date()); +/// }, +/// DatePickerInput { +/// DatePickerPopover { +/// DatePickerPopoverTrigger {} +/// DatePickerPopoverContent { +/// align: ContentAlign::End, +/// DatePickerCalendar { +/// selected_date: selected_date(), +/// on_date_change: move |date| selected_date.set(date), +/// } +/// } +/// } +/// } +/// } +/// } +/// } +///} +/// ``` +#[component] +pub fn DatePickerPopoverContent(props: PopoverContentProps) -> Element { + rsx! { + PopoverContent { + id: props.id, + side: props.side, + align: props.align, + attributes: props.attributes, + {props.children} + } + } +} + +/// # DatePickerCalendar +/// +/// The [`DatePickerCalendar`] component provides an accessible calendar interface with arrow key navigation, month switching, and date selection. +/// Used as date picker popover component +/// +/// ## Example +/// ```rust +/// use dioxus::prelude::*; +/// use dioxus_primitives::{date_picker::*, ContentAlign}; +/// use time::Date; +/// #[component] +/// pub fn Demo() -> Element { +/// let v = DatePickerValue::new_day(None); +/// let mut value = use_signal(|| v); +/// let mut selected_date = use_signal(|| None::); +/// rsx! { +/// div { +/// DatePicker { +/// value: value(), +/// selected_date: selected_date(), +/// on_value_change: move |v| { +/// tracing::info!("Date changed to: {v}"); +/// value.set(v); +/// selected_date.set(v.date()); +/// }, +/// DatePickerInput { +/// DatePickerPopover { +/// DatePickerPopoverTrigger {} +/// DatePickerPopoverContent { +/// align: ContentAlign::End, +/// DatePickerCalendar { +/// selected_date: selected_date(), +/// on_date_change: move |date| selected_date.set(date), +/// } +/// } +/// } +/// } +/// } +/// } +/// } +///} +/// ``` +#[component] +pub fn DatePickerCalendar(props: CalendarProps) -> Element { + let ctx = use_context::(); + let mut view_date = use_signal(|| UtcDateTime::now().date()); + + use_effect(move || { + if let Some(date) = (props.selected_date)() { + view_date.set(date); + } + }); + + rsx! { + Calendar { + selected_date: props.selected_date, + on_date_change: props.on_date_change, + on_format_weekday: props.on_format_weekday, + on_format_month: props.on_format_month, + view_date: view_date(), + today: props.today, + on_view_change: move |date| view_date.set(date), + disabled: props.disabled, + first_day_of_week: props.first_day_of_week, + min_date: ctx.min_date, + max_date: ctx.max_date, + attributes: props.attributes, + {props.children} + } + } +} + +// The props for the [`DateSegment`] component +#[derive(Props, Clone, PartialEq)] +struct DateSegmentProps { + // The index of the segment + pub index: ReadSignal, + + // The controlled value of the date picker + pub value: ReadSignal>, + + // Default value + pub default: T, + + // Callback when value changes + #[props(default)] + pub on_value_change: Callback>, + + // The minimum value + pub min: T, + + // The maximum value + pub max: T, + + // Max field length + pub max_length: usize, + + // Callback when display placeholder + pub on_format_placeholder: Callback<(), String>, + + // Additional attributes for the value element + #[props(extends = GlobalAttributes)] + pub attributes: Vec, +} + +#[component] +fn DateSegment( + props: DateSegmentProps, +) -> Element { + let mut text_value = use_signal(|| "".to_string()); + use_effect(move || { + let text = match (props.value)() { + Some(value) => value.to_string(), + None => String::default(), + }; + text_value.set(text); + }); + + let mut reset_value = use_signal(|| false); + + // The formatted text for the segment + let display_value = use_memo(move || { + let value = (props.value)(); + match value { + Some(value) => format!("{:0>width$}", value, width = props.max_length), + None => props + .on_format_placeholder + .call(()) + .repeat(props.max_length), + } + }); + + let now_value = use_memo(move || (props.value)().unwrap_or(props.default)); + + let mut ctx = use_context::(); + + let mut set_value = move |text: String| { + if text.is_empty() { + props.on_value_change.call(None); + ctx.focus.focus_prev(); + return; + } + + let value = text.parse::().map(|v| v.min(props.max)).ok(); + if let Some(value) = value { + let inRange = value >= props.min && value <= props.max; + + let newValue = (text + "0").parse::().unwrap_or(value); + if inRange && newValue > props.max { + ctx.focus.focus_next(); + reset_value.set(true); + } + }; + + props.on_value_change.call(value); + }; + + let clamp_value = move |value: T| { + if value < props.min { + props.max + } else if value > props.max { + props.min + } else { + value + } + }; + + let handle_keydown = move |event: Event| { + let key = event.key(); + match key { + Key::Character(actual_char) => { + if actual_char.parse::().is_ok() { + let mut text = text_value(); + if text.len() == props.max_length || reset_value() { + text = String::default(); + reset_value.set(false); + }; + text.push_str(&actual_char); + set_value(text); + } + + event.prevent_default(); + event.stop_propagation(); + } + Key::Backspace => { + let mut text = text_value(); + text.pop(); + set_value(text); + } + Key::Delete => { + let mut text = text_value(); + text.remove(0); + set_value(text); + } + Key::ArrowLeft => { + ctx.focus.focus_prev(); + } + Key::ArrowRight => { + ctx.focus.focus_next(); + } + Key::ArrowUp => { + let value = match (props.value)() { + Some(mut value) => { + value.inc(); + clamp_value(value) + } + None => props.default, + }; + props.on_value_change.call(Some(value)); + } + Key::ArrowDown => { + let value = match (props.value)() { + Some(mut value) => { + value.dec(); + clamp_value(value) + } + None => props.default, + }; + props.on_value_change.call(Some(value)); + } + _ => (), + } + }; + + let focused = move || ctx.focus.is_focused(props.index.cloned()); + let onmounted = use_focus_controlled_item(props.index); + + let span_id = use_unique_id(); + let id = use_memo(move || format!("span-{span_id}")); + let label_id = format!("{id}-label"); + + rsx! { + span { + class: "date-segment", + id, + role: "spinbutton", + aria_valuemin: props.min.to_string(), + aria_valuemax: props.max.to_string(), + aria_valuenow: now_value.to_string(), + aria_labelledby: "{label_id}", + inputmode: "numeric", + contenteditable: !(ctx.read_only)(), + spellcheck: false, + tabindex: if focused() { "0" } else { "-1" }, + enterkeyhint: "next", + onkeydown: handle_keydown, + onmounted, + onfocus: move |_| { + ctx.focus.set_focus(Some(props.index.cloned())); + if (ctx.open)() { + ctx.open.set(false); + } + }, + "no-date": (props.value)().is_none(), + "data-disabled": (ctx.disabled)(), + ..props.attributes, + {display_value} + } + } +} + +#[component] +fn DateSeparator() -> Element { + rsx! { + span { + class: "date-segment", + aria_hidden: "true", + tabindex: "-1", + "is-separator": true, + "no-date": true, + {"-"} + } + } +} + +/// The props for the [`DatePickerInput`] component +#[derive(Props, Clone, PartialEq)] +pub struct DatePickerInputProps { + /// 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>, + + /// Additional attributes for the value element + #[props(extends = GlobalAttributes)] + pub attributes: Vec, + + /// The children of the date picker element + pub children: Element, +} + +/// # DatePickerInput +/// +/// The input element for the [`DatePicker`] component which allow users to enter a date value. +/// +/// ## Example +/// ```rust +/// use dioxus::prelude::*; +/// use dioxus_primitives::{date_picker::*, ContentAlign}; +/// use time::Date; +/// #[component] +/// pub fn Demo() -> Element { +/// let v = DatePickerValue::new_day(None); +/// let mut value = use_signal(|| v); +/// let mut selected_date = use_signal(|| None::); +/// rsx! { +/// div { +/// DatePicker { +/// value: value(), +/// selected_date: selected_date(), +/// on_value_change: move |v| { +/// tracing::info!("Date changed to: {v}"); +/// value.set(v); +/// selected_date.set(v.date()); +/// }, +/// DatePickerInput { +/// DatePickerPopover { +/// DatePickerPopoverTrigger {} +/// DatePickerPopoverContent { +/// align: ContentAlign::End, +/// DatePickerCalendar { +/// selected_date: selected_date(), +/// on_date_change: move |date| selected_date.set(date), +/// } +/// } +/// } +/// } +/// } +/// } +/// } +///} +/// ``` +#[component] +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) = year_value() { + let value = month_value().unwrap_or(0); + if let Ok(month) = Month::try_from(value) { + if let Some(value) = day_value() { + let max = month.length(year); + let day = value.clamp(1, max); + + let date = Date::from_calendar_date(year, month, day) + .ok() + .map(|date| date.clamp(ctx.min_date, ctx.max_date)); + + tracing::info!("Parsed date: {date:?}"); + ctx.set_date(date); + return; + } + } + } + + ctx.set_date(None); + }); + + let today = UtcDateTime::now().date(); + + rsx! { + div { + class: "date-picker-group", + div { + class: "date-picker-container", + DateSegment { + aria_label: "year", + index: 0usize, + value: year_value, + default: today.year(), + on_value_change: move |value: Option| year_value.set(value), + min: 1, + max: 9999, + 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: Month::January as u8, + max: Month::December as u8, + max_length: 2, + on_format_placeholder: props.on_format_month_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: 1, + max: 31, + max_length: 2, + on_format_placeholder: props.on_format_day_placeholder, + } + } + {props.children} + } + } +} diff --git a/primitives/src/lib.rs b/primitives/src/lib.rs index eb98a7d7..b58fa05f 100644 --- a/primitives/src/lib.rs +++ b/primitives/src/lib.rs @@ -17,6 +17,7 @@ pub mod calendar; pub mod checkbox; pub mod collapsible; pub mod context_menu; +pub mod date_picker; pub mod dialog; pub mod dropdown_menu; mod focus;