Compare commits
No commits in common. "35ae5bb6a8ff51e9ba5cb435952744f50c88420c" and "af53cd4b528dda660d6b174576d477ed2feb2696" have entirely different histories.
35ae5bb6a8
...
af53cd4b52
33 changed files with 409 additions and 2041 deletions
14
CREDITS.md
14
CREDITS.md
|
|
@ -1,9 +1,9 @@
|
|||
# Dependencias
|
||||
# 🔃 Dependencias
|
||||
|
||||
PageTop está basado en [Rust](https://www.rust-lang.org/) y crece a hombros de gigantes aprovechando
|
||||
algunas de las librerías más robustas y populares del [ecosistema Rust](https://lib.rs/) como son:
|
||||
|
||||
* [Axum](https://github.com/tokio-rs/axum) para los servicios web.
|
||||
* [Actix Web](https://actix.rs/) para los servicios web.
|
||||
* [Config](https://docs.rs/config) para cargar y procesar las opciones de configuración.
|
||||
* [Tracing](https://github.com/tokio-rs/tracing) para la gestión de trazas y registro de eventos
|
||||
de la aplicación.
|
||||
|
|
@ -13,7 +13,7 @@ algunas de las librerías más robustas y populares del [ecosistema Rust](https:
|
|||
PageTop y sus extensiones.
|
||||
|
||||
|
||||
# FIGfonts
|
||||
# 🗚 FIGfonts
|
||||
|
||||
PageTop usa el *crate* [figlet-rs](https://crates.io/crates/figlet-rs) desarrollado por *yuanbohan*
|
||||
para mostrar un banner de presentación en el terminal con el nombre de la aplicación en caracteres
|
||||
|
|
@ -26,13 +26,7 @@ para mostrar un banner de presentación en el terminal con el nombre de la aplic
|
|||
* [starwars.flf](http://www.figlet.org/fontdb_example.cgi?font=starwars.flf) de *Ryan Youck*
|
||||
|
||||
|
||||
# Menús accesibles
|
||||
|
||||
El tema Basic de PageTop usa [Accessible Menu](https://github.com/NickDJM/accessible-menu),
|
||||
desarrollado por *Nick Milton*, para implementar la navegación de sus menús con soporte avanzado.
|
||||
|
||||
|
||||
# Icono
|
||||
# 👾 Icono
|
||||
|
||||
"La Mascota" sonriente es una simpática creación de [Webalys](https://www.iconfinder.com/webalys).
|
||||
Forma parte de su colección [Nasty Icons](https://www.iconfinder.com/iconsets/nasty), disponible en
|
||||
|
|
|
|||
202
Cargo.lock
generated
202
Cargo.lock
generated
|
|
@ -115,18 +115,6 @@ version = "1.0.102"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
|
||||
[[package]]
|
||||
name = "argon2"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"blake2",
|
||||
"cpufeatures 0.2.17",
|
||||
"password-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-stream"
|
||||
version = "0.3.6"
|
||||
|
|
@ -254,15 +242,6 @@ dependencies = [
|
|||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "blake2"
|
||||
version = "0.10.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
|
||||
dependencies = [
|
||||
"digest 0.10.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
|
|
@ -272,15 +251,6 @@ dependencies = [
|
|||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
|
||||
dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bstr"
|
||||
version = "1.12.1"
|
||||
|
|
@ -429,12 +399,6 @@ version = "0.9.6"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
|
||||
|
||||
[[package]]
|
||||
name = "const-oid"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.10.1"
|
||||
|
|
@ -460,15 +424,6 @@ dependencies = [
|
|||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc"
|
||||
version = "3.4.0"
|
||||
|
|
@ -546,15 +501,6 @@ dependencies = [
|
|||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
|
||||
dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling"
|
||||
version = "0.20.11"
|
||||
|
|
@ -595,7 +541,7 @@ version = "0.7.10"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
|
||||
dependencies = [
|
||||
"const-oid 0.9.6",
|
||||
"const-oid",
|
||||
"pem-rfc7468",
|
||||
"zeroize",
|
||||
]
|
||||
|
|
@ -637,23 +583,12 @@ version = "0.10.7"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer 0.10.4",
|
||||
"const-oid 0.9.6",
|
||||
"crypto-common 0.1.7",
|
||||
"block-buffer",
|
||||
"const-oid",
|
||||
"crypto-common",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
|
||||
dependencies = [
|
||||
"block-buffer 0.12.1",
|
||||
"const-oid 0.10.2",
|
||||
"crypto-common 0.2.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "displaydoc"
|
||||
version = "0.2.5"
|
||||
|
|
@ -1118,7 +1053,7 @@ version = "0.12.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
|
||||
dependencies = [
|
||||
"digest 0.10.7",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1181,15 +1116,6 @@ version = "1.0.3"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||
|
||||
[[package]]
|
||||
name = "hybrid-array"
|
||||
version = "0.4.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper"
|
||||
version = "1.9.0"
|
||||
|
|
@ -1563,7 +1489,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"digest 0.10.7",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1790,7 +1716,6 @@ dependencies = [
|
|||
name = "pagetop"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum",
|
||||
"chrono",
|
||||
"colored",
|
||||
|
|
@ -1811,7 +1736,6 @@ dependencies = [
|
|||
"serde_json",
|
||||
"tempfile",
|
||||
"terminal_size",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tower",
|
||||
"tower-http",
|
||||
|
|
@ -1821,17 +1745,6 @@ dependencies = [
|
|||
"unic-langid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pagetop-admin"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"pagetop",
|
||||
"pagetop-seaorm",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pagetop-aliner"
|
||||
version = "0.1.0"
|
||||
|
|
@ -1864,7 +1777,6 @@ version = "0.1.0"
|
|||
dependencies = [
|
||||
"pagetop",
|
||||
"pagetop-build",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1877,19 +1789,6 @@ dependencies = [
|
|||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pagetop-menu"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"pagetop",
|
||||
"pagetop-seaorm",
|
||||
"sea-orm",
|
||||
"serde",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pagetop-minimal"
|
||||
version = "0.1.0"
|
||||
|
|
@ -1903,6 +1802,7 @@ dependencies = [
|
|||
name = "pagetop-seaorm"
|
||||
version = "0.0.4"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"pagetop",
|
||||
"sea-orm",
|
||||
"sea-schema",
|
||||
|
|
@ -1920,24 +1820,6 @@ dependencies = [
|
|||
"path-slash 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pagetop-user"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"argon2",
|
||||
"base64ct",
|
||||
"pagetop",
|
||||
"pagetop-admin",
|
||||
"pagetop-htmx",
|
||||
"pagetop-seaorm",
|
||||
"rand_core",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_qs",
|
||||
"sha2 0.11.0",
|
||||
"thiserror",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parking"
|
||||
version = "2.2.1"
|
||||
|
|
@ -1980,17 +1862,6 @@ dependencies = [
|
|||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "password-hash"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"rand_core",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pastey"
|
||||
version = "0.2.2"
|
||||
|
|
@ -2290,8 +2161,8 @@ version = "0.9.10"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d"
|
||||
dependencies = [
|
||||
"const-oid 0.9.6",
|
||||
"digest 0.10.7",
|
||||
"const-oid",
|
||||
"digest",
|
||||
"num-bigint-dig",
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
|
|
@ -2389,7 +2260,6 @@ checksum = "2dc312fedd460a47ea563911761d254a84e7b51d8cc73ec92c929e78f33fa957"
|
|||
dependencies = [
|
||||
"async-stream",
|
||||
"async-trait",
|
||||
"chrono",
|
||||
"derive_more",
|
||||
"futures-util",
|
||||
"log",
|
||||
|
|
@ -2425,7 +2295,6 @@ version = "0.32.7"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a5d1c518eaf5eda38e5773f902b26ab6d5e9e9e2bb2349ca6c64cf96f80448c"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"inherent",
|
||||
"ordered-float",
|
||||
"sea-query-derive",
|
||||
|
|
@ -2437,7 +2306,6 @@ version = "0.7.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b0019f47430f7995af63deda77e238c17323359af241233ec768aba1faea7608"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"sea-query",
|
||||
"sqlx",
|
||||
]
|
||||
|
|
@ -2568,18 +2436,6 @@ dependencies = [
|
|||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_qs"
|
||||
version = "1.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67d525c8ff68aa99e5818302259bdd02d86d0303710616f39c0f44846ff6d332"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"percent-encoding",
|
||||
"ryu",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_spanned"
|
||||
version = "1.1.1"
|
||||
|
|
@ -2608,8 +2464,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.2.17",
|
||||
"digest 0.10.7",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -2619,19 +2475,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.2.17",
|
||||
"digest 0.10.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.3.0",
|
||||
"digest 0.11.3",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -2665,7 +2510,7 @@ version = "2.2.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
|
||||
dependencies = [
|
||||
"digest 0.10.7",
|
||||
"digest",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
|
|
@ -2746,7 +2591,6 @@ checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6"
|
|||
dependencies = [
|
||||
"base64",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"crc",
|
||||
"crossbeam-queue",
|
||||
"either",
|
||||
|
|
@ -2765,7 +2609,7 @@ dependencies = [
|
|||
"percent-encoding",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"sha2",
|
||||
"smallvec",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
|
|
@ -2802,7 +2646,7 @@ dependencies = [
|
|||
"quote",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"sha2",
|
||||
"sqlx-core",
|
||||
"sqlx-mysql",
|
||||
"sqlx-postgres",
|
||||
|
|
@ -2823,9 +2667,8 @@ dependencies = [
|
|||
"bitflags",
|
||||
"byteorder",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"crc",
|
||||
"digest 0.10.7",
|
||||
"digest",
|
||||
"dotenvy",
|
||||
"either",
|
||||
"futures-channel",
|
||||
|
|
@ -2846,7 +2689,7 @@ dependencies = [
|
|||
"rsa",
|
||||
"serde",
|
||||
"sha1",
|
||||
"sha2 0.10.9",
|
||||
"sha2",
|
||||
"smallvec",
|
||||
"sqlx-core",
|
||||
"stringprep",
|
||||
|
|
@ -2865,7 +2708,6 @@ dependencies = [
|
|||
"base64",
|
||||
"bitflags",
|
||||
"byteorder",
|
||||
"chrono",
|
||||
"crc",
|
||||
"dotenvy",
|
||||
"etcetera",
|
||||
|
|
@ -2884,7 +2726,7 @@ dependencies = [
|
|||
"rand",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"sha2",
|
||||
"smallvec",
|
||||
"sqlx-core",
|
||||
"stringprep",
|
||||
|
|
@ -2900,7 +2742,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea"
|
||||
dependencies = [
|
||||
"atoi",
|
||||
"chrono",
|
||||
"flume",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
|
|
@ -3203,9 +3044,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "tower-http"
|
||||
version = "0.7.0"
|
||||
version = "0.6.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b11f75e912b0c2be01b63d8cf8057b8c3f97cf34abb3d431a3a4c8675498e233"
|
||||
checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"bytes",
|
||||
|
|
@ -3224,7 +3065,6 @@ dependencies = [
|
|||
"tokio-util",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
24
Cargo.toml
24
Cargo.toml
|
|
@ -10,10 +10,7 @@ members = [
|
|||
"extensions/pagetop-aliner",
|
||||
"extensions/pagetop-bootsier",
|
||||
"extensions/pagetop-htmx",
|
||||
"extensions/pagetop-admin",
|
||||
"extensions/pagetop-menu",
|
||||
"extensions/pagetop-seaorm",
|
||||
"extensions/pagetop-user",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
|
|
@ -24,10 +21,8 @@ license = "MIT OR Apache-2.0"
|
|||
authors = ["Manuel Cillero <manuel@cillero.es>"]
|
||||
|
||||
[workspace.dependencies]
|
||||
argon2 = "0.5"
|
||||
async-trait = "0.1"
|
||||
axum = { version = "0.8" }
|
||||
base64ct = { version = "1.8", features = ["alloc"] }
|
||||
change-detection = "1.2"
|
||||
chrono = "0.4"
|
||||
colored = "3.1"
|
||||
|
|
@ -48,18 +43,14 @@ path-slash = "0.2"
|
|||
proc-macro2 = "1.0"
|
||||
proc-macro2-diagnostics = { version = "0.10", default-features = false }
|
||||
quote = "1.0"
|
||||
rand_core = { version = "0.6", features = ["getrandom"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
serde_qs = "1.1"
|
||||
sha2 = "0.11"
|
||||
syn = { version = "2.0", features = ["full", "extra-traits"] }
|
||||
tempfile = "3.27"
|
||||
terminal_size = "0.4"
|
||||
thiserror = "2.0"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
tower = { version = "0.5", features = ["util"] }
|
||||
tower-http = { version = "0.7", features = ["catch-panic", "fs"] }
|
||||
tower-http = { version = "0.6", features = ["fs"] }
|
||||
tracing = "0.1"
|
||||
tracing-appender = "0.2"
|
||||
tracing-subscriber = { version = "0.3", features = ["json", "env-filter"] }
|
||||
|
|
@ -71,19 +62,16 @@ pagetop-macros = { version = "0.3", path = "helpers/pagetop-macros" }
|
|||
pagetop-minimal = { version = "0.1", path = "helpers/pagetop-minimal" }
|
||||
pagetop-statics = { version = "0.1", path = "helpers/pagetop-statics" }
|
||||
# Extensions
|
||||
pagetop-admin = { version = "0.1", path = "extensions/pagetop-admin" }
|
||||
pagetop-aliner = { version = "0.1", path = "extensions/pagetop-aliner" }
|
||||
pagetop-aliner = { version = "0.1", path = "extensions/pagetop-aliner" }
|
||||
pagetop-bootsier = { version = "0.1", path = "extensions/pagetop-bootsier" }
|
||||
pagetop-htmx = { version = "0.1", path = "extensions/pagetop-htmx" }
|
||||
pagetop-menu = { version = "0.1", path = "extensions/pagetop-menu" }
|
||||
pagetop-seaorm = { version = "0.0", path = "extensions/pagetop-seaorm" }
|
||||
pagetop-user = { version = "0.1", path = "extensions/pagetop-user" }
|
||||
pagetop-htmx = { version = "0.1", path = "extensions/pagetop-htmx" }
|
||||
pagetop-seaorm = { version = "0.0", path = "extensions/pagetop-seaorm" }
|
||||
# PageTop
|
||||
pagetop = { version = "0.5", path = "." }
|
||||
|
||||
[workspace.dependencies.sea-orm]
|
||||
version = "1.1"
|
||||
features = ["debug-print", "macros", "runtime-tokio-native-tls", "with-chrono"]
|
||||
features = ["debug-print", "macros", "runtime-tokio-native-tls"]
|
||||
default-features = false
|
||||
|
||||
[workspace.dependencies.sea-schema]
|
||||
|
|
@ -111,7 +99,6 @@ default = []
|
|||
testing = []
|
||||
|
||||
[dependencies]
|
||||
async-trait.workspace = true
|
||||
axum.workspace = true
|
||||
chrono.workspace = true
|
||||
colored.workspace = true
|
||||
|
|
@ -127,7 +114,6 @@ pagetop-minimal.workspace = true
|
|||
pagetop-statics.workspace = true
|
||||
serde.workspace = true
|
||||
terminal_size.workspace = true
|
||||
thiserror.workspace = true
|
||||
tokio.workspace = true
|
||||
tower.workspace = true
|
||||
tower-http.workspace = true
|
||||
|
|
|
|||
|
|
@ -44,10 +44,21 @@ body {
|
|||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
/* Header, Aside, Content y Footer se apilan en el mismo orden ocupando todo el ancho disponible. */
|
||||
.wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/*
|
||||
* Brand component
|
||||
*/
|
||||
|
||||
.brand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: var(--val-color--text);
|
||||
font-size: 1.75rem;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
.brand .image {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/*
|
||||
|
|
@ -70,75 +81,11 @@ body {
|
|||
color: #000;
|
||||
}
|
||||
.badge-primary { background-color: var(--val-color--primary); }
|
||||
.badge-neutral { background-color: var(--val-color--secondary); }
|
||||
.badge-secondary { background-color: var(--val-color--secondary); }
|
||||
.badge-success { background-color: var(--val-color--success); }
|
||||
.badge-info { background-color: var(--val-color--info); }
|
||||
.badge-warning { background-color: var(--val-color--warning); }
|
||||
.badge-severe { background-color: var(--val-color--danger); }
|
||||
|
||||
/*
|
||||
* Brand component
|
||||
*/
|
||||
|
||||
.brand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: var(--val-color--text);
|
||||
font-size: 1.75rem;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
.brand .image {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/*
|
||||
* Breadcrumb component
|
||||
*/
|
||||
|
||||
.breadcrumb {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
list-style: none;
|
||||
margin: 0 0 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.breadcrumb-item + .breadcrumb-item {
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
.breadcrumb-item + .breadcrumb-item::before {
|
||||
float: left;
|
||||
padding-right: 0.5rem;
|
||||
color: var(--val-color--text--muted);
|
||||
content: "/";
|
||||
}
|
||||
.breadcrumb-item.active {
|
||||
color: var(--val-color--text--muted);
|
||||
}
|
||||
.breadcrumb-item a {
|
||||
color: var(--val-color--text);
|
||||
text-decoration: none;
|
||||
}
|
||||
.breadcrumb-item a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Geometría base de .dropdown-button/.dropdown-toggle (ver "Dropdown component" más abajo). Se
|
||||
* declara aquí, antes de "Buttons", para que las clases .button-sm/.button-lg/.button-* ganen el
|
||||
* empate de especificidad frente a ella cuando se combinan en el mismo elemento (CSS resuelve un
|
||||
* empate por orden de aparición en el fichero, no por el orden de las clases en el HTML). */
|
||||
.dropdown-button,
|
||||
.dropdown-toggle {
|
||||
padding: 0.5rem 0.75rem;
|
||||
font: inherit;
|
||||
color: var(--val-color--text);
|
||||
background-color: transparent;
|
||||
border: 1px solid var(--val-color--border);
|
||||
border-radius: 0.375rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.badge-danger { background-color: var(--val-color--danger); }
|
||||
|
||||
/*
|
||||
* Buttons
|
||||
|
|
@ -154,7 +101,6 @@ body {
|
|||
background-color: var(--val-color--primary);
|
||||
border: 1px solid var(--val-color--primary);
|
||||
border-radius: 0.375rem;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: background-color .15s ease-in-out, border-color .15s ease-in-out;
|
||||
}
|
||||
|
|
@ -170,19 +116,10 @@ body {
|
|||
border: 0;
|
||||
}
|
||||
|
||||
.button-sm {
|
||||
padding: 0.25rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.button-lg {
|
||||
padding: 0.75rem 1.5rem;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.button-primary,
|
||||
.button-neutral,
|
||||
.button-secondary,
|
||||
.button-success,
|
||||
.button-severe {
|
||||
.button-danger {
|
||||
color: #fff;
|
||||
}
|
||||
.button-info,
|
||||
|
|
@ -190,40 +127,40 @@ body {
|
|||
color: #000;
|
||||
}
|
||||
.button-primary { background-color: var(--val-color--primary); border-color: var(--val-color--primary); }
|
||||
.button-neutral { background-color: var(--val-color--secondary); border-color: var(--val-color--secondary); }
|
||||
.button-secondary { background-color: var(--val-color--secondary); border-color: var(--val-color--secondary); }
|
||||
.button-success { background-color: var(--val-color--success); border-color: var(--val-color--success); }
|
||||
.button-info { background-color: var(--val-color--info); border-color: var(--val-color--info); }
|
||||
.button-warning { background-color: var(--val-color--warning); border-color: var(--val-color--warning); }
|
||||
.button-severe { background-color: var(--val-color--danger); border-color: var(--val-color--danger); }
|
||||
.button-danger { background-color: var(--val-color--danger); border-color: var(--val-color--danger); }
|
||||
|
||||
.button-primary:hover { background-color: color-mix(in srgb, var(--val-color--primary) 85%, black); border-color: color-mix(in srgb, var(--val-color--primary) 85%, black); }
|
||||
.button-neutral:hover { background-color: color-mix(in srgb, var(--val-color--secondary) 85%, black); border-color: color-mix(in srgb, var(--val-color--secondary) 85%, black); }
|
||||
.button-secondary:hover { background-color: color-mix(in srgb, var(--val-color--secondary) 85%, black); border-color: color-mix(in srgb, var(--val-color--secondary) 85%, black); }
|
||||
.button-success:hover { background-color: color-mix(in srgb, var(--val-color--success) 85%, black); border-color: color-mix(in srgb, var(--val-color--success) 85%, black); }
|
||||
.button-info:hover { background-color: color-mix(in srgb, var(--val-color--info) 85%, black); border-color: color-mix(in srgb, var(--val-color--info) 85%, black); }
|
||||
.button-warning:hover { background-color: color-mix(in srgb, var(--val-color--warning) 85%, black); border-color: color-mix(in srgb, var(--val-color--warning) 85%, black); }
|
||||
.button-severe:hover { background-color: color-mix(in srgb, var(--val-color--danger) 85%, black); border-color: color-mix(in srgb, var(--val-color--danger) 85%, black); }
|
||||
.button-danger:hover { background-color: color-mix(in srgb, var(--val-color--danger) 85%, black); border-color: color-mix(in srgb, var(--val-color--danger) 85%, black); }
|
||||
|
||||
.button-outline-primary,
|
||||
.button-outline-neutral,
|
||||
.button-outline-secondary,
|
||||
.button-outline-success,
|
||||
.button-outline-info,
|
||||
.button-outline-warning,
|
||||
.button-outline-severe {
|
||||
.button-outline-danger {
|
||||
background-color: transparent;
|
||||
}
|
||||
.button-outline-primary { border-color: var(--val-color--primary); color: var(--val-color--primary); }
|
||||
.button-outline-neutral { border-color: var(--val-color--secondary); color: var(--val-color--secondary); }
|
||||
.button-outline-secondary { border-color: var(--val-color--secondary); color: var(--val-color--secondary); }
|
||||
.button-outline-success { border-color: var(--val-color--success); color: var(--val-color--success); }
|
||||
.button-outline-info { border-color: var(--val-color--info); color: var(--val-color--info); }
|
||||
.button-outline-warning { border-color: var(--val-color--warning); color: var(--val-color--warning); }
|
||||
.button-outline-severe { border-color: var(--val-color--danger); color: var(--val-color--danger); }
|
||||
.button-outline-danger { border-color: var(--val-color--danger); color: var(--val-color--danger); }
|
||||
|
||||
.button-outline-primary:hover { background-color: var(--val-color--primary); border-color: var(--val-color--primary); color: #fff; }
|
||||
.button-outline-neutral:hover { background-color: var(--val-color--secondary); border-color: var(--val-color--secondary); color: #fff; }
|
||||
.button-outline-secondary:hover { background-color: var(--val-color--secondary); border-color: var(--val-color--secondary); color: #fff; }
|
||||
.button-outline-success:hover { background-color: var(--val-color--success); border-color: var(--val-color--success); color: #fff; }
|
||||
.button-outline-info:hover { background-color: var(--val-color--info); border-color: var(--val-color--info); color: #000; }
|
||||
.button-outline-warning:hover { background-color: var(--val-color--warning); border-color: var(--val-color--warning); color: #000; }
|
||||
.button-outline-severe:hover { background-color: var(--val-color--danger); border-color: var(--val-color--danger); color: #fff; }
|
||||
.button-outline-danger:hover { background-color: var(--val-color--danger); border-color: var(--val-color--danger); color: #fff; }
|
||||
|
||||
.button-link {
|
||||
font-weight: 400;
|
||||
|
|
@ -247,171 +184,6 @@ body {
|
|||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/*
|
||||
* Dialog component
|
||||
*/
|
||||
|
||||
.dialog {
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
max-width: 32rem;
|
||||
color: var(--val-color--text);
|
||||
background-color: var(--val-color--bg);
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
.dialog::backdrop {
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.dialog-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.25rem;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--val-color--border) 15%, var(--val-color--bg));
|
||||
}
|
||||
|
||||
.dialog-title {
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dialog-close {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
color: var(--val-color--text--muted);
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.dialog-close::before {
|
||||
content: "\2715";
|
||||
}
|
||||
.dialog-close:hover {
|
||||
color: var(--val-color--text);
|
||||
}
|
||||
|
||||
.dialog-body {
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem 1.25rem;
|
||||
border-top: 1px solid color-mix(in srgb, var(--val-color--border) 15%, var(--val-color--bg));
|
||||
}
|
||||
|
||||
/*
|
||||
* Dropdown component
|
||||
*/
|
||||
|
||||
.dropdown {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* La geometría base de .dropdown-button/.dropdown-toggle vive junto a "Buttons" (antes de esta
|
||||
* sección), para que las clases .button-sm/.button-lg/.button-* ganen el empate de especificidad
|
||||
* frente a ella (CSS resuelve un empate por orden de aparición en el fichero, no por el orden de
|
||||
* las clases en el HTML). */
|
||||
.dropdown-toggle::after {
|
||||
content: "\25BC";
|
||||
display: inline-block;
|
||||
margin-left: 0.35em;
|
||||
font-size: 0.8em;
|
||||
transition: transform .15s ease-in-out;
|
||||
}
|
||||
.dropdown:has(> .dropdown-menu.show) > .dropdown-toggle::after {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
position: absolute;
|
||||
z-index: 1000;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
min-width: 12rem;
|
||||
margin: 0.25rem 0 0;
|
||||
padding: 0.5rem 0;
|
||||
list-style: none;
|
||||
background-color: var(--val-color--bg);
|
||||
border: 1px solid color-mix(in srgb, var(--val-color--border) 15%, var(--val-color--bg));
|
||||
border-radius: 0.375rem;
|
||||
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
|
||||
/* Closed by default */
|
||||
display: none;
|
||||
}
|
||||
.dropdown-menu.show {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0.375rem 1rem;
|
||||
text-align: left;
|
||||
color: var(--val-color--text);
|
||||
text-decoration: none;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.dropdown-item:hover {
|
||||
background-color: color-mix(in srgb, var(--val-color--text) 8%, transparent);
|
||||
}
|
||||
.dropdown-item.active {
|
||||
color: var(--val-color--primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
.dropdown-item.disabled {
|
||||
color: var(--val-color--text--muted);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.dropdown-item-text {
|
||||
display: block;
|
||||
padding: 0.375rem 1rem;
|
||||
color: var(--val-color--text--muted);
|
||||
}
|
||||
|
||||
.dropdown-header {
|
||||
display: block;
|
||||
padding: 0.5rem 1rem 0.25rem;
|
||||
margin: 0;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--val-color--text--muted);
|
||||
}
|
||||
|
||||
.dropdown-divider {
|
||||
height: 0;
|
||||
margin: 0.5rem 0;
|
||||
border: none;
|
||||
border-top: 1px solid var(--val-color--border);
|
||||
}
|
||||
|
||||
/* Utility: visually hidden but still accessible to assistive technology */
|
||||
.visually-hidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/*
|
||||
* Form components
|
||||
*/
|
||||
|
|
@ -511,20 +283,175 @@ input:disabled + label {
|
|||
color: var(--val-color--text--muted);
|
||||
}
|
||||
|
||||
.form-field > select.form-select,
|
||||
.form-field > input.form-control,
|
||||
.form-field > textarea.form-control {
|
||||
/*
|
||||
* Dialog component
|
||||
*/
|
||||
|
||||
.dialog {
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
max-width: 32rem;
|
||||
color: var(--val-color--text);
|
||||
background-color: var(--val-color--bg);
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
.dialog::backdrop {
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.dialog-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.25rem;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--val-color--border) 15%, var(--val-color--bg));
|
||||
}
|
||||
|
||||
.dialog-title {
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dialog-close {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
color: var(--val-color--text--muted);
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.dialog-close::before {
|
||||
content: "\2715";
|
||||
}
|
||||
.dialog-close:hover {
|
||||
color: var(--val-color--text);
|
||||
}
|
||||
|
||||
.dialog-body {
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem 1.25rem;
|
||||
border-top: 1px solid color-mix(in srgb, var(--val-color--border) 15%, var(--val-color--bg));
|
||||
}
|
||||
|
||||
/*
|
||||
* Dropdown component
|
||||
*/
|
||||
|
||||
.dropdown {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.dropdown-button,
|
||||
.dropdown-toggle {
|
||||
padding: 0.5rem 0.75rem;
|
||||
font: inherit;
|
||||
color: var(--val-color--text);
|
||||
background-color: transparent;
|
||||
border: 1px solid var(--val-color--border);
|
||||
border-radius: 0.375rem;
|
||||
background-color: #fff;
|
||||
transition: border-color .15s ease-in-out, box-shadow .15s ease-in-out;
|
||||
cursor: pointer;
|
||||
}
|
||||
.form-field > select.form-select:focus,
|
||||
.form-field > input.form-control:focus,
|
||||
.form-field > textarea.form-control:focus {
|
||||
outline: none;
|
||||
border-color: var(--val-color--primary);
|
||||
box-shadow: 0 0 0 0.2rem color-mix(in srgb, var(--val-color--primary) 25%, transparent);
|
||||
.dropdown-toggle::after {
|
||||
content: "\25BC";
|
||||
display: inline-block;
|
||||
margin-left: 0.35em;
|
||||
font-size: 0.8em;
|
||||
transition: transform .15s ease-in-out;
|
||||
}
|
||||
.dropdown:has(> .dropdown-menu.show) > .dropdown-toggle::after {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
position: absolute;
|
||||
z-index: 1000;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
min-width: 12rem;
|
||||
margin: 0.25rem 0 0;
|
||||
padding: 0.5rem 0;
|
||||
list-style: none;
|
||||
background-color: var(--val-color--bg);
|
||||
border: 1px solid color-mix(in srgb, var(--val-color--border) 15%, var(--val-color--bg));
|
||||
border-radius: 0.375rem;
|
||||
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
|
||||
/* Closed by default */
|
||||
display: none;
|
||||
}
|
||||
.dropdown-menu.show {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0.375rem 1rem;
|
||||
text-align: left;
|
||||
color: var(--val-color--text);
|
||||
text-decoration: none;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.dropdown-item:hover {
|
||||
background-color: color-mix(in srgb, var(--val-color--text) 8%, transparent);
|
||||
}
|
||||
.dropdown-item.active {
|
||||
color: var(--val-color--primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
.dropdown-item.disabled {
|
||||
color: var(--val-color--text--muted);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.dropdown-item-text {
|
||||
display: block;
|
||||
padding: 0.375rem 1rem;
|
||||
color: var(--val-color--text--muted);
|
||||
}
|
||||
|
||||
.dropdown-header {
|
||||
display: block;
|
||||
padding: 0.5rem 1rem 0.25rem;
|
||||
margin: 0;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--val-color--text--muted);
|
||||
}
|
||||
|
||||
.dropdown-divider {
|
||||
height: 0;
|
||||
margin: 0.5rem 0;
|
||||
border: none;
|
||||
border-top: 1px solid var(--val-color--border);
|
||||
}
|
||||
|
||||
/* Utility: visually hidden but still accessible to assistive technology */
|
||||
.visually-hidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/*
|
||||
|
|
@ -577,125 +504,61 @@ input:disabled + label {
|
|||
}
|
||||
|
||||
/*
|
||||
* Nav component
|
||||
* Table component
|
||||
*/
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
.table-responsive {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
position: relative;
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
display: block;
|
||||
.table th,
|
||||
.table td {
|
||||
padding: 0.5rem 0.75rem;
|
||||
color: var(--val-color--text);
|
||||
text-decoration: none;
|
||||
border-radius: 0.375rem;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--val-color--border) 15%, var(--val-color--bg));
|
||||
}
|
||||
.nav-link:hover {
|
||||
background-color: color-mix(in srgb, var(--val-color--text) 8%, transparent);
|
||||
}
|
||||
.nav-link.active {
|
||||
color: var(--val-color--primary);
|
||||
|
||||
.table thead th {
|
||||
border-bottom-width: 2px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.nav-link.disabled {
|
||||
color: var(--val-color--text--muted);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.nav-start {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.nav-center {
|
||||
justify-content: center;
|
||||
}
|
||||
.nav-end {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.nav-vertical {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
.nav-fill > .nav-item {
|
||||
flex: 1 1 auto;
|
||||
text-align: center;
|
||||
}
|
||||
.nav-justified > .nav-item {
|
||||
flex-basis: 0;
|
||||
flex-grow: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/*
|
||||
* Navbar component
|
||||
*/
|
||||
|
||||
.navbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
|
||||
.navbar-toggle {
|
||||
display: none;
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 1.25rem;
|
||||
line-height: 1;
|
||||
.table-sort {
|
||||
color: inherit;
|
||||
background-color: transparent;
|
||||
border: 1px solid var(--val-color--border);
|
||||
border-radius: 0.375rem;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
.navbar-toggle::before {
|
||||
content: "\2630";
|
||||
.table-sort:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.table-sort::after {
|
||||
content: "\25BC";
|
||||
margin-left: 0.25rem;
|
||||
font-size: 0.8em;
|
||||
opacity: 0.15;
|
||||
}
|
||||
.table-sort-asc::after {
|
||||
content: "\25B2";
|
||||
font-size: 0.8em;
|
||||
opacity: 1;
|
||||
}
|
||||
.table-sort-desc::after {
|
||||
content: "\25BC";
|
||||
font-size: 0.8em;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.navbar-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.navbar-text {
|
||||
.table-empty {
|
||||
text-align: center;
|
||||
font-style: italic;
|
||||
color: var(--val-color--text--muted);
|
||||
}
|
||||
|
||||
@media (max-width: 767.98px) {
|
||||
.navbar {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.navbar:has(.navbar-toggle) .navbar-toggle {
|
||||
display: inline-block;
|
||||
order: -1;
|
||||
}
|
||||
.navbar:has(.navbar-toggle) .navbar-content {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
width: 100%;
|
||||
}
|
||||
.navbar:has(.navbar-toggle) .navbar-content.show {
|
||||
display: flex;
|
||||
}
|
||||
.navbar .nav {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Pager component
|
||||
*/
|
||||
|
|
@ -813,7 +676,7 @@ input:disabled + label {
|
|||
border: 0;
|
||||
border-start-end-radius: 0;
|
||||
border-end-end-radius: 0;
|
||||
background-color: var(--val-color--bg--muted);
|
||||
background-color: var(--val-color--light);
|
||||
}
|
||||
.pager-jump-input > input.form-control {
|
||||
text-align: center;
|
||||
|
|
@ -836,60 +699,36 @@ input:disabled + label {
|
|||
}
|
||||
|
||||
/*
|
||||
* Table component
|
||||
* Breadcrumb component
|
||||
*/
|
||||
|
||||
.table-responsive {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
.breadcrumb {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
list-style: none;
|
||||
margin: 0 0 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
text-align: left;
|
||||
.breadcrumb-item + .breadcrumb-item {
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
|
||||
.table th,
|
||||
.table td {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--val-color--border) 15%, var(--val-color--bg));
|
||||
.breadcrumb-item + .breadcrumb-item::before {
|
||||
float: left;
|
||||
padding-right: 0.5rem;
|
||||
color: var(--val-color--text--muted);
|
||||
content: "/";
|
||||
}
|
||||
|
||||
.table thead th {
|
||||
border-bottom-width: 2px;
|
||||
font-weight: 600;
|
||||
.breadcrumb-item.active {
|
||||
color: var(--val-color--text--muted);
|
||||
}
|
||||
|
||||
.table-sort {
|
||||
color: inherit;
|
||||
.breadcrumb-item a {
|
||||
color: var(--val-color--text);
|
||||
text-decoration: none;
|
||||
}
|
||||
.table-sort:hover {
|
||||
.breadcrumb-item a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.table-sort::after {
|
||||
content: "\25BC";
|
||||
margin-left: 0.25rem;
|
||||
font-size: 0.8em;
|
||||
opacity: 0.15;
|
||||
}
|
||||
.table-sort-asc::after {
|
||||
content: "\25B2";
|
||||
font-size: 0.8em;
|
||||
opacity: 1;
|
||||
}
|
||||
.table-sort-desc::after {
|
||||
content: "\25BC";
|
||||
font-size: 0.8em;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.table-empty {
|
||||
text-align: center;
|
||||
font-style: italic;
|
||||
color: var(--val-color--text--muted);
|
||||
}
|
||||
|
||||
/*
|
||||
* Region Footer
|
||||
|
|
@ -899,76 +738,3 @@ input:disabled + label {
|
|||
padding: .75rem 0 3rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/*
|
||||
* pagetop-user: login page
|
||||
*/
|
||||
|
||||
.user-login-page {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 70vh;
|
||||
padding: 2rem 1rem;
|
||||
background: radial-gradient(
|
||||
ellipse 120% 60% at 50% -10%,
|
||||
color-mix(in srgb, var(--val-color--primary) 18%, var(--val-color--bg)) 0%,
|
||||
var(--val-color--bg) 70%
|
||||
);
|
||||
}
|
||||
|
||||
.user-login-card {
|
||||
width: 100%;
|
||||
max-width: 24rem;
|
||||
padding: 2rem;
|
||||
background-color: #fff;
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: 0 1rem 3rem rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.user-login-card .form-label {
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
|
||||
.user-login-card .button {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.user-admin-table-wrapper {
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
.user-admin-table {
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.user-admin-permission-list {
|
||||
list-style: none;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.user-admin-permission-granted {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.user-admin-permission-missing {
|
||||
color: var(--val-color--text--muted);
|
||||
}
|
||||
|
||||
.user-form-error {
|
||||
padding: 0.625rem 0.875rem;
|
||||
margin-bottom: 1rem;
|
||||
color: #fff;
|
||||
background-color: var(--val-color--danger);
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.user-register-link,
|
||||
.user-reset-link {
|
||||
margin-top: 1rem;
|
||||
text-align: center;
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ body {
|
|||
|
||||
.intro {
|
||||
position: relative;
|
||||
min-width: 350px;
|
||||
color: var(--intro-color);
|
||||
background-color: var(--intro-bg-color);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,18 +0,0 @@
|
|||
// Conecta cada botón `.navbar-toggle` con el contenido que controla (`aria-controls`), alternando
|
||||
// `aria-expanded` en el botón y la clase `.show` en el contenido. A diferencia de `Dropdown` no usa
|
||||
// `accessible-menu`: el contenido de una barra de navegación no es una única lista de elementos
|
||||
// (puede llevar varios `Nav`, texto, lo que sea), así que no encaja con el modelo de menú de esa
|
||||
// librería (ver `base/component/navbar/`).
|
||||
document.querySelectorAll(".navbar-toggle").forEach(function (toggle) {
|
||||
var id = toggle.getAttribute("aria-controls");
|
||||
var content = id && document.getElementById(id);
|
||||
if (!content) {
|
||||
return;
|
||||
}
|
||||
|
||||
toggle.addEventListener("click", function () {
|
||||
var expanded = toggle.getAttribute("aria-expanded") === "true";
|
||||
toggle.setAttribute("aria-expanded", String(!expanded));
|
||||
content.classList.toggle("show", !expanded);
|
||||
});
|
||||
});
|
||||
17
build.rs
17
build.rs
|
|
@ -1,4 +1,4 @@
|
|||
use pagetop_build::{StaticFilesBundle, compile_scss, copy_dir, minify_js};
|
||||
use pagetop_build::{StaticFilesBundle, copy_dir};
|
||||
|
||||
fn main() -> std::io::Result<()> {
|
||||
// Regenera `static/` desde cero sólo si hay cambios en `assets/`.
|
||||
|
|
@ -7,21 +7,6 @@ fn main() -> std::io::Result<()> {
|
|||
|
||||
copy_dir("assets", "static")?;
|
||||
|
||||
// CSS: genera la variante minificada de `basic.css` del tema Basic.
|
||||
compile_scss("assets/css/basic.css", "static/css/basic.min.css")?;
|
||||
|
||||
// JS: minifica el manejo de `Dialog` del tema Basic.
|
||||
minify_js(
|
||||
"assets/js/basic.dialog.init.js",
|
||||
"static/js/basic.dialog.min.js",
|
||||
)?;
|
||||
|
||||
// JS: minifica la integración de `Dropdown` con `accessible-menu` del tema Basic.
|
||||
minify_js(
|
||||
"assets/js/basic.dropdown.init.js",
|
||||
"static/js/basic.dropdown.min.js",
|
||||
)?;
|
||||
|
||||
StaticFilesBundle::from_dir("./static", None)
|
||||
.with_name("assets")
|
||||
.build()
|
||||
|
|
|
|||
|
|
@ -1,46 +0,0 @@
|
|||
use pagetop::prelude::*;
|
||||
|
||||
struct NavDemo;
|
||||
|
||||
#[async_trait]
|
||||
impl Extension for NavDemo {
|
||||
fn dependencies(&self) -> Vec<ExtensionRef> {
|
||||
vec![&pagetop::base::extension::Welcome]
|
||||
}
|
||||
|
||||
async fn initialize(&self) {
|
||||
let main_nav = Nav::new()
|
||||
.with_item(nav::Item::link(Lc::n("Home"), "/"))
|
||||
.with_item(nav::Item::link_blank(
|
||||
Lc::n("Docs"),
|
||||
"https://docs.rs/pagetop",
|
||||
))
|
||||
.with_item(nav::Item::dropdown(
|
||||
Dropdown::new()
|
||||
.with_title(Lc::n("Tools"))
|
||||
.with_item(dropdown::Item::link(Lc::n("Generator"), "/tools/gen"))
|
||||
.with_item(dropdown::Item::link(Lc::n("Reports"), "/tools/reports"))
|
||||
.with_item(dropdown::Item::divider())
|
||||
.with_item(dropdown::Item::header(Lc::n("Danger zone")))
|
||||
.with_item(dropdown::Item::button(Lc::n("Reset settings"))),
|
||||
))
|
||||
.with_item(nav::Item::link_disabled(Lc::n("Disabled"), "#"));
|
||||
|
||||
let user_menu = Dropdown::new()
|
||||
.with_title(Lc::n("Account"))
|
||||
.with_item(dropdown::Item::label(Lc::n("Signed in as demo")))
|
||||
.with_item(dropdown::Item::divider())
|
||||
.with_item(dropdown::Item::link(Lc::n("Profile"), "/profile"))
|
||||
.with_item(dropdown::Item::link(Lc::n("Settings"), "/settings"))
|
||||
.with_item(dropdown::Item::divider())
|
||||
.with_item(dropdown::Item::button(Lc::n("Sign out")));
|
||||
|
||||
InRegion::Global(&CoreRegions::Header).add(main_nav);
|
||||
InRegion::Global(&CoreRegions::Aside).add(user_menu);
|
||||
}
|
||||
}
|
||||
|
||||
#[pagetop::main]
|
||||
async fn main() -> std::io::Result<()> {
|
||||
Application::prepare(&NavDemo).await.run().await
|
||||
}
|
||||
|
|
@ -1,55 +0,0 @@
|
|||
use pagetop::prelude::*;
|
||||
|
||||
include_locales!(LOC from "examples/locale");
|
||||
|
||||
struct NavbarDemo;
|
||||
|
||||
#[async_trait]
|
||||
impl Extension for NavbarDemo {
|
||||
fn dependencies(&self) -> Vec<ExtensionRef> {
|
||||
vec![
|
||||
&pagetop_bootsier::Bootsier,
|
||||
&pagetop::base::extension::Welcome,
|
||||
]
|
||||
}
|
||||
|
||||
async fn initialize(&self) {
|
||||
let brand = Brand::new()
|
||||
.with_image(image::Source::logo(PageTopSvg::Color))
|
||||
.with_title(Lc::n("PageTop"))
|
||||
.with_route(Route::from("/"));
|
||||
|
||||
let main_nav = Nav::new()
|
||||
.with_item(nav::Item::link(Lc::t("navbar_home", &LOC), "/"))
|
||||
.with_item(nav::Item::link_blank(
|
||||
Lc::t("navbar_docs", &LOC),
|
||||
"https://docs.rs/pagetop",
|
||||
))
|
||||
.with_item(nav::Item::dropdown(
|
||||
Dropdown::new()
|
||||
.with_title(Lc::t("navbar_tools", &LOC))
|
||||
.with_item(dropdown::Item::link(
|
||||
Lc::t("navbar_generator", &LOC),
|
||||
"/tools/gen",
|
||||
))
|
||||
.with_item(dropdown::Item::link(
|
||||
Lc::t("navbar_reports", &LOC),
|
||||
"/tools/reports",
|
||||
)),
|
||||
))
|
||||
.with_item(nav::Item::link(Lc::t("navbar_active", &LOC), "/active").with_active(true))
|
||||
.with_item(nav::Item::link_disabled(
|
||||
Lc::t("navbar_disabled", &LOC),
|
||||
"#",
|
||||
));
|
||||
|
||||
let navbar = Navbar::brand_left(brand).with_item(navbar::Item::nav(main_nav));
|
||||
|
||||
InRegion::Global(&CoreRegions::Header).add(navbar);
|
||||
}
|
||||
}
|
||||
|
||||
#[pagetop::main]
|
||||
async fn main() -> std::io::Result<()> {
|
||||
Application::prepare(&NavbarDemo).await.run().await
|
||||
}
|
||||
|
|
@ -142,17 +142,18 @@ async fn form_controls(request: HttpRequest) -> Result<Markup, ErrorPage> {
|
|||
.with_child(
|
||||
button::ButtonSet::new()
|
||||
.with_button(
|
||||
Button::submit(Lc::t("btn_submit", &LOC))
|
||||
.with_style(button::Style::Solid(Intent::Primary)),
|
||||
Button::submit(Lc::t("btn_submit", &LOC)).with_style(
|
||||
button::ButtonStyle::Solid(Intent::Primary),
|
||||
),
|
||||
)
|
||||
.with_button(
|
||||
Button::reset(Lc::t("btn_reset", &LOC)).with_style(
|
||||
button::Style::Outline(Intent::Neutral),
|
||||
button::ButtonStyle::Outline(Intent::Secondary),
|
||||
),
|
||||
)
|
||||
.with_button(
|
||||
Button::plain(Lc::t("btn_cancel", &LOC))
|
||||
.with_style(button::Style::Link),
|
||||
.with_style(button::ButtonStyle::Link),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -257,17 +258,18 @@ async fn form_controls(request: HttpRequest) -> Result<Markup, ErrorPage> {
|
|||
.with_child(
|
||||
button::ButtonSet::new()
|
||||
.with_button(
|
||||
Button::submit(Lc::t("btn_submit", &LOC))
|
||||
.with_style(button::Style::Solid(Intent::Primary)),
|
||||
Button::submit(Lc::t("btn_submit", &LOC)).with_style(
|
||||
button::ButtonStyle::Solid(Intent::Primary),
|
||||
),
|
||||
)
|
||||
.with_button(
|
||||
Button::reset(Lc::t("btn_reset", &LOC)).with_style(
|
||||
button::Style::Outline(Intent::Neutral),
|
||||
button::ButtonStyle::Outline(Intent::Secondary),
|
||||
),
|
||||
)
|
||||
.with_button(
|
||||
Button::plain(Lc::t("btn_cancel", &LOC))
|
||||
.with_style(button::Style::Link),
|
||||
.with_style(button::ButtonStyle::Link),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -297,22 +299,24 @@ async fn form_controls(request: HttpRequest) -> Result<Markup, ErrorPage> {
|
|||
p { (Lc::t("dialog_delete_body", &LOC).using(cx)) }
|
||||
}
|
||||
}))
|
||||
.with_footer(
|
||||
Button::plain(Lc::t("btn_ok", &LOC))
|
||||
.with_prop(PropsOp::set("data-dialog-dismiss", "modal"))
|
||||
.with_style(button::Style::Solid(Intent::Primary)),
|
||||
)
|
||||
.with_footer(
|
||||
Button::plain(Lc::t("btn_cancel", &LOC))
|
||||
.with_prop(PropsOp::set("data-dialog-dismiss", "modal"))
|
||||
.with_style(button::Style::Outline(Intent::Neutral)),
|
||||
.with_style(button::ButtonStyle::Outline(
|
||||
Intent::Secondary,
|
||||
)),
|
||||
)
|
||||
.with_footer(
|
||||
Button::plain(Lc::t("btn_ok", &LOC))
|
||||
.with_prop(PropsOp::set("data-dialog-dismiss", "modal"))
|
||||
.with_style(button::ButtonStyle::Solid(Intent::Primary)),
|
||||
),
|
||||
)
|
||||
.with_child(
|
||||
Button::plain(Lc::t("btn_delete", &LOC))
|
||||
.with_prop(PropsOp::set("data-dialog-toggle", "modal"))
|
||||
.with_prop(PropsOp::set("data-dialog-target", "#delete-confirm"))
|
||||
.with_style(button::Style::Solid(Intent::Severe)),
|
||||
.with_style(button::ButtonStyle::Solid(Intent::Danger)),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
|
@ -433,14 +437,14 @@ fn form_lists() -> Form {
|
|||
button::ButtonSet::new()
|
||||
.with_button(
|
||||
Button::submit(Lc::t("btn_submit", &LOC))
|
||||
.with_style(button::Style::Solid(Intent::Primary)),
|
||||
.with_style(button::ButtonStyle::Solid(Intent::Primary)),
|
||||
)
|
||||
.with_button(
|
||||
Button::reset(Lc::t("btn_reset", &LOC))
|
||||
.with_style(button::Style::Outline(Intent::Neutral)),
|
||||
.with_style(button::ButtonStyle::Outline(Intent::Secondary)),
|
||||
)
|
||||
.with_button(
|
||||
Button::plain(Lc::t("btn_cancel", &LOC)).with_style(button::Style::Link),
|
||||
Button::plain(Lc::t("btn_cancel", &LOC)).with_style(button::ButtonStyle::Link),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +0,0 @@
|
|||
navbar_home = Home
|
||||
navbar_docs = Docs
|
||||
navbar_tools = Tools
|
||||
navbar_generator = Generator
|
||||
navbar_reports = Reports
|
||||
navbar_active = Active page
|
||||
navbar_disabled = Disabled
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
navbar_home = Inicio
|
||||
navbar_docs = Documentación
|
||||
navbar_tools = Herramientas
|
||||
navbar_generator = Generador
|
||||
navbar_reports = Informes
|
||||
navbar_active = Página activa
|
||||
navbar_disabled = Deshabilitado
|
||||
|
|
@ -47,14 +47,6 @@ pub use intro::{Intro, IntroOpening};
|
|||
mod messages;
|
||||
pub use messages::Messages;
|
||||
|
||||
pub mod nav;
|
||||
#[doc(inline)]
|
||||
pub use nav::Nav;
|
||||
|
||||
pub mod navbar;
|
||||
#[doc(inline)]
|
||||
pub use navbar::Navbar;
|
||||
|
||||
mod pager;
|
||||
pub use pager::{Pager, PagerAlign, PagerVisibility};
|
||||
|
||||
|
|
|
|||
|
|
@ -6,10 +6,10 @@ use crate::prelude::*;
|
|||
///
|
||||
/// ```rust,no_run
|
||||
/// # use pagetop::prelude::*;
|
||||
/// let badge = Badge::labeled(Lc::n("Admin")).with_intent(Intent::Severe);
|
||||
/// let badge = Badge::labeled(Lc::n("Admin")).with_intent(Intent::Danger);
|
||||
///
|
||||
/// // Equivalente usando el constructor directo del tipo.
|
||||
/// let badge = Badge::severe(Lc::n("Admin"));
|
||||
/// let badge = Badge::danger(Lc::n("Admin"));
|
||||
/// ```
|
||||
#[derive(AutoDefault, Clone, Debug, Getters)]
|
||||
pub struct Badge {
|
||||
|
|
@ -19,7 +19,7 @@ pub struct Badge {
|
|||
label: Lc,
|
||||
/// Devuelve la intención semántica del badge.
|
||||
#[getters(copy)]
|
||||
#[default(Intent::Neutral)]
|
||||
#[default(Intent::Secondary)]
|
||||
intent: Intent,
|
||||
}
|
||||
|
||||
|
|
@ -33,10 +33,10 @@ impl Component for Badge {
|
|||
self.props.get_id()
|
||||
}
|
||||
|
||||
fn setup(&mut self, cx: &Context) {
|
||||
fn setup(&mut self, _cx: &Context) {
|
||||
self.alter_prop(PropsOp::prepend_classes(util::join!(
|
||||
"badge badge-",
|
||||
self.intent().color(cx)
|
||||
self.intent().as_str()
|
||||
)));
|
||||
}
|
||||
|
||||
|
|
@ -67,11 +67,11 @@ impl Badge {
|
|||
}
|
||||
}
|
||||
|
||||
/// Crea un badge de tipo *neutral* con la etiqueta indicada.
|
||||
pub fn neutral(label: Lc) -> Self {
|
||||
/// Crea un badge de tipo *secondary* con la etiqueta indicada.
|
||||
pub fn secondary(label: Lc) -> Self {
|
||||
Self {
|
||||
label,
|
||||
intent: Intent::Neutral,
|
||||
intent: Intent::Secondary,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
|
@ -103,11 +103,11 @@ impl Badge {
|
|||
}
|
||||
}
|
||||
|
||||
/// Crea un badge de tipo *severe* con la etiqueta indicada.
|
||||
pub fn severe(label: Lc) -> Self {
|
||||
/// Crea un badge de tipo *danger* con la etiqueta indicada.
|
||||
pub fn danger(label: Lc) -> Self {
|
||||
Self {
|
||||
label,
|
||||
intent: Intent::Severe,
|
||||
intent: Intent::Danger,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
//! Definiciones para crear botones ([`Button`]) y conjuntos de botones ([`ButtonSet`]).
|
||||
|
||||
mod props;
|
||||
pub use props::{Kind, Size, Style};
|
||||
pub use props::{ButtonKind, ButtonStyle};
|
||||
|
||||
mod component;
|
||||
pub use component::Button;
|
||||
|
|
|
|||
|
|
@ -7,12 +7,6 @@ use crate::prelude::*;
|
|||
/// - [`Button::submit()`]: botón de envío (por defecto).
|
||||
/// - [`Button::reset()`]: botón de restablecimiento de valores.
|
||||
/// - [`Button::plain()`]: botón genérico sin comportamiento predeterminado.
|
||||
/// - [`Button::anchor()`]: enlace de navegación real con el aspecto de un botón.
|
||||
///
|
||||
/// No confundir [`Button::anchor()`] con [`button::Style::Link`]: el primero renderiza un `<a
|
||||
/// href=...>` real que navega; el segundo es sólo un estilo visual (clase `button-link`) que se
|
||||
/// aplica con [`with_style()`](Self::with_style) sobre cualquiera de las otras variantes, que
|
||||
/// siguen siendo un `<button>`.
|
||||
///
|
||||
/// Un botón puede usarse dentro o fuera de un formulario.
|
||||
///
|
||||
|
|
@ -24,7 +18,6 @@ use crate::prelude::*;
|
|||
/// let save = Button::submit(Lc::n("Save"));
|
||||
/// let cancel = Button::plain(Lc::n("Cancel"));
|
||||
/// let clear = Button::reset(Lc::n("Clear"));
|
||||
/// let edit = Button::anchor(Lc::n("Edit"), "/items/1/edit");
|
||||
/// ```
|
||||
///
|
||||
/// Cuando el botón activa el envío, el navegador incluye el par `name=value` en los datos del
|
||||
|
|
@ -43,13 +36,10 @@ pub struct Button {
|
|||
/// Devuelve identificador, clases CSS, atributos HTML y valores extra del componente.
|
||||
props: Props,
|
||||
/// Devuelve el comportamiento del botón al activarse.
|
||||
kind: button::Kind,
|
||||
/// Devuelve el tamaño visual del botón.
|
||||
#[getters(copy)]
|
||||
size: button::Size,
|
||||
kind: button::ButtonKind,
|
||||
/// Devuelve el estilo visual del botón.
|
||||
#[getters(copy)]
|
||||
style: button::Style,
|
||||
style: button::ButtonStyle,
|
||||
/// Devuelve el nombre del botón.
|
||||
name: AttrName,
|
||||
/// Devuelve el valor del botón.
|
||||
|
|
@ -58,10 +48,6 @@ pub struct Button {
|
|||
label: Lc,
|
||||
/// Devuelve el texto emergente del botón (atributo `title`).
|
||||
title: Lc,
|
||||
/// Devuelve la ruta de destino cuando el botón se renderiza como enlace de navegación
|
||||
/// (`<a href=...>` en vez de `<button>`). Vacía por defecto: en ese caso `prepare()` renderiza
|
||||
/// un `<button>` normal, ignorando este campo.
|
||||
href: Route,
|
||||
/// Devuelve si el botón recibe el foco automáticamente al cargar la página.
|
||||
autofocus: bool,
|
||||
/// Devuelve si el botón está deshabilitado.
|
||||
|
|
@ -78,43 +64,18 @@ impl Component for Button {
|
|||
self.props.get_id()
|
||||
}
|
||||
|
||||
fn setup(&mut self, cx: &Context) {
|
||||
use button::{Size, Style};
|
||||
fn setup(&mut self, _cx: &Context) {
|
||||
use button::ButtonStyle;
|
||||
|
||||
self.alter_prop(PropsOp::prepend_classes(match self.size() {
|
||||
Size::None => "",
|
||||
Size::Small => "button-sm",
|
||||
Size::Large => "button-lg",
|
||||
}));
|
||||
self.alter_prop(PropsOp::prepend_classes(match self.style() {
|
||||
Style::None => "button".to_string(),
|
||||
Style::Solid(intent) => util::join!("button button-", intent.color(cx)),
|
||||
Style::Outline(intent) => util::join!("button button-outline-", intent.color(cx)),
|
||||
Style::Link => "button button-link".to_string(),
|
||||
ButtonStyle::None => "button".to_string(),
|
||||
ButtonStyle::Solid(intent) => util::join!("button button-", intent.as_str()),
|
||||
ButtonStyle::Outline(intent) => util::join!("button button-outline-", intent.as_str()),
|
||||
ButtonStyle::Link => "button button-link".to_string(),
|
||||
}));
|
||||
}
|
||||
|
||||
async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
|
||||
if let Some(route) = self.href().try_resolve(cx) {
|
||||
let disabled = *self.disabled();
|
||||
let href = (!disabled).then_some(route);
|
||||
let aria_disabled = disabled.then_some("true");
|
||||
let tabindex = disabled.then_some("-1");
|
||||
|
||||
return Ok(html! {
|
||||
a
|
||||
(self.props())
|
||||
href=[href]
|
||||
title=[self.title().lookup(cx)]
|
||||
autofocus[*self.autofocus()]
|
||||
aria-disabled=[aria_disabled]
|
||||
tabindex=[tabindex]
|
||||
{
|
||||
(self.label().using(cx))
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Ok(html! {
|
||||
button
|
||||
type=(self.kind())
|
||||
|
|
@ -125,7 +86,9 @@ impl Component for Button {
|
|||
autofocus[*self.autofocus()]
|
||||
disabled[*self.disabled()]
|
||||
{
|
||||
(self.label().using(cx))
|
||||
@if let Some(label) = self.label().lookup(cx) {
|
||||
(label)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -138,7 +101,7 @@ impl Button {
|
|||
/// datos al servidor.
|
||||
pub fn submit(label: Lc) -> Self {
|
||||
Self {
|
||||
kind: button::Kind::Submit,
|
||||
kind: button::ButtonKind::Submit,
|
||||
label,
|
||||
..Default::default()
|
||||
}
|
||||
|
|
@ -149,7 +112,7 @@ impl Button {
|
|||
/// Al pulsarlo, devuelve todos los campos del formulario a sus valores iniciales.
|
||||
pub fn reset(label: Lc) -> Self {
|
||||
Self {
|
||||
kind: button::Kind::Reset,
|
||||
kind: button::ButtonKind::Reset,
|
||||
label,
|
||||
..Default::default()
|
||||
}
|
||||
|
|
@ -161,28 +124,12 @@ impl Button {
|
|||
/// definirse mediante JavaScript.
|
||||
pub fn plain(label: Lc) -> Self {
|
||||
Self {
|
||||
kind: button::Kind::Plain,
|
||||
kind: button::ButtonKind::Plain,
|
||||
label,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Crea un **enlace de navegación** con el aspecto de un botón (`<a href=...>`).
|
||||
///
|
||||
/// A diferencia de [`Button::submit()`], [`Button::reset()`] y [`Button::plain()`], que
|
||||
/// siempre renderizan un `<button>`, este constructor produce un enlace real. Navega a `route`
|
||||
/// en vez de interactuar con un formulario. Se aplican las mismas clases de estilo (ver
|
||||
/// [`with_style()`](Self::with_style)), por lo que el tema activo puede mostrarlo igual que
|
||||
/// cualquier otra variante de `Button`. No confundir con [`button::Style::Link`], que es sólo
|
||||
/// un estilo visual sobre un `<button>`.
|
||||
pub fn anchor(label: Lc, route: impl Into<Route>) -> Self {
|
||||
Self {
|
||||
label,
|
||||
href: route.into(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
// **< Button BUILDER >*************************************************************************
|
||||
|
||||
/// Establece el identificador único del componente; igual a `with_prop(PropsOp::set_id(id))`.
|
||||
|
|
@ -201,21 +148,14 @@ impl Button {
|
|||
|
||||
/// Establece el comportamiento del botón al activarse.
|
||||
#[builder_fn]
|
||||
pub fn with_kind(mut self, kind: button::Kind) -> Self {
|
||||
pub fn with_kind(mut self, kind: button::ButtonKind) -> Self {
|
||||
self.kind = kind;
|
||||
self
|
||||
}
|
||||
|
||||
/// Establece el tamaño visual del botón (usa [`button::Size::None`] para quitarlo).
|
||||
/// Establece el estilo visual del botón (usa [`button::ButtonStyle::None`] para quitarlo).
|
||||
#[builder_fn]
|
||||
pub fn with_size(mut self, size: button::Size) -> Self {
|
||||
self.size = size;
|
||||
self
|
||||
}
|
||||
|
||||
/// Establece el estilo visual del botón (usa [`button::Style::None`] para quitarlo).
|
||||
#[builder_fn]
|
||||
pub fn with_style(mut self, style: button::Style) -> Self {
|
||||
pub fn with_style(mut self, style: button::ButtonStyle) -> Self {
|
||||
self.style = style;
|
||||
self
|
||||
}
|
||||
|
|
@ -254,15 +194,6 @@ impl Button {
|
|||
self
|
||||
}
|
||||
|
||||
/// Establece la ruta de destino y convierte el botón en enlace de navegación (`<a href=...>`).
|
||||
/// Puedes usar un [`Route`] vacío (por defecto) para que vuelva a renderizarse como `<button>`.
|
||||
/// Ver [`Button::anchor()`] para el constructor equivalente.
|
||||
#[builder_fn]
|
||||
pub fn with_href(mut self, route: impl Into<Route>) -> Self {
|
||||
self.href = route.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Establece si el botón recibe el foco automáticamente al cargar la página.
|
||||
#[builder_fn]
|
||||
pub fn with_autofocus(mut self, autofocus: bool) -> Self {
|
||||
|
|
|
|||
|
|
@ -2,11 +2,27 @@ use crate::prelude::*;
|
|||
|
||||
use std::fmt;
|
||||
|
||||
// **< Kind >***************************************************************************************
|
||||
// **< ButtonStyle >********************************************************************************
|
||||
|
||||
/// Estilo visual de un [`Button`](super::Button).
|
||||
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
|
||||
pub enum ButtonStyle {
|
||||
/// Sin clase de estilo (estilo por defecto del tema).
|
||||
#[default]
|
||||
None,
|
||||
/// Botón sólido: genera la clase `button-{color}`.
|
||||
Solid(Intent),
|
||||
/// Botón con contorno: genera la clase `button-outline-{color}`.
|
||||
Outline(Intent),
|
||||
/// Botón tipo enlace: genera la clase `button-link`.
|
||||
Link,
|
||||
}
|
||||
|
||||
// **< ButtonKind >*********************************************************************************
|
||||
|
||||
/// Comportamiento de un [`Button`](super::Button) al activarse.
|
||||
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
|
||||
pub enum Kind {
|
||||
pub enum ButtonKind {
|
||||
/// Envía un formulario al servidor. Es el **tipo por defecto**.
|
||||
#[default]
|
||||
Submit,
|
||||
|
|
@ -17,44 +33,12 @@ pub enum Kind {
|
|||
Plain,
|
||||
}
|
||||
|
||||
impl fmt::Display for Kind {
|
||||
impl fmt::Display for ButtonKind {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(match self {
|
||||
Kind::Submit => "submit",
|
||||
Kind::Reset => "reset",
|
||||
Kind::Plain => "button",
|
||||
ButtonKind::Submit => "submit",
|
||||
ButtonKind::Reset => "reset",
|
||||
ButtonKind::Plain => "button",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// **< Size >***************************************************************************************
|
||||
|
||||
/// Tamaño visual de un [`Button`](super::Button).
|
||||
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
|
||||
pub enum Size {
|
||||
/// Sin clase de tamaño (tamaño por defecto del tema).
|
||||
#[default]
|
||||
None,
|
||||
/// Botón compacto: genera la clase `button-sm`.
|
||||
Small,
|
||||
/// Botón grande: genera la clase `button-lg`.
|
||||
Large,
|
||||
}
|
||||
|
||||
// **< Style >**************************************************************************************
|
||||
|
||||
/// Estilo visual de un [`Button`](super::Button).
|
||||
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
|
||||
pub enum Style {
|
||||
/// Sin clase de estilo (estilo por defecto del tema).
|
||||
#[default]
|
||||
None,
|
||||
/// Botón sólido: genera la clase `button-{color}`.
|
||||
Solid(Intent),
|
||||
/// Botón con contorno: genera la clase `button-outline-{color}`.
|
||||
Outline(Intent),
|
||||
/// Botón tipo enlace: genera la clase `button-link`. Es sólo un estilo visual; el elemento
|
||||
/// sigue siendo un `<button>`. Para un enlace de navegación real, usa
|
||||
/// [`Button::anchor()`](super::Button::anchor) en su lugar.
|
||||
Link,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,12 +31,6 @@ pub struct Dropdown {
|
|||
title: Lc,
|
||||
/// Devuelve si el botón se desdobla (*split*) en botón de acción más *toggle*.
|
||||
button_split: bool,
|
||||
/// Devuelve el tamaño visual del botón.
|
||||
#[getters(copy)]
|
||||
button_size: button::Size,
|
||||
/// Devuelve el estilo visual del botón.
|
||||
#[getters(copy)]
|
||||
button_style: button::Style,
|
||||
/// Devuelve la lista de elementos del menú.
|
||||
items: Children,
|
||||
}
|
||||
|
|
@ -71,44 +65,15 @@ impl Component for Dropdown {
|
|||
});
|
||||
}
|
||||
|
||||
// Sin tamaño ni estilo (caso más común), evita construir dinámicamente las clases.
|
||||
let (button_classes, toggle_classes): (CowStr, CowStr) =
|
||||
if matches!(self.button_size(), button::Size::None)
|
||||
&& matches!(self.button_style(), button::Style::None)
|
||||
{
|
||||
(
|
||||
CowStr::from("dropdown-button"),
|
||||
CowStr::from("dropdown-toggle"),
|
||||
)
|
||||
} else {
|
||||
use button::{Size, Style};
|
||||
|
||||
let size_class = match self.button_size() {
|
||||
Size::None => "",
|
||||
Size::Small => " button-sm",
|
||||
Size::Large => " button-lg",
|
||||
};
|
||||
let style_class = match self.button_style() {
|
||||
Style::None => String::new(),
|
||||
Style::Solid(intent) => util::join!(" button-", intent.color(cx)),
|
||||
Style::Outline(intent) => util::join!(" button-outline-", intent.color(cx)),
|
||||
Style::Link => " button-link".to_string(),
|
||||
};
|
||||
(
|
||||
util::join!("dropdown-button", size_class, &style_class).into(),
|
||||
util::join!("dropdown-toggle", size_class, &style_class).into(),
|
||||
)
|
||||
};
|
||||
|
||||
let toggle_label = Lc::l("dropdown_toggle").using(cx);
|
||||
|
||||
Ok(html! {
|
||||
div (self.props()) {
|
||||
@if *self.button_split() {
|
||||
button type="button" class=(&button_classes) { (&title) }
|
||||
button type="button" class="dropdown-button" { (&title) }
|
||||
button
|
||||
type="button"
|
||||
class=(&toggle_classes)
|
||||
class="dropdown-toggle"
|
||||
aria-haspopup="true"
|
||||
aria-expanded="false"
|
||||
{
|
||||
|
|
@ -117,7 +82,7 @@ impl Component for Dropdown {
|
|||
} @else {
|
||||
button
|
||||
type="button"
|
||||
class=(&toggle_classes)
|
||||
class="dropdown-toggle"
|
||||
aria-haspopup="true"
|
||||
aria-expanded="false"
|
||||
{
|
||||
|
|
@ -161,20 +126,6 @@ impl Dropdown {
|
|||
self
|
||||
}
|
||||
|
||||
/// Establece el tamaño visual del botón (usa [`button::Size::None`] para quitarlo).
|
||||
#[builder_fn]
|
||||
pub fn with_button_size(mut self, size: button::Size) -> Self {
|
||||
self.button_size = size;
|
||||
self
|
||||
}
|
||||
|
||||
/// Establece el estilo visual del botón (usa [`button::Style::None`] para quitarlo).
|
||||
#[builder_fn]
|
||||
pub fn with_button_style(mut self, style: button::Style) -> Self {
|
||||
self.button_style = style;
|
||||
self
|
||||
}
|
||||
|
||||
/// Añade un nuevo elemento al menú o modifica la lista de elementos del menú con una operación
|
||||
/// [`TypedOp`].
|
||||
///
|
||||
|
|
|
|||
|
|
@ -1,10 +0,0 @@
|
|||
//! Definiciones para crear menús de navegación planos ([`Nav`]) y sus elementos ([`Item`]).
|
||||
|
||||
mod props;
|
||||
pub use props::Layout;
|
||||
|
||||
mod component;
|
||||
pub use component::Nav;
|
||||
|
||||
mod item;
|
||||
pub use item::{Item, ItemKind};
|
||||
|
|
@ -1,113 +0,0 @@
|
|||
use crate::prelude::*;
|
||||
|
||||
/// Componente para crear un **menú de navegación plano**.
|
||||
///
|
||||
/// Presenta una lista de elementos [`nav::Item`](super::Item), donde alguno puede desplegar un
|
||||
/// [`Dropdown`](super::super::Dropdown) embebido.
|
||||
///
|
||||
/// Si no contiene elementos, el componente **no se renderiza**.
|
||||
///
|
||||
/// # Ejemplo
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// use pagetop::prelude::*;
|
||||
///
|
||||
/// let nav = nav::Nav::new()
|
||||
/// .with_item(nav::Item::link(Lc::n("Home"), "/"))
|
||||
/// .with_item(nav::Item::link_blank(Lc::n("External"), "https://docs.rs"))
|
||||
/// .with_item(nav::Item::dropdown(
|
||||
/// dropdown::Dropdown::new()
|
||||
/// .with_title(Lc::n("Options"))
|
||||
/// .with_item(dropdown::Item::link(Lc::n("Action"), "/action"))
|
||||
/// .with_item(dropdown::Item::link(Lc::n("Another"), "/another")),
|
||||
/// ))
|
||||
/// .with_item(nav::Item::link_disabled(Lc::n("Disabled"), "#"));
|
||||
/// ```
|
||||
#[derive(AutoDefault, Clone, Debug, Getters)]
|
||||
pub struct Nav {
|
||||
/// Devuelve identificador, clases CSS, atributos HTML y valores extra del componente.
|
||||
props: Props,
|
||||
/// Devuelve la distribución y orientación seleccionada.
|
||||
nav_layout: nav::Layout,
|
||||
/// Devuelve la lista de elementos del menú.
|
||||
items: Children,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Component for Nav {
|
||||
fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
fn id(&self) -> Option<String> {
|
||||
self.props.get_id()
|
||||
}
|
||||
|
||||
fn setup(&mut self, _cx: &Context) {
|
||||
self.alter_prop(PropsOp::prepend_classes(match self.nav_layout() {
|
||||
nav::Layout::Default => "nav",
|
||||
nav::Layout::Start => "nav nav-start",
|
||||
nav::Layout::Center => "nav nav-center",
|
||||
nav::Layout::End => "nav nav-end",
|
||||
nav::Layout::Vertical => "nav nav-vertical",
|
||||
nav::Layout::Fill => "nav nav-fill",
|
||||
nav::Layout::Justified => "nav nav-justified",
|
||||
}));
|
||||
}
|
||||
|
||||
async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
|
||||
let items = self.items().render(cx).await;
|
||||
if items.is_empty() {
|
||||
return Ok(html! {});
|
||||
}
|
||||
|
||||
Ok(html! {
|
||||
ul (self.props()) {
|
||||
(items)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Nav {
|
||||
// **< Nav BUILDER >****************************************************************************
|
||||
|
||||
/// Establece el identificador único del componente; igual a `with_prop(PropsOp::set_id(id))`.
|
||||
#[builder_fn]
|
||||
pub fn with_id(mut self, id: impl Into<CowStr>) -> Self {
|
||||
self.props.alter_id(id);
|
||||
self
|
||||
}
|
||||
|
||||
/// Modifica identificador, clases CSS, atributos HTML o valores extra del componente.
|
||||
#[builder_fn]
|
||||
pub fn with_prop(mut self, op: PropsOp) -> Self {
|
||||
self.props.alter_prop(op);
|
||||
self
|
||||
}
|
||||
|
||||
/// Selecciona la distribución y orientación del menú.
|
||||
#[builder_fn]
|
||||
pub fn with_layout(mut self, layout: nav::Layout) -> Self {
|
||||
self.nav_layout = layout;
|
||||
self
|
||||
}
|
||||
|
||||
/// Añade un nuevo elemento al menú o modifica la lista de elementos del menú con una
|
||||
/// operación [`TypedOp`].
|
||||
///
|
||||
/// # Ejemplo
|
||||
///
|
||||
/// ```rust,ignore
|
||||
/// nav.with_item(nav::Item::link("Inicio", "/"));
|
||||
/// nav.with_item(TypedOp::AddMany(vec![
|
||||
/// nav::Item::link(...),
|
||||
/// nav::Item::link_disabled(...),
|
||||
/// ]));
|
||||
/// ```
|
||||
#[builder_fn]
|
||||
pub fn with_item(mut self, op: impl Into<TypedOp<nav::Item>>) -> Self {
|
||||
self.items.alter_child(op.into());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
|
@ -1,291 +0,0 @@
|
|||
use crate::prelude::*;
|
||||
|
||||
// **< ItemKind >***********************************************************************************
|
||||
|
||||
/// Tipos de [`nav::Item`](super::Item) disponibles en un menú [`Nav`](super::Nav).
|
||||
///
|
||||
/// Define internamente la naturaleza del elemento y su comportamiento al mostrarse o interactuar
|
||||
/// con él.
|
||||
#[derive(AutoDefault, Clone, Debug)]
|
||||
pub enum ItemKind {
|
||||
/// Elemento vacío, no produce salida.
|
||||
#[default]
|
||||
Void,
|
||||
/// Etiqueta sin comportamiento interactivo.
|
||||
Label(Lc),
|
||||
/// Elemento de navegación basado en una [`RoutePath`] dinámica resuelta por una [`Route`].
|
||||
/// Opcionalmente, puede abrirse en una nueva ventana y estar inicialmente deshabilitado.
|
||||
Link {
|
||||
label: Lc,
|
||||
route: Route,
|
||||
blank: bool,
|
||||
disabled: bool,
|
||||
},
|
||||
/// Contenido HTML arbitrario. El componente [`Html`] se renderiza tal cual como elemento del
|
||||
/// menú, sin añadir ningún comportamiento de navegación adicional.
|
||||
Html(Embed<Html>),
|
||||
/// Elemento que despliega un menú [`Dropdown`](super::super::Dropdown).
|
||||
Dropdown(Embed<Dropdown>),
|
||||
}
|
||||
|
||||
impl ItemKind {
|
||||
const ITEM: &str = "nav-item";
|
||||
const DROPDOWN: &str = "nav-item dropdown";
|
||||
|
||||
/// Devuelve las clases base asociadas al tipo de elemento.
|
||||
#[inline]
|
||||
pub const fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Void => "",
|
||||
Self::Dropdown(_) => Self::DROPDOWN,
|
||||
_ => Self::ITEM,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// **< Item >***************************************************************************************
|
||||
|
||||
/// Representa un **elemento individual** de un menú [`Nav`](super::Nav).
|
||||
///
|
||||
/// Cada instancia de [`nav::Item`](super::Item) se traduce en un componente visible que puede
|
||||
/// comportarse como texto, enlace, contenido HTML o menú desplegable, según su [`ItemKind`].
|
||||
///
|
||||
/// Permite definir el identificador, las clases de estilo adicionales y el tipo de interacción
|
||||
/// asociada, manteniendo una interfaz común para renderizar todos los elementos del menú.
|
||||
#[derive(AutoDefault, Clone, Debug, Getters)]
|
||||
pub struct Item {
|
||||
/// Devuelve identificador, clases CSS, atributos HTML y valores extra del componente.
|
||||
props: Props,
|
||||
/// Devuelve el tipo de elemento representado.
|
||||
item_kind: ItemKind,
|
||||
/// Devuelve el valor de `active` forzado explícitamente, si lo hay.
|
||||
///
|
||||
/// Sin forzar (`None`), un [`ItemKind::Link`] se marca activo cuando su ruta coincide
|
||||
/// exactamente con la del *request* actual (ver [`prepare()`](Component::prepare)). Forzarlo
|
||||
/// permite otros criterios, como marcar activa una sección mientras la ruta actual sea
|
||||
/// cualquiera de sus subrutas.
|
||||
active_override: Option<bool>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Component for Item {
|
||||
fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
fn id(&self) -> Option<String> {
|
||||
self.props.get_id()
|
||||
}
|
||||
|
||||
fn setup(&mut self, _cx: &Context) {
|
||||
self.alter_prop(PropsOp::prepend_classes(self.item_kind().as_str()));
|
||||
}
|
||||
|
||||
async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
|
||||
Ok(match self.item_kind() {
|
||||
ItemKind::Void => html! {},
|
||||
|
||||
ItemKind::Label(label) => html! {
|
||||
li (self.props()) {
|
||||
span class="nav-link disabled" aria-disabled="true" {
|
||||
(label.using(cx))
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
ItemKind::Link {
|
||||
label,
|
||||
route,
|
||||
blank,
|
||||
disabled,
|
||||
} => {
|
||||
let enabled = !*disabled;
|
||||
|
||||
// Deshabilitado, `href` no la usa: evita resolver la ruta para nada.
|
||||
let route_link = enabled.then(|| route.resolve(cx));
|
||||
let current_path = cx.request().map(|request| request.path());
|
||||
let is_current = self
|
||||
.active_override()
|
||||
.copied()
|
||||
.unwrap_or(enabled && route_link.as_ref().map(RoutePath::path) == current_path);
|
||||
|
||||
let active_class = if is_current { " active" } else { "" };
|
||||
let disabled_class = if *disabled { " disabled" } else { "" };
|
||||
let classes = util::join!("nav-link", active_class, disabled_class);
|
||||
|
||||
let target = (enabled && *blank).then_some("_blank");
|
||||
let rel = (enabled && *blank).then_some("noopener noreferrer");
|
||||
let aria_current = (enabled && is_current).then_some("page");
|
||||
let aria_disabled = (*disabled).then_some("true");
|
||||
|
||||
html! {
|
||||
li (self.props()) {
|
||||
a
|
||||
class=(classes)
|
||||
href=[route_link]
|
||||
target=[target]
|
||||
rel=[rel]
|
||||
aria-current=[aria_current]
|
||||
aria-disabled=[aria_disabled]
|
||||
{
|
||||
(label.using(cx))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ItemKind::Html(html) => html! {
|
||||
li (self.props()) {
|
||||
(html.render(cx).await)
|
||||
}
|
||||
},
|
||||
|
||||
ItemKind::Dropdown(menu) => {
|
||||
if let Some(dd) = menu.get() {
|
||||
let items = dd.items().render(cx).await;
|
||||
if items.is_empty() {
|
||||
return Ok(html! {});
|
||||
}
|
||||
let title = dd.title().using(cx);
|
||||
let title = if title.is_empty() {
|
||||
Lc::l("dropdown_default_title").using(cx)
|
||||
} else {
|
||||
title
|
||||
};
|
||||
html! {
|
||||
li (self.props()) {
|
||||
a
|
||||
class="nav-link dropdown-toggle"
|
||||
href="#"
|
||||
role="button"
|
||||
aria-haspopup="true"
|
||||
aria-expanded="false"
|
||||
{
|
||||
(title)
|
||||
}
|
||||
ul class="dropdown-menu" {
|
||||
(items)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Item {
|
||||
/// Crea un elemento de tipo texto, mostrado sin interacción.
|
||||
pub fn label(label: Lc) -> Self {
|
||||
Self {
|
||||
item_kind: ItemKind::Label(label),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Crea un enlace para la navegación.
|
||||
///
|
||||
/// La ruta se obtiene invocando [`Route::resolve()`], que devuelve dinámicamente una
|
||||
/// [`RoutePath`] en función del [`Context`]. El enlace se marca como `active` si la ruta
|
||||
/// actual del *request* coincide con la ruta de destino (devuelta por `RoutePath::path`).
|
||||
pub fn link(label: Lc, route: impl Into<Route>) -> Self {
|
||||
Self {
|
||||
item_kind: ItemKind::Link {
|
||||
label,
|
||||
route: route.into(),
|
||||
blank: false,
|
||||
disabled: false,
|
||||
},
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Crea un enlace deshabilitado que no permite la interacción.
|
||||
pub fn link_disabled(label: Lc, route: impl Into<Route>) -> Self {
|
||||
Self {
|
||||
item_kind: ItemKind::Link {
|
||||
label,
|
||||
route: route.into(),
|
||||
blank: false,
|
||||
disabled: true,
|
||||
},
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Crea un enlace que se abre en una nueva ventana o pestaña.
|
||||
pub fn link_blank(label: Lc, route: impl Into<Route>) -> Self {
|
||||
Self {
|
||||
item_kind: ItemKind::Link {
|
||||
label,
|
||||
route: route.into(),
|
||||
blank: true,
|
||||
disabled: false,
|
||||
},
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Crea un enlace inicialmente deshabilitado que se abriría en una nueva ventana.
|
||||
pub fn link_blank_disabled(label: Lc, route: impl Into<Route>) -> Self {
|
||||
Self {
|
||||
item_kind: ItemKind::Link {
|
||||
label,
|
||||
route: route.into(),
|
||||
blank: true,
|
||||
disabled: true,
|
||||
},
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Crea un elemento con contenido HTML arbitrario.
|
||||
///
|
||||
/// El contenido se renderiza tal cual lo devuelve el componente [`Html`], dentro de un `<li>`
|
||||
/// con las clases de navegación asociadas a [`Item`].
|
||||
pub fn html(html: Html) -> Self {
|
||||
Self {
|
||||
item_kind: ItemKind::Html(Embed::with(html)),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Crea un elemento de navegación que contiene un menú desplegable
|
||||
/// [`Dropdown`](super::super::Dropdown).
|
||||
///
|
||||
/// Sólo se tienen en cuenta **el título** (si no existe, se asigna uno por defecto) y **la
|
||||
/// lista de elementos** del [`Dropdown`](super::super::Dropdown); el resto de propiedades no
|
||||
/// afectarán a su representación en [`Nav`](super::Nav).
|
||||
pub fn dropdown(menu: Dropdown) -> Self {
|
||||
Self {
|
||||
item_kind: ItemKind::Dropdown(Embed::with(menu)),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
// **< Item BUILDER >***************************************************************************
|
||||
|
||||
/// Establece el identificador único del componente; igual a `with_prop(PropsOp::set_id(id))`.
|
||||
#[builder_fn]
|
||||
pub fn with_id(mut self, id: impl Into<CowStr>) -> Self {
|
||||
self.props.alter_id(id);
|
||||
self
|
||||
}
|
||||
|
||||
/// Modifica identificador, clases CSS, atributos HTML o valores extra del componente.
|
||||
#[builder_fn]
|
||||
pub fn with_prop(mut self, op: PropsOp) -> Self {
|
||||
self.props.alter_prop(op);
|
||||
self
|
||||
}
|
||||
|
||||
/// Fuerza si un [`ItemKind::Link`] se marca activo, o `None` para volver a la detección
|
||||
/// automática por coincidencia exacta de ruta.
|
||||
#[builder_fn]
|
||||
pub fn with_active(mut self, active: impl Into<Option<bool>>) -> Self {
|
||||
self.active_override = active.into();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
use crate::prelude::*;
|
||||
|
||||
// **< Layout >*************************************************************************************
|
||||
|
||||
/// Distribución y orientación de un menú [`Nav`](super::Nav).
|
||||
///
|
||||
/// Las variantes son puramente semánticas: cada tema decide en su propio `setup()` qué clases CSS
|
||||
/// les corresponden, según su propio vocabulario de estilos.
|
||||
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
|
||||
pub enum Layout {
|
||||
/// Comportamiento por defecto, ancho definido por el contenido y sin alineación forzada.
|
||||
#[default]
|
||||
Default,
|
||||
/// Alinea los elementos al inicio de la fila.
|
||||
Start,
|
||||
/// Centra horizontalmente los elementos.
|
||||
Center,
|
||||
/// Alinea los elementos al final de la fila.
|
||||
End,
|
||||
/// Apila los elementos en columna.
|
||||
Vertical,
|
||||
/// Los elementos se expanden para rellenar la fila.
|
||||
Fill,
|
||||
/// Todos los elementos ocupan el mismo ancho rellenando la fila.
|
||||
Justified,
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
//! Definiciones para crear barras de navegación ([`Navbar`]) y sus elementos ([`Item`]).
|
||||
|
||||
mod props;
|
||||
pub use props::Layout;
|
||||
|
||||
mod component;
|
||||
pub use component::Navbar;
|
||||
|
||||
mod item;
|
||||
pub use item::Item;
|
||||
|
|
@ -1,238 +0,0 @@
|
|||
use crate::prelude::*;
|
||||
|
||||
/// Componente para crear una **barra de navegación**.
|
||||
///
|
||||
/// Permite mostrar enlaces, menús desplegables ([`nav::Item::dropdown()`]) y una marca de
|
||||
/// identidad, en distintas disposiciones controladas por [`navbar::Layout`].
|
||||
///
|
||||
/// Si no contiene elementos, el componente **no se renderiza**.
|
||||
///
|
||||
/// # Ejemplos
|
||||
///
|
||||
/// Barra **simple**, sólo con un menú horizontal:
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// use pagetop::prelude::*;
|
||||
///
|
||||
/// let navbar = Navbar::simple()
|
||||
/// .with_item(navbar::Item::nav(
|
||||
/// Nav::new()
|
||||
/// .with_item(nav::Item::link(Lc::n("Home"), "/"))
|
||||
/// .with_item(nav::Item::link(Lc::n("About"), "/about"))
|
||||
/// .with_item(nav::Item::link(Lc::n("Contact"), "/contact")),
|
||||
/// ));
|
||||
/// ```
|
||||
///
|
||||
/// Barra **colapsable**, con botón de despliegue:
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// # use pagetop::prelude::*;
|
||||
/// let navbar = Navbar::simple_toggle()
|
||||
/// .with_item(navbar::Item::nav(
|
||||
/// Nav::new()
|
||||
/// .with_item(nav::Item::link(Lc::n("Home"), "/"))
|
||||
/// .with_item(nav::Item::link_blank(Lc::n("Doc"), "https://docs.rs"))
|
||||
/// .with_item(nav::Item::link(Lc::n("Support"), "/support")),
|
||||
/// ));
|
||||
/// ```
|
||||
///
|
||||
/// Barra con **marca de identidad** y menú, con menús desplegables:
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// # use pagetop::prelude::*;
|
||||
/// let brand = Brand::new()
|
||||
/// .with_title(Lc::n("PageTop"))
|
||||
/// .with_route(Route::from("/"));
|
||||
///
|
||||
/// let navbar = Navbar::brand_left(brand)
|
||||
/// .with_item(navbar::Item::nav(
|
||||
/// Nav::new()
|
||||
/// .with_item(nav::Item::link(Lc::n("Home"), "/"))
|
||||
/// .with_item(nav::Item::dropdown(
|
||||
/// Dropdown::new()
|
||||
/// .with_title(Lc::n("Tools"))
|
||||
/// .with_item(dropdown::Item::link(Lc::n("Generator"), "/tools/gen"))
|
||||
/// .with_item(dropdown::Item::link(Lc::n("Reports"), "/tools/reports")),
|
||||
/// ))
|
||||
/// .with_item(nav::Item::link_disabled(Lc::n("Disabled"), "#")),
|
||||
/// ));
|
||||
/// ```
|
||||
///
|
||||
/// Barra con **botón de despliegue** y **marca de identidad**, en ese orden:
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// # use pagetop::prelude::*;
|
||||
/// let brand = Brand::new()
|
||||
/// .with_title(Lc::n("Intranet"))
|
||||
/// .with_route(Route::from("/"));
|
||||
///
|
||||
/// let navbar = Navbar::brand_right(brand).with_item(navbar::Item::nav(
|
||||
/// Nav::new()
|
||||
/// .with_item(nav::Item::link(Lc::n("Dashboard"), "/dashboard"))
|
||||
/// .with_item(nav::Item::link(Lc::n("Users"), "/users")),
|
||||
/// ));
|
||||
/// ```
|
||||
///
|
||||
/// [`nav::Item::dropdown()`]: super::super::nav::Item::dropdown
|
||||
/// [`navbar::Layout`]: super::Layout
|
||||
#[derive(AutoDefault, Clone, Debug, Getters)]
|
||||
pub struct Navbar {
|
||||
/// Devuelve identificador, clases CSS, atributos HTML y valores extra del componente.
|
||||
props: Props,
|
||||
/// Devuelve la disposición configurada para la barra de navegación.
|
||||
layout: navbar::Layout,
|
||||
/// Devuelve la lista de contenidos.
|
||||
items: Children,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Component for Navbar {
|
||||
fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
fn id(&self) -> Option<String> {
|
||||
self.props.get_id()
|
||||
}
|
||||
|
||||
fn setup(&mut self, cx: &Context) {
|
||||
// Asegura que la barra de navegación tiene un identificador único: lo necesita el botón de
|
||||
// despliegue para referenciar el contenido colapsable con `aria-controls`.
|
||||
self.alter_prop(PropsOp::ensure_id(cx.build_id::<Self>(1)));
|
||||
self.alter_prop(PropsOp::prepend_classes("navbar"));
|
||||
}
|
||||
|
||||
async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
|
||||
// Botón de despliegue para el contenido colapsable de la barra.
|
||||
fn button(cx: &mut Context, id_content: &str) -> Markup {
|
||||
html! {
|
||||
button
|
||||
type="button"
|
||||
class="navbar-toggle"
|
||||
aria-expanded="false"
|
||||
aria-controls=(id_content)
|
||||
aria-label=[Lc::l("navbar_toggle").lookup(cx)]
|
||||
{
|
||||
span class="navbar-toggle-icon" {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Si no hay contenidos, no tiene sentido mostrar una barra vacía.
|
||||
let items = self.items().render(cx).await;
|
||||
if items.is_empty() {
|
||||
return Ok(html! {});
|
||||
}
|
||||
|
||||
// `setup()` garantiza que habrá un `id` antes de renderizar.
|
||||
let id = self.id().unwrap();
|
||||
let id_content = util::join!(id, "-content");
|
||||
|
||||
Ok(html! {
|
||||
nav (self.props()) {
|
||||
@match self.layout() {
|
||||
// Barra más sencilla: sólo contenido, siempre visible.
|
||||
navbar::Layout::Simple => {
|
||||
div class="navbar-content" { (items) }
|
||||
},
|
||||
|
||||
// Barra sencilla que se puede contraer/expandir.
|
||||
navbar::Layout::SimpleToggle => {
|
||||
(button(cx, &id_content))
|
||||
div id=(&id_content) class="navbar-content" { (items) }
|
||||
},
|
||||
|
||||
// Barra con marca, siempre visible, sin botón.
|
||||
navbar::Layout::SimpleBrandLeft(brand) => {
|
||||
(brand.render(cx).await)
|
||||
div class="navbar-content" { (items) }
|
||||
},
|
||||
|
||||
// Barra con marca y botón, en ese orden.
|
||||
navbar::Layout::BrandLeft(brand) => {
|
||||
(brand.render(cx).await)
|
||||
(button(cx, &id_content))
|
||||
div id=(&id_content) class="navbar-content" { (items) }
|
||||
},
|
||||
|
||||
// Barra con botón y marca, en ese orden.
|
||||
navbar::Layout::BrandRight(brand) => {
|
||||
(button(cx, &id_content))
|
||||
div id=(&id_content) class="navbar-content" { (items) }
|
||||
(brand.render(cx).await)
|
||||
},
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Navbar {
|
||||
/// Crea una barra de navegación **simple**, sin marca y sin botón.
|
||||
pub fn simple() -> Self {
|
||||
Self::default().with_layout(navbar::Layout::Simple)
|
||||
}
|
||||
|
||||
/// Crea una barra de navegación **simple pero colapsable**, con botón de despliegue.
|
||||
pub fn simple_toggle() -> Self {
|
||||
Self::default().with_layout(navbar::Layout::SimpleToggle)
|
||||
}
|
||||
|
||||
/// Crea una barra de navegación **con marca de identidad**, siempre visible, sin botón.
|
||||
pub fn simple_brand_left(brand: Brand) -> Self {
|
||||
Self::default().with_layout(navbar::Layout::SimpleBrandLeft(Embed::with(brand)))
|
||||
}
|
||||
|
||||
/// Crea una barra de navegación con **marca de identidad** y **botón de despliegue**, en ese
|
||||
/// orden.
|
||||
pub fn brand_left(brand: Brand) -> Self {
|
||||
Self::default().with_layout(navbar::Layout::BrandLeft(Embed::with(brand)))
|
||||
}
|
||||
|
||||
/// Crea una barra de navegación con **botón de despliegue** y **marca de identidad**, en ese
|
||||
/// orden.
|
||||
pub fn brand_right(brand: Brand) -> Self {
|
||||
Self::default().with_layout(navbar::Layout::BrandRight(Embed::with(brand)))
|
||||
}
|
||||
|
||||
// **< Navbar BUILDER >*************************************************************************
|
||||
|
||||
/// Establece el identificador único del componente; igual a `with_prop(PropsOp::set_id(id))`.
|
||||
#[builder_fn]
|
||||
pub fn with_id(mut self, id: impl Into<CowStr>) -> Self {
|
||||
self.props.alter_id(id);
|
||||
self
|
||||
}
|
||||
|
||||
/// Modifica identificador, clases CSS, atributos HTML o valores extra del componente.
|
||||
#[builder_fn]
|
||||
pub fn with_prop(mut self, op: PropsOp) -> Self {
|
||||
self.props.alter_prop(op);
|
||||
self
|
||||
}
|
||||
|
||||
/// Define el tipo de disposición que tendrá la barra de navegación.
|
||||
#[builder_fn]
|
||||
pub fn with_layout(mut self, layout: navbar::Layout) -> Self {
|
||||
self.layout = layout;
|
||||
self
|
||||
}
|
||||
|
||||
/// Añade un nuevo contenido a la barra de navegación o modifica la lista de contenidos de la
|
||||
/// barra con una operación [`TypedOp`].
|
||||
///
|
||||
/// # Ejemplo
|
||||
///
|
||||
/// ```rust,ignore
|
||||
/// navbar.with_item(navbar::Item::nav(...));
|
||||
/// navbar.with_item(TypedOp::AddMany(vec![
|
||||
/// navbar::Item::nav(...),
|
||||
/// navbar::Item::text(...),
|
||||
/// ]));
|
||||
/// ```
|
||||
#[builder_fn]
|
||||
pub fn with_item(mut self, op: impl Into<TypedOp<navbar::Item>>) -> Self {
|
||||
self.items.alter_child(op.into());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
|
@ -1,84 +0,0 @@
|
|||
use crate::prelude::*;
|
||||
|
||||
/// Elementos que puede contener una barra de navegación [`Navbar`](super::Navbar).
|
||||
///
|
||||
/// Cada variante determina qué se renderiza y cómo. Estos elementos se colocan **dentro del
|
||||
/// contenido** de la barra (la parte colapsable), por lo que son independientes de la marca o del
|
||||
/// botón que ya pueda definir el propio [`navbar::Layout`](super::Layout).
|
||||
#[derive(AutoDefault, Clone, Debug)]
|
||||
pub enum Item {
|
||||
/// Sin contenido, no produce salida.
|
||||
#[default]
|
||||
Void,
|
||||
/// Marca de identidad mostrada dentro del contenido de la barra de navegación.
|
||||
///
|
||||
/// Útil cuando el [`navbar::Layout`](super::Layout) no incluye marca, y se quiere incluir
|
||||
/// dentro del área colapsable. Si el *layout* ya muestra una marca, esta variante no la
|
||||
/// sustituye, sólo añade otra dentro del bloque de contenidos.
|
||||
Brand(Embed<Brand>),
|
||||
/// Representa un menú de navegación [`Nav`].
|
||||
Nav(Embed<Nav>),
|
||||
/// Representa un *texto localizado* libre.
|
||||
Text(Lc),
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Component for Item {
|
||||
fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
fn id(&self) -> Option<String> {
|
||||
match self {
|
||||
Self::Void => None,
|
||||
Self::Brand(brand) => brand.id(),
|
||||
Self::Nav(nav) => nav.id(),
|
||||
Self::Text(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn setup(&mut self, _cx: &Context) {
|
||||
if let Self::Nav(nav) = self
|
||||
&& let Some(nav) = nav.get_mut()
|
||||
{
|
||||
// Se añade aquí, antes de que `nav.render(cx)` (en `prepare()`) dispare el ciclo de
|
||||
// vida normal del `Nav` embebido y su propio `setup()` prepend las clases de `nav`/
|
||||
// `nav::Layout`.
|
||||
nav.alter_prop(PropsOp::prepend_classes("navbar-nav"));
|
||||
}
|
||||
}
|
||||
|
||||
async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
|
||||
Ok(match self {
|
||||
Self::Void => html! {},
|
||||
Self::Brand(brand) => html! { (brand.render(cx).await) },
|
||||
Self::Nav(nav) => html! { (nav.render(cx).await) },
|
||||
Self::Text(text) => html! {
|
||||
span class="navbar-text" {
|
||||
(text.using(cx))
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Item {
|
||||
/// Crea un elemento de tipo [`navbar::Brand`](super::super::Brand) para añadir en el contenido
|
||||
/// de [`Navbar`](super::Navbar).
|
||||
///
|
||||
/// Pensado para barras colapsables donde se quiere que la marca aparezca en la zona
|
||||
/// desplegable.
|
||||
pub fn brand(brand: Brand) -> Self {
|
||||
Self::Brand(Embed::with(brand))
|
||||
}
|
||||
|
||||
/// Crea un elemento de tipo [`Nav`] para añadir al contenido de [`Navbar`](super::Navbar).
|
||||
pub fn nav(item: Nav) -> Self {
|
||||
Self::Nav(Embed::with(item))
|
||||
}
|
||||
|
||||
/// Crea un elemento con un *texto localizado*, mostrado sin interacción.
|
||||
pub fn text(item: Lc) -> Self {
|
||||
Self::Text(item)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
use crate::prelude::*;
|
||||
|
||||
// **< Layout >*************************************************************************************
|
||||
|
||||
/// Representa las distintas formas de presentación de una barra de navegación
|
||||
/// [`Navbar`](super::Navbar).
|
||||
///
|
||||
/// Sólo recoge las combinaciones de marca y botón de despliegue independientes de cualquier
|
||||
/// framework CSS. Un tema puede definir su propia variante de disposición (posiciones fijas,
|
||||
/// contenido en un panel lateral...) con su propio tipo, sin depender de éste.
|
||||
#[derive(AutoDefault, Clone, Debug)]
|
||||
pub enum Layout {
|
||||
/// Barra simple, sin marca de identidad y sin botón de despliegue.
|
||||
///
|
||||
/// La barra de navegación no se colapsa.
|
||||
#[default]
|
||||
Simple,
|
||||
|
||||
/// Barra simple, con botón de despliegue y sin marca de identidad.
|
||||
SimpleToggle,
|
||||
|
||||
/// Barra simple, con marca de identidad y sin botón de despliegue.
|
||||
///
|
||||
/// La barra de navegación no se colapsa.
|
||||
SimpleBrandLeft(Embed<Brand>),
|
||||
|
||||
/// Barra con marca de identidad y botón de despliegue, en ese orden.
|
||||
BrandLeft(Embed<Brand>),
|
||||
|
||||
/// Barra con botón de despliegue y marca de identidad, en ese orden.
|
||||
BrandRight(Embed<Brand>),
|
||||
}
|
||||
|
|
@ -20,7 +20,7 @@ impl Theme for Basic {
|
|||
.with_weight(-99),
|
||||
))
|
||||
.alter_assets(AssetsOp::AddStyleSheet(
|
||||
StyleSheet::from("/pagetop/css/basic.min.css")
|
||||
StyleSheet::from("/pagetop/css/basic.css")
|
||||
.with_version(PAGETOP_VERSION)
|
||||
.with_weight(-99),
|
||||
))
|
||||
|
|
@ -30,9 +30,6 @@ impl Theme for Basic {
|
|||
.alter_assets(AssetsOp::AddJavaScript(
|
||||
JavaScript::defer("/pagetop/js/basic.dropdown.min.js").with_version(PAGETOP_VERSION),
|
||||
))
|
||||
.alter_assets(AssetsOp::AddJavaScript(
|
||||
JavaScript::defer("/pagetop/js/basic.navbar.init.js").with_version(PAGETOP_VERSION),
|
||||
))
|
||||
.alter_assets(AssetsOp::AddJavaScript(
|
||||
JavaScript::defer("/pagetop/js/basic.dialog.min.js").with_version(PAGETOP_VERSION),
|
||||
))
|
||||
|
|
|
|||
|
|
@ -13,14 +13,12 @@
|
|||
//!
|
||||
//! # Temas hijo, herencia y componentes
|
||||
//!
|
||||
//! PageTop permite crear **temas hijo** que refinan el comportamiento de su tema padre,
|
||||
//! identificado por [`Theme::parent()`]. Un tema hijo hereda automáticamente todos los métodos del
|
||||
//! padre y puede sobrescribirlos selectivamente. Esta herencia determina qué implementación de sus
|
||||
//! métodos se usa cuando el tema hijo no los sobrescribe (ya sea el renderizado del `<body>` o del
|
||||
//! `<head>`, la definición de los recursos necesarios, la asignación de colores por intención vía
|
||||
//! [`Theme::intent_color()`], la captura de componentes con [`Theme::handle_component()`], las
|
||||
//! páginas de error, etc.). Un tema hijo puede ser a su vez padre de otro, basta declararlo cada
|
||||
//! vez con [`Theme::parent()`].
|
||||
//! PageTop permite crear **temas hijo** que refinan el comportamiento de su tema padre. Un tema
|
||||
//! hijo hereda automáticamente todos los métodos del padre y puede sobrescribirlos selectivamente.
|
||||
//! Esta herencia sólo determina qué implementación de sus métodos se usa cuando el tema hijo no los
|
||||
//! sobrescribe (como el renderizado del `<body>` y del `<head>`, los recursos incorporados, el uso
|
||||
//! de [`Theme::handle_component()`], las páginas de error, etc.). Un tema hijo puede ser a su vez
|
||||
//! padre de otro, basta declararlo cada vez con [`Theme::parent()`].
|
||||
//!
|
||||
//! Como `parent()` se resuelve en tiempo de ejecución, PageTop no puede descartar en compilación
|
||||
//! referencias circulares (un tema acaba siendo padre de sí mismo, directa o transitivamente). Ese
|
||||
|
|
@ -50,8 +48,8 @@
|
|||
//! devuelva `Some(&Self)`. Basta con un `impl Theme for MyTheme {}` vacío, ya que todos los
|
||||
//! métodos de [`Theme`] tienen implementación por defecto.
|
||||
//!
|
||||
//! Un tema puede personalizarse en cinco pasos, cada uno necesario sólo si lo que ofrece PageTop
|
||||
//! por defecto no basta:
|
||||
//! Un tema puede personalizarse en tres pasos, cada uno necesario sólo si lo que ofrece PageTop por
|
||||
//! defecto no basta:
|
||||
//!
|
||||
//! 1. **Definir regiones nuevas**. Por defecto, PageTop define [`CoreRegions`] (`Header`, `Aside`,
|
||||
//! `Content`, `Footer`) como regiones de plantilla siempre disponibles, y [`ReservedRegions`]
|
||||
|
|
@ -77,21 +75,6 @@
|
|||
//! ejemplo, [`CoreTemplates`] o el propio *enum* del tema). `pagetop-bootsier` hace exactamente
|
||||
//! esto para maquetar `Standard` y `Admin` de forma distinta, sin necesitar sus propias
|
||||
//! variantes de plantilla.
|
||||
//! 4. **Traducir [`Intent`] a la paleta de colores propia del tema** sobrescribiendo
|
||||
//! [`Theme::intent_color()`]. Por defecto, este método devuelve el vocabulario semántico de
|
||||
//! [`Intent`] (`"primary"`, `"severe"`, etc.); un tema con su propio catálogo de colores (por
|
||||
//! ejemplo, uno basado en Bootstrap) debe traducir cada variante al nombre que le corresponda en
|
||||
//! su paleta. Los componentes que generan clases CSS a partir de una [`Intent`] (`Button`,
|
||||
//! `Badge`, `Dropdown`, etc.) consultan este método a través de [`Intent::color()`], así que la
|
||||
//! clase resultante ya nace en la paleta del tema activo.
|
||||
//! 5. **Reexportar, extender o añadir componentes**. Un tema puede reexportar tal cual los
|
||||
//! componentes propios de PageTop que no requieran adaptación, extenderlos con un trait propio
|
||||
//! para añadir métodos exclusivos (guardando su estado en valores extra con
|
||||
//! [`PropsOp::set_extra()`](crate::html::PropsOp::set_extra) para consumirlos en el
|
||||
//! `setup()`/`render()` vía [`Theme::handle_component()`]), o aportar componentes propios.
|
||||
//! `pagetop-bootsier` combina las tres estrategias: reexporta `Form`/`Fieldset` sin cambios,
|
||||
//! extiende `Button`/`Badge`/`Dropdown`/`Nav`/`Navbar` con sus propios traits (`ButtonBootsier`,
|
||||
//! `BadgeBootsier`, etc.), y añade componentes propios como `Offcanvas`.
|
||||
//!
|
||||
//! Para forzar una plantilla completamente distinta en una página concreta, se puede llamar
|
||||
//! manualmente a [`with_template()`](crate::core::component::Contextual::with_template).
|
||||
|
|
@ -103,7 +86,7 @@
|
|||
//! plantilla distinta.
|
||||
//!
|
||||
//! El resto del comportamiento de un tema (por ejemplo, el renderizado del `<head>`) se sobrescribe
|
||||
//! de forma independiente de estos pasos y no es necesario para tener un tema funcional.
|
||||
//! de forma independiente de estos tres pasos y no es necesario para tener un tema funcional.
|
||||
//!
|
||||
//! # Componentes que se procesan en todas las páginas
|
||||
//!
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ use crate::base::component::{Html, Intro, IntroOpening, layout};
|
|||
use crate::core::component::{ChildOp, Component, ComponentError, ComponentRender};
|
||||
use crate::core::component::{Context, Contextual};
|
||||
use crate::core::extension::Extension;
|
||||
use crate::core::theme::{CoreRegions, Intent};
|
||||
use crate::core::theme::CoreRegions;
|
||||
use crate::global;
|
||||
use crate::html::{Markup, html};
|
||||
use crate::locale::Lc;
|
||||
|
|
@ -65,34 +65,6 @@ pub trait Theme: Extension + Send + Sync {
|
|||
None
|
||||
}
|
||||
|
||||
/// Traduce una [`Intent`] al nombre de color de la paleta propia del tema.
|
||||
///
|
||||
/// `Intent` no define ninguna cadena propia. Cada tema decide qué nombre le corresponde a cada
|
||||
/// variante en su paleta (p. ej. un tema basado en Bootstrap traduce `Severe` a `"danger"`).
|
||||
/// Los componentes que generan clases CSS a partir de una `Intent` (`Button`, `Badge`,
|
||||
/// `Dropdown`, etc.) no llaman a este método directamente en su `setup()`, usan mejor
|
||||
/// [`Intent::color()`](crate::core::theme::Intent::color) como la forma más sencilla de obtener
|
||||
/// este mismo valor a través de [`Context::theme()`](crate::core::component::Context::theme),
|
||||
/// para que la clase resultante ya nazca en la paleta del tema activo.
|
||||
///
|
||||
/// La implementación por defecto devuelve el vocabulario semántico propio de PageTop
|
||||
/// (`"primary"`, `"severe"`, etc.), que actúa como paleta base cuando ningún tema la
|
||||
/// sobrescribe.
|
||||
#[rustfmt::skip]
|
||||
fn intent_color(&self, intent: Intent) -> &'static str {
|
||||
if let Some(parent) = self.parent() {
|
||||
return parent.intent_color(intent);
|
||||
}
|
||||
match intent {
|
||||
Intent::Primary => "primary",
|
||||
Intent::Neutral => "neutral",
|
||||
Intent::Info => "info",
|
||||
Intent::Success => "success",
|
||||
Intent::Warning => "warning",
|
||||
Intent::Severe => "severe",
|
||||
}
|
||||
}
|
||||
|
||||
/// Acciones específicas del tema antes de renderizar el `<body>` de la página.
|
||||
///
|
||||
/// Es un buen lugar para inicializar o ajustar recursos en función del contexto de la página,
|
||||
|
|
|
|||
|
|
@ -1,41 +1,31 @@
|
|||
use crate::AutoDefault;
|
||||
use crate::core::component::{Context, Contextual};
|
||||
|
||||
/// Intención semántica de un componente visual.
|
||||
///
|
||||
/// Describe *qué comunica* un componente, y cada tema traduce la intención a su propia paleta de
|
||||
/// colores. Define un vocabulario con variantes por énfasis (`Primary`, `Neutral`), un canal
|
||||
/// informativo (`Info`) y una escala de severidad (`Success`, `Warning`, `Severe`).
|
||||
/// Representa la intención que pretende comunicar un componente (énfasis principal, confirmación de
|
||||
/// un evento exitoso, aviso, peligro, etc.). Cada tema decidirá cómo pintarlo.
|
||||
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
|
||||
pub enum Intent {
|
||||
/// Énfasis principal, suele ser la acción que más destaca a la vista.
|
||||
#[default]
|
||||
Primary,
|
||||
/// Presencia sin énfasis, una opción discreta o normal.
|
||||
Neutral,
|
||||
/// Informa de algo que merece leerse, ajeno a la escala de severidad. Encaja en notas o avisos
|
||||
/// informativos.
|
||||
Info,
|
||||
/// Destaca algo que ha ido bien, un estado favorable o un camino afirmativo. En una alerta
|
||||
/// podría indicar la confirmación de una operación correcta o un estado saludable; en un botón,
|
||||
/// la acción que confirma o aprueba algo positivamente.
|
||||
Secondary,
|
||||
Success,
|
||||
/// Precaución, representa algo que requiere atención o cuidado. En una alerta, sería un
|
||||
/// problema no bloqueante o un aviso; en un botón, una acción que conviene meditar sin llegar a
|
||||
/// ser destructiva.
|
||||
Info,
|
||||
Warning,
|
||||
/// Algo va mal, es peligroso o destructivo. En una alerta sería un error o un fallo bloqueante;
|
||||
/// en un botón, la acción destructiva o irreversible (p. ej. eliminar).
|
||||
Severe,
|
||||
Danger,
|
||||
}
|
||||
|
||||
impl Intent {
|
||||
/// Devuelve el nombre del color asociado a la intención en el tema activo del contexto actual.
|
||||
///
|
||||
/// Atajo de [`Theme::intent_color()`](crate::core::theme::Theme::intent_color) a través de
|
||||
/// [`Context::theme()`]. La intención no tiene una cadena propia, es el tema activo quien
|
||||
/// decide su traducción.
|
||||
pub fn color(&self, cx: &Context) -> &'static str {
|
||||
cx.theme().intent_color(*self)
|
||||
/// Devuelve el nombre de la intención (`"primary"`, `"danger"`, etc.).
|
||||
#[rustfmt::skip]
|
||||
pub const fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Primary => "primary",
|
||||
Self::Secondary => "secondary",
|
||||
Self::Success => "success",
|
||||
Self::Info => "info",
|
||||
Self::Warning => "warning",
|
||||
Self::Danger => "danger",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
12
src/web.rs
12
src/web.rs
|
|
@ -67,11 +67,11 @@ use std::task::{Context, Poll};
|
|||
/// ```
|
||||
///
|
||||
/// `HttpRequest` no consume las extensiones de la petición, por lo que el resto de extractores del
|
||||
/// handler que también las necesiten (como `Path<T>`, `Extension<T>`, etc.) las siguen viendo
|
||||
/// handler que también las necesiten (como `Path<T>`, `Extension<T>`...) las siguen viendo
|
||||
/// intactas, sea cual sea la posición en la que se declare `HttpRequest`. Por convención, se
|
||||
/// declara como primer parámetro del handler. El único orden que sigue siendo obligatorio es el que
|
||||
/// impone Axum: un extractor que consuma el cuerpo de la petición (`Form<T>`, `RawForm`, etc.) debe
|
||||
/// ir siempre el último.
|
||||
/// impone Axum: un extractor que consuma el cuerpo de la petición (`Form<T>`, `RawForm`...) debe ir
|
||||
/// siempre el último.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct HttpRequest {
|
||||
uri: http::Uri,
|
||||
|
|
@ -120,9 +120,9 @@ impl<S: Send + Sync> FromRequestParts<S> for HttpRequest {
|
|||
type Rejection = Infallible;
|
||||
|
||||
// Clona (no toma) las extensiones inyectadas por middleware, para que otros extractores del
|
||||
// handler (`Path<T>`, `Extension<T>`, etc.) las sigan viendo intactas sin importar en qué
|
||||
// posición se declare `HttpRequest`. El clon se envuelve en un `Arc` compartido para que
|
||||
// `HttpRequest` sea `Clone` a coste mínimo en el resto de su ciclo de vida.
|
||||
// handler (`Path<T>`, `Extension<T>`...) las sigan viendo intactas sin importar en qué posición
|
||||
// se declare `HttpRequest`. El clon se envuelve en un `Arc` compartido para que `HttpRequest`
|
||||
// sea `Clone` a coste mínimo en el resto de su ciclo de vida.
|
||||
async fn from_request_parts(
|
||||
parts: &mut http::request::Parts,
|
||||
_state: &S,
|
||||
|
|
|
|||
|
|
@ -28,22 +28,22 @@ async fn renders_as_a_span_element() {
|
|||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn default_intent_is_neutral() {
|
||||
async fn default_intent_is_secondary() {
|
||||
let mut badge = Badge::labeled(Lc::n("Admin"));
|
||||
let html = badge.render(&mut Context::default()).await.into_string();
|
||||
|
||||
assert!(html.contains(r#"class="badge badge-neutral""#));
|
||||
assert!(html.contains(r#"class="badge badge-secondary""#));
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn each_direct_constructor_sets_its_intent() {
|
||||
let cases = [
|
||||
(Badge::primary(Lc::n("x")), "badge-primary"),
|
||||
(Badge::neutral(Lc::n("x")), "badge-neutral"),
|
||||
(Badge::secondary(Lc::n("x")), "badge-secondary"),
|
||||
(Badge::success(Lc::n("x")), "badge-success"),
|
||||
(Badge::info(Lc::n("x")), "badge-info"),
|
||||
(Badge::warning(Lc::n("x")), "badge-warning"),
|
||||
(Badge::severe(Lc::n("x")), "badge-severe"),
|
||||
(Badge::danger(Lc::n("x")), "badge-danger"),
|
||||
];
|
||||
for (mut badge, expected_class) in cases {
|
||||
let html = badge.render(&mut Context::default()).await.into_string();
|
||||
|
|
@ -56,16 +56,16 @@ async fn each_direct_constructor_sets_its_intent() {
|
|||
|
||||
#[pagetop::test]
|
||||
async fn with_intent_overrides_the_default() {
|
||||
let mut badge = Badge::labeled(Lc::n("Admin")).with_intent(Intent::Severe);
|
||||
let mut badge = Badge::labeled(Lc::n("Admin")).with_intent(Intent::Danger);
|
||||
let html = badge.render(&mut Context::default()).await.into_string();
|
||||
|
||||
assert!(html.contains(r#"class="badge badge-severe""#));
|
||||
assert!(html.contains(r#"class="badge badge-danger""#));
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn direct_constructor_is_equivalent_to_labeled_with_intent() {
|
||||
let mut from_constructor = Badge::severe(Lc::n("Admin"));
|
||||
let mut from_builder = Badge::labeled(Lc::n("Admin")).with_intent(Intent::Severe);
|
||||
let mut from_constructor = Badge::danger(Lc::n("Admin"));
|
||||
let mut from_builder = Badge::labeled(Lc::n("Admin")).with_intent(Intent::Danger);
|
||||
|
||||
assert_eq!(
|
||||
from_constructor
|
||||
|
|
@ -89,9 +89,9 @@ async fn with_id_sets_the_identifier() {
|
|||
|
||||
#[pagetop::test]
|
||||
async fn with_prop_adds_extra_classes_alongside_the_intent_class() {
|
||||
let mut badge = Badge::severe(Lc::n("Admin")).with_prop(PropsOp::add_classes("custom"));
|
||||
let mut badge = Badge::danger(Lc::n("Admin")).with_prop(PropsOp::add_classes("custom"));
|
||||
let html = badge.render(&mut Context::default()).await.into_string();
|
||||
|
||||
assert!(html.contains("badge-severe"));
|
||||
assert!(html.contains("badge-danger"));
|
||||
assert!(html.contains("custom"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,16 +48,17 @@ async fn title_attribute_can_be_cleared_with_lc_none() {
|
|||
|
||||
#[pagetop::test]
|
||||
async fn style_class_reflects_intent_and_style() {
|
||||
let mut button = Button::submit(Lc::n("Save")).with_style(button::Style::Solid(Intent::Severe));
|
||||
let mut button =
|
||||
Button::submit(Lc::n("Save")).with_style(button::ButtonStyle::Solid(Intent::Danger));
|
||||
let html = button.render(&mut Context::default()).await.into_string();
|
||||
|
||||
assert!(html.contains("button-severe"));
|
||||
assert!(html.contains("button-danger"));
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn outline_style_generates_outline_class() {
|
||||
let mut button =
|
||||
Button::submit(Lc::n("Save")).with_style(button::Style::Outline(Intent::Primary));
|
||||
Button::submit(Lc::n("Save")).with_style(button::ButtonStyle::Outline(Intent::Primary));
|
||||
let html = button.render(&mut Context::default()).await.into_string();
|
||||
|
||||
assert!(html.contains("button-outline-primary"));
|
||||
|
|
@ -65,84 +66,12 @@ async fn outline_style_generates_outline_class() {
|
|||
|
||||
#[pagetop::test]
|
||||
async fn link_style_generates_link_class_without_intent() {
|
||||
let mut button = Button::plain(Lc::n("Cancel")).with_style(button::Style::Link);
|
||||
let mut button = Button::plain(Lc::n("Cancel")).with_style(button::ButtonStyle::Link);
|
||||
let html = button.render(&mut Context::default()).await.into_string();
|
||||
|
||||
assert!(html.contains("button-link"));
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn size_small_generates_button_sm_class() {
|
||||
let mut button = Button::submit(Lc::n("Save")).with_size(button::Size::Small);
|
||||
let html = button.render(&mut Context::default()).await.into_string();
|
||||
|
||||
assert!(html.contains("button-sm"));
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn size_large_generates_button_lg_class() {
|
||||
let mut button = Button::submit(Lc::n("Save")).with_size(button::Size::Large);
|
||||
let html = button.render(&mut Context::default()).await.into_string();
|
||||
|
||||
assert!(html.contains("button-lg"));
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn size_none_omits_size_class_by_default() {
|
||||
let mut button = Button::submit(Lc::n("Save"));
|
||||
let html = button.render(&mut Context::default()).await.into_string();
|
||||
|
||||
assert!(!html.contains("button-sm"));
|
||||
assert!(!html.contains("button-lg"));
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn anchor_renders_as_a_tag_with_href() {
|
||||
let mut button = Button::anchor(Lc::n("Edit"), "/items/1/edit");
|
||||
let html = button.render(&mut Context::default()).await.into_string();
|
||||
|
||||
assert!(html.starts_with("<a "));
|
||||
assert!(html.contains(r#"href="/items/1/edit""#));
|
||||
assert!(html.contains("Edit"));
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn button_without_href_renders_as_button_tag() {
|
||||
let mut button = Button::submit(Lc::n("Save"));
|
||||
let html = button.render(&mut Context::default()).await.into_string();
|
||||
|
||||
assert!(html.starts_with("<button"));
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn anchor_falls_back_to_button_when_href_is_reset() {
|
||||
// `Route::default()` resuelve a una ruta vacía; `prepare()` debe ignorar `href` y volver a
|
||||
// renderizar un `<button>`, tal y como documenta `Button::with_href()`.
|
||||
let mut button = Button::anchor(Lc::n("Edit"), "/items/1/edit").with_href(Route::default());
|
||||
let html = button.render(&mut Context::default()).await.into_string();
|
||||
|
||||
assert!(html.starts_with("<button"));
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn enabled_anchor_has_no_aria_disabled_or_tabindex() {
|
||||
let mut button = Button::anchor(Lc::n("Edit"), "/items/1/edit");
|
||||
let html = button.render(&mut Context::default()).await.into_string();
|
||||
|
||||
assert!(!html.contains("aria-disabled"));
|
||||
assert!(!html.contains("tabindex"));
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn disabled_anchor_omits_href_and_sets_aria_disabled() {
|
||||
let mut button = Button::anchor(Lc::n("Edit"), "/items/1/edit").with_disabled(true);
|
||||
let html = button.render(&mut Context::default()).await.into_string();
|
||||
|
||||
assert!(!html.contains("href="));
|
||||
assert!(html.contains(r#"aria-disabled="true""#));
|
||||
assert!(html.contains(r#"tabindex="-1""#));
|
||||
}
|
||||
|
||||
// **< ButtonSet >**********************************************************************************
|
||||
|
||||
#[pagetop::test]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue