WIP
This commit is contained in:
parent
5ce50b76f5
commit
94a1935ffc
21 changed files with 3406 additions and 85 deletions
120
Cargo.lock
generated
120
Cargo.lock
generated
|
@ -179,7 +179,7 @@ checksum = "2b1866ecef4f2d06a0bb77880015fdf2b89e25a1c2e5addacb87e459c86dc67e"
|
|||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"blowfish",
|
||||
"getrandom",
|
||||
"getrandom 0.2.15",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
@ -226,9 +226,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.16.0"
|
||||
version = "3.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c"
|
||||
checksum = "1628fb46dfa0b37568d12e5edd512553eccf6a22a78e8bde00bb4aed84d5bdbf"
|
||||
|
||||
[[package]]
|
||||
name = "bytemuck"
|
||||
|
@ -250,9 +250,9 @@ checksum = "325918d6fe32f23b19878fe4b34794ae41fc19ddbe53b10571a4874d44ffd39b"
|
|||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.2.9"
|
||||
version = "1.2.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8293772165d9345bdaaa39b45b2109591e63fe5e6fbc23c6ff930a048aa310b"
|
||||
checksum = "13208fcbb66eaeffe09b99fffbe1af420f00a7b35aa99ad683dfc1aa76145229"
|
||||
dependencies = [
|
||||
"shlex",
|
||||
]
|
||||
|
@ -332,9 +332,9 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
|||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.16"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "16b80225097f2e5ae4e7179dd2266824648f3e2f49d9134d584b76389d31c4c3"
|
||||
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
@ -767,7 +767,19 @@ checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7"
|
|||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"wasi",
|
||||
"wasi 0.11.0+wasi-snapshot-preview1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "43a49c392881ce6d5c3b8cb70f98717b7c07aabbdff06687b9030dbfbe2725f8"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"wasi 0.13.3+wasi-0.2.2",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
@ -913,9 +925,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "httparse"
|
||||
version = "1.9.5"
|
||||
version = "1.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d71d3574edd2771538b901e6549113b4006ece66150fb69c0fb6d9a2adae946"
|
||||
checksum = "f2d708df4e7140240a16cd6ab0ab65c972d7433ab77819ea693fde9c43811e2a"
|
||||
|
||||
[[package]]
|
||||
name = "httpdate"
|
||||
|
@ -1130,9 +1142,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.7.0"
|
||||
version = "2.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "62f822373a4fe84d4bb149bf54e584a7f4abec90e072ed49cda0edea5b95471f"
|
||||
checksum = "8c9c992b02b5b4c94ea26e32fe5bccb7aa7d9f390ab5c1221ff895bc7ea8b652"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown 0.15.2",
|
||||
|
@ -1156,19 +1168,19 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "ipnet"
|
||||
version = "2.10.1"
|
||||
version = "2.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ddc24109865250148c2e0f3d25d4f0f479571723792d3802153c60922a4fb708"
|
||||
checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130"
|
||||
|
||||
[[package]]
|
||||
name = "is-terminal"
|
||||
version = "0.4.13"
|
||||
version = "0.4.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "261f68e344040fbd0edea105bef17c66edf46f984ddb1115b775ce31be948f4b"
|
||||
checksum = "e19b23d53f35ce9f56aebc7d1bb4e6ac1e9c0db7ac85c8d1760c04379edced37"
|
||||
dependencies = [
|
||||
"hermit-abi 0.4.0",
|
||||
"libc",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
@ -1330,7 +1342,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"wasi",
|
||||
"wasi 0.11.0+wasi-snapshot-preview1",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
|
@ -1355,9 +1367,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "native-tls"
|
||||
version = "0.2.12"
|
||||
version = "0.2.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a8614eb2c83d59d1c8cc974dd3f920198647674a0a035e1af1fa58707e317466"
|
||||
checksum = "0dab59f8e050d5df8e4dd87d9206fb6f65a483e20ac9fda365ade4fab353196c"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
|
@ -1460,9 +1472,9 @@ checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775"
|
|||
|
||||
[[package]]
|
||||
name = "openssl"
|
||||
version = "0.10.68"
|
||||
version = "0.10.69"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6174bc48f102d208783c2c84bf931bb75927a617866870de8a4ea85597f871f5"
|
||||
checksum = "f5e534d133a060a3c19daec1eb3e98ec6f4685978834f2dbadfe2ec215bab64e"
|
||||
dependencies = [
|
||||
"bitflags 2.8.0",
|
||||
"cfg-if",
|
||||
|
@ -1486,9 +1498,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "openssl-probe"
|
||||
version = "0.1.5"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf"
|
||||
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
|
||||
|
||||
[[package]]
|
||||
name = "openssl-sys"
|
||||
|
@ -1710,7 +1722,7 @@ version = "0.6.4"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
||||
dependencies = [
|
||||
"getrandom",
|
||||
"getrandom 0.2.15",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
@ -1988,9 +2000,9 @@ checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f"
|
|||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "0.38.43"
|
||||
version = "0.38.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a78891ee6bf2340288408954ac787aa063d8e8817e9f53abb37c695c6d834ef6"
|
||||
checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
|
||||
dependencies = [
|
||||
"bitflags 2.8.0",
|
||||
"errno",
|
||||
|
@ -2016,9 +2028,9 @@ checksum = "f7c45b9784283f1b2e7fb61b42047c2fd678ef0960d4f6f1eba131594cc369d4"
|
|||
|
||||
[[package]]
|
||||
name = "ryu"
|
||||
version = "1.0.18"
|
||||
version = "1.0.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f"
|
||||
checksum = "6ea1a2d0a644769cc99faa24c3ad26b379b786fe7c36fd3c546254801650e6dd"
|
||||
|
||||
[[package]]
|
||||
name = "same-file"
|
||||
|
@ -2095,9 +2107,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.135"
|
||||
version = "1.0.138"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b0d7ba2887406110130a978386c4e1befb98c674b4fba677954e4db976630d9"
|
||||
checksum = "d434192e7da787e94a6ea7e9670b26a036d0ca41e0b7efb2676dd32bae872949"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
|
@ -2523,13 +2535,13 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.15.0"
|
||||
version = "3.16.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a8a559c81686f576e8cd0290cd2a24a2a9ad80c98b3478856500fcbd7acd704"
|
||||
checksum = "38c246215d7d24f48ae091a2902398798e05d978b24315d6efbc00ede9a8bb91"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"fastrand",
|
||||
"getrandom",
|
||||
"getrandom 0.3.1",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.59.0",
|
||||
|
@ -2716,9 +2728,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "toml_edit"
|
||||
version = "0.22.22"
|
||||
version = "0.22.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ae48d6208a266e853d946088ed816055e556cc6028c5e8e2b84d9fa5dd7c7f5"
|
||||
checksum = "02a8b472d1a3d7c18e2d61a489aee3453fd9031c33e4f55bd533f4a7adca1bee"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"serde",
|
||||
|
@ -2850,9 +2862,9 @@ checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5"
|
|||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.14"
|
||||
version = "1.0.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "adb9e6ca4f869e1180728b7950e35922a7fc6397f7b641499e8f3ef06e50dc83"
|
||||
checksum = "a210d160f08b701c8721ba1c726c11662f877ea6b7094007e1ca9a1041945034"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-normalization"
|
||||
|
@ -2906,19 +2918,19 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
|||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.12.0"
|
||||
version = "1.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "744018581f9a3454a9e15beb8a33b017183f1e7c0cd170232a2d1453b23a51c4"
|
||||
checksum = "b3758f5e68192bb96cc8f9b7e2c2cfdabb435499a28499a42f8f984092adad4b"
|
||||
dependencies = [
|
||||
"getrandom",
|
||||
"getrandom 0.2.15",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "valuable"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d"
|
||||
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
|
||||
|
||||
[[package]]
|
||||
name = "vcpkg"
|
||||
|
@ -2957,6 +2969,15 @@ version = "0.11.0+wasi-snapshot-preview1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.13.3+wasi-0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "26816d2e1a4a36a2940b96c5296ce403917633dff8f3440e9b236ed6f6bacad2"
|
||||
dependencies = [
|
||||
"wit-bindgen-rt",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasite"
|
||||
version = "0.1.0"
|
||||
|
@ -3253,9 +3274,9 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
|||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "0.6.24"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8d71a593cc5c42ad7876e2c1fda56f314f3754c084128833e64f1345ff8a03a"
|
||||
checksum = "7e49d2d35d3fad69b39b94139037ecfb4f359f08958b9c11e7315ce770462419"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
@ -3270,6 +3291,15 @@ dependencies = [
|
|||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rt"
|
||||
version = "0.33.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3268f3d866458b787f390cf61f4bbb563b922d091359f9608842999eaee3943c"
|
||||
dependencies = [
|
||||
"bitflags 2.8.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "write16"
|
||||
version = "1.0.0"
|
||||
|
|
|
@ -8,6 +8,15 @@ pub trait ColorCircle {
|
|||
#[must_use]
|
||||
fn previous(&self) -> Self;
|
||||
|
||||
#[must_use]
|
||||
fn middle(&self) -> Self;
|
||||
|
||||
#[must_use]
|
||||
fn start(&self) -> Self;
|
||||
|
||||
#[must_use]
|
||||
fn end(&self) -> Self;
|
||||
|
||||
#[must_use]
|
||||
fn next(&self) -> Self;
|
||||
}
|
||||
|
@ -25,6 +34,8 @@ impl UIColor for RGBColor {
|
|||
}
|
||||
}
|
||||
|
||||
pub trait UIColorCircle: UIColor + ColorCircle + Sized {}
|
||||
|
||||
macro_rules! color_map {
|
||||
($name:ident, $id:literal) => {
|
||||
#[derive(Debug, Clone)]
|
||||
|
@ -77,6 +88,18 @@ macro_rules! color_map {
|
|||
}
|
||||
}
|
||||
|
||||
fn middle(&self) -> Self {
|
||||
Self::_500
|
||||
}
|
||||
|
||||
fn start(&self) -> Self {
|
||||
Self::_50
|
||||
}
|
||||
|
||||
fn end(&self) -> Self {
|
||||
Self::_950
|
||||
}
|
||||
|
||||
fn previous(&self) -> Self {
|
||||
match self {
|
||||
$name::_50 => $name::_950,
|
||||
|
@ -133,6 +156,44 @@ pub enum Colors {
|
|||
White,
|
||||
}
|
||||
|
||||
impl ColorCircle for Colors {
|
||||
fn previous(&self) -> Self {
|
||||
match self {
|
||||
Colors::Black => Colors::White,
|
||||
Colors::White => Colors::Black,
|
||||
_ => self.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn middle(&self) -> Self {
|
||||
self.clone()
|
||||
}
|
||||
|
||||
fn start(&self) -> Self {
|
||||
match self {
|
||||
Colors::Black => Colors::White,
|
||||
Colors::White => Colors::White,
|
||||
_ => self.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn end(&self) -> Self {
|
||||
match self {
|
||||
Colors::Black => Colors::Black,
|
||||
Colors::White => Colors::Black,
|
||||
_ => self.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn next(&self) -> Self {
|
||||
match self {
|
||||
Colors::Black => Colors::White,
|
||||
Colors::White => Colors::Black,
|
||||
_ => self.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl UIColor for Colors {
|
||||
fn color_class(&self) -> &str {
|
||||
match self {
|
||||
|
|
171
src/ui/components/avatar.rs
Normal file
171
src/ui/components/avatar.rs
Normal file
|
@ -0,0 +1,171 @@
|
|||
use maud::{Render, html};
|
||||
|
||||
use crate::ui::UIWidget;
|
||||
|
||||
#[allow(non_snake_case)]
|
||||
pub fn Avatar<T: Into<String>>(image: T, name: T) -> AvatarWidget {
|
||||
AvatarWidget {
|
||||
users: vec![(image.into(), name.into())],
|
||||
ring: false,
|
||||
use_initials: false,
|
||||
indicator: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(non_snake_case)]
|
||||
pub fn AvatarStack(users: Vec<(String, String)>) -> AvatarWidget {
|
||||
AvatarWidget {
|
||||
users,
|
||||
ring: false,
|
||||
use_initials: false,
|
||||
indicator: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub struct AvatarWidget {
|
||||
users: Vec<(String, String)>,
|
||||
ring: bool,
|
||||
use_initials: bool,
|
||||
indicator: Option<bool>,
|
||||
}
|
||||
|
||||
impl AvatarWidget {
|
||||
pub fn with_ring(mut self) -> Self {
|
||||
self.ring = true;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn online(mut self, value: bool) -> Self {
|
||||
self.indicator = Some(value);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn use_initials(mut self) -> Self {
|
||||
self.use_initials = true;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn build_avatar(&self, image: &str, name: &str) -> maud::Markup {
|
||||
let mut img_class = "w-10 h-10 rounded-full".to_string();
|
||||
|
||||
if self.ring {
|
||||
img_class.push_str(" ring-2 ring-gray-300 dark:ring-gray-500");
|
||||
}
|
||||
|
||||
if self.indicator.is_some() {
|
||||
let online = self.indicator.unwrap();
|
||||
|
||||
let indicator_class = match online {
|
||||
true => {
|
||||
"bottom-0 left-7 absolute w-3.5 h-3.5 bg-green-400 border-2 border-white dark:border-gray-800 rounded-full"
|
||||
}
|
||||
false => {
|
||||
"bottom-0 left-7 absolute w-3.5 h-3.5 bg-red-400 border-2 border-white dark:border-gray-800 rounded-full"
|
||||
}
|
||||
};
|
||||
|
||||
return html! {
|
||||
@if image.is_empty() {
|
||||
@if self.use_initials {
|
||||
div class="relative" title=(name) {
|
||||
div class=(format!("relative inline-flex items-center justify-center w-10 h-10 overflow-hidden bg-gray-100 rounded-full dark:bg-gray-600 {img_class}")) {
|
||||
span class="font-medium text-gray-600 dark:text-gray-300" { (initials(name)) };
|
||||
};
|
||||
span class=(indicator_class) {};
|
||||
}
|
||||
} @else {
|
||||
div class="relative" title=(name) {
|
||||
div class=(format!("relative w-10 h-10 overflow-hidden bg-gray-100 rounded-full dark:bg-gray-600 {img_class}")){
|
||||
svg class="absolute w-12 h-12 text-gray-400 -left-1" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg" {
|
||||
path fill-rule="evenodd" d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z" clip-rule="evenodd" {};
|
||||
};
|
||||
};
|
||||
span class=(indicator_class) {};
|
||||
};
|
||||
};
|
||||
} @else {
|
||||
div class="relative" title=(name) {
|
||||
img class=(img_class) src=(image) alt=(name) {};
|
||||
span class=(indicator_class) {};
|
||||
};
|
||||
}
|
||||
};
|
||||
} else {
|
||||
return html! {
|
||||
@if image.is_empty() {
|
||||
@if self.use_initials {
|
||||
div class="relative" title=(name) {
|
||||
div class=(format!("relative inline-flex items-center justify-center w-10 h-10 overflow-hidden bg-gray-100 rounded-full dark:bg-gray-600 {img_class}")) {
|
||||
span class="font-medium text-gray-600 dark:text-gray-300" { (initials(name)) };
|
||||
};
|
||||
}
|
||||
} @else {
|
||||
div class="relative" title=(name) {
|
||||
div class=(format!("relative w-10 h-10 overflow-hidden bg-gray-100 rounded-full dark:bg-gray-600 {img_class}")){
|
||||
svg class="absolute w-12 h-12 text-gray-400 -left-1" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg" {
|
||||
path fill-rule="evenodd" d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z" clip-rule="evenodd" {};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
} @else {
|
||||
div class="relative" title=(name) {
|
||||
img class=(img_class) src=(image) alt=(name) {};
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn initials(txt: &str) -> String {
|
||||
let elements = txt.split_whitespace();
|
||||
let mut initials = Vec::new();
|
||||
|
||||
for e in elements {
|
||||
initials.push(e.chars().next().unwrap().to_string());
|
||||
}
|
||||
|
||||
initials.join("")
|
||||
}
|
||||
|
||||
impl Render for AvatarWidget {
|
||||
fn render(&self) -> maud::Markup {
|
||||
self.render_with_class("")
|
||||
}
|
||||
}
|
||||
|
||||
impl UIWidget for AvatarWidget {
|
||||
fn can_inherit(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn base_class(&self) -> Vec<String> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn extended_class(&self) -> Vec<String> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn render_with_class(&self, _: &str) -> maud::Markup {
|
||||
if self.users.len() == 1 {
|
||||
let (image, name) = &self.users[0];
|
||||
self.build_avatar(image, name);
|
||||
}
|
||||
|
||||
html! {
|
||||
div class="flex -space-x-4 rtl:space-x-reverse" {
|
||||
@for (image, name) in self.users.iter().take(4) {
|
||||
(self.build_avatar(&image, &name))
|
||||
}
|
||||
|
||||
@if self.users.len() > 4 {
|
||||
p class="flex items-center justify-center w-11 h-11 text-xs font-medium text-white bg-gray-700 border-2 border-white rounded-full hover:bg-gray-600 dark:border-gray-800 z-10 -translate-y-[2px]" {
|
||||
(format!("+{}", self.users.len()-4))
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
204
src/ui/components/htmx.rs
Normal file
204
src/ui/components/htmx.rs
Normal file
|
@ -0,0 +1,204 @@
|
|||
use maud::{PreEscaped, Render, html};
|
||||
|
||||
use crate::ui::{
|
||||
UIWidget,
|
||||
htmx::{Event, HTMXAttributes, SwapStrategy},
|
||||
prelude::Div,
|
||||
};
|
||||
|
||||
pub fn ClickLoad<T: UIWidget + 'static>(reference: &str, widget: T) -> PreEscaped<String> {
|
||||
html! {
|
||||
button hx-get=(reference)
|
||||
hx-target="this"
|
||||
hx-swap="outerHTML" {
|
||||
(widget)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
pub fn LazyLoad<T: UIWidget + 'static>(reference: &str, widget: T) -> PreEscaped<String> {
|
||||
html! {
|
||||
div hx-get=(reference) hx-trigger="load" {
|
||||
div class="htmx-indicator" {
|
||||
(widget)
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
pub fn InfinityScroll() -> PreEscaped<String> {
|
||||
// TODO : integrate with pager
|
||||
let url = String::new();
|
||||
Div()
|
||||
.hx_get(&url)
|
||||
.hx_swap(SwapStrategy::outerHTML)
|
||||
.hx_trigger(Event::on_revealed())
|
||||
.render()
|
||||
}
|
||||
|
||||
/*
|
||||
Progress Bar
|
||||
|
||||
This example shows how to implement a smoothly scrolling progress bar.
|
||||
|
||||
We start with an initial state with a button that issues a POST to /start to begin the job:
|
||||
|
||||
<div hx-target="this" hx-swap="outerHTML">
|
||||
<h3>Start Progress</h3>
|
||||
<button class="btn primary" hx-post="/start">
|
||||
Start Job
|
||||
</button>
|
||||
</div>
|
||||
|
||||
This div is then replaced with a new div containing status and a progress bar that reloads itself every 600ms:
|
||||
|
||||
<div hx-trigger="done" hx-get="/job" hx-swap="outerHTML" hx-target="this">
|
||||
<h3 role="status" id="pblabel" tabindex="-1" autofocus>Running</h3>
|
||||
|
||||
<div
|
||||
hx-get="/job/progress"
|
||||
hx-trigger="every 600ms"
|
||||
hx-target="this"
|
||||
hx-swap="innerHTML">
|
||||
<div class="progress" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" aria-labelledby="pblabel">
|
||||
<div id="pb" class="progress-bar" style="width:0%">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
This progress bar is updated every 600 milliseconds, with the “width” style attribute and aria-valuenow attributed set to current progress value. Because there is an id on the progress bar div, htmx will smoothly transition between requests by settling the style attribute into its new value. This, when coupled with CSS transitions, makes the visual transition continuous rather than jumpy.
|
||||
|
||||
Finally, when the process is complete, a server returns HX-Trigger: done header, which triggers an update of the UI to “Complete” state with a restart button added to the UI (we are using the class-tools extension in this example to add fade-in effect on the button):
|
||||
|
||||
<div hx-trigger="done" hx-get="/job" hx-swap="outerHTML" hx-target="this">
|
||||
<h3 role="status" id="pblabel" tabindex="-1" autofocus>Complete</h3>
|
||||
|
||||
<div
|
||||
hx-get="/job/progress"
|
||||
hx-trigger="none"
|
||||
hx-target="this"
|
||||
hx-swap="innerHTML">
|
||||
<div class="progress" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="122" aria-labelledby="pblabel">
|
||||
<div id="pb" class="progress-bar" style="width:122%">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button id="restart-btn" class="btn primary" hx-post="/start" classes="add show:600ms">
|
||||
Restart Job
|
||||
</button>
|
||||
</div>
|
||||
|
||||
This example uses styling cribbed from the bootstrap progress bar:
|
||||
|
||||
.progress {
|
||||
height: 20px;
|
||||
margin-bottom: 20px;
|
||||
overflow: hidden;
|
||||
background-color: #f5f5f5;
|
||||
border-radius: 4px;
|
||||
box-shadow: inset 0 1px 2px rgba(0,0,0,.1);
|
||||
}
|
||||
.progress-bar {
|
||||
float: left;
|
||||
width: 0%;
|
||||
height: 100%;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
background-color: #337ab7;
|
||||
-webkit-box-shadow: inset 0 -1px 0 rgba(0,0,0,.15);
|
||||
box-shadow: inset 0 -1px 0 rgba(0,0,0,.15);
|
||||
-webkit-transition: width .6s ease;
|
||||
-o-transition: width .6s ease;
|
||||
transition: width .6s ease;
|
||||
}
|
||||
*/
|
||||
|
||||
/*
|
||||
Cascading Selects
|
||||
|
||||
In this example we show how to make the values in one select depend on the value selected in another select.
|
||||
|
||||
To begin we start with a default value for the make select: Audi. We render the model select for this make. We then have the make select trigger a GET to /models to retrieve the models options and target the models select.
|
||||
|
||||
Here is the code:
|
||||
|
||||
<div>
|
||||
<label >Make</label>
|
||||
<select name="make" hx-get="/models" hx-target="#models" hx-indicator=".htmx-indicator">
|
||||
<option value="audi">Audi</option>
|
||||
<option value="toyota">Toyota</option>
|
||||
<option value="bmw">BMW</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label>Model</label>
|
||||
<select id="models" name="model">
|
||||
<option value="a1">A1</option>
|
||||
...
|
||||
</select>
|
||||
<img class="htmx-indicator" width="20" src="/img/bars.svg">
|
||||
</div>
|
||||
|
||||
When a request is made to the /models end point, we return the models for that make:
|
||||
|
||||
<option value='325i'>325i</option>
|
||||
<option value='325ix'>325ix</option>
|
||||
<option value='X5'>X5</option>
|
||||
|
||||
And they become available in the model select.
|
||||
*/
|
||||
|
||||
/*
|
||||
Sortable
|
||||
|
||||
In this example we show how to integrate the Sortable javascript library with htmx.
|
||||
|
||||
To begin we initialize the .sortable class with the Sortable javascript library:
|
||||
|
||||
htmx.onLoad(function(content) {
|
||||
var sortables = content.querySelectorAll(".sortable");
|
||||
for (var i = 0; i < sortables.length; i++) {
|
||||
var sortable = sortables[i];
|
||||
var sortableInstance = new Sortable(sortable, {
|
||||
animation: 150,
|
||||
ghostClass: 'blue-background-class',
|
||||
|
||||
// Make the `.htmx-indicator` unsortable
|
||||
filter: ".htmx-indicator",
|
||||
onMove: function (evt) {
|
||||
return evt.related.className.indexOf('htmx-indicator') === -1;
|
||||
},
|
||||
|
||||
// Disable sorting on the `end` event
|
||||
onEnd: function (evt) {
|
||||
this.option("disabled", true);
|
||||
}
|
||||
});
|
||||
|
||||
// Re-enable sorting on the `htmx:afterSwap` event
|
||||
sortable.addEventListener("htmx:afterSwap", function() {
|
||||
sortableInstance.option("disabled", false);
|
||||
});
|
||||
}
|
||||
})
|
||||
|
||||
Next, we create a form that has some sortable divs within it, and we trigger an ajax request on the end event, fired by Sortable.js:
|
||||
|
||||
<form class="sortable" hx-post="/items" hx-trigger="end">
|
||||
<div class="htmx-indicator">Updating...</div>
|
||||
<div><input type='hidden' name='item' value='1'/>Item 1</div>
|
||||
<div><input type='hidden' name='item' value='2'/>Item 2</div>
|
||||
<div><input type='hidden' name='item' value='3'/>Item 3</div>
|
||||
<div><input type='hidden' name='item' value='4'/>Item 4</div>
|
||||
<div><input type='hidden' name='item' value='5'/>Item 5</div>
|
||||
</form>
|
||||
|
||||
Note that each div has a hidden input inside of it that specifies the item id for that row.
|
||||
|
||||
When the list is reordered via the Sortable.js drag-and-drop, the end event will be fired. htmx will then post the item ids in the new order to /items, to be persisted by the server.
|
||||
|
||||
That’s it!
|
||||
*/
|
63
src/ui/components/indicator.rs
Normal file
63
src/ui/components/indicator.rs
Normal file
|
@ -0,0 +1,63 @@
|
|||
use maud::{PreEscaped, html};
|
||||
|
||||
use crate::ui::{UIWidget, color::UIColor};
|
||||
|
||||
use super::ColorCircle;
|
||||
|
||||
pub fn Indicator<C: UIColor + 'static>(color: C) -> PreEscaped<String> {
|
||||
html! {
|
||||
span class=(format!("flex w-3 h-3 me-3 bg-{} rounded-full", color.color_class())) {};
|
||||
}
|
||||
}
|
||||
|
||||
pub fn IndicatorLegend<C: UIColor + 'static>(color: C, legend: &str) -> PreEscaped<String> {
|
||||
html! {
|
||||
span class="flex items-center text-sm font-medium text-gray-900 dark:text-white me-3" {
|
||||
span class=(format!("flex w-2.5 h-2.5 bg-{} rounded-full me-1.5 shrink-0", color.color_class())) {};
|
||||
(legend)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
pub fn NumberIndicator<T: UIWidget + 'static>(on: T, amount: u32) -> PreEscaped<String> {
|
||||
html! {
|
||||
div class="relative items-center max-w-fit" {
|
||||
(on)
|
||||
div class="absolute inline-flex items-center justify-center px-2 text-xs font-bold text-white bg-red-500 border-2 border-white rounded-full -top-2 -end-2 dark:border-gray-900" { (amount) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn BadgeIndicator<C: UIColor + 'static + ColorCircle>(
|
||||
color: C,
|
||||
dark_color: C,
|
||||
txt: &str,
|
||||
) -> PreEscaped<String> {
|
||||
// BG -100
|
||||
let bg_color = color.color_class();
|
||||
// Text -800
|
||||
let text_color = color.next().next().next().next().next().next().next();
|
||||
let text_color = text_color.color_class();
|
||||
// Dark BG -900
|
||||
let dark_bg = dark_color.color_class();
|
||||
// Dark Text -300
|
||||
let dark_text = dark_color
|
||||
.previous()
|
||||
.previous()
|
||||
.previous()
|
||||
.previous()
|
||||
.previous()
|
||||
.previous();
|
||||
let dark_text = dark_text.color_class();
|
||||
// Indicator -500
|
||||
let indicator_color = color.middle();
|
||||
let indicator_color = indicator_color.color_class();
|
||||
|
||||
html! {
|
||||
span
|
||||
class=(format!("inline-flex items-center bg-{bg_color} text-{text_color} text-xs font-medium px-2.5 py-0.5 rounded-full dark:bg-{dark_bg} dark:text-{dark_text}")) {
|
||||
span class=(format!("w-2 h-2 me-1 bg-{indicator_color} rounded-full")) {};
|
||||
(txt)
|
||||
};
|
||||
}
|
||||
}
|
|
@ -1,7 +1,705 @@
|
|||
mod appbar;
|
||||
mod avatar;
|
||||
mod htmx;
|
||||
mod indicator;
|
||||
mod modal;
|
||||
mod overlay;
|
||||
mod pagination;
|
||||
mod placeholder;
|
||||
mod search;
|
||||
mod shell;
|
||||
mod timeline;
|
||||
|
||||
// TODO : ENSURE: prelude
|
||||
pub mod prelude {
|
||||
pub use super::avatar::{Avatar, AvatarStack};
|
||||
pub use super::htmx::*;
|
||||
pub use super::indicator::*;
|
||||
pub use super::modal::*;
|
||||
pub use super::overlay::*;
|
||||
pub use super::pagination::*;
|
||||
pub use super::placeholder::{
|
||||
CardPlaceholder, ImagePlaceholder, ListPlaceholder, Placeholder, TextPlaceholder,
|
||||
VideoPlaceholder,
|
||||
};
|
||||
pub use super::timeline::*;
|
||||
}
|
||||
|
||||
pub use super::color::ColorCircle;
|
||||
pub use appbar::AppBar;
|
||||
use maud::{PreEscaped, Render, html};
|
||||
pub use search::Search;
|
||||
pub use shell::Shell;
|
||||
pub use shell::*;
|
||||
|
||||
use crate::ui::prelude::script;
|
||||
|
||||
use super::{
|
||||
UIWidget,
|
||||
color::{Gray, UIColor},
|
||||
htmx::{Event, HTMXAttributes, SwapStrategy},
|
||||
prelude::Div,
|
||||
};
|
||||
|
||||
// ENSURE: doc pages? test
|
||||
|
||||
#[allow(non_snake_case)]
|
||||
pub fn HorizontalLine() -> PreEscaped<String> {
|
||||
html! {
|
||||
hr class="h-px my-8 bg-gray-200 border-0 dark:bg-gray-700" {};
|
||||
}
|
||||
}
|
||||
|
||||
pub fn FnKey(key: &str) -> PreEscaped<String> {
|
||||
html! {
|
||||
kbd class="px-2 py-1.5 text-xs font-semibold text-gray-800 bg-gray-100 border border-gray-200 rounded-lg dark:bg-gray-600 dark:text-gray-100 dark:border-gray-500" { (key) };
|
||||
}
|
||||
}
|
||||
|
||||
pub fn ColoredSpinner<T: UIColor + 'static>(color: T) -> PreEscaped<String> {
|
||||
let col = color.color_class();
|
||||
html! {
|
||||
div role="status" {
|
||||
svg aria-hidden="true" class=(format!("w-8 h-8 text-gray-200 animate-spin dark:text-gray-600 fill-{col}")) viewBox="0 0 100 101" fill="none" xmlns="http://www.w3.org/2000/svg" {
|
||||
path d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z" fill="currentColor" {};
|
||||
path d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z" fill="currentFill" {};
|
||||
};
|
||||
span class="sr-only" { "Loading..." }
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(non_snake_case)]
|
||||
pub fn Spinner() -> PreEscaped<String> {
|
||||
ColoredSpinner(super::color::Blue::_600)
|
||||
}
|
||||
|
||||
pub fn CopyText(txt: &str) -> PreEscaped<String> {
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
let gscript = format!(
|
||||
"
|
||||
window.addEventListener('load', function () {{
|
||||
const clipboard = FlowbiteInstances.getInstance('CopyClipboard', '{id}-copy');
|
||||
const tooltip = FlowbiteInstances.getInstance('Tooltip', '{id}-copy-tooltip');
|
||||
|
||||
const $defaultIcon = document.getElementById('default-icon');
|
||||
const $successIcon = document.getElementById('success-icon');
|
||||
|
||||
const $defaultTooltipMessage = document.getElementById('default-tooltip-message');
|
||||
const $successTooltipMessage = document.getElementById('success-tooltip-message');
|
||||
|
||||
clipboard.updateOnCopyCallback((clipboard) => {{
|
||||
showSuccess();
|
||||
|
||||
// reset to default state
|
||||
setTimeout(() => {{
|
||||
resetToDefault();
|
||||
}}, 2000);
|
||||
}})
|
||||
|
||||
const showSuccess = () => {{
|
||||
$defaultIcon.classList.add('hidden');
|
||||
$successIcon.classList.remove('hidden');
|
||||
$defaultTooltipMessage.classList.add('hidden');
|
||||
$successTooltipMessage.classList.remove('hidden');
|
||||
tooltip.show();
|
||||
}}
|
||||
|
||||
const resetToDefault = () => {{
|
||||
$defaultIcon.classList.remove('hidden');
|
||||
$successIcon.classList.add('hidden');
|
||||
$defaultTooltipMessage.classList.remove('hidden');
|
||||
$successTooltipMessage.classList.add('hidden');
|
||||
tooltip.hide();
|
||||
}}
|
||||
}})
|
||||
"
|
||||
);
|
||||
|
||||
html! {
|
||||
|
||||
(script(&gscript))
|
||||
|
||||
div class="w-full max-w-[16rem]" {
|
||||
div class="relative" {
|
||||
input id=(format!("{id}-copy")) type="text" class="col-span-6 bg-gray-50 border border-gray-300 text-gray-500 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-gray-400 dark:focus:ring-blue-500 dark:focus:border-blue-500" value=(txt) disabled readonly {};
|
||||
button data-copy-to-clipboard-target=(format!("{id}-copy")) data-tooltip-target=(format!("{id}-copy-tooltip")) class="absolute end-2 top-1/2 -translate-y-1/2 text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg p-2 inline-flex items-center justify-center" {
|
||||
span id="default-icon" {
|
||||
svg class="w-3.5 h-3.5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 18 20" {
|
||||
path d="M16 1h-3.278A1.992 1.992 0 0 0 11 0H7a1.993 1.993 0 0 0-1.722 1H2a2 2 0 0 0-2 2v15a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V3a2 2 0 0 0-2-2Zm-3 14H5a1 1 0 0 1 0-2h8a1 1 0 0 1 0 2Zm0-4H5a1 1 0 0 1 0-2h8a1 1 0 1 1 0 2Zm0-5H5a1 1 0 0 1 0-2h2V2h4v2h2a1 1 0 1 1 0 2Z" {};
|
||||
};
|
||||
};
|
||||
span id="success-icon" class="hidden inline-flex items-center" {
|
||||
svg class="w-3.5 h-3.5 text-blue-700 dark:text-blue-500" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 16 12" {
|
||||
path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M1 5.917 5.724 10.5 15 1.5" {};
|
||||
};
|
||||
};
|
||||
};
|
||||
div id=(format!("{id}-copy-tooltip")) role="tooltip" class="absolute z-10 invisible inline-block px-3 py-2 text-sm font-medium text-white transition-opacity duration-300 bg-gray-900 rounded-lg shadow-sm opacity-0 tooltip dark:bg-gray-700" {
|
||||
span id="default-tooltip-message" { "Copy to clipboard" };
|
||||
span id="success-tooltip-message" class="hidden" { "Copied!" };
|
||||
div class="tooltip-arrow" data-popper-arrow {};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// NOCOMMIT ---
|
||||
|
||||
// TODO : test + fix accordion
|
||||
pub fn Accordion<H: UIWidget + 'static, B: UIWidget + 'static>(
|
||||
title: H,
|
||||
body: B,
|
||||
) -> PreEscaped<String> {
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
html! {
|
||||
div id=(format!("accordion-collapse-{id}")) data-accordion="collapse" {
|
||||
h2 id=(format!("accordion-collapse-heading-{id}")) {
|
||||
button type="button"
|
||||
class="flex items-center justify-between w-full p-5 font-medium rtl:text-right rounded-t-xl gap-3" data-accordion-target=(format!("#accordion-collapse-body-{id}")) aria-expanded="true" aria-controls=(format!("accordion-collapse-body-{id}")) {
|
||||
(title)
|
||||
svg data-accordion-icon class="w-3 h-3 rotate-180 shrink-0" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 10 6" {
|
||||
path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5 5 1 1 5" {};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
div id=(format!("accordion-collapse-body-{id}")) class="hidden" aria-labelledby=(format!("accordion-collapse-heading-{id}")) {
|
||||
div class="p-5 border border-b-0 border-gray-200 dark:border-gray-700 dark:bg-gray-900" {
|
||||
(body)
|
||||
}
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(non_snake_case)]
|
||||
pub fn InfoIcon() -> PreEscaped<String> {
|
||||
html! {
|
||||
svg class="shrink-0 inline w-4 h-4 me-3" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 20 20" {
|
||||
path d="M10 .5a9.5 9.5 0 1 0 9.5 9.5A9.51 9.51 0 0 0 10 .5ZM9.5 4a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3ZM12 15H8a1 1 0 0 1 0-2h1v-3H8a1 1 0 0 1 0-2h2a1 1 0 0 1 1 1v4h1a1 1 0 0 1 0 2Z" {};
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(non_snake_case)]
|
||||
pub fn X_Icon() -> PreEscaped<String> {
|
||||
html! {
|
||||
svg class="w-3 h-3" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 14" {
|
||||
path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6" {};
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(non_snake_case)]
|
||||
pub fn ColoredAlert<T: UIWidget + 'static, C: UIColor + ColorCircle + 'static>(
|
||||
color: C,
|
||||
inner: T,
|
||||
) -> PreEscaped<String> {
|
||||
let dark_color = color.previous().previous().previous().previous();
|
||||
let dark_color = dark_color.color_class();
|
||||
let bg_light = color.start();
|
||||
let bg_light = bg_light.color_class();
|
||||
let btn_bg_light = color
|
||||
.previous()
|
||||
.previous()
|
||||
.previous()
|
||||
.previous()
|
||||
.previous()
|
||||
.previous();
|
||||
let btn_bg_light = btn_bg_light.color_class();
|
||||
let ring_light = color.previous().previous().previous().previous();
|
||||
let ring_light = ring_light.color_class();
|
||||
let btn_light = color.previous().previous().previous();
|
||||
let btn_light = btn_light.color_class();
|
||||
let color = color.color_class();
|
||||
|
||||
let id = format!("alert-{}", uuid::Uuid::new_v4().to_string());
|
||||
|
||||
html! {
|
||||
div id=(id) class=(format!("flex items-center p-4 mb-4 text-{color} rounded-lg bg-{bg_light} dark:bg-gray-800 dark:text-{dark_color}")) role="alert" {
|
||||
(inner)
|
||||
|
||||
button type="button"
|
||||
class=(format!("ms-auto -mx-1.5 -my-1.5 bg-{bg_light} text-{btn_light} rounded-lg focus:ring-2 focus:ring-{ring_light} p-1.5 hover:bg-{btn_bg_light} inline-flex items-center justify-center h-8 w-8 dark:bg-gray-800 dark:text-{dark_color} dark:hover:bg-gray-700"))
|
||||
data-dismiss-target=(format!("#{id}")) aria-label="Close" {
|
||||
span class="sr-only" { "Close" };
|
||||
(X_Icon())
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(non_snake_case)]
|
||||
pub fn Alert<T: UIWidget + 'static>(inner: T) -> PreEscaped<String> {
|
||||
ColoredAlert(Gray::_800, inner)
|
||||
}
|
||||
|
||||
#[allow(non_snake_case)]
|
||||
pub fn FetchAlert(reference: &str) -> PreEscaped<String> {
|
||||
Div()
|
||||
.hx_get(reference)
|
||||
.hx_target(super::htmx::Selector::Query(
|
||||
"#notification_area".to_string(),
|
||||
))
|
||||
.hx_swap(SwapStrategy::beforeend)
|
||||
.hx_trigger(Event::on_load())
|
||||
.render()
|
||||
}
|
||||
|
||||
pub struct BreadcrumbWidget {
|
||||
elements: Vec<(String, String)>,
|
||||
seperator: Option<Box<dyn UIWidget>>,
|
||||
}
|
||||
|
||||
impl BreadcrumbWidget {
|
||||
pub fn seperator<T: UIWidget + 'static>(mut self, seperator: T) -> Self {
|
||||
self.seperator = Some(Box::new(seperator));
|
||||
self
|
||||
}
|
||||
|
||||
fn arrow_seperator() -> PreEscaped<String> {
|
||||
html! {
|
||||
svg class="rtl:rotate-180 w-3 h-3 text-gray-400 mx-1" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 6 10" {
|
||||
path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 9 4-4-4-4" {};
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for BreadcrumbWidget {
|
||||
fn render(&self) -> maud::Markup {
|
||||
self.render_with_class("")
|
||||
}
|
||||
}
|
||||
|
||||
impl UIWidget for BreadcrumbWidget {
|
||||
fn can_inherit(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn base_class(&self) -> Vec<String> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn extended_class(&self) -> Vec<String> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn render_with_class(&self, _: &str) -> maud::Markup {
|
||||
html! {
|
||||
nav class="flex" aria-label="Breadcrumb" {
|
||||
|
||||
ol class="inline-flex items-center space-x-1 md:space-x-2 rtl:space-x-reverse p-2" {
|
||||
|
||||
@for (index, (name, url)) in self.elements.iter().enumerate() {
|
||||
@if index == 0 {
|
||||
li class="inline-flex items-center" {
|
||||
a href=(url) class="inline-flex items-center text-sm font-medium hover:text-blue-600" { (name) };
|
||||
};
|
||||
} @else if index == (self.elements.len()-1) {
|
||||
|
||||
li aria-current="page" {
|
||||
div class="flex items-center" {
|
||||
@if let Some(s) = self.seperator.as_ref() {
|
||||
(s)
|
||||
} @else {
|
||||
(Self::arrow_seperator())
|
||||
}
|
||||
span class="ms-1 text-sm font-medium text-gray-500 md:ms-2 dark:text-gray-400" { (name) };
|
||||
};
|
||||
};
|
||||
} @else {
|
||||
li {
|
||||
div class="flex items-center" {
|
||||
@if let Some(s) = self.seperator.as_ref() {
|
||||
(s)
|
||||
} @else {
|
||||
(Self::arrow_seperator())
|
||||
}
|
||||
|
||||
a href=(url) class="ms-1 text-sm font-medium hover:text-blue-600 md:ms-2" { (name) };
|
||||
|
||||
};
|
||||
};
|
||||
}
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(non_snake_case)]
|
||||
pub fn Breadcrumb(paths: Vec<(String, String)>) -> BreadcrumbWidget {
|
||||
BreadcrumbWidget {
|
||||
elements: paths,
|
||||
seperator: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(non_snake_case)]
|
||||
pub fn Card<T: UIWidget + 'static>(inner: T) -> PreEscaped<String> {
|
||||
html! {
|
||||
div class="w-fit p-4 bg-white border border-gray-200 rounded-lg shadow dark:bg-gray-800 dark:border-gray-700" {
|
||||
(inner)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(non_snake_case)]
|
||||
pub fn Banner<T: UIWidget + 'static>(inner: T) -> PreEscaped<String> {
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
html! {
|
||||
div id=(format!("banner-{id}")) tabindex="-1" class="fixed top-0 start-0 z-40 flex justify-between w-full p-4 border-b border-gray-200 bg-gray-50 dark:bg-gray-700 dark:border-gray-600" {
|
||||
div class="flex items-center mx-auto" {
|
||||
p class="flex items-center text-sm font-normal text-gray-500 dark:text-gray-400" {
|
||||
(inner)
|
||||
};
|
||||
};
|
||||
div class="flex items-center" {
|
||||
button data-dismiss-target=(format!("#banner-{id}")) type="button" class="flex-shrink-0 inline-flex justify-center w-7 h-7 items-center text-gray-400 hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm p-1.5 dark:hover:bg-gray-600 dark:hover:text-white" {
|
||||
(X_Icon())
|
||||
span class="sr-only" { "Close banner" };
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// [NOCOMMIT]
|
||||
// COMPONENTS
|
||||
|
||||
pub enum CarouselMode {
|
||||
Slide,
|
||||
Static,
|
||||
}
|
||||
|
||||
#[allow(non_snake_case)]
|
||||
pub fn Carousel<T: UIWidget + 'static>(elements: Vec<T>) -> CarouselWidget {
|
||||
let mut boxed_elements: Vec<Box<dyn UIWidget>> = Vec::with_capacity(elements.len());
|
||||
|
||||
for e in elements {
|
||||
boxed_elements.push(Box::new(e));
|
||||
}
|
||||
|
||||
CarouselWidget {
|
||||
elements: boxed_elements,
|
||||
mode: CarouselMode::Slide,
|
||||
controls: false,
|
||||
indicators: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub struct CarouselWidget {
|
||||
elements: Vec<Box<dyn UIWidget>>,
|
||||
mode: CarouselMode,
|
||||
controls: bool,
|
||||
indicators: bool,
|
||||
}
|
||||
|
||||
impl CarouselWidget {
|
||||
pub fn mode(mut self, mode: CarouselMode) -> Self {
|
||||
self.mode = mode;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_controls(mut self) -> Self {
|
||||
self.controls = true;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_indicators(mut self) -> Self {
|
||||
self.indicators = true;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for CarouselWidget {
|
||||
fn render(&self) -> maud::Markup {
|
||||
self.render_with_class("")
|
||||
}
|
||||
}
|
||||
|
||||
impl UIWidget for CarouselWidget {
|
||||
fn can_inherit(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn base_class(&self) -> Vec<String> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn extended_class(&self) -> Vec<String> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn render_with_class(&self, _: &str) -> maud::Markup {
|
||||
let mode = match self.mode {
|
||||
CarouselMode::Slide => "slide",
|
||||
CarouselMode::Static => "static",
|
||||
};
|
||||
|
||||
html! {
|
||||
|
||||
div id="default-carousel" class="relative w-full" data-carousel=(mode) {
|
||||
|
||||
// Carousel wrapper
|
||||
div class="relative h-56 overflow-hidden rounded-lg md:h-96" {
|
||||
|
||||
@for element in &self.elements {
|
||||
div class="hidden duration-700 ease-in-out absolute block w-full flex justify-center items-center" data-carousel-item {
|
||||
(element)
|
||||
};
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
@if self.indicators {
|
||||
// Slider indicators
|
||||
div class="absolute z-30 flex -translate-x-1/2 bottom-5 left-1/2 space-x-3 rtl:space-x-reverse" {
|
||||
@for i in 0..self.elements.len() {
|
||||
@if i == 0 {
|
||||
button type="button" class="w-3 h-3 rounded-full" aria-current="true" aria-label=(format!("Slide {i}")) data-carousel-slide-to=(i) {};
|
||||
} @else {
|
||||
button type="button" class="w-3 h-3 rounded-full" aria-current="false" aria-label=(format!("Slide {i}")) data-carousel-slide-to=(i) {};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@if self.controls {
|
||||
// Slider controls
|
||||
button type="button" class="absolute top-0 start-0 z-30 flex items-center justify-center h-full px-4 cursor-pointer group focus:outline-none" data-carousel-prev {
|
||||
span class="inline-flex items-center justify-center w-10 h-10 rounded-full bg-white/30 dark:bg-gray-800/30 group-hover:bg-white/50 dark:group-hover:bg-gray-800/60 group-focus:ring-4 group-focus:ring-white dark:group-focus:ring-gray-800/70 group-focus:outline-none" {
|
||||
svg class="w-4 h-4 text-white dark:text-gray-800 rtl:rotate-180" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 6 10" {
|
||||
path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 1 1 5l4 4" {};
|
||||
};
|
||||
span class="sr-only" { "Previous" };
|
||||
};
|
||||
};
|
||||
|
||||
button type="button" class="absolute top-0 end-0 z-30 flex items-center justify-center h-full px-4 cursor-pointer group focus:outline-none" data-carousel-next {
|
||||
span class="inline-flex items-center justify-center w-10 h-10 rounded-full bg-white/30 dark:bg-gray-800/30 group-hover:bg-white/50 dark:group-hover:bg-gray-800/60 group-focus:ring-4 group-focus:ring-white dark:group-focus:ring-gray-800/70 group-focus:outline-none" {
|
||||
svg class="w-4 h-4 text-white dark:text-gray-800 rtl:rotate-180" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 6 10" {
|
||||
path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 9 4-4-4-4" {};
|
||||
};
|
||||
span class="sr-only" { "Next" };
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(non_snake_case)]
|
||||
pub fn HelpIcon() -> PreEscaped<String> {
|
||||
html! {
|
||||
svg class="w-4 h-4 text-gray-400 hover:text-gray-500" aria-hidden="true" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg" {
|
||||
path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-3a1 1 0 00-.867.5 1 1 0 11-1.731-1A3 3 0 0113 8a3.001 3.001 0 01-2 2.83V11a1 1 0 11-2 0v-1a1 1 0 011-1 1 1 0 100-2zm0 8a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd" {};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO : test + struct progressbar
|
||||
pub fn ProgressBar(percentage: u8, label: bool) -> PreEscaped<String> {
|
||||
assert!(percentage < 100, "Percentage must be less than 100");
|
||||
html! {
|
||||
@if label {
|
||||
div class="w-full bg-gray-200 rounded-full dark:bg-gray-700" {
|
||||
div class="bg-blue-600 text-xs font-medium text-blue-100 text-center p-0.5 leading-none rounded-full" style=(format!("width: {percentage}%")) { (format!("{percentage}%")) };
|
||||
};
|
||||
} @else {
|
||||
div class="w-full bg-gray-200 rounded-full h-2.5 dark:bg-gray-700" {
|
||||
div class="bg-blue-600 h-2.5 rounded-full" style=(format!("width: {percentage}%")) {};
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO : timeline stepper
|
||||
|
||||
#[allow(non_snake_case)]
|
||||
pub fn Stepper<S: Into<String>>(steps: Vec<S>) -> StepperWidget {
|
||||
let mut boxed_elements: Vec<Box<dyn UIWidget>> = Vec::with_capacity(steps.len());
|
||||
|
||||
for e in steps {
|
||||
boxed_elements.push(Box::new(e.into()));
|
||||
}
|
||||
|
||||
StepperWidget {
|
||||
elements: boxed_elements,
|
||||
icons: false,
|
||||
progress: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(non_snake_case)]
|
||||
pub fn IconStepper<T: UIWidget + 'static>(elements: Vec<T>) -> StepperWidget {
|
||||
let mut boxed_elements: Vec<Box<dyn UIWidget>> = Vec::with_capacity(elements.len());
|
||||
|
||||
for e in elements {
|
||||
boxed_elements.push(Box::new(e));
|
||||
}
|
||||
|
||||
StepperWidget {
|
||||
elements: boxed_elements,
|
||||
icons: true,
|
||||
progress: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub struct StepperWidget {
|
||||
elements: Vec<Box<dyn UIWidget>>,
|
||||
icons: bool,
|
||||
progress: u8,
|
||||
}
|
||||
|
||||
impl StepperWidget {
|
||||
pub fn step(mut self, step: u8) -> Self {
|
||||
if step != 0 {
|
||||
self.progress = step - 1;
|
||||
} else {
|
||||
self.progress = step;
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
pub fn check_icon() -> PreEscaped<String> {
|
||||
html! {
|
||||
svg class="w-3.5 h-3.5 sm:w-4 sm:h-4 me-2.5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 20 20" {
|
||||
path d="M10 .5a9.5 9.5 0 1 0 9.5 9.5A9.51 9.51 0 0 0 10 .5Zm3.707 8.207-4 4a1 1 0 0 1-1.414 0l-2-2a1 1 0 0 1 1.414-1.414L9 10.586l3.293-3.293a1 1 0 0 1 1.414 1.414Z" {};
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_final(index: usize, element: &Box<dyn UIWidget>) -> PreEscaped<String> {
|
||||
html! {
|
||||
li class="flex items-center" {
|
||||
span class="me-2" { (index+1) };
|
||||
(element)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_middle(index: usize, element: &Box<dyn UIWidget>) -> PreEscaped<String> {
|
||||
html! {
|
||||
li class="flex md:w-full items-center after:content-[''] after:w-full after:h-1 after:border-b after:border-gray-200 after:border-1 after:hidden sm:after:inline-block after:mx-6 xl:after:mx-10 dark:after:border-gray-700" {
|
||||
span class="flex items-center after:content-['/'] sm:after:hidden after:mx-2 after:text-gray-200 dark:after:text-gray-500" {
|
||||
span class="me-2" { (index+1) };
|
||||
(element)
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_done(element: &Box<dyn UIWidget>) -> PreEscaped<String> {
|
||||
html! {
|
||||
li class="flex md:w-full items-center text-blue-600 dark:text-blue-500 sm:after:content-[''] after:w-full after:h-1 after:border-b after:border-gray-200 after:border-1 after:hidden sm:after:inline-block after:mx-6 xl:after:mx-10 dark:after:border-gray-700" {
|
||||
span class="flex items-center after:content-['/'] sm:after:hidden after:mx-2 after:text-gray-200 dark:after:text-gray-500" {
|
||||
(Self::check_icon())
|
||||
(element)
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_final_icon(element: &Box<dyn UIWidget>) -> PreEscaped<String> {
|
||||
html! {
|
||||
li class="flex items-center w-full" {
|
||||
span class="flex items-center justify-center w-10 h-10 bg-gray-100 rounded-full lg:h-12 lg:w-12 dark:bg-gray-700 shrink-0" {
|
||||
(element)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_middle_icon(element: &Box<dyn UIWidget>) -> PreEscaped<String> {
|
||||
html! {
|
||||
li class="flex w-full items-center after:content-[''] after:w-full after:h-1 after:border-b after:border-gray-100 after:border-4 after:inline-block dark:after:border-gray-700" {
|
||||
span class="flex items-center justify-center w-10 h-10 bg-gray-100 rounded-full lg:h-12 lg:w-12 dark:bg-gray-700 shrink-0" {
|
||||
(element)
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_done_icon(element: &Box<dyn UIWidget>) -> PreEscaped<String> {
|
||||
html! {
|
||||
li class="flex w-full items-center text-blue-600 dark:text-blue-500 after:content-[''] after:w-full after:h-1 after:border-b after:border-blue-100 after:border-4 after:inline-block dark:after:border-blue-800" {
|
||||
span class="flex items-center justify-center w-10 h-10 bg-blue-100 rounded-full lg:h-12 lg:w-12 dark:bg-blue-800 shrink-0" {
|
||||
(element)
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for StepperWidget {
|
||||
fn render(&self) -> maud::Markup {
|
||||
self.render_with_class("")
|
||||
}
|
||||
}
|
||||
|
||||
impl UIWidget for StepperWidget {
|
||||
fn can_inherit(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn base_class(&self) -> Vec<String> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn extended_class(&self) -> Vec<String> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn render_with_class(&self, _: &str) -> maud::Markup {
|
||||
html! {
|
||||
@if self.icons {
|
||||
ol class="flex items-center w-full" {
|
||||
|
||||
@for (index, e) in self.elements.iter().enumerate() {
|
||||
@if index == (self.elements.len()-1) {
|
||||
(Self::build_final_icon(e))
|
||||
} @else if index <= self.progress as usize {
|
||||
(Self::build_done_icon(e))
|
||||
} @else {
|
||||
(Self::build_middle_icon(e))
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
} @else {
|
||||
|
||||
|
||||
ol class="flex items-center w-full text-sm font-medium text-center text-gray-500 dark:text-gray-400 sm:text-base" {
|
||||
|
||||
@for (index, e) in self.elements.iter().enumerate() {
|
||||
@if index == (self.elements.len()-1) {
|
||||
(Self::build_final(index, e))
|
||||
} @else if index <= self.progress as usize {
|
||||
(Self::build_done(e))
|
||||
} @else {
|
||||
(Self::build_middle(index, e))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO : impl tabs
|
||||
// https://flowbite.com/docs/components/tabs/
|
||||
// tabs (with inline + htmx content)
|
||||
pub struct TabWidget {
|
||||
pub htmx_content: Vec<(String, String)>,
|
||||
pub content: Vec<(String, Box<dyn UIWidget>)>,
|
||||
}
|
||||
|
||||
// TODO : Material Icons
|
||||
// TODO : Common SVG Icons
|
||||
|
|
56
src/ui/components/modal.rs
Normal file
56
src/ui/components/modal.rs
Normal file
|
@ -0,0 +1,56 @@
|
|||
use maud::{PreEscaped, Render, html};
|
||||
|
||||
use crate::ui::UIWidget;
|
||||
|
||||
// TODO : rework modal
|
||||
pub fn ModalCloseButton<T: UIWidget + 'static>(modal: &str, inner: T) -> PreEscaped<String> {
|
||||
html! {
|
||||
button
|
||||
data-modal-hide=(modal)
|
||||
type="button"
|
||||
class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800" { (inner) };
|
||||
}
|
||||
}
|
||||
|
||||
pub fn ModalOpenButton<T: UIWidget + 'static>(modal: &str, inner: T) -> PreEscaped<String> {
|
||||
html! {
|
||||
button
|
||||
data-modal-target=(modal)
|
||||
data-modal-toggle=(modal)
|
||||
class="block text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800" type="button" { (inner) };
|
||||
}
|
||||
}
|
||||
|
||||
pub fn Modal<T: UIWidget + 'static, E: UIWidget + 'static, F: FnOnce(String) -> E>(
|
||||
title: &str,
|
||||
body: T,
|
||||
footer: F,
|
||||
) -> (String, PreEscaped<String>) {
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
(format!("modal-{id}"), html! {
|
||||
div id=(format!("modal-{id}")) tabindex="-1" aria-hidden="true" class="hidden overflow-y-auto overflow-x-hidden fixed top-0 right-0 left-0 z-50 justify-center items-center w-full md:inset-0 h-[calc(100%-1rem)] max-h-full" {
|
||||
div class="relative p-4 w-full max-w-2xl max-h-full" {
|
||||
|
||||
div class="relative bg-white rounded-lg shadow dark:bg-gray-700" {
|
||||
div class="flex items-center justify-between p-4 md:p-5 border-b rounded-t dark:border-gray-600" {
|
||||
h3 class="text-xl font-semibold text-gray-900 dark:text-white" { (title) }
|
||||
button type="button" class="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm w-8 h-8 ms-auto inline-flex justify-center items-center dark:hover:bg-gray-600 dark:hover:text-white" data-modal-hide="default-modal" {
|
||||
svg class="w-3 h-3" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 14" {
|
||||
path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6" {};
|
||||
};
|
||||
span class="sr-only" { "Close modal" };
|
||||
}
|
||||
};
|
||||
|
||||
div class="p-4 md:p-5 space-y-4" {
|
||||
(body)
|
||||
};
|
||||
|
||||
div class="flex items-center p-4 md:p-5 border-t border-gray-200 rounded-b dark:border-gray-600" {
|
||||
(footer(format!("modal-{id}")))
|
||||
};
|
||||
};
|
||||
}};
|
||||
})
|
||||
}
|
334
src/ui/components/overlay.rs
Normal file
334
src/ui/components/overlay.rs
Normal file
|
@ -0,0 +1,334 @@
|
|||
#[allow(non_snake_case)]
|
||||
pub fn Popover<T: UIWidget + 'static, I: UIWidget + 'static>(on: T, inner: I) -> PopoverWidget {
|
||||
PopoverWidget {
|
||||
on: Box::new(on),
|
||||
inner: Box::new(inner),
|
||||
placement: Placement::Top,
|
||||
arrow: true,
|
||||
animated: true,
|
||||
trigger: PopoverTrigger::Hover,
|
||||
offset: 10,
|
||||
}
|
||||
}
|
||||
|
||||
pub enum PopoverTrigger {
|
||||
Click,
|
||||
Hover,
|
||||
}
|
||||
|
||||
pub struct PopoverWidget {
|
||||
on: Box<dyn UIWidget>,
|
||||
inner: Box<dyn UIWidget>,
|
||||
placement: Placement,
|
||||
arrow: bool,
|
||||
animated: bool,
|
||||
trigger: PopoverTrigger,
|
||||
offset: i32,
|
||||
}
|
||||
|
||||
impl PopoverWidget {
|
||||
pub fn place(mut self, placement: Placement) -> Self {
|
||||
self.placement = placement;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn no_arrow(mut self) -> Self {
|
||||
self.arrow = false;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn animate(mut self, value: bool) -> Self {
|
||||
self.animated = value;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn trigger(mut self, trigger: PopoverTrigger) -> Self {
|
||||
self.trigger = trigger;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn offset(mut self, offset: i32) -> Self {
|
||||
self.offset = offset;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for PopoverWidget {
|
||||
fn render(&self) -> maud::Markup {
|
||||
self.render_with_class("")
|
||||
}
|
||||
}
|
||||
|
||||
impl UIWidget for PopoverWidget {
|
||||
fn can_inherit(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn base_class(&self) -> Vec<String> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn extended_class(&self) -> Vec<String> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn render_with_class(&self, class: &str) -> maud::Markup {
|
||||
let id = format!("popover-{}", uuid::Uuid::new_v4().to_string());
|
||||
|
||||
let el_class = "absolute z-10 invisible inline-block w-fit p-2 text-sm text-gray-500 bg-white border border-gray-200 rounded-lg shadow-xs opacity-0 dark:text-gray-400 dark:border-gray-600 dark:bg-gray-800";
|
||||
|
||||
let el_class = if self.animated {
|
||||
format!("{el_class} transition-opacity duration-200")
|
||||
} else {
|
||||
el_class.to_string()
|
||||
};
|
||||
|
||||
let trigger = match self.trigger {
|
||||
PopoverTrigger::Click => "click",
|
||||
PopoverTrigger::Hover => "hover",
|
||||
};
|
||||
|
||||
html! {
|
||||
|
||||
div data-popover-target=(id)
|
||||
data-popover-trigger=(trigger)
|
||||
data-popover-placement=(self.placement.to_value())
|
||||
data-popover-offset=(self.offset)
|
||||
class=(format!("{class} my-auto max-w-fit")) { (self.on) };
|
||||
|
||||
div data-popover id=(id) role="tooltip" class=(el_class) {
|
||||
(self.inner)
|
||||
|
||||
@if self.arrow {
|
||||
div data-popper-arrow {};
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
use maud::{Render, html};
|
||||
|
||||
use crate::ui::UIWidget;
|
||||
|
||||
#[allow(non_snake_case)]
|
||||
pub fn Tooltip<T: UIWidget + 'static, I: UIWidget + 'static>(on: T, inner: I) -> TooltipWidget {
|
||||
TooltipWidget {
|
||||
on: Box::new(on),
|
||||
inner: Box::new(inner),
|
||||
placement: Placement::Top,
|
||||
arrow: true,
|
||||
dark: true,
|
||||
animated: true,
|
||||
}
|
||||
}
|
||||
|
||||
pub enum Placement {
|
||||
Left,
|
||||
Right,
|
||||
Top,
|
||||
Bottom,
|
||||
}
|
||||
|
||||
impl Placement {
|
||||
pub fn to_value(&self) -> &str {
|
||||
match *self {
|
||||
Placement::Left => "left",
|
||||
Placement::Right => "right",
|
||||
Placement::Top => "top",
|
||||
Placement::Bottom => "bottom",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct TooltipWidget {
|
||||
on: Box<dyn UIWidget>,
|
||||
inner: Box<dyn UIWidget>,
|
||||
placement: Placement,
|
||||
arrow: bool,
|
||||
dark: bool,
|
||||
animated: bool,
|
||||
}
|
||||
|
||||
impl TooltipWidget {
|
||||
pub fn place(mut self, placement: Placement) -> Self {
|
||||
self.placement = placement;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn no_arrow(mut self) -> Self {
|
||||
self.arrow = false;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn animate(mut self, value: bool) -> Self {
|
||||
self.animated = value;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn white(mut self) -> Self {
|
||||
self.dark = false;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for TooltipWidget {
|
||||
fn render(&self) -> maud::Markup {
|
||||
self.render_with_class("")
|
||||
}
|
||||
}
|
||||
|
||||
impl UIWidget for TooltipWidget {
|
||||
fn can_inherit(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn base_class(&self) -> Vec<String> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn extended_class(&self) -> Vec<String> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn render_with_class(&self, class: &str) -> maud::Markup {
|
||||
let id = format!("tooltip-{}", uuid::Uuid::new_v4().to_string());
|
||||
|
||||
let tt_class = match self.dark {
|
||||
true => {
|
||||
"absolute z-10 invisible inline-block px-3 py-2 text-sm font-medium text-white bg-gray-900 rounded-lg shadow-xs opacity-0 tooltip dark:bg-gray-700"
|
||||
}
|
||||
false => {
|
||||
"absolute z-10 invisible inline-block px-3 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-lg shadow-xs opacity-0 tooltip"
|
||||
}
|
||||
};
|
||||
|
||||
let tt_class = if self.animated {
|
||||
format!("{tt_class} transition-opacity duration-200")
|
||||
} else {
|
||||
tt_class.to_string()
|
||||
};
|
||||
|
||||
html! {
|
||||
@if self.dark {
|
||||
div data-tooltip-target=(id) data-tooltip-placement=(self.placement.to_value())
|
||||
class=(format!("{class} my-auto max-w-fit")) { (self.on) };
|
||||
} @else {
|
||||
div data-tooltip-target=(id) data-tooltip-style="light" data-tooltip-placement=(self.placement.to_value())
|
||||
class=(format!("{class} my-auto max-w-fit")) { (self.on) };
|
||||
}
|
||||
|
||||
div id=(id) role="tooltip" class=(tt_class) {
|
||||
(self.inner)
|
||||
@if self.arrow {
|
||||
div class="tooltip-arrow" data-popper-arrow {};
|
||||
}
|
||||
}}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(non_snake_case)]
|
||||
pub fn DropDown<T: UIWidget + 'static, I: UIWidget + 'static>(on: T, inner: I) -> DropDownWidget {
|
||||
DropDownWidget {
|
||||
on: Box::new(on),
|
||||
inner: Box::new(inner),
|
||||
delay: 0,
|
||||
placement: Placement::Bottom,
|
||||
trigger: PopoverTrigger::Click,
|
||||
distance: 10,
|
||||
skidding: 0,
|
||||
stretch: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub struct DropDownWidget {
|
||||
on: Box<dyn UIWidget>,
|
||||
inner: Box<dyn UIWidget>,
|
||||
placement: Placement,
|
||||
delay: u32,
|
||||
trigger: PopoverTrigger,
|
||||
distance: u32,
|
||||
skidding: u32,
|
||||
stretch: bool,
|
||||
}
|
||||
|
||||
impl DropDownWidget {
|
||||
pub fn place(mut self, placement: Placement) -> Self {
|
||||
self.placement = placement;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn delay(mut self, delay: u32) -> Self {
|
||||
self.delay = delay;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn stretch(mut self) -> Self {
|
||||
self.stretch = true;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn distance(mut self, distance: u32) -> Self {
|
||||
self.distance = distance;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn skidding(mut self, skidding: u32) -> Self {
|
||||
self.skidding = skidding;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn trigger(mut self, trigger: PopoverTrigger) -> Self {
|
||||
self.trigger = trigger;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for DropDownWidget {
|
||||
fn render(&self) -> maud::Markup {
|
||||
self.render_with_class("")
|
||||
}
|
||||
}
|
||||
|
||||
impl UIWidget for DropDownWidget {
|
||||
fn can_inherit(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn base_class(&self) -> Vec<String> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn extended_class(&self) -> Vec<String> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn render_with_class(&self, class: &str) -> maud::Markup {
|
||||
let id = format!("dropdown-{}", uuid::Uuid::new_v4().to_string());
|
||||
|
||||
let trigger = match self.trigger {
|
||||
PopoverTrigger::Click => "click",
|
||||
PopoverTrigger::Hover => "hover",
|
||||
};
|
||||
|
||||
html! {
|
||||
div
|
||||
data-dropdown-toggle=(id)
|
||||
data-dropdown-trigger=(trigger)
|
||||
data-dropdown-delay=(self.delay)
|
||||
data-dropdown-placement=(self.placement.to_value())
|
||||
data-dropdown-offset-distance=(self.distance)
|
||||
data-dropdown-offset-skidding=(self.skidding)
|
||||
class=(format!("{class} hover:cursor-pointer my-auto{}", if self.stretch { "" } else { " max-w-fit" })) {
|
||||
(self.on)
|
||||
};
|
||||
|
||||
div id=(id) class="z-50 hidden bg-white divide-y divide-gray-100 rounded-lg shadow-sm w-44 dark:bg-gray-700" {
|
||||
ul class="py-2 text-sm text-gray-700 dark:text-gray-200" {
|
||||
(self.inner)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
108
src/ui/components/pagination.rs
Normal file
108
src/ui/components/pagination.rs
Normal file
|
@ -0,0 +1,108 @@
|
|||
use maud::{PreEscaped, html};
|
||||
|
||||
pub struct Pagination {
|
||||
icons: bool,
|
||||
}
|
||||
|
||||
// TODO : With pager support
|
||||
impl Pagination {
|
||||
pub fn no_icons(mut self) -> Self {
|
||||
self.icons = false;
|
||||
self
|
||||
}
|
||||
|
||||
fn build_page_link_active(page_num: usize, link: &str) -> PreEscaped<String> {
|
||||
html! {
|
||||
li {
|
||||
a href=(link) aria-current="page" class="flex items-center justify-center px-3 h-8 text-blue-600 border border-gray-300 bg-blue-50 hover:bg-blue-100 hover:text-blue-700 dark:border-gray-700 dark:bg-gray-700 dark:text-white" { (page_num) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn build_page_link(page_num: usize, link: &str) -> PreEscaped<String> {
|
||||
html! {
|
||||
li {
|
||||
a href=(link) class="flex items-center justify-center px-3 h-8 leading-tight text-gray-500 bg-white border border-gray-300 hover:bg-gray-100 hover:text-gray-700 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white" { (page_num) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn build(&self) -> PreEscaped<String> {
|
||||
html! {
|
||||
nav {
|
||||
ul class="inline-flex -space-x-px text-sm" {
|
||||
li {
|
||||
a href="#" class="flex items-center justify-center px-3 h-8 ms-0 leading-tight text-gray-500 bg-white border border-e-0 border-gray-300 rounded-s-lg hover:bg-gray-100 hover:text-gray-700 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white" {
|
||||
@if self.icons {
|
||||
span class="sr-only" { "Previous" };
|
||||
svg class="w-2.5 h-2.5 rtl:rotate-180" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 6 10" {
|
||||
path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 1 1 5l4 4" {};
|
||||
}
|
||||
} @else {
|
||||
"Previous"
|
||||
}
|
||||
|
||||
};
|
||||
}
|
||||
|
||||
(Self::build_page_link(1, "#"))
|
||||
(Self::build_page_link(2, "#"))
|
||||
(Self::build_page_link_active(3, "#"))
|
||||
(Self::build_page_link(4, "#"))
|
||||
(Self::build_page_link(5, "#"))
|
||||
|
||||
li {
|
||||
a href="#" class="flex items-center justify-center px-3 h-8 leading-tight text-gray-500 bg-white border border-gray-300 rounded-e-lg hover:bg-gray-100 hover:text-gray-700 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white" {
|
||||
|
||||
@if self.icons {
|
||||
span class="sr-only" { "Next" };
|
||||
svg class="w-2.5 h-2.5 rtl:rotate-180" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 6 10" {
|
||||
path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 9 4-4-4-4" {};
|
||||
}
|
||||
} @else {
|
||||
"Next"
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
};
|
||||
};
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO : work with pager
|
||||
pub fn PaginationButtonsOnly(previous: bool, next: bool) -> PreEscaped<String> {
|
||||
let buttons = html! {
|
||||
@if previous {
|
||||
a href="#" class="flex items-center justify-center px-3 h-8 me-3 text-sm font-medium text-gray-500 bg-white border border-gray-300 rounded-lg hover:bg-gray-100 hover:text-gray-700 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white" {
|
||||
svg class="w-3.5 h-3.5 me-2 rtl:rotate-180" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 10" {
|
||||
path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 5H1m0 0 4 4M1 5l4-4" {};
|
||||
}
|
||||
"Previous"
|
||||
}
|
||||
};
|
||||
|
||||
@if next {
|
||||
a href="#" class="flex items-center justify-center px-3 h-8 text-sm font-medium text-gray-500 bg-white border border-gray-300 rounded-lg hover:bg-gray-100 hover:text-gray-700 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white" {
|
||||
"Next"
|
||||
svg class="w-3.5 h-3.5 ms-2 rtl:rotate-180" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 10" {
|
||||
path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M1 5h12m0 0L9 1m4 4L9 9" {};
|
||||
}
|
||||
};
|
||||
}};
|
||||
|
||||
html! {
|
||||
@if previous {
|
||||
div class="flex justify-between" {
|
||||
(buttons)
|
||||
};
|
||||
} @else {
|
||||
div class="flex justify-end" {
|
||||
(buttons)
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
147
src/ui/components/placeholder.rs
Normal file
147
src/ui/components/placeholder.rs
Normal file
|
@ -0,0 +1,147 @@
|
|||
use maud::{PreEscaped, html};
|
||||
|
||||
#[allow(non_snake_case)]
|
||||
pub fn Placeholder() -> PreEscaped<String> {
|
||||
html! {
|
||||
div role="status" class="max-w-sm animate-pulse" {
|
||||
div class="h-2.5 bg-gray-200 rounded-full dark:bg-gray-700 w-48 mb-4" {};
|
||||
div class="h-2 bg-gray-200 rounded-full dark:bg-gray-700 max-w-[360px] mb-2.5" {};
|
||||
div class="h-2 bg-gray-200 rounded-full dark:bg-gray-700 mb-2.5" {};
|
||||
div class="h-2 bg-gray-200 rounded-full dark:bg-gray-700 max-w-[330px] mb-2.5" {};
|
||||
div class="h-2 bg-gray-200 rounded-full dark:bg-gray-700 max-w-[300px] mb-2.5" {};
|
||||
div class="h-2 bg-gray-200 rounded-full dark:bg-gray-700 max-w-[360px]" {};
|
||||
span class="sr-only" { "Loading..." };
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(non_snake_case)]
|
||||
pub fn ImagePlaceholder() -> PreEscaped<String> {
|
||||
html! {
|
||||
div class="flex items-center justify-center w-full h-48 animate-pulse bg-gray-300 rounded-sm sm:w-96 dark:bg-gray-700" {
|
||||
svg class="w-10 h-10 text-gray-200 dark:text-gray-600" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 20 18" {
|
||||
path d="M18 0H2a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2Zm-5.5 4a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3Zm4.376 10.481A1 1 0 0 1 16 15H4a1 1 0 0 1-.895-1.447l3.5-7A1 1 0 0 1 7.468 6a.965.965 0 0 1 .9.5l2.775 4.757 1.546-1.887a1 1 0 0 1 1.618.1l2.541 4a1 1 0 0 1 .028 1.011Z" {};
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(non_snake_case)]
|
||||
pub fn VideoPlaceholder() -> PreEscaped<String> {
|
||||
html! {
|
||||
div role="status" class="flex items-center justify-center h-56 max-w-sm bg-gray-300 rounded-lg animate-pulse dark:bg-gray-700" {
|
||||
svg class="w-10 h-10 text-gray-200 dark:text-gray-600" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 16 20" {
|
||||
path d="M5 5V.13a2.96 2.96 0 0 0-1.293.749L.879 3.707A2.98 2.98 0 0 0 .13 5H5Z" {}
|
||||
path d="M14.066 0H7v5a2 2 0 0 1-2 2H0v11a1.97 1.97 0 0 0 1.934 2h12.132A1.97 1.97 0 0 0 16 18V2a1.97 1.97 0 0 0-1.934-2ZM9 13a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-2a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v2Zm4 .382a1 1 0 0 1-1.447.894L10 13v-2l1.553-1.276a1 1 0 0 1 1.447.894v2.764Z" {};
|
||||
};
|
||||
span class="sr-only" { "Loading..." }
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(non_snake_case)]
|
||||
pub fn TextPlaceholder() -> PreEscaped<String> {
|
||||
html! {
|
||||
div role="status" class="space-y-2.5 animate-pulse max-w-lg" {
|
||||
div class="flex items-center w-full" {
|
||||
div class="h-2.5 bg-gray-200 rounded-full dark:bg-gray-700 w-32" {};
|
||||
div class="h-2.5 ms-2 bg-gray-300 rounded-full dark:bg-gray-600 w-24" {};
|
||||
div class="h-2.5 ms-2 bg-gray-300 rounded-full dark:bg-gray-600 w-full" {};
|
||||
};
|
||||
|
||||
div class="flex items-center w-full max-w-[480px]" {
|
||||
div class="h-2.5 bg-gray-200 rounded-full dark:bg-gray-700 w-full" {};
|
||||
div class="h-2.5 ms-2 bg-gray-300 rounded-full dark:bg-gray-600 w-full" {};
|
||||
div class="h-2.5 ms-2 bg-gray-300 rounded-full dark:bg-gray-600 w-24" {};
|
||||
};
|
||||
|
||||
div class="flex items-center w-full max-w-[400px]" {
|
||||
div class="h-2.5 bg-gray-300 rounded-full dark:bg-gray-600 w-full" {};
|
||||
div class="h-2.5 ms-2 bg-gray-200 rounded-full dark:bg-gray-700 w-80" {};
|
||||
div class="h-2.5 ms-2 bg-gray-300 rounded-full dark:bg-gray-600 w-full" {};
|
||||
};
|
||||
|
||||
div class="flex items-center w-full max-w-[480px]" {
|
||||
div class="h-2.5 ms-2 bg-gray-200 rounded-full dark:bg-gray-700 w-full" {};
|
||||
div class="h-2.5 ms-2 bg-gray-300 rounded-full dark:bg-gray-600 w-full" {};
|
||||
div class="h-2.5 ms-2 bg-gray-300 rounded-full dark:bg-gray-600 w-24" {};
|
||||
};
|
||||
|
||||
div class="flex items-center w-full max-w-[440px]" {
|
||||
div class="h-2.5 ms-2 bg-gray-300 rounded-full dark:bg-gray-600 w-32" {};
|
||||
div class="h-2.5 ms-2 bg-gray-300 rounded-full dark:bg-gray-600 w-24" {};
|
||||
div class="h-2.5 ms-2 bg-gray-200 rounded-full dark:bg-gray-700 w-full" {};
|
||||
};
|
||||
|
||||
div class="flex items-center w-full max-w-[360px]" {
|
||||
div class="h-2.5 ms-2 bg-gray-300 rounded-full dark:bg-gray-600 w-full" {};
|
||||
div class="h-2.5 ms-2 bg-gray-200 rounded-full dark:bg-gray-700 w-80" {};
|
||||
div class="h-2.5 ms-2 bg-gray-300 rounded-full dark:bg-gray-600 w-full" {};
|
||||
};
|
||||
span class="sr-only" { "Loading..." };
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(non_snake_case)]
|
||||
pub fn CardPlaceholder() -> PreEscaped<String> {
|
||||
html! {
|
||||
div role="status" class="max-w-sm p-4 border border-gray-200 rounded-sm shadow-sm animate-pulse md:p-6 dark:border-gray-700" {
|
||||
div class="flex items-center justify-center h-48 mb-4 bg-gray-300 rounded-sm dark:bg-gray-700" {
|
||||
svg class="w-10 h-10 text-gray-200 dark:text-gray-600" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 16 20" {
|
||||
path d="M14.066 0H7v5a2 2 0 0 1-2 2H0v11a1.97 1.97 0 0 0 1.934 2h12.132A1.97 1.97 0 0 0 16 18V2a1.97 1.97 0 0 0-1.934-2ZM10.5 6a1.5 1.5 0 1 1 0 2.999A1.5 1.5 0 0 1 10.5 6Zm2.221 10.515a1 1 0 0 1-.858.485h-8a1 1 0 0 1-.9-1.43L5.6 10.039a.978.978 0 0 1 .936-.57 1 1 0 0 1 .9.632l1.181 2.981.541-1a.945.945 0 0 1 .883-.522 1 1 0 0 1 .879.529l1.832 3.438a1 1 0 0 1-.031.988Z" {};
|
||||
path d="M5 5V.13a2.96 2.96 0 0 0-1.293.749L.879 3.707A2.98 2.98 0 0 0 .13 5H5Z" {};
|
||||
}
|
||||
}
|
||||
div class="h-2.5 bg-gray-200 rounded-full dark:bg-gray-700 w-48 mb-4" {};
|
||||
div class="h-2 bg-gray-200 rounded-full dark:bg-gray-700 mb-2.5" {};
|
||||
div class="h-2 bg-gray-200 rounded-full dark:bg-gray-700 mb-2.5" {};
|
||||
div class="h-2 bg-gray-200 rounded-full dark:bg-gray-700" {};
|
||||
span class="sr-only" { "Loading..." };
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(non_snake_case)]
|
||||
pub fn ListPlaceholder() -> PreEscaped<String> {
|
||||
html! {
|
||||
div role="status" class="max-w-md p-4 space-y-4 border border-gray-200 divide-y divide-gray-200 rounded-sm shadow-sm animate-pulse dark:divide-gray-700 md:p-6 dark:border-gray-700" {
|
||||
div class="flex items-center justify-between" {
|
||||
div {
|
||||
div class="h-2.5 bg-gray-300 rounded-full dark:bg-gray-600 w-24 mb-2.5" {};
|
||||
div class="w-32 h-2 bg-gray-200 rounded-full dark:bg-gray-700" {};
|
||||
}
|
||||
div class="h-2.5 bg-gray-300 rounded-full dark:bg-gray-700 w-12" {};
|
||||
}
|
||||
div class="flex items-center justify-between pt-4" {
|
||||
div {
|
||||
div class="h-2.5 bg-gray-300 rounded-full dark:bg-gray-600 w-24 mb-2.5" {};
|
||||
div class="w-32 h-2 bg-gray-200 rounded-full dark:bg-gray-700" {};
|
||||
};
|
||||
div class="h-2.5 bg-gray-300 rounded-full dark:bg-gray-700 w-12" {};
|
||||
}
|
||||
div class="flex items-center justify-between pt-4" {
|
||||
div {
|
||||
div class="h-2.5 bg-gray-300 rounded-full dark:bg-gray-600 w-24 mb-2.5" {};
|
||||
div class="w-32 h-2 bg-gray-200 rounded-full dark:bg-gray-700" {};
|
||||
}
|
||||
div class="h-2.5 bg-gray-300 rounded-full dark:bg-gray-700 w-12" {};
|
||||
}
|
||||
div class="flex items-center justify-between pt-4" {
|
||||
div {
|
||||
div class="h-2.5 bg-gray-300 rounded-full dark:bg-gray-600 w-24 mb-2.5" {};
|
||||
div class="w-32 h-2 bg-gray-200 rounded-full dark:bg-gray-700" {};
|
||||
};
|
||||
div class="h-2.5 bg-gray-300 rounded-full dark:bg-gray-700 w-12" {};
|
||||
}
|
||||
div class="flex items-center justify-between pt-4" {
|
||||
div {
|
||||
div class="h-2.5 bg-gray-300 rounded-full dark:bg-gray-600 w-24 mb-2.5" {};
|
||||
div class="w-32 h-2 bg-gray-200 rounded-full dark:bg-gray-700" {};
|
||||
};
|
||||
div class="h-2.5 bg-gray-300 rounded-full dark:bg-gray-700 w-12" {};
|
||||
}
|
||||
span class="sr-only" { "Loading..." };
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
use maud::{PreEscaped, html};
|
||||
use maud::{PreEscaped, Render, html};
|
||||
|
||||
use crate::{
|
||||
request::{RequestContext, StringResponse},
|
||||
|
@ -11,11 +11,14 @@ use crate::{
|
|||
pub struct Shell {
|
||||
/// The HTML content for the `<head>` section of the page.
|
||||
head: PreEscaped<String>,
|
||||
/// An optional class attribute for the `<body>` element.
|
||||
body_class: String,
|
||||
/// An optional class attribute for the main container element.
|
||||
main_class: String,
|
||||
/// The HTML content for the static body portion.
|
||||
body_content: PreEscaped<String>,
|
||||
ui: bool,
|
||||
bottom_nav: Option<PreEscaped<String>>,
|
||||
sidebar: Option<SidebarWidget>,
|
||||
navbar: Option<NavBarWidget>,
|
||||
}
|
||||
|
||||
impl Shell {
|
||||
|
@ -36,12 +39,30 @@ impl Shell {
|
|||
) -> Self {
|
||||
Self {
|
||||
head: head.render(),
|
||||
body_class: body_class.extended_class().join(" "),
|
||||
main_class: body_class.extended_class().join(" "),
|
||||
body_content: body_content.render(),
|
||||
ui: false,
|
||||
bottom_nav: None,
|
||||
sidebar: None,
|
||||
navbar: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_navbar(mut self, navbar: NavBarWidget) -> Self {
|
||||
self.navbar = Some(navbar);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_bottom_navigation(mut self, bottom_nav: PreEscaped<String>) -> Self {
|
||||
self.bottom_nav = Some(bottom_nav);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_sidebar(mut self, inner: PreEscaped<String>) -> Self {
|
||||
self.sidebar = Some(Sidebar(inner));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn use_ui(mut self) -> Self {
|
||||
self.ui = true;
|
||||
self
|
||||
|
@ -57,36 +78,53 @@ impl Shell {
|
|||
/// A `PreEscaped<String>` containing the full HTML page content.
|
||||
#[must_use]
|
||||
pub fn render(&self, content: PreEscaped<String>, title: &str) -> PreEscaped<String> {
|
||||
let mut main_class = self.main_class.clone();
|
||||
|
||||
if self.bottom_nav.is_some() {
|
||||
main_class.push_str(" pb-20");
|
||||
}
|
||||
|
||||
if self.sidebar.is_some() {
|
||||
main_class.push_str(" ml-[264px]");
|
||||
}
|
||||
|
||||
html! {
|
||||
html {
|
||||
head {
|
||||
title { (title) };
|
||||
@if self.ui {
|
||||
script src="https://cdn.tailwindcss.com" {};
|
||||
script src="/assets/htmx.min.js" {};
|
||||
script src="/assets/flowbite.min.js" {};
|
||||
link href="/assets/flowbite.min.css" rel="stylesheet" {};
|
||||
meta name="viewport" content="width=device-width, initial-scale=1.0";
|
||||
(maud::DOCTYPE)
|
||||
html {
|
||||
head {
|
||||
title { (title) };
|
||||
@if self.ui {
|
||||
script src="https://cdn.tailwindcss.com" {};
|
||||
script src="/assets/htmx.min.js" {};
|
||||
script src="/assets/flowbite.min.js" {};
|
||||
link href="/assets/flowbite.min.css" rel="stylesheet" {};
|
||||
meta name="viewport" content="width=device-width, initial-scale=1.0";
|
||||
};
|
||||
(self.head)
|
||||
};
|
||||
(self.head)
|
||||
};
|
||||
@if !self.body_class.is_empty() {
|
||||
body class=(self.body_class) {
|
||||
(self.body_content);
|
||||
|
||||
div id="main_content" {
|
||||
(content)
|
||||
};
|
||||
};
|
||||
} @else {
|
||||
body {
|
||||
(self.body_content);
|
||||
|
||||
div id="main_content" {
|
||||
(content)
|
||||
};
|
||||
};
|
||||
}
|
||||
body class=(self.main_class) {
|
||||
(PreEscaped(self.navbar.as_ref().map(|x| {
|
||||
if self.sidebar.is_some() {
|
||||
x.clone().sticky().render().0.clone()
|
||||
} else {
|
||||
x.render().0.clone()
|
||||
}
|
||||
}).unwrap_or_default()));
|
||||
div id="notification_area" class="fixed top-0 start-0 z-50 w-full p-4" {
|
||||
|
||||
}
|
||||
|
||||
(PreEscaped(self.sidebar.as_ref().map(|x| x.render(self.navbar.is_some()).0).unwrap_or_default()))
|
||||
|
||||
(self.body_content);
|
||||
div id="main_content" class=(main_class) {
|
||||
(content)
|
||||
};
|
||||
|
||||
(PreEscaped(self.bottom_nav.as_ref().map(|x| x.0.as_str()).unwrap_or_default()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -125,3 +163,275 @@ impl Shell {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// NOCOMMIT
|
||||
|
||||
// TODO : Integrate into shell
|
||||
|
||||
pub fn Drawer<T: UIWidget + 'static>(inner: T) -> PreEscaped<String> {
|
||||
html! {
|
||||
// Toggle
|
||||
div class="text-center" {
|
||||
button class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 mb-2 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800" type="button" data-drawer-target="drawer-example" data-drawer-show="drawer-example" aria-controls="drawer-example" { "Show drawer" };
|
||||
};
|
||||
|
||||
div id="drawer-example" class="fixed top-0 left-0 z-40 h-screen p-4 overflow-y-auto transition-transform -translate-x-full bg-white w-80 dark:bg-gray-800" tabindex="-1" aria-labelledby="drawer-label" {
|
||||
h5 id="drawer-label" class="inline-flex items-center mb-4 text-base font-semibold text-gray-500 dark:text-gray-400" {
|
||||
svg class="w-4 h-4 me-2.5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 20 20" {
|
||||
path d="M10 .5a9.5 9.5 0 1 0 9.5 9.5A9.51 9.51 0 0 0 10 .5ZM9.5 4a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3ZM12 15H8a1 1 0 0 1 0-2h1v-3H8a1 1 0 0 1 0-2h2a1 1 0 0 1 1 1v4h1a1 1 0 0 1 0 2Z" {};
|
||||
}
|
||||
"Info"
|
||||
};
|
||||
button type="button" data-drawer-hide="drawer-example" aria-controls="drawer-example" class="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm w-8 h-8 absolute top-2.5 end-2.5 flex items-center justify-center dark:hover:bg-gray-600 dark:hover:text-white" {
|
||||
svg class="w-3 h-3" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 14" {
|
||||
path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6" {};
|
||||
};
|
||||
span class="sr-only" { "Close menu" };
|
||||
};
|
||||
|
||||
(inner)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
pub fn BottomNavigation<T: UIWidget + 'static>(inner: &[T]) -> PreEscaped<String> {
|
||||
let elements_len = inner.len();
|
||||
|
||||
html! {
|
||||
div class="fixed bottom-0 left-0 z-50 w-full h-16 bg-white border-t border-gray-200 dark:bg-gray-700 dark:border-gray-600" {
|
||||
div class=(format!("grid h-full max-w-lg grid-cols-{elements_len} mx-auto font-medium")) {
|
||||
@for item in inner {
|
||||
(item)
|
||||
}
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
pub fn BottomNavigationTile<T: UIWidget + 'static>(
|
||||
icon: Option<T>,
|
||||
text: &str,
|
||||
) -> PreEscaped<String> {
|
||||
html! {
|
||||
button type="button" class="inline-flex flex-col items-center justify-center px-5 hover:bg-gray-50 dark:hover:bg-gray-800 group" {
|
||||
(icon.map(|x| x.render()).unwrap_or_default());
|
||||
span class="text-sm text-gray-500 dark:text-gray-400 group-hover:text-blue-600 dark:group-hover:text-blue-500" { (text) };
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// TODO : NavBar integration with auth system
|
||||
|
||||
pub fn NavBar(title: &str) -> NavBarWidget {
|
||||
NavBarWidget {
|
||||
icon: None,
|
||||
name: title.to_string(),
|
||||
menu: None,
|
||||
user: None,
|
||||
no_dropdown: false,
|
||||
centered: false,
|
||||
sticky: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct NavBarWidget {
|
||||
icon: Option<PreEscaped<String>>,
|
||||
name: String,
|
||||
menu: Option<PreEscaped<String>>,
|
||||
user: Option<PreEscaped<String>>,
|
||||
no_dropdown: bool,
|
||||
centered: bool,
|
||||
sticky: bool,
|
||||
}
|
||||
|
||||
impl NavBarWidget {
|
||||
pub fn icon<T: UIWidget + 'static>(mut self, icon: T) -> Self {
|
||||
self.icon = Some(icon.render());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn sticky(mut self) -> Self {
|
||||
self.sticky = true;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn menu<T: UIWidget + 'static>(mut self, menu: T) -> Self {
|
||||
self.menu = Some(menu.render());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn extra<T: UIWidget + 'static>(mut self, extra: T) -> Self {
|
||||
self.user = Some(extra.render());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn no_dropdown(mut self) -> Self {
|
||||
self.no_dropdown = true;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn center(mut self) -> Self {
|
||||
self.centered = true;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for NavBarWidget {
|
||||
fn render(&self) -> maud::Markup {
|
||||
self.render_with_class("")
|
||||
}
|
||||
}
|
||||
|
||||
impl UIWidget for NavBarWidget {
|
||||
fn can_inherit(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn base_class(&self) -> Vec<String> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn extended_class(&self) -> Vec<String> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn render_with_class(&self, _: &str) -> maud::Markup {
|
||||
let div_class = if self.centered {
|
||||
"max-w-screen-xl flex flex-wrap items-center justify-between mx-auto p-4"
|
||||
} else {
|
||||
"flex flex-wrap items-center justify-between mx-auto p-4"
|
||||
};
|
||||
|
||||
let nav_class = if self.sticky {
|
||||
"sticky top-0 bg-white border-gray-200 dark:bg-gray-900"
|
||||
} else {
|
||||
"bg-white border-gray-200 dark:bg-gray-900"
|
||||
};
|
||||
|
||||
html! {
|
||||
nav class=(nav_class) {
|
||||
div class=(div_class) {
|
||||
a href="/" class="flex items-center space-x-3 rtl:space-x-reverse" {
|
||||
div class="h-8" {
|
||||
(PreEscaped(self.icon.as_ref().map(|x| x.render().0).unwrap_or_default()))
|
||||
};
|
||||
span class="self-center text-2xl font-semibold whitespace-nowrap dark:text-white" { (self.name) };
|
||||
};
|
||||
div class="flex items-center md:order-2 space-x-3 md:space-x-0 rtl:space-x-reverse" {
|
||||
(PreEscaped(self.user.as_ref().map(|x| x.render().0).unwrap_or_default()))
|
||||
|
||||
@if !self.no_dropdown {
|
||||
button data-collapse-toggle="navbar-user" type="button" class="inline-flex items-center p-2 w-10 h-10 justify-center text-sm text-gray-500 rounded-lg md:hidden hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-gray-200 dark:text-gray-400 dark:hover:bg-gray-700 dark:focus:ring-gray-600" aria-controls="navbar-user" aria-expanded="false" {
|
||||
span class="sr-only" { "Open main menu" };
|
||||
svg class="w-5 h-5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 17 14" {
|
||||
path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M1 1h15M1 7h15M1 13h15" {};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
div class="items-center justify-between hidden w-full md:flex md:w-auto md:order-1" id="navbar-user" {
|
||||
ul class="flex flex-col font-medium p-4 md:p-0 mt-4 border border-gray-100 rounded-lg bg-gray-50 md:space-x-8 rtl:space-x-reverse md:flex-row md:mt-0 md:border-0 md:bg-white dark:bg-gray-800 md:dark:bg-gray-900 dark:border-gray-700" {
|
||||
(PreEscaped(self.menu.as_ref().map(|x| x.render().0).unwrap_or_default()))
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SidebarWidget {
|
||||
inner: PreEscaped<String>,
|
||||
}
|
||||
|
||||
impl SidebarWidget {
|
||||
pub fn render(&self, has_navbar: bool) -> PreEscaped<String> {
|
||||
let class = match has_navbar {
|
||||
true => {
|
||||
"fixed left-0 w-64 h-screen transition-transform -translate-x-full sm:translate-x-0 z-50"
|
||||
}
|
||||
false => {
|
||||
"fixed top-0 left-0 w-64 h-screen transition-transform -translate-x-full sm:translate-x-0 z-50"
|
||||
}
|
||||
};
|
||||
|
||||
html! {
|
||||
aside id="default-sidebar" class=(class) aria-label="Sidebar" {
|
||||
div class="h-full px-3 py-4 overflow-y-auto bg-gray-50 dark:bg-gray-800" {
|
||||
ul class="space-y-2 font-medium" {
|
||||
(self.inner)
|
||||
}
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn Sidebar<T: UIWidget + 'static>(inner: T) -> SidebarWidget {
|
||||
SidebarWidget {
|
||||
inner: inner.render(),
|
||||
}
|
||||
}
|
||||
|
||||
// https://flowbite.com/docs/components/speed-dial/
|
||||
// TODO : speed dial (circle/square) (postioning) (buttons opt with text)
|
||||
|
||||
pub enum Position {
|
||||
TopLeft,
|
||||
TopRight,
|
||||
BottomLeft,
|
||||
BottomRight,
|
||||
}
|
||||
|
||||
pub enum Alignment {
|
||||
Horizontal,
|
||||
Vertical,
|
||||
}
|
||||
|
||||
pub struct SpeedDialWidget {
|
||||
buttons: Vec<Box<dyn UIWidget>>,
|
||||
square: bool,
|
||||
position: Position,
|
||||
alignment: Alignment,
|
||||
}
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! page {
|
||||
($shell:ident, $ctx:ident, $title:literal, $content:expr) => {{
|
||||
let content = $content.render();
|
||||
$shell.render_page(content, $title, $ctx).await
|
||||
}};
|
||||
($shell:ident, $ctx:ident, $title:ident, $content:expr) => {{
|
||||
let content = $content.render();
|
||||
$shell.render_page(content, $title, $ctx).await
|
||||
}};
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
// https://flowbite.com/docs/components/toast/
|
||||
// TODO : impl toast
|
||||
// icons + bg color
|
||||
// positioning
|
||||
// undo btn toast
|
||||
|
||||
pub fn Toast<T: UIWidget + 'static>(icon: T, text: &str) -> PreEscaped<String> {
|
||||
html! {
|
||||
div id="toast-default" class="flex items-center w-full max-w-xs p-4 text-gray-500 bg-white rounded-lg shadow dark:text-gray-400 dark:bg-gray-800" role="alert" {
|
||||
div class="inline-flex items-center justify-center flex-shrink-0 w-8 h-8 text-blue-500 bg-blue-100 rounded-lg dark:bg-blue-800 dark:text-blue-200" {
|
||||
(icon)
|
||||
};
|
||||
|
||||
div class="ms-3 text-sm font-normal" { (text) };
|
||||
|
||||
button type="button" class="ms-auto -mx-1.5 -my-1.5 bg-white text-gray-400 hover:text-gray-900 rounded-lg focus:ring-2 focus:ring-gray-300 p-1.5 hover:bg-gray-100 inline-flex items-center justify-center h-8 w-8 dark:text-gray-500 dark:hover:text-white dark:bg-gray-800 dark:hover:bg-gray-700" data-dismiss-target="#toast-default" aria-label="Close" {
|
||||
span class="sr-only" { "Close" };
|
||||
svg class="w-3 h-3" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 14" {
|
||||
path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6" {};
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
54
src/ui/components/timeline.rs
Normal file
54
src/ui/components/timeline.rs
Normal file
|
@ -0,0 +1,54 @@
|
|||
// https://flowbite.com/docs/components/timeline/
|
||||
// TODO : Timeline
|
||||
// Timeline with icons
|
||||
// Horizontal timeline
|
||||
// activity timeline (custom)
|
||||
|
||||
use maud::{PreEscaped, html};
|
||||
|
||||
use crate::ui::UIWidget;
|
||||
|
||||
pub struct TimelineElement {
|
||||
icon: Option<Box<dyn UIWidget>>,
|
||||
title: Box<dyn UIWidget>,
|
||||
time: String,
|
||||
body: Box<dyn UIWidget>,
|
||||
}
|
||||
|
||||
pub struct TimelineWidget {
|
||||
horizontal: bool,
|
||||
elements: Vec<TimelineElement>,
|
||||
}
|
||||
|
||||
pub fn Timeline() -> PreEscaped<String> {
|
||||
html! {
|
||||
ol class="relative border-s border-gray-200 dark:border-gray-700" {
|
||||
|
||||
li class="mb-10 ms-6" {
|
||||
span class="absolute flex items-center justify-center w-6 h-6 bg-blue-100 rounded-full -start-3 dark:ring-gray-900 dark:bg-blue-900" {
|
||||
svg class="w-2.5 h-2.5 text-blue-800 dark:text-blue-300" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 20 20" {
|
||||
path d="M20 4a2 2 0 0 0-2-2h-2V1a1 1 0 0 0-2 0v1h-3V1a1 1 0 0 0-2 0v1H6V1a1 1 0 0 0-2 0v1H2a2 2 0 0 0-2 2v2h20V4ZM0 18a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V8H0v10Zm5-8h10a1 1 0 0 1 0 2H5a1 1 0 0 1 0-2Z" {};
|
||||
};
|
||||
};
|
||||
h3 class="mb-1 text-lg font-semibold text-gray-900 dark:text-white" { "Flowbite Figma v1.3.0" };
|
||||
time class="block mb-2 text-sm font-normal leading-none text-gray-400 dark:text-gray-500" { "Released on December 7th, 2021" };
|
||||
p class="text-base font-normal text-gray-500 dark:text-gray-400" { "All of the pages and components are first designed in Figma and we keep a parity between the two versions even as we update the project." };
|
||||
};
|
||||
|
||||
li class="mb-10 ms-4" {
|
||||
div class="absolute w-3 h-3 bg-gray-200 rounded-full mt-1.5 -start-1.5 border border-white dark:border-gray-900 dark:bg-gray-700" {};
|
||||
time class="mb-1 text-sm font-normal leading-none text-gray-400 dark:text-gray-500" { "March 2022" };
|
||||
h3 class="text-lg font-semibold text-gray-900 dark:text-white" { "Marketing UI design in Figma" };
|
||||
p class="text-base font-normal text-gray-500 dark:text-gray-400" { "All of the pages and components are first designed in Figma and we keep a parity between the two versions even as we update the project." };
|
||||
};
|
||||
|
||||
li class="ms-4" {
|
||||
div class="absolute w-3 h-3 bg-gray-200 rounded-full mt-1.5 -start-1.5 border border-white dark:border-gray-900 dark:bg-gray-700" {};
|
||||
time class="mb-1 text-sm font-normal leading-none text-gray-400 dark:text-gray-500" { "April 2022" };
|
||||
h3 class="text-lg font-semibold text-gray-900 dark:text-white" { "E-Commerce UI code in Tailwind CSS" };
|
||||
p class="text-base font-normal text-gray-500 dark:text-gray-400" { "Get started with dozens of web components and interactive elements built on top of Tailwind CSS." };
|
||||
};
|
||||
|
||||
};
|
||||
}
|
||||
}
|
|
@ -1,4 +1,5 @@
|
|||
use maud::{Markup, PreEscaped, Render, html};
|
||||
use prelude::Div;
|
||||
|
||||
// UI
|
||||
|
||||
|
@ -8,7 +9,7 @@ pub mod htmx;
|
|||
pub mod primitives;
|
||||
pub mod wrapper;
|
||||
|
||||
// Stacked Components
|
||||
// Complex Components
|
||||
pub mod components;
|
||||
|
||||
// Preludes
|
||||
|
@ -46,7 +47,7 @@ pub mod prelude {
|
|||
pub use super::primitives::image::{Image, Source, Video};
|
||||
pub use super::primitives::width::{MaxWidth, MinWidth, Width};
|
||||
pub use super::primitives::{Context, NoBrowserAppearance, Nothing, Side, Size, script};
|
||||
// TODO :
|
||||
// ENSURE: prelude
|
||||
pub use super::primitives::input::*;
|
||||
pub use super::primitives::link::Link;
|
||||
pub use super::primitives::list::{OrderedList, UnorderedList};
|
||||
|
@ -152,11 +153,13 @@ impl UIWidget for &str {
|
|||
}
|
||||
|
||||
/// Trait for an element which can add new `attrs`
|
||||
pub trait AttrExtendable {
|
||||
pub trait AttrExtendable: Sized {
|
||||
#[must_use]
|
||||
fn add_attr(self, key: &str, val: &str) -> Self;
|
||||
|
||||
/// Set the `id` attribute of an element.
|
||||
#[must_use]
|
||||
fn id(self, id: &str) -> Self;
|
||||
fn id(self, id: &str) -> Self {
|
||||
self.add_attr("id", id)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -135,10 +135,7 @@ impl UIWidget for DivWidget {
|
|||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
|
||||
PreEscaped(format!(
|
||||
"<div class='{class}' {attrs}> {} </div>",
|
||||
inner.0
|
||||
))
|
||||
PreEscaped(format!("<div class='{class}' {attrs}> {} </div>", inner.0))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1 +0,0 @@
|
|||
// TODO : Implement input types
|
84
src/ui/primitives/input/form.rs
Normal file
84
src/ui/primitives/input/form.rs
Normal file
|
@ -0,0 +1,84 @@
|
|||
use std::{collections::HashMap, fmt::Write};
|
||||
|
||||
use maud::{Markup, PreEscaped, Render, html};
|
||||
|
||||
use crate::ui::UIWidget;
|
||||
|
||||
pub enum FormMethod {
|
||||
GET,
|
||||
POST,
|
||||
}
|
||||
|
||||
// TODO : Use csrf by default
|
||||
|
||||
pub struct Form {
|
||||
action: String,
|
||||
method: Option<FormMethod>,
|
||||
multipart: bool,
|
||||
items: Vec<Box<dyn UIWidget>>,
|
||||
}
|
||||
|
||||
impl Form {
|
||||
pub fn new(action: &str) -> Self {
|
||||
Self {
|
||||
action: action.to_string(),
|
||||
method: None,
|
||||
multipart: false,
|
||||
items: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn multipart(mut self) -> Self {
|
||||
self.multipart = true;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn method(mut self, m: FormMethod) -> Self {
|
||||
self.method = Some(m);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn add_input<T: UIWidget + 'static>(mut self, input: T) -> Self {
|
||||
self.items.push(Box::new(input));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Form {
|
||||
fn render(&self) -> maud::Markup {
|
||||
self.render_with_class("")
|
||||
}
|
||||
}
|
||||
|
||||
impl UIWidget for Form {
|
||||
fn can_inherit(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn base_class(&self) -> Vec<String> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn extended_class(&self) -> Vec<String> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn render_with_class(&self, class: &str) -> Markup {
|
||||
let method = self
|
||||
.method
|
||||
.as_ref()
|
||||
.map(|x| match x {
|
||||
FormMethod::GET => "get",
|
||||
FormMethod::POST => "post",
|
||||
})
|
||||
.unwrap_or("post");
|
||||
|
||||
html! {
|
||||
form action=(self.action) method=(method) enctype=(if self.multipart { "multipart/form-data" } else { "application/x-www-form-urlencoded" }) class=(class) {
|
||||
@for item in &self.items {
|
||||
(item)
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
699
src/ui/primitives/input/mod.rs
Normal file
699
src/ui/primitives/input/mod.rs
Normal file
|
@ -0,0 +1,699 @@
|
|||
use std::{collections::HashMap, fmt::Write};
|
||||
|
||||
use maud::{Markup, PreEscaped, Render, html};
|
||||
|
||||
pub mod form;
|
||||
pub mod toggle;
|
||||
pub use form::*;
|
||||
pub use toggle::*;
|
||||
|
||||
use crate::ui::{
|
||||
AttrExtendable, UIWidget,
|
||||
color::{ColorCircle, UIColor},
|
||||
htmx::HTMXAttributes,
|
||||
prelude::{Nothing, script},
|
||||
};
|
||||
|
||||
#[allow(non_snake_case)]
|
||||
pub fn TextInput(name: &str) -> TextInputWidget {
|
||||
TextInputWidget {
|
||||
attrs: HashMap::new(),
|
||||
password: false,
|
||||
icon: None,
|
||||
icon_border: false
|
||||
}.name(name)
|
||||
}
|
||||
|
||||
pub struct TextInputWidget {
|
||||
attrs: HashMap<String, String>,
|
||||
password: bool,
|
||||
icon: Option<Box<dyn UIWidget>>,
|
||||
icon_border: bool,
|
||||
}
|
||||
|
||||
impl TextInputWidget {
|
||||
pub fn placeholder(self, placeholder: &str) -> Self {
|
||||
self.add_attr("placeholder", placeholder)
|
||||
}
|
||||
|
||||
pub fn pattern(self, pattern: &str) -> Self {
|
||||
self.add_attr("pattern", pattern)
|
||||
}
|
||||
|
||||
pub fn maxlength(self, maxlength: &str) -> Self {
|
||||
self.add_attr("maxlength", maxlength)
|
||||
}
|
||||
|
||||
pub fn minlength(self, minlength: &str) -> Self {
|
||||
self.add_attr("minlength", minlength)
|
||||
}
|
||||
|
||||
pub fn icon_border(mut self) -> Self {
|
||||
self.icon_border = true;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn icon<T: UIWidget + 'static>(mut self, icon: T) -> Self {
|
||||
self.icon = Some(Box::new(icon));
|
||||
self
|
||||
}
|
||||
|
||||
|
||||
pub fn password(mut self) -> Self {
|
||||
self.password = true;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl InputAttr for TextInputWidget {}
|
||||
|
||||
impl AttrExtendable for TextInputWidget {
|
||||
fn add_attr(mut self, key: &str, val: &str) -> Self {
|
||||
self.attrs.insert(key.into(), val.into());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for TextInputWidget {
|
||||
fn render(&self) -> Markup {
|
||||
self.render_with_class("")
|
||||
}
|
||||
}
|
||||
|
||||
impl UIWidget for TextInputWidget {
|
||||
fn can_inherit(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn base_class(&self) -> Vec<String> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn extended_class(&self) -> Vec<String> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn render_with_class(&self, class: &str) -> Markup {
|
||||
let mut attrs = self.attrs.clone();
|
||||
|
||||
if self.password {
|
||||
attrs.insert("type".into(), "password".into());
|
||||
} else {
|
||||
attrs.insert("type".into(), "text".into());
|
||||
}
|
||||
|
||||
if self.icon_border {
|
||||
attrs.insert("class".into(),
|
||||
"rounded-none rounded-e-lg bg-gray-50 border text-gray-900 focus:ring-blue-500 focus:border-blue-500 block flex-1 min-w-0 w-full text-sm border-gray-300 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500".into()
|
||||
);
|
||||
} else {
|
||||
attrs.insert("class".into(),
|
||||
format!("bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full {} p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500", if self.icon.is_some() { "ps-10" } else { "" })
|
||||
);
|
||||
}
|
||||
|
||||
let input = build_element("input", &attrs, Nothing());
|
||||
|
||||
html! {
|
||||
@if self.icon_border {
|
||||
div class="flex" {
|
||||
@if let Some(icon) = &self.icon {
|
||||
span class="inline-flex items-center px-3 text-sm text-gray-900 bg-gray-200 border rounded-e-0 border-gray-300 border-e-0 rounded-s-md dark:bg-gray-600 dark:text-gray-400 dark:border-gray-600" {
|
||||
(icon)
|
||||
};
|
||||
};
|
||||
|
||||
(input)
|
||||
|
||||
};
|
||||
} @else {
|
||||
div class="relative mb-6" {
|
||||
@if let Some(icon) = &self.icon {
|
||||
div class="absolute inset-y-0 start-0 flex items-center ps-3.5 pointer-events-none" {
|
||||
(icon)
|
||||
};
|
||||
};
|
||||
|
||||
(input)
|
||||
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(non_snake_case)]
|
||||
pub fn FileInput(name: &str) -> FileInputWidget {
|
||||
FileInputWidget { dropzone: None, attrs: HashMap::new() }.name(name)
|
||||
}
|
||||
|
||||
pub struct FileInputWidget {
|
||||
dropzone: Option<PreEscaped<String>>,
|
||||
attrs: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl FileInputWidget {
|
||||
pub fn multiple(self) -> Self {
|
||||
self.add_attr("multiple", "")
|
||||
}
|
||||
|
||||
pub fn accept(self, mime: &str) -> Self {
|
||||
self.add_attr("accept", mime)
|
||||
}
|
||||
|
||||
pub fn dropzone<T: UIWidget + 'static>(mut self, inner: T) -> Self {
|
||||
self.dropzone = Some(inner.render());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl InputAttr for FileInputWidget {}
|
||||
|
||||
impl AttrExtendable for FileInputWidget {
|
||||
fn add_attr(mut self, key: &str, val: &str) -> Self {
|
||||
self.attrs.insert(key.into(), val.into());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for FileInputWidget {
|
||||
fn render(&self) -> Markup {
|
||||
self.render_with_class("")
|
||||
}
|
||||
}
|
||||
|
||||
impl UIWidget for FileInputWidget {
|
||||
fn can_inherit(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn base_class(&self) -> Vec<String> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn extended_class(&self) -> Vec<String> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn render_with_class(&self, class: &str) -> Markup {
|
||||
let class = format!("
|
||||
{class} block w-full text-sm text-gray-900 border border-gray-300 rounded-lg cursor-pointer bg-gray-50 dark:text-gray-400 focus:outline-none dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400
|
||||
");
|
||||
|
||||
let mut attrs = self.attrs.clone();
|
||||
|
||||
if self.dropzone.is_some() {
|
||||
attrs.insert("class".to_string(), "hidden".to_string());
|
||||
} else {
|
||||
attrs.insert("class".to_string(), class);
|
||||
}
|
||||
attrs.insert("type".to_string(), "file".to_string());
|
||||
|
||||
let input_name = attrs.get("name").map(|x| x.as_str()).unwrap_or_default();
|
||||
|
||||
if let Some(dropzone) = &self.dropzone {
|
||||
return html! {
|
||||
div class="flex items-center justify-center w-full" {
|
||||
label for=(input_name) class="flex flex-col items-center justify-center w-full h-64 border-2 border-gray-300 border-dashed rounded-lg cursor-pointer bg-gray-50 dark:hover:bg-gray-800 dark:bg-gray-700 hover:bg-gray-100 dark:border-gray-600 dark:hover:border-gray-500 dark:hover:bg-gray-600" {
|
||||
div class="flex flex-col items-center justify-center pt-5 pb-6" {
|
||||
svg class="w-8 h-8 mb-4 text-gray-500 dark:text-gray-400" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 16" {
|
||||
path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 13h3a3 3 0 0 0 0-6h-.025A5.56 5.56 0 0 0 16 6.5 5.5 5.5 0 0 0 5.207 5.021C5.137 5.017 5.071 5 5 5a4 4 0 0 0 0 8h2.167M10 15V6m0 0L8 8m2-2 2 2" {};
|
||||
};
|
||||
p class="mb-2 text-sm text-gray-500 dark:text-gray-400" { span class="font-semibold" { "Click to upload" } };
|
||||
(dropzone)
|
||||
};
|
||||
(build_element("input", &attrs, Nothing()))
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
build_element("input", &attrs, Nothing())
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(non_snake_case)]
|
||||
pub fn PinInput(digits: u8) -> PreEscaped<String> {
|
||||
let js = r#"
|
||||
function pin_move_next(event) {
|
||||
const input = event.target;
|
||||
if (input.value.length === 1) {
|
||||
const nextInput = document.querySelector(`#${input.dataset.focusInputNext}`);
|
||||
if (nextInput) nextInput.focus();
|
||||
}
|
||||
}
|
||||
function pin_move_back(event) {
|
||||
const input = event.target;
|
||||
if (event.key === "Backspace" && input.value === "") {
|
||||
const prevInput = document.querySelector(`[data-focus-input-next='${input.id}']`);
|
||||
if (prevInput) {
|
||||
prevInput.focus();
|
||||
prevInput.setSelectionRange(1, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
function enforceNumericInput(event) {
|
||||
if (!/^[0-9]$/.test(event.key)) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
"#;
|
||||
|
||||
html! {
|
||||
div class="flex mb-2 space-x-2 rtl:space-x-reverse" {
|
||||
(script(js))
|
||||
|
||||
@for i in 1..=digits {
|
||||
div {
|
||||
label for=(format!("pin-{i}")) class="sr-only" { (format!("Pin {i}")) };
|
||||
|
||||
@if i == digits {
|
||||
input type="text"
|
||||
maxlength="1"
|
||||
data-focus-input-init
|
||||
pattern="[0-9]"
|
||||
onkeypress="enforceNumericInput(event)"
|
||||
id=(format!("pin-{i}"))
|
||||
oninput="pin_move_next(event)" onkeydown="pin_move_back(event)"
|
||||
class="block w-9 h-9 py-3 text-sm font-extrabold text-center text-gray-900 bg-white border border-gray-300 rounded-lg focus:ring-primary-500 focus:border-primary-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500" required {};
|
||||
} @else {
|
||||
input type="text"
|
||||
maxlength="1"
|
||||
pattern="[0-9]"
|
||||
data-focus-input-init
|
||||
onkeypress="enforceNumericInput(event)"
|
||||
data-focus-input-next=(format!("pin-{}", i+1))
|
||||
id=(format!("pin-{i}"))
|
||||
oninput="pin_move_next(event)" onkeydown="pin_move_back(event)"
|
||||
class="block w-9 h-9 py-3 text-sm font-extrabold text-center text-gray-900 bg-white border border-gray-300 rounded-lg focus:ring-primary-500 focus:border-primary-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500" required {};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct NumberInputWidget {
|
||||
inner: Option<Box<dyn UIWidget>>,
|
||||
attr: HashMap<String, String>,
|
||||
buttons: bool
|
||||
}
|
||||
|
||||
pub fn NumberInput() -> NumberInputWidget {
|
||||
NumberInputWidget {
|
||||
inner: None,
|
||||
attr: HashMap::new(),
|
||||
buttons: false,
|
||||
}
|
||||
}
|
||||
|
||||
impl NumberInputWidget {
|
||||
pub fn with_buttons(mut self) -> Self {
|
||||
self.buttons = true;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn min(self, min: u32) -> Self {
|
||||
self.add_attr("min", &min.to_string())
|
||||
.add_attr("data-input-counter-min", &min.to_string())
|
||||
}
|
||||
|
||||
pub fn max(self, max: u32) -> Self {
|
||||
self.add_attr("max", &max.to_string())
|
||||
.add_attr("data-input-counter-max", &max.to_string())
|
||||
}
|
||||
|
||||
pub fn step(self, step: u32) -> Self {
|
||||
self.add_attr("step", &step.to_string())
|
||||
}
|
||||
|
||||
pub fn help<T: UIWidget + 'static>(mut self, help: T) -> Self {
|
||||
self.inner = Some(Box::new(help));
|
||||
self
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
impl AttrExtendable for NumberInputWidget {
|
||||
fn add_attr(mut self, key: &str, val: &str) -> Self {
|
||||
self.attr.insert(key.into(), val.into());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl InputAttr for NumberInputWidget {}
|
||||
|
||||
impl Render for NumberInputWidget {
|
||||
fn render(&self) -> Markup {
|
||||
self.render_with_class("")
|
||||
}
|
||||
}
|
||||
|
||||
impl UIWidget for NumberInputWidget {
|
||||
fn can_inherit(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn base_class(&self) -> Vec<String> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn extended_class(&self) -> Vec<String> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn render_with_class(&self, _: &str) -> Markup {
|
||||
let mut attrs = self.attr.clone();
|
||||
|
||||
attrs.insert("type".to_string(), "text".to_string());
|
||||
attrs.insert("data-input-counter".into(), "".into());
|
||||
attrs.insert("class".into(), format!(
|
||||
"bg-gray-50 border-x-0 border-gray-300 h-11 font-medium text-center text-gray-900 text-sm focus:ring-blue-500 focus:border-blue-500 block w-full dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500 {}", if self.inner.is_some() { "pb-6" } else { ""}));
|
||||
|
||||
let input_name = attrs.get("id").map(|x| x.as_str()).unwrap_or_default();
|
||||
|
||||
let input = build_element("input", &attrs, Nothing());
|
||||
|
||||
html! {
|
||||
div class="relative flex items-center max-w-[11rem]" {
|
||||
@if self.buttons {
|
||||
button
|
||||
type="button"
|
||||
id="decrement-button"
|
||||
onclick=(format!("document.getElementById(\"{input_name}\").value = (+document.getElementById(\"{input_name}\").value || 0) + ((-document.getElementById(\"{input_name}\").step || 1) + 1);"))
|
||||
data-input-counter-decrement=(input_name)
|
||||
class="bg-gray-100 dark:bg-gray-700 dark:hover:bg-gray-600 dark:border-gray-600 hover:bg-gray-200 border border-gray-300 rounded-s-lg p-3 h-11 focus:ring-gray-100 dark:focus:ring-gray-700 focus:ring-2 focus:outline-none" {
|
||||
svg class="w-3 h-3 text-gray-900 dark:text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 18 2" {
|
||||
path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M1 1h16" {};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
(input)
|
||||
|
||||
@if let Some(inner) = &self.inner {
|
||||
div class="absolute bottom-1 start-1/2 -translate-x-1/2 rtl:translate-x-1/2 flex items-center text-xs text-gray-400 space-x-1 rtl:space-x-reverse" {
|
||||
(inner)
|
||||
};
|
||||
};
|
||||
|
||||
@if self.buttons {
|
||||
button type="button" id="increment-button"
|
||||
onclick=(format!("document.getElementById(\"{input_name}\").value = (+document.getElementById(\"{input_name}\").value || 0) + ((+document.getElementById(\"{input_name}\").step || 1) - 1);"))
|
||||
data-input-counter-increment=(input_name) class="bg-gray-100 dark:bg-gray-700 dark:hover:bg-gray-600 dark:border-gray-600 hover:bg-gray-200 border border-gray-300 rounded-e-lg p-3 h-11 focus:ring-gray-100 dark:focus:ring-gray-700 focus:ring-2 focus:outline-none" {
|
||||
svg class="w-3 h-3 text-gray-900 dark:text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 18 18" {
|
||||
path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 1v16M1 9h16" {};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// TODO : Test impl
|
||||
pub fn TimePicker() -> PreEscaped<String> {
|
||||
html! {
|
||||
div class="relative" {
|
||||
div class="absolute inset-y-0 end-0 top-0 flex items-center pe-3.5 pointer-events-none" {
|
||||
svg class="w-4 h-4 text-gray-500 dark:text-gray-400" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 24 24" {
|
||||
path fill-rule="evenodd" d="M2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12Zm11-4a1 1 0 1 0-2 0v4a1 1 0 0 0 .293.707l3 3a1 1 0 0 0 1.414-1.414L13 11.586V8Z" clip-rule="evenodd" {};
|
||||
};
|
||||
};
|
||||
input type="time" id="time" class="bg-gray-50 border leading-none border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" min="09:00" max="18:00" value="00:00" required {};
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(non_snake_case)]
|
||||
pub fn TextArea() -> TextAreaWidget {
|
||||
TextAreaWidget {
|
||||
content: String::new(),
|
||||
attr: HashMap::new()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct TextAreaWidget {
|
||||
content: String,
|
||||
attr: HashMap<String, String>
|
||||
}
|
||||
|
||||
impl UIWidget for TextAreaWidget {
|
||||
fn can_inherit(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn base_class(&self) -> Vec<String> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn extended_class(&self) -> Vec<String> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn render_with_class(&self, class: &str) -> Markup {
|
||||
let mut attrs = self.attr.clone();
|
||||
attrs.insert("class".into(), format!(
|
||||
"{class} block p-2.5 w-full text-sm text-gray-900 bg-gray-50 rounded-lg border border-gray-300 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
||||
));
|
||||
|
||||
build_element("textarea", &attrs, PreEscaped(self.content.clone()))
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for TextAreaWidget {
|
||||
fn render(&self) -> Markup {
|
||||
self.render_with_class("")
|
||||
}
|
||||
}
|
||||
|
||||
impl InputAttr for TextAreaWidget {}
|
||||
|
||||
impl AttrExtendable for TextAreaWidget {
|
||||
fn add_attr(mut self, key: &str, val: &str) -> Self {
|
||||
self.attr.insert(key.into(), val.into());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl TextAreaWidget {
|
||||
pub fn content(mut self, content: String) -> Self {
|
||||
self.content = content;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn placeholder(self, placeholder: &str) -> Self {
|
||||
self.add_attr("placeholder", placeholder)
|
||||
}
|
||||
|
||||
pub fn rows(self, rows: u32) -> Self {
|
||||
self.add_attr("rows", &rows.to_string())
|
||||
}
|
||||
|
||||
pub fn pattern(self, pattern: &str) -> Self {
|
||||
self.add_attr("pattern", pattern)
|
||||
}
|
||||
|
||||
pub fn maxlength(self, maxlength: &str) -> Self {
|
||||
self.add_attr("maxlength", maxlength)
|
||||
}
|
||||
|
||||
pub fn minlength(self, minlength: &str) -> Self {
|
||||
self.add_attr("minlength", minlength)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn Select(
|
||||
label: Option<String>,
|
||||
id: &str,
|
||||
default: &str,
|
||||
options: Vec<(String, String)>,
|
||||
) -> PreEscaped<String> {
|
||||
html! {
|
||||
@if let Some(label) = label {
|
||||
label for=(id) class="block mb-2 text-sm font-medium text-gray-900 dark:text-white" { (label) };
|
||||
};
|
||||
|
||||
select id=(id) class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" {
|
||||
@for (value, label) in &options {
|
||||
@if value == default {
|
||||
@if value.is_empty() {
|
||||
option selected { (label) };
|
||||
} @else {
|
||||
option value=(value) selected { (label) };
|
||||
}
|
||||
} @else {
|
||||
@if value.is_empty() {
|
||||
option { (label) };
|
||||
} @else {
|
||||
option value=(value) { (label) };
|
||||
}
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
pub trait InputAttr: AttrExtendable + Sized {
|
||||
fn name(self, name: &str) -> Self {
|
||||
self.add_attr("name", name).add_attr("id", name)
|
||||
}
|
||||
|
||||
fn value(self, value: &str) -> Self {
|
||||
self.add_attr("value", value)
|
||||
}
|
||||
|
||||
fn readonly(self) -> Self {
|
||||
self.add_attr("readonly", "")
|
||||
}
|
||||
|
||||
fn disabled(self) -> Self {
|
||||
self.add_attr("disabled", "")
|
||||
}
|
||||
|
||||
fn required(self) -> Self {
|
||||
self.add_attr("required", "")
|
||||
}
|
||||
|
||||
fn autofocus(self) -> Self {
|
||||
self.add_attr("autofocus", "")
|
||||
}
|
||||
|
||||
fn autocomplete(self, value: bool) -> Self {
|
||||
self.add_attr("autocomplete", &value.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
// TODO : other inputs
|
||||
// color input
|
||||
// date
|
||||
// datetime
|
||||
// time
|
||||
// url
|
||||
// email
|
||||
// hidden
|
||||
|
||||
#[allow(non_snake_case)]
|
||||
pub fn FormResetButton<T: UIWidget + 'static>(inner: T) -> ButtonWidget {
|
||||
let btn = ButtonWidget {
|
||||
inner: Box::new(inner),
|
||||
attrs: HashMap::new(),
|
||||
color: None,
|
||||
hover_color: None,
|
||||
};
|
||||
btn.add_attr("type", "reset")
|
||||
}
|
||||
|
||||
#[allow(non_snake_case)]
|
||||
pub fn FormSubmitButton<T: UIWidget + 'static>(inner: T) -> ButtonWidget {
|
||||
let btn = ButtonWidget {
|
||||
inner: Box::new(inner),
|
||||
attrs: HashMap::new(),
|
||||
color: None,
|
||||
hover_color: None,
|
||||
};
|
||||
btn.add_attr("type", "submit")
|
||||
}
|
||||
|
||||
pub struct ButtonWidget {
|
||||
inner: Box<dyn UIWidget>,
|
||||
attrs: HashMap<String, String>,
|
||||
color: Option<Box<dyn UIColor>>,
|
||||
hover_color: Option<Box<dyn UIColor>>,
|
||||
}
|
||||
|
||||
impl ButtonWidget {
|
||||
pub fn color<C: UIColor + ColorCircle + 'static>(mut self, color: C) -> Self {
|
||||
let hover = color.next();
|
||||
self.color = Some(Box::new(color));
|
||||
self.hover_color = Some(Box::new(hover));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl HTMXAttributes for ButtonWidget {}
|
||||
impl InputAttr for ButtonWidget {}
|
||||
|
||||
impl AttrExtendable for ButtonWidget {
|
||||
fn add_attr(mut self, key: &str, val: &str) -> Self {
|
||||
self.attrs.insert(key.into(), val.into());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for ButtonWidget {
|
||||
fn render(&self) -> Markup {
|
||||
self.render_with_class("")
|
||||
}
|
||||
}
|
||||
|
||||
impl UIWidget for ButtonWidget {
|
||||
fn can_inherit(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn base_class(&self) -> Vec<String> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn extended_class(&self) -> Vec<String> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn render_with_class(&self, class: &str) -> Markup {
|
||||
let mut attrs = self.attrs.clone();
|
||||
|
||||
let color = if let Some(c) = &self.color {
|
||||
c.color_class()
|
||||
} else {
|
||||
"blue-700"
|
||||
};
|
||||
|
||||
let hover_color = if let Some(c) = &self.hover_color {
|
||||
c.color_class()
|
||||
} else {
|
||||
"blue-800"
|
||||
};
|
||||
|
||||
attrs.insert("class".to_string(), format!(
|
||||
"{class} px-5 py-2.5 text-sm font-medium text-white inline-flex items-center bg-{color} hover:bg-{hover_color} rounded-lg text-center"
|
||||
));
|
||||
|
||||
build_element("button", &attrs, self.inner.render())
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(non_snake_case)]
|
||||
pub fn Button<T: UIWidget + 'static>(inner: T) -> ButtonWidget {
|
||||
ButtonWidget {
|
||||
inner: Box::new(inner),
|
||||
attrs: HashMap::new(),
|
||||
color: None,
|
||||
hover_color: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(non_snake_case)]
|
||||
pub fn ButtonGroup(buttons: Vec<ButtonWidget>) -> PreEscaped<String> {
|
||||
html! {
|
||||
div class="inline-flex rounded-md shadow-xs" role="group" {
|
||||
@for (index, element) in buttons.iter().enumerate() {
|
||||
@if index == 0 {
|
||||
button type="button" class="px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-s-lg hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white" {
|
||||
(element.inner)
|
||||
}
|
||||
} @else if index == (buttons.len()-1) {
|
||||
button type="button" class="px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-e-lg hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white" {
|
||||
(element.inner)
|
||||
}
|
||||
} @else {
|
||||
button type="button" class="px-4 py-2 text-sm font-medium text-gray-900 bg-white border-t border-b border-gray-200 hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white" {
|
||||
(element.inner)
|
||||
}
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
288
src/ui/primitives/input/toggle.rs
Normal file
288
src/ui/primitives/input/toggle.rs
Normal file
|
@ -0,0 +1,288 @@
|
|||
use std::{collections::HashMap, fmt::Write};
|
||||
|
||||
use maud::{Markup, PreEscaped, Render, html};
|
||||
|
||||
use crate::ui::{
|
||||
AttrExtendable, UIWidget,
|
||||
color::{Blue, UIColor},
|
||||
prelude::Nothing,
|
||||
};
|
||||
|
||||
use super::InputAttr;
|
||||
|
||||
#[allow(non_snake_case)]
|
||||
pub fn Range() -> RangeWidget {
|
||||
RangeWidget {
|
||||
attrs: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub struct RangeWidget {
|
||||
attrs: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl AttrExtendable for RangeWidget {
|
||||
fn add_attr(mut self, key: &str, val: &str) -> Self {
|
||||
self.attrs.insert(key.into(), val.into());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RangeWidget {
|
||||
pub fn min(self, min: u32) -> Self {
|
||||
self.add_attr("min", &min.to_string())
|
||||
}
|
||||
|
||||
pub fn max(self, max: u32) -> Self {
|
||||
self.add_attr("max", &max.to_string())
|
||||
}
|
||||
|
||||
pub fn step(self, step: u32) -> Self {
|
||||
self.add_attr("step", &step.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl InputAttr for RangeWidget {}
|
||||
|
||||
impl Render for RangeWidget {
|
||||
fn render(&self) -> Markup {
|
||||
self.render_with_class("")
|
||||
}
|
||||
}
|
||||
|
||||
impl UIWidget for RangeWidget {
|
||||
fn can_inherit(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn base_class(&self) -> Vec<String> {
|
||||
vec![
|
||||
"w-full".into(),
|
||||
"h-2".into(),
|
||||
"bg-gray-200".into(),
|
||||
"rounded-lg".into(),
|
||||
"appearence-none".into(),
|
||||
"cursor-pointer".into(),
|
||||
"dark:bg-gray-700".into(),
|
||||
]
|
||||
}
|
||||
|
||||
fn extended_class(&self) -> Vec<String> {
|
||||
self.base_class()
|
||||
}
|
||||
|
||||
fn render_with_class(&self, class: &str) -> Markup {
|
||||
let mut attrs = self.attrs.clone();
|
||||
attrs.insert(
|
||||
"class".into(),
|
||||
format!("{class} {}", self.base_class().join(" ")),
|
||||
);
|
||||
attrs.insert("type".into(), "range".into());
|
||||
build_element("input", &attrs, PreEscaped(String::new()))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_element(
|
||||
element: &str,
|
||||
attrs: &HashMap<String, String>,
|
||||
inner: PreEscaped<String>,
|
||||
) -> PreEscaped<String> {
|
||||
let mut ret = String::with_capacity(256);
|
||||
write!(&mut ret, "<{element}").unwrap();
|
||||
|
||||
for (key, value) in attrs {
|
||||
if value.is_empty() {
|
||||
write!(&mut ret, " {key}").unwrap();
|
||||
} else {
|
||||
write!(&mut ret, " {key}='{}'", value.replace("'", "\\'")).unwrap();
|
||||
};
|
||||
}
|
||||
|
||||
if inner.0.is_empty() {
|
||||
if element == "textarea" {
|
||||
write!(&mut ret, "></textarea>").unwrap();
|
||||
} else {
|
||||
write!(&mut ret, ">").unwrap();
|
||||
}
|
||||
} else {
|
||||
write!(&mut ret, ">{}</{element}>", inner.0).unwrap();
|
||||
}
|
||||
|
||||
PreEscaped(ret)
|
||||
}
|
||||
|
||||
#[allow(non_snake_case)]
|
||||
pub fn Toggle(title: &str) -> ToggleWidget {
|
||||
ToggleWidget {
|
||||
color: Box::new(Blue::_600),
|
||||
checked: false,
|
||||
attrs: HashMap::new(),
|
||||
title: title.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ToggleWidget {
|
||||
color: Box<dyn UIColor>,
|
||||
checked: bool,
|
||||
attrs: HashMap<String, String>,
|
||||
title: String,
|
||||
}
|
||||
|
||||
impl ToggleWidget {
|
||||
pub fn color<C: UIColor + 'static>(mut self, color: C) -> Self {
|
||||
self.color = Box::new(color);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn checked(mut self, checked: bool) -> Self {
|
||||
self.checked = checked;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl AttrExtendable for ToggleWidget {
|
||||
fn add_attr(mut self, key: &str, val: &str) -> Self {
|
||||
self.attrs.insert(key.to_string(), val.to_string());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl InputAttr for ToggleWidget {}
|
||||
|
||||
impl Render for ToggleWidget {
|
||||
fn render(&self) -> Markup {
|
||||
self.render_with_class("")
|
||||
}
|
||||
}
|
||||
|
||||
impl UIWidget for ToggleWidget {
|
||||
fn can_inherit(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn base_class(&self) -> Vec<String> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn extended_class(&self) -> Vec<String> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn render_with_class(&self, _: &str) -> Markup {
|
||||
let color = self.color.color_class();
|
||||
let class = format!(
|
||||
"relative w-11 h-6 bg-gray-200 rounded-full peer peer-focus:ring-4 peer-focus:ring-{color} dark:peer-focus:ring-blue-800 dark:bg-gray-700 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-0.5 after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-{color} dark:peer-checked:bg-{color}"
|
||||
);
|
||||
|
||||
let mut attrs = self.attrs.clone();
|
||||
|
||||
attrs.insert("class".into(), "sr-only peer".into());
|
||||
attrs.insert("type".into(), "checkbox".into());
|
||||
|
||||
if self.checked {
|
||||
attrs.insert("checked".into(), "".into());
|
||||
}
|
||||
|
||||
let input = build_element("input", &attrs, Nothing());
|
||||
|
||||
html! {
|
||||
label class="inline-flex items-center cursor-pointer" {
|
||||
(input)
|
||||
div class=(class) {};
|
||||
span class="ms-3 text-sm font-medium text-gray-900 dark:text-gray-300" { (self.title) };
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO : impl radio
|
||||
// https://flowbite.com/docs/forms/radio/
|
||||
// https://flowbite.com/docs/forms/radio/#advanced-layout
|
||||
|
||||
|
||||
|
||||
|
||||
#[allow(non_snake_case)]
|
||||
pub fn Checkbox(title: &str) -> CheckboxWidget {
|
||||
CheckboxWidget {
|
||||
color: Box::new(Blue::_600),
|
||||
checked: false,
|
||||
attrs: HashMap::new(),
|
||||
title: title.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub struct CheckboxWidget {
|
||||
color: Box<dyn UIColor>,
|
||||
checked: bool,
|
||||
attrs: HashMap<String, String>,
|
||||
title: String,
|
||||
}
|
||||
|
||||
impl CheckboxWidget {
|
||||
pub fn color<C: UIColor + 'static>(mut self, color: C) -> Self {
|
||||
self.color = Box::new(color);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn checked(mut self, checked: bool) -> Self {
|
||||
self.checked = checked;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl AttrExtendable for CheckboxWidget {
|
||||
fn add_attr(mut self, key: &str, val: &str) -> Self {
|
||||
self.attrs.insert(key.to_string(), val.to_string());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl InputAttr for CheckboxWidget {}
|
||||
|
||||
impl Render for CheckboxWidget {
|
||||
fn render(&self) -> Markup {
|
||||
self.render_with_class("")
|
||||
}
|
||||
}
|
||||
|
||||
impl UIWidget for CheckboxWidget {
|
||||
fn can_inherit(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn base_class(&self) -> Vec<String> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn extended_class(&self) -> Vec<String> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn render_with_class(&self, _: &str) -> Markup {
|
||||
let color = self.color.color_class();
|
||||
let class = format!(
|
||||
"w-4 h-4 text-{color} bg-gray-100 border-gray-300 rounded-sm focus:ring-{color} dark:focus:ring-{color} dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
|
||||
);
|
||||
|
||||
let mut attrs = self.attrs.clone();
|
||||
|
||||
attrs.insert("class".into(), class);
|
||||
attrs.insert("type".into(), "checkbox".into());
|
||||
|
||||
if self.checked {
|
||||
attrs.insert("checked".into(), "".into());
|
||||
}
|
||||
|
||||
let input = build_element("input", &attrs, Nothing());
|
||||
|
||||
let input_id = attrs.get("id").map(|x| x.as_str()).unwrap_or_default();
|
||||
|
||||
html! {
|
||||
div class="flex items-center me-4" {
|
||||
(input)
|
||||
label for=(input_id) class="ms-2 text-sm font-medium text-gray-900 dark:text-gray-300" { (self.title) };
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
|
@ -101,3 +101,9 @@ impl UIWidget for ListWidget {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// https://flowbite.com/docs/typography/lists/
|
||||
// TODO : List
|
||||
// list with icons
|
||||
// horizontal list
|
||||
// data backed list + reorderable + add + remove + crud
|
||||
|
|
|
@ -111,6 +111,7 @@ pub enum ScreenValue {
|
|||
min,
|
||||
max,
|
||||
fit,
|
||||
fill,
|
||||
screen,
|
||||
full,
|
||||
auto,
|
||||
|
@ -159,6 +160,7 @@ impl ScreenValue {
|
|||
Self::min => "min",
|
||||
Self::max => "max",
|
||||
Self::fit => "fit",
|
||||
Self::fill => "fill",
|
||||
Self::screen => "screen",
|
||||
Self::full => "full",
|
||||
Self::auto => "auto",
|
||||
|
|
|
@ -208,3 +208,10 @@ element_widget!(TableRow, TableRowWidget, tr);
|
|||
element_widget!(TableHead, TableHeadWidget, th);
|
||||
element_widget!(TableData, TableDataWidget, td);
|
||||
element_widget!(Header, HeaderWidget, header);
|
||||
|
||||
// https://flowbite.com/docs/components/tables/
|
||||
// TODO : tables
|
||||
// table options
|
||||
// wrap data to table (crud)
|
||||
|
||||
// TODO: TABLE https://flowbite.com/docs/components/pagination/#table-data-pagination
|
||||
|
|
Loading…
Add table
Reference in a new issue