一些组件

This commit is contained in:
tommy
2025-11-04 10:03:07 +08:00
parent cc5109df38
commit 634800ac0a
12 changed files with 1271 additions and 8 deletions

1
Cargo.lock generated
View File

@@ -1871,6 +1871,7 @@ checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
name = "dx-admin-template"
version = "0.1.0"
dependencies = [
"chrono",
"dioxus 0.7.0",
"dioxus-motion",
]

View File

@@ -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"]

View File

@@ -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));

View 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}"
}
}
}
}
}
}
}
}

View 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}" }
}
}
}
}
}
}
}
}
}
}
}
}
}

View 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}
}
}
}

View File

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

View 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}
}
}
}

View 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
View 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}
}
}
}

View 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}
}
}
}

View File

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