Compare commits

...

4 commits

Author SHA1 Message Date
35ae5bb6a8 (base): Añade componentes Nav y Navbar
Dropdown incorpora tamaño y estilo de botón configurables
(`button::Size`/`Style`), reutilizados también por Navbar. El tema Basic
sirve `basic.min.css` y añade el JS de inicialización de Navbar.
2026-08-28 18:24:43 +02:00
da959183f6 (button): Añade Button::anchor() para navegación
- Renderiza un `<a href>` real con el aspecto de un botón, distinto del
  estilo puramente visual `button::Style::Link`.
- Renombra `ButtonKind`/`ButtonStyle` a `Kind`/`Style`.
- Añade `button::Size` (`button-sm`/`button-lg`).
2026-08-28 11:38:57 +02:00
434d31c5eb 📝 (doc): Sustituye "..." por "etc." 2026-08-28 11:16:58 +02:00
56a6691c87 🚧 (theme): Renombra variantes de Intent
Renombra `Intent::Secondary`/`Danger` a `Neutral`/`Severe` y actualiza
Badge y su CSS por defecto (basic.css) al nuevo vocabulario.

Introduce el punto de extensión `Theme::intent_color()`, que traduce una
`Intent` al nombre de color de la paleta propia de cada tema, con
`Intent::color(cx)` como atajo de uso desde los componentes.
2026-08-28 01:10:45 +02:00
33 changed files with 2046 additions and 414 deletions

View file

@ -1,9 +1,9 @@
# 🔃 Dependencias # Dependencias
PageTop está basado en [Rust](https://www.rust-lang.org/) y crece a hombros de gigantes aprovechando 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: algunas de las librerías más robustas y populares del [ecosistema Rust](https://lib.rs/) como son:
* [Actix Web](https://actix.rs/) para los servicios web. * [Axum](https://github.com/tokio-rs/axum) para los servicios web.
* [Config](https://docs.rs/config) para cargar y procesar las opciones de configuración. * [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 * [Tracing](https://github.com/tokio-rs/tracing) para la gestión de trazas y registro de eventos
de la aplicación. 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. PageTop y sus extensiones.
# 🗚 FIGfonts # FIGfonts
PageTop usa el *crate* [figlet-rs](https://crates.io/crates/figlet-rs) desarrollado por *yuanbohan* 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 para mostrar un banner de presentación en el terminal con el nombre de la aplicación en caracteres
@ -26,7 +26,13 @@ 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* * [starwars.flf](http://www.figlet.org/fontdb_example.cgi?font=starwars.flf) de *Ryan Youck*
# 👾 Icono # 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
"La Mascota" sonriente es una simpática creación de [Webalys](https://www.iconfinder.com/webalys). "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 Forma parte de su colección [Nasty Icons](https://www.iconfinder.com/iconsets/nasty), disponible en

202
Cargo.lock generated
View file

@ -115,6 +115,18 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" 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]] [[package]]
name = "async-stream" name = "async-stream"
version = "0.3.6" version = "0.3.6"
@ -242,6 +254,15 @@ dependencies = [
"serde_core", "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]] [[package]]
name = "block-buffer" name = "block-buffer"
version = "0.10.4" version = "0.10.4"
@ -251,6 +272,15 @@ dependencies = [
"generic-array", "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]] [[package]]
name = "bstr" name = "bstr"
version = "1.12.1" version = "1.12.1"
@ -399,6 +429,12 @@ version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
[[package]]
name = "const-oid"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
[[package]] [[package]]
name = "core-foundation" name = "core-foundation"
version = "0.10.1" version = "0.10.1"
@ -424,6 +460,15 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "cpufeatures"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "crc" name = "crc"
version = "3.4.0" version = "3.4.0"
@ -501,6 +546,15 @@ dependencies = [
"typenum", "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]] [[package]]
name = "darling" name = "darling"
version = "0.20.11" version = "0.20.11"
@ -541,7 +595,7 @@ version = "0.7.10"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
dependencies = [ dependencies = [
"const-oid", "const-oid 0.9.6",
"pem-rfc7468", "pem-rfc7468",
"zeroize", "zeroize",
] ]
@ -583,12 +637,23 @@ version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [ dependencies = [
"block-buffer", "block-buffer 0.10.4",
"const-oid", "const-oid 0.9.6",
"crypto-common", "crypto-common 0.1.7",
"subtle", "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]] [[package]]
name = "displaydoc" name = "displaydoc"
version = "0.2.5" version = "0.2.5"
@ -1053,7 +1118,7 @@ version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
dependencies = [ dependencies = [
"digest", "digest 0.10.7",
] ]
[[package]] [[package]]
@ -1116,6 +1181,15 @@ version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" 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]] [[package]]
name = "hyper" name = "hyper"
version = "1.9.0" version = "1.9.0"
@ -1489,7 +1563,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"digest", "digest 0.10.7",
] ]
[[package]] [[package]]
@ -1716,6 +1790,7 @@ dependencies = [
name = "pagetop" name = "pagetop"
version = "0.5.0" version = "0.5.0"
dependencies = [ dependencies = [
"async-trait",
"axum", "axum",
"chrono", "chrono",
"colored", "colored",
@ -1736,6 +1811,7 @@ dependencies = [
"serde_json", "serde_json",
"tempfile", "tempfile",
"terminal_size", "terminal_size",
"thiserror",
"tokio", "tokio",
"tower", "tower",
"tower-http", "tower-http",
@ -1745,6 +1821,17 @@ dependencies = [
"unic-langid", "unic-langid",
] ]
[[package]]
name = "pagetop-admin"
version = "0.1.0"
dependencies = [
"pagetop",
"pagetop-seaorm",
"serde",
"serde_json",
"thiserror",
]
[[package]] [[package]]
name = "pagetop-aliner" name = "pagetop-aliner"
version = "0.1.0" version = "0.1.0"
@ -1777,6 +1864,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"pagetop", "pagetop",
"pagetop-build", "pagetop-build",
"serde_json",
] ]
[[package]] [[package]]
@ -1789,6 +1877,19 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "pagetop-menu"
version = "0.1.0"
dependencies = [
"chrono",
"pagetop",
"pagetop-seaorm",
"sea-orm",
"serde",
"thiserror",
"tokio",
]
[[package]] [[package]]
name = "pagetop-minimal" name = "pagetop-minimal"
version = "0.1.0" version = "0.1.0"
@ -1802,7 +1903,6 @@ dependencies = [
name = "pagetop-seaorm" name = "pagetop-seaorm"
version = "0.0.4" version = "0.0.4"
dependencies = [ dependencies = [
"async-trait",
"pagetop", "pagetop",
"sea-orm", "sea-orm",
"sea-schema", "sea-schema",
@ -1820,6 +1920,24 @@ dependencies = [
"path-slash 0.2.1", "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]] [[package]]
name = "parking" name = "parking"
version = "2.2.1" version = "2.2.1"
@ -1862,6 +1980,17 @@ dependencies = [
"memchr", "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]] [[package]]
name = "pastey" name = "pastey"
version = "0.2.2" version = "0.2.2"
@ -2161,8 +2290,8 @@ version = "0.9.10"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d"
dependencies = [ dependencies = [
"const-oid", "const-oid 0.9.6",
"digest", "digest 0.10.7",
"num-bigint-dig", "num-bigint-dig",
"num-integer", "num-integer",
"num-traits", "num-traits",
@ -2260,6 +2389,7 @@ checksum = "2dc312fedd460a47ea563911761d254a84e7b51d8cc73ec92c929e78f33fa957"
dependencies = [ dependencies = [
"async-stream", "async-stream",
"async-trait", "async-trait",
"chrono",
"derive_more", "derive_more",
"futures-util", "futures-util",
"log", "log",
@ -2295,6 +2425,7 @@ version = "0.32.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a5d1c518eaf5eda38e5773f902b26ab6d5e9e9e2bb2349ca6c64cf96f80448c" checksum = "8a5d1c518eaf5eda38e5773f902b26ab6d5e9e9e2bb2349ca6c64cf96f80448c"
dependencies = [ dependencies = [
"chrono",
"inherent", "inherent",
"ordered-float", "ordered-float",
"sea-query-derive", "sea-query-derive",
@ -2306,6 +2437,7 @@ version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0019f47430f7995af63deda77e238c17323359af241233ec768aba1faea7608" checksum = "b0019f47430f7995af63deda77e238c17323359af241233ec768aba1faea7608"
dependencies = [ dependencies = [
"chrono",
"sea-query", "sea-query",
"sqlx", "sqlx",
] ]
@ -2436,6 +2568,18 @@ dependencies = [
"serde_core", "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]] [[package]]
name = "serde_spanned" name = "serde_spanned"
version = "1.1.1" version = "1.1.1"
@ -2464,8 +2608,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"cpufeatures", "cpufeatures 0.2.17",
"digest", "digest 0.10.7",
] ]
[[package]] [[package]]
@ -2475,8 +2619,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"cpufeatures", "cpufeatures 0.2.17",
"digest", "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",
] ]
[[package]] [[package]]
@ -2510,7 +2665,7 @@ version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
dependencies = [ dependencies = [
"digest", "digest 0.10.7",
"rand_core", "rand_core",
] ]
@ -2591,6 +2746,7 @@ checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6"
dependencies = [ dependencies = [
"base64", "base64",
"bytes", "bytes",
"chrono",
"crc", "crc",
"crossbeam-queue", "crossbeam-queue",
"either", "either",
@ -2609,7 +2765,7 @@ dependencies = [
"percent-encoding", "percent-encoding",
"serde", "serde",
"serde_json", "serde_json",
"sha2", "sha2 0.10.9",
"smallvec", "smallvec",
"thiserror", "thiserror",
"tokio", "tokio",
@ -2646,7 +2802,7 @@ dependencies = [
"quote", "quote",
"serde", "serde",
"serde_json", "serde_json",
"sha2", "sha2 0.10.9",
"sqlx-core", "sqlx-core",
"sqlx-mysql", "sqlx-mysql",
"sqlx-postgres", "sqlx-postgres",
@ -2667,8 +2823,9 @@ dependencies = [
"bitflags", "bitflags",
"byteorder", "byteorder",
"bytes", "bytes",
"chrono",
"crc", "crc",
"digest", "digest 0.10.7",
"dotenvy", "dotenvy",
"either", "either",
"futures-channel", "futures-channel",
@ -2689,7 +2846,7 @@ dependencies = [
"rsa", "rsa",
"serde", "serde",
"sha1", "sha1",
"sha2", "sha2 0.10.9",
"smallvec", "smallvec",
"sqlx-core", "sqlx-core",
"stringprep", "stringprep",
@ -2708,6 +2865,7 @@ dependencies = [
"base64", "base64",
"bitflags", "bitflags",
"byteorder", "byteorder",
"chrono",
"crc", "crc",
"dotenvy", "dotenvy",
"etcetera", "etcetera",
@ -2726,7 +2884,7 @@ dependencies = [
"rand", "rand",
"serde", "serde",
"serde_json", "serde_json",
"sha2", "sha2 0.10.9",
"smallvec", "smallvec",
"sqlx-core", "sqlx-core",
"stringprep", "stringprep",
@ -2742,6 +2900,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea"
dependencies = [ dependencies = [
"atoi", "atoi",
"chrono",
"flume", "flume",
"futures-channel", "futures-channel",
"futures-core", "futures-core",
@ -3044,9 +3203,9 @@ dependencies = [
[[package]] [[package]]
name = "tower-http" name = "tower-http"
version = "0.6.11" version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" checksum = "b11f75e912b0c2be01b63d8cf8057b8c3f97cf34abb3d431a3a4c8675498e233"
dependencies = [ dependencies = [
"bitflags", "bitflags",
"bytes", "bytes",
@ -3065,6 +3224,7 @@ dependencies = [
"tokio-util", "tokio-util",
"tower-layer", "tower-layer",
"tower-service", "tower-service",
"tracing",
] ]
[[package]] [[package]]

View file

@ -10,7 +10,10 @@ members = [
"extensions/pagetop-aliner", "extensions/pagetop-aliner",
"extensions/pagetop-bootsier", "extensions/pagetop-bootsier",
"extensions/pagetop-htmx", "extensions/pagetop-htmx",
"extensions/pagetop-admin",
"extensions/pagetop-menu",
"extensions/pagetop-seaorm", "extensions/pagetop-seaorm",
"extensions/pagetop-user",
] ]
[workspace.package] [workspace.package]
@ -21,8 +24,10 @@ license = "MIT OR Apache-2.0"
authors = ["Manuel Cillero <manuel@cillero.es>"] authors = ["Manuel Cillero <manuel@cillero.es>"]
[workspace.dependencies] [workspace.dependencies]
argon2 = "0.5"
async-trait = "0.1" async-trait = "0.1"
axum = { version = "0.8" } axum = { version = "0.8" }
base64ct = { version = "1.8", features = ["alloc"] }
change-detection = "1.2" change-detection = "1.2"
chrono = "0.4" chrono = "0.4"
colored = "3.1" colored = "3.1"
@ -43,14 +48,18 @@ path-slash = "0.2"
proc-macro2 = "1.0" proc-macro2 = "1.0"
proc-macro2-diagnostics = { version = "0.10", default-features = false } proc-macro2-diagnostics = { version = "0.10", default-features = false }
quote = "1.0" quote = "1.0"
rand_core = { version = "0.6", features = ["getrandom"] }
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
serde_qs = "1.1"
sha2 = "0.11"
syn = { version = "2.0", features = ["full", "extra-traits"] } syn = { version = "2.0", features = ["full", "extra-traits"] }
tempfile = "3.27" tempfile = "3.27"
terminal_size = "0.4" terminal_size = "0.4"
thiserror = "2.0"
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }
tower = { version = "0.5", features = ["util"] } tower = { version = "0.5", features = ["util"] }
tower-http = { version = "0.6", features = ["fs"] } tower-http = { version = "0.7", features = ["catch-panic", "fs"] }
tracing = "0.1" tracing = "0.1"
tracing-appender = "0.2" tracing-appender = "0.2"
tracing-subscriber = { version = "0.3", features = ["json", "env-filter"] } tracing-subscriber = { version = "0.3", features = ["json", "env-filter"] }
@ -62,16 +71,19 @@ pagetop-macros = { version = "0.3", path = "helpers/pagetop-macros" }
pagetop-minimal = { version = "0.1", path = "helpers/pagetop-minimal" } pagetop-minimal = { version = "0.1", path = "helpers/pagetop-minimal" }
pagetop-statics = { version = "0.1", path = "helpers/pagetop-statics" } pagetop-statics = { version = "0.1", path = "helpers/pagetop-statics" }
# Extensions # 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-bootsier = { version = "0.1", path = "extensions/pagetop-bootsier" }
pagetop-htmx = { version = "0.1", path = "extensions/pagetop-htmx" } 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-seaorm = { version = "0.0", path = "extensions/pagetop-seaorm" }
pagetop-user = { version = "0.1", path = "extensions/pagetop-user" }
# PageTop # PageTop
pagetop = { version = "0.5", path = "." } pagetop = { version = "0.5", path = "." }
[workspace.dependencies.sea-orm] [workspace.dependencies.sea-orm]
version = "1.1" version = "1.1"
features = ["debug-print", "macros", "runtime-tokio-native-tls"] features = ["debug-print", "macros", "runtime-tokio-native-tls", "with-chrono"]
default-features = false default-features = false
[workspace.dependencies.sea-schema] [workspace.dependencies.sea-schema]
@ -99,6 +111,7 @@ default = []
testing = [] testing = []
[dependencies] [dependencies]
async-trait.workspace = true
axum.workspace = true axum.workspace = true
chrono.workspace = true chrono.workspace = true
colored.workspace = true colored.workspace = true
@ -114,6 +127,7 @@ pagetop-minimal.workspace = true
pagetop-statics.workspace = true pagetop-statics.workspace = true
serde.workspace = true serde.workspace = true
terminal_size.workspace = true terminal_size.workspace = true
thiserror.workspace = true
tokio.workspace = true tokio.workspace = true
tower.workspace = true tower.workspace = true
tower-http.workspace = true tower-http.workspace = true

View file

@ -44,21 +44,10 @@ body {
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
} }
/* /* Header, Aside, Content y Footer se apilan en el mismo orden ocupando todo el ancho disponible. */
* Brand component .wrapper {
*/
.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; display: flex;
flex-direction: column;
} }
/* /*
@ -81,11 +70,75 @@ body {
color: #000; color: #000;
} }
.badge-primary { background-color: var(--val-color--primary); } .badge-primary { background-color: var(--val-color--primary); }
.badge-secondary { background-color: var(--val-color--secondary); } .badge-neutral { background-color: var(--val-color--secondary); }
.badge-success { background-color: var(--val-color--success); } .badge-success { background-color: var(--val-color--success); }
.badge-info { background-color: var(--val-color--info); } .badge-info { background-color: var(--val-color--info); }
.badge-warning { background-color: var(--val-color--warning); } .badge-warning { background-color: var(--val-color--warning); }
.badge-danger { background-color: var(--val-color--danger); } .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;
}
/* /*
* Buttons * Buttons
@ -101,6 +154,7 @@ body {
background-color: var(--val-color--primary); background-color: var(--val-color--primary);
border: 1px solid var(--val-color--primary); border: 1px solid var(--val-color--primary);
border-radius: 0.375rem; border-radius: 0.375rem;
text-decoration: none;
cursor: pointer; cursor: pointer;
transition: background-color .15s ease-in-out, border-color .15s ease-in-out; transition: background-color .15s ease-in-out, border-color .15s ease-in-out;
} }
@ -116,10 +170,19 @@ body {
border: 0; 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-primary,
.button-secondary, .button-neutral,
.button-success, .button-success,
.button-danger { .button-severe {
color: #fff; color: #fff;
} }
.button-info, .button-info,
@ -127,40 +190,40 @@ body {
color: #000; color: #000;
} }
.button-primary { background-color: var(--val-color--primary); border-color: var(--val-color--primary); } .button-primary { background-color: var(--val-color--primary); border-color: var(--val-color--primary); }
.button-secondary { background-color: var(--val-color--secondary); border-color: var(--val-color--secondary); } .button-neutral { 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-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-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-warning { background-color: var(--val-color--warning); border-color: var(--val-color--warning); }
.button-danger { background-color: var(--val-color--danger); border-color: var(--val-color--danger); } .button-severe { 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-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-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-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-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-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-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-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-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-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-outline-primary, .button-outline-primary,
.button-outline-secondary, .button-outline-neutral,
.button-outline-success, .button-outline-success,
.button-outline-info, .button-outline-info,
.button-outline-warning, .button-outline-warning,
.button-outline-danger { .button-outline-severe {
background-color: transparent; background-color: transparent;
} }
.button-outline-primary { border-color: var(--val-color--primary); color: var(--val-color--primary); } .button-outline-primary { border-color: var(--val-color--primary); color: var(--val-color--primary); }
.button-outline-secondary { border-color: var(--val-color--secondary); color: var(--val-color--secondary); } .button-outline-neutral { 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-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-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-warning { border-color: var(--val-color--warning); color: var(--val-color--warning); }
.button-outline-danger { border-color: var(--val-color--danger); color: var(--val-color--danger); } .button-outline-severe { 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-primary:hover { background-color: var(--val-color--primary); border-color: var(--val-color--primary); color: #fff; }
.button-outline-secondary:hover { background-color: var(--val-color--secondary); border-color: var(--val-color--secondary); color: #fff; } .button-outline-neutral: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-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-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-warning:hover { background-color: var(--val-color--warning); border-color: var(--val-color--warning); color: #000; }
.button-outline-danger:hover { background-color: var(--val-color--danger); border-color: var(--val-color--danger); color: #fff; } .button-outline-severe:hover { background-color: var(--val-color--danger); border-color: var(--val-color--danger); color: #fff; }
.button-link { .button-link {
font-weight: 400; font-weight: 400;
@ -184,6 +247,171 @@ body {
gap: 0.5rem; 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 * Form components
*/ */
@ -283,175 +511,20 @@ input:disabled + label {
color: var(--val-color--text--muted); color: var(--val-color--text--muted);
} }
/* .form-field > select.form-select,
* Dialog component .form-field > input.form-control,
*/ .form-field > textarea.form-control {
.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: 1px solid var(--val-color--border);
border-radius: 0.375rem; border-radius: 0.375rem;
cursor: pointer; background-color: #fff;
transition: border-color .15s ease-in-out, box-shadow .15s ease-in-out;
} }
.dropdown-toggle::after { .form-field > select.form-select:focus,
content: "\25BC"; .form-field > input.form-control:focus,
display: inline-block; .form-field > textarea.form-control:focus {
margin-left: 0.35em; outline: none;
font-size: 0.8em; border-color: var(--val-color--primary);
transition: transform .15s ease-in-out; box-shadow: 0 0 0 0.2rem color-mix(in srgb, var(--val-color--primary) 25%, transparent);
}
.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;
} }
/* /*
@ -504,59 +577,123 @@ input:disabled + label {
} }
/* /*
* Table component * Nav component
*/ */
.table-responsive { .nav {
overflow-x: auto; display: flex;
-webkit-overflow-scrolling: touch; align-items: center;
gap: 0.25rem;
list-style: none;
margin: 0;
padding: 0;
} }
.table { .nav-item {
width: 100%; position: relative;
border-collapse: collapse;
text-align: left;
} }
.table th, .nav-link {
.table td { display: block;
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
border-bottom: 1px solid color-mix(in srgb, var(--val-color--border) 15%, var(--val-color--bg)); color: var(--val-color--text);
text-decoration: none;
border-radius: 0.375rem;
} }
.nav-link:hover {
.table thead th { background-color: color-mix(in srgb, var(--val-color--text) 8%, transparent);
border-bottom-width: 2px; }
.nav-link.active {
color: var(--val-color--primary);
font-weight: 600; font-weight: 600;
} }
.nav-link.disabled {
.table-sort {
color: inherit;
text-decoration: none;
}
.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;
}
.table-empty {
text-align: center;
font-style: italic;
color: var(--val-color--text--muted); 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;
color: inherit;
background-color: transparent;
border: 1px solid var(--val-color--border);
border-radius: 0.375rem;
cursor: pointer;
}
.navbar-toggle::before {
content: "\2630";
}
.navbar-content {
display: flex;
align-items: center;
gap: 1rem;
flex: 1 1 auto;
}
.navbar-text {
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%;
}
} }
/* /*
@ -676,7 +813,7 @@ input:disabled + label {
border: 0; border: 0;
border-start-end-radius: 0; border-start-end-radius: 0;
border-end-end-radius: 0; border-end-end-radius: 0;
background-color: var(--val-color--light); background-color: var(--val-color--bg--muted);
} }
.pager-jump-input > input.form-control { .pager-jump-input > input.form-control {
text-align: center; text-align: center;
@ -699,36 +836,60 @@ input:disabled + label {
} }
/* /*
* Breadcrumb component * Table component
*/ */
.breadcrumb { .table-responsive {
display: flex; overflow-x: auto;
flex-wrap: wrap; -webkit-overflow-scrolling: touch;
list-style: none;
margin: 0 0 1rem;
padding: 0.75rem 1rem;
font-size: 0.875rem;
} }
.breadcrumb-item + .breadcrumb-item {
padding-left: 0.5rem; .table {
width: 100%;
border-collapse: collapse;
text-align: left;
} }
.breadcrumb-item + .breadcrumb-item::before {
float: left; .table th,
padding-right: 0.5rem; .table td {
color: var(--val-color--text--muted); padding: 0.5rem 0.75rem;
content: "/"; border-bottom: 1px solid color-mix(in srgb, var(--val-color--border) 15%, var(--val-color--bg));
} }
.breadcrumb-item.active {
color: var(--val-color--text--muted); .table thead th {
border-bottom-width: 2px;
font-weight: 600;
} }
.breadcrumb-item a {
color: var(--val-color--text); .table-sort {
color: inherit;
text-decoration: none; text-decoration: none;
} }
.breadcrumb-item a:hover { .table-sort:hover {
text-decoration: underline; 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 * Region Footer
@ -738,3 +899,76 @@ input:disabled + label {
padding: .75rem 0 3rem; padding: .75rem 0 3rem;
text-align: center; 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;
}

View file

@ -24,7 +24,6 @@ body {
.intro { .intro {
position: relative; position: relative;
min-width: 350px;
color: var(--intro-color); color: var(--intro-color);
background-color: var(--intro-bg-color); background-color: var(--intro-bg-color);

View file

@ -0,0 +1,18 @@
// 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);
});
});

View file

@ -1,4 +1,4 @@
use pagetop_build::{StaticFilesBundle, copy_dir}; use pagetop_build::{StaticFilesBundle, compile_scss, copy_dir, minify_js};
fn main() -> std::io::Result<()> { fn main() -> std::io::Result<()> {
// Regenera `static/` desde cero sólo si hay cambios en `assets/`. // Regenera `static/` desde cero sólo si hay cambios en `assets/`.
@ -7,6 +7,21 @@ fn main() -> std::io::Result<()> {
copy_dir("assets", "static")?; 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) StaticFilesBundle::from_dir("./static", None)
.with_name("assets") .with_name("assets")
.build() .build()

46
examples/basic-nav.rs Normal file
View file

@ -0,0 +1,46 @@
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
}

55
examples/basic-navbar.rs Normal file
View file

@ -0,0 +1,55 @@
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
}

View file

@ -142,18 +142,17 @@ async fn form_controls(request: HttpRequest) -> Result<Markup, ErrorPage> {
.with_child( .with_child(
button::ButtonSet::new() button::ButtonSet::new()
.with_button( .with_button(
Button::submit(Lc::t("btn_submit", &LOC)).with_style( Button::submit(Lc::t("btn_submit", &LOC))
button::ButtonStyle::Solid(Intent::Primary), .with_style(button::Style::Solid(Intent::Primary)),
),
) )
.with_button( .with_button(
Button::reset(Lc::t("btn_reset", &LOC)).with_style( Button::reset(Lc::t("btn_reset", &LOC)).with_style(
button::ButtonStyle::Outline(Intent::Secondary), button::Style::Outline(Intent::Neutral),
), ),
) )
.with_button( .with_button(
Button::plain(Lc::t("btn_cancel", &LOC)) Button::plain(Lc::t("btn_cancel", &LOC))
.with_style(button::ButtonStyle::Link), .with_style(button::Style::Link),
), ),
), ),
), ),
@ -258,18 +257,17 @@ async fn form_controls(request: HttpRequest) -> Result<Markup, ErrorPage> {
.with_child( .with_child(
button::ButtonSet::new() button::ButtonSet::new()
.with_button( .with_button(
Button::submit(Lc::t("btn_submit", &LOC)).with_style( Button::submit(Lc::t("btn_submit", &LOC))
button::ButtonStyle::Solid(Intent::Primary), .with_style(button::Style::Solid(Intent::Primary)),
),
) )
.with_button( .with_button(
Button::reset(Lc::t("btn_reset", &LOC)).with_style( Button::reset(Lc::t("btn_reset", &LOC)).with_style(
button::ButtonStyle::Outline(Intent::Secondary), button::Style::Outline(Intent::Neutral),
), ),
) )
.with_button( .with_button(
Button::plain(Lc::t("btn_cancel", &LOC)) Button::plain(Lc::t("btn_cancel", &LOC))
.with_style(button::ButtonStyle::Link), .with_style(button::Style::Link),
), ),
), ),
), ),
@ -299,24 +297,22 @@ async fn form_controls(request: HttpRequest) -> Result<Markup, ErrorPage> {
p { (Lc::t("dialog_delete_body", &LOC).using(cx)) } p { (Lc::t("dialog_delete_body", &LOC).using(cx)) }
} }
})) }))
.with_footer(
Button::plain(Lc::t("btn_cancel", &LOC))
.with_prop(PropsOp::set("data-dialog-dismiss", "modal"))
.with_style(button::ButtonStyle::Outline(
Intent::Secondary,
)),
)
.with_footer( .with_footer(
Button::plain(Lc::t("btn_ok", &LOC)) Button::plain(Lc::t("btn_ok", &LOC))
.with_prop(PropsOp::set("data-dialog-dismiss", "modal")) .with_prop(PropsOp::set("data-dialog-dismiss", "modal"))
.with_style(button::ButtonStyle::Solid(Intent::Primary)), .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_child( .with_child(
Button::plain(Lc::t("btn_delete", &LOC)) Button::plain(Lc::t("btn_delete", &LOC))
.with_prop(PropsOp::set("data-dialog-toggle", "modal")) .with_prop(PropsOp::set("data-dialog-toggle", "modal"))
.with_prop(PropsOp::set("data-dialog-target", "#delete-confirm")) .with_prop(PropsOp::set("data-dialog-target", "#delete-confirm"))
.with_style(button::ButtonStyle::Solid(Intent::Danger)), .with_style(button::Style::Solid(Intent::Severe)),
), ),
), ),
) )
@ -437,14 +433,14 @@ fn form_lists() -> Form {
button::ButtonSet::new() button::ButtonSet::new()
.with_button( .with_button(
Button::submit(Lc::t("btn_submit", &LOC)) Button::submit(Lc::t("btn_submit", &LOC))
.with_style(button::ButtonStyle::Solid(Intent::Primary)), .with_style(button::Style::Solid(Intent::Primary)),
) )
.with_button( .with_button(
Button::reset(Lc::t("btn_reset", &LOC)) Button::reset(Lc::t("btn_reset", &LOC))
.with_style(button::ButtonStyle::Outline(Intent::Secondary)), .with_style(button::Style::Outline(Intent::Neutral)),
) )
.with_button( .with_button(
Button::plain(Lc::t("btn_cancel", &LOC)).with_style(button::ButtonStyle::Link), Button::plain(Lc::t("btn_cancel", &LOC)).with_style(button::Style::Link),
), ),
) )
} }

View file

@ -0,0 +1,7 @@
navbar_home = Home
navbar_docs = Docs
navbar_tools = Tools
navbar_generator = Generator
navbar_reports = Reports
navbar_active = Active page
navbar_disabled = Disabled

View file

@ -0,0 +1,7 @@
navbar_home = Inicio
navbar_docs = Documentación
navbar_tools = Herramientas
navbar_generator = Generador
navbar_reports = Informes
navbar_active = Página activa
navbar_disabled = Deshabilitado

View file

@ -47,6 +47,14 @@ pub use intro::{Intro, IntroOpening};
mod messages; mod messages;
pub use messages::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; mod pager;
pub use pager::{Pager, PagerAlign, PagerVisibility}; pub use pager::{Pager, PagerAlign, PagerVisibility};

View file

@ -6,10 +6,10 @@ use crate::prelude::*;
/// ///
/// ```rust,no_run /// ```rust,no_run
/// # use pagetop::prelude::*; /// # use pagetop::prelude::*;
/// let badge = Badge::labeled(Lc::n("Admin")).with_intent(Intent::Danger); /// let badge = Badge::labeled(Lc::n("Admin")).with_intent(Intent::Severe);
/// ///
/// // Equivalente usando el constructor directo del tipo. /// // Equivalente usando el constructor directo del tipo.
/// let badge = Badge::danger(Lc::n("Admin")); /// let badge = Badge::severe(Lc::n("Admin"));
/// ``` /// ```
#[derive(AutoDefault, Clone, Debug, Getters)] #[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Badge { pub struct Badge {
@ -19,7 +19,7 @@ pub struct Badge {
label: Lc, label: Lc,
/// Devuelve la intención semántica del badge. /// Devuelve la intención semántica del badge.
#[getters(copy)] #[getters(copy)]
#[default(Intent::Secondary)] #[default(Intent::Neutral)]
intent: Intent, intent: Intent,
} }
@ -33,10 +33,10 @@ impl Component for Badge {
self.props.get_id() self.props.get_id()
} }
fn setup(&mut self, _cx: &Context) { fn setup(&mut self, cx: &Context) {
self.alter_prop(PropsOp::prepend_classes(util::join!( self.alter_prop(PropsOp::prepend_classes(util::join!(
"badge badge-", "badge badge-",
self.intent().as_str() self.intent().color(cx)
))); )));
} }
@ -67,11 +67,11 @@ impl Badge {
} }
} }
/// Crea un badge de tipo *secondary* con la etiqueta indicada. /// Crea un badge de tipo *neutral* con la etiqueta indicada.
pub fn secondary(label: Lc) -> Self { pub fn neutral(label: Lc) -> Self {
Self { Self {
label, label,
intent: Intent::Secondary, intent: Intent::Neutral,
..Default::default() ..Default::default()
} }
} }
@ -103,11 +103,11 @@ impl Badge {
} }
} }
/// Crea un badge de tipo *danger* con la etiqueta indicada. /// Crea un badge de tipo *severe* con la etiqueta indicada.
pub fn danger(label: Lc) -> Self { pub fn severe(label: Lc) -> Self {
Self { Self {
label, label,
intent: Intent::Danger, intent: Intent::Severe,
..Default::default() ..Default::default()
} }
} }

View file

@ -1,7 +1,7 @@
//! Definiciones para crear botones ([`Button`]) y conjuntos de botones ([`ButtonSet`]). //! Definiciones para crear botones ([`Button`]) y conjuntos de botones ([`ButtonSet`]).
mod props; mod props;
pub use props::{ButtonKind, ButtonStyle}; pub use props::{Kind, Size, Style};
mod component; mod component;
pub use component::Button; pub use component::Button;

View file

@ -7,6 +7,12 @@ use crate::prelude::*;
/// - [`Button::submit()`]: botón de envío (por defecto). /// - [`Button::submit()`]: botón de envío (por defecto).
/// - [`Button::reset()`]: botón de restablecimiento de valores. /// - [`Button::reset()`]: botón de restablecimiento de valores.
/// - [`Button::plain()`]: botón genérico sin comportamiento predeterminado. /// - [`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. /// Un botón puede usarse dentro o fuera de un formulario.
/// ///
@ -18,6 +24,7 @@ use crate::prelude::*;
/// let save = Button::submit(Lc::n("Save")); /// let save = Button::submit(Lc::n("Save"));
/// let cancel = Button::plain(Lc::n("Cancel")); /// let cancel = Button::plain(Lc::n("Cancel"));
/// let clear = Button::reset(Lc::n("Clear")); /// 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 /// Cuando el botón activa el envío, el navegador incluye el par `name=value` en los datos del
@ -36,10 +43,13 @@ pub struct Button {
/// Devuelve identificador, clases CSS, atributos HTML y valores extra del componente. /// Devuelve identificador, clases CSS, atributos HTML y valores extra del componente.
props: Props, props: Props,
/// Devuelve el comportamiento del botón al activarse. /// Devuelve el comportamiento del botón al activarse.
kind: button::ButtonKind, kind: button::Kind,
/// Devuelve el tamaño visual del botón.
#[getters(copy)]
size: button::Size,
/// Devuelve el estilo visual del botón. /// Devuelve el estilo visual del botón.
#[getters(copy)] #[getters(copy)]
style: button::ButtonStyle, style: button::Style,
/// Devuelve el nombre del botón. /// Devuelve el nombre del botón.
name: AttrName, name: AttrName,
/// Devuelve el valor del botón. /// Devuelve el valor del botón.
@ -48,6 +58,10 @@ pub struct Button {
label: Lc, label: Lc,
/// Devuelve el texto emergente del botón (atributo `title`). /// Devuelve el texto emergente del botón (atributo `title`).
title: Lc, 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. /// Devuelve si el botón recibe el foco automáticamente al cargar la página.
autofocus: bool, autofocus: bool,
/// Devuelve si el botón está deshabilitado. /// Devuelve si el botón está deshabilitado.
@ -64,18 +78,43 @@ impl Component for Button {
self.props.get_id() self.props.get_id()
} }
fn setup(&mut self, _cx: &Context) { fn setup(&mut self, cx: &Context) {
use button::ButtonStyle; use button::{Size, Style};
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() { self.alter_prop(PropsOp::prepend_classes(match self.style() {
ButtonStyle::None => "button".to_string(), Style::None => "button".to_string(),
ButtonStyle::Solid(intent) => util::join!("button button-", intent.as_str()), Style::Solid(intent) => util::join!("button button-", intent.color(cx)),
ButtonStyle::Outline(intent) => util::join!("button button-outline-", intent.as_str()), Style::Outline(intent) => util::join!("button button-outline-", intent.color(cx)),
ButtonStyle::Link => "button button-link".to_string(), Style::Link => "button button-link".to_string(),
})); }));
} }
async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> { 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! { Ok(html! {
button button
type=(self.kind()) type=(self.kind())
@ -86,9 +125,7 @@ impl Component for Button {
autofocus[*self.autofocus()] autofocus[*self.autofocus()]
disabled[*self.disabled()] disabled[*self.disabled()]
{ {
@if let Some(label) = self.label().lookup(cx) { (self.label().using(cx))
(label)
}
} }
}) })
} }
@ -101,7 +138,7 @@ impl Button {
/// datos al servidor. /// datos al servidor.
pub fn submit(label: Lc) -> Self { pub fn submit(label: Lc) -> Self {
Self { Self {
kind: button::ButtonKind::Submit, kind: button::Kind::Submit,
label, label,
..Default::default() ..Default::default()
} }
@ -112,7 +149,7 @@ impl Button {
/// Al pulsarlo, devuelve todos los campos del formulario a sus valores iniciales. /// Al pulsarlo, devuelve todos los campos del formulario a sus valores iniciales.
pub fn reset(label: Lc) -> Self { pub fn reset(label: Lc) -> Self {
Self { Self {
kind: button::ButtonKind::Reset, kind: button::Kind::Reset,
label, label,
..Default::default() ..Default::default()
} }
@ -124,12 +161,28 @@ impl Button {
/// definirse mediante JavaScript. /// definirse mediante JavaScript.
pub fn plain(label: Lc) -> Self { pub fn plain(label: Lc) -> Self {
Self { Self {
kind: button::ButtonKind::Plain, kind: button::Kind::Plain,
label, label,
..Default::default() ..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 >************************************************************************* // **< Button BUILDER >*************************************************************************
/// Establece el identificador único del componente; igual a `with_prop(PropsOp::set_id(id))`. /// Establece el identificador único del componente; igual a `with_prop(PropsOp::set_id(id))`.
@ -148,14 +201,21 @@ impl Button {
/// Establece el comportamiento del botón al activarse. /// Establece el comportamiento del botón al activarse.
#[builder_fn] #[builder_fn]
pub fn with_kind(mut self, kind: button::ButtonKind) -> Self { pub fn with_kind(mut self, kind: button::Kind) -> Self {
self.kind = kind; self.kind = kind;
self self
} }
/// Establece el estilo visual del botón (usa [`button::ButtonStyle::None`] para quitarlo). /// Establece el tamaño visual del botón (usa [`button::Size::None`] para quitarlo).
#[builder_fn] #[builder_fn]
pub fn with_style(mut self, style: button::ButtonStyle) -> Self { 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 {
self.style = style; self.style = style;
self self
} }
@ -194,6 +254,15 @@ impl Button {
self 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. /// Establece si el botón recibe el foco automáticamente al cargar la página.
#[builder_fn] #[builder_fn]
pub fn with_autofocus(mut self, autofocus: bool) -> Self { pub fn with_autofocus(mut self, autofocus: bool) -> Self {

View file

@ -2,27 +2,11 @@ use crate::prelude::*;
use std::fmt; use std::fmt;
// **< ButtonStyle >******************************************************************************** // **< Kind >***************************************************************************************
/// 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. /// Comportamiento de un [`Button`](super::Button) al activarse.
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)] #[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
pub enum ButtonKind { pub enum Kind {
/// Envía un formulario al servidor. Es el **tipo por defecto**. /// Envía un formulario al servidor. Es el **tipo por defecto**.
#[default] #[default]
Submit, Submit,
@ -33,12 +17,44 @@ pub enum ButtonKind {
Plain, Plain,
} }
impl fmt::Display for ButtonKind { impl fmt::Display for Kind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self { f.write_str(match self {
ButtonKind::Submit => "submit", Kind::Submit => "submit",
ButtonKind::Reset => "reset", Kind::Reset => "reset",
ButtonKind::Plain => "button", Kind::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,
}

View file

@ -31,6 +31,12 @@ pub struct Dropdown {
title: Lc, title: Lc,
/// Devuelve si el botón se desdobla (*split*) en botón de acción más *toggle*. /// Devuelve si el botón se desdobla (*split*) en botón de acción más *toggle*.
button_split: bool, 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ú. /// Devuelve la lista de elementos del menú.
items: Children, items: Children,
} }
@ -65,15 +71,44 @@ 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); let toggle_label = Lc::l("dropdown_toggle").using(cx);
Ok(html! { Ok(html! {
div (self.props()) { div (self.props()) {
@if *self.button_split() { @if *self.button_split() {
button type="button" class="dropdown-button" { (&title) } button type="button" class=(&button_classes) { (&title) }
button button
type="button" type="button"
class="dropdown-toggle" class=(&toggle_classes)
aria-haspopup="true" aria-haspopup="true"
aria-expanded="false" aria-expanded="false"
{ {
@ -82,7 +117,7 @@ impl Component for Dropdown {
} @else { } @else {
button button
type="button" type="button"
class="dropdown-toggle" class=(&toggle_classes)
aria-haspopup="true" aria-haspopup="true"
aria-expanded="false" aria-expanded="false"
{ {
@ -126,6 +161,20 @@ impl Dropdown {
self 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 /// Añade un nuevo elemento al menú o modifica la lista de elementos del menú con una operación
/// [`TypedOp`]. /// [`TypedOp`].
/// ///

10
src/base/component/nav.rs Normal file
View file

@ -0,0 +1,10 @@
//! 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};

View file

@ -0,0 +1,113 @@
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
}
}

View file

@ -0,0 +1,291 @@
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
}
}

View file

@ -0,0 +1,26 @@
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,
}

View file

@ -0,0 +1,10 @@
//! 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;

View file

@ -0,0 +1,238 @@
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
}
}

View file

@ -0,0 +1,84 @@
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)
}
}

View file

@ -0,0 +1,32 @@
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>),
}

View file

@ -20,7 +20,7 @@ impl Theme for Basic {
.with_weight(-99), .with_weight(-99),
)) ))
.alter_assets(AssetsOp::AddStyleSheet( .alter_assets(AssetsOp::AddStyleSheet(
StyleSheet::from("/pagetop/css/basic.css") StyleSheet::from("/pagetop/css/basic.min.css")
.with_version(PAGETOP_VERSION) .with_version(PAGETOP_VERSION)
.with_weight(-99), .with_weight(-99),
)) ))
@ -30,6 +30,9 @@ impl Theme for Basic {
.alter_assets(AssetsOp::AddJavaScript( .alter_assets(AssetsOp::AddJavaScript(
JavaScript::defer("/pagetop/js/basic.dropdown.min.js").with_version(PAGETOP_VERSION), 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( .alter_assets(AssetsOp::AddJavaScript(
JavaScript::defer("/pagetop/js/basic.dialog.min.js").with_version(PAGETOP_VERSION), JavaScript::defer("/pagetop/js/basic.dialog.min.js").with_version(PAGETOP_VERSION),
)) ))

View file

@ -13,12 +13,14 @@
//! //!
//! # Temas hijo, herencia y componentes //! # Temas hijo, herencia y componentes
//! //!
//! PageTop permite crear **temas hijo** que refinan el comportamiento de su tema padre. Un tema //! PageTop permite crear **temas hijo** que refinan el comportamiento de su tema padre,
//! hijo hereda automáticamente todos los métodos del padre y puede sobrescribirlos selectivamente. //! identificado por [`Theme::parent()`]. Un tema hijo hereda automáticamente todos los métodos del
//! Esta herencia sólo determina qué implementación de sus métodos se usa cuando el tema hijo no los //! padre y puede sobrescribirlos selectivamente. Esta herencia determina qué implementación de sus
//! sobrescribe (como el renderizado del `<body>` y del `<head>`, los recursos incorporados, el uso //! métodos se usa cuando el tema hijo no los sobrescribe (ya sea el renderizado del `<body>` o del
//! de [`Theme::handle_component()`], las páginas de error, etc.). Un tema hijo puede ser a su vez //! `<head>`, la definición de los recursos necesarios, la asignación de colores por intención vía
//! padre de otro, basta declararlo cada vez con [`Theme::parent()`]. //! [`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()`].
//! //!
//! Como `parent()` se resuelve en tiempo de ejecución, PageTop no puede descartar en compilación //! 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 //! referencias circulares (un tema acaba siendo padre de sí mismo, directa o transitivamente). Ese
@ -48,8 +50,8 @@
//! devuelva `Some(&Self)`. Basta con un `impl Theme for MyTheme {}` vacío, ya que todos los //! 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. //! métodos de [`Theme`] tienen implementación por defecto.
//! //!
//! Un tema puede personalizarse en tres pasos, cada uno necesario sólo si lo que ofrece PageTop por //! Un tema puede personalizarse en cinco pasos, cada uno necesario sólo si lo que ofrece PageTop
//! defecto no basta: //! por defecto no basta:
//! //!
//! 1. **Definir regiones nuevas**. Por defecto, PageTop define [`CoreRegions`] (`Header`, `Aside`, //! 1. **Definir regiones nuevas**. Por defecto, PageTop define [`CoreRegions`] (`Header`, `Aside`,
//! `Content`, `Footer`) como regiones de plantilla siempre disponibles, y [`ReservedRegions`] //! `Content`, `Footer`) como regiones de plantilla siempre disponibles, y [`ReservedRegions`]
@ -75,6 +77,21 @@
//! ejemplo, [`CoreTemplates`] o el propio *enum* del tema). `pagetop-bootsier` hace exactamente //! 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 //! esto para maquetar `Standard` y `Admin` de forma distinta, sin necesitar sus propias
//! variantes de plantilla. //! 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 //! Para forzar una plantilla completamente distinta en una página concreta, se puede llamar
//! manualmente a [`with_template()`](crate::core::component::Contextual::with_template). //! manualmente a [`with_template()`](crate::core::component::Contextual::with_template).
@ -86,7 +103,7 @@
//! plantilla distinta. //! plantilla distinta.
//! //!
//! El resto del comportamiento de un tema (por ejemplo, el renderizado del `<head>`) se sobrescribe //! El resto del comportamiento de un tema (por ejemplo, el renderizado del `<head>`) se sobrescribe
//! de forma independiente de estos tres pasos y no es necesario para tener un tema funcional. //! de forma independiente de estos pasos y no es necesario para tener un tema funcional.
//! //!
//! # Componentes que se procesan en todas las páginas //! # Componentes que se procesan en todas las páginas
//! //!

View file

@ -3,7 +3,7 @@ use crate::base::component::{Html, Intro, IntroOpening, layout};
use crate::core::component::{ChildOp, Component, ComponentError, ComponentRender}; use crate::core::component::{ChildOp, Component, ComponentError, ComponentRender};
use crate::core::component::{Context, Contextual}; use crate::core::component::{Context, Contextual};
use crate::core::extension::Extension; use crate::core::extension::Extension;
use crate::core::theme::CoreRegions; use crate::core::theme::{CoreRegions, Intent};
use crate::global; use crate::global;
use crate::html::{Markup, html}; use crate::html::{Markup, html};
use crate::locale::Lc; use crate::locale::Lc;
@ -65,6 +65,34 @@ pub trait Theme: Extension + Send + Sync {
None 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. /// 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, /// Es un buen lugar para inicializar o ajustar recursos en función del contexto de la página,

View file

@ -1,31 +1,41 @@
use crate::AutoDefault; use crate::AutoDefault;
use crate::core::component::{Context, Contextual};
/// Intención semántica de un componente visual. /// Intención semántica de un componente visual.
/// ///
/// Representa la intención que pretende comunicar un componente (énfasis principal, confirmación de /// Describe *qué comunica* un componente, y cada tema traduce la intención a su propia paleta de
/// un evento exitoso, aviso, peligro, etc.). Cada tema decidirá cómo pintarlo. /// colores. Define un vocabulario con variantes por énfasis (`Primary`, `Neutral`), un canal
/// informativo (`Info`) y una escala de severidad (`Success`, `Warning`, `Severe`).
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)] #[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
pub enum Intent { pub enum Intent {
/// Énfasis principal, suele ser la acción que más destaca a la vista.
#[default] #[default]
Primary, Primary,
Secondary, /// Presencia sin énfasis, una opción discreta o normal.
Success, Neutral,
/// Informa de algo que merece leerse, ajeno a la escala de severidad. Encaja en notas o avisos
/// informativos.
Info, 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.
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.
Warning, Warning,
Danger, /// 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,
} }
impl Intent { impl Intent {
/// Devuelve el nombre de la intención (`"primary"`, `"danger"`, etc.). /// Devuelve el nombre del color asociado a la intención en el tema activo del contexto actual.
#[rustfmt::skip] ///
pub const fn as_str(&self) -> &'static str { /// Atajo de [`Theme::intent_color()`](crate::core::theme::Theme::intent_color) a través de
match self { /// [`Context::theme()`]. La intención no tiene una cadena propia, es el tema activo quien
Self::Primary => "primary", /// decide su traducción.
Self::Secondary => "secondary", pub fn color(&self, cx: &Context) -> &'static str {
Self::Success => "success", cx.theme().intent_color(*self)
Self::Info => "info",
Self::Warning => "warning",
Self::Danger => "danger",
}
} }
} }

View file

@ -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 /// `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>`...) las siguen viendo /// handler que también las necesiten (como `Path<T>`, `Extension<T>`, etc.) las siguen viendo
/// intactas, sea cual sea la posición en la que se declare `HttpRequest`. Por convención, se /// 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 /// 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`...) debe ir /// impone Axum: un extractor que consuma el cuerpo de la petición (`Form<T>`, `RawForm`, etc.) debe
/// siempre el último. /// ir siempre el último.
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct HttpRequest { pub struct HttpRequest {
uri: http::Uri, uri: http::Uri,
@ -120,9 +120,9 @@ impl<S: Send + Sync> FromRequestParts<S> for HttpRequest {
type Rejection = Infallible; type Rejection = Infallible;
// Clona (no toma) las extensiones inyectadas por middleware, para que otros extractores del // Clona (no toma) las extensiones inyectadas por middleware, para que otros extractores del
// handler (`Path<T>`, `Extension<T>`...) las sigan viendo intactas sin importar en qué posición // handler (`Path<T>`, `Extension<T>`, etc.) las sigan viendo intactas sin importar en qué
// se declare `HttpRequest`. El clon se envuelve en un `Arc` compartido para que `HttpRequest` // posición se declare `HttpRequest`. El clon se envuelve en un `Arc` compartido para que
// sea `Clone` a coste mínimo en el resto de su ciclo de vida. // `HttpRequest` sea `Clone` a coste mínimo en el resto de su ciclo de vida.
async fn from_request_parts( async fn from_request_parts(
parts: &mut http::request::Parts, parts: &mut http::request::Parts,
_state: &S, _state: &S,

View file

@ -28,22 +28,22 @@ async fn renders_as_a_span_element() {
} }
#[pagetop::test] #[pagetop::test]
async fn default_intent_is_secondary() { async fn default_intent_is_neutral() {
let mut badge = Badge::labeled(Lc::n("Admin")); let mut badge = Badge::labeled(Lc::n("Admin"));
let html = badge.render(&mut Context::default()).await.into_string(); let html = badge.render(&mut Context::default()).await.into_string();
assert!(html.contains(r#"class="badge badge-secondary""#)); assert!(html.contains(r#"class="badge badge-neutral""#));
} }
#[pagetop::test] #[pagetop::test]
async fn each_direct_constructor_sets_its_intent() { async fn each_direct_constructor_sets_its_intent() {
let cases = [ let cases = [
(Badge::primary(Lc::n("x")), "badge-primary"), (Badge::primary(Lc::n("x")), "badge-primary"),
(Badge::secondary(Lc::n("x")), "badge-secondary"), (Badge::neutral(Lc::n("x")), "badge-neutral"),
(Badge::success(Lc::n("x")), "badge-success"), (Badge::success(Lc::n("x")), "badge-success"),
(Badge::info(Lc::n("x")), "badge-info"), (Badge::info(Lc::n("x")), "badge-info"),
(Badge::warning(Lc::n("x")), "badge-warning"), (Badge::warning(Lc::n("x")), "badge-warning"),
(Badge::danger(Lc::n("x")), "badge-danger"), (Badge::severe(Lc::n("x")), "badge-severe"),
]; ];
for (mut badge, expected_class) in cases { for (mut badge, expected_class) in cases {
let html = badge.render(&mut Context::default()).await.into_string(); let html = badge.render(&mut Context::default()).await.into_string();
@ -56,16 +56,16 @@ async fn each_direct_constructor_sets_its_intent() {
#[pagetop::test] #[pagetop::test]
async fn with_intent_overrides_the_default() { async fn with_intent_overrides_the_default() {
let mut badge = Badge::labeled(Lc::n("Admin")).with_intent(Intent::Danger); let mut badge = Badge::labeled(Lc::n("Admin")).with_intent(Intent::Severe);
let html = badge.render(&mut Context::default()).await.into_string(); let html = badge.render(&mut Context::default()).await.into_string();
assert!(html.contains(r#"class="badge badge-danger""#)); assert!(html.contains(r#"class="badge badge-severe""#));
} }
#[pagetop::test] #[pagetop::test]
async fn direct_constructor_is_equivalent_to_labeled_with_intent() { async fn direct_constructor_is_equivalent_to_labeled_with_intent() {
let mut from_constructor = Badge::danger(Lc::n("Admin")); let mut from_constructor = Badge::severe(Lc::n("Admin"));
let mut from_builder = Badge::labeled(Lc::n("Admin")).with_intent(Intent::Danger); let mut from_builder = Badge::labeled(Lc::n("Admin")).with_intent(Intent::Severe);
assert_eq!( assert_eq!(
from_constructor from_constructor
@ -89,9 +89,9 @@ async fn with_id_sets_the_identifier() {
#[pagetop::test] #[pagetop::test]
async fn with_prop_adds_extra_classes_alongside_the_intent_class() { async fn with_prop_adds_extra_classes_alongside_the_intent_class() {
let mut badge = Badge::danger(Lc::n("Admin")).with_prop(PropsOp::add_classes("custom")); let mut badge = Badge::severe(Lc::n("Admin")).with_prop(PropsOp::add_classes("custom"));
let html = badge.render(&mut Context::default()).await.into_string(); let html = badge.render(&mut Context::default()).await.into_string();
assert!(html.contains("badge-danger")); assert!(html.contains("badge-severe"));
assert!(html.contains("custom")); assert!(html.contains("custom"));
} }

View file

@ -48,17 +48,16 @@ async fn title_attribute_can_be_cleared_with_lc_none() {
#[pagetop::test] #[pagetop::test]
async fn style_class_reflects_intent_and_style() { async fn style_class_reflects_intent_and_style() {
let mut button = let mut button = Button::submit(Lc::n("Save")).with_style(button::Style::Solid(Intent::Severe));
Button::submit(Lc::n("Save")).with_style(button::ButtonStyle::Solid(Intent::Danger));
let html = button.render(&mut Context::default()).await.into_string(); let html = button.render(&mut Context::default()).await.into_string();
assert!(html.contains("button-danger")); assert!(html.contains("button-severe"));
} }
#[pagetop::test] #[pagetop::test]
async fn outline_style_generates_outline_class() { async fn outline_style_generates_outline_class() {
let mut button = let mut button =
Button::submit(Lc::n("Save")).with_style(button::ButtonStyle::Outline(Intent::Primary)); Button::submit(Lc::n("Save")).with_style(button::Style::Outline(Intent::Primary));
let html = button.render(&mut Context::default()).await.into_string(); let html = button.render(&mut Context::default()).await.into_string();
assert!(html.contains("button-outline-primary")); assert!(html.contains("button-outline-primary"));
@ -66,12 +65,84 @@ async fn outline_style_generates_outline_class() {
#[pagetop::test] #[pagetop::test]
async fn link_style_generates_link_class_without_intent() { async fn link_style_generates_link_class_without_intent() {
let mut button = Button::plain(Lc::n("Cancel")).with_style(button::ButtonStyle::Link); let mut button = Button::plain(Lc::n("Cancel")).with_style(button::Style::Link);
let html = button.render(&mut Context::default()).await.into_string(); let html = button.render(&mut Context::default()).await.into_string();
assert!(html.contains("button-link")); 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 >********************************************************************************** // **< ButtonSet >**********************************************************************************
#[pagetop::test] #[pagetop::test]