(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.
This commit is contained in:
Manuel Cillero 2026-08-28 18:24:43 +02:00
parent da959183f6
commit 35ae5bb6a8
22 changed files with 1695 additions and 280 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-aliner = { version = "0.1", path = "extensions/pagetop-aliner" } pagetop-admin = { version = "0.1", path = "extensions/pagetop-admin" }
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-seaorm = { version = "0.0", path = "extensions/pagetop-seaorm" } pagetop-menu = { version = "0.1", path = "extensions/pagetop-menu" }
pagetop-seaorm = { version = "0.0", path = "extensions/pagetop-seaorm" }
pagetop-user = { version = "0.1", path = "extensions/pagetop-user" }
# PageTop # 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 {
*/ display: flex;
flex-direction: column;
.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;
} }
/* /*
@ -87,6 +76,70 @@ body {
.badge-warning { background-color: var(--val-color--warning); } .badge-warning { background-color: var(--val-color--warning); }
.badge-severe { 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
*/ */
@ -194,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
*/ */
@ -293,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;
} }
/* /*
@ -514,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%;
}
} }
/* /*
@ -686,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;
@ -709,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
@ -748,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

@ -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

@ -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),
)) ))