From d598e4922193ad685a807445a5bc46d5add74363 Mon Sep 17 00:00:00 2001 From: tommy Date: Tue, 4 Nov 2025 11:03:18 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E5=A4=9A=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/styling/shadcn.css | 273 +++++++++++++++++++++++++ src/components/ui/aspect_ratio.rs | 30 +++ src/components/ui/collapsible.rs | 91 +++++++++ src/components/ui/date_range_picker.rs | 247 ++++++++++++++++++++++ src/components/ui/file_drop_zone.rs | 137 +++++++++++++ src/components/ui/mod.rs | 12 ++ src/components/ui/resizable.rs | 104 ++++++++++ src/components/ui/toggle_group.rs | 140 +++++++++++++ src/views/components.rs | 223 ++++++++++++++++++-- 9 files changed, 1244 insertions(+), 13 deletions(-) create mode 100644 src/components/ui/aspect_ratio.rs create mode 100644 src/components/ui/collapsible.rs create mode 100644 src/components/ui/date_range_picker.rs create mode 100644 src/components/ui/file_drop_zone.rs create mode 100644 src/components/ui/resizable.rs create mode 100644 src/components/ui/toggle_group.rs diff --git a/assets/styling/shadcn.css b/assets/styling/shadcn.css index 10c22b5..46c351b 100644 --- a/assets/styling/shadcn.css +++ b/assets/styling/shadcn.css @@ -223,6 +223,22 @@ transform: none; } +.ui-toggle-group { + display: inline-flex; + align-items: center; + gap: 0.4rem; +} + +.ui-toggle-group[data-orientation="vertical"] { + flex-direction: column; + align-items: stretch; +} + +.ui-toggle-group-item { + min-width: 2.25rem; + justify-content: center; +} + .ui-badge { align-items: center; background-color: hsl(var(--primary) / 0.1); @@ -583,6 +599,68 @@ gap: 0.75rem; } +.ui-collapsible { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.ui-collapsible-trigger { + display: inline-flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + border-radius: var(--radius); + border: 1px solid hsl(var(--border)); + background-color: hsl(var(--background)); + color: hsl(var(--foreground)); + padding: 0.6rem 0.9rem; + font-weight: 500; + cursor: pointer; + transition: + background-color 0.2s ease, + border-color 0.2s ease; +} + +.ui-collapsible-trigger[data-state="open"] { + border-color: hsl(var(--ring)); + background-color: hsl(var(--muted)); +} + +.ui-collapsible-trigger:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.ui-collapsible-content { + border-radius: calc(var(--radius) - 2px); + border: 1px solid hsl(var(--border)); + padding: 0.9rem; + background-color: hsl(var(--muted) / 0.25); +} + +.ui-aspect-ratio { + position: relative; + width: 100%; + overflow: hidden; + border-radius: calc(var(--radius) - 2px); + background-color: hsl(var(--muted) / 0.2); +} + +.ui-aspect-ratio::before { + content: ""; + display: block; + padding-bottom: calc(100% / var(--ui-aspect-ratio)); +} + +.ui-aspect-ratio-inner { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; +} + .ui-cluster { display: flex; align-items: center; @@ -614,6 +692,138 @@ background: transparent; } +.ui-resizable-panels { + display: flex; + gap: 0.5rem; + align-items: stretch; +} + +.ui-resizable-panels[data-orientation="vertical"] { + flex-direction: column; + height: 100%; +} + +.ui-resizable-pane { + flex: 0 1 auto; + background-color: hsl(var(--background)); + border: 1px solid hsl(var(--border)); + border-radius: calc(var(--radius) - 2px); + padding: 0.75rem; + box-shadow: var(--shadow-xs); + min-width: 6rem; + min-height: 4rem; +} + +.ui-resizable-handle-stack { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.35rem; +} + +.ui-resizable-panels[data-orientation="vertical"] .ui-resizable-handle-stack { + flex-direction: row; +} + +.ui-resizable-handle { + width: 0.4rem; + height: 3rem; + border-radius: 999px; + background: repeating-linear-gradient( + 90deg, + hsl(var(--border)), + hsl(var(--border)) 2px, + transparent 2px, + transparent 4px + ); + opacity: 0.6; +} + +.ui-resizable-panels[data-orientation="vertical"] .ui-resizable-handle { + width: 3rem; + height: 0.4rem; + background: repeating-linear-gradient( + 0deg, + hsl(var(--border)), + hsl(var(--border)) 2px, + transparent 2px, + transparent 4px + ); +} + +.ui-resizable-slider { + appearance: none; + width: 4.5rem; + height: 0.3rem; + border-radius: 999px; + background: hsl(var(--muted)); + cursor: ew-resize; +} + +.ui-resizable-slider::-webkit-slider-thumb { + appearance: none; + width: 0.9rem; + height: 0.9rem; + border-radius: 50%; + background: hsl(var(--primary)); + box-shadow: var(--shadow-sm); +} + +.ui-resizable-panels[data-orientation="vertical"] .ui-resizable-slider { + cursor: ns-resize; +} + +.ui-dropzone { + position: relative; + border: 1.5px dashed hsl(var(--border)); + border-radius: calc(var(--radius)); + padding: 1.5rem; + display: flex; + flex-direction: column; + gap: 1rem; + align-items: center; + justify-content: center; + background-color: hsl(var(--background)); + color: hsl(var(--foreground)); + cursor: pointer; + transition: + border-color 0.2s ease, + background-color 0.2s ease, + box-shadow 0.2s ease; +} + +.ui-dropzone[data-state="active"] { + border-color: hsl(var(--primary)); + background-color: hsl(var(--primary) / 0.08); + box-shadow: 0 0 0 3px hsl(var(--primary) / 0.12); +} + +.ui-dropzone-input { + position: absolute; + inset: 0; + opacity: 0; + cursor: pointer; +} + +.ui-dropzone-body { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; + pointer-events: none; +} + +.ui-dropzone-title { + font-weight: 600; + font-size: 1rem; +} + +.ui-dropzone-summary { + font-size: 0.8rem; + color: hsl(var(--muted-foreground)); +} + .ui-bleed { display: flex; align-items: center; @@ -1190,6 +1400,38 @@ font-weight: 600; } +.ui-date-range { + display: flex; + flex-direction: column; + gap: 0.75rem; + border: 1px solid hsl(var(--border)); + border-radius: calc(var(--radius)); + padding: 1rem; + background-color: hsl(var(--background)); + box-shadow: var(--shadow-sm); +} + +.ui-date-range-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; +} + +.ui-date-range-title { + font-weight: 600; + color: hsl(var(--foreground)); +} + +.ui-date-range-labels { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.5rem; + text-align: center; + font-size: 0.85rem; + color: hsl(var(--muted-foreground)); +} + .ui-calendar-nav { background-color: hsl(var(--muted)); border: none; @@ -1208,6 +1450,31 @@ background-color: hsl(var(--muted) / 0.8); } +.ui-date-range-preview { + font-size: 0.85rem; + color: hsl(var(--muted-foreground)); +} + +.ui-date-range-calendars { + display: grid; + gap: 1rem; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); +} + +.ui-date-range-calendars > .ui-date-range-calendar { + padding: 0.75rem; + border: 1px solid hsl(var(--border)); + border-radius: calc(var(--radius) - 2px); + background-color: hsl(var(--background)); + box-shadow: var(--shadow-xs); +} + +.ui-date-range-calendar { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + .ui-calendar-weekdays { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); @@ -1251,6 +1518,12 @@ .ui-calendar-day[data-state="selected"] { background-color: hsl(var(--primary)); color: hsl(var(--primary-foreground)); + box-shadow: inset 0 0 0 1px hsl(var(--primary) / 0.9); +} + +.ui-calendar-day[data-in-range="true"]:not([data-state="selected"]) { + background-color: hsl(var(--primary) / 0.12); + color: hsl(var(--foreground)); } .ui-calendar-day:disabled { diff --git a/src/components/ui/aspect_ratio.rs b/src/components/ui/aspect_ratio.rs new file mode 100644 index 0000000..0e0c790 --- /dev/null +++ b/src/components/ui/aspect_ratio.rs @@ -0,0 +1,30 @@ +use dioxus::prelude::*; + +fn merge_class(base: &str, extra: Option) -> String { + if let Some(extra) = extra.filter(|extra| !extra.trim().is_empty()) { + format!("{base} {}", extra.trim()) + } else { + base.to_string() + } +} + +#[component] +pub fn AspectRatio( + #[props(default = 1.0f32)] ratio: f32, + #[props(into, default)] class: Option, + children: Element, +) -> Element { + let clamped_ratio = if ratio <= 0.0 { 1.0 } else { ratio }; + let classes = merge_class("ui-aspect-ratio", class); + + rsx! { + div { + class: classes, + style: format!("--ui-aspect-ratio: {clamped_ratio};"), + div { + class: "ui-aspect-ratio-inner", + {children} + } + } + } +} diff --git a/src/components/ui/collapsible.rs b/src/components/ui/collapsible.rs new file mode 100644 index 0000000..54eecd0 --- /dev/null +++ b/src/components/ui/collapsible.rs @@ -0,0 +1,91 @@ +use dioxus::prelude::*; + +fn merge_class(base: &str, extra: Option) -> String { + if let Some(extra) = extra.filter(|extra| !extra.trim().is_empty()) { + format!("{base} {}", extra.trim()) + } else { + base.to_string() + } +} + +#[derive(Clone)] +struct CollapsibleContext { + open: Signal, + on_change: Option>, +} + +#[component] +pub fn Collapsible( + mut open: Signal, + #[props(optional)] on_open_change: Option>, + #[props(into, default)] class: Option, + children: Element, +) -> Element { + let classes = merge_class("ui-collapsible", class); + let context = CollapsibleContext { + open: open.clone(), + on_change: on_open_change.clone(), + }; + + use_context_provider(|| context); + + rsx! { + div { + class: classes, + "data-state": if open() { "open" } else { "closed" }, + {children} + } + } +} + +#[component] +pub fn CollapsibleTrigger( + #[props(into, default)] class: Option, + #[props(default)] disabled: bool, + children: Element, +) -> Element { + let context = use_context::(); + let classes = merge_class("ui-collapsible-trigger", class); + let mut open_signal = context.open.clone(); + let on_change = context.on_change.clone(); + let is_open = open_signal(); + + rsx! { + button { + class: classes, + disabled, + "data-state": if is_open { "open" } else { "closed" }, + onclick: move |_| { + if disabled { + return; + } + let next = !open_signal(); + open_signal.set(next); + if let Some(handler) = on_change.clone() { + handler.call(next); + } + }, + {children} + } + } +} + +#[component] +pub fn CollapsibleContent( + #[props(into, default)] class: Option, + children: Element, +) -> Element { + let context = use_context::(); + let classes = merge_class("ui-collapsible-content", class); + let is_open = (context.open)(); + + rsx! { + div { + class: classes, + "data-state": if is_open { "open" } else { "closed" }, + if is_open { + {children} + } + } + } +} diff --git a/src/components/ui/date_range_picker.rs b/src/components/ui/date_range_picker.rs new file mode 100644 index 0000000..e4b5f4d --- /dev/null +++ b/src/components/ui/date_range_picker.rs @@ -0,0 +1,247 @@ +use chrono::{Datelike, Duration, NaiveDate}; +use dioxus::prelude::*; + +fn merge_class(base: &str, extra: Option) -> String { + if let Some(extra) = extra.filter(|extra| !extra.trim().is_empty()) { + format!("{base} {}", extra.trim()) + } else { + base.to_string() + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct DateRange { + pub start: NaiveDate, + pub end: NaiveDate, +} + +impl DateRange { + pub fn new(a: NaiveDate, b: NaiveDate) -> Self { + if a <= b { + Self { start: a, end: b } + } else { + Self { start: b, end: a } + } + } + + pub fn contains(&self, date: &NaiveDate) -> bool { + *date >= self.start && *date <= self.end + } +} + +const WEEKDAY_LABELS: [&str; 7] = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; + +fn first_day_of_month(date: NaiveDate) -> NaiveDate { + date.with_day(1).unwrap_or(date) +} + +fn add_months(date: NaiveDate, offset: i32) -> NaiveDate { + let mut year = date.year(); + let mut month = date.month() as i32 + offset; + while month > 12 { + month -= 12; + year += 1; + } + while month < 1 { + month += 12; + year -= 1; + } + NaiveDate::from_ymd_opt(year, month as u32, 1).unwrap_or(date) +} + +fn days_for_month(month_start: NaiveDate) -> Vec { + let start_offset = month_start.weekday().num_days_from_monday() as i64; + let mut cursor = month_start - Duration::days(start_offset); + let mut days = Vec::with_capacity(42); + for _ in 0..42 { + days.push(cursor); + cursor += Duration::days(1); + } + days +} + +fn range_preview(range: Option, hovered: Option) -> Option { + match (range, hovered) { + (Some(active), Some(hover)) if active.start == active.end => { + Some(DateRange::new(active.start, hover)) + } + (Some(active), _) => Some(active), + (None, _) => None, + } +} + +#[component] +pub fn DateRangePicker( + mut value: Signal>, + #[props(optional)] on_change: Option>>, + #[props(optional)] initial_month: Option, + #[props(into, default)] class: Option, +) -> Element { + 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)); + let hover_date = use_signal(|| None::); + let classes = merge_class("ui-date-range", class); + let on_change_handler = on_change.clone(); + + let active_range = value(); + let month_label = |date: NaiveDate| date.format("%B %Y").to_string(); + + 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-labels", + span { class: "ui-date-range-title", "{month_label(month())}" } + span { class: "ui-date-range-title", "{month_label(add_months(month(), 1))}" } + } + button { + class: "ui-date-range-nav", + r#type: "button", + "aria-label": "Next 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-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." } }, + } + } + div { + class: "ui-date-range-calendars", + for offset in 0..2 { + { + let calendar_month = add_months(month(), offset); + let days = days_for_month(calendar_month); + let active_month = calendar_month.month(); + let range_signal = value.clone(); + let hover_signal = hover_date.clone(); + let on_change_handler = on_change_handler.clone(); + + rsx! { + div { + class: "ui-date-range-calendar", + div { class: "ui-calendar-weekdays", + for label in WEEKDAY_LABELS { + span { class: "ui-calendar-weekday", "{label}" } + } + } + div { class: "ui-calendar-grid", + for day in days { + { + 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); + + rsx! { + button { + class: "ui-calendar-day", + r#type: "button", + "data-state": if is_selected_start || is_selected_end { "selected" } else { "idle" }, + "data-in-range": if is_in_range { "true" } else { "false" }, + "data-outside": if in_current_month { "false" } else { "true" }, + onclick: { + let day_value = day; + let on_change_handler = on_change_handler.clone(); + move |_| { + let current_range = range_signal(); + 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(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)); + } + } 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)); + } + } + } + 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)); + } + } + } + } + }, + onmouseenter: move |_| { + let range = range_signal(); + if let Some(range) = range { + if range.start == range.end { + hover_signal.set(Some(day)); + } + } + }, + onmouseleave: move |_| hover_signal.set(None), + "{day.day()}" + } + } + } + } + } + } + } + } + } + } + } + } +} diff --git a/src/components/ui/file_drop_zone.rs b/src/components/ui/file_drop_zone.rs new file mode 100644 index 0000000..b7fb8c4 --- /dev/null +++ b/src/components/ui/file_drop_zone.rs @@ -0,0 +1,137 @@ +use dioxus::html::events::{DragEvent, FormEvent}; +use dioxus::html::{FileData, HasFileData}; +use dioxus::prelude::*; + +fn merge_class(base: &str, extra: Option) -> String { + if let Some(extra) = extra.filter(|extra| !extra.trim().is_empty()) { + format!("{base} {}", extra.trim()) + } else { + base.to_string() + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct FileMetadata { + pub name: String, + pub size: u64, + pub content_type: Option, +} + +fn collect_metadata(files: Vec) -> Vec { + files + .into_iter() + .map(|file| FileMetadata { + name: file.name(), + size: file.size(), + content_type: file.content_type(), + }) + .collect() +} + +#[component] +pub fn FileDropZone( + #[props(into, default)] class: Option, + #[props(default)] multiple: bool, + #[props(into, default)] accept: Option, + #[props(optional)] on_files: Option>>, + #[props(optional)] content: Option, +) -> Element { + let classes = merge_class("ui-dropzone", class); + let accept_attr = accept.unwrap_or_default(); + let is_active = use_signal(|| false); + let selected_files = use_signal(|| Vec::::new()); + let on_files_handler = on_files.clone(); + + let upload_summary = move || { + let files = selected_files(); + if files.is_empty() { + "No files selected yet".to_string() + } else if files.len() == 1 { + format!( + "Ready to upload “{}” ({:.1} KB)", + files[0].name, + files[0].size as f64 / 1024.0 + ) + } else { + format!( + "{} files queued • total {:.1} KB", + files.len(), + files.iter().map(|f| f.size as f64).sum::() / 1024.0 + ) + } + }; + + let body_content: Element = content.unwrap_or_else(|| { + rsx! { + div { + class: "ui-stack", + span { class: "ui-dropzone-title", "Drag & drop files" } + span { class: "ui-field-helper", "or click to browse from your computer" } + } + } + }); + + rsx! { + label { + class: classes, + "data-state": if is_active() { "active" } else { "idle" }, + ondragenter: { + let mut hovering = is_active.clone(); + move |event: DragEvent| { + event.prevent_default(); + hovering.set(true); + } + }, + ondragover: { + let mut hovering = is_active.clone(); + move |event: DragEvent| { + event.prevent_default(); + hovering.set(true); + } + }, + ondragleave: { + let mut hovering = is_active.clone(); + move |_event: DragEvent| hovering.set(false) + }, + ondrop: { + let mut hovering = is_active.clone(); + let mut selected = selected_files.clone(); + let handler = on_files_handler.clone(); + move |event: DragEvent| { + event.prevent_default(); + hovering.set(false); + let files = collect_metadata(event.data().files()); + selected.set(files.clone()); + if let Some(callback) = handler.clone() { + callback.call(files); + } + } + }, + input { + class: "ui-dropzone-input", + r#type: "file", + multiple, + accept: accept_attr.clone(), + onchange: { + let mut selected = selected_files.clone(); + let handler = on_files_handler.clone(); + move |event: FormEvent| { + let files = collect_metadata(event.files()); + selected.set(files.clone()); + if let Some(callback) = handler.clone() { + callback.call(files); + } + } + }, + } + div { + class: "ui-dropzone-body", + {body_content} + div { + class: "ui-dropzone-summary", + "{upload_summary()}" + } + } + } + } +} diff --git a/src/components/ui/mod.rs b/src/components/ui/mod.rs index 47de13d..022e113 100644 --- a/src/components/ui/mod.rs +++ b/src/components/ui/mod.rs @@ -4,6 +4,7 @@ mod accordion; mod alert; +mod aspect_ratio; mod avatar; mod badge; mod breadcrumb; @@ -11,11 +12,14 @@ mod button; mod calendar; mod card; mod checkbox; +mod collapsible; mod combobox; mod command; mod context_menu; +mod date_range_picker; mod dialog; mod dropdown_menu; +mod file_drop_zone; mod form_field; mod hover_card; mod input; @@ -26,6 +30,7 @@ mod pagination; mod popover; mod progress; mod radio_group; +mod resizable; mod scroll_area; mod select; mod separator; @@ -40,10 +45,12 @@ mod tabs; mod textarea; mod toast; mod toggle; +mod toggle_group; mod tooltip; pub use accordion::*; pub use alert::*; +pub use aspect_ratio::*; pub use avatar::*; pub use badge::*; pub use breadcrumb::*; @@ -51,11 +58,14 @@ pub use button::*; pub use calendar::*; pub use card::*; pub use checkbox::*; +pub use collapsible::*; pub use combobox::*; pub use command::*; pub use context_menu::*; +pub use date_range_picker::*; pub use dialog::*; pub use dropdown_menu::*; +pub use file_drop_zone::*; pub use form_field::*; pub use hover_card::*; pub use input::*; @@ -66,6 +76,7 @@ pub use pagination::*; pub use popover::*; pub use progress::*; pub use radio_group::*; +pub use resizable::*; pub use scroll_area::*; pub use select::*; pub use separator::*; @@ -80,4 +91,5 @@ pub use tabs::*; pub use textarea::*; pub use toast::*; pub use toggle::*; +pub use toggle_group::*; pub use tooltip::*; diff --git a/src/components/ui/resizable.rs b/src/components/ui/resizable.rs new file mode 100644 index 0000000..9998d9c --- /dev/null +++ b/src/components/ui/resizable.rs @@ -0,0 +1,104 @@ +use dioxus::html::events::FormEvent; +use dioxus::prelude::*; + +fn merge_class(base: &str, extra: Option) -> String { + if let Some(extra) = extra.filter(|extra| !extra.trim().is_empty()) { + format!("{base} {}", extra.trim()) + } else { + base.to_string() + } +} + +#[allow(dead_code)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ResizableOrientation { + Horizontal, + Vertical, +} + +impl ResizableOrientation { + fn as_str(&self) -> &'static str { + match self { + ResizableOrientation::Horizontal => "horizontal", + ResizableOrientation::Vertical => "vertical", + } + } +} + +impl Default for ResizableOrientation { + fn default() -> Self { + ResizableOrientation::Horizontal + } +} + +#[component] +pub fn ResizablePanels( + first: Element, + second: Element, + #[props(default = 0.5f32)] initial: f32, + #[props(default = 0.2f32)] min: f32, + #[props(default = 0.8f32)] max: f32, + #[props(default)] orientation: ResizableOrientation, + #[props(optional)] on_resize: Option>, + #[props(into, default)] class: Option, +) -> Element { + let min_clamped = min.clamp(0.05, 0.95); + let max_clamped = max.clamp(min_clamped + f32::EPSILON, 0.95); + let initial_ratio = initial.clamp(min_clamped, max_clamped); + let ratio = use_signal(move || initial_ratio); + let classes = merge_class("ui-resizable-panels", class); + let orientation_attr = orientation.as_str(); + let on_resize_handler = on_resize.clone(); + let slider_min = (min_clamped * 100.0).round(); + let slider_max = (max_clamped * 100.0).round(); + + let ratio_value = ratio(); + let first_basis = format!("{:.2}%", ratio_value * 100.0); + let second_basis = format!("{:.2}%", (1.0 - ratio_value) * 100.0); + let slider_value = format!("{:.0}", ratio_value * 100.0); + + rsx! { + div { + class: classes, + "data-orientation": orientation_attr, + div { + class: "ui-resizable-pane", + style: format!("flex-basis: {first_basis};"), + {first} + } + div { + class: "ui-resizable-handle-stack", + div { class: "ui-resizable-handle" } + input { + class: "ui-resizable-slider", + r#type: "range", + min: format!("{slider_min:.0}"), + max: format!("{slider_max:.0}"), + step: "1", + value: slider_value, + "aria-label": "Resize panels", + oninput: { + let mut ratio_signal = ratio.clone(); + let min = min_clamped; + let max = max_clamped; + let handler = on_resize_handler.clone(); + move |event: FormEvent| { + if let Ok(raw) = event.value().parse::() { + let value = (raw / 100.0).clamp(min, max); + ratio_signal.set(value); + if let Some(callback) = handler.clone() { + callback.call(value); + } + } + } + }, + } + } + div { + class: "ui-resizable-pane", + style: format!("flex-basis: {second_basis};"), + {second} + } + } + } +} diff --git a/src/components/ui/toggle_group.rs b/src/components/ui/toggle_group.rs new file mode 100644 index 0000000..03d6847 --- /dev/null +++ b/src/components/ui/toggle_group.rs @@ -0,0 +1,140 @@ +use crate::components::ui::Toggle; +use dioxus::prelude::*; + +fn merge_class(base: &str, extra: Option) -> String { + if let Some(extra) = extra.filter(|extra| !extra.trim().is_empty()) { + format!("{base} {}", extra.trim()) + } else { + base.to_string() + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ToggleGroupMode { + Single, + Multiple, +} + +impl Default for ToggleGroupMode { + fn default() -> Self { + ToggleGroupMode::Single + } +} + +#[allow(dead_code)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ToggleGroupOrientation { + Horizontal, + Vertical, +} + +impl ToggleGroupOrientation { + fn as_str(&self) -> &'static str { + match self { + ToggleGroupOrientation::Horizontal => "horizontal", + ToggleGroupOrientation::Vertical => "vertical", + } + } +} + +impl Default for ToggleGroupOrientation { + fn default() -> Self { + ToggleGroupOrientation::Horizontal + } +} + +#[derive(Clone)] +struct ToggleGroupContext { + values: Signal>, + mode: ToggleGroupMode, + disabled: bool, + on_change: Option>>, +} + +#[component] +pub fn ToggleGroup( + mut values: Signal>, + #[props(default)] mode: ToggleGroupMode, + #[props(default)] orientation: ToggleGroupOrientation, + #[props(default)] disabled: bool, + #[props(optional)] on_value_change: Option>>, + #[props(into, default)] class: Option, + children: Element, +) -> Element { + let classes = merge_class("ui-toggle-group", class); + + let context = ToggleGroupContext { + values: values.clone(), + mode, + disabled, + on_change: on_value_change.clone(), + }; + + use_context_provider(|| context); + + rsx! { + div { + class: classes, + "data-orientation": orientation.as_str(), + "data-mode": match mode { + ToggleGroupMode::Single => "single", + ToggleGroupMode::Multiple => "multiple", + }, + "data-disabled": disabled, + {children} + } + } +} + +#[component] +pub fn ToggleGroupItem( + #[props(into)] value: String, + #[props(into, default)] class: Option, + #[props(default)] disabled: bool, + children: Element, +) -> Element { + let context = use_context::(); + let classes = merge_class("ui-toggle-group-item", class); + let values_signal = context.values.clone(); + let mode = context.mode; + let mut_disabled = context.disabled || disabled; + let on_change = context.on_change.clone(); + let is_active = values_signal().contains(&value); + + rsx! { + Toggle { + class: Some(classes), + pressed: is_active, + disabled: mut_disabled, + on_pressed_change: { + let value = value.clone(); + let mut values_signal = values_signal.clone(); + let on_change = on_change.clone(); + move |next| { + values_signal.with_mut(|items| match mode { + ToggleGroupMode::Single => { + items.clear(); + if next { + items.push(value.clone()); + } + } + ToggleGroupMode::Multiple => { + if next { + if !items.contains(&value) { + items.push(value.clone()); + } + } else if let Some(index) = items.iter().position(|item| item == &value) { + items.remove(index); + } + } + }); + + if let Some(handler) = on_change.clone() { + handler.call(values_signal()); + } + } + }, + {children} + } + } +} diff --git a/src/views/components.rs b/src/views/components.rs index a7a1494..05ae6e7 100644 --- a/src/views/components.rs +++ b/src/views/components.rs @@ -1,19 +1,22 @@ use crate::components::ui::{ - Accordion, AccordionContent, AccordionItem, AccordionTrigger, Alert, AlertVariant, Avatar, - Badge, BadgeVariant, Breadcrumb, Button, ButtonSize, ButtonVariant, Calendar, Card, - CardContent, CardDescription, CardFooter, CardHeader, CardTitle, Checkbox, Combobox, - ComboboxOption, CommandItem, CommandPalette, ContextItem, ContextMenu, Crumb, Dialog, - DropdownMenu, DropdownMenuItem, FormField, FormMessage, FormMessageVariant, HoverCard, Input, - Label, Menubar, MenubarItem, MenubarMenu, NavigationItem, NavigationMenu, Pagination, Popover, - Progress, RadioGroup, RadioGroupItem, ScrollArea, Select, SelectOption, Separator, - SeparatorOrientation, Sheet, SheetSide, Sidebar, SidebarContent, SidebarFooter, SidebarGroup, - SidebarGroupContent, SidebarGroupLabel, SidebarHeader, SidebarInset, SidebarLayout, - SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarSeparator, SidebarTrigger, Skeleton, - Slider, StepItem, Steps, Switch, Table, TableBody, TableCaption, TableCell, TableFooter, - TableHead, TableHeader, TableRow, Tabs, TabsContent, TabsList, TabsTrigger, Textarea, Toast, - ToastViewport, Toggle, Tooltip, + Accordion, AccordionContent, AccordionItem, AccordionTrigger, Alert, AlertVariant, AspectRatio, + Avatar, Badge, BadgeVariant, Breadcrumb, Button, ButtonSize, ButtonVariant, Calendar, Card, + CardContent, CardDescription, CardFooter, CardHeader, CardTitle, Checkbox, Collapsible, + CollapsibleContent, CollapsibleTrigger, Combobox, ComboboxOption, CommandItem, CommandPalette, + ContextItem, ContextMenu, Crumb, DateRange, DateRangePicker, Dialog, DropdownMenu, + DropdownMenuItem, FileDropZone, FileMetadata, FormField, FormMessage, FormMessageVariant, + HoverCard, Input, Label, Menubar, MenubarItem, MenubarMenu, NavigationItem, NavigationMenu, + Pagination, Popover, Progress, RadioGroup, RadioGroupItem, ResizableOrientation, + ResizablePanels, ScrollArea, Select, SelectOption, Separator, SeparatorOrientation, Sheet, + SheetSide, Sidebar, SidebarContent, SidebarFooter, SidebarGroup, SidebarGroupContent, + SidebarGroupLabel, SidebarHeader, SidebarInset, SidebarLayout, SidebarMenu, SidebarMenuButton, + SidebarMenuItem, SidebarSeparator, SidebarTrigger, Skeleton, Slider, StepItem, Steps, Switch, + Table, TableBody, TableCaption, TableCell, TableFooter, TableHead, TableHeader, TableRow, Tabs, + TabsContent, TabsList, TabsTrigger, Textarea, Toast, ToastViewport, Toggle, ToggleGroup, + ToggleGroupItem, ToggleGroupMode, ToggleGroupOrientation, Tooltip, }; use chrono::NaiveDate; +use dioxus::html::events::FormEvent; use dioxus::prelude::*; #[component] @@ -57,6 +60,16 @@ fn UiShowcase() -> Element { let toggle_active = use_signal(|| true); let calendar_selection = use_signal(|| Some(NaiveDate::from_ymd_opt(2024, 6, 11).expect("valid date"))); + let collapsible_open = use_signal(|| false); + let toggle_group_values = use_signal(|| vec!["daily".to_string()]); + let date_range_value = use_signal(|| { + Some(DateRange::new( + NaiveDate::from_ymd_opt(2024, 6, 1).expect("valid start"), + NaiveDate::from_ymd_opt(2024, 6, 7).expect("valid end"), + )) + }); + let dropzone_files = use_signal(|| Vec::::new()); + let resizable_ratio = use_signal(|| 0.45f32); let slider_value_signal = slider_value.clone(); let slider_value_setter = slider_value.clone(); let contact_method_signal = contact_method.clone(); @@ -88,6 +101,16 @@ fn UiShowcase() -> Element { let toggle_active_setter = toggle_active.clone(); let calendar_selection_signal = calendar_selection.clone(); let calendar_selection_setter = calendar_selection.clone(); + let collapsible_signal = collapsible_open.clone(); + let collapsible_setter = collapsible_open.clone(); + let toggle_group_signal = toggle_group_values.clone(); + let toggle_group_setter = toggle_group_values.clone(); + let date_range_signal = date_range_value.clone(); + let date_range_setter = date_range_value.clone(); + let dropzone_files_signal = dropzone_files.clone(); + let dropzone_files_setter = dropzone_files.clone(); + let resizable_ratio_signal = resizable_ratio.clone(); + let resizable_ratio_setter = resizable_ratio.clone(); let intensity_text = move || format!("Accent intensity: {:.0}%", slider_value_signal()); let contact_text = move || format!("Preferred contact: {}", contact_method_signal()); let profile_preview = move || { @@ -119,6 +142,39 @@ fn UiShowcase() -> Element { "Emails are paused until you re-enable them.".to_string() } }; + let toggle_group_summary = move || { + let values = toggle_group_signal(); + if values.is_empty() { + "No frequencies selected.".to_string() + } else { + format!("Cadence: {}", values.join(", ")) + } + }; + let range_preview = move || match date_range_signal() { + Some(range) if range.start != range.end => format!( + "Range: {} → {}", + range.start.format("%b %d"), + range.end.format("%b %d %Y") + ), + Some(range) => format!("Single day: {}", range.start.format("%b %d, %Y")), + None => "Pick a date window to compare analytics.".to_string(), + }; + let dropzone_summary = move || { + let files = dropzone_files_signal(); + if files.is_empty() { + "No assets queued.".to_string() + } else { + format!("{} file(s) staged.", files.len()) + } + }; + let resizable_summary = move || { + let ratio = resizable_ratio_signal(); + format!( + "Split: {:.0}% / {:.0}%", + ratio * 100.0, + (1.0 - ratio) * 100.0 + ) + }; let select_options = vec![ SelectOption::new("System", "system"), SelectOption::new("Light", "light"), @@ -443,6 +499,147 @@ fn UiShowcase() -> Element { } } + div { + style: single_column_style, + Card { + CardHeader { + CardTitle { "Layout & uploads" } + CardDescription { "Aspect ratios, resizable panels, and drag-and-drop staging." } + } + CardContent { + div { class: "ui-stack", + AspectRatio { + ratio: 16.0 / 9.0, + div { + style: "width: 100%; height: 100%; background: radial-gradient(circle at 20% 20%, hsl(var(--primary) / 0.3), transparent); border-radius: calc(var(--radius) - 2px); display: flex; align-items: center; justify-content: center; font-size: 0.85rem; color: hsl(var(--muted-foreground));", + "Video or hero media stays perfectly scaled." + } + } + ResizablePanels { + orientation: ResizableOrientation::Horizontal, + initial: resizable_ratio_signal(), + on_resize: { + let mut setter = resizable_ratio_setter.clone(); + move |ratio| setter.set(ratio) + }, + first: rsx! { + div { class: "ui-stack", + SpanHelper { "Primary workbench" } + SpanHelper { "Keep data tables or editors anchored on the left." } + } + }, + second: rsx! { + div { class: "ui-stack", + SpanHelper { "Preview" } + SpanHelper { "Live output or documentation tracks on the right pane." } + } + } + } + FormMessage { + variant: FormMessageVariant::Helper, + class: Some("ui-field-helper".to_string()), + "{resizable_summary()}" + } + FileDropZone { + multiple: true, + on_files: { + let mut setter = dropzone_files_setter.clone(); + move |files| setter.set(files) + }, + content: Some(rsx! { + div { + class: "ui-stack", + span { class: "ui-dropzone-title", "Drop brand assets" } + span { class: "ui-field-helper", "Supports PNG, SVG, and MP4 up to 200 MB." } + } + }), + } + FormMessage { + variant: FormMessageVariant::Helper, + class: Some("ui-field-helper".to_string()), + "{dropzone_summary()}" + } + if !dropzone_files_signal().is_empty() { + { + let files = dropzone_files_signal(); + rsx! { + ul { + style: "font-size: 0.8rem; display: flex; flex-direction: column; gap: 0.35rem;", + for file in files { + { + let label = format!("{} ({:.1} KB)", file.name, file.size as f64 / 1024.0); + rsx! { li { "{label}" } } + } + } + } + } + } + } + } + } + } + } + + div { + style: single_column_style, + Card { + CardHeader { + CardTitle { "Schedules & ranges" } + CardDescription { "Collapsible filters, toggle groups, and dual-month range picking." } + } + CardContent { + div { class: "ui-stack", + Collapsible { + open: collapsible_signal.clone(), + on_open_change: { + let mut setter = collapsible_setter.clone(); + move |state| setter.set(state) + }, + CollapsibleTrigger { "Advanced filters" } + CollapsibleContent { + SpanHelper { "Keep optional controls tucked away until needed." } + SpanHelper { "Current state: " } + SpanHelper { if collapsible_signal() { "Expanded" } else { "Collapsed" } } + } + } + div { class: "ui-stack", + span { class: "ui-field-helper", "Delivery cadence" } + ToggleGroup { + values: toggle_group_signal.clone(), + mode: ToggleGroupMode::Multiple, + orientation: ToggleGroupOrientation::Horizontal, + on_value_change: { + let mut setter = toggle_group_setter.clone(); + move |values| setter.set(values) + }, + ToggleGroupItem { value: "daily".to_string(), "Daily" } + ToggleGroupItem { value: "weekly".to_string(), "Weekly" } + ToggleGroupItem { value: "monthly".to_string(), "Monthly" } + } + FormMessage { + variant: FormMessageVariant::Helper, + class: Some("ui-field-helper".to_string()), + "{toggle_group_summary()}" + } + } + DateRangePicker { + value: date_range_signal.clone(), + on_change: { + let mut setter = date_range_setter.clone(); + move |range| setter.set(range) + }, + initial_month: Some(NaiveDate::from_ymd_opt(2024, 6, 1).expect("valid month")), + } + FormMessage { + variant: FormMessageVariant::Helper, + class: Some("ui-field-helper".to_string()), + "{range_preview()}" + } + } + } + } + } + div { style: single_column_style, Card {