diff --git a/Cargo.lock b/Cargo.lock index 11f4d8aa..5eb620fc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2146,6 +2146,7 @@ dependencies = [ "dioxus", "dioxus-time", "lazy-js-bundle 0.6.2", + "num-integer", "time", "tracing", ] diff --git a/README.md b/README.md index f91f63f3..7b6a9747 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/assets/dx-components-theme.css b/preview/assets/dx-components-theme.css index 7c61d735..6d51a267 100644 --- a/preview/assets/dx-components-theme.css +++ b/preview/assets/dx-components-theme.css @@ -71,7 +71,7 @@ body { } :hover { - scrollbar-color: var(--secondary-color-2) rgba(0, 0, 0, 0); + scrollbar-color: var(--secondary-color-2) rgb(0 0 0 / 0%); } } diff --git a/preview/assets/hero.css b/preview/assets/hero.css index 10e856df..78ca826e 100644 --- a/preview/assets/hero.css +++ b/preview/assets/hero.css @@ -61,11 +61,11 @@ flex-direction: row; align-items: center; justify-content: center; - gap: 1em; + padding: 0.5em 1em; border: 1px solid var(--primary-color-6); border-radius: 0.5rem; - padding: 0.5em 1em; margin: 1em auto; + gap: 1em; } #hero-installation:hover { @@ -77,9 +77,9 @@ flex-direction: row; align-items: center; justify-content: center; - gap: 1em; - background: var(--primary-warning-color); - border-radius: 0.5rem; padding: 0.5em 1em; + border-radius: 0.5rem; margin: 1em auto; + background: var(--primary-warning-color); + gap: 1em; } diff --git a/preview/assets/language-select.css b/preview/assets/language-select.css index 7cab72e7..70ddbfdf 100644 --- a/preview/assets/language-select.css +++ b/preview/assets/language-select.css @@ -10,29 +10,29 @@ } .language-select-container:has(:focus-visible) { - outline: 2px solid var(--focused-border-color); border-radius: 0.5rem; + outline: 2px solid var(--focused-border-color); } .language-select { - opacity: 0; - inset: 0; + position: absolute; width: 100%; height: 100%; - margin: 0; - position: absolute; padding: .25rem; + margin: 0; + inset: 0; + opacity: 0; } .language-select-value { + display: inline-flex; + align-items: center; + justify-content: center; + padding: .25rem; border: none; background-color: transparent; color: var(--secondary-color-4); cursor: pointer; font-size: 1.5rem; - display: inline-flex; - align-items: center; - justify-content: center; - padding: .25rem; transition: background-color 0.2s ease, color 0.2s ease; } \ No newline at end of file diff --git a/preview/assets/main.css b/preview/assets/main.css index 058698d0..5e011874 100644 --- a/preview/assets/main.css +++ b/preview/assets/main.css @@ -116,14 +116,14 @@ body { display: flex; align-items: center; justify-content: center; - gap: 0.5em; + padding: 0; border: none; border-radius: 0.5rem; + margin: 0; background: none; color: var(--secondary-color-4); cursor: pointer; - margin: 0; - padding: 0; + gap: 0.5em; } .copy-button:hover { @@ -198,8 +198,8 @@ body { } .component-installation-list { - list-style-type: decimal; padding-left: 2rem; + list-style-type: decimal; } .component-installation-list li { @@ -254,6 +254,6 @@ body { /* Disable animations while the page is loading */ .preload * { - transition: none !important; animation-duration: 0.001s !important; + transition: none !important; } diff --git a/preview/src/components/alert_dialog/style.css b/preview/src/components/alert_dialog/style.css index c079a9a0..c89d62ba 100644 --- a/preview/src/components/alert_dialog/style.css +++ b/preview/src/components/alert_dialog/style.css @@ -15,6 +15,7 @@ opacity: 1; transform: scale(1) translateY(0); } + 100% { opacity: 0; transform: scale(0.95) translateY(-2px); @@ -30,6 +31,7 @@ opacity: 0; transform: scale(0.95) translateY(-2px); } + 100% { opacity: 1; transform: scale(1) translateY(0); diff --git a/preview/src/components/button/style.css b/preview/src/components/button/style.css index 1a02c70a..2f0d9ed6 100644 --- a/preview/src/components/button/style.css +++ b/preview/src/components/button/style.css @@ -1,11 +1,12 @@ .button { padding: 8px 18px; - border-radius: 0.5rem; border: none; + border-radius: 0.5rem; cursor: pointer; font-size: 1rem; transition: background-color 0.2s ease, color 0.2s ease; } + .button:focus-visible { box-shadow: 0 0 0 2px var(--focused-border-color); } @@ -14,6 +15,7 @@ background-color: var(--secondary-color-2); color: var(--primary-color); } + .button[data-style="primary"]:hover { background-color: var(--secondary-color-1); } @@ -22,6 +24,7 @@ background-color: var(--primary-color-5); color: var(--secondary-color-1); } + .button[data-style="secondary"]:hover { background-color: var(--primary-color-4); } @@ -30,6 +33,7 @@ background-color: transparent; color: var(--secondary-color-4); } + .button[data-style="ghost"]:hover { background-color: var(--primary-color-5); color: var(--secondary-color-1); @@ -41,6 +45,7 @@ var(--dark, var(--primary-color-3)); color: var(--secondary-color-4); } + .button[data-style="outline"]:hover { background-color: var(--primary-color-4); } @@ -49,6 +54,7 @@ background-color: var(--primary-error-color); color: var(--contrast-error-color); } + .button[data-style="destructive"]:hover { background-color: var(--secondary-error-color); } diff --git a/preview/src/components/calendar/style.css b/preview/src/components/calendar/style.css index 6adada25..48ffe8af 100644 --- a/preview/src/components/calendar/style.css +++ b/preview/src/components/calendar/style.css @@ -159,31 +159,31 @@ } .calendar-month-select-container:has(:focus-visible), .calendar-year-select-container:has(:focus-visible) { - outline: 2px solid var(--focused-border-color); border-radius: 0.5rem; + outline: 2px solid var(--focused-border-color); } .calendar-month-select, .calendar-year-select { - opacity: 0; - inset: 0; + position: absolute; width: 100%; height: 100%; - margin: 0; - position: absolute; padding: .25rem; + margin: 0; + inset: 0; + opacity: 0; } .calendar-month-select-value, .calendar-year-select-value { + display: inline-flex; + align-items: center; + justify-content: center; + padding: .25rem; border: none; background-color: transparent; color: var(--secondary-color-4); cursor: pointer; font-size: 1rem; - display: inline-flex; - align-items: center; - justify-content: center; - padding: .25rem; transition: background-color 0.2s ease, color 0.2s ease; } diff --git a/preview/src/components/context_menu/style.css b/preview/src/components/context_menu/style.css index 187d7bea..3ad0a91a 100644 --- a/preview/src/components/context_menu/style.css +++ b/preview/src/components/context_menu/style.css @@ -21,6 +21,7 @@ opacity: 1; transform: scale(1) translateY(0); } + 100% { opacity: 0; transform: scale(0.95) translateY(-2px); @@ -36,6 +37,7 @@ opacity: 0; transform: scale(0.95) translateY(-2px); } + 100% { opacity: 1; transform: scale(1) translateY(0); 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..87ffb181 --- /dev/null +++ b/preview/src/components/date_picker/component.rs @@ -0,0 +1,90 @@ +use dioxus::prelude::*; + +use dioxus_primitives::{ + date_picker::{self, DatePickerInputProps, DatePickerProps}, + popover::{PopoverContentProps, PopoverTriggerProps}, + ContentAlign, +}; + +use super::super::calendar::component::*; +use super::super::popover::component::*; + +#[component] +pub fn DatePicker(props: DatePickerProps) -> Element { + rsx! { + document::Link { rel: "stylesheet", href: asset!("./style.css") } + div { + date_picker::DatePicker { + class: "date-picker", + on_value_change: props.on_value_change, + selected_date: props.selected_date, + 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! { + date_picker::DatePickerInput { + 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::DatePickerCalendar { + calendar: Calendar, + CalendarHeader { + CalendarNavigation { + CalendarPreviousMonthButton {} + CalendarSelectMonth {} + CalendarSelectYear {} + CalendarNextMonthButton {} + } + } + CalendarGrid {} + } + } + } + } +} + +#[component] +pub fn DatePickerPopoverTrigger(props: PopoverTriggerProps) -> Element { + rsx! { + PopoverTrigger { + aria_label: "Show Calendar", + 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! { + PopoverContent { + class: "popover-content", + id: props.id, + side: props.side, + align: props.align, + attributes: props.attributes, + {props.children} + } + } +} diff --git a/preview/src/components/date_picker/docs.md b/preview/src/components/date_picker/docs.md new file mode 100644 index 00000000..982a87bf --- /dev/null +++ b/preview/src/components/date_picker/docs.md @@ -0,0 +1,52 @@ +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 selected date in the date picker (if any). + selected_date, + on_value_change: move |v: Option| { + // 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. + }, + + // The DatePickerPopover is the root popover component that wraps the input and calendar. + DatePickerPopover { + // Allows the user to enter a date using the keyboard. + // The input field includes a button to display the calendar. + DatePickerInput { + // Optional placeholder formatters for the input fields + on_format_day_placeholder: || "D", + on_format_month_placeholder: || "M", + on_format_year_placeholder: || "Y", + + // The DatePickerPopoverTrigger contains the button that triggers the popover + // to display the 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. + align: ContentAlign::Center, + + // The DatePickerCalendar component wraps the Calendar with its navigation components + DatePickerCalendar { + CalendarHeader { + CalendarNavigation { + CalendarPreviousMonthButton {} + CalendarSelectMonth {} + CalendarSelectYear {} + CalendarNextMonthButton {} + } + } + CalendarGrid {} + } + } + } + } +} +``` \ 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..4fb434c2 --- /dev/null +++ b/preview/src/components/date_picker/style.css @@ -0,0 +1,79 @@ +.date-picker { + position: relative; + display: inline-flex; + align-items: center; +} + +.date-picker-group .popover-trigger { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + border: none; + background-color: transparent; + cursor: pointer; + transition: rotate 150ms cubic-bezier(0.4, 0, 0.2, 1); +} + +.popover[data-state="open"] div .date-picker-trigger { + rotate: 180deg; +} + +.date-picker-expand-icon { + width: 20px; + height: 20px; + fill: none; + stroke: var(--primary-color-7); + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 2; +} + +[data-disabled="true"] { + cursor: not-allowed; + opacity: 0.5; +} + +.date-picker-group { + display: flex; + width: fit-content; + min-width: 150px; + flex-direction: row; + align-items: center; + justify-content: space-between; + padding: 0.5rem; + border: none; + border-radius: 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-group .popover-content { + max-width: unset; + padding: 0; +} + +.date-segment { + caret-color: transparent; +} + +.date-segment[no-date="true"] { + color: var(--secondary-color-5); +} + +.date-segment[is-separator="true"] { + padding: 0; +} + +.date-segment:focus-visible { + border-radius: 0.25rem; + background: var(--secondary-color-3); + color: var(--primary-color); + outline: none; +} 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..265c40a5 --- /dev/null +++ b/preview/src/components/date_picker/variants/main/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/dialog/style.css b/preview/src/components/dialog/style.css index 8884e0d5..89ac33e9 100644 --- a/preview/src/components/dialog/style.css +++ b/preview/src/components/dialog/style.css @@ -9,8 +9,8 @@ } .dialog-backdrop[data-state="closed"] { - pointer-events: none; animation: dialog-backdrop-animate-out 150ms ease-in forwards; + pointer-events: none; } @keyframes dialog-backdrop-animate-out { @@ -18,6 +18,7 @@ opacity: 1; transform: scale(1) translateY(0); } + 100% { opacity: 0; transform: scale(0.95) translateY(-2px); @@ -33,6 +34,7 @@ opacity: 0; transform: scale(0.95) translateY(-2px); } + 100% { opacity: 1; transform: scale(1) translateY(0); diff --git a/preview/src/components/dropdown_menu/style.css b/preview/src/components/dropdown_menu/style.css index e5eb0ebb..98befa7a 100644 --- a/preview/src/components/dropdown_menu/style.css +++ b/preview/src/components/dropdown_menu/style.css @@ -48,8 +48,8 @@ } .dropdown-menu-content[data-state="closed"] { - pointer-events: none; animation: dropdown-menu-content-animate-out 150ms ease-in forwards; + pointer-events: none; } @keyframes dropdown-menu-content-animate-out { @@ -57,6 +57,7 @@ opacity: 1; transform: scale(1) translateY(0); } + 100% { opacity: 0; transform: scale(0.95) translateY(-2px); @@ -72,6 +73,7 @@ opacity: 0; transform: scale(0.95) translateY(-2px); } + 100% { opacity: 1; transform: scale(1) translateY(0); diff --git a/preview/src/components/menubar/style.css b/preview/src/components/menubar/style.css index 91ff3ea2..3653738a 100644 --- a/preview/src/components/menubar/style.css +++ b/preview/src/components/menubar/style.css @@ -55,9 +55,9 @@ var(--dark, var(--primary-color-5)); box-shadow: inset 0 0 0 1px var(--light, var(--primary-color-6)) var(--dark, var(--primary-color-7)); - transform-origin: top; opacity: 0; pointer-events: none; + transform-origin: top; will-change: transform, opacity; } @@ -66,8 +66,8 @@ } .menubar-content[data-state="closed"] { - pointer-events: none; animation: menubar-content-animate-out 150ms ease-in forwards; + pointer-events: none; } @keyframes menubar-content-animate-out { @@ -75,6 +75,7 @@ opacity: 1; transform: scale(1) translateY(0); } + 100% { opacity: 0; transform: scale(0.95) translateY(-2px); @@ -91,6 +92,7 @@ opacity: 0; transform: scale(0.95) translateY(-2px); } + 100% { opacity: 1; transform: scale(1) translateY(0); 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/components/navbar/style.css b/preview/src/components/navbar/style.css index 282d1b56..151cc94e 100644 --- a/preview/src/components/navbar/style.css +++ b/preview/src/components/navbar/style.css @@ -67,9 +67,9 @@ var(--dark, var(--primary-color-5)); box-shadow: inset 0 0 0 1px var(--light, var(--primary-color-6)) var(--dark, var(--primary-color-7)); - transform-origin: top; opacity: 0; pointer-events: none; + transform-origin: top; will-change: transform, opacity; } diff --git a/preview/src/components/select/style.css b/preview/src/components/select/style.css index 31f0eab4..5a98dd3e 100644 --- a/preview/src/components/select/style.css +++ b/preview/src/components/select/style.css @@ -80,15 +80,15 @@ var(--dark, var(--primary-color-5)); box-shadow: inset 0 0 0 1px var(--light, var(--primary-color-6)) var(--dark, var(--primary-color-7)); - transform-origin: top; opacity: 0; pointer-events: none; + transform-origin: top; will-change: transform, opacity; } .select-list[data-state="closed"] { - pointer-events: none; animation: select-list-animate-out 150ms ease-in forwards; + pointer-events: none; } @keyframes select-list-animate-out { @@ -96,6 +96,7 @@ opacity: 1; transform: scale(1) translateY(0); } + 100% { opacity: 0; transform: scale(0.95) translateY(-2px); @@ -103,8 +104,8 @@ } .select-list[data-state="open"] { - pointer-events: auto; animation: select-list-animate-in 150ms ease-out forwards; + pointer-events: auto; } @keyframes select-list-animate-in { @@ -112,6 +113,7 @@ opacity: 0; transform: scale(0.95) translateY(-2px); } + 100% { opacity: 1; transform: scale(1) translateY(0); 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/preview/src/main.rs b/preview/src/main.rs index f371822f..0d939788 100644 --- a/preview/src/main.rs +++ b/preview/src/main.rs @@ -20,6 +20,7 @@ struct ComponentDemoData { variants: &'static [ComponentVariantDemoData], } +#[allow(unpredictable_function_pointer_comparisons)] #[derive(Clone, PartialEq)] struct ComponentVariantDemoData { name: &'static str, 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..a9237a71 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 { @@ -191,7 +198,7 @@ impl CalendarContext { } } -fn weekday_abbreviation(weekday: Weekday) -> &'static str { +pub(crate) fn weekday_abbreviation(weekday: Weekday) -> &'static str { match weekday { Weekday::Monday => "Mo", Weekday::Tuesday => "Tu", @@ -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) @@ -326,7 +334,7 @@ pub fn Calendar(props: CalendarProps) -> Element { rsx! { div { role: "application", - "aria-label": "Calendar", + aria_label: "Calendar", "data-disabled": (props.disabled)(), onkeydown: move |e| { let Some(focused_date) = (ctx.focused_date)() else { @@ -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..83090e0c --- /dev/null +++ b/primitives/src/date_picker.rs @@ -0,0 +1,771 @@ +//! Defines the [`DatePicker`] component and its subcomponents, which allowing users to enter or select a date value + +use crate::{ + calendar::{weekday_abbreviation, 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, Weekday}; + +/// The context provided by the [`DatePicker`] component to its children. +#[derive(Copy, Clone)] +struct DatePickerContext { + // State + 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.selected_date.peek().cloned() }; + if value != date { + self.on_value_change.call(date); + } + + self.open.set(false); + } +} + +/// The props for the [`DatePicker`] component. +#[derive(Props, Clone, PartialEq)] +pub struct DatePickerProps { + /// 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::*, popover::*, ContentAlign}; +/// 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 |date| { +/// tracing::info!("Date changed to: {date:?}"); +/// selected_date.set(date); +/// }, +/// DatePickerPopover { +/// DatePickerInput { +/// PopoverTrigger { +/// "Select date" +/// } +/// PopoverContent { +/// 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 { + 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: "group", + aria_label: "Date", + "data-disabled": (props.disabled)(), + ..props.attributes, + {props.children} + } + } +} + +/// The props for the [`DatePickerPopover`] component. +#[allow(unpredictable_function_pointer_comparisons)] +#[derive(Props, Clone, PartialEq)] +pub struct DatePickerPopoverProps { + /// Whether the popover is a modal and should capture focus. + #[props(default = ReadSignal::new(Signal::new(true)))] + pub is_modal: ReadSignal, + + /// The controlled open state of the popover. + pub open: ReadSignal>, + + /// The default open state when uncontrolled. + #[props(default)] + pub default_open: bool, + + /// Callback fired when the open state changes. + #[props(default)] + pub on_open_change: Callback, + + /// Additional attributes to apply to the popover root element. + #[props(extends = GlobalAttributes)] + pub attributes: Vec, + + /// The children of the popover root component. + pub children: Element, + + /// The popover root component to use. + #[props(default = PopoverRoot)] + pub popover_root: fn(PopoverRootProps) -> Element, +} + +/// # DatePickerPopover +/// +/// The `DatePickerPopover` component wraps all the popover components and manages the state. +/// +/// ## Example +/// ```rust +/// use dioxus::prelude::*; +/// use dioxus_primitives::{date_picker::*, popover::*, ContentAlign}; +/// 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 |date| { +/// tracing::info!("Date changed to: {date:?}"); +/// selected_date.set(date); +/// }, +/// DatePickerPopover { +/// DatePickerInput { +/// PopoverTrigger { +/// "Select date" +/// } +/// PopoverContent { +/// align: ContentAlign::End, +/// DatePickerCalendar { +/// selected_date: selected_date(), +/// on_date_change: move |date| selected_date.set(date), +/// } +/// } +/// } +/// } +/// } +/// } +/// } +///} +/// ``` +#[component] +pub fn DatePickerPopover(props: DatePickerPopoverProps) -> Element { + let ctx = use_context::(); + let mut open = ctx.open; + + let PopoverRoot = props.popover_root; + + rsx! { + PopoverRoot { + open: open(), + on_open_change: move |v| open.set(v), + attributes: props.attributes, + {props.children} + } + } +} + +/// 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>, + + /// Callback when display weekday + #[props(default = Callback::new(|weekday: Weekday| weekday_abbreviation(weekday).to_string()))] + pub on_format_weekday: Callback, + + /// Callback when display month + #[props(default = Callback::new(|month: Month| month.to_string()))] + 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) + #[props(default = UtcDateTime::now().date())] + pub today: Date, + + /// Callback when view date changes + #[props(default)] + pub on_view_change: Callback, + + /// Whether the calendar is disabled + #[props(default)] + pub disabled: ReadSignal, + + /// First day of the week + #[props(default = Weekday::Sunday)] + pub first_day_of_week: Weekday, + + /// 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, + + /// Additional attributes to extend the calendar element + #[props(extends = GlobalAttributes)] + pub attributes: Vec, + + /// The children of the calendar element + pub children: Element, + + /// The calendar to render with + #[props(default = Calendar)] + pub calendar: fn(CalendarProps) -> Element, +} + +/// # 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::*, popover::*, ContentAlign}; +/// 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 |date| { +/// tracing::info!("Date changed to: {date:?}"); +/// selected_date.set(date); +/// }, +/// DatePickerPopover { +/// DatePickerInput { +/// PopoverTrigger { +/// "Select date" +/// } +/// PopoverContent { +/// align: ContentAlign::End, +/// DatePickerCalendar { +/// selected_date: selected_date(), +/// on_date_change: move |date| selected_date.set(date), +/// } +/// } +/// } +/// } +/// } +/// } +/// } +///} +/// ``` +#[component] +pub fn DatePickerCalendar(props: DatePickerCalendarProps) -> Element { + 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)() { + view_date.set(date); + } + }); + + rsx! { + Calendar { + selected_date: ctx.selected_date, + on_date_change: move |date| { + tracing::info!("calendar selected date {date:?}"); + ctx.set_date(date) + }, + 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: 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: ReadSignal, + + // The maximum value + pub max: ReadSignal, + + // 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 min = props.min.cloned(); + let max = props.max.cloned(); + + let value = text.parse::().map(|v| v.min(max)).ok(); + if let Some(value) = value { + let inRange = value >= min && value <= max; + + // If adding a new digit would exceed max, move to next segment + let newValue = (text + "0").parse::().unwrap_or(value); + if inRange && newValue > max { + ctx.focus.focus_next(); + } + }; + + props.on_value_change.call(value); + }; + use_effect(move || { + // If this item is not focused, always keep the value clamped + if !ctx.focus.is_focused(props.index.cloned()) { + if let Some(value) = (props.value)() { + let clamped_value = value.clamp(props.min.cloned(), props.max.cloned()); + if clamped_value != value { + props.on_value_change.call(Some(clamped_value)); + } + } + } + }); + + let roll_value = move |value: T| { + let min = props.min.cloned(); + let max = props.max.cloned(); + if value < min { + max + } else if value > max { + min + } else { + value + } + }; + + let handle_keydown = move |event: Event| { + let key = event.key(); + match key { + Key::Character(actual_char) => { + // Don't block keyboard shortcuts + if event.modifiers().ctrl() || event.modifiers().meta() || event.modifiers().alt() { + return; + } + 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(); + if event.modifiers().ctrl() || event.modifiers().meta() { + text.clear(); + } else { + 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::Enter => { + ctx.focus.focus_next(); + event.prevent_default(); + event.stop_propagation(); + } + Key::ArrowUp => { + let value = match (props.value)() { + Some(mut value) => { + value.inc(); + roll_value(value) + } + None => props.default, + }; + props.on_value_change.call(Some(value)); + } + Key::ArrowDown => { + let value = match (props.value)() { + Some(mut value) => { + value.dec(); + roll_value(value) + } + None => props.default, + }; + props.on_value_change.call(Some(value)); + } + _ => (), + } + }; + + 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: "0", + enterkeyhint: "next", + onkeydown: handle_keydown, + onmounted, + onfocus: move |_| { + reset_value.set(true); + 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::*, popover::*, ContentAlign}; +/// 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 |date| { +/// tracing::info!("Date changed to: {date:?}"); +/// selected_date.set(date); +/// }, +/// DatePickerPopover { +/// DatePickerInput { +/// PopoverTrigger { +/// "Select date" +/// } +/// PopoverContent { +/// 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), 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)); + } + } + }); + + 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, + }; + + 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, + } + 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, + } + {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; diff --git a/primitives/src/scroll_area.rs b/primitives/src/scroll_area.rs index 87338051..13b936fe 100644 --- a/primitives/src/scroll_area.rs +++ b/primitives/src/scroll_area.rs @@ -25,26 +25,22 @@ pub struct ScrollAreaProps { } /// The direction in which scrolling is allowed. -#[derive(Clone, Copy, PartialEq)] +#[derive(Clone, Copy, PartialEq, Default)] pub enum ScrollDirection { /// Allow vertical scrolling only. Vertical, /// Allow horizontal scrolling only. Horizontal, /// Allow scrolling in both directions. + #[default] Both, } -impl Default for ScrollDirection { - fn default() -> Self { - Self::Both - } -} - /// The type of scrolling behavior. -#[derive(Clone, Copy, PartialEq)] +#[derive(Clone, Copy, PartialEq, Default)] pub enum ScrollType { /// Browser default scrolling. + #[default] Auto, /// Always show scrollbars. Always, @@ -52,12 +48,6 @@ pub enum ScrollType { Hidden, } -impl Default for ScrollType { - fn default() -> Self { - Self::Auto - } -} - /// # ScrollArea /// /// The `ScrollArea` component creates a scrollable area. If you don't diff --git a/primitives/src/select/text_search.rs b/primitives/src/select/text_search.rs index 5a553e9b..bc311b96 100644 --- a/primitives/src/select/text_search.rs +++ b/primitives/src/select/text_search.rs @@ -65,10 +65,10 @@ fn levenshtein_distance( let mut dp = vec![vec![0.0; value.len() + 1]; typeahead.len() + 1]; let mut prev = 0.0; - for j in 0..=value.len() { + for (j, item) in dp[0].iter_mut().enumerate().take(value.len() + 1) { let new = prev + (1.0 - recency_bias(j, value.len())) * 0.5; prev = new; - dp[0][j] = new; + *item = new; } let mut prev = 0.0; for (i, row) in dp.iter_mut().enumerate().take(typeahead.len() + 1) {