From 77cd16a792dd6a7a75b9f8fb8d0117577cbef11c Mon Sep 17 00:00:00 2001 From: tommy Date: Mon, 3 Nov 2025 11:38:04 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/styling/shadcn.css | 222 +++++++++++++++++++++++++++++ src/components/ui/accordion.rs | 132 +++++++++++++++++ src/components/ui/alert.rs | 44 ++++++ src/components/ui/avatar.rs | 51 +++++++ src/components/ui/dropdown_menu.rs | 121 ++++++++++++++++ src/components/ui/mod.rs | 12 ++ src/components/ui/select.rs | 120 ++++++++++++++++ src/components/ui/tooltip.rs | 29 ++++ src/views/home.rs | 210 +++++++++++++++++++++++---- 9 files changed, 912 insertions(+), 29 deletions(-) create mode 100644 src/components/ui/accordion.rs create mode 100644 src/components/ui/alert.rs create mode 100644 src/components/ui/avatar.rs create mode 100644 src/components/ui/dropdown_menu.rs create mode 100644 src/components/ui/select.rs create mode 100644 src/components/ui/tooltip.rs diff --git a/assets/styling/shadcn.css b/assets/styling/shadcn.css index a9d4a52..198fe3b 100644 --- a/assets/styling/shadcn.css +++ b/assets/styling/shadcn.css @@ -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; diff --git a/src/components/ui/accordion.rs b/src/components/ui/accordion.rs new file mode 100644 index 0000000..500254c --- /dev/null +++ b/src/components/ui/accordion.rs @@ -0,0 +1,132 @@ +use dioxus::prelude::*; + +#[derive(Clone)] +struct AccordionContext { + open_value: Signal>, + 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, + #[props(into, default)] class: Option, + 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, + children: Element, +) -> Element { + let root = use_context::(); + 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::(); + 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::(); + 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} + } + } + } +} diff --git a/src/components/ui/alert.rs b/src/components/ui/alert.rs new file mode 100644 index 0000000..aa60eef --- /dev/null +++ b/src/components/ui/alert.rs @@ -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, + 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} + } + } + } +} diff --git a/src/components/ui/avatar.rs b/src/components/ui/avatar.rs new file mode 100644 index 0000000..755d58d --- /dev/null +++ b/src/components/ui/avatar.rs @@ -0,0 +1,51 @@ +use dioxus::prelude::*; + +#[component] +pub fn Avatar( + #[props(into, default)] src: Option, + #[props(into, default)] alt: Option, + #[props(into, default)] fallback: Option, + #[props(into, default)] class: Option, +) -> 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::() + }) + }) + .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}" } + } + } + } +} diff --git a/src/components/ui/dropdown_menu.rs b/src/components/ui/dropdown_menu.rs new file mode 100644 index 0000000..e60647b --- /dev/null +++ b/src/components/ui/dropdown_menu.rs @@ -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, + pub variant: DropdownItemVariant, +} + +impl DropdownMenuItem { + pub fn new(label: impl Into, value: impl Into) -> Self { + Self { + label: label.into(), + value: value.into(), + shortcut: None, + variant: DropdownItemVariant::Default, + } + } + + pub fn with_shortcut(mut self, shortcut: impl Into) -> 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, + #[props(optional)] on_select: Option>, +) -> 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}" } + } + } + } + } + } + } + } + } + } + } +} diff --git a/src/components/ui/mod.rs b/src/components/ui/mod.rs index 83b003e..cf791a3 100644 --- a/src/components/ui/mod.rs +++ b/src/components/ui/mod.rs @@ -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::*; diff --git a/src/components/ui/select.rs b/src/components/ui/select.rs new file mode 100644 index 0000000..54a5a5b --- /dev/null +++ b/src/components/ui/select.rs @@ -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, value: impl Into) -> Self { + Self { + label: label.into(), + value: value.into(), + } + } +} + +#[component] +pub fn Select( + #[props(into, default)] id: Option, + #[props(into)] placeholder: String, + #[props(into)] options: Vec, + #[props(into, default)] selected: Option, + #[props(default)] disabled: bool, + #[props(optional)] on_change: Option>, +) -> 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;", + "✓" + } + } + } + } + } + } + } + } + } + } + } +} diff --git a/src/components/ui/tooltip.rs b/src/components/ui/tooltip.rs new file mode 100644 index 0000000..36bf81b --- /dev/null +++ b/src/components/ui/tooltip.rs @@ -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}", + } + } + } +} diff --git a/src/views/home.rs b/src/views/home.rs index 30ff06b..3004af2 100644 --- a/src/views/home.rs +++ b/src/views/home.rs @@ -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()), + } + } + } + } } } }