优化日历组件

This commit is contained in:
tommy
2025-11-07 14:18:11 +08:00
parent 2b4b789250
commit 9157983772
6 changed files with 302 additions and 95 deletions

1
Cargo.lock generated
View File

@@ -1874,6 +1874,7 @@ dependencies = [
"chrono",
"dioxus 0.7.0",
"dioxus-motion",
"js-sys",
]
[[package]]

View File

@@ -11,6 +11,9 @@ dioxus = { version = "0.7.0", features = ["router", "fullstack"] }
dioxus-motion = "0.3.1"
chrono = { version = "0.4", default-features = false, features = ["std"] }
[target.'cfg(target_arch = "wasm32")'.dependencies]
js-sys = "0.3"
[features]
default = ["web"]
# The feature that are only required for the web = ["dioxus/web"] build target should be optional and only enabled in the web = ["dioxus/web"] feature

View File

@@ -1421,6 +1421,20 @@
gap: 0.75rem;
}
.ui-date-range-nav-group {
display: flex;
gap: 0.25rem;
}
.ui-date-range-nav {
width: 2rem;
height: 2rem;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
}
.ui-date-range-title {
font-weight: 600;
color: hsl(var(--foreground));
@@ -1458,6 +1472,27 @@
color: hsl(var(--muted-foreground));
}
.ui-date-range-shortcuts {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.ui-date-range-controls {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
width: 100%;
margin-top: 0.5rem;
}
.ui-date-range-shortcut,
.ui-date-range-clear,
.ui-date-range-confirm {
min-width: 0;
}
.ui-date-range-calendars {
display: grid;
gap: 1rem;

View File

@@ -1,5 +1,9 @@
use super::button::{Button, ButtonSize, ButtonVariant};
use chrono::{Datelike, Duration, NaiveDate};
use dioxus::prelude::*;
#[cfg(not(target_arch = "wasm32"))]
use std::time::SystemTime;
use crate::components::ui::PopoverHandle;
fn merge_class(base: &str, extra: Option<String>) -> String {
if let Some(extra) = extra.filter(|extra| !extra.trim().is_empty()) {
@@ -49,6 +53,53 @@ fn add_months(date: NaiveDate, offset: i32) -> NaiveDate {
NaiveDate::from_ymd_opt(year, month as u32, 1).unwrap_or(date)
}
fn last_day_of_month(date: NaiveDate) -> NaiveDate {
let first = first_day_of_month(date);
let next_month = add_months(first, 1);
next_month - Duration::days(1)
}
fn quick_range_week(today: NaiveDate) -> DateRange {
let start = today - Duration::days(today.weekday().num_days_from_monday() as i64);
let end = start + Duration::days(6);
DateRange::new(start, end)
}
fn quick_range_month(today: NaiveDate) -> DateRange {
let start = first_day_of_month(today);
let end = last_day_of_month(today);
DateRange::new(start, end)
}
#[cfg(target_arch = "wasm32")]
fn today_date() -> NaiveDate {
use js_sys::Date;
let date = Date::new_0();
let year = date.get_full_year() as i32;
let month = (date.get_month() + 1) as u32;
let day = date.get_date() as u32;
NaiveDate::from_ymd_opt(year, month, day)
.unwrap_or_else(|| NaiveDate::from_ymd_opt(1970, 1, 1).expect("unix epoch"))
}
#[cfg(not(target_arch = "wasm32"))]
fn today_date() -> NaiveDate {
let now = SystemTime::now();
let datetime: chrono::DateTime<chrono::Utc> = now.into();
datetime.date_naive()
}
fn describe_range(range: DateRange) -> String {
if range.start == range.end {
range.start.format("%b %d %Y").to_string()
} else {
let start_label = range.start.format("%b %d");
let end_label = range.end.format("%b %d %Y");
format!("{start_label}{end_label}")
}
}
fn days_for_month(month_start: NaiveDate) -> Vec<NaiveDate> {
let start_offset = month_start.weekday().num_days_from_monday() as i64;
let mut cursor = month_start - Duration::days(start_offset);
@@ -77,81 +128,176 @@ pub fn DateRangePicker(
#[props(optional)] initial_month: Option<NaiveDate>,
#[props(into, default)] class: Option<String>,
) -> Element {
let today = today_date();
let initial_month = value()
.map(|range| range.start)
.or(initial_month)
.unwrap_or_else(|| NaiveDate::from_ymd_opt(2024, 1, 1).expect("valid default date"));
let month = use_signal(move || first_day_of_month(initial_month));
.map(first_day_of_month)
.unwrap_or_else(|| first_day_of_month(today));
let month = use_signal(move || initial_month);
let hover_date = use_signal(|| None::<NaiveDate>);
let draft_range = use_signal({
let initial_value = value();
move || initial_value
});
let popover_handle = try_use_context::<PopoverHandle>();
let classes = merge_class("ui-date-range", class);
let on_change_handler = on_change.clone();
let active_range = value();
{
let mut draft_signal = draft_range.clone();
let value_signal = value.clone();
use_effect(move || {
draft_signal.set(value_signal());
});
}
let confirmed_range = value();
let pending_range = draft_range();
let base_month = month();
let month_label = |date: NaiveDate| date.format("%B %Y").to_string();
let preview_primary_text = match (pending_range, confirmed_range) {
(Some(pending), Some(confirmed)) if pending == confirmed => {
format!("当前: {}", describe_range(pending))
}
(Some(pending), _) => format!("待确认: {}", describe_range(pending)),
(None, Some(_)) => "待确认: 清除日期".to_string(),
(None, None) => "选择一个开始日期以创建范围。".to_string(),
};
let preview_secondary_text = match (pending_range, confirmed_range) {
(Some(pending), Some(confirmed)) if pending != confirmed => {
Some(format!("当前: {}", describe_range(confirmed)))
}
(None, Some(confirmed)) => Some(format!("当前: {}", describe_range(confirmed))),
_ => None,
};
let confirm_disabled = match (pending_range, confirmed_range) {
(None, None) => true,
(Some(pending), Some(confirmed)) if pending == confirmed => true,
_ => false,
};
rsx! {
div {
class: classes,
div {
class: "ui-date-range-toolbar",
button {
class: "ui-date-range-nav",
r#type: "button",
"aria-label": "Previous month",
onclick: {
let mut month_signal = month.clone();
move |_| {
let next = add_months(month_signal(), -1);
month_signal.set(next);
}
},
""
div {
class: "ui-date-range-nav-group",
Button {
class: "ui-date-range-nav",
variant: ButtonVariant::Ghost,
on_click: {
let mut month_signal = month.clone();
move |_| {
let next = add_months(month_signal(), -12);
month_signal.set(next);
}
},
"«"
}
Button {
class: "ui-date-range-nav",
variant: ButtonVariant::Ghost,
on_click: {
let mut month_signal = month.clone();
move |_| {
let next = add_months(month_signal(), -1);
month_signal.set(next);
}
},
""
}
}
div {
class: "ui-date-range-labels",
span { class: "ui-date-range-title", "{month_label(month())}" }
span { class: "ui-date-range-title", "{month_label(add_months(month(), 1))}" }
span { class: "ui-date-range-title", "{month_label(base_month)}" }
span { class: "ui-date-range-title", "{month_label(add_months(base_month, 1))}" }
}
button {
class: "ui-date-range-nav",
r#type: "button",
"aria-label": "Next month",
onclick: {
div {
class: "ui-date-range-nav-group",
Button {
class: "ui-date-range-nav",
variant: ButtonVariant::Ghost,
on_click: {
let mut month_signal = month.clone();
move |_| {
let next = add_months(month_signal(), 1);
month_signal.set(next);
}
},
""
}
Button {
class: "ui-date-range-nav",
variant: ButtonVariant::Ghost,
on_click: {
let mut month_signal = month.clone();
move |_| {
let next = add_months(month_signal(), 12);
month_signal.set(next);
}
},
"»"
}
}
}
div {
class: "ui-date-range-shortcuts",
Button {
class: "ui-date-range-shortcut",
size: ButtonSize::Sm,
variant: ButtonVariant::Secondary,
on_click: {
let mut draft_signal = draft_range.clone();
let mut month_signal = month.clone();
let mut hover_signal = hover_date.clone();
move |_| {
let next = add_months(month_signal(), 1);
month_signal.set(next);
let selected = quick_range_week(today);
draft_signal.set(Some(selected));
month_signal.set(first_day_of_month(selected.start));
hover_signal.set(None);
}
},
""
"本周"
}
Button {
class: "ui-date-range-shortcut",
size: ButtonSize::Sm,
variant: ButtonVariant::Secondary,
on_click: {
let mut draft_signal = draft_range.clone();
let mut month_signal = month.clone();
let mut hover_signal = hover_date.clone();
move |_| {
let selected = quick_range_month(today);
draft_signal.set(Some(selected));
month_signal.set(first_day_of_month(selected.start));
hover_signal.set(None);
}
},
"本月"
}
}
div {
class: "ui-date-range-preview",
match active_range {
Some(range) if range.start != range.end => {
let start_label = range.start.format("%b %d").to_string();
let end_label = range.end.format("%b %d %Y").to_string();
rsx! { span { "{start_label} → {end_label}" } }
}
Some(range) => {
let label = range.start.format("%b %d %Y").to_string();
rsx! { span { "Selected {label}" } }
}
None => rsx! { span { "Pick a start date to begin the range." } },
span { class: "ui-date-range-preview-primary", "{preview_primary_text}" }
for text in preview_secondary_text.iter() {
span { class: "ui-date-range-preview-secondary", "{text}" }
}
}
div {
class: "ui-date-range-calendars",
for offset in 0..2 {
{
let calendar_month = add_months(month(), offset);
let calendar_month = add_months(base_month, offset);
let days = days_for_month(calendar_month);
let active_month = calendar_month.month();
let range_signal = value.clone();
let range_signal = draft_range.clone();
let hover_signal = hover_date.clone();
let on_change_handler = on_change_handler.clone();
rsx! {
div {
class: "ui-date-range-calendar",
@@ -165,18 +311,9 @@ pub fn DateRangePicker(
{
let preview = range_preview(range_signal(), hover_signal());
let in_current_month = day.month() == active_month;
let mut range_signal = range_signal.clone();
let mut hover_signal = hover_signal.clone();
let is_selected_start = preview
.map(|range| range.start == day)
.unwrap_or(false);
let is_selected_end = preview
.map(|range| range.end == day)
.unwrap_or(false);
let is_in_range = preview
.map(|range| range.contains(&day))
.unwrap_or(false);
let is_selected_start = preview.map(|range| range.start == day).unwrap_or(false);
let is_selected_end = preview.map(|range| range.end == day).unwrap_or(false);
let is_in_range = preview.map(|range| range.contains(&day)).unwrap_or(false);
rsx! {
button {
class: "ui-calendar-day",
@@ -186,51 +323,43 @@ pub fn DateRangePicker(
"data-outside": if in_current_month { "false" } else { "true" },
onclick: {
let day_value = day;
let on_change_handler = on_change_handler.clone();
let mut draft_signal = range_signal.clone();
let mut hover_signal = hover_signal.clone();
move |_| {
let current_range = range_signal();
match current_range {
let current_range = draft_signal();
let next_range = match current_range {
Some(current) if current.start != current.end => {
let new_range = DateRange::new(day_value, day_value);
range_signal.set(Some(new_range));
if let Some(handler) = on_change_handler.clone() {
handler.call(Some(new_range));
}
Some(DateRange::new(day_value, day_value))
}
Some(current) => {
if day_value == current.start {
let new_range = DateRange::new(day_value, day_value);
range_signal.set(Some(new_range));
if let Some(handler) = on_change_handler.clone() {
handler.call(Some(new_range));
}
Some(DateRange::new(day_value, day_value))
} else {
let new_range = DateRange::new(current.start, day_value);
range_signal.set(Some(new_range));
if let Some(handler) = on_change_handler.clone() {
handler.call(Some(new_range));
}
Some(DateRange::new(current.start, day_value))
}
}
None => {
let new_range = DateRange::new(day_value, day_value);
range_signal.set(Some(new_range));
if let Some(handler) = on_change_handler.clone() {
handler.call(Some(new_range));
}
None => Some(DateRange::new(day_value, day_value)),
};
draft_signal.set(next_range);
hover_signal.set(None);
}
},
onmouseenter: {
let day_value = day;
let draft_signal = range_signal.clone();
let mut hover_signal = hover_signal.clone();
move |_| {
if let Some(range) = draft_signal() {
if range.start == range.end {
hover_signal.set(Some(day_value));
}
}
}
},
onmouseenter: move |_| {
let range = range_signal();
if let Some(range) = range {
if range.start == range.end {
hover_signal.set(Some(day));
}
}
onmouseleave: {
let mut hover_signal = hover_signal.clone();
move |_| hover_signal.set(None)
},
onmouseleave: move |_| hover_signal.set(None),
"{day.day()}"
}
}
@@ -242,6 +371,50 @@ pub fn DateRangePicker(
}
}
}
div {
class: "ui-date-range-controls",
Button {
class: "ui-date-range-clear",
variant: ButtonVariant::Ghost,
size: ButtonSize::Sm,
on_click: {
let mut draft_signal = draft_range.clone();
let mut hover_signal = hover_date.clone();
move |_| {
draft_signal.set(None);
hover_signal.set(None);
}
},
"清除"
}
Button {
class: "ui-date-range-confirm",
size: ButtonSize::Sm,
disabled: confirm_disabled,
on_click: {
let mut value_signal = value.clone();
let mut hover_signal = hover_date.clone();
let draft_signal = draft_range.clone();
let on_change = on_change_handler.clone();
let popover_handle = popover_handle.clone();
move |_| {
let selection = draft_signal();
if selection.is_none() && value_signal().is_none() {
return;
}
value_signal.set(selection);
if let Some(handler) = on_change.clone() {
handler.call(selection);
}
if let Some(mut handle) = popover_handle {
handle.state.set(false);
}
hover_signal.set(None);
}
},
"确定"
}
}
}
}
}

View File

@@ -1,5 +1,10 @@
use dioxus::prelude::*;
#[derive(Clone, Copy)]
pub struct PopoverHandle {
pub state: Signal<bool>,
}
#[component]
pub fn Popover(
trigger: Element,
@@ -7,6 +12,7 @@ pub fn Popover(
#[props(into, default = "bottom".to_string())] placement: String,
) -> Element {
let mut open = use_signal(|| false);
use_context_provider(|| PopoverHandle { state: open.clone() });
rsx! {
div {

View File

@@ -862,17 +862,6 @@ pub fn Orders() -> Element {
move |range: Option<DateRange>| setter.set(range)
},
}
div { class: "orders-date-actions",
Button {
variant: ButtonVariant::Ghost,
size: ButtonSize::Sm,
on_click: {
let mut setter = date_range.clone();
move |_| setter.set(None)
},
"清除日期"
}
}
}
},
}