(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
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.
* [Tracing](https://github.com/tokio-rs/tracing) para la gestión de trazas y registro de eventos
de la aplicación.
@ -13,7 +13,7 @@ algunas de las librerías más robustas y populares del [ecosistema Rust](https:
PageTop y sus extensiones.
# 🗚 FIGfonts
# FIGfonts
PageTop usa el *crate* [figlet-rs](https://crates.io/crates/figlet-rs) desarrollado por *yuanbohan*
para mostrar un banner de presentación en el terminal con el nombre de la aplicación en caracteres
@ -26,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*
# 👾 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).
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"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "argon2"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
dependencies = [
"base64ct",
"blake2",
"cpufeatures 0.2.17",
"password-hash",
]
[[package]]
name = "async-stream"
version = "0.3.6"
@ -242,6 +254,15 @@ dependencies = [
"serde_core",
]
[[package]]
name = "blake2"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
dependencies = [
"digest 0.10.7",
]
[[package]]
name = "block-buffer"
version = "0.10.4"
@ -251,6 +272,15 @@ dependencies = [
"generic-array",
]
[[package]]
name = "block-buffer"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
dependencies = [
"hybrid-array",
]
[[package]]
name = "bstr"
version = "1.12.1"
@ -399,6 +429,12 @@ version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
[[package]]
name = "const-oid"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
[[package]]
name = "core-foundation"
version = "0.10.1"
@ -424,6 +460,15 @@ dependencies = [
"libc",
]
[[package]]
name = "cpufeatures"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
dependencies = [
"libc",
]
[[package]]
name = "crc"
version = "3.4.0"
@ -501,6 +546,15 @@ dependencies = [
"typenum",
]
[[package]]
name = "crypto-common"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
dependencies = [
"hybrid-array",
]
[[package]]
name = "darling"
version = "0.20.11"
@ -541,7 +595,7 @@ version = "0.7.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
dependencies = [
"const-oid",
"const-oid 0.9.6",
"pem-rfc7468",
"zeroize",
]
@ -583,12 +637,23 @@ version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"const-oid",
"crypto-common",
"block-buffer 0.10.4",
"const-oid 0.9.6",
"crypto-common 0.1.7",
"subtle",
]
[[package]]
name = "digest"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
dependencies = [
"block-buffer 0.12.1",
"const-oid 0.10.2",
"crypto-common 0.2.2",
]
[[package]]
name = "displaydoc"
version = "0.2.5"
@ -1053,7 +1118,7 @@ version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
dependencies = [
"digest",
"digest 0.10.7",
]
[[package]]
@ -1116,6 +1181,15 @@ version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "hybrid-array"
version = "0.4.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c"
dependencies = [
"typenum",
]
[[package]]
name = "hyper"
version = "1.9.0"
@ -1489,7 +1563,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
dependencies = [
"cfg-if",
"digest",
"digest 0.10.7",
]
[[package]]
@ -1716,6 +1790,7 @@ dependencies = [
name = "pagetop"
version = "0.5.0"
dependencies = [
"async-trait",
"axum",
"chrono",
"colored",
@ -1736,6 +1811,7 @@ dependencies = [
"serde_json",
"tempfile",
"terminal_size",
"thiserror",
"tokio",
"tower",
"tower-http",
@ -1745,6 +1821,17 @@ dependencies = [
"unic-langid",
]
[[package]]
name = "pagetop-admin"
version = "0.1.0"
dependencies = [
"pagetop",
"pagetop-seaorm",
"serde",
"serde_json",
"thiserror",
]
[[package]]
name = "pagetop-aliner"
version = "0.1.0"
@ -1777,6 +1864,7 @@ version = "0.1.0"
dependencies = [
"pagetop",
"pagetop-build",
"serde_json",
]
[[package]]
@ -1789,6 +1877,19 @@ dependencies = [
"syn",
]
[[package]]
name = "pagetop-menu"
version = "0.1.0"
dependencies = [
"chrono",
"pagetop",
"pagetop-seaorm",
"sea-orm",
"serde",
"thiserror",
"tokio",
]
[[package]]
name = "pagetop-minimal"
version = "0.1.0"
@ -1802,7 +1903,6 @@ dependencies = [
name = "pagetop-seaorm"
version = "0.0.4"
dependencies = [
"async-trait",
"pagetop",
"sea-orm",
"sea-schema",
@ -1820,6 +1920,24 @@ dependencies = [
"path-slash 0.2.1",
]
[[package]]
name = "pagetop-user"
version = "0.1.0"
dependencies = [
"argon2",
"base64ct",
"pagetop",
"pagetop-admin",
"pagetop-htmx",
"pagetop-seaorm",
"rand_core",
"serde",
"serde_json",
"serde_qs",
"sha2 0.11.0",
"thiserror",
]
[[package]]
name = "parking"
version = "2.2.1"
@ -1862,6 +1980,17 @@ dependencies = [
"memchr",
]
[[package]]
name = "password-hash"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
dependencies = [
"base64ct",
"rand_core",
"subtle",
]
[[package]]
name = "pastey"
version = "0.2.2"
@ -2161,8 +2290,8 @@ version = "0.9.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d"
dependencies = [
"const-oid",
"digest",
"const-oid 0.9.6",
"digest 0.10.7",
"num-bigint-dig",
"num-integer",
"num-traits",
@ -2260,6 +2389,7 @@ checksum = "2dc312fedd460a47ea563911761d254a84e7b51d8cc73ec92c929e78f33fa957"
dependencies = [
"async-stream",
"async-trait",
"chrono",
"derive_more",
"futures-util",
"log",
@ -2295,6 +2425,7 @@ version = "0.32.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a5d1c518eaf5eda38e5773f902b26ab6d5e9e9e2bb2349ca6c64cf96f80448c"
dependencies = [
"chrono",
"inherent",
"ordered-float",
"sea-query-derive",
@ -2306,6 +2437,7 @@ version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0019f47430f7995af63deda77e238c17323359af241233ec768aba1faea7608"
dependencies = [
"chrono",
"sea-query",
"sqlx",
]
@ -2436,6 +2568,18 @@ dependencies = [
"serde_core",
]
[[package]]
name = "serde_qs"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67d525c8ff68aa99e5818302259bdd02d86d0303710616f39c0f44846ff6d332"
dependencies = [
"itoa",
"percent-encoding",
"ryu",
"serde",
]
[[package]]
name = "serde_spanned"
version = "1.1.1"
@ -2464,8 +2608,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
"cpufeatures 0.2.17",
"digest 0.10.7",
]
[[package]]
@ -2475,8 +2619,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
"cpufeatures 0.2.17",
"digest 0.10.7",
]
[[package]]
name = "sha2"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
dependencies = [
"cfg-if",
"cpufeatures 0.3.0",
"digest 0.11.3",
]
[[package]]
@ -2510,7 +2665,7 @@ version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
dependencies = [
"digest",
"digest 0.10.7",
"rand_core",
]
@ -2591,6 +2746,7 @@ checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6"
dependencies = [
"base64",
"bytes",
"chrono",
"crc",
"crossbeam-queue",
"either",
@ -2609,7 +2765,7 @@ dependencies = [
"percent-encoding",
"serde",
"serde_json",
"sha2",
"sha2 0.10.9",
"smallvec",
"thiserror",
"tokio",
@ -2646,7 +2802,7 @@ dependencies = [
"quote",
"serde",
"serde_json",
"sha2",
"sha2 0.10.9",
"sqlx-core",
"sqlx-mysql",
"sqlx-postgres",
@ -2667,8 +2823,9 @@ dependencies = [
"bitflags",
"byteorder",
"bytes",
"chrono",
"crc",
"digest",
"digest 0.10.7",
"dotenvy",
"either",
"futures-channel",
@ -2689,7 +2846,7 @@ dependencies = [
"rsa",
"serde",
"sha1",
"sha2",
"sha2 0.10.9",
"smallvec",
"sqlx-core",
"stringprep",
@ -2708,6 +2865,7 @@ dependencies = [
"base64",
"bitflags",
"byteorder",
"chrono",
"crc",
"dotenvy",
"etcetera",
@ -2726,7 +2884,7 @@ dependencies = [
"rand",
"serde",
"serde_json",
"sha2",
"sha2 0.10.9",
"smallvec",
"sqlx-core",
"stringprep",
@ -2742,6 +2900,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea"
dependencies = [
"atoi",
"chrono",
"flume",
"futures-channel",
"futures-core",
@ -3044,9 +3203,9 @@ dependencies = [
[[package]]
name = "tower-http"
version = "0.6.11"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
checksum = "b11f75e912b0c2be01b63d8cf8057b8c3f97cf34abb3d431a3a4c8675498e233"
dependencies = [
"bitflags",
"bytes",
@ -3065,6 +3224,7 @@ dependencies = [
"tokio-util",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]

View file

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

View file

@ -44,21 +44,10 @@ body {
-webkit-tap-highlight-color: transparent;
}
/*
* 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 {
/* Header, Aside, Content y Footer se apilan en el mismo orden ocupando todo el ancho disponible. */
.wrapper {
display: flex;
flex-direction: column;
}
/*
@ -87,6 +76,70 @@ body {
.badge-warning { background-color: var(--val-color--warning); }
.badge-severe { background-color: var(--val-color--danger); }
/*
* Brand component
*/
.brand {
display: inline-flex;
align-items: center;
gap: 0.5rem;
color: var(--val-color--text);
font-size: 1.75rem;
font-weight: 600;
text-decoration: none;
}
.brand .image {
display: flex;
}
/*
* Breadcrumb component
*/
.breadcrumb {
display: flex;
flex-wrap: wrap;
list-style: none;
margin: 0 0 1rem;
padding: 0.75rem 1rem;
font-size: 0.875rem;
}
.breadcrumb-item + .breadcrumb-item {
padding-left: 0.5rem;
}
.breadcrumb-item + .breadcrumb-item::before {
float: left;
padding-right: 0.5rem;
color: var(--val-color--text--muted);
content: "/";
}
.breadcrumb-item.active {
color: var(--val-color--text--muted);
}
.breadcrumb-item a {
color: var(--val-color--text);
text-decoration: none;
}
.breadcrumb-item a:hover {
text-decoration: underline;
}
/* Geometría base de .dropdown-button/.dropdown-toggle (ver "Dropdown component" más abajo). Se
* declara aquí, antes de "Buttons", para que las clases .button-sm/.button-lg/.button-* ganen el
* empate de especificidad frente a ella cuando se combinan en el mismo elemento (CSS resuelve un
* empate por orden de aparición en el fichero, no por el orden de las clases en el HTML). */
.dropdown-button,
.dropdown-toggle {
padding: 0.5rem 0.75rem;
font: inherit;
color: var(--val-color--text);
background-color: transparent;
border: 1px solid var(--val-color--border);
border-radius: 0.375rem;
cursor: pointer;
}
/*
* Buttons
*/
@ -194,6 +247,171 @@ body {
gap: 0.5rem;
}
/*
* Dialog component
*/
.dialog {
padding: 0;
width: 100%;
max-width: 32rem;
color: var(--val-color--text);
background-color: var(--val-color--bg);
border: none;
border-radius: 0.5rem;
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
}
.dialog::backdrop {
background-color: rgba(0, 0, 0, 0.5);
}
.dialog-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 1rem 1.25rem;
border-bottom: 1px solid color-mix(in srgb, var(--val-color--border) 15%, var(--val-color--bg));
}
.dialog-title {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
}
.dialog-close {
width: 1.5rem;
height: 1.5rem;
padding: 0;
line-height: 1;
color: var(--val-color--text--muted);
background-color: transparent;
border: none;
cursor: pointer;
}
.dialog-close::before {
content: "\2715";
}
.dialog-close:hover {
color: var(--val-color--text);
}
.dialog-body {
padding: 1.25rem;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
padding: 1rem 1.25rem;
border-top: 1px solid color-mix(in srgb, var(--val-color--border) 15%, var(--val-color--bg));
}
/*
* Dropdown component
*/
.dropdown {
position: relative;
display: inline-block;
}
/* La geometría base de .dropdown-button/.dropdown-toggle vive junto a "Buttons" (antes de esta
* sección), para que las clases .button-sm/.button-lg/.button-* ganen el empate de especificidad
* frente a ella (CSS resuelve un empate por orden de aparición en el fichero, no por el orden de
* las clases en el HTML). */
.dropdown-toggle::after {
content: "\25BC";
display: inline-block;
margin-left: 0.35em;
font-size: 0.8em;
transition: transform .15s ease-in-out;
}
.dropdown:has(> .dropdown-menu.show) > .dropdown-toggle::after {
transform: rotate(180deg);
}
.dropdown-menu {
position: absolute;
z-index: 1000;
top: 100%;
left: 0;
min-width: 12rem;
margin: 0.25rem 0 0;
padding: 0.5rem 0;
list-style: none;
background-color: var(--val-color--bg);
border: 1px solid color-mix(in srgb, var(--val-color--border) 15%, var(--val-color--bg));
border-radius: 0.375rem;
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
/* Closed by default */
display: none;
}
.dropdown-menu.show {
display: block;
}
.dropdown-item {
display: block;
width: 100%;
padding: 0.375rem 1rem;
text-align: left;
color: var(--val-color--text);
text-decoration: none;
background-color: transparent;
border: none;
font: inherit;
cursor: pointer;
}
.dropdown-item:hover {
background-color: color-mix(in srgb, var(--val-color--text) 8%, transparent);
}
.dropdown-item.active {
color: var(--val-color--primary);
font-weight: 600;
}
.dropdown-item.disabled {
color: var(--val-color--text--muted);
pointer-events: none;
}
.dropdown-item-text {
display: block;
padding: 0.375rem 1rem;
color: var(--val-color--text--muted);
}
.dropdown-header {
display: block;
padding: 0.5rem 1rem 0.25rem;
margin: 0;
font-size: 0.8125rem;
font-weight: 600;
color: var(--val-color--text--muted);
}
.dropdown-divider {
height: 0;
margin: 0.5rem 0;
border: none;
border-top: 1px solid var(--val-color--border);
}
/* Utility: visually hidden but still accessible to assistive technology */
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
/*
* Form components
*/
@ -293,175 +511,20 @@ input:disabled + label {
color: var(--val-color--text--muted);
}
/*
* Dialog component
*/
.dialog {
padding: 0;
width: 100%;
max-width: 32rem;
color: var(--val-color--text);
background-color: var(--val-color--bg);
border: none;
border-radius: 0.5rem;
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
}
.dialog::backdrop {
background-color: rgba(0, 0, 0, 0.5);
}
.dialog-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 1rem 1.25rem;
border-bottom: 1px solid color-mix(in srgb, var(--val-color--border) 15%, var(--val-color--bg));
}
.dialog-title {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
}
.dialog-close {
width: 1.5rem;
height: 1.5rem;
padding: 0;
line-height: 1;
color: var(--val-color--text--muted);
background-color: transparent;
border: none;
cursor: pointer;
}
.dialog-close::before {
content: "\2715";
}
.dialog-close:hover {
color: var(--val-color--text);
}
.dialog-body {
padding: 1.25rem;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
padding: 1rem 1.25rem;
border-top: 1px solid color-mix(in srgb, var(--val-color--border) 15%, var(--val-color--bg));
}
/*
* Dropdown component
*/
.dropdown {
position: relative;
display: inline-block;
}
.dropdown-button,
.dropdown-toggle {
padding: 0.5rem 0.75rem;
font: inherit;
color: var(--val-color--text);
background-color: transparent;
.form-field > select.form-select,
.form-field > input.form-control,
.form-field > textarea.form-control {
border: 1px solid var(--val-color--border);
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 {
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-field > select.form-select:focus,
.form-field > input.form-control:focus,
.form-field > textarea.form-control:focus {
outline: none;
border-color: var(--val-color--primary);
box-shadow: 0 0 0 0.2rem color-mix(in srgb, var(--val-color--primary) 25%, transparent);
}
/*
@ -514,59 +577,123 @@ input:disabled + label {
}
/*
* Table component
* Nav component
*/
.table-responsive {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
.nav {
display: flex;
align-items: center;
gap: 0.25rem;
list-style: none;
margin: 0;
padding: 0;
}
.table {
width: 100%;
border-collapse: collapse;
text-align: left;
.nav-item {
position: relative;
}
.table th,
.table td {
.nav-link {
display: block;
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;
}
.table thead th {
border-bottom-width: 2px;
.nav-link:hover {
background-color: color-mix(in srgb, var(--val-color--text) 8%, transparent);
}
.nav-link.active {
color: var(--val-color--primary);
font-weight: 600;
}
.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;
.nav-link.disabled {
color: var(--val-color--text--muted);
pointer-events: none;
}
.nav-start {
justify-content: flex-start;
}
.nav-center {
justify-content: center;
}
.nav-end {
justify-content: flex-end;
}
.nav-vertical {
flex-direction: column;
align-items: stretch;
}
.nav-fill > .nav-item {
flex: 1 1 auto;
text-align: center;
}
.nav-justified > .nav-item {
flex-basis: 0;
flex-grow: 1;
text-align: center;
}
/*
* Navbar component
*/
.navbar {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.5rem 1rem;
}
.navbar-toggle {
display: none;
padding: 0.25rem 0.5rem;
font-size: 1.25rem;
line-height: 1;
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-start-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 {
text-align: center;
@ -709,36 +836,60 @@ input:disabled + label {
}
/*
* Breadcrumb component
* Table component
*/
.breadcrumb {
display: flex;
flex-wrap: wrap;
list-style: none;
margin: 0 0 1rem;
padding: 0.75rem 1rem;
font-size: 0.875rem;
.table-responsive {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.breadcrumb-item + .breadcrumb-item {
padding-left: 0.5rem;
.table {
width: 100%;
border-collapse: collapse;
text-align: left;
}
.breadcrumb-item + .breadcrumb-item::before {
float: left;
padding-right: 0.5rem;
color: var(--val-color--text--muted);
content: "/";
.table th,
.table td {
padding: 0.5rem 0.75rem;
border-bottom: 1px solid color-mix(in srgb, var(--val-color--border) 15%, var(--val-color--bg));
}
.breadcrumb-item.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;
}
.breadcrumb-item a:hover {
.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);
}
/*
* Region Footer
@ -748,3 +899,76 @@ input:disabled + label {
padding: .75rem 0 3rem;
text-align: center;
}
/*
* pagetop-user: login page
*/
.user-login-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 70vh;
padding: 2rem 1rem;
background: radial-gradient(
ellipse 120% 60% at 50% -10%,
color-mix(in srgb, var(--val-color--primary) 18%, var(--val-color--bg)) 0%,
var(--val-color--bg) 70%
);
}
.user-login-card {
width: 100%;
max-width: 24rem;
padding: 2rem;
background-color: #fff;
border-radius: 0.75rem;
box-shadow: 0 1rem 3rem rgba(0, 0, 0, 0.15);
}
.user-login-card .form-label {
margin-top: 1.25rem;
}
.user-login-card .button {
display: block;
width: 100%;
margin-top: 1.5rem;
}
.user-admin-table-wrapper {
margin: 1.5rem 0;
}
.user-admin-table {
font-size: 0.9375rem;
}
.user-admin-permission-list {
list-style: none;
padding-left: 0;
}
.user-admin-permission-granted {
font-weight: 600;
}
.user-admin-permission-missing {
color: var(--val-color--text--muted);
}
.user-form-error {
padding: 0.625rem 0.875rem;
margin-bottom: 1rem;
color: #fff;
background-color: var(--val-color--danger);
border-radius: 0.375rem;
font-size: 0.9375rem;
}
.user-register-link,
.user-reset-link {
margin-top: 1rem;
text-align: center;
font-size: 0.9375rem;
}

View file

@ -24,7 +24,6 @@ body {
.intro {
position: relative;
min-width: 350px;
color: var(--intro-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<()> {
// Regenera `static/` desde cero sólo si hay cambios en `assets/`.
@ -7,6 +7,21 @@ fn main() -> std::io::Result<()> {
copy_dir("assets", "static")?;
// CSS: genera la variante minificada de `basic.css` del tema Basic.
compile_scss("assets/css/basic.css", "static/css/basic.min.css")?;
// JS: minifica el manejo de `Dialog` del tema Basic.
minify_js(
"assets/js/basic.dialog.init.js",
"static/js/basic.dialog.min.js",
)?;
// JS: minifica la integración de `Dropdown` con `accessible-menu` del tema Basic.
minify_js(
"assets/js/basic.dropdown.init.js",
"static/js/basic.dropdown.min.js",
)?;
StaticFilesBundle::from_dir("./static", None)
.with_name("assets")
.build()

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;
pub use messages::Messages;
pub mod nav;
#[doc(inline)]
pub use nav::Nav;
pub mod navbar;
#[doc(inline)]
pub use navbar::Navbar;
mod pager;
pub use pager::{Pager, PagerAlign, PagerVisibility};

View file

@ -31,6 +31,12 @@ pub struct Dropdown {
title: Lc,
/// Devuelve si el botón se desdobla (*split*) en botón de acción más *toggle*.
button_split: bool,
/// Devuelve el tamaño visual del botón.
#[getters(copy)]
button_size: button::Size,
/// Devuelve el estilo visual del botón.
#[getters(copy)]
button_style: button::Style,
/// Devuelve la lista de elementos del menú.
items: Children,
}
@ -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);
Ok(html! {
div (self.props()) {
@if *self.button_split() {
button type="button" class="dropdown-button" { (&title) }
button type="button" class=(&button_classes) { (&title) }
button
type="button"
class="dropdown-toggle"
class=(&toggle_classes)
aria-haspopup="true"
aria-expanded="false"
{
@ -82,7 +117,7 @@ impl Component for Dropdown {
} @else {
button
type="button"
class="dropdown-toggle"
class=(&toggle_classes)
aria-haspopup="true"
aria-expanded="false"
{
@ -126,6 +161,20 @@ impl Dropdown {
self
}
/// Establece el tamaño visual del botón (usa [`button::Size::None`] para quitarlo).
#[builder_fn]
pub fn with_button_size(mut self, size: button::Size) -> Self {
self.button_size = size;
self
}
/// Establece el estilo visual del botón (usa [`button::Style::None`] para quitarlo).
#[builder_fn]
pub fn with_button_style(mut self, style: button::Style) -> Self {
self.button_style = style;
self
}
/// Añade un nuevo elemento al menú o modifica la lista de elementos del menú con una operación
/// [`TypedOp`].
///

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