增加更多组件

This commit is contained in:
tommy
2025-11-03 13:15:17 +08:00
parent 77cd16a792
commit ed7af4eeda
15 changed files with 1486 additions and 6 deletions

View File

@@ -462,6 +462,245 @@
gap: 1rem;
}
.ui-breadcrumb {
display: inline-flex;
align-items: center;
gap: 0.6rem;
font-size: 0.85rem;
color: hsl(var(--muted-foreground));
}
.ui-breadcrumb a {
color: hsl(var(--foreground));
text-decoration: none;
}
.ui-breadcrumb a:hover {
color: hsl(var(--primary));
}
.ui-breadcrumb-separator {
opacity: 0.6;
}
.ui-pagination {
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.ui-page-button {
min-width: 2.25rem;
height: 2.25rem;
padding: 0 0.5rem;
border-radius: calc(var(--radius) - 2px);
border: 1px solid hsl(var(--border));
background-color: hsl(var(--background));
color: hsl(var(--foreground));
font-size: 0.85rem;
cursor: pointer;
transition: background-color 0.2s ease, border-color 0.2s ease;
}
.ui-page-button:hover {
background-color: hsl(var(--muted));
}
.ui-page-button[data-active="true"] {
border-color: hsl(var(--primary));
background-color: hsl(var(--primary));
color: hsl(var(--primary-foreground));
}
.ui-page-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.ui-steps {
display: flex;
align-items: center;
gap: 1rem;
}
.ui-step {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.4rem;
font-size: 0.8rem;
color: hsl(var(--muted-foreground));
}
.ui-step-indicator {
width: 2rem;
height: 2rem;
border-radius: 999px;
border: 2px solid hsl(var(--border));
display: flex;
align-items: center;
justify-content: center;
background-color: hsl(var(--background));
font-weight: 600;
}
.ui-step[data-state="active"] .ui-step-indicator {
border-color: hsl(var(--primary));
background-color: hsl(var(--primary));
color: hsl(var(--primary-foreground));
}
.ui-step[data-state="complete"] .ui-step-indicator {
border-color: hsl(var(--primary));
color: hsl(var(--primary));
}
.ui-navmenu,
.ui-menubar {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.2rem;
border-radius: calc(var(--radius) - 2px);
background-color: hsl(var(--muted));
position: relative;
}
.ui-navmenu-trigger,
.ui-menubar-trigger {
appearance: none;
border: none;
background: transparent;
padding: 0.45rem 0.75rem;
border-radius: calc(var(--radius) - 4px);
font-size: 0.85rem;
color: hsl(var(--muted-foreground));
cursor: pointer;
}
.ui-navmenu-trigger[data-open="true"],
.ui-menubar-trigger[data-open="true"] {
background-color: hsl(var(--background));
color: hsl(var(--foreground));
box-shadow: var(--shadow-sm);
}
.ui-navmenu-content,
.ui-menubar-content {
position: absolute;
top: calc(100% + 0.35rem);
left: 0;
min-width: 12rem;
background-color: hsl(var(--popover));
border: 1px solid hsl(var(--border));
border-radius: calc(var(--radius) - 2px);
box-shadow: var(--shadow-md);
padding: 0.5rem 0.4rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
z-index: 35;
}
.ui-navmenu-item,
.ui-menubar-item {
padding: 0.4rem 0.6rem;
border-radius: calc(var(--radius) - 4px);
font-size: 0.85rem;
color: hsl(var(--foreground));
cursor: pointer;
}
.ui-navmenu-item:hover,
.ui-menubar-item:hover {
background-color: hsl(var(--muted));
}
.ui-command {
width: 100%;
max-width: 420px;
background-color: hsl(var(--popover));
border: 1px solid hsl(var(--border));
border-radius: calc(var(--radius) + 2px);
box-shadow: var(--shadow-md);
overflow: hidden;
}
.ui-command-header {
padding: 0.6rem 0.9rem;
border-bottom: 1px solid hsl(var(--border));
display: flex;
align-items: center;
gap: 0.6rem;
}
.ui-command-input {
flex: 1;
border: none;
background: transparent;
font-size: 0.9rem;
color: hsl(var(--foreground));
outline: none;
}
.ui-command-list {
max-height: 14rem;
overflow-y: auto;
display: flex;
flex-direction: column;
}
.ui-command-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.55rem 0.9rem;
font-size: 0.85rem;
cursor: pointer;
color: hsl(var(--foreground));
}
.ui-command-item:hover,
.ui-command-item[data-state="active"] {
background-color: hsl(var(--muted));
}
.ui-context-trigger {
display: inline-flex;
align-items: center;
justify-content: center;
}
.ui-context-menu {
position: fixed;
min-width: 220px;
background-color: hsl(var(--popover));
border: 1px solid hsl(var(--border));
border-radius: calc(var(--radius) - 2px);
box-shadow: var(--shadow-md);
padding: 0.3rem 0.2rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
z-index: 40;
}
.ui-context-item {
padding: 0.45rem 0.75rem;
border-radius: calc(var(--radius) - 4px);
font-size: 0.85rem;
color: hsl(var(--foreground));
cursor: pointer;
}
.ui-context-item[data-variant="destructive"] {
color: hsl(var(--destructive));
}
.ui-context-item:hover {
background-color: hsl(var(--muted));
}
.ui-alert {
border-radius: calc(var(--radius) + 2px);
border: 1px solid hsl(var(--border));
@@ -684,6 +923,129 @@
letter-spacing: 0.03em;
}
.ui-overlay-backdrop {
position: fixed;
inset: 0;
background-color: hsl(var(--foreground) / 0.45);
display: flex;
align-items: center;
justify-content: center;
padding: 1.5rem;
z-index: 48;
}
.ui-dialog {
background-color: hsl(var(--popover));
border: 1px solid hsl(var(--border));
border-radius: calc(var(--radius) + 4px);
padding: 1.5rem;
width: min(480px, 92vw);
box-shadow: var(--shadow-md);
display: flex;
flex-direction: column;
gap: 1rem;
color: hsl(var(--popover-foreground));
}
.ui-dialog-header {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.ui-dialog-title {
font-size: 1.1rem;
font-weight: 600;
}
.ui-dialog-description {
font-size: 0.9rem;
color: hsl(var(--muted-foreground));
}
.ui-dialog-footer {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
}
.ui-popover,
.ui-hovercard {
position: absolute;
min-width: 220px;
border-radius: calc(var(--radius) - 2px);
border: 1px solid hsl(var(--border));
background-color: hsl(var(--popover));
box-shadow: var(--shadow-md);
padding: 0.75rem;
z-index: 45;
}
.ui-popover[data-placement="top"] {
transform: translate(-50%, -0.75rem);
}
.ui-popover[data-placement="bottom"] {
transform: translate(-50%, 0.75rem);
}
.ui-hovercard {
transform: translate(-50%, -0.75rem);
}
.ui-sheet-backdrop {
position: fixed;
inset: 0;
background-color: hsl(var(--foreground) / 0.45);
z-index: 49;
}
.ui-sheet {
position: fixed;
top: 0;
bottom: 0;
width: min(360px, 85vw);
background-color: hsl(var(--popover));
border-left: 1px solid hsl(var(--border));
box-shadow: -16px 0 40px -28px rgb(15 23 42 / 0.65);
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1rem;
z-index: 50;
}
.ui-sheet[data-side="left"] {
left: 0;
border-left: none;
border-right: 1px solid hsl(var(--border));
box-shadow: 16px 0 40px -28px rgb(15 23 42 / 0.65);
}
.ui-sheet[data-side="right"] {
right: 0;
}
.ui-toast-container {
position: fixed;
right: 1.5rem;
bottom: 1.5rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
z-index: 60;
}
.ui-toast {
background-color: hsl(var(--popover));
border: 1px solid hsl(var(--border));
border-radius: calc(var(--radius) + 2px);
padding: 0.85rem 1.1rem;
box-shadow: var(--shadow-md);
min-width: 240px;
color: hsl(var(--popover-foreground));
}
.ui-separator {
background-color: hsl(var(--border));
display: block;

View File

@@ -0,0 +1,43 @@
use dioxus::prelude::*;
#[derive(Clone, PartialEq)]
pub struct Crumb {
pub label: String,
pub href: Option<String>,
}
impl Crumb {
pub fn new(label: impl Into<String>, href: Option<impl Into<String>>) -> Self {
Self {
label: label.into(),
href: href.map(|value| value.into()),
}
}
}
#[component]
pub fn Breadcrumb(
#[props(into)] items: Vec<Crumb>,
#[props(into, default = "/".to_string())] separator: String,
) -> Element {
rsx! {
nav {
role: "navigation",
aria_label: "Breadcrumb",
span {
class: "ui-breadcrumb",
for (index, item) in items.iter().enumerate() {
if let Some(href) = &item.href {
a { href: href.clone(), "{item.label}" }
} else {
span { "{item.label}" }
}
if index < items.len().saturating_sub(1) {
span { class: "ui-breadcrumb-separator", "{separator}" }
}
}
}
}
}
}

View File

@@ -0,0 +1,110 @@
use dioxus::prelude::*;
#[derive(Clone, PartialEq)]
pub struct CommandItem {
pub label: String,
pub value: String,
pub shortcut: Option<String>,
pub group: Option<String>,
}
impl CommandItem {
pub fn new(label: impl Into<String>, value: impl Into<String>) -> Self {
Self {
label: label.into(),
value: value.into(),
shortcut: None,
group: None,
}
}
pub fn shortcut(mut self, shortcut: impl Into<String>) -> Self {
self.shortcut = Some(shortcut.into());
self
}
pub fn group(mut self, group: impl Into<String>) -> Self {
self.group = Some(group.into());
self
}
}
#[component]
pub fn CommandPalette(
#[props(into)] items: Vec<CommandItem>,
#[props(optional)] on_select: Option<EventHandler<String>>,
#[props(into, default = "Search commands".to_string())] placeholder: String,
) -> Element {
let mut query = use_signal(|| String::new());
let mut filtered = items.clone();
let q = query().to_lowercase();
if !q.is_empty() {
filtered.retain(|item| {
item.label.to_lowercase().contains(&q)
|| item
.group
.as_ref()
.map(|g| g.to_lowercase().contains(&q))
.unwrap_or(false)
});
}
let handler = on_select.clone();
let command_nodes: Vec<_> = filtered
.iter()
.map(|item| {
let value = item.value.clone();
let shortcut = item.shortcut.clone();
let group = item.group.clone();
let handler_clone = handler.clone();
rsx! {
div {
class: "ui-command-item",
"data-state": "inactive",
onclick: move |_| {
if let Some(cb) = handler_clone.clone() {
cb.call(value.clone());
}
},
div {
style: "display: flex; flex-direction: column; gap: 0.2rem;",
span { "{item.label}" }
if let Some(group) = group {
span { style: "font-size: 0.7rem; color: hsl(var(--muted-foreground));", "{group}" }
}
}
if let Some(shortcut) = shortcut {
span { style: "font-size: 0.75rem; opacity: 0.6;", "{shortcut}" }
}
}
}
})
.collect();
rsx! {
div {
class: "ui-command",
div {
class: "ui-command-header",
span { style: "font-size: 0.85rem; opacity: 0.6;", "⌘K" }
input {
class: "ui-command-input",
value: query(),
placeholder: placeholder.clone(),
oninput: move |event| query.set(event.value()),
}
}
div {
class: "ui-command-list",
if command_nodes.is_empty() {
span { style: "padding: 0.6rem 0.9rem; color: hsl(var(--muted-foreground));", "No results" }
} else {
{command_nodes.into_iter()}
}
}
}
}
}

View File

@@ -0,0 +1,82 @@
use dioxus::prelude::*;
#[derive(Clone, PartialEq)]
pub struct ContextItem {
pub label: String,
pub value: String,
pub destructive: bool,
}
impl ContextItem {
pub fn new(label: impl Into<String>, value: impl Into<String>) -> Self {
Self {
label: label.into(),
value: value.into(),
destructive: false,
}
}
pub fn destructive(mut self) -> Self {
self.destructive = true;
self
}
}
#[component]
pub fn ContextMenu(
#[props(into)] items: Vec<ContextItem>,
#[props(optional)] on_select: Option<EventHandler<String>>,
children: Element,
) -> Element {
let mut position = use_signal(|| None::<(f32, f32)>);
let handler = on_select.clone();
let menu_portal = position().map(|(x, y)| {
let nodes: Vec<_> = items
.iter()
.map(|item| {
let value = item.value.clone();
let mut pos_signal = position.clone();
let handler_clone = handler.clone();
rsx! {
button {
class: "ui-context-item",
"data-variant": if item.destructive { "destructive" } else { "default" },
onclick: move |_| {
if let Some(cb) = handler_clone.clone() {
cb.call(value.clone());
}
pos_signal.set(None);
},
"{item.label}"
}
}
})
.collect();
(x, y, nodes)
});
rsx! {
span {
class: "ui-context-trigger",
oncontextmenu: move |event| {
event.prevent_default();
let coords = event.client_coordinates();
position.set(Some((coords.x as f32, coords.y as f32)));
},
{children}
}
if let Some((x, y, nodes)) = menu_portal {
div {
style: "position: fixed; inset: 0; z-index: 39;",
onclick: move |_| position.set(None),
}
div {
class: "ui-context-menu",
style: format!("top: {y}px; left: {x}px;"),
{nodes.into_iter()}
}
}
}
}

View File

@@ -0,0 +1,60 @@
use crate::components::ui::{Button, ButtonSize, ButtonVariant};
use dioxus::prelude::*;
#[component]
pub fn Dialog(
mut open: Signal<bool>,
#[props(into, default)] title: Option<String>,
#[props(into, default)] description: Option<String>,
#[props(optional)] on_close: Option<EventHandler<()>>,
children: Element,
) -> Element {
if !open() {
return rsx! { Fragment {} };
}
let mut overlay_signal = open.clone();
let overlay_handler = on_close.clone();
let mut button_signal = open.clone();
let button_handler = on_close.clone();
rsx! {
div {
class: "ui-overlay-backdrop",
onclick: move |_| {
overlay_signal.set(false);
if let Some(cb) = overlay_handler.clone() {
cb.call(());
}
},
div {
class: "ui-dialog",
onclick: move |event| event.stop_propagation(),
if let Some(title) = title.clone() {
div {
class: "ui-dialog-header",
h3 { class: "ui-dialog-title", "{title}" }
if let Some(desc) = description.clone() {
p { class: "ui-dialog-description", "{desc}" }
}
}
}
{children}
div {
class: "ui-dialog-footer",
Button {
variant: ButtonVariant::Outline,
size: ButtonSize::Sm,
on_click: move |_| {
button_signal.set(false);
if let Some(cb) = button_handler.clone() {
cb.call(());
}
},
"Close"
}
}
}
}
}
}

View File

@@ -0,0 +1,22 @@
use dioxus::prelude::*;
#[component]
pub fn HoverCard(trigger: Element, content: Element) -> Element {
let mut open = use_signal(|| false);
rsx! {
span {
style: "position: relative; display: inline-flex;",
onmouseenter: move |_| open.set(true),
onmouseleave: move |_| open.set(false),
{trigger}
if open() {
div {
class: "ui-hovercard",
style: "left: 50%; bottom: 100%; transform: translate(-50%, -0.75rem);",
{content}
}
}
}
}
}

View File

@@ -0,0 +1,116 @@
use dioxus::prelude::*;
#[derive(Clone, PartialEq)]
pub struct MenubarItem {
pub label: String,
pub value: String,
pub shortcut: Option<String>,
pub destructive: bool,
}
impl MenubarItem {
pub fn new(label: impl Into<String>, value: impl Into<String>) -> Self {
Self {
label: label.into(),
value: value.into(),
shortcut: None,
destructive: false,
}
}
pub fn shortcut(mut self, shortcut: impl Into<String>) -> Self {
self.shortcut = Some(shortcut.into());
self
}
pub fn destructive(mut self) -> Self {
self.destructive = true;
self
}
}
#[derive(Clone, PartialEq)]
pub struct MenubarMenu {
pub label: String,
pub items: Vec<MenubarItem>,
}
impl MenubarMenu {
pub fn new(label: impl Into<String>, items: Vec<MenubarItem>) -> Self {
Self {
label: label.into(),
items,
}
}
}
#[component]
pub fn Menubar(
#[props(into)] menus: Vec<MenubarMenu>,
#[props(optional)] on_select: Option<EventHandler<String>>,
) -> Element {
let mut open = use_signal(|| None::<usize>);
let handler = on_select.clone();
let menu_nodes: Vec<_> = menus
.iter()
.enumerate()
.map(|(index, menu)| {
let mut open_signal_hover = open.clone();
let mut open_signal_click = open.clone();
let is_open = open() == Some(index);
let item_nodes: Vec<_> = menu
.items
.iter()
.map(|item| {
let value = item.value.clone();
let shortcut = item.shortcut.clone();
let destructive = item.destructive;
let handler_clone = handler.clone();
let mut open_close = open.clone();
rsx! {
button {
class: "ui-menubar-item",
"data-variant": if destructive { "destructive" } else { "default" },
onclick: move |_| {
if let Some(cb) = handler_clone.clone() {
cb.call(value.clone());
}
open_close.set(None);
},
span { "{item.label}" }
if let Some(shortcut) = shortcut.clone() {
span { style: "font-size: 0.75rem; opacity: 0.6;", "{shortcut}" }
}
}
}
})
.collect();
rsx! {
span {
style: "position: relative;",
button {
class: "ui-menubar-trigger",
"data-open": if is_open { "true" } else { "false" },
onmouseenter: move |_| open_signal_hover.set(Some(index)),
onclick: move |_| open_signal_click.set(Some(index)),
"{menu.label}"
}
if is_open {
div { class: "ui-menubar-content", {item_nodes.into_iter()} }
}
}
}
})
.collect();
rsx! {
div {
class: "ui-menubar",
onmouseleave: move |_| open.set(None),
{menu_nodes.into_iter()}
}
}
}

View File

@@ -6,38 +6,62 @@ mod accordion;
mod alert;
mod avatar;
mod badge;
mod breadcrumb;
mod button;
mod card;
mod checkbox;
mod command;
mod context_menu;
mod dialog;
mod dropdown_menu;
mod hover_card;
mod input;
mod label;
mod menubar;
mod navigation_menu;
mod pagination;
mod popover;
mod progress;
mod radio_group;
mod select;
mod separator;
mod sheet;
mod slider;
mod steps;
mod switch;
mod tabs;
mod textarea;
mod toast;
mod tooltip;
pub use accordion::*;
pub use alert::*;
pub use avatar::*;
pub use badge::*;
pub use breadcrumb::*;
pub use button::*;
pub use card::*;
pub use checkbox::*;
pub use command::*;
pub use context_menu::*;
pub use dialog::*;
pub use dropdown_menu::*;
pub use hover_card::*;
pub use input::*;
pub use label::*;
pub use menubar::*;
pub use navigation_menu::*;
pub use pagination::*;
pub use popover::*;
pub use progress::*;
pub use radio_group::*;
pub use select::*;
pub use separator::*;
pub use sheet::*;
pub use slider::*;
pub use steps::*;
pub use switch::*;
pub use tabs::*;
pub use textarea::*;
pub use toast::*;
pub use tooltip::*;

View File

@@ -0,0 +1,75 @@
use dioxus::prelude::*;
#[derive(Clone, PartialEq)]
pub struct NavigationItem {
pub label: String,
pub description: Option<String>,
pub href: String,
}
impl NavigationItem {
pub fn new(
label: impl Into<String>,
href: impl Into<String>,
description: Option<impl Into<String>>,
) -> Self {
Self {
label: label.into(),
href: href.into(),
description: description.map(|d| d.into()),
}
}
}
#[component]
pub fn NavigationMenu(#[props(into)] items: Vec<NavigationItem>) -> Element {
let active = use_signal(|| 0usize);
let trigger_nodes: Vec<_> = items
.iter()
.enumerate()
.map(|(index, item)| {
let mut active_signal = active.clone();
let is_active = active() == index;
rsx! {
button {
class: "ui-navmenu-trigger",
"data-open": if is_active { "true" } else { "false" },
onmouseenter: move |_| active_signal.set(index),
onclick: move |_| active_signal.set(index),
"{item.label}"
}
}
})
.collect();
let selected_content = items
.get(active().min(items.len().saturating_sub(1)))
.map(|item| {
let description = item.description.clone();
rsx! {
div {
class: "ui-navmenu-content",
a {
href: item.href.clone(),
class: "ui-navmenu-item",
"{item.label}"
}
if let Some(desc) = description {
span { style: "font-size: 0.75rem; color: hsl(var(--muted-foreground));", "{desc}" }
}
}
}
});
rsx! {
div {
class: "ui-navmenu",
{trigger_nodes.into_iter()}
if let Some(content) = selected_content {
{content}
}
}
}
}

View File

@@ -0,0 +1,101 @@
use dioxus::prelude::*;
#[component]
pub fn Pagination(
total_pages: usize,
current_page: usize,
#[props(optional)] on_page_change: Option<EventHandler<usize>>,
) -> Element {
let mut current = use_signal(move || current_page.max(1).min(total_pages.max(1)));
use_effect(move || {
if current() != current_page {
current.set(current_page.max(1).min(total_pages.max(1)));
}
});
let on_change = on_page_change.clone();
let mut buttons = vec![];
if total_pages <= 7 {
buttons.extend(1..=total_pages);
} else {
let active = current();
buttons.extend([1, 2]);
if active > 4 {
buttons.push(0); // ellipsis indicator
}
let start = active.saturating_sub(1).max(3);
let end = (active + 1).min(total_pages - 2);
for page in start..=end {
buttons.push(page);
}
if active + 2 < total_pages - 1 {
buttons.push(0);
}
buttons.extend([total_pages - 1, total_pages]);
}
let page_nodes: Vec<_> = buttons
.iter()
.map(|page| {
if *page == 0 {
rsx! { span { class: "ui-page-button", style: "pointer-events: none;", "" } }
} else {
let mut page_signal = current.clone();
let page_handler = on_change.clone();
let target = *page;
rsx! {
button {
class: "ui-page-button",
"data-active": if page_signal() == target { "true" } else { "false" },
onclick: move |_| {
let new_page = target.max(1).min(total_pages.max(1));
page_signal.set(new_page);
if let Some(cb) = page_handler.clone() {
cb.call(new_page);
}
},
"{target}"
}
}
}
})
.collect();
let mut prev_signal = current.clone();
let prev_handler = on_change.clone();
let mut next_signal = current.clone();
let next_handler = on_change.clone();
rsx! {
nav {
class: "ui-pagination",
aria_label: "Pagination",
button {
class: "ui-page-button",
disabled: prev_signal() <= 1,
onclick: move |_| {
let new_page = prev_signal().saturating_sub(1).max(1);
prev_signal.set(new_page);
if let Some(cb) = prev_handler.clone() {
cb.call(new_page);
}
},
"Prev"
}
{page_nodes.into_iter()}
button {
class: "ui-page-button",
disabled: next_signal() >= total_pages.max(1),
onclick: move |_| {
let new_page = (next_signal() + 1).min(total_pages.max(1));
next_signal.set(new_page);
if let Some(cb) = next_handler.clone() {
cb.call(new_page);
}
},
"Next"
}
}
}
}

View File

@@ -0,0 +1,35 @@
use dioxus::prelude::*;
#[component]
pub fn Popover(
trigger: Element,
content: Element,
#[props(into, default = "bottom".to_string())] placement: String,
) -> Element {
let mut open = use_signal(|| false);
rsx! {
span {
style: "position: relative; display: inline-flex;",
onclick: move |_| open.set(!open()),
tabindex: 0,
onfocusout: move |_| open.set(false),
{trigger}
if open() {
div {
class: "ui-popover",
"data-placement": placement.clone(),
style: match placement.as_str() {
"top" => "left: 50%; bottom: 100%;",
"bottom" => "left: 50%; top: 100%;",
"left" => "right: 100%; top: 50%; transform: translate(-0.75rem, -50%);",
"right" => "left: 100%; top: 50%; transform: translate(0.75rem, -50%);",
_ => "left: 50%; top: 100%;"
},
onclick: move |event| event.stop_propagation(),
{content}
}
}
}
}
}

View File

@@ -0,0 +1,81 @@
use crate::components::ui::{Button, ButtonSize, ButtonVariant};
use dioxus::prelude::*;
#[allow(dead_code)]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SheetSide {
Left,
Right,
}
impl SheetSide {
fn as_str(&self) -> &'static str {
match self {
SheetSide::Left => "left",
SheetSide::Right => "right",
}
}
}
impl Default for SheetSide {
fn default() -> Self {
SheetSide::Right
}
}
#[component]
pub fn Sheet(
mut open: Signal<bool>,
#[props(default)] side: SheetSide,
#[props(into, default)] title: Option<String>,
#[props(into, default)] description: Option<String>,
#[props(optional)] on_close: Option<EventHandler<()>>,
children: Element,
) -> Element {
if !open() {
return rsx! { Fragment {} };
}
let mut overlay_signal = open.clone();
let overlay_handler = on_close.clone();
let mut button_signal = open.clone();
let button_handler = on_close.clone();
rsx! {
div {
class: "ui-sheet-backdrop",
onclick: move |_| {
overlay_signal.set(false);
if let Some(cb) = overlay_handler.clone() {
cb.call(());
}
},
}
div {
class: "ui-sheet",
"data-side": side.as_str(),
onclick: move |event| event.stop_propagation(),
if let Some(title) = title.clone() {
h3 { class: "ui-dialog-title", "{title}" }
}
if let Some(desc) = description.clone() {
p { class: "ui-dialog-description", "{desc}" }
}
{children}
div {
class: "ui-dialog-footer",
Button {
variant: ButtonVariant::Outline,
size: ButtonSize::Sm,
on_click: move |_| {
button_signal.set(false);
if let Some(cb) = button_handler.clone() {
cb.call(());
}
},
"Close"
}
}
}
}
}

View File

@@ -0,0 +1,55 @@
use dioxus::prelude::*;
#[derive(Clone, PartialEq)]
pub struct StepItem {
pub title: String,
pub description: Option<String>,
}
impl StepItem {
pub fn new(title: impl Into<String>, description: Option<impl Into<String>>) -> Self {
Self {
title: title.into(),
description: description.map(|d| d.into()),
}
}
}
#[component]
pub fn Steps(#[props(into)] steps: Vec<StepItem>, #[props(default = 1)] current: usize) -> Element {
let rendered_steps: Vec<_> = steps
.iter()
.enumerate()
.map(|(index, step)| {
let position = index + 1;
let state = if position < current {
"complete"
} else if position == current {
"active"
} else {
"upcoming"
};
let indicator_text = if position < current {
"".to_string()
} else {
position.to_string()
};
rsx! {
div {
class: "ui-step",
"data-state": state,
span { class: "ui-step-indicator", "{indicator_text}" }
span { "{step.title}" }
if let Some(description) = &step.description {
span { style: "font-size: 0.72rem; color: hsl(var(--muted-foreground));", "{description}" }
}
}
}
})
.collect();
let step_nodes = rendered_steps;
rsx! { div { class: "ui-steps", {step_nodes.into_iter()} } }
}

View File

@@ -0,0 +1,42 @@
use dioxus::prelude::*;
#[component]
pub fn ToastViewport(children: Element) -> Element {
rsx! { div { class: "ui-toast-container", {children} } }
}
#[component]
pub fn Toast(
#[props(default)] open: bool,
#[props(into, default)] title: Option<String>,
#[props(into, default)] description: Option<String>,
#[props(optional)] on_close: Option<EventHandler<()>>,
) -> Element {
if !open {
return rsx! { Fragment {} };
}
rsx! {
div {
class: "ui-toast",
if let Some(title) = title {
h4 { style: "font-weight: 600; font-size: 0.95rem;", "{title}" }
}
if let Some(desc) = description {
p { style: "font-size: 0.8rem; color: hsl(var(--muted-foreground));", "{desc}" }
}
if on_close.is_some() {
button {
class: "ui-page-button",
style: "align-self: flex-end; height: 2rem;",
onclick: move |_| {
if let Some(cb) = on_close.clone() {
cb.call(());
}
},
"Dismiss"
}
}
}
}
}

View File

@@ -1,11 +1,13 @@
use crate::components::{
ui::{
Accordion, AccordionContent, AccordionItem, AccordionTrigger, Alert, AlertVariant, Avatar,
Badge, BadgeVariant, Button, ButtonSize, ButtonVariant, Card, CardContent, CardDescription,
CardFooter, CardHeader, CardTitle, Checkbox, DropdownMenu, DropdownMenuItem, Input, Label,
Progress, RadioGroup, RadioGroupItem, Select, SelectOption, Separator,
SeparatorOrientation, Slider, Switch, Tabs, TabsContent, TabsList, TabsTrigger, Textarea,
Tooltip,
Badge, BadgeVariant, Breadcrumb, Button, ButtonSize, ButtonVariant, Card, CardContent,
CardDescription, CardFooter, CardHeader, CardTitle, Checkbox, CommandItem, CommandPalette,
ContextItem, ContextMenu, Crumb, Dialog, DropdownMenu, DropdownMenuItem, HoverCard, Input,
Label, Menubar, MenubarItem, MenubarMenu, NavigationItem, NavigationMenu, Pagination,
Popover, Progress, RadioGroup, RadioGroupItem, Select, SelectOption, Separator,
SeparatorOrientation, Sheet, SheetSide, Slider, StepItem, Steps, Switch, Tabs, TabsContent,
TabsList, TabsTrigger, Textarea, Toast, ToastViewport, Tooltip,
},
Echo, Hero,
};
@@ -31,6 +33,14 @@ fn UiShowcase() -> Element {
let dark_mode = use_signal(|| false);
let theme_choice = use_signal(|| Some("system".to_string()));
let menu_selection = use_signal(|| "Select a menu action".to_string());
let menubar_selection = use_signal(|| "Choose a menu item".to_string());
let pagination_current = use_signal(|| 3usize);
let steps_current = use_signal(|| 2usize);
let command_selection = use_signal(|| "Nothing selected yet".to_string());
let context_selection = use_signal(|| "Right click the area to choose an action".to_string());
let dialog_open = use_signal(|| false);
let sheet_open = use_signal(|| false);
let toast_open = use_signal(|| false);
let slider_value_signal = slider_value.clone();
let slider_value_setter = slider_value.clone();
let contact_method_signal = contact_method.clone();
@@ -42,6 +52,14 @@ fn UiShowcase() -> Element {
let dark_mode_setter = dark_mode.clone();
let theme_choice_setter = theme_choice.clone();
let menu_selection_setter = menu_selection.clone();
let menubar_selection_setter = menubar_selection.clone();
let pagination_setter = pagination_current.clone();
let steps_setter = steps_current.clone();
let command_selection_setter = command_selection.clone();
let context_selection_setter = context_selection.clone();
let dialog_signal = dialog_open.clone();
let sheet_signal = sheet_open.clone();
let toast_signal = toast_open.clone();
let intensity_text = move || format!("Accent intensity: {:.0}%", slider_value_signal());
let contact_text = move || format!("Preferred contact: {}", contact_method_signal());
let select_options = vec![
@@ -55,6 +73,70 @@ fn UiShowcase() -> Element {
DropdownMenuItem::new("Team", "team"),
DropdownMenuItem::new("Sign out", "logout").destructive(),
];
let breadcrumb_items = vec![
Crumb::new("Dashboard", Some("#")),
Crumb::new("Settings", Some("#settings")),
Crumb::new("Team", None::<String>),
];
let navigation_items = vec![
NavigationItem::new(
"Overview",
"#overview",
Some("Project snapshots and quick metrics"),
),
NavigationItem::new(
"Playground",
"#playground",
Some("Prototype new ideas and components"),
),
NavigationItem::new(
"Documentation",
"https://dioxuslabs.com/learn",
Some("Dive into the latest Dioxus 0.7 docs"),
),
];
let menubar_menus = vec![
MenubarMenu::new(
"File",
vec![
MenubarItem::new("New Tab", "new_tab").shortcut("⌘T"),
MenubarItem::new("Open Workspace", "open_workspace"),
MenubarItem::new("Save", "save").shortcut("⌘S"),
],
),
MenubarMenu::new(
"Edit",
vec![
MenubarItem::new("Undo", "undo").shortcut("⌘Z"),
MenubarItem::new("Redo", "redo").shortcut("⇧⌘Z"),
MenubarItem::new("Delete", "delete").destructive(),
],
),
];
let command_items = vec![
CommandItem::new("Create project", "create_project")
.shortcut("⌘N")
.group("Actions"),
CommandItem::new("Invite teammate", "invite").group("Actions"),
CommandItem::new("Open documentation", "docs").group("Resources"),
CommandItem::new("Keyboard shortcuts", "shortcuts").group("Resources"),
];
let context_items = vec![
ContextItem::new("Rename", "rename"),
ContextItem::new("Duplicate", "duplicate"),
ContextItem::new("Archive", "archive"),
ContextItem::new("Delete", "delete").destructive(),
];
let steps_items = vec![
StepItem::new("Plan", Some("Outline requirements")),
StepItem::new("Build", Some("Implement features")),
StepItem::new("Review", Some("QA and ship")),
];
let total_pages = 8usize;
let pagination_summary =
move || format!("Showing page {} of {total_pages}", pagination_current());
let steps_total = steps_items.len();
let steps_summary = move || format!("Stage {} of {steps_total}", steps_current());
let theme_display = {
let current = theme_choice();
current
@@ -202,7 +284,7 @@ fn UiShowcase() -> Element {
Card {
CardHeader {
CardTitle { "Select & menus" }
CardTitle { "Select & dropdowns" }
CardDescription { "Select, dropdown menu, tooltip and dynamic feedback." }
}
CardContent {
@@ -245,6 +327,75 @@ fn UiShowcase() -> Element {
}
}
Card {
CardHeader {
CardTitle { "Navigation patterns" }
CardDescription { "Breadcrumbs, menus, pagination, and progress steps." }
}
CardContent {
div { class: "ui-stack",
SpanHelper { "Breadcrumb" }
Breadcrumb { items: breadcrumb_items.clone(), separator: ">".to_string() }
}
div { class: "ui-stack",
SpanHelper { "Navigation menu" }
NavigationMenu { items: navigation_items.clone() }
}
div { class: "ui-stack",
SpanHelper { "Menubar" }
Menubar {
menus: menubar_menus.clone(),
on_select: move |value| {
let mut signal = menubar_selection_setter.clone();
signal.set(format!("Menubar selected: {value}"));
},
}
SpanHelper { "{menubar_selection()}" }
}
div { class: "ui-stack",
SpanHelper { "Pagination" }
Pagination {
total_pages: total_pages,
current_page: pagination_current(),
on_page_change: move |page| {
let mut signal = pagination_setter.clone();
signal.set(page);
},
}
SpanHelper { "{pagination_summary()}" }
}
div { class: "ui-stack",
SpanHelper { "Steps" }
Steps {
steps: steps_items.clone(),
current: steps_current(),
}
div { class: "ui-cluster",
Button {
variant: ButtonVariant::Outline,
size: ButtonSize::Sm,
on_click: move |_| {
let mut signal = steps_setter.clone();
let prev = signal().saturating_sub(1).max(1);
signal.set(prev);
},
"Previous"
}
Button {
size: ButtonSize::Sm,
on_click: move |_| {
let mut signal = steps_setter.clone();
let next = (signal() + 1).min(steps_total);
signal.set(next);
},
"Next"
}
}
SpanHelper { "{steps_summary()}" }
}
}
}
Card {
CardHeader {
CardTitle { "Selection controls" }
@@ -292,6 +443,41 @@ fn UiShowcase() -> Element {
}
}
Card {
CardHeader {
CardTitle { "Command & context" }
CardDescription { "Command palette filtering and contextual menus." }
}
CardContent {
div { class: "ui-stack",
SpanHelper { "Command palette" }
CommandPalette {
items: command_items.clone(),
on_select: move |value| {
let mut signal = command_selection_setter.clone();
signal.set(format!("Command selected: {value}"));
},
}
SpanHelper { "{command_selection()}" }
}
div { class: "ui-stack",
SpanHelper { "Context menu" }
ContextMenu {
items: context_items.clone(),
on_select: move |value| {
let mut signal = context_selection_setter.clone();
signal.set(format!("Context action: {value}"));
},
div {
style: "padding: 1.5rem; border: 1px dashed hsl(var(--border)); border-radius: var(--radius); text-align: center;",
"Right click anywhere in this box"
}
}
SpanHelper { "{context_selection()}" }
}
}
}
Card {
CardHeader {
CardTitle { "Tabs & panels" }
@@ -331,6 +517,56 @@ fn UiShowcase() -> Element {
}
}
Card {
CardHeader {
CardTitle { "Dialogs & overlays" }
CardDescription { "Popover, hover card, dialogs, sheet, and toast examples." }
}
CardContent {
div { class: "ui-cluster",
Button {
variant: ButtonVariant::Secondary,
on_click: move |_| {
let mut signal = dialog_signal.clone();
signal.set(true);
},
"Open dialog"
}
Button {
variant: ButtonVariant::Outline,
on_click: move |_| {
let mut signal = sheet_signal.clone();
signal.set(true);
},
"Open sheet"
}
Button {
variant: ButtonVariant::Ghost,
on_click: move |_| {
let mut signal = toast_signal.clone();
signal.set(true);
},
"Notify me"
}
}
div { class: "ui-stack",
SpanHelper { "Popover" }
Popover {
placement: "bottom".to_string(),
trigger: rsx! { Button { variant: ButtonVariant::Outline, size: ButtonSize::Sm, "Toggle popover" } },
content: rsx! { SpanHelper { "Choose the dialog or sheet you want to configure." } },
}
}
div { class: "ui-stack",
SpanHelper { "Hover card" }
HoverCard {
trigger: rsx! { Badge { variant: BadgeVariant::Secondary, "Hover me" } },
content: rsx! { span { style: "font-size: 0.8rem; color: hsl(var(--muted-foreground));", "Preview contextual information instantly." } },
}
}
}
}
Card {
CardHeader {
CardTitle { "Alerts & extras" }
@@ -384,6 +620,42 @@ fn UiShowcase() -> Element {
}
}
}
Dialog {
open: dialog_signal.clone(),
title: Some("Create project".to_string()),
description: Some("Configure the new analytics workspace.".to_string()),
div { class: "ui-stack",
Label { html_for: "dialog-name", "Project name" }
Input { id: "dialog-name", placeholder: "Analytics redesign" }
}
}
Sheet {
open: sheet_signal.clone(),
side: SheetSide::Right,
title: Some("Activity log".to_string()),
description: Some("Review the latest changes from your teammates.".to_string()),
div {
class: "ui-stack",
SpanHelper { "Today" }
ul {
style: "display: flex; flex-direction: column; gap: 0.5rem; font-size: 0.85rem;",
li { "Maria added new metrics to the dashboard." }
li { "Evan approved the Q2 launch plan." }
li { "Ada commented on revenue projections." }
}
}
}
ToastViewport {
Toast {
open: toast_open(),
title: Some("Changes saved".to_string()),
description: Some("We synced your workspace preferences.".to_string()),
on_close: move |_| {
let mut signal = toast_signal.clone();
signal.set(false);
},
}
}
}
}
}