更多组件

This commit is contained in:
tommy
2025-11-04 11:03:18 +08:00
parent 634800ac0a
commit d598e49221
9 changed files with 1244 additions and 13 deletions

View File

@@ -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 {

View File

@@ -0,0 +1,30 @@
use dioxus::prelude::*;
fn merge_class(base: &str, extra: Option<String>) -> 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<String>,
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}
}
}
}
}

View File

@@ -0,0 +1,91 @@
use dioxus::prelude::*;
fn merge_class(base: &str, extra: Option<String>) -> 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<bool>,
on_change: Option<EventHandler<bool>>,
}
#[component]
pub fn Collapsible(
mut open: Signal<bool>,
#[props(optional)] on_open_change: Option<EventHandler<bool>>,
#[props(into, default)] class: Option<String>,
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<String>,
#[props(default)] disabled: bool,
children: Element,
) -> Element {
let context = use_context::<CollapsibleContext>();
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<String>,
children: Element,
) -> Element {
let context = use_context::<CollapsibleContext>();
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}
}
}
}
}

View File

@@ -0,0 +1,247 @@
use chrono::{Datelike, Duration, NaiveDate};
use dioxus::prelude::*;
fn merge_class(base: &str, extra: Option<String>) -> 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<NaiveDate> {
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<DateRange>, hovered: Option<NaiveDate>) -> Option<DateRange> {
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<Option<DateRange>>,
#[props(optional)] on_change: Option<EventHandler<Option<DateRange>>>,
#[props(optional)] initial_month: Option<NaiveDate>,
#[props(into, default)] class: Option<String>,
) -> 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::<NaiveDate>);
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()}"
}
}
}
}
}
}
}
}
}
}
}
}
}

View File

@@ -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>) -> 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<String>,
}
fn collect_metadata(files: Vec<FileData>) -> Vec<FileMetadata> {
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<String>,
#[props(default)] multiple: bool,
#[props(into, default)] accept: Option<String>,
#[props(optional)] on_files: Option<EventHandler<Vec<FileMetadata>>>,
#[props(optional)] content: Option<Element>,
) -> 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::<FileMetadata>::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::<f64>() / 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()}"
}
}
}
}
}

View File

@@ -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::*;

View File

@@ -0,0 +1,104 @@
use dioxus::html::events::FormEvent;
use dioxus::prelude::*;
fn merge_class(base: &str, extra: Option<String>) -> 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<EventHandler<f32>>,
#[props(into, default)] class: Option<String>,
) -> 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::<f32>() {
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}
}
}
}
}

View File

@@ -0,0 +1,140 @@
use crate::components::ui::Toggle;
use dioxus::prelude::*;
fn merge_class(base: &str, extra: Option<String>) -> 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<Vec<String>>,
mode: ToggleGroupMode,
disabled: bool,
on_change: Option<EventHandler<Vec<String>>>,
}
#[component]
pub fn ToggleGroup(
mut values: Signal<Vec<String>>,
#[props(default)] mode: ToggleGroupMode,
#[props(default)] orientation: ToggleGroupOrientation,
#[props(default)] disabled: bool,
#[props(optional)] on_value_change: Option<EventHandler<Vec<String>>>,
#[props(into, default)] class: Option<String>,
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<String>,
#[props(default)] disabled: bool,
children: Element,
) -> Element {
let context = use_context::<ToggleGroupContext>();
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}
}
}
}

View File

@@ -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::<FileMetadata>::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 {