mirror of
https://github.com/mztlive/dx-admin-template.git
synced 2025-12-22 21:59:59 +00:00
一些组件
This commit is contained in:
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -1871,6 +1871,7 @@ checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
|
||||
name = "dx-admin-template"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dioxus 0.7.0",
|
||||
"dioxus-motion",
|
||||
]
|
||||
|
||||
@@ -9,6 +9,7 @@ edition = "2021"
|
||||
[dependencies]
|
||||
dioxus = { version = "0.7.0", features = ["router", "fullstack"] }
|
||||
dioxus-motion = "0.3.1"
|
||||
chrono = { version = "0.4", default-features = false, features = ["std"] }
|
||||
|
||||
[features]
|
||||
default = ["web"]
|
||||
|
||||
@@ -192,6 +192,37 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.ui-toggle {
|
||||
border: 1px solid hsl(var(--border));
|
||||
background-color: hsl(var(--background));
|
||||
color: hsl(var(--foreground));
|
||||
border-radius: calc(var(--radius) - 0.25rem);
|
||||
padding: 0.35rem 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.35rem;
|
||||
transition: background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease,
|
||||
transform 0.2s ease;
|
||||
}
|
||||
|
||||
.ui-toggle[data-state="on"] {
|
||||
background-color: hsl(var(--primary));
|
||||
color: hsl(var(--primary-foreground));
|
||||
border-color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
.ui-toggle:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.ui-toggle:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.ui-badge {
|
||||
align-items: center;
|
||||
background-color: hsl(var(--primary) / 0.1);
|
||||
@@ -559,6 +590,30 @@
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.ui-scroll-area {
|
||||
position: relative;
|
||||
overflow: auto;
|
||||
border-radius: calc(var(--radius) - 2px);
|
||||
border: 1px solid hsl(var(--border));
|
||||
background-color: hsl(var(--background));
|
||||
padding: 0.75rem;
|
||||
box-shadow: inset 0 0 0 1px hsl(var(--border) / 0.35);
|
||||
}
|
||||
|
||||
.ui-scroll-area::-webkit-scrollbar {
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
}
|
||||
|
||||
.ui-scroll-area::-webkit-scrollbar-thumb {
|
||||
background-color: hsl(var(--muted));
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.ui-scroll-area::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.ui-bleed {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -930,6 +985,284 @@
|
||||
color: hsl(var(--destructive));
|
||||
}
|
||||
|
||||
.ui-combobox {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ui-combobox[data-disabled="true"] {
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.ui-combobox-trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
height: 2.5rem;
|
||||
padding: 0 0.85rem;
|
||||
border-radius: calc(var(--radius) - 2px);
|
||||
border: 1px solid hsl(var(--border));
|
||||
background-color: hsl(var(--background));
|
||||
color: hsl(var(--foreground));
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 0.2s ease,
|
||||
box-shadow 0.2s ease,
|
||||
background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.ui-combobox-trigger:hover {
|
||||
background-color: hsl(var(--accent) / 0.4);
|
||||
}
|
||||
|
||||
.ui-combobox-content {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.35rem);
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 40;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: calc(var(--radius) - 2px);
|
||||
background-color: hsl(var(--popover));
|
||||
box-shadow: var(--shadow-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ui-combobox-search {
|
||||
padding: 0.5rem 0.65rem;
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
}
|
||||
|
||||
.ui-combobox-input {
|
||||
width: 100%;
|
||||
border: 1px solid hsl(var(--border) / 0.6);
|
||||
border-radius: calc(var(--radius) - 4px);
|
||||
padding: 0.45rem 0.6rem;
|
||||
font-size: 0.875rem;
|
||||
background-color: hsl(var(--background));
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.ui-combobox-input:focus-visible {
|
||||
outline: none;
|
||||
border-color: hsl(var(--ring));
|
||||
box-shadow: 0 0 0 2px hsl(var(--ring) / 0.35);
|
||||
}
|
||||
|
||||
.ui-combobox-list {
|
||||
max-height: 14rem;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.ui-combobox-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
padding: 0.65rem 0.85rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.ui-combobox-item button {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
gap: 0.2rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
color: hsl(var(--foreground));
|
||||
font-size: 0.85rem;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.ui-combobox-item[data-state="active"] button {
|
||||
background-color: hsl(var(--muted));
|
||||
}
|
||||
|
||||
.ui-combobox-item button:hover {
|
||||
background-color: hsl(var(--muted) / 0.8);
|
||||
}
|
||||
|
||||
.ui-combobox-label {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.ui-combobox-description {
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.ui-combobox-empty {
|
||||
padding: 1rem 0.85rem;
|
||||
font-size: 0.85rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ui-combobox-caret {
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.ui-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
border-spacing: 0;
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: calc(var(--radius) - 2px);
|
||||
overflow: hidden;
|
||||
background-color: hsl(var(--background));
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.ui-table caption {
|
||||
caption-side: bottom;
|
||||
}
|
||||
|
||||
.ui-table-header,
|
||||
.ui-table-footer {
|
||||
background-color: hsl(var(--muted) / 0.3);
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.ui-table-row {
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
}
|
||||
|
||||
.ui-table-row:hover {
|
||||
background-color: hsl(var(--muted) / 0.3);
|
||||
}
|
||||
|
||||
.ui-table-head {
|
||||
text-align: left;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 0.75rem 1rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
}
|
||||
|
||||
.ui-table-cell {
|
||||
padding: 0.75rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.ui-table-caption {
|
||||
font-size: 0.8rem;
|
||||
padding: 0.85rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.ui-calendar {
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: calc(var(--radius) - 2px);
|
||||
padding: 1rem;
|
||||
background-color: hsl(var(--background));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.8rem;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.ui-calendar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.ui-calendar-title {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ui-calendar-nav {
|
||||
background-color: hsl(var(--muted));
|
||||
border: none;
|
||||
color: hsl(var(--foreground));
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: calc(var(--radius) - 4px);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.ui-calendar-nav:hover {
|
||||
background-color: hsl(var(--muted) / 0.8);
|
||||
}
|
||||
|
||||
.ui-calendar-weekdays {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||
gap: 0.35rem;
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.ui-calendar-weekday {
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.ui-calendar-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.ui-calendar-day {
|
||||
border: none;
|
||||
border-radius: calc(var(--radius) - 4px);
|
||||
padding: 0.55rem 0;
|
||||
font-size: 0.85rem;
|
||||
background-color: hsl(var(--muted) / 0.2);
|
||||
color: hsl(var(--foreground));
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color 0.2s ease,
|
||||
color 0.2s ease,
|
||||
transform 0.2s ease;
|
||||
}
|
||||
|
||||
.ui-calendar-day[data-outside="true"] {
|
||||
color: hsl(var(--muted-foreground));
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.ui-calendar-day[data-state="selected"] {
|
||||
background-color: hsl(var(--primary));
|
||||
color: hsl(var(--primary-foreground));
|
||||
}
|
||||
|
||||
.ui-calendar-day:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.ui-calendar-day:not(:disabled):hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.ui-tooltip-wrapper {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
@@ -1319,6 +1652,36 @@
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.ui-skeleton {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background-color: hsl(var(--muted));
|
||||
border-radius: calc(var(--radius) - 4px);
|
||||
}
|
||||
|
||||
.ui-skeleton::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
transform: translateX(-100%);
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
hsl(var(--muted) / 0.5),
|
||||
transparent
|
||||
);
|
||||
animation: ui-skeleton-shimmer 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes ui-skeleton-shimmer {
|
||||
0% {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
|
||||
.ui-form-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -1330,6 +1693,16 @@
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.ui-form-message {
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.2;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.ui-form-message[data-variant="error"] {
|
||||
color: hsl(var(--destructive));
|
||||
}
|
||||
|
||||
.ui-sidebar-layout {
|
||||
background-color: hsl(var(--card));
|
||||
border: 1px solid hsl(var(--border));
|
||||
|
||||
140
src/components/ui/calendar.rs
Normal file
140
src/components/ui/calendar.rs
Normal file
@@ -0,0 +1,140 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
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 next_month(date: NaiveDate) -> NaiveDate {
|
||||
let year = if date.month() == 12 {
|
||||
date.year() + 1
|
||||
} else {
|
||||
date.year()
|
||||
};
|
||||
let month = if date.month() == 12 {
|
||||
1
|
||||
} else {
|
||||
date.month() + 1
|
||||
};
|
||||
NaiveDate::from_ymd_opt(year, month, 1).unwrap_or(date)
|
||||
}
|
||||
|
||||
fn previous_month(date: NaiveDate) -> NaiveDate {
|
||||
let year = if date.month() == 1 {
|
||||
date.year() - 1
|
||||
} else {
|
||||
date.year()
|
||||
};
|
||||
let month = if date.month() == 1 {
|
||||
12
|
||||
} else {
|
||||
date.month() - 1
|
||||
};
|
||||
NaiveDate::from_ymd_opt(year, month, 1).unwrap_or(date)
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn Calendar(
|
||||
#[props(into)] initial_month: NaiveDate,
|
||||
#[props(optional)] selected: Option<NaiveDate>,
|
||||
#[props(default)] show_outside_days: bool,
|
||||
#[props(optional)] on_select: Option<EventHandler<NaiveDate>>,
|
||||
#[props(into, default)] class: Option<String>,
|
||||
) -> Element {
|
||||
let starting_month = first_day_of_month(initial_month);
|
||||
let month = use_signal(move || starting_month);
|
||||
let selection = use_signal(move || selected);
|
||||
let mut month_signal = month.clone();
|
||||
let on_select_handler = on_select.clone();
|
||||
|
||||
let active_month = month();
|
||||
let month_label = active_month.format("%B %Y").to_string();
|
||||
let start_weekday = active_month.weekday().num_days_from_monday() as i64;
|
||||
let mut first_visible = active_month - Duration::days(start_weekday);
|
||||
let mut days = Vec::with_capacity(42);
|
||||
for _ in 0..42 {
|
||||
days.push(first_visible);
|
||||
first_visible = first_visible + Duration::days(1);
|
||||
}
|
||||
|
||||
let current_selection = selection();
|
||||
|
||||
rsx! {
|
||||
div {
|
||||
class: merge_class("ui-calendar", class),
|
||||
div {
|
||||
class: "ui-calendar-header",
|
||||
button {
|
||||
class: "ui-calendar-nav",
|
||||
r#type: "button",
|
||||
"aria-label": "Go to previous month",
|
||||
onclick: move |_| {
|
||||
month_signal.set(previous_month(active_month));
|
||||
},
|
||||
"‹"
|
||||
}
|
||||
span { class: "ui-calendar-title", "{month_label}" }
|
||||
button {
|
||||
class: "ui-calendar-nav",
|
||||
r#type: "button",
|
||||
"aria-label": "Go to next month",
|
||||
onclick: move |_| {
|
||||
month_signal.set(next_month(active_month));
|
||||
},
|
||||
"›"
|
||||
}
|
||||
}
|
||||
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 is_current_month = day.month() == active_month.month();
|
||||
let is_selected = current_selection
|
||||
.as_ref()
|
||||
.map(|selected| *selected == day)
|
||||
.unwrap_or(false);
|
||||
let is_disabled = !show_outside_days && !is_current_month;
|
||||
let day_display = day.day();
|
||||
let mut selection_signal = selection.clone();
|
||||
let handler = on_select_handler.clone();
|
||||
|
||||
rsx! {
|
||||
button {
|
||||
class: "ui-calendar-day",
|
||||
r#type: "button",
|
||||
"data-state": if is_selected { "selected" } else { "idle" },
|
||||
"data-outside": if is_current_month { "false" } else { "true" },
|
||||
disabled: is_disabled,
|
||||
onclick: move |_| {
|
||||
if !is_disabled {
|
||||
selection_signal.set(Some(day));
|
||||
if let Some(callback) = handler.clone() {
|
||||
callback.call(day);
|
||||
}
|
||||
}
|
||||
},
|
||||
"{day_display}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
176
src/components/ui/combobox.rs
Normal file
176
src/components/ui/combobox.rs
Normal file
@@ -0,0 +1,176 @@
|
||||
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, PartialEq)]
|
||||
pub struct ComboboxOption {
|
||||
pub label: String,
|
||||
pub value: String,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
impl ComboboxOption {
|
||||
pub fn new(label: impl Into<String>, value: impl Into<String>) -> Self {
|
||||
Self {
|
||||
label: label.into(),
|
||||
value: value.into(),
|
||||
description: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_description(mut self, description: impl Into<String>) -> Self {
|
||||
self.description = Some(description.into());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn Combobox(
|
||||
#[props(into, default)] class: Option<String>,
|
||||
#[props(into, default)] id: Option<String>,
|
||||
#[props(into)] placeholder: String,
|
||||
#[props(into, default)] search_placeholder: Option<String>,
|
||||
#[props(into)] options: Vec<ComboboxOption>,
|
||||
#[props(into, default)] selected: Option<String>,
|
||||
#[props(default)] disabled: bool,
|
||||
#[props(optional)] on_select: Option<EventHandler<String>>,
|
||||
) -> Element {
|
||||
let classes = merge_class("ui-combobox", class);
|
||||
let trigger_id = id.unwrap_or_default();
|
||||
let search_placeholder = search_placeholder.unwrap_or_else(|| "Search...".to_string());
|
||||
let open = use_signal(|| false);
|
||||
let current_selection = use_signal(move || selected.clone());
|
||||
let query = use_signal(|| String::new());
|
||||
let on_select_handler = on_select.clone();
|
||||
|
||||
let current_value = current_selection();
|
||||
let display_label = current_value
|
||||
.as_ref()
|
||||
.and_then(|value| {
|
||||
options
|
||||
.iter()
|
||||
.find(|option| option.value == *value)
|
||||
.map(|option| option.label.clone())
|
||||
})
|
||||
.unwrap_or_else(|| placeholder.clone());
|
||||
|
||||
let filtered_options: Vec<ComboboxOption> = {
|
||||
let query_text = query().to_lowercase();
|
||||
if query_text.is_empty() {
|
||||
options.clone()
|
||||
} else {
|
||||
options
|
||||
.iter()
|
||||
.cloned()
|
||||
.filter(|option| option.label.to_lowercase().contains(&query_text))
|
||||
.collect()
|
||||
}
|
||||
};
|
||||
|
||||
rsx! {
|
||||
div {
|
||||
class: classes,
|
||||
"data-disabled": disabled,
|
||||
onfocusout: {
|
||||
let mut open_signal = open.clone();
|
||||
move |_| open_signal.set(false)
|
||||
},
|
||||
button {
|
||||
class: "ui-combobox-trigger",
|
||||
id: trigger_id.clone(),
|
||||
"aria-haspopup": "dialog",
|
||||
"aria-expanded": if open() { "true" } else { "false" },
|
||||
disabled,
|
||||
onclick: {
|
||||
let mut open_signal = open.clone();
|
||||
move |_| {
|
||||
if !disabled {
|
||||
let next_state = !open_signal();
|
||||
open_signal.set(next_state);
|
||||
if !next_state {
|
||||
let mut query_signal = query.clone();
|
||||
query_signal.set(String::new());
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
span { "{display_label}" }
|
||||
span { class: "ui-combobox-caret", if open() { "▲" } else { "▼" } }
|
||||
}
|
||||
if open() {
|
||||
div {
|
||||
class: "ui-combobox-content",
|
||||
div {
|
||||
class: "ui-combobox-search",
|
||||
input {
|
||||
class: "ui-combobox-input",
|
||||
placeholder: search_placeholder.clone(),
|
||||
r#type: "text",
|
||||
autofocus: true,
|
||||
value: "{query()}",
|
||||
oninput: {
|
||||
let mut query_signal = query.clone();
|
||||
move |event| query_signal.set(event.value())
|
||||
},
|
||||
}
|
||||
}
|
||||
if filtered_options.is_empty() {
|
||||
div {
|
||||
class: "ui-combobox-empty",
|
||||
"No results found"
|
||||
}
|
||||
} else {
|
||||
ul {
|
||||
class: "ui-combobox-list",
|
||||
for option in filtered_options {
|
||||
{
|
||||
let is_active = current_value
|
||||
.as_ref()
|
||||
.map(|value| value == &option.value)
|
||||
.unwrap_or(false);
|
||||
let option_value = option.value.clone();
|
||||
let option_label = option.label.clone();
|
||||
let option_description = option.description.clone();
|
||||
rsx! {
|
||||
li {
|
||||
class: "ui-combobox-item",
|
||||
"data-state": if is_active { "active" } else { "inactive" },
|
||||
button {
|
||||
r#type: "button",
|
||||
onclick: {
|
||||
let option_value = option_value.clone();
|
||||
let handler = on_select_handler.clone();
|
||||
let mut open_signal = open.clone();
|
||||
let mut current_signal = current_selection.clone();
|
||||
let mut query_signal = query.clone();
|
||||
move |_| {
|
||||
current_signal.set(Some(option_value.clone()));
|
||||
if let Some(callback) = handler.clone() {
|
||||
callback.call(option_value.clone());
|
||||
}
|
||||
open_signal.set(false);
|
||||
query_signal.set(String::new());
|
||||
}
|
||||
},
|
||||
span { class: "ui-combobox-label", "{option_label}" }
|
||||
if let Some(description) = option_description {
|
||||
span { class: "ui-combobox-description", "{description}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
97
src/components/ui/form_field.rs
Normal file
97
src/components/ui/form_field.rs
Normal file
@@ -0,0 +1,97 @@
|
||||
use crate::components::ui::Label;
|
||||
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 FormMessageVariant {
|
||||
Helper,
|
||||
Error,
|
||||
}
|
||||
|
||||
impl FormMessageVariant {
|
||||
fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
FormMessageVariant::Helper => "helper",
|
||||
FormMessageVariant::Error => "error",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for FormMessageVariant {
|
||||
fn default() -> Self {
|
||||
FormMessageVariant::Helper
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn FormField(
|
||||
#[props(into, default)] class: Option<String>,
|
||||
#[props(optional)] label: Option<String>,
|
||||
#[props(optional)] helper_text: Option<String>,
|
||||
#[props(optional)] description: Option<String>,
|
||||
#[props(optional)] error: Option<Signal<Option<String>>>,
|
||||
#[props(into, default)] id: Option<String>,
|
||||
children: Element,
|
||||
) -> Element {
|
||||
let classes = merge_class("ui-form-field", class);
|
||||
let id_attr = id.unwrap_or_default();
|
||||
let error_signal = error;
|
||||
let current_error = error_signal.map(|signal| signal()).flatten();
|
||||
|
||||
rsx! {
|
||||
div {
|
||||
class: classes,
|
||||
if let Some(label_text) = label {
|
||||
Label {
|
||||
html_for: id_attr.clone(),
|
||||
"{label_text}"
|
||||
}
|
||||
}
|
||||
if let Some(description_text) = description {
|
||||
FormMessage {
|
||||
variant: FormMessageVariant::Helper,
|
||||
class: Some("ui-field-helper".to_string()),
|
||||
"{description_text}"
|
||||
}
|
||||
}
|
||||
{children}
|
||||
if let Some(helper) = helper_text {
|
||||
FormMessage {
|
||||
variant: FormMessageVariant::Helper,
|
||||
class: Some("ui-field-helper".to_string()),
|
||||
"{helper}"
|
||||
}
|
||||
}
|
||||
if let Some(message) = current_error {
|
||||
FormMessage {
|
||||
variant: FormMessageVariant::Error,
|
||||
"{message}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn FormMessage(
|
||||
#[props(default)] variant: FormMessageVariant,
|
||||
#[props(into, default)] class: Option<String>,
|
||||
children: Element,
|
||||
) -> Element {
|
||||
let classes = merge_class("ui-form-message", class);
|
||||
|
||||
rsx! {
|
||||
div {
|
||||
class: classes,
|
||||
"data-variant": variant.as_str(),
|
||||
{children}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,12 +8,15 @@ mod avatar;
|
||||
mod badge;
|
||||
mod breadcrumb;
|
||||
mod button;
|
||||
mod calendar;
|
||||
mod card;
|
||||
mod checkbox;
|
||||
mod combobox;
|
||||
mod command;
|
||||
mod context_menu;
|
||||
mod dialog;
|
||||
mod dropdown_menu;
|
||||
mod form_field;
|
||||
mod hover_card;
|
||||
mod input;
|
||||
mod label;
|
||||
@@ -23,16 +26,20 @@ mod pagination;
|
||||
mod popover;
|
||||
mod progress;
|
||||
mod radio_group;
|
||||
mod scroll_area;
|
||||
mod select;
|
||||
mod separator;
|
||||
mod sheet;
|
||||
mod sidebar;
|
||||
mod skeleton;
|
||||
mod slider;
|
||||
mod steps;
|
||||
mod switch;
|
||||
mod table;
|
||||
mod tabs;
|
||||
mod textarea;
|
||||
mod toast;
|
||||
mod toggle;
|
||||
mod tooltip;
|
||||
|
||||
pub use accordion::*;
|
||||
@@ -41,12 +48,15 @@ pub use avatar::*;
|
||||
pub use badge::*;
|
||||
pub use breadcrumb::*;
|
||||
pub use button::*;
|
||||
pub use calendar::*;
|
||||
pub use card::*;
|
||||
pub use checkbox::*;
|
||||
pub use combobox::*;
|
||||
pub use command::*;
|
||||
pub use context_menu::*;
|
||||
pub use dialog::*;
|
||||
pub use dropdown_menu::*;
|
||||
pub use form_field::*;
|
||||
pub use hover_card::*;
|
||||
pub use input::*;
|
||||
pub use label::*;
|
||||
@@ -56,14 +66,18 @@ pub use pagination::*;
|
||||
pub use popover::*;
|
||||
pub use progress::*;
|
||||
pub use radio_group::*;
|
||||
pub use scroll_area::*;
|
||||
pub use select::*;
|
||||
pub use separator::*;
|
||||
pub use sheet::*;
|
||||
pub use sidebar::*;
|
||||
pub use skeleton::*;
|
||||
pub use slider::*;
|
||||
pub use steps::*;
|
||||
pub use switch::*;
|
||||
pub use table::*;
|
||||
pub use tabs::*;
|
||||
pub use textarea::*;
|
||||
pub use toast::*;
|
||||
pub use toggle::*;
|
||||
pub use tooltip::*;
|
||||
|
||||
36
src/components/ui/scroll_area.rs
Normal file
36
src/components/ui/scroll_area.rs
Normal file
@@ -0,0 +1,36 @@
|
||||
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 ScrollArea(
|
||||
#[props(into, default)] class: Option<String>,
|
||||
#[props(into, default)] style: Option<String>,
|
||||
#[props(into, default)] max_height: Option<String>,
|
||||
children: Element,
|
||||
) -> Element {
|
||||
let classes = merge_class("ui-scroll-area", class);
|
||||
|
||||
let mut styles = Vec::new();
|
||||
if let Some(style) = style.filter(|style| !style.trim().is_empty()) {
|
||||
styles.push(style);
|
||||
}
|
||||
if let Some(max_height) = max_height.filter(|height| !height.trim().is_empty()) {
|
||||
styles.push(format!("max-height: {max_height};"));
|
||||
}
|
||||
let style_attr = styles.join(" ");
|
||||
|
||||
rsx! {
|
||||
div {
|
||||
class: classes,
|
||||
style: style_attr,
|
||||
{children}
|
||||
}
|
||||
}
|
||||
}
|
||||
38
src/components/ui/skeleton.rs
Normal file
38
src/components/ui/skeleton.rs
Normal file
@@ -0,0 +1,38 @@
|
||||
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 Skeleton(
|
||||
#[props(into, default)] class: Option<String>,
|
||||
#[props(into, default)] width: Option<String>,
|
||||
#[props(into, default)] height: Option<String>,
|
||||
#[props(into, default)] radius: Option<String>,
|
||||
) -> Element {
|
||||
let classes = merge_class("ui-skeleton", class);
|
||||
|
||||
let mut styles = Vec::new();
|
||||
if let Some(width) = width.filter(|value| !value.trim().is_empty()) {
|
||||
styles.push(format!("width: {width};"));
|
||||
}
|
||||
if let Some(height) = height.filter(|value| !value.trim().is_empty()) {
|
||||
styles.push(format!("height: {height};"));
|
||||
}
|
||||
if let Some(radius) = radius.filter(|value| !value.trim().is_empty()) {
|
||||
styles.push(format!("border-radius: {radius};"));
|
||||
}
|
||||
let style_attr = styles.join(" ");
|
||||
|
||||
rsx! {
|
||||
div {
|
||||
class: classes,
|
||||
style: style_attr,
|
||||
}
|
||||
}
|
||||
}
|
||||
106
src/components/ui/table.rs
Normal file
106
src/components/ui/table.rs
Normal file
@@ -0,0 +1,106 @@
|
||||
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 Table(#[props(into, default)] class: Option<String>, children: Element) -> Element {
|
||||
let classes = merge_class("ui-table", class);
|
||||
|
||||
rsx! {
|
||||
table {
|
||||
class: classes,
|
||||
{children}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn TableHeader(#[props(into, default)] class: Option<String>, children: Element) -> Element {
|
||||
let classes = merge_class("ui-table-header", class);
|
||||
|
||||
rsx! {
|
||||
thead {
|
||||
class: classes,
|
||||
{children}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn TableBody(#[props(into, default)] class: Option<String>, children: Element) -> Element {
|
||||
let classes = merge_class("ui-table-body", class);
|
||||
|
||||
rsx! {
|
||||
tbody {
|
||||
class: classes,
|
||||
{children}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn TableFooter(#[props(into, default)] class: Option<String>, children: Element) -> Element {
|
||||
let classes = merge_class("ui-table-footer", class);
|
||||
|
||||
rsx! {
|
||||
tfoot {
|
||||
class: classes,
|
||||
{children}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn TableRow(#[props(into, default)] class: Option<String>, children: Element) -> Element {
|
||||
let classes = merge_class("ui-table-row", class);
|
||||
|
||||
rsx! {
|
||||
tr {
|
||||
class: classes,
|
||||
{children}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn TableHead(#[props(into, default)] class: Option<String>, children: Element) -> Element {
|
||||
let classes = merge_class("ui-table-head", class);
|
||||
|
||||
rsx! {
|
||||
th {
|
||||
class: classes,
|
||||
scope: "col",
|
||||
{children}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn TableCell(#[props(into, default)] class: Option<String>, children: Element) -> Element {
|
||||
let classes = merge_class("ui-table-cell", class);
|
||||
|
||||
rsx! {
|
||||
td {
|
||||
class: classes,
|
||||
{children}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn TableCaption(#[props(into, default)] class: Option<String>, children: Element) -> Element {
|
||||
let classes = merge_class("ui-table-caption", class);
|
||||
|
||||
rsx! {
|
||||
caption {
|
||||
class: classes,
|
||||
{children}
|
||||
}
|
||||
}
|
||||
}
|
||||
36
src/components/ui/toggle.rs
Normal file
36
src/components/ui/toggle.rs
Normal file
@@ -0,0 +1,36 @@
|
||||
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 Toggle(
|
||||
#[props(default)] pressed: bool,
|
||||
#[props(default)] disabled: bool,
|
||||
#[props(into, default)] class: Option<String>,
|
||||
#[props(optional)] on_pressed_change: Option<EventHandler<bool>>,
|
||||
children: Element,
|
||||
) -> Element {
|
||||
let classes = merge_class("ui-toggle", class);
|
||||
let handler = on_pressed_change.clone();
|
||||
|
||||
rsx! {
|
||||
button {
|
||||
class: classes,
|
||||
"data-state": if pressed { "on" } else { "off" },
|
||||
"aria-pressed": pressed,
|
||||
disabled,
|
||||
onclick: move |_| {
|
||||
if let Some(callback) = handler.clone() {
|
||||
callback.call(!pressed);
|
||||
}
|
||||
},
|
||||
{children}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,19 @@
|
||||
use crate::components::ui::{
|
||||
Accordion, AccordionContent, AccordionItem, AccordionTrigger, Alert, AlertVariant, Avatar,
|
||||
Badge, BadgeVariant, Breadcrumb, Button, ButtonSize, ButtonVariant, Card, CardContent,
|
||||
CardDescription, CardFooter, CardHeader, CardTitle, Checkbox, CommandItem, CommandPalette,
|
||||
ContextItem, ContextMenu, Crumb, Dialog, DropdownMenu, DropdownMenuItem, HoverCard, Input,
|
||||
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, Select, SelectOption, Separator, SeparatorOrientation,
|
||||
Sheet, SheetSide, Sidebar, SidebarContent, SidebarFooter, SidebarGroup, SidebarGroupContent,
|
||||
SidebarGroupLabel, SidebarHeader, SidebarInset, SidebarLayout, SidebarMenu, SidebarMenuButton,
|
||||
SidebarMenuItem, SidebarSeparator, SidebarTrigger, Slider, StepItem, Steps, Switch, Tabs,
|
||||
TabsContent, TabsList, TabsTrigger, Textarea, Toast, ToastViewport, Tooltip,
|
||||
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,
|
||||
};
|
||||
use chrono::NaiveDate;
|
||||
use dioxus::prelude::*;
|
||||
|
||||
#[component]
|
||||
@@ -46,6 +50,13 @@ fn UiShowcase() -> Element {
|
||||
let toast_open = use_signal(|| false);
|
||||
let sidebar_collapsed = use_signal(|| false);
|
||||
let sidebar_active = use_signal(|| "analytics".to_string());
|
||||
let profile_name = use_signal(|| "".to_string());
|
||||
let name_error =
|
||||
use_signal(|| Some("Use at least 3 characters to stay descriptive.".to_string()));
|
||||
let combobox_selection = use_signal(|| Some("analytics".to_string()));
|
||||
let toggle_active = use_signal(|| true);
|
||||
let calendar_selection =
|
||||
use_signal(|| Some(NaiveDate::from_ymd_opt(2024, 6, 11).expect("valid date")));
|
||||
let slider_value_signal = slider_value.clone();
|
||||
let slider_value_setter = slider_value.clone();
|
||||
let contact_method_signal = contact_method.clone();
|
||||
@@ -67,13 +78,63 @@ fn UiShowcase() -> Element {
|
||||
let toast_signal = toast_open.clone();
|
||||
let sidebar_collapsed_setter = sidebar_collapsed.clone();
|
||||
let sidebar_active_setter = sidebar_active.clone();
|
||||
let profile_name_signal = profile_name.clone();
|
||||
let profile_name_setter = profile_name.clone();
|
||||
let name_error_signal = name_error.clone();
|
||||
let name_error_setter = name_error.clone();
|
||||
let combobox_selection_signal = combobox_selection.clone();
|
||||
let combobox_selection_setter = combobox_selection.clone();
|
||||
let toggle_active_signal = toggle_active.clone();
|
||||
let toggle_active_setter = toggle_active.clone();
|
||||
let calendar_selection_signal = calendar_selection.clone();
|
||||
let calendar_selection_setter = calendar_selection.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 || {
|
||||
let value = profile_name_signal();
|
||||
if value.trim().is_empty() {
|
||||
"Name is currently empty".to_string()
|
||||
} else {
|
||||
format!("Display name preview: {}", value.trim())
|
||||
}
|
||||
};
|
||||
let combobox_summary = move || {
|
||||
if let Some(value) = combobox_selection_signal() {
|
||||
format!("Project owner: {value}")
|
||||
} else {
|
||||
"Assign a project owner to sync permissions.".to_string()
|
||||
}
|
||||
};
|
||||
let calendar_summary = move || {
|
||||
if let Some(date) = calendar_selection_signal() {
|
||||
format!("Next milestone: {}", date.format("%b %d, %Y"))
|
||||
} else {
|
||||
"Pick a date to keep the timeline on track.".to_string()
|
||||
}
|
||||
};
|
||||
let toggle_summary = move || {
|
||||
if toggle_active_signal() {
|
||||
"Emails are enabled for this workflow.".to_string()
|
||||
} else {
|
||||
"Emails are paused until you re-enable them.".to_string()
|
||||
}
|
||||
};
|
||||
let select_options = vec![
|
||||
SelectOption::new("System", "system"),
|
||||
SelectOption::new("Light", "light"),
|
||||
SelectOption::new("Dark", "dark"),
|
||||
];
|
||||
let calendar_month = NaiveDate::from_ymd_opt(2024, 6, 1).expect("valid date");
|
||||
let combobox_options = vec![
|
||||
ComboboxOption::new("Analytics", "analytics")
|
||||
.with_description("Dashboards, funnels, and trend alerts"),
|
||||
ComboboxOption::new("Growth", "growth")
|
||||
.with_description("Lifecycle campaigns and experiments"),
|
||||
ComboboxOption::new("Infrastructure", "infrastructure")
|
||||
.with_description("Runtime, deploys, and observability"),
|
||||
ComboboxOption::new("Support", "support")
|
||||
.with_description("Queues, macros, and response goals"),
|
||||
];
|
||||
let menu_items = vec![
|
||||
DropdownMenuItem::new("Profile", "profile").with_shortcut("⌘P"),
|
||||
DropdownMenuItem::new("Billing", "billing").with_shortcut("⌘B"),
|
||||
@@ -102,6 +163,26 @@ fn UiShowcase() -> Element {
|
||||
Some("Dive into the latest Dioxus 0.7 docs"),
|
||||
),
|
||||
];
|
||||
let table_rows = vec![
|
||||
("DW-9021", "Realtime dashboard", "Shipping", "2 minutes ago"),
|
||||
(
|
||||
"DB-1740",
|
||||
"AI campaign assistant",
|
||||
"Review",
|
||||
"14 minutes ago",
|
||||
),
|
||||
("MR-1183", "Metrics service", "Building", "38 minutes ago"),
|
||||
("PK-9422", "Payments ledger", "Queued", "58 minutes ago"),
|
||||
("XD-7710", "Access gateway", "Paused", "2 hours ago"),
|
||||
];
|
||||
let activity_items = vec![
|
||||
("09:05", "Jesse", "merged \"navigation cleanups\" into main"),
|
||||
("10:18", "Mia", "scheduled the weekly metrics export"),
|
||||
("11:42", "Arjun", "paused the experiment \"Pricing v2\""),
|
||||
("12:03", "Ivy", "restarted the realtime analytics workers"),
|
||||
("12:44", "Kai", "commented on the onboarding funnel deck"),
|
||||
("13:27", "Lena", "acknowledged alert \"Queue depth\""),
|
||||
];
|
||||
let menubar_menus = vec![
|
||||
MenubarMenu::new(
|
||||
"File",
|
||||
@@ -281,6 +362,87 @@ fn UiShowcase() -> Element {
|
||||
}
|
||||
}
|
||||
|
||||
div {
|
||||
style: single_column_style,
|
||||
Card {
|
||||
CardHeader {
|
||||
CardTitle { "Form helpers" }
|
||||
CardDescription { "FormField, Combobox, and Toggle wire up validation with shadcn styling." }
|
||||
}
|
||||
CardContent {
|
||||
div { class: "ui-stack",
|
||||
FormField {
|
||||
id: Some("helper-name".to_string()),
|
||||
label: Some("Project name".to_string()),
|
||||
helper_text: Some(profile_preview()),
|
||||
error: Some(name_error_signal),
|
||||
Input {
|
||||
id: "helper-name",
|
||||
placeholder: "Launch analytics workspace",
|
||||
value: profile_name_signal(),
|
||||
on_input: {
|
||||
let mut value_signal = profile_name_setter.clone();
|
||||
let mut error_signal = name_error_setter.clone();
|
||||
move |event: FormEvent| {
|
||||
let value = event.value();
|
||||
let trimmed_len = value.trim().len();
|
||||
value_signal.set(value.clone());
|
||||
if trimmed_len >= 3 {
|
||||
error_signal.set(None);
|
||||
} else {
|
||||
error_signal.set(Some("Use at least 3 characters to stay descriptive.".to_string()));
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
FormField {
|
||||
id: Some("helper-brief".to_string()),
|
||||
label: Some("Summary".to_string()),
|
||||
description: Some("Share quick context for the owners reviewing this request.".to_string()),
|
||||
helper_text: Some("You can mention teammates with @ and use Markdown formatting.".to_string()),
|
||||
Textarea {
|
||||
id: "helper-brief",
|
||||
placeholder: "Outline the goal, stakeholders, and success signal...",
|
||||
rows: 4,
|
||||
}
|
||||
}
|
||||
FormField {
|
||||
id: Some("owner-combobox".to_string()),
|
||||
label: Some("Assign owner".to_string()),
|
||||
description: Some("Search across teams to hand off this initiative.".to_string()),
|
||||
helper_text: Some(combobox_summary()),
|
||||
Combobox {
|
||||
id: Some("owner-combobox".to_string()),
|
||||
placeholder: "Search by team...",
|
||||
options: combobox_options.clone(),
|
||||
selected: combobox_selection_signal(),
|
||||
on_select: {
|
||||
let mut setter = combobox_selection_setter.clone();
|
||||
move |value| setter.set(Some(value))
|
||||
},
|
||||
}
|
||||
}
|
||||
div { class: "ui-stack",
|
||||
Toggle {
|
||||
pressed: toggle_active_signal(),
|
||||
on_pressed_change: {
|
||||
let mut setter = toggle_active_setter.clone();
|
||||
move |state| setter.set(state)
|
||||
},
|
||||
"Email alerts"
|
||||
}
|
||||
FormMessage {
|
||||
variant: FormMessageVariant::Helper,
|
||||
class: Some("ui-field-helper".to_string()),
|
||||
"{toggle_summary()}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
div {
|
||||
style: single_column_style,
|
||||
Card {
|
||||
@@ -372,6 +534,89 @@ fn UiShowcase() -> Element {
|
||||
}
|
||||
}
|
||||
|
||||
div {
|
||||
style: full_width_style,
|
||||
Card {
|
||||
CardHeader {
|
||||
CardTitle { "Data timelines" }
|
||||
CardDescription { "Tables, scroll areas, calendars, and skeleton loaders keep admin dashboards responsive." }
|
||||
}
|
||||
CardContent {
|
||||
div { class: "ui-stack",
|
||||
SpanHelper { "Deploys" }
|
||||
ScrollArea {
|
||||
max_height: Some("220px".to_string()),
|
||||
Table {
|
||||
TableCaption { "Latest updates from the delivery pipeline." }
|
||||
TableHeader {
|
||||
TableRow {
|
||||
TableHead { "ID" }
|
||||
TableHead { "Project" }
|
||||
TableHead { "Status" }
|
||||
TableHead { "Updated" }
|
||||
}
|
||||
}
|
||||
TableBody {
|
||||
for (id, name, status, updated) in table_rows.iter().copied() {
|
||||
TableRow {
|
||||
TableCell { "{id}" }
|
||||
TableCell { "{name}" }
|
||||
TableCell { "{status}" }
|
||||
TableCell { "{updated}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
TableFooter {
|
||||
TableRow {
|
||||
TableCell { "Total" }
|
||||
TableCell { "{table_rows.len()} pipelines" }
|
||||
TableCell { class: Some("ui-field-helper".to_string()), "Automated checks" }
|
||||
TableCell { class: Some("ui-field-helper".to_string()), "Past hour" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
FormMessage {
|
||||
variant: FormMessageVariant::Helper,
|
||||
class: Some("ui-field-helper".to_string()),
|
||||
"Keep automation quick by streaming the hottest rows into view."
|
||||
}
|
||||
Separator { style: "margin: 1rem 0;" }
|
||||
SpanHelper { "Activity feed" }
|
||||
ScrollArea {
|
||||
max_height: Some("140px".to_string()),
|
||||
ul {
|
||||
style: "display: flex; flex-direction: column; gap: 0.6rem; font-size: 0.85rem;",
|
||||
for (time, author, action) in activity_items.iter().copied() {
|
||||
li {
|
||||
style: "display: flex; gap: 0.5rem; align-items: baseline;",
|
||||
span { style: "font-weight: 600; font-variant-numeric: tabular-nums;", "{time}" }
|
||||
span { style: "font-weight: 600;", "{author}" }
|
||||
span { style: "color: hsl(var(--muted-foreground));", "{action}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Separator { style: "margin: 1rem 0;" }
|
||||
SpanHelper { "{calendar_summary()}" }
|
||||
Calendar {
|
||||
initial_month: calendar_month,
|
||||
selected: calendar_selection_signal(),
|
||||
on_select: {
|
||||
let mut setter = calendar_selection_setter.clone();
|
||||
move |day| setter.set(Some(day))
|
||||
},
|
||||
}
|
||||
div { class: "ui-cluster",
|
||||
Skeleton { width: Some("160px".to_string()), height: Some("1rem".to_string()) }
|
||||
Skeleton { width: Some("120px".to_string()), height: Some("1rem".to_string()) }
|
||||
Skeleton { width: Some("200px".to_string()), height: Some("1rem".to_string()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
div {
|
||||
style: full_width_style,
|
||||
Card {
|
||||
|
||||
Reference in New Issue
Block a user