mirror of
https://github.com/mztlive/dx-admin-template.git
synced 2025-12-22 21:59:59 +00:00
增加组件
This commit is contained in:
@@ -462,6 +462,228 @@
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.ui-alert {
|
||||
border-radius: calc(var(--radius) + 2px);
|
||||
border: 1px solid hsl(var(--border));
|
||||
background-color: hsl(var(--muted) / 0.6);
|
||||
padding: 1.1rem 1.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.ui-alert[data-variant="destructive"] {
|
||||
border-color: hsl(var(--destructive));
|
||||
background-color: hsl(var(--destructive) / 0.1);
|
||||
color: hsl(var(--destructive));
|
||||
}
|
||||
|
||||
.ui-alert-title {
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.ui-alert-description {
|
||||
font-size: 0.85rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.ui-select,
|
||||
.ui-dropdown {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ui-select[data-disabled="true"] {
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.ui-select-trigger,
|
||||
.ui-dropdown-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));
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ui-select-trigger:focus-visible,
|
||||
.ui-dropdown-trigger:focus-visible {
|
||||
outline: none;
|
||||
border-color: hsl(var(--ring));
|
||||
box-shadow: 0 0 0 1px hsl(var(--ring));
|
||||
}
|
||||
|
||||
.ui-select-trigger[data-open="true"],
|
||||
.ui-dropdown-trigger[data-open="true"] {
|
||||
border-color: hsl(var(--ring));
|
||||
}
|
||||
|
||||
.ui-select-content,
|
||||
.ui-dropdown-content {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.35rem);
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 30;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: hsl(var(--popover));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: calc(var(--radius) - 2px);
|
||||
box-shadow: var(--shadow-md);
|
||||
overflow: hidden;
|
||||
max-height: 14rem;
|
||||
}
|
||||
|
||||
.ui-select-list,
|
||||
.ui-dropdown-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.ui-select-item,
|
||||
.ui-dropdown-item {
|
||||
width: 100%;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.65rem 0.9rem;
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--foreground));
|
||||
transition: background-color 0.2s ease, color 0.2s ease;
|
||||
}
|
||||
|
||||
.ui-select-item:hover,
|
||||
.ui-dropdown-item:hover,
|
||||
.ui-select-item[data-state="active"],
|
||||
.ui-dropdown-item[data-state="active"] {
|
||||
background-color: hsl(var(--muted));
|
||||
}
|
||||
|
||||
.ui-dropdown-item[data-variant="destructive"] {
|
||||
color: hsl(var(--destructive));
|
||||
}
|
||||
|
||||
.ui-tooltip-wrapper {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.ui-tooltip-bubble {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -8px);
|
||||
bottom: 100%;
|
||||
background-color: hsl(var(--foreground));
|
||||
color: hsl(var(--background));
|
||||
font-size: 0.72rem;
|
||||
padding: 0.3rem 0.55rem;
|
||||
border-radius: calc(var(--radius) - 4px);
|
||||
white-space: nowrap;
|
||||
box-shadow: var(--shadow-sm);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.1s ease, transform 0.1s ease;
|
||||
}
|
||||
|
||||
.ui-tooltip-bubble[data-state="visible"] {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -12px);
|
||||
}
|
||||
|
||||
.ui-accordion {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.ui-accordion-item {
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: calc(var(--radius) - 2px);
|
||||
overflow: hidden;
|
||||
background-color: hsl(var(--card));
|
||||
}
|
||||
|
||||
.ui-accordion-trigger {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
font-size: 0.9rem;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.ui-accordion-trigger:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 1px hsl(var(--ring));
|
||||
}
|
||||
|
||||
.ui-accordion-content {
|
||||
padding: 0 1rem 0.9rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-size: 0.85rem;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ui-accordion-content[data-state="open"] {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.ui-avatar {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
border-radius: 999px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: hsl(var(--muted));
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
border: 1px solid hsl(var(--border));
|
||||
}
|
||||
|
||||
.ui-avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.ui-avatar-fallback {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1rem;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.ui-separator {
|
||||
background-color: hsl(var(--border));
|
||||
display: block;
|
||||
|
||||
132
src/components/ui/accordion.rs
Normal file
132
src/components/ui/accordion.rs
Normal file
@@ -0,0 +1,132 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct AccordionContext {
|
||||
open_value: Signal<Option<String>>,
|
||||
collapsible: bool,
|
||||
}
|
||||
|
||||
impl AccordionContext {
|
||||
fn is_open(&self, value: &str) -> bool {
|
||||
matches!((self.open_value)(), Some(current) if current == value)
|
||||
}
|
||||
|
||||
fn toggle(&self, value: String) {
|
||||
let mut state = self.open_value.clone();
|
||||
if self.is_open(&value) {
|
||||
if self.collapsible {
|
||||
state.set(None);
|
||||
}
|
||||
} else {
|
||||
state.set(Some(value));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct AccordionItemContext {
|
||||
value: String,
|
||||
root: AccordionContext,
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn Accordion(
|
||||
#[props(default)] collapsible: bool,
|
||||
#[props(into, default)] default_value: Option<String>,
|
||||
#[props(into, default)] class: Option<String>,
|
||||
children: Element,
|
||||
) -> Element {
|
||||
let initial_value = default_value.clone();
|
||||
let state = use_signal(move || initial_value.clone());
|
||||
|
||||
let context = AccordionContext {
|
||||
open_value: state,
|
||||
collapsible,
|
||||
};
|
||||
|
||||
use_context_provider(|| context.clone());
|
||||
|
||||
let class_name = format!(
|
||||
"{}{}",
|
||||
"ui-accordion",
|
||||
class
|
||||
.filter(|c| !c.trim().is_empty())
|
||||
.map(|c| format!(" {c}"))
|
||||
.unwrap_or_default()
|
||||
);
|
||||
|
||||
rsx! {
|
||||
div {
|
||||
class: class_name,
|
||||
{children}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn AccordionItem(
|
||||
#[props(into)] value: String,
|
||||
#[props(into, default)] class: Option<String>,
|
||||
children: Element,
|
||||
) -> Element {
|
||||
let root = use_context::<AccordionContext>();
|
||||
let item_context = AccordionItemContext {
|
||||
value: value.clone(),
|
||||
root: root.clone(),
|
||||
};
|
||||
use_context_provider(|| item_context);
|
||||
|
||||
let class_name = format!(
|
||||
"{}{}",
|
||||
"ui-accordion-item",
|
||||
class
|
||||
.filter(|c| !c.trim().is_empty())
|
||||
.map(|c| format!(" {c}"))
|
||||
.unwrap_or_default()
|
||||
);
|
||||
|
||||
rsx! {
|
||||
div {
|
||||
class: class_name,
|
||||
"data-state": if root.is_open(&value) { "open" } else { "closed" },
|
||||
{children}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn AccordionTrigger(children: Element) -> Element {
|
||||
let item = use_context::<AccordionItemContext>();
|
||||
let is_open = item.root.is_open(&item.value);
|
||||
let value = item.value.clone();
|
||||
let root = item.root.clone();
|
||||
|
||||
rsx! {
|
||||
button {
|
||||
class: "ui-accordion-trigger",
|
||||
"data-state": if is_open { "open" } else { "closed" },
|
||||
onclick: move |_| root.toggle(value.clone()),
|
||||
{children}
|
||||
span {
|
||||
style: "font-size: 0.8rem; opacity: 0.6;",
|
||||
if is_open { "−" } else { "+" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn AccordionContent(children: Element) -> Element {
|
||||
let item = use_context::<AccordionItemContext>();
|
||||
let is_open = item.root.is_open(&item.value);
|
||||
|
||||
rsx! {
|
||||
div {
|
||||
class: "ui-accordion-content",
|
||||
"data-state": if is_open { "open" } else { "closed" },
|
||||
if is_open {
|
||||
{children}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
44
src/components/ui/alert.rs
Normal file
44
src/components/ui/alert.rs
Normal file
@@ -0,0 +1,44 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
/// Visual variants for alerts.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum AlertVariant {
|
||||
Default,
|
||||
Destructive,
|
||||
}
|
||||
|
||||
impl AlertVariant {
|
||||
fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
AlertVariant::Default => "default",
|
||||
AlertVariant::Destructive => "destructive",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AlertVariant {
|
||||
fn default() -> Self {
|
||||
AlertVariant::Default
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn Alert(
|
||||
#[props(default)] variant: AlertVariant,
|
||||
#[props(into, default)] title: Option<String>,
|
||||
children: Element,
|
||||
) -> Element {
|
||||
rsx! {
|
||||
div {
|
||||
class: "ui-alert",
|
||||
"data-variant": variant.as_str(),
|
||||
if let Some(title) = title {
|
||||
h4 { class: "ui-alert-title", "{title}" }
|
||||
}
|
||||
div {
|
||||
class: "ui-alert-description",
|
||||
{children}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
51
src/components/ui/avatar.rs
Normal file
51
src/components/ui/avatar.rs
Normal file
@@ -0,0 +1,51 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
#[component]
|
||||
pub fn Avatar(
|
||||
#[props(into, default)] src: Option<String>,
|
||||
#[props(into, default)] alt: Option<String>,
|
||||
#[props(into, default)] fallback: Option<String>,
|
||||
#[props(into, default)] class: Option<String>,
|
||||
) -> Element {
|
||||
let initial_missing = src.is_none();
|
||||
let mut show_fallback = use_signal(move || initial_missing);
|
||||
|
||||
let class_name = format!(
|
||||
"{}{}",
|
||||
"ui-avatar",
|
||||
class
|
||||
.filter(|c| !c.trim().is_empty())
|
||||
.map(|c| format!(" {c}"))
|
||||
.unwrap_or_default()
|
||||
);
|
||||
|
||||
let fallback_text = fallback
|
||||
.clone()
|
||||
.or_else(|| {
|
||||
alt.clone().map(|text| {
|
||||
text.split_whitespace()
|
||||
.filter_map(|part| part.chars().next())
|
||||
.take(2)
|
||||
.collect::<String>()
|
||||
})
|
||||
})
|
||||
.unwrap_or_else(|| "??".to_string())
|
||||
.to_uppercase();
|
||||
|
||||
rsx! {
|
||||
div {
|
||||
class: class_name,
|
||||
if let Some(src) = src {
|
||||
img {
|
||||
src: src,
|
||||
alt: alt.clone().unwrap_or_default(),
|
||||
onerror: move |_| show_fallback.set(true),
|
||||
onload: move |_| show_fallback.set(false),
|
||||
}
|
||||
}
|
||||
if show_fallback() {
|
||||
div { class: "ui-avatar-fallback", "{fallback_text}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
121
src/components/ui/dropdown_menu.rs
Normal file
121
src/components/ui/dropdown_menu.rs
Normal file
@@ -0,0 +1,121 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub enum DropdownItemVariant {
|
||||
Default,
|
||||
Destructive,
|
||||
}
|
||||
|
||||
impl DropdownItemVariant {
|
||||
fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
DropdownItemVariant::Default => "default",
|
||||
DropdownItemVariant::Destructive => "destructive",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for DropdownItemVariant {
|
||||
fn default() -> Self {
|
||||
DropdownItemVariant::Default
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub struct DropdownMenuItem {
|
||||
pub label: String,
|
||||
pub value: String,
|
||||
pub shortcut: Option<String>,
|
||||
pub variant: DropdownItemVariant,
|
||||
}
|
||||
|
||||
impl DropdownMenuItem {
|
||||
pub fn new(label: impl Into<String>, value: impl Into<String>) -> Self {
|
||||
Self {
|
||||
label: label.into(),
|
||||
value: value.into(),
|
||||
shortcut: None,
|
||||
variant: DropdownItemVariant::Default,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_shortcut(mut self, shortcut: impl Into<String>) -> Self {
|
||||
self.shortcut = Some(shortcut.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn destructive(mut self) -> Self {
|
||||
self.variant = DropdownItemVariant::Destructive;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn DropdownMenu(
|
||||
#[props(into)] label: String,
|
||||
#[props(into)] items: Vec<DropdownMenuItem>,
|
||||
#[props(optional)] on_select: Option<EventHandler<String>>,
|
||||
) -> Element {
|
||||
let open = use_signal(|| false);
|
||||
let on_select_handler = on_select.clone();
|
||||
|
||||
rsx! {
|
||||
div {
|
||||
class: "ui-dropdown",
|
||||
button {
|
||||
class: "ui-dropdown-trigger",
|
||||
"data-open": if open() { "true" } else { "false" },
|
||||
onclick: {
|
||||
let mut signal = open.clone();
|
||||
move |_| {
|
||||
let new_state = !signal();
|
||||
signal.set(new_state);
|
||||
}
|
||||
},
|
||||
span { "{label}" }
|
||||
span {
|
||||
style: "font-size: 0.85rem; opacity: 0.7;",
|
||||
"⋮"
|
||||
}
|
||||
}
|
||||
if open() {
|
||||
div {
|
||||
class: "ui-dropdown-content",
|
||||
div {
|
||||
class: "ui-dropdown-list",
|
||||
for item in items.iter().cloned() {
|
||||
{
|
||||
let value = item.value.clone();
|
||||
let shortcut = item.shortcut.clone();
|
||||
let variant = item.variant.as_str().to_string();
|
||||
let mut open_signal = open.clone();
|
||||
let handler = on_select_handler.clone();
|
||||
|
||||
rsx! {
|
||||
button {
|
||||
class: "ui-dropdown-item",
|
||||
"data-variant": variant,
|
||||
onclick: {
|
||||
let value = value.clone();
|
||||
let handler = handler.clone();
|
||||
move |_| {
|
||||
if let Some(callback) = handler.clone() {
|
||||
callback.call(value.clone());
|
||||
}
|
||||
open_signal.set(false);
|
||||
}
|
||||
},
|
||||
span { "{item.label}" }
|
||||
if let Some(shortcut) = shortcut.clone() {
|
||||
span { style: "font-size: 0.75rem; opacity: 0.6;", "{shortcut}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,30 +2,42 @@
|
||||
//! Each component mirrors the styling and API conventions of the upstream React components while
|
||||
//! remaining idiomatic to Rust and Dioxus.
|
||||
|
||||
mod accordion;
|
||||
mod alert;
|
||||
mod avatar;
|
||||
mod badge;
|
||||
mod button;
|
||||
mod card;
|
||||
mod checkbox;
|
||||
mod dropdown_menu;
|
||||
mod input;
|
||||
mod label;
|
||||
mod progress;
|
||||
mod radio_group;
|
||||
mod select;
|
||||
mod separator;
|
||||
mod slider;
|
||||
mod switch;
|
||||
mod tabs;
|
||||
mod textarea;
|
||||
mod tooltip;
|
||||
|
||||
pub use accordion::*;
|
||||
pub use alert::*;
|
||||
pub use avatar::*;
|
||||
pub use badge::*;
|
||||
pub use button::*;
|
||||
pub use card::*;
|
||||
pub use checkbox::*;
|
||||
pub use dropdown_menu::*;
|
||||
pub use input::*;
|
||||
pub use label::*;
|
||||
pub use progress::*;
|
||||
pub use radio_group::*;
|
||||
pub use select::*;
|
||||
pub use separator::*;
|
||||
pub use slider::*;
|
||||
pub use switch::*;
|
||||
pub use tabs::*;
|
||||
pub use textarea::*;
|
||||
pub use tooltip::*;
|
||||
|
||||
120
src/components/ui/select.rs
Normal file
120
src/components/ui/select.rs
Normal file
@@ -0,0 +1,120 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub struct SelectOption {
|
||||
pub label: String,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
impl SelectOption {
|
||||
pub fn new(label: impl Into<String>, value: impl Into<String>) -> Self {
|
||||
Self {
|
||||
label: label.into(),
|
||||
value: value.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn Select(
|
||||
#[props(into, default)] id: Option<String>,
|
||||
#[props(into)] placeholder: String,
|
||||
#[props(into)] options: Vec<SelectOption>,
|
||||
#[props(into, default)] selected: Option<String>,
|
||||
#[props(default)] disabled: bool,
|
||||
#[props(optional)] on_change: Option<EventHandler<String>>,
|
||||
) -> Element {
|
||||
let open = use_signal(|| false);
|
||||
let current = use_signal(move || selected.clone());
|
||||
let on_change_handler = on_change.clone();
|
||||
let trigger_id = id.unwrap_or_default();
|
||||
|
||||
let selected_value = current();
|
||||
let display_text = selected_value
|
||||
.as_ref()
|
||||
.and_then(|value| {
|
||||
options
|
||||
.iter()
|
||||
.find(|option| option.value == *value)
|
||||
.map(|option| option.label.clone())
|
||||
})
|
||||
.unwrap_or_else(|| placeholder.clone());
|
||||
|
||||
rsx! {
|
||||
div {
|
||||
class: "ui-select",
|
||||
"data-disabled": disabled,
|
||||
onfocusout: {
|
||||
let mut signal = open.clone();
|
||||
move |_| signal.set(false)
|
||||
},
|
||||
button {
|
||||
class: "ui-select-trigger",
|
||||
"data-open": if open() { "true" } else { "false" },
|
||||
disabled,
|
||||
id: trigger_id.clone(),
|
||||
"aria-haspopup": "listbox",
|
||||
"aria-expanded": if open() { "true" } else { "false" },
|
||||
onclick: {
|
||||
let mut open_signal = open.clone();
|
||||
move |_| {
|
||||
if !disabled {
|
||||
let new_state = !open_signal();
|
||||
open_signal.set(new_state);
|
||||
}
|
||||
}
|
||||
},
|
||||
span { "{display_text}" }
|
||||
span {
|
||||
style: "font-size: 0.8rem; opacity: 0.7;",
|
||||
if open() { "▲" } else { "▼" }
|
||||
}
|
||||
}
|
||||
if open() {
|
||||
div {
|
||||
class: "ui-select-content",
|
||||
div {
|
||||
class: "ui-select-list",
|
||||
for option in options.iter().cloned() {
|
||||
{
|
||||
let is_active = selected_value
|
||||
.as_ref()
|
||||
.map(|value| value == &option.value)
|
||||
.unwrap_or(false);
|
||||
let value = option.value.clone();
|
||||
let handler = on_change_handler.clone();
|
||||
let mut open_signal = open.clone();
|
||||
let mut current_signal = current.clone();
|
||||
|
||||
rsx! {
|
||||
button {
|
||||
class: "ui-select-item",
|
||||
"data-state": if is_active { "active" } else { "inactive" },
|
||||
onclick: {
|
||||
let value = value.clone();
|
||||
let handler = handler.clone();
|
||||
move |_| {
|
||||
current_signal.set(Some(value.clone()));
|
||||
if let Some(callback) = handler.clone() {
|
||||
callback.call(value.clone());
|
||||
}
|
||||
open_signal.set(false);
|
||||
}
|
||||
},
|
||||
span { "{option.label}" }
|
||||
if is_active {
|
||||
span {
|
||||
style: "font-size: 0.75rem; opacity: 0.7;",
|
||||
"✓"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
29
src/components/ui/tooltip.rs
Normal file
29
src/components/ui/tooltip.rs
Normal file
@@ -0,0 +1,29 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
#[component]
|
||||
pub fn Tooltip(
|
||||
#[props(into)] label: String,
|
||||
#[props(default = 0)]
|
||||
#[allow(unused)]
|
||||
delay_ms: u64,
|
||||
children: Element,
|
||||
) -> Element {
|
||||
let mut visible = use_signal(|| false);
|
||||
|
||||
rsx! {
|
||||
span {
|
||||
class: "ui-tooltip-wrapper",
|
||||
tabindex: 0,
|
||||
onmouseenter: move |_| visible.set(true),
|
||||
onmouseleave: move |_| visible.set(false),
|
||||
onfocusin: move |_| visible.set(true),
|
||||
onfocusout: move |_| visible.set(false),
|
||||
{children}
|
||||
span {
|
||||
class: "ui-tooltip-bubble",
|
||||
"data-state": if visible() { "visible" } else { "hidden" },
|
||||
"{label}",
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
use crate::components::{
|
||||
ui::{
|
||||
Accordion, AccordionContent, AccordionItem, AccordionTrigger, Alert, AlertVariant, Avatar,
|
||||
Badge, BadgeVariant, Button, ButtonSize, ButtonVariant, Card, CardContent, CardDescription,
|
||||
CardFooter, CardHeader, CardTitle, Checkbox, Input, Label, Progress, RadioGroup,
|
||||
RadioGroupItem, Separator, SeparatorOrientation, Slider, Switch, Tabs, TabsContent,
|
||||
TabsList, TabsTrigger, Textarea,
|
||||
CardFooter, CardHeader, CardTitle, Checkbox, DropdownMenu, DropdownMenuItem, Input, Label,
|
||||
Progress, RadioGroup, RadioGroupItem, Select, SelectOption, Separator,
|
||||
SeparatorOrientation, Slider, Switch, Tabs, TabsContent, TabsList, TabsTrigger, Textarea,
|
||||
Tooltip,
|
||||
},
|
||||
Echo, Hero,
|
||||
};
|
||||
@@ -21,16 +23,51 @@ pub fn Home() -> Element {
|
||||
|
||||
#[component]
|
||||
fn UiShowcase() -> Element {
|
||||
let mut accepted_terms = use_signal(|| false);
|
||||
let mut email_notifications = use_signal(|| true);
|
||||
let mut slider_value = use_signal(|| 42.0f32);
|
||||
let mut contact_method = use_signal(|| "email".to_string());
|
||||
let mut newsletter_opt_in = use_signal(|| true);
|
||||
let mut dark_mode = use_signal(|| false);
|
||||
let accepted_terms = use_signal(|| false);
|
||||
let email_notifications = use_signal(|| true);
|
||||
let slider_value = use_signal(|| 42.0f32);
|
||||
let contact_method = use_signal(|| "email".to_string());
|
||||
let newsletter_opt_in = use_signal(|| true);
|
||||
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 slider_value_signal = slider_value.clone();
|
||||
let slider_value_setter = slider_value.clone();
|
||||
let contact_method_signal = contact_method.clone();
|
||||
let theme_choice_signal = theme_choice.clone();
|
||||
let accepted_terms_setter = accepted_terms.clone();
|
||||
let email_notifications_setter = email_notifications.clone();
|
||||
let contact_method_setter = contact_method.clone();
|
||||
let newsletter_opt_in_setter = newsletter_opt_in.clone();
|
||||
let dark_mode_setter = dark_mode.clone();
|
||||
let theme_choice_setter = theme_choice.clone();
|
||||
let menu_selection_setter = menu_selection.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![
|
||||
SelectOption::new("System", "system"),
|
||||
SelectOption::new("Light", "light"),
|
||||
SelectOption::new("Dark", "dark"),
|
||||
];
|
||||
let menu_items = vec![
|
||||
DropdownMenuItem::new("Profile", "profile").with_shortcut("⌘P"),
|
||||
DropdownMenuItem::new("Billing", "billing").with_shortcut("⌘B"),
|
||||
DropdownMenuItem::new("Team", "team"),
|
||||
DropdownMenuItem::new("Sign out", "logout").destructive(),
|
||||
];
|
||||
let theme_display = {
|
||||
let current = theme_choice();
|
||||
current
|
||||
.as_ref()
|
||||
.and_then(|value| {
|
||||
select_options
|
||||
.iter()
|
||||
.find(|option| option.value == *value)
|
||||
.map(|option| option.label.clone())
|
||||
})
|
||||
.unwrap_or_else(|| "System".to_string())
|
||||
};
|
||||
let theme_summary = format!("Active theme: {theme_display}");
|
||||
|
||||
rsx! {
|
||||
section {
|
||||
@@ -74,29 +111,46 @@ fn UiShowcase() -> Element {
|
||||
min: 0.0,
|
||||
max: 100.0,
|
||||
step: 1.0,
|
||||
on_value_change: move |val| slider_value.set(val),
|
||||
on_value_change: {
|
||||
let mut signal = slider_value_setter.clone();
|
||||
move |val| signal.set(val)
|
||||
},
|
||||
}
|
||||
Progress { value: slider_value(), max: 100.0 }
|
||||
SpanHelper { "{intensity_text()}" }
|
||||
}
|
||||
div { class: "ui-bleed",
|
||||
div { class: "ui-cluster",
|
||||
Checkbox {
|
||||
id: Some("accept-terms".to_string()),
|
||||
checked: accepted_terms(),
|
||||
on_checked_change: move |state| accepted_terms.set(state),
|
||||
}
|
||||
Label { html_for: "accept-terms", "Agree to terms" }
|
||||
}
|
||||
div { class: "ui-cluster",
|
||||
Label { html_for: "profile-emails", "Email notifications" }
|
||||
Switch {
|
||||
id: Some("profile-emails".to_string()),
|
||||
checked: email_notifications(),
|
||||
on_checked_change: move |state| email_notifications.set(state),
|
||||
}
|
||||
div { class: "ui-stack",
|
||||
Label { html_for: "theme-select", "Theme preference" }
|
||||
Select {
|
||||
id: Some("theme-select".to_string()),
|
||||
placeholder: "Select a theme",
|
||||
options: select_options.clone(),
|
||||
selected: theme_choice_signal(),
|
||||
on_change: move |value| {
|
||||
let mut signal = theme_choice_setter.clone();
|
||||
signal.set(Some(value));
|
||||
},
|
||||
}
|
||||
SpanHelper { "{theme_summary}" }
|
||||
}
|
||||
div { class: "ui-bleed",
|
||||
div { class: "ui-cluster",
|
||||
Checkbox {
|
||||
id: Some("accept-terms".to_string()),
|
||||
checked: accepted_terms(),
|
||||
on_checked_change: move |state| accepted_terms_setter.clone().set(state),
|
||||
}
|
||||
Label { html_for: "accept-terms", "Agree to terms" }
|
||||
}
|
||||
div { class: "ui-cluster",
|
||||
Label { html_for: "profile-emails", "Email notifications" }
|
||||
Switch {
|
||||
id: Some("profile-emails".to_string()),
|
||||
checked: email_notifications(),
|
||||
on_checked_change: move |state| email_notifications_setter.clone().set(state),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
CardFooter {
|
||||
div { class: "ui-cluster",
|
||||
@@ -146,6 +200,51 @@ fn UiShowcase() -> Element {
|
||||
}
|
||||
}
|
||||
|
||||
Card {
|
||||
CardHeader {
|
||||
CardTitle { "Select & menus" }
|
||||
CardDescription { "Select, dropdown menu, tooltip and dynamic feedback." }
|
||||
}
|
||||
CardContent {
|
||||
div { class: "ui-stack",
|
||||
Label { html_for: "quick-theme", "Quick theme" }
|
||||
Select {
|
||||
id: Some("quick-theme".to_string()),
|
||||
placeholder: "Choose theme",
|
||||
options: select_options.clone(),
|
||||
selected: theme_choice_signal(),
|
||||
on_change: move |value| {
|
||||
let mut signal = theme_choice_setter.clone();
|
||||
signal.set(Some(value));
|
||||
},
|
||||
}
|
||||
}
|
||||
div { class: "ui-stack",
|
||||
SpanHelper { "Dropdown menu" }
|
||||
DropdownMenu {
|
||||
label: "Open menu",
|
||||
items: menu_items.clone(),
|
||||
on_select: move |value| {
|
||||
let mut signal = menu_selection_setter.clone();
|
||||
signal.set(format!("Selected action: {value}"));
|
||||
},
|
||||
}
|
||||
SpanHelper { "{menu_selection()}" }
|
||||
}
|
||||
div { class: "ui-stack",
|
||||
SpanHelper { "Tooltip" }
|
||||
Tooltip {
|
||||
label: "Invite collaborators",
|
||||
Button {
|
||||
variant: ButtonVariant::Ghost,
|
||||
size: ButtonSize::Sm,
|
||||
"Hover me"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Card {
|
||||
CardHeader {
|
||||
CardTitle { "Selection controls" }
|
||||
@@ -157,7 +256,7 @@ fn UiShowcase() -> Element {
|
||||
Checkbox {
|
||||
id: Some("newsletter-opt".to_string()),
|
||||
checked: newsletter_opt_in(),
|
||||
on_checked_change: move |state| newsletter_opt_in.set(state),
|
||||
on_checked_change: move |state| newsletter_opt_in_setter.clone().set(state),
|
||||
}
|
||||
Label { html_for: "newsletter-opt", "Subscribe to newsletter" }
|
||||
}
|
||||
@@ -166,13 +265,13 @@ fn UiShowcase() -> Element {
|
||||
Switch {
|
||||
id: Some("dark-mode".to_string()),
|
||||
checked: dark_mode(),
|
||||
on_checked_change: move |state| dark_mode.set(state),
|
||||
on_checked_change: move |state| dark_mode_setter.clone().set(state),
|
||||
}
|
||||
}
|
||||
Separator { style: "margin: 0.75rem 0;" }
|
||||
RadioGroup {
|
||||
default_value: contact_method(),
|
||||
on_value_change: move |value| contact_method.set(value),
|
||||
on_value_change: move |value| contact_method_setter.clone().set(value),
|
||||
div { class: "ui-stack",
|
||||
div { class: "ui-cluster",
|
||||
RadioGroupItem { id: Some("contact-email".to_string()), value: "email" }
|
||||
@@ -231,6 +330,59 @@ fn UiShowcase() -> Element {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Card {
|
||||
CardHeader {
|
||||
CardTitle { "Alerts & extras" }
|
||||
CardDescription { "Feedback surfaces, accordions, and avatar fallbacks." }
|
||||
}
|
||||
CardContent {
|
||||
div { class: "ui-stack",
|
||||
Alert {
|
||||
title: Some("Heads up!".to_string()),
|
||||
"We just shipped async server functions to production."
|
||||
}
|
||||
Alert {
|
||||
variant: AlertVariant::Destructive,
|
||||
title: Some("Deployment failed".to_string()),
|
||||
"Check the build logs and retry once the issue is resolved."
|
||||
}
|
||||
}
|
||||
Separator { style: "margin: 1rem 0;" }
|
||||
Accordion {
|
||||
collapsible: true,
|
||||
default_value: Some("item-1".to_string()),
|
||||
AccordionItem {
|
||||
value: "item-1".to_string(),
|
||||
AccordionTrigger { "What is shadcn/ui?" }
|
||||
AccordionContent {
|
||||
"A collection of unstyled, accessible primitives built on top of Radix, ready for your design system."
|
||||
}
|
||||
}
|
||||
AccordionItem {
|
||||
value: "item-2".to_string(),
|
||||
AccordionTrigger { "Does this work with Dioxus?" }
|
||||
AccordionContent {
|
||||
"Yes! These components mirror the shadcn/ui ergonomics using Dioxus 0.7 signals."
|
||||
}
|
||||
}
|
||||
}
|
||||
Separator { style: "margin: 1rem 0;" }
|
||||
div { class: "ui-cluster",
|
||||
Tooltip {
|
||||
label: "Ada Lovelace",
|
||||
Avatar {
|
||||
alt: Some("Ada Lovelace".to_string()),
|
||||
fallback: Some("AL".to_string()),
|
||||
}
|
||||
}
|
||||
Avatar {
|
||||
alt: Some("Grace Hopper".to_string()),
|
||||
fallback: Some("GH".to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user