一些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

7
.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
# Generated by Cargo
# will have compiled files and executables
/target
.DS_Store
# These are backup files generated by rustfmt
**/*.rs.bk

265
AGENTS.md Normal file
View File

@@ -0,0 +1,265 @@
You are an expert [0.7 Dioxus](https://dioxuslabs.com/learn/0.7) assistant. Dioxus 0.7 changes every api in dioxus. Only use this up to date documentation. `cx`, `Scope`, and `use_state` are gone
Provide concise code examples with detailed descriptions
# Dioxus Dependency
You can add Dioxus to your `Cargo.toml` like this:
```toml
[dependencies]
dioxus = { version = "0.7.0" }
[features]
default = ["web", "webview", "server"]
web = ["dioxus/web"]
webview = ["dioxus/desktop"]
server = ["dioxus/server"]
```
# Launching your application
You need to create a main function that sets up the Dioxus runtime and mounts your root component.
```rust
use dioxus::prelude::*;
fn main() {
dioxus::launch(App);
}
#[component]
fn App() -> Element {
rsx! { "Hello, Dioxus!" }
}
```
Then serve with `dx serve`:
```sh
curl -sSL http://dioxus.dev/install.sh | sh
dx serve
```
# UI with RSX
```rust
rsx! {
div {
class: "container", // Attribute
color: "red", // Inline styles
width: if condition { "100%" }, // Conditional attributes
"Hello, Dioxus!"
}
// Prefer loops over iterators
for i in 0..5 {
div { "{i}" } // use elements or components directly in loops
}
if condition {
div { "Condition is true!" } // use elements or components directly in conditionals
}
{children} // Expressions are wrapped in brace
{(0..5).map(|i| rsx! { span { "Item {i}" } })} // Iterators must be wrapped in braces
}
```
# Assets
The asset macro can be used to link to local files to use in your project. All links start with `/` and are relative to the root of your project.
```rust
rsx! {
img {
src: asset!("/assets/image.png"),
alt: "An image",
}
}
```
## Styles
The `document::Stylesheet` component will inject the stylesheet into the `<head>` of the document
```rust
rsx! {
document::Stylesheet {
href: asset!("/assets/styles.css"),
}
}
```
# Components
Components are the building blocks of apps
* Component are functions annotated with the `#[component]` macro.
* The function name must start with a capital letter or contain an underscore.
* A component re-renders only under two conditions:
1. Its props change (as determined by `PartialEq`).
2. An internal reactive state it depends on is updated.
```rust
#[component]
fn Input(mut value: Signal<String>) -> Element {
rsx! {
input {
value,
oninput: move |e| {
*value.write() = e.value();
},
onkeydown: move |e| {
if e.key() == Key::Enter {
value.write().clear();
}
},
}
}
}
```
Each component accepts function arguments (props)
* Props must be owned values, not references. Use `String` and `Vec<T>` instead of `&str` or `&[T]`.
* Props must implement `PartialEq` and `Clone`.
* To make props reactive and copy, you can wrap the type in `ReadOnlySignal`. Any reactive state like memos and resources that read `ReadOnlySignal` props will automatically re-run when the prop changes.
# State
A signal is a wrapper around a value that automatically tracks where it's read and written. Changing a signal's value causes code that relies on the signal to rerun.
## Local State
The `use_signal` hook creates state that is local to a single component. You can call the signal like a function (e.g. `my_signal()`) to clone the value, or use `.read()` to get a reference. `.write()` gets a mutable reference to the value.
Use `use_memo` to create a memoized value that recalculates when its dependencies change. Memos are useful for expensive calculations that you don't want to repeat unnecessarily.
```rust
#[component]
fn Counter() -> Element {
let mut count = use_signal(|| 0);
let mut doubled = use_memo(move || count() * 2); // doubled will re-run when count changes because it reads the signal
rsx! {
h1 { "Count: {count}" } // Counter will re-render when count changes because it reads the signal
h2 { "Doubled: {doubled}" }
button {
onclick: move |_| *count.write() += 1, // Writing to the signal rerenders Counter
"Increment"
}
button {
onclick: move |_| count.with_mut(|count| *count += 1), // use with_mut to mutate the signal
"Increment with with_mut"
}
}
}
```
## Context API
The Context API allows you to share state down the component tree. A parent provides the state using `use_context_provider`, and any child can access it with `use_context`
```rust
#[component]
fn App() -> Element {
let mut theme = use_signal(|| "light".to_string());
use_context_provider(|| theme); // Provide a type to children
rsx! { Child {} }
}
#[component]
fn Child() -> Element {
let theme = use_context::<Signal<String>>(); // Consume the same type
rsx! {
div {
"Current theme: {theme}"
}
}
}
```
# Async
For state that depends on an asynchronous operation (like a network request), Dioxus provides a hook called `use_resource`. This hook manages the lifecycle of the async task and provides the result to your component.
* The `use_resource` hook takes an `async` closure. It re-runs this closure whenever any signals it depends on (reads) are updated
* The `Resource` object returned can be in several states when read:
1. `None` if the resource is still loading
2. `Some(value)` if the resource has successfully loaded
```rust
let mut dog = use_resource(move || async move {
// api request
});
match dog() {
Some(dog_info) => rsx! { Dog { dog_info } },
None => rsx! { "Loading..." },
}
```
# Routing
All possible routes are defined in a single Rust `enum` that derives `Routable`. Each variant represents a route and is annotated with `#[route("/path")]`. Dynamic Segments can capture parts of the URL path as parameters by using `:name` in the route string. These become fields in the enum variant.
The `Router<Route> {}` component is the entry point that manages rendering the correct component for the current URL.
You can use the `#[layout(NavBar)]` to create a layout shared between pages and place an `Outlet<Route> {}` inside your layout component. The child routes will be rendered in the outlet.
```rust
#[derive(Routable, Clone, PartialEq)]
enum Route {
#[layout(NavBar)] // This will use NavBar as the layout for all routes
#[route("/")]
Home {},
#[route("/blog/:id")] // Dynamic segment
BlogPost { id: i32 },
}
#[component]
fn NavBar() -> Element {
rsx! {
a { href: "/", "Home" }
Outlet<Route> {} // Renders Home or BlogPost
}
}
#[component]
fn App() -> Element {
rsx! { Router::<Route> {} }
}
```
```toml
dioxus = { version = "0.7.0", features = ["router"] }
```
# Fullstack
Fullstack enables server rendering and ipc calls. It uses Cargo features (`server` and a client feature like `web`) to split the code into a server and client binaries.
```toml
dioxus = { version = "0.7.0", features = ["fullstack"] }
```
## Server Functions
Use the `#[post]` / `#[get]` macros to define an `async` function that will only run on the server. On the server, this macro generates an API endpoint. On the client, it generates a function that makes an HTTP request to that endpoint.
```rust
#[post("/api/double/:path/&query")]
async fn double_server(number: i32, path: String, query: i32) -> Result<i32, ServerFnError> {
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
Ok(number * 2)
}
```
## Hydration
Hydration is the process of making a server-rendered HTML page interactive on the client. The server sends the initial HTML, and then the client-side runs, attaches event listeners, and takes control of future rendering.
### Errors
The initial UI rendered by the component on the client must be identical to the UI rendered on the server.
* Use the `use_server_future` hook instead of `use_resource`. It runs the future on the server, serializes the result, and sends it to the client, ensuring the client has the data immediately for its first render.
* Any code that relies on browser-specific APIs (like accessing `localStorage`) must be run *after* hydration. Place this code inside a `use_effect` hook.

6439
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

21
Cargo.toml Normal file
View File

@@ -0,0 +1,21 @@
[package]
name = "dx-admin-template"
version = "0.1.0"
authors = ["tommy <mzt.live@live.com>"]
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
dioxus = { version = "0.7.0", features = ["router", "fullstack"] }
[features]
default = ["web"]
# The feature that are only required for the web = ["dioxus/web"] build target should be optional and only enabled in the web = ["dioxus/web"] feature
web = ["dioxus/web"]
# The feature that are only required for the desktop = ["dioxus/desktop"] build target should be optional and only enabled in the desktop = ["dioxus/desktop"] feature
desktop = ["dioxus/desktop"]
# The feature that are only required for the mobile = ["dioxus/mobile"] build target should be optional and only enabled in the mobile = ["dioxus/mobile"] feature
mobile = ["dioxus/mobile"]
# The feature that are only required for the server = ["dioxus/server"] build target should be optional and only enabled in the server = ["dioxus/server"] feature
server = ["dioxus/server"]

21
Dioxus.toml Normal file
View File

@@ -0,0 +1,21 @@
[application]
[web.app]
# HTML title tag content
title = "dx-admin-template"
# include `assets` in web platform
[web.resource]
# Additional CSS style files
style = []
# Additional JavaScript files
script = []
[web.resource.dev]
# Javascript code file
# serve: [dev-server] only
script = []

34
README.md Normal file
View File

@@ -0,0 +1,34 @@
# Development
Your new jumpstart project includes basic organization with an organized `assets` folder and a `components` folder.
If you chose to develop with the router feature, you will also have a `views` folder.
```
project/
├─ assets/ # Any assets that are used by the app should be placed here
├─ src/
│ ├─ main.rs # The entrypoint for the app. It also defines the routes for the app.
│ ├─ components/
│ │ ├─ mod.rs # Defines the components module
│ │ ├─ hero.rs # The Hero component for use in the home page
│ │ ├─ echo.rs # The echo component uses server functions to communicate with the server
│ ├─ views/ # The views each route will render in the app.
│ │ ├─ mod.rs # Defines the module for the views route and re-exports the components for each route
│ │ ├─ blog.rs # The component that will render at the /blog/:id route
│ │ ├─ home.rs # The component that will render at the / route
├─ Cargo.toml # The Cargo.toml file defines the dependencies and feature flags for your project
```
### Serving Your App
Run the following command in the root of your project to start developing with the default platform:
```bash
dx serve --platform web
```
To run for a different platform, use the `--platform platform` flag. E.g.
```bash
dx serve --platform desktop
```

BIN
assets/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

20
assets/header.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 23 KiB

8
assets/styling/blog.css Normal file
View File

@@ -0,0 +1,8 @@
#blog {
margin-top: 50px;
}
#blog a {
color: #ffffff;
margin-top: 50px;
}

34
assets/styling/echo.css Normal file
View File

@@ -0,0 +1,34 @@
#echo {
width: 360px;
margin-left: auto;
margin-right: auto;
margin-top: 50px;
background-color: #1e222d;
padding: 20px;
border-radius: 10px;
}
#echo>h4 {
margin: 0px 0px 15px 0px;
}
#echo>input {
border: none;
border-bottom: 1px white solid;
background-color: transparent;
color: #ffffff;
transition: border-bottom-color 0.2s ease;
outline: none;
display: block;
padding: 0px 0px 5px 0px;
width: 100%;
}
#echo>input:focus {
border-bottom-color: #6d85c6;
}
#echo>p {
margin: 20px 0px 0px auto;
}

42
assets/styling/main.css Normal file
View File

@@ -0,0 +1,42 @@
body {
background-color: #0f1116;
color: #ffffff;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
margin: 20px;
}
#hero {
margin: 0;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
#links {
width: 400px;
text-align: left;
font-size: x-large;
color: white;
display: flex;
flex-direction: column;
}
#links a {
color: white;
text-decoration: none;
margin-top: 20px;
margin: 10px 0px;
border: white 1px solid;
border-radius: 5px;
padding: 10px;
}
#links a:hover {
background-color: #1f1f1f;
cursor: pointer;
}
#header {
max-width: 1200px;
}

16
assets/styling/navbar.css Normal file
View File

@@ -0,0 +1,16 @@
#navbar {
display: flex;
flex-direction: row;
}
#navbar a {
color: #ffffff;
margin-right: 20px;
text-decoration: none;
transition: color 0.2s ease;
}
#navbar a:hover {
cursor: pointer;
color: #91a4d2;
}

567
assets/styling/shadcn.css Normal file
View File

@@ -0,0 +1,567 @@
:root {
color-scheme: light;
--background: 0 0% 100%;
--foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 47.4% 11.2%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--card: 0 0% 100%;
--card-foreground: 222.2 47.4% 11.2%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--ring: 215 20.2% 65.1%;
--radius: 0.5rem;
--shadow-sm: 0 1px 2px 0 rgb(15 23 42 / 0.04);
--shadow-md: 0 8px 20px -12px rgb(15 23 42 / 0.45);
}
.dark,
:root[data-theme="dark"] {
color-scheme: dark;
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--ring: 215 20.2% 65.1%;
}
.shadcn {
background-color: hsl(var(--background));
color: hsl(var(--foreground));
}
.ui-button {
align-items: center;
background-color: hsl(var(--primary));
border: 1px solid transparent;
border-radius: var(--radius);
color: hsl(var(--primary-foreground));
cursor: pointer;
display: inline-flex;
font-size: 0.875rem;
font-weight: 500;
gap: 0.5rem;
height: 2.25rem;
justify-content: center;
line-height: 1;
padding: 0 1rem;
transition: background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
}
.ui-button[data-size="sm"] {
height: 2rem;
border-radius: calc(var(--radius) - 4px);
padding: 0 0.75rem;
}
.ui-button[data-size="lg"] {
height: 2.75rem;
border-radius: calc(var(--radius) + 2px);
padding: 0 1.5rem;
font-size: 1rem;
}
.ui-button[data-size="icon"] {
width: 2.25rem;
height: 2.25rem;
padding: 0;
border-radius: calc(var(--radius) - 2px);
}
.ui-button[data-variant="secondary"] {
background-color: hsl(var(--secondary));
border-color: hsl(var(--border));
color: hsl(var(--secondary-foreground));
}
.ui-button[data-variant="destructive"] {
background-color: hsl(var(--destructive));
color: hsl(var(--destructive-foreground));
}
.ui-button[data-variant="outline"] {
background-color: transparent;
border-color: hsl(var(--border));
color: hsl(var(--foreground));
}
.ui-button[data-variant="ghost"] {
background-color: transparent;
border-color: transparent;
color: hsl(var(--foreground));
}
.ui-button[data-variant="ghost"]:hover {
background-color: hsl(var(--muted));
}
.ui-button[data-variant="link"] {
background-color: transparent;
border-color: transparent;
color: hsl(var(--primary));
height: auto;
padding: 0;
border-radius: 0;
text-decoration: underline;
}
.ui-button[data-variant="secondary"]:hover {
background-color: hsl(var(--secondary) / 0.9);
}
.ui-button[data-variant="destructive"]:hover {
background-color: hsl(var(--destructive) / 0.9);
}
.ui-button[data-variant="outline"]:hover {
background-color: hsl(var(--accent));
}
.ui-button:hover {
background-color: hsl(var(--primary) / 0.9);
}
.ui-button:focus-visible {
outline: none;
box-shadow: 0 0 0 2px hsl(var(--ring) / 0.6), 0 0 0 4px hsl(var(--background));
}
.ui-button:disabled,
.ui-button[aria-disabled="true"] {
opacity: 0.5;
pointer-events: none;
}
.ui-badge {
align-items: center;
background-color: hsl(var(--primary) / 0.1);
border: 1px solid transparent;
border-radius: calc(var(--radius) - 6px);
color: hsl(var(--primary));
display: inline-flex;
font-size: 0.75rem;
font-weight: 500;
height: 1.5rem;
line-height: 1;
padding: 0 0.6rem;
text-transform: none;
white-space: nowrap;
}
.ui-badge[data-variant="secondary"] {
background-color: hsl(var(--secondary));
color: hsl(var(--secondary-foreground));
}
.ui-badge[data-variant="outline"] {
background-color: transparent;
border-color: hsl(var(--border));
color: hsl(var(--foreground));
}
.ui-badge[data-variant="destructive"] {
background-color: hsl(var(--destructive) / 0.1);
color: hsl(var(--destructive));
}
.ui-input,
.ui-textarea {
appearance: none;
background-color: hsl(var(--background));
border: 1px solid hsl(var(--input));
border-radius: calc(var(--radius) - 2px);
color: hsl(var(--foreground));
font-size: 0.875rem;
line-height: 1.4;
outline: none;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
width: 100%;
}
.ui-input {
height: 2.5rem;
padding: 0 0.75rem;
}
.ui-textarea {
min-height: 5rem;
padding: 0.75rem;
resize: vertical;
}
.ui-input::placeholder,
.ui-textarea::placeholder {
color: hsl(var(--muted-foreground));
}
.ui-input:focus-visible,
.ui-textarea:focus-visible {
border-color: hsl(var(--ring));
box-shadow: 0 0 0 1px hsl(var(--ring));
}
.ui-input:disabled,
.ui-textarea:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.ui-label {
color: hsl(var(--foreground));
display: inline-flex;
font-size: 0.875rem;
font-weight: 500;
gap: 0.5rem;
}
.ui-label[data-disabled="true"] {
cursor: not-allowed;
opacity: 0.7;
}
.ui-checkbox,
.ui-radio {
appearance: none;
align-items: center;
border-radius: calc(var(--radius) - 6px);
border: 1px solid hsl(var(--border));
display: inline-flex;
height: 1.1rem;
justify-content: center;
width: 1.1rem;
background-color: hsl(var(--background));
transition: border-color 0.2s ease, background-color 0.2s ease, box-shadow 0.2s ease;
}
.ui-checkbox:focus-visible,
.ui-radio:focus-visible {
outline: none;
box-shadow: 0 0 0 2px hsl(var(--ring) / 0.5);
}
.ui-checkbox:checked {
background-color: hsl(var(--primary));
border-color: hsl(var(--primary));
color: hsl(var(--primary-foreground));
}
.ui-checkbox:checked::after {
content: "";
display: block;
width: 0.45rem;
height: 0.45rem;
border-radius: 0.1rem;
background-color: currentColor;
}
.ui-radio {
border-radius: 9999px;
}
.ui-radio:checked {
border-color: hsl(var(--primary));
box-shadow: inset 0 0 0 4px hsl(var(--primary));
}
.ui-radio-group {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.ui-switch {
appearance: none;
background-color: hsl(var(--muted));
border: 1px solid hsl(var(--border));
border-radius: 999px;
cursor: pointer;
display: inline-flex;
height: 1.5rem;
padding: 0.125rem;
position: relative;
transition: background-color 0.2s ease, border-color 0.2s ease;
width: 2.75rem;
}
.ui-switch::after {
background-color: hsl(var(--background));
border-radius: 50%;
content: "";
height: 1.1rem;
left: 0.1rem;
position: absolute;
top: 0.1rem;
transition: transform 0.2s ease, background-color 0.2s ease;
width: 1.1rem;
box-shadow: var(--shadow-sm);
}
.ui-switch:checked {
background-color: hsl(var(--primary));
border-color: hsl(var(--primary));
}
.ui-switch:checked::after {
background-color: hsl(var(--primary-foreground));
transform: translateX(1.2rem);
}
.ui-switch:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.ui-tabs {
display: flex;
flex-direction: column;
gap: 1rem;
}
.ui-tabs-nav {
display: inline-flex;
align-items: center;
justify-content: flex-start;
background-color: hsl(var(--muted));
border-radius: calc(var(--radius) - 4px);
padding: 0.25rem;
width: max-content;
}
.ui-tabs-trigger {
appearance: none;
background-color: transparent;
border: none;
border-radius: calc(var(--radius) - 6px);
color: hsl(var(--muted-foreground));
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
padding: 0.4rem 0.9rem;
transition: color 0.2s ease, background-color 0.2s ease;
}
.ui-tabs-trigger[data-state="active"] {
background-color: hsl(var(--background));
color: hsl(var(--foreground));
box-shadow: var(--shadow-sm);
}
.ui-tabs-trigger:focus-visible {
outline: none;
box-shadow: 0 0 0 2px hsl(var(--ring) / 0.4);
}
.ui-tabs-content {
outline: none;
}
.ui-tabs-content[hidden] {
display: none;
}
.ui-card {
background-color: hsl(var(--card));
border-radius: calc(var(--radius) + 4px);
border: 1px solid hsl(var(--border));
box-shadow: var(--shadow-md);
color: hsl(var(--card-foreground));
display: flex;
flex-direction: column;
}
.ui-card-header {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 1.5rem 1.5rem 0 1.5rem;
}
.ui-card-title {
font-size: 1.125rem;
font-weight: 600;
letter-spacing: -0.01em;
}
.ui-card-description {
color: hsl(var(--muted-foreground));
font-size: 0.9rem;
}
.ui-card-content {
padding: 1.5rem;
padding-top: 0;
}
.ui-card-footer {
display: flex;
align-items: center;
justify-content: flex-end;
padding: 1.25rem 1.5rem;
border-top: 1px solid hsl(var(--border));
gap: 0.75rem;
}
.ui-shell {
background-color: hsl(var(--background));
color: hsl(var(--foreground));
margin: 3rem auto;
max-width: 1024px;
width: 100%;
padding: 0 1.5rem 3rem;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.ui-demo-grid {
display: grid;
gap: 1.5rem;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
}
.ui-stack {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.ui-cluster {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.75rem;
}
.ui-bleed {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.ui-separator {
background-color: hsl(var(--border));
display: block;
}
.ui-separator[data-orientation="horizontal"] {
height: 1px;
width: 100%;
}
.ui-separator[data-orientation="vertical"] {
width: 1px;
align-self: stretch;
}
.ui-progress {
position: relative;
height: 0.5rem;
width: 100%;
overflow: hidden;
border-radius: 9999px;
background-color: hsl(var(--muted));
}
.ui-progress span {
position: absolute;
left: 0;
top: 0;
bottom: 0;
background-color: hsl(var(--primary));
transition: width 0.2s ease;
}
.ui-slider {
width: 100%;
display: inline-flex;
align-items: center;
height: 1.5rem;
}
.ui-slider input[type="range"] {
appearance: none;
width: 100%;
height: 0.3rem;
border-radius: 999px;
background: linear-gradient(
to right,
hsl(var(--primary)) var(--fill, 50%),
hsl(var(--muted)) var(--fill, 50%)
);
outline: none;
border: none;
}
.ui-slider input[type="range"]::-webkit-slider-thumb {
appearance: none;
width: 1.1rem;
height: 1.1rem;
border-radius: 50%;
background-color: hsl(var(--background));
border: 2px solid hsl(var(--primary));
box-shadow: var(--shadow-sm);
cursor: pointer;
transition: transform 0.2s ease;
}
.ui-slider input[type="range"]::-webkit-slider-thumb:active {
transform: scale(1.05);
}
.ui-slider input[type="range"]::-moz-range-track {
height: 0.3rem;
border-radius: 999px;
background-color: hsl(var(--muted));
}
.ui-slider input[type="range"]::-moz-range-progress {
height: 0.3rem;
border-radius: 999px;
background-color: hsl(var(--primary));
}
.ui-slider input[type="range"]::-moz-range-thumb {
width: 1.1rem;
height: 1.1rem;
border-radius: 50%;
background-color: hsl(var(--background));
border: 2px solid hsl(var(--primary));
box-shadow: var(--shadow-sm);
cursor: pointer;
transition: transform 0.2s ease;
}
.ui-form-field {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.ui-field-helper {
color: hsl(var(--muted-foreground));
font-size: 0.75rem;
}

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