mirror of
https://github.com/mztlive/dx-admin-template.git
synced 2025-12-22 21:59:59 +00:00
更多组件
This commit is contained in:
@@ -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 {
|
||||
|
||||
30
src/components/ui/aspect_ratio.rs
Normal file
30
src/components/ui/aspect_ratio.rs
Normal 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}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
91
src/components/ui/collapsible.rs
Normal file
91
src/components/ui/collapsible.rs
Normal 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}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
247
src/components/ui/date_range_picker.rs
Normal file
247
src/components/ui/date_range_picker.rs
Normal 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()}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
137
src/components/ui/file_drop_zone.rs
Normal file
137
src/components/ui/file_drop_zone.rs
Normal 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()}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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::*;
|
||||
|
||||
104
src/components/ui/resizable.rs
Normal file
104
src/components/ui/resizable.rs
Normal 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}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
140
src/components/ui/toggle_group.rs
Normal file
140
src/components/ui/toggle_group.rs
Normal 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}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user