一些shadcn的基础组件

This commit is contained in:
tommy
2025-11-03 11:14:07 +08:00
commit 2e10decc71
35 changed files with 8872 additions and 0 deletions

61
src/components/echo.rs Normal file
View File

@@ -0,0 +1,61 @@
use dioxus::prelude::*;
const ECHO_CSS: Asset = asset!("/assets/styling/echo.css");
/// Echo component that demonstrates fullstack server functions.
#[component]
pub fn Echo() -> Element {
// use_signal is a hook. Hooks in dioxus must be run in a consistent order every time the component is rendered.
// That means they can't be run inside other hooks, async blocks, if statements, or loops.
//
// use_signal is a hook that creates a state for the component. It takes a closure that returns the initial value of the state.
// The state is automatically tracked and will rerun any other hooks or components that read it whenever it changes.
let mut response = use_signal(|| String::new());
rsx! {
document::Link { rel: "stylesheet", href: ECHO_CSS }
div {
id: "echo",
h4 { "ServerFn Echo" }
input {
placeholder: "Type here to echo...",
// `oninput` is an event handler that will run when the input changes. It can return either nothing or a future
// that will be run when the event runs.
oninput: move |event| async move {
// When we call the echo_server function from the client, it will fire a request to the server and return
// the response. It handles serialization and deserialization of the request and response for us.
let data = echo_server(event.value()).await.unwrap();
// After we have the data from the server, we can set the state of the signal to the new value.
// Since we read the `response` signal later in this component, the component will rerun.
response.set(data);
},
}
// Signals can be called like a function to clone the current value of the signal
if !response().is_empty() {
p {
"Server echoed: "
// Since we read the signal inside this component, the component "subscribes" to the signal. Whenever
// the signal changes, the component will rerun.
i { "{response}" }
}
}
}
}
}
// Server functions let us define public APIs on the server that can be called like a normal async function from the client.
// Each server function needs to be annotated with the `#[post]`/`#[get]` attributes, accept and return serializable types, and return
// a `Result` with the error type [`ServerFnError`].
//
// When the server function is called from the client, it will just serialize the arguments, call the API, and deserialize the
// response.
#[post("/api/echo")]
async fn echo_server(input: String) -> Result<String> {
// The body of server function like this comment are only included on the server. If you have any server-only logic like
// database queries, you can put it here. Any imports for the server function should either be imported inside the function
// or imported under a `#[cfg(feature = "server")]` block.
Ok(input)
}

25
src/components/hero.rs Normal file
View File

@@ -0,0 +1,25 @@
use dioxus::prelude::*;
const HEADER_SVG: Asset = asset!("/assets/header.svg");
#[component]
pub fn Hero() -> Element {
rsx! {
// We can create elements inside the rsx macro with the element name followed by a block of attributes and children.
div {
// Attributes should be defined in the element before any children
id: "hero",
// After all attributes are defined, we can define child elements and components
img { src: HEADER_SVG, id: "header" }
div { id: "links",
// The RSX macro also supports text nodes surrounded by quotes
a { href: "https://dioxuslabs.com/learn/0.6/", "📚 Learn Dioxus" }
a { href: "https://dioxuslabs.com/awesome", "🚀 Awesome Dioxus" }
a { href: "https://github.com/dioxus-community/", "📡 Community Libraries" }
a { href: "https://github.com/DioxusLabs/sdk", "⚙️ Dioxus Development Kit" }
a { href: "https://marketplace.visualstudio.com/items?itemName=DioxusLabs.dioxus", "💫 VSCode Extension" }
a { href: "https://discord.gg/XgGxMSkvUM", "👋 Community Discord" }
}
}
}
}

11
src/components/mod.rs Normal file
View File

@@ -0,0 +1,11 @@
//! The components module contains all shared components for our app. Components are the building blocks of dioxus apps.
//! They can be used to defined common UI elements like buttons, forms, and modals. In this template, we define a Hero
//! component and an Echo component for fullstack apps to be used in our app.
mod hero;
pub use hero::Hero;
mod echo;
pub use echo::Echo;
pub mod ui;

View File

@@ -0,0 +1,47 @@
use dioxus::prelude::*;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum BadgeVariant {
Default,
Secondary,
Outline,
Destructive,
}
impl BadgeVariant {
fn as_str(&self) -> &'static str {
match self {
BadgeVariant::Default => "default",
BadgeVariant::Secondary => "secondary",
BadgeVariant::Outline => "outline",
BadgeVariant::Destructive => "destructive",
}
}
}
impl Default for BadgeVariant {
fn default() -> Self {
BadgeVariant::Default
}
}
#[component]
pub fn Badge(
#[props(default)] variant: BadgeVariant,
#[props(into, default)] class: Option<String>,
children: Element,
) -> Element {
let mut classes = String::from("ui-badge");
if let Some(extra) = class.filter(|extra| !extra.trim().is_empty()) {
classes.push(' ');
classes.push_str(extra.trim());
}
rsx! {
span {
class: classes,
"data-variant": variant.as_str(),
{children}
}
}
}

View File

@@ -0,0 +1,95 @@
use dioxus::prelude::*;
/// Visual style variants that match shadcn button presets.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ButtonVariant {
Default,
Secondary,
Destructive,
Outline,
Ghost,
Link,
}
impl ButtonVariant {
fn as_str(&self) -> &'static str {
match self {
ButtonVariant::Default => "default",
ButtonVariant::Secondary => "secondary",
ButtonVariant::Destructive => "destructive",
ButtonVariant::Outline => "outline",
ButtonVariant::Ghost => "ghost",
ButtonVariant::Link => "link",
}
}
}
impl Default for ButtonVariant {
fn default() -> Self {
ButtonVariant::Default
}
}
/// Sizing presets lifted from the shadcn button component.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ButtonSize {
Default,
Sm,
Lg,
Icon,
}
impl ButtonSize {
fn as_str(&self) -> &'static str {
match self {
ButtonSize::Default => "default",
ButtonSize::Sm => "sm",
ButtonSize::Lg => "lg",
ButtonSize::Icon => "icon",
}
}
}
impl Default for ButtonSize {
fn default() -> Self {
ButtonSize::Default
}
}
/// A faithful port of `Button` from shadcn/ui. Styling is provided by `shadcn.css`.
#[component]
pub fn Button(
#[props(default)] variant: ButtonVariant,
#[props(default)] size: ButtonSize,
#[props(into, default)] class: Option<String>,
#[props(default)] disabled: bool,
#[props(default = "button".to_string())]
#[props(into)]
r#type: String,
#[props(optional)] on_click: Option<EventHandler<MouseEvent>>,
children: Element,
) -> Element {
let mut classes = String::from("ui-button");
if let Some(extra) = class.filter(|extra| !extra.trim().is_empty()) {
classes.push(' ');
classes.push_str(extra.trim());
}
let click_handler = on_click.clone();
rsx! {
button {
class: classes,
disabled,
r#type: r#type,
"data-variant": variant.as_str(),
"data-size": size.as_str(),
onclick: move |event| {
if let Some(handler) = click_handler.clone() {
handler.call(event);
}
},
{children}
}
}
}

78
src/components/ui/card.rs Normal file
View File

@@ -0,0 +1,78 @@
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 Card(#[props(into, default)] class: Option<String>, children: Element) -> Element {
let classes = merge_class("ui-card", class);
rsx! {
div {
class: classes,
{children}
}
}
}
#[component]
pub fn CardHeader(#[props(into, default)] class: Option<String>, children: Element) -> Element {
let classes = merge_class("ui-card-header", class);
rsx! {
div {
class: classes,
{children}
}
}
}
#[component]
pub fn CardTitle(#[props(into, default)] class: Option<String>, children: Element) -> Element {
let classes = merge_class("ui-card-title", class);
rsx! {
h3 {
class: classes,
{children}
}
}
}
#[component]
pub fn CardDescription(
#[props(into, default)] class: Option<String>,
children: Element,
) -> Element {
let classes = merge_class("ui-card-description", class);
rsx! {
p {
class: classes,
{children}
}
}
}
#[component]
pub fn CardContent(#[props(into, default)] class: Option<String>, children: Element) -> Element {
let classes = merge_class("ui-card-content", class);
rsx! {
div {
class: classes,
{children}
}
}
}
#[component]
pub fn CardFooter(#[props(into, default)] class: Option<String>, children: Element) -> Element {
let classes = merge_class("ui-card-footer", class);
rsx! {
div {
class: classes,
{children}
}
}
}

View File

@@ -0,0 +1,58 @@
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 Checkbox(
#[props(default)] checked: bool,
#[props(default)] disabled: bool,
#[props(default)] required: bool,
#[props(into, default)] id: Option<String>,
#[props(into, default)] name: Option<String>,
#[props(into, default)] value: Option<String>,
#[props(into, default)] class: Option<String>,
#[props(optional)] on_checked_change: Option<EventHandler<bool>>,
#[props(optional)] on_input: Option<EventHandler<FormEvent>>,
#[props(optional)] on_change: Option<EventHandler<FormEvent>>,
) -> Element {
let classes = merge_class("ui-checkbox", class);
let checked_handler = on_checked_change.clone();
let input_handler = on_input.clone();
let change_handler = on_change.clone();
let id_attr = id.unwrap_or_default();
let name_attr = name.unwrap_or_default();
let value_attr = value.unwrap_or_else(|| "on".to_string());
rsx! {
input {
class: classes,
r#type: "checkbox",
role: "checkbox",
checked,
disabled,
required,
id: id_attr,
name: name_attr,
value: value_attr,
oninput: move |event| {
if let Some(handler) = checked_handler.clone() {
handler.call(event.checked());
}
if let Some(handler) = input_handler.clone() {
handler.call(event);
}
},
onchange: move |event| {
if let Some(handler) = change_handler.clone() {
handler.call(event);
}
},
}
}
}

View File

@@ -0,0 +1,62 @@
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 Input(
#[props(into, default)] class: Option<String>,
#[props(into, default)] value: Option<String>,
#[props(into, default)] default_value: Option<String>,
#[props(into, default)] placeholder: Option<String>,
#[props(into, default)] name: Option<String>,
#[props(into, default)] id: Option<String>,
#[props(into, default)] autocomplete: Option<String>,
#[props(into, default)] r#type: Option<String>,
#[props(default)] disabled: bool,
#[props(default)] readonly: bool,
#[props(default)] required: bool,
#[props(optional)] on_input: Option<EventHandler<FormEvent>>,
#[props(optional)] on_change: Option<EventHandler<FormEvent>>,
) -> Element {
let classes = merge_class("ui-input", class);
let input_handler = on_input.clone();
let change_handler = on_change.clone();
// Clone optional attributes so they can be moved into rsx
let resolved_value = value.or(default_value).unwrap_or_default();
let placeholder_attr = placeholder.unwrap_or_default();
let name_attr = name.unwrap_or_default();
let id_attr = id.unwrap_or_default();
let autocomplete_attr = autocomplete.unwrap_or_default();
rsx! {
input {
class: classes,
r#type: r#type.unwrap_or_else(|| "text".to_string()),
disabled,
readonly,
required,
id: id_attr,
name: name_attr,
value: resolved_value,
placeholder: placeholder_attr,
autocomplete: autocomplete_attr,
oninput: move |event| {
if let Some(handler) = input_handler.clone() {
handler.call(event);
}
},
onchange: move |event| {
if let Some(handler) = change_handler.clone() {
handler.call(event);
}
},
}
}
}

View File

@@ -0,0 +1,30 @@
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 Label(
#[props(into, default)] class: Option<String>,
#[props(into, default)] html_for: Option<String>,
#[props(default)] disabled: bool,
children: Element,
) -> Element {
let classes = merge_class("ui-label", class);
let html_for_attr = html_for.unwrap_or_default();
rsx! {
label {
class: classes,
"data-disabled": disabled,
r#for: html_for_attr,
{children}
}
}
}

31
src/components/ui/mod.rs Normal file
View File

@@ -0,0 +1,31 @@
//! Shadcn-inspired reusable primitives implemented with Dioxus 0.7 signals and the shared `shadcn.css`.
//! Each component mirrors the styling and API conventions of the upstream React components while
//! remaining idiomatic to Rust and Dioxus.
mod badge;
mod button;
mod card;
mod checkbox;
mod input;
mod label;
mod progress;
mod radio_group;
mod separator;
mod slider;
mod switch;
mod tabs;
mod textarea;
pub use badge::*;
pub use button::*;
pub use card::*;
pub use checkbox::*;
pub use input::*;
pub use label::*;
pub use progress::*;
pub use radio_group::*;
pub use separator::*;
pub use slider::*;
pub use switch::*;
pub use tabs::*;
pub use textarea::*;

View File

@@ -0,0 +1,37 @@
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 Progress(
#[props(default = 0.0f32)] value: f32,
#[props(default = 100.0f32)] max: f32,
#[props(into, default)] class: Option<String>,
) -> Element {
let classes = merge_class("ui-progress", class);
let percent = if max <= 0.0f32 {
0.0
} else {
(value / max).clamp(0.0, 1.0) * 100.0
};
let indicator_style = format!("width: {percent:.2}%;");
rsx! {
div {
class: classes,
role: "progressbar",
"aria-valuemin": 0,
"aria-valuemax": max,
"aria-valuenow": value,
span {
style: indicator_style,
}
}
}
}

View File

@@ -0,0 +1,105 @@
use std::sync::atomic::{AtomicUsize, Ordering};
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()
}
}
static RADIO_GROUP_IDS: AtomicUsize = AtomicUsize::new(0);
fn next_radio_group_name() -> String {
let id = RADIO_GROUP_IDS.fetch_add(1, Ordering::Relaxed);
format!("radio-group-{id}")
}
#[derive(Clone)]
struct RadioGroupContext {
name: Signal<String>,
value: Signal<Option<String>>,
disabled: bool,
on_change: Option<EventHandler<String>>,
}
#[component]
pub fn RadioGroup(
#[props(into, default)] class: Option<String>,
#[props(into, default)] name: Option<String>,
#[props(into, default)] default_value: Option<String>,
#[props(default)] disabled: bool,
#[props(optional)] on_value_change: Option<EventHandler<String>>,
children: Element,
) -> Element {
let provided_name = name.clone();
let group_name = use_signal(move || {
provided_name
.clone()
.unwrap_or_else(|| next_radio_group_name())
});
let initial_value = default_value.clone();
let selected = use_signal(move || initial_value.clone());
let context = RadioGroupContext {
name: group_name.clone(),
value: selected.clone(),
disabled,
on_change: on_value_change.clone(),
};
use_context_provider(|| context);
let classes = merge_class("ui-radio-group", class);
rsx! {
div {
class: classes,
role: "radiogroup",
"aria-disabled": disabled,
{children}
}
}
}
#[component]
pub fn RadioGroupItem(
#[props(into)] value: String,
#[props(default)] disabled: bool,
#[props(into, default)] id: Option<String>,
#[props(into, default)] class: Option<String>,
) -> Element {
let context = use_context::<RadioGroupContext>();
let classes = merge_class("ui-radio", class);
let id_attr = id.unwrap_or_default();
let is_disabled = disabled || context.disabled;
let mut group_value_signal = context.value.clone();
let current_value = group_value_signal();
let is_selected = current_value.as_ref() == Some(&value);
let group_name_signal = context.name.clone();
let group_name = group_name_signal();
let value_attr = value.clone();
let value_for_handler = value.clone();
let on_change = context.on_change.clone();
rsx! {
input {
class: classes,
r#type: "radio",
role: "radio",
name: "{group_name}",
value: "{value_attr}",
checked: is_selected,
disabled: is_disabled,
id: format_args!("{}", id_attr),
onchange: move |_| {
group_value_signal.set(Some(value_for_handler.clone()));
if let Some(handler) = on_change.clone() {
handler.call(value_for_handler.clone());
}
},
}
}
}

View File

@@ -0,0 +1,50 @@
use dioxus::prelude::*;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SeparatorOrientation {
Horizontal,
Vertical,
}
impl SeparatorOrientation {
fn as_str(&self) -> &'static str {
match self {
SeparatorOrientation::Horizontal => "horizontal",
SeparatorOrientation::Vertical => "vertical",
}
}
}
impl Default for SeparatorOrientation {
fn default() -> Self {
SeparatorOrientation::Horizontal
}
}
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 Separator(
#[props(default)] orientation: SeparatorOrientation,
#[props(into, default)] class: Option<String>,
#[props(into, default)] style: Option<String>,
) -> Element {
let classes = merge_class("ui-separator", class);
let style_attr = style.unwrap_or_default();
rsx! {
div {
class: classes,
role: "separator",
"data-orientation": orientation.as_str(),
"aria-orientation": orientation.as_str(),
style: style_attr,
}
}
}

View File

@@ -0,0 +1,63 @@
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 Slider(
#[props(default = 0.0f32)] value: f32,
#[props(default = 0.0f32)] min: f32,
#[props(default = 100.0f32)] max: f32,
#[props(default = 1.0f32)] step: f32,
#[props(default)] disabled: bool,
#[props(into, default)] class: Option<String>,
#[props(optional)] on_value_change: Option<EventHandler<f32>>,
#[props(optional)] on_input: Option<EventHandler<FormEvent>>,
#[props(optional)] on_change: Option<EventHandler<FormEvent>>,
) -> Element {
let classes = merge_class("ui-slider", class);
let percent = if (max - min).abs() <= f32::EPSILON {
0.0
} else {
((value - min) / (max - min)).clamp(0.0, 1.0) * 100.0
};
let style = format!("--fill: {percent:.2}%;");
let value_change = on_value_change.clone();
let input_handler = on_input.clone();
let change_handler = on_change.clone();
rsx! {
div {
class: classes,
input {
r#type: "range",
min: min,
max: max,
step: step,
value: value,
disabled,
style: style,
oninput: move |event| {
if let Some(handler) = input_handler.clone() {
handler.call(event.clone());
}
if let Some(handler) = value_change.clone() {
if let Ok(parsed) = event.value().parse::<f32>() {
handler.call(parsed);
}
}
},
onchange: move |event| {
if let Some(handler) = change_handler.clone() {
handler.call(event);
}
},
}
}
}
}

View File

@@ -0,0 +1,54 @@
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 Switch(
#[props(default)] checked: bool,
#[props(default)] disabled: bool,
#[props(into, default)] id: Option<String>,
#[props(into, default)] name: Option<String>,
#[props(into, default)] class: Option<String>,
#[props(optional)] on_checked_change: Option<EventHandler<bool>>,
#[props(optional)] on_input: Option<EventHandler<FormEvent>>,
#[props(optional)] on_change: Option<EventHandler<FormEvent>>,
) -> Element {
let classes = merge_class("ui-switch", class);
let checked_handler = on_checked_change.clone();
let input_handler = on_input.clone();
let change_handler = on_change.clone();
let id_attr = id.unwrap_or_default();
let name_attr = name.unwrap_or_default();
rsx! {
input {
class: classes,
r#type: "checkbox",
role: "switch",
checked,
disabled,
"aria-checked": checked,
id: id_attr,
name: name_attr,
oninput: move |event| {
if let Some(handler) = checked_handler.clone() {
handler.call(event.checked());
}
if let Some(handler) = input_handler.clone() {
handler.call(event);
}
},
onchange: move |event| {
if let Some(handler) = change_handler.clone() {
handler.call(event);
}
},
}
}
}

137
src/components/ui/tabs.rs Normal file
View File

@@ -0,0 +1,137 @@
use dioxus::prelude::*;
#[allow(dead_code)]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TabsOrientation {
Horizontal,
Vertical,
}
impl TabsOrientation {
fn as_str(&self) -> &'static str {
match self {
TabsOrientation::Horizontal => "horizontal",
TabsOrientation::Vertical => "vertical",
}
}
}
impl Default for TabsOrientation {
fn default() -> Self {
TabsOrientation::Horizontal
}
}
#[derive(Clone)]
struct TabsContext {
value: Signal<String>,
on_change: Option<EventHandler<String>>,
}
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 Tabs(
#[props(into)] default_value: String,
#[props(default)] orientation: TabsOrientation,
#[props(into, default)] class: Option<String>,
#[props(optional)] on_value_change: Option<EventHandler<String>>,
children: Element,
) -> Element {
let initial_value = default_value.clone();
let selected = use_signal(move || initial_value.clone());
let context = TabsContext {
value: selected.clone(),
on_change: on_value_change.clone(),
};
use_context_provider(|| context);
let classes = merge_class("ui-tabs", class);
rsx! {
div {
class: classes,
"data-orientation": orientation.as_str(),
{children}
}
}
}
#[component]
pub fn TabsList(#[props(into, default)] class: Option<String>, children: Element) -> Element {
let classes = merge_class("ui-tabs-nav", class);
rsx! {
div {
class: classes,
role: "tablist",
{children}
}
}
}
#[component]
pub fn TabsTrigger(
#[props(into)] value: String,
#[props(into, default)] class: Option<String>,
#[props(default)] disabled: bool,
children: Element,
) -> Element {
let context = use_context::<TabsContext>();
let classes = merge_class("ui-tabs-trigger", class);
let mut selected_signal = context.value.clone();
let is_active = selected_signal() == value;
let trigger_value = value.clone();
let trigger_attr_value = trigger_value.clone();
let on_change = context.on_change.clone();
rsx! {
button {
class: classes,
role: "tab",
"data-state": if is_active { "active" } else { "inactive" },
"aria-selected": is_active,
"aria-controls": format!("tab-panel-{}", trigger_value),
value: trigger_attr_value,
disabled,
onclick: move |_event| {
selected_signal.set(trigger_value.clone());
if let Some(handler) = on_change.clone() {
handler.call(trigger_value.clone());
}
},
{children}
}
}
}
#[component]
pub fn TabsContent(
#[props(into)] value: String,
#[props(into, default)] class: Option<String>,
children: Element,
) -> Element {
let context = use_context::<TabsContext>();
let classes = merge_class("ui-tabs-content", class);
let selected_signal = context.value.clone();
let is_active = selected_signal() == value;
let panel_id = format!("tab-panel-{value}");
rsx! {
div {
class: classes,
role: "tabpanel",
id: panel_id,
hidden: !is_active,
{children}
}
}
}

View File

@@ -0,0 +1,57 @@
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 Textarea(
#[props(into, default)] class: Option<String>,
#[props(into, default)] value: Option<String>,
#[props(into, default)] placeholder: Option<String>,
#[props(into, default)] name: Option<String>,
#[props(into, default)] id: Option<String>,
#[props(into, default)] rows: Option<u16>,
#[props(default)] disabled: bool,
#[props(default)] readonly: bool,
#[props(default)] required: bool,
#[props(optional)] on_input: Option<EventHandler<FormEvent>>,
#[props(optional)] on_change: Option<EventHandler<FormEvent>>,
) -> Element {
let classes = merge_class("ui-textarea", class);
let input_handler = on_input.clone();
let change_handler = on_change.clone();
let resolved_value = value.unwrap_or_default();
let placeholder_attr = placeholder.unwrap_or_default();
let name_attr = name.unwrap_or_default();
let id_attr = id.unwrap_or_default();
let rows_attr = rows.unwrap_or(5);
rsx! {
textarea {
class: classes,
disabled,
readonly,
required,
rows: rows_attr,
id: id_attr,
name: name_attr,
value: resolved_value,
placeholder: placeholder_attr,
oninput: move |event| {
if let Some(handler) = input_handler.clone() {
handler.call(event);
}
},
onchange: move |event| {
if let Some(handler) = change_handler.clone() {
handler.call(event);
}
},
}
}
}

67
src/main.rs Normal file
View File

@@ -0,0 +1,67 @@
// The dioxus prelude contains a ton of common items used in dioxus apps. It's a good idea to import wherever you
// need dioxus
use dioxus::prelude::*;
use views::{Blog, Home, Navbar};
/// Define a components module that contains all shared components for our app.
mod components;
/// Define a views module that contains the UI for all Layouts and Routes for our app.
mod views;
/// The Route enum is used to define the structure of internal routes in our app. All route enums need to derive
/// the [`Routable`] trait, which provides the necessary methods for the router to work.
///
/// Each variant represents a different URL pattern that can be matched by the router. If that pattern is matched,
/// the components for that route will be rendered.
#[derive(Debug, Clone, Routable, PartialEq)]
#[rustfmt::skip]
enum Route {
// The layout attribute defines a wrapper for all routes under the layout. Layouts are great for wrapping
// many routes with a common UI like a navbar.
#[layout(Navbar)]
// The route attribute defines the URL pattern that a specific route matches. If that pattern matches the URL,
// the component for that route will be rendered. The component name that is rendered defaults to the variant name.
#[route("/")]
Home {},
// The route attribute can include dynamic parameters that implement [`std::str::FromStr`] and [`std::fmt::Display`] with the `:` syntax.
// In this case, id will match any integer like `/blog/123` or `/blog/-456`.
#[route("/blog/:id")]
// Fields of the route variant will be passed to the component as props. In this case, the blog component must accept
// an `id` prop of type `i32`.
Blog { id: i32 },
}
// We can import assets in dioxus with the `asset!` macro. This macro takes a path to an asset relative to the crate root.
// The macro returns an `Asset` type that will display as the path to the asset in the browser or a local path in desktop bundles.
const FAVICON: Asset = asset!("/assets/favicon.ico");
// The asset macro also minifies some assets like CSS and JS to make bundled smaller
const MAIN_CSS: Asset = asset!("/assets/styling/main.css");
const SHADCN_CSS: Asset = asset!("/assets/styling/shadcn.css");
fn main() {
// The `launch` function is the main entry point for a dioxus app. It takes a component and renders it with the platform feature
// you have enabled
dioxus::launch(App);
}
/// App is the main component of our app. Components are the building blocks of dioxus apps. Each component is a function
/// that takes some props and returns an Element. In this case, App takes no props because it is the root of our app.
///
/// Components should be annotated with `#[component]` to support props, better error messages, and autocomplete
#[component]
fn App() -> Element {
// The `rsx!` macro lets us define HTML inside of rust. It expands to an Element with all of our HTML inside.
rsx! {
// In addition to element and text (which we will see later), rsx can contain other components. In this case,
// we are using the `document::Link` component to add a link to our favicon and main CSS file into the head of our app.
document::Link { rel: "icon", href: FAVICON }
document::Link { rel: "stylesheet", href: MAIN_CSS }
document::Link { rel: "stylesheet", href: SHADCN_CSS }
// The router component renders the route enum we defined above. It will handle synchronization of the URL and render
// the layouts and components for the active route.
Router::<Route> {}
}
}

39
src/views/blog.rs Normal file
View File

@@ -0,0 +1,39 @@
use crate::Route;
use dioxus::prelude::*;
const BLOG_CSS: Asset = asset!("/assets/styling/blog.css");
/// The Blog page component that will be rendered when the current route is `[Route::Blog]`
///
/// The component takes a `id` prop of type `i32` from the route enum. Whenever the id changes, the component function will be
/// re-run and the rendered HTML will be updated.
#[component]
pub fn Blog(id: i32) -> Element {
rsx! {
document::Link { rel: "stylesheet", href: BLOG_CSS }
div {
id: "blog",
// Content
h1 { "This is blog #{id}!" }
p { "In blog #{id}, we show how the Dioxus router works and how URL parameters can be passed as props to our route components." }
// Navigation links
// The `Link` component lets us link to other routes inside our app. It takes a `to` prop of type `Route` and
// any number of child nodes.
Link {
// The `to` prop is the route that the link should navigate to. We can use the `Route` enum to link to the
// blog page with the id of -1. Since we are using an enum instead of a string, all of the routes will be checked
// at compile time to make sure they are valid.
to: Route::Blog { id: id - 1 },
"Previous"
}
span { " <---> " }
Link {
to: Route::Blog { id: id + 1 },
"Next"
}
}
}
}

241
src/views/home.rs Normal file
View File

@@ -0,0 +1,241 @@
use crate::components::{
ui::{
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,
},
Echo, Hero,
};
use dioxus::prelude::*;
/// The Home page component that will be rendered when the current route is `[Route::Home]`
#[component]
pub fn Home() -> Element {
rsx! {
Hero {}
Echo {}
UiShowcase {}
}
}
#[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 slider_value_signal = slider_value.clone();
let contact_method_signal = contact_method.clone();
let intensity_text = move || format!("Accent intensity: {:.0}%", slider_value_signal());
let contact_text = move || format!("Preferred contact: {}", contact_method_signal());
rsx! {
section {
class: "ui-shell shadcn",
div {
class: "ui-stack",
h2 { style: "font-size: 1.75rem; font-weight: 600;", "Shadcn primitives for Dioxus" }
p {
style: "color: hsl(var(--muted-foreground)); max-width: 640px;",
"A compact gallery of the shadcn/ui building blocks, rebuilt with Dioxus 0.7 signals."
}
}
div {
class: "ui-demo-grid",
Card {
CardHeader {
CardTitle { "Profile form" }
CardDescription { "Inputs, sliders, helpers, and actions inside a card layout." }
}
CardContent {
div { class: "ui-stack",
Label { html_for: "profile-name", "Name" }
Input { id: "profile-name", placeholder: "Ada Lovelace" }
}
div { class: "ui-stack",
Label { html_for: "profile-about", "About" }
Textarea {
id: "profile-about",
placeholder: "Tell us something fun...",
rows: 4,
}
SpanHelper { "Textarea adopts shadcn spacing and typography out of the box." }
}
Separator { style: "margin: 1rem 0;" }
div { class: "ui-stack",
Label { html_for: "accent-slider", "Accent strength" }
Slider {
value: slider_value(),
min: 0.0,
max: 100.0,
step: 1.0,
on_value_change: move |val| slider_value.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),
}
}
}
}
CardFooter {
div { class: "ui-cluster",
Button { variant: ButtonVariant::Outline, size: ButtonSize::Sm, "Cancel" }
Button { disabled: !accepted_terms(), "Save changes" }
}
}
}
Card {
CardHeader {
CardTitle { "Buttons & badges" }
CardDescription { "Variant + size matrix copied directly from shadcn/ui." }
}
CardContent {
div { class: "ui-stack",
SpanHelper { "Buttons variants" }
div { class: "ui-cluster",
Button { "Primary" }
Button { variant: ButtonVariant::Secondary, "Secondary" }
Button { variant: ButtonVariant::Destructive, "Destructive" }
Button { variant: ButtonVariant::Outline, "Outline" }
Button { variant: ButtonVariant::Ghost, "Ghost" }
Button { variant: ButtonVariant::Link, "Learn more" }
}
}
div { class: "ui-stack",
SpanHelper { "Buttons sizes" }
div { class: "ui-cluster",
Button { size: ButtonSize::Sm, "Small" }
Button { "Default" }
Button { size: ButtonSize::Lg, "Large" }
Button { size: ButtonSize::Icon, "" }
}
}
Separator { style: "margin: 1rem 0;" }
div { class: "ui-stack",
SpanHelper { "Badges" }
div { class: "ui-cluster",
Badge { "Default" }
Badge { variant: BadgeVariant::Secondary, "Secondary" }
Badge { variant: BadgeVariant::Destructive, "Destructive" }
Separator { orientation: SeparatorOrientation::Vertical, style: "height: 1.5rem;" }
Badge { variant: BadgeVariant::Outline, "Outline" }
}
}
}
}
Card {
CardHeader {
CardTitle { "Selection controls" }
CardDescription { "Checkboxes, switches, and radio groups stay in sync with signals." }
}
CardContent {
div { class: "ui-stack",
div { class: "ui-cluster",
Checkbox {
id: Some("newsletter-opt".to_string()),
checked: newsletter_opt_in(),
on_checked_change: move |state| newsletter_opt_in.set(state),
}
Label { html_for: "newsletter-opt", "Subscribe to newsletter" }
}
div { class: "ui-cluster",
Label { html_for: "dark-mode", "Dark mode" }
Switch {
id: Some("dark-mode".to_string()),
checked: dark_mode(),
on_checked_change: move |state| dark_mode.set(state),
}
}
Separator { style: "margin: 0.75rem 0;" }
RadioGroup {
default_value: contact_method(),
on_value_change: move |value| contact_method.set(value),
div { class: "ui-stack",
div { class: "ui-cluster",
RadioGroupItem { id: Some("contact-email".to_string()), value: "email" }
Label { html_for: "contact-email", "Email" }
}
div { class: "ui-cluster",
RadioGroupItem { id: Some("contact-sms".to_string()), value: "sms" }
Label { html_for: "contact-sms", "SMS" }
}
div { class: "ui-cluster",
RadioGroupItem { id: Some("contact-call".to_string()), value: "call" }
Label { html_for: "contact-call", "Phone call" }
}
}
}
SpanHelper { "{contact_text()}" }
}
}
}
Card {
CardHeader {
CardTitle { "Tabs & panels" }
CardDescription { "Tabbed navigation with content surfaces that stay in sync." }
}
CardContent {
Tabs {
default_value: "overview",
TabsList {
TabsTrigger { value: "overview", "Overview" }
TabsTrigger { value: "analytics", "Analytics" }
TabsTrigger { value: "reports", "Reports" }
}
TabsContent {
value: "overview",
div { class: "ui-stack",
Label { html_for: "overview-search", "Search" }
Input { id: "overview-search", placeholder: "Search docs..." }
SpanHelper { "Triggers share the same focus ring and sizing as the original UI kit." }
}
}
TabsContent {
value: "analytics",
div { class: "ui-stack",
SpanHelper { "Analytics aggregates live metrics and shows their progress." }
Progress { value: 64.0, max: 100.0 }
}
}
TabsContent {
value: "reports",
div { class: "ui-stack",
SpanHelper { "Generate PDF, CSV, or scheduled exports directly from here." }
Button { variant: ButtonVariant::Secondary, "Create report" }
}
}
}
}
}
}
}
}
}
#[component]
fn SpanHelper(children: Element) -> Element {
rsx! { span { class: "ui-field-helper", {children} } }
}

18
src/views/mod.rs Normal file
View File

@@ -0,0 +1,18 @@
//! The views module contains the components for all Layouts and Routes for our app. Each layout and route in our [`Route`]
//! enum will render one of these components.
//!
//!
//! The [`Home`] and [`Blog`] components will be rendered when the current route is [`Route::Home`] or [`Route::Blog`] respectively.
//!
//!
//! The [`Navbar`] component will be rendered on all pages of our app since every page is under the layout. The layout defines
//! a common wrapper around all child routes.
mod home;
pub use home::Home;
mod blog;
pub use blog::Blog;
mod navbar;
pub use navbar::Navbar;

32
src/views/navbar.rs Normal file
View File

@@ -0,0 +1,32 @@
use crate::Route;
use dioxus::prelude::*;
const NAVBAR_CSS: Asset = asset!("/assets/styling/navbar.css");
/// The Navbar component that will be rendered on all pages of our app since every page is under the layout.
///
///
/// This layout component wraps the UI of [Route::Home] and [Route::Blog] in a common navbar. The contents of the Home and Blog
/// routes will be rendered under the outlet inside this component
#[component]
pub fn Navbar() -> Element {
rsx! {
document::Link { rel: "stylesheet", href: NAVBAR_CSS }
div {
id: "navbar",
Link {
to: Route::Home {},
"Home"
}
Link {
to: Route::Blog { id: 1 },
"Blog"
}
}
// The `Outlet` component is used to render the next component inside the layout. In this case, it will render either
// the [`Home`] or [`Blog`] component depending on the current route.
Outlet::<Route> {}
}
}