Compare commits

...

No commits in common. "main" and "legacy/upgrade-sea-orm-to-0_12_8" have entirely different histories.

566 changed files with 17689 additions and 46944 deletions

View file

@ -1,79 +0,0 @@
# cliff.toml
[changelog]
header = """
# CHANGELOG
Este archivo documenta los cambios más relevantes realizados en cada versión. El formato está basado
en [Keep a Changelog](https://keepachangelog.com/es-ES/1.0.0/), y las versiones se numeran siguiendo
las reglas del [Versionado Semántico](https://semver.org/lang/es/).
Resume la evolución del proyecto para usuarios y colaboradores, destacando nuevas funcionalidades,
correcciones, mejoras durante el desarrollo o cambios en la documentación. Cambios menores o
internos pueden omitirse si no afectan al uso del proyecto.
"""
trim = true
render_always = true
body = """
{% if version %}
## {{ version | trim_start_matches(pat="v") }} ({{ timestamp | date(format="%Y-%m-%d") }})
{% else %}
## Pendiente de publicación
{% endif %}\
{% for group, commits in commits | group_by(attribute="group") %}
### {{ group | upper_first }}
{% for commit in commits %}
{%- set msg = commit.message
| split(pat="\n")
| first
| replace(from="✨ ", to="")
| replace(from="🐛 ", to="")
| replace(from="🚑 ", to="")
| replace(from="⬆️ ", to="")
| replace(from="🚧 ", to="")
| replace(from="♻️ ", to="")
| replace(from="✏️ ", to="")
| replace(from="🏷️ ", to="")
| replace(from="🧑‍💻 ", to="")
| replace(from="🍱 ", to="")
| replace(from="📝 ", to="")
| replace(from="💡 ", to="")
| replace(from="🎨 ", to="")
| replace(from="✅ ", to="")
| replace(from="🔨 ", to="")
| replace(from="💄 ", to="")
| replace(from="🔥 ", to="")
| replace(from="🚩 ", to="")
| replace(from="🚨 ", to="")
| replace(from="📄 ", to="")
| replace(from="🩹 ", to="")
-%}
- {{ msg | trim }}{% if commit.author.name != "Manuel Cillero" %} - {{ commit.author.name }}{% endif %}
{% endfor %}{% endfor %}
"""
[git]
conventional_commits = false
filter_unconventional = false
topo_order_commits = true
sort_commits = "oldest"
commit_parsers = [
{ message = "^🔖", skip = true },
{ message = "^✨", group = "Añadido" },
{ message = "^🐛", group = "Corregido" },
{ message = "^🚑", group = "Corregido" },
{ message = "^🚧", group = "Cambiado" },
{ message = "^♻️", group = "Cambiado" },
{ message = "^✏️", group = "Cambiado" },
{ message = "^🏷️", group = "Cambiado" },
{ message = "^🧑‍💻", group = "Cambiado" },
{ message = "^🍱", group = "Cambiado" },
{ message = "^⬆️", group = "Dependencias" },
{ message = "^📝", group = "Documentado" },
{ message = "^💡", group = "Documentado" },
{ message = "^.*", group = "Otros cambios" },
]

View file

@ -1,3 +0,0 @@
[alias]
ts = ["test", "--features", "testing"] # cargo ts
tw = ["test", "--workspace", "--features", "testing"] # cargo tw

View file

@ -1,25 +0,0 @@
# release.toml
# Etiqueta por crate: `pagetop-macros-v0.2.0`
tag-prefix = "{{crate_name}}-"
# Confirmaciones firmadas (no requeridas)
sign-commit = false
sign-tag = false
# Empuja etiquetas y commits
push = true
# Publica en crates.io (puedes desactivarlo para pruebas)
publish = true
# Solo permite publicar estos crates (los que forman parte del workspace)
allow-branch = ["main"]
consolidate-commits = false
# Mensaje personalizado para el commit de versión
pre-release-commit-message = "🔖 Prepara publicación de {{crate_name}} {{version}}"
pre-release-hook = [
"sh", "-c", "ROOT=$(git rev-parse --show-toplevel) && \"$ROOT/tools/changelog.sh\" {{crate_name}} {{version}} --stage"
]

9
.gitignore vendored
View file

@ -1,11 +1,6 @@
# Ignora directorios de compilación
**/target
# Archivos de log
**/log/*.log*
# Archivos de configuración locales
**/local.*.toml
**/local.toml
.env
.vscode
Cargo.lock
workdir

View file

@ -1,115 +0,0 @@
# CHANGELOG
Este archivo documenta los cambios más relevantes realizados en cada versión. El formato está basado
en [Keep a Changelog](https://keepachangelog.com/es-ES/1.0.0/), y las versiones se numeran siguiendo
las reglas del [Versionado Semántico](https://semver.org/lang/es/).
Resume la evolución del proyecto para usuarios y colaboradores, destacando nuevas funcionalidades,
correcciones, mejoras durante el desarrollo o cambios en la documentación. Cambios menores o
internos pueden omitirse si no afectan al uso del proyecto.
## 0.5.0 (2026-05-03)
PageTop 0.5.0 es la versión más ambiciosa hasta la fecha; concentra un largo periodo de trabajo en
refactorizaciones, nuevas abstracciones y mejoras que sientan las bases para una API estable y
robusta.
Algunos cambios pueden romper la compatibilidad con versiones anteriores. Se recomienda consultar la
[documentación de PageTop](https://docs.rs/pagetop) para actualizar el código a un entorno más
expresivo y mejor preparado para crecer hacia la versión 1.0. Entre estos cambios destacan:
- **Respuestas web completas**: soporte para páginas HTML, redirecciones HTTP, respuestas JSON,
cookies, y página de bienvenida integrada.
- **API de componentes consolidada**: ciclo de renderizado definitivo con `is_renderable`, manejo de
errores con `ComponentError` o mensajes de estado con `StatusMessage`/`MessageLevel`.
- **Temas hijo y macros de renderizado**: los temas pueden extenderse entre sí para sobrescribir el
renderizado de cualquier componente con `render_component!` y `setup_component!`.
- **Nueva acción `AlterMarkup`**: permite a extensiones y temas interceptar y transformar el HTML
final de cualquier componente antes de entregarlo.
- **Regiones y plantillas en temas**: los componentes `Region` y `Template` formalizan la gestión de
regiones, respaldados por una API de `Children` e `InRegion` completamente revisada.
- **Sistema de localización refactorizado**: nueva arquitectura interna con API más clara, mejor
integración en el contexto y soporte robusto para múltiples idiomas.
- **Tipos HTML consolidados**: unidades CSS, clase `Classes`, atributos HTML refactorizados y
cadenas internas optimizadas con `CowStr`.
- **Nuevas macros y utilidades de API pública**: macro `Getters` para exponer campos de componentes.
- **Configuración tipada**: nuevas opciones de configuración enumeradas para el log y otros
parámetros del sistema, con una gestión más expresiva y segura.
- **Recursos estáticos y trazabilidad**: gestión de recursos estáticos integrada en el núcleo de
PageTop y soporte para trazas y registro de eventos desde la propia librería.
## 0.4.0 (2025-09-20)
### Añadido
- (app) Añade manejo de rutas no encontradas
- (context) Añade métodos auxiliares de parámetros
- (util) Añade `indoc` para indentar código bien
- Añade componente `PoweredBy` para copyright
### Cambiado
- (html) Cambia tipos `Option...` por `Attr...`
- (html) Implementa `Default` en `Context`
- (welcome) Crea página de bienvenida desde intro
- (context) Generaliza los parámetros de contexto
- (context) Define un `trait` común de contexto
- Modifica tipos para atributos HTML a minúsculas
- Renombra `with_component` por `add_child`
### Corregido
- (welcome) Corrige giro botón con ancho estrecho
- (welcome) Corrige centrado del pie de página
- Corrige nombre de función en prueba de `Html`
- Corrige doc y código por cambios en Page
### Dependencias
- Actualiza dependencias para 0.4.0
### Documentado
- (component) Amplía documentación de preparación
- Normaliza referencias al nombre PageTop
- Simplifica documentación de obsoletos
- Mejora la documentación de recursos y contexto
### Otros cambios
- (theme) Mejora gestión de regiones en páginas
- (tests) Amplía pruebas para `PrepareMarkup'
- (locale) Mejora el uso de `lookup` / `using`
- (tools) Fuerza pulsar intro para confirmar input
- Unifica conversiones a String con `to_string()`
- Elimina `Render` para usar siempre el contexto
## 0.3.0 (2025-08-16)
### Cambiado
- Redefine función para directorios absolutos
- Mejora la integración de archivos estáticos
### Documentado
- Cambia el formato para la documentación
## 0.2.0 (2025-08-09)
### Añadido
- Añade librería para gestionar recursos estáticos
- Añade soporte a changelog de `pagetop-statics`
### Documentado
- Corrige enlace del botón de licencia en la documentación
### Otros cambios
- Afina Cargo.toml para buscar la mejor categoría
## 0.1.0 (2025-08-06)
- Versión inicial

View file

@ -1,130 +0,0 @@
# Guía de contribución a PageTop
Gracias por tu interés en contribuir a **PageTop** 🎉
Este documento describe **cómo participar en el desarrollo del proyecto**, el flujo de trabajo y las
normas que permitan garantizar un historial limpio, trazable y sostenible a largo plazo.
Por favor, léelo completo antes de abrir una *issue* o una *pull request*.
## 1. Repositorios
PageTop mantiene **un único repositorio oficial**:
* **Repositorio oficial:** https://git.cillero.es/manuelcillero/pagetop
* **Repositorio espejo:** https://github.com/manuelcillero/pagetop
> ⚠️ **Importante**
> Aunque GitHub permite abrir *issues* y *pull requests*, **la integración del código se realiza
> únicamente en el repositorio oficial**. GitHub actúa como repositorio espejo que se sincroniza
> automáticamente para reflejar el mismo estado.
## 2. Issues (incidencias, propuestas, preguntas)
Antes de abrir una *issue* **en GitHub**:
* comprueba que no exista ya una similar,
* describe claramente el problema o propuesta,
* incluye pasos de reproducción si se trata de un *bug*,
* indica versión, entorno y contexto cuando sea relevante.
Las *issues* se usan para:
* informar de errores,
* propuestas de mejora,
* discusión técnica previa a cambios relevantes.
## 3. Pull Requests (PRs)
### 3.1 Dónde abrirlas
Las *pull requests* se abren **en GitHub**, normalmente contra la rama `main`. GitHub es el punto de
entrada recomendado para contribuciones externas.
### 3.2 Reglas generales para PRs
* Cada PR debe abordar **un único objetivo claro**.
* Mantén el alcance lo más acotado posible.
* Incluye descripción clara del cambio.
* Si el PR corrige una *issue*, enlázala explícitamente.
* Asegúrate de que el código compila y pasa las pruebas.
### 3.3 Revisión y aceptación
Todas las PRs son **revisadas manualmente** y pueden recibir comentarios o solicitudes de cambios.
Las PRs aceptadas se integran en el repositorio oficial, nunca directamente en GitHub, preservando
siempre la **autoría original** del contribuidor.
### 3.4. Cierre de Pull Requests y sincronización
Una vez que el cambio ha sido integrado en el repositorio oficial:
* La PR en GitHub se **cierra manualmente**.
* Se añade un **mensaje estándar de cierre** indicando que el cambio ha sido integrado.
* El repositorio de GitHub **se sincroniza automáticamente** como espejo.
## 4. Estilo de código y calidad
* Sigue el estilo existente del proyecto.
* Mantén los comentarios claros y precisos.
* La documentación es parte del código: actualízala cuando sea necesario.
* Cambios públicos o estructurales deben ir acompañados de documentación.
## 5. Commits
PageTop usa la especificación **gitmoji** para los mensajes de *commit*. El formato recomendado es:
```text
<propósito> [(ámbito opcional):] <mensaje>
«LÍNEA EN BLANCO»
Cuerpo opcional
«LÍNEA EN BLANCO»
Nota(s) al pie opcional(es) para referencias, incidencias o cambios incompatibles
```
Ejemplos (no más de 50 caracteres en la primera línea, y no más de 80 en el resto):
* `📝 Actualiza la guía de contribución`
* `♻️ (locale): Refactoriza sistema de localización`
* Un mensaje completo:
```
🎨 (bootsier): Mejora la asignación de clases
- Simplifica la generación de clases CSS para componentes Bootstrap.
- Elimina duplicidades en enums de estilos y centraliza la lógica de composición
para reducir errores y facilitar mantenimiento.
- Alinea los nombres de variantes con la documentación pública.
Refs: PR #123
```
El emoji puede usarse en formato Unicode o como *shortcode*, por ejemplo `:sparkles:` en vez de ✨.
Consulta la especificación oficial en https://gitmoji.dev/specification
Durante la integración, los *commits* pueden ajustarse para adaptarse al historial del proyecto.
Un *commit* debe representar una unidad lógica de cambio. Usa mensajes claros y descriptivos.
## 6. Comunicación y respeto
PageTop sigue un enfoque profesional y colaborativo:
* Sé respetuoso en revisiones y discusiones.
* Acepta sugerencias técnicas como parte del proceso.
* Recuerda que todas las contribuciones son revisadas con el objetivo de mejorar el proyecto.
Si tienes dudas sobre el proceso, abre una *issue* de tipo pregunta para tratar la cuestión en
comunidad.
---
Gracias por contribuir a **PageTop** 🚀 Cada aportación contribuye a mejorar el proyecto.

View file

@ -1,40 +1,45 @@
# 🔃 Dependencias
# ⌨️ Código
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:
* PageTop incluye código de la versión [0.11.0](https://github.com/mehcode/config-rs/tree/0.11.0) de
[config](https://crates.io/crates/config), de [Ryan Leckey](https://crates.io/users/mehcode), por
las facilidades que ofrece frente a sus versiones más modernas para leer los ajustes de
configuración y delegar su asignación a tipos seguros según los requerimientos de cada módulo,
tema o aplicación.
* [Actix Web](https://actix.rs/) para los servicios web.
* [Config](https://docs.rs/config) para cargar y procesar las opciones de configuración.
* [Tracing](https://github.com/tokio-rs/tracing) para la gestión de trazas y registro de eventos
de la aplicación.
* [Fluent templates](https://github.com/XAMPPRocky/fluent-templates), que integra
[Fluent](https://projectfluent.org/) para internacionalizar las aplicaciones.
* Además de otros *crates* adicionales que se pueden explorar en los archivos `Cargo.toml` de
PageTop y sus extensiones.
* PageTop incorpora una versión adaptada del excelente *crate* [maud](https://crates.io/crates/maud)
de [Chris Wong](https://crates.io/users/lambda-fairy) (versión
[0.25.0](https://github.com/lambda-fairy/maud/tree/v0.25.0/maud)), para añadir sus funcionalidades
sin requerir la referencia a `maud` en el archivo `Cargo.toml` de cada proyecto.
* PageTop usa los reconocidos *crates* [SQLx](https://github.com/launchbadge/sqlx) y
[SeaQuery](https://github.com/SeaQL/sea-query) para interactuar con bases de datos. Sin embargo,
para restringir las migraciones a módulos, se ha integrado en el código una versión modificada de
[SeaORM Migration](https://github.com/SeaQL/sea-orm/tree/master/sea-orm-migration) (versión
[0.12.8](https://github.com/SeaQL/sea-orm/tree/0.12.8/sea-orm-migration/src)).
# 🗚 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
[FIGlet](http://www.figlet.org). Las fuentes incluidas en `pagetop/src/app` son:
PageTop utiliza el paquete [figlet-rs](https://crates.io/crates/figlet-rs) de *yuanbohan* para
mostrar en el terminal un rótulo de presentación con el nombre de la aplicación usando caracteres
[FIGlet](http://www.figlet.org). Las fuentes incluidas en `src/app` son:
* [slant.flf](http://www.figlet.org/fontdb_example.cgi?font=slant.flf) de *Glenn Chappell*
* [small.flf](http://www.figlet.org/fontdb_example.cgi?font=small.flf) de *Glenn Chappell*
(predeterminada)
* [speed.flf](http://www.figlet.org/fontdb_example.cgi?font=speed.flf) de *Claude Martins*
* [starwars.flf](http://www.figlet.org/fontdb_example.cgi?font=starwars.flf) de *Ryan Youck*
* [slant.flf](http://www.figlet.org/fontdb_example.cgi?font=slant.flf) por *Glenn Chappell*.
* [small.flf](http://www.figlet.org/fontdb_example.cgi?font=small.flf) por *Glenn Chappell*
(predeterminado).
* [speed.flf](http://www.figlet.org/fontdb_example.cgi?font=speed.flf) por *Claude Martins*.
* [starwars.flf](http://www.figlet.org/fontdb_example.cgi?font=starwars.flf) por *Ryan Youck*.
# 🎨 CSS
# 📰 Plantillas
La extensión `pagetop-bootsier` es un tema que integra [Bootstrap 5.3.8](https://getbootstrap.com/)
para los estilos y componentes de la interfaz. Bootstrap está distribuido bajo licencia
[MIT](https://github.com/twbs/bootstrap/blob/main/LICENSE).
* El diseño de la página predeterminada de inicio está basado en la plantilla
[Zinc](https://themewagon.com/themes/free-bootstrap-5-html5-business-website-template-zinc) creada
por [inovatik](https://inovatik.com/) y distribuida por [ThemeWagon](https://themewagon.com).
# 👾 Icono
# 🎨 Icono
"La Mascota" sonriente es una simpática creación de [Webalys](https://www.iconfinder.com/webalys).
Forma parte de su colección [Nasty Icons](https://www.iconfinder.com/iconsets/nasty), disponible en
[ICONFINDER](https://www.iconfinder.com).
"La criatura" sonriente es una divertida creación de [Webalys](https://www.iconfinder.com/webalys).
Puede encontrarse en su colección [Nasty Icons](https://www.iconfinder.com/iconsets/nasty)
disponible en [ICONFINDER](https://www.iconfinder.com).

3276
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,92 +1,22 @@
[package]
name = "pagetop"
version = "0.5.0"
edition = "2021"
description = """
Un entorno de desarrollo para crear soluciones web modulares, extensibles y configurables.
"""
categories = ["web-programming::http-server"]
keywords = ["pagetop", "web", "framework", "frontend", "ssr"]
repository.workspace = true
homepage.workspace = true
license.workspace = true
authors.workspace = true
[dependencies]
chrono = "0.4"
colored = "3.1"
config = { version = "0.15", default-features = false, features = ["toml"] }
figlet-rs = "1.0"
getter-methods = "2.0"
itoa = "1.0"
indexmap = "2.14"
parking_lot = "0.12"
substring = "1.4"
terminal_size = "0.4"
tracing = "0.1"
tracing-appender = "0.2"
tracing-subscriber = { version = "0.3", features = ["json", "env-filter"] }
tracing-actix-web = "0.7"
fluent-templates = "0.14"
unic-langid = { version = "0.9", features = ["macros"] }
actix-web = { workspace = true, default-features = true }
actix-session = { version = "0.11", features = ["cookie-session"] }
actix-web-files = { package = "actix-files", version = "0.6" }
serde.workspace = true
pagetop-macros.workspace = true
pagetop-minimal.workspace = true
pagetop-statics.workspace = true
[features]
default = []
testing = []
[dev-dependencies]
tempfile = "3.27"
serde_json = "1.0"
pagetop-aliner.workspace = true
pagetop-bootsier.workspace = true
[build-dependencies]
pagetop-build.workspace = true
[workspace]
resolver = "2"
members = [
# Helpers
"helpers/pagetop-build",
"helpers/pagetop-macros",
"helpers/pagetop-minimal",
"helpers/pagetop-statics",
# Extensions
"extensions/pagetop-aliner",
"extensions/pagetop-bootsier",
"pagetop",
# Utilities.
"pagetop-macros",
"pagetop-build",
"pagetop-homedemo",
# Modules.
"pagetop-admin",
"pagetop-user",
"pagetop-node",
# Themes.
"pagetop-bootsier",
"pagetop-bulmix",
]
[workspace.package]
repository = "https://git.cillero.es/manuelcillero/pagetop"
homepage = "https://pagetop.cillero.es"
license = "MIT OR Apache-2.0"
authors = ["Manuel Cillero <manuel@cillero.es>"]
[workspace.dependencies]
actix-web = { version = "4.13", default-features = false }
serde = { version = "1.0", features = ["derive"] }
# Helpers
pagetop-build = { version = "0.3", path = "helpers/pagetop-build" }
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-aliner = { version = "0.1", path = "extensions/pagetop-aliner" }
pagetop-bootsier = { version = "0.1", path = "extensions/pagetop-bootsier" }
# PageTop
pagetop = { version = "0.5", path = "." }
exclude = [
"drust",
"examples",
"tests",
]

View file

@ -1,156 +0,0 @@
# MAINTAINERS.md
## Guía para mantenedores de PageTop
Este documento describe **el flujo técnico interno de revisión e integración de contribuciones** en
**PageTop**.
Está dirigido a **mantenedores del proyecto** y **no forma parte de la guía de contribución para
usuarios externos**. Su objetivo es servir como **referencia operativa**, garantizando coherencia,
trazabilidad y preservación de la autoría en un entorno con repositorios espejo.
## 1. Repositorios y principios
PageTop mantiene **un único repositorio oficial**:
* **Repositorio oficial:** https://git.cillero.es/manuelcillero/pagetop
* **Repositorio espejo:** https://github.com/manuelcillero/pagetop
### Principios clave
* El repositorio oficial **es la única fuente de verdad** del historial.
* **Nunca se realizan *merges* en GitHub**.
* Toda integración definitiva se realiza en el repositorio oficial.
* La autoría original debe preservarse siempre.
## 2. Configuración local recomendada
El remoto `github` debe configurarse únicamente para operaciones de lectura (*fetch*), con la URL de
*push* deshabilitada para evitar publicaciones accidentales en el repositorio espejo.
Estado esperado de `git remote -v`:
```text
origin git@git.cillero.es:manuelcillero/pagetop.git (fetch)
origin git@git.cillero.es:manuelcillero/pagetop.git (push)
github git@github.com:manuelcillero/pagetop.git (fetch)
github DISABLED (push)
```
Convenciones usadas en este documento:
* `origin` -> Repositorio oficial
* `github` -> Repositorio espejo
## 3. Recepción y revisión de Pull Requests
Las PRs externas llegan por GitHub, normalmente contra la rama `main`.
Se asume que el repositorio local está configurado para recuperar PRs de GitHub como referencias
remotas (`refs/pull/<N>/head`):
```bash
git fetch github --prune
git checkout -b pr-123 github/pr/123
```
Antes de integrar:
* Revisar el código manualmente.
* Verificar formato, análisis y pruebas:
```bash
cargo fmt
cargo clippy
cargo test
```
* Comprobar impacto en documentación.
* Evaluar coherencia con la arquitectura y el estilo del proyecto.
Los cambios adicionales se solicitan o se aplican explicando claramente el motivo.
## 4. Estrategia de integración
La integración **se realiza siempre en el repositorio oficial** (`origin`).
### 4.1 Estrategia por defecto: *rebase* + *fast-forward*
Esta es la **estrategia estándar y recomendada** en PageTop. Ventajas:
* conserva los commits originales,
* preserva la autoría real de cada cambio,
* mantiene un historial lineal y trazable,
* facilita auditoría y depuración.
Procedimiento típico:
```bash
git checkout pr-123
git rebase main
# Resolver conflictos si los hay
git checkout main
git merge --ff-only pr-123
```
Si `merge --ff-only` falla, **no se debe continuar**, indica divergencias que deben resolverse antes
de integrar la PR.
### 4.2 Estrategia excepcional: *Squash*
Sólo debe usarse cuando esté justificado:
* la PR contiene múltiples commits de prueba o ruido,
* el historial aportado no es significativo,
* el cambio es pequeño y autocontenido.
En este caso, se debe **preservar explícitamente la autoría**:
```bash
git checkout main
git merge --squash pr-123
git commit --author="Nombre Apellido <email@ejemplo.com>"
```
### 4.3. Publicación en el repositorio oficial
```bash
git push origin main
```
Este *push* representa la **integración definitiva** del cambio en la rama `main`.
## 5. Cierre de la PR y sincronización
Tras integrar el cambio en el repositorio oficial, se cierra manualmente la PR en GitHub con un
mensaje estándar:
```text
Gracias por tu contribución.
Este cambio ha sido integrado en el repositorio oficial en `main` (`<hash>`).
GitHub actúa como repositorio espejo sincronizado.
```
## 6. Principios de mantenimiento
* Priorizar **claridad y trazabilidad** frente a rapidez.
* Mantener un historial legible y significativo.
* Documentar cambios estructurales o públicos.
* Tratar las contribuciones externas con respeto y transparencia.
---
Este documento puede evolucionar con el proyecto.
No se trata de imponer rigidez, sino de **capturar el conocimiento operativo real** de PageTop como
guía práctica para el mantenimiento.

207
README.md
View file

@ -1,176 +1,89 @@
<div align="center">
<img src="https://git.cillero.es/manuelcillero/pagetop/raw/branch/main/static/banner.png" />
<img src="https://raw.githubusercontent.com/manuelcillero/pagetop/main/banner-pagetop.png" />
<h1>PageTop</h1>
<p>Un entorno para el desarrollo de soluciones web modulares, extensibles y configurables.</p>
[![crate](https://img.shields.io/crates/v/pagetop.svg)](https://crates.io/crates/pagetop)
[![docs](https://docs.rs/pagetop/badge.svg)](https://docs.rs/pagetop)
[![Doc API](https://img.shields.io/docsrs/pagetop?label=Doc%20API&style=for-the-badge&logo=Docs.rs)](https://docs.rs/pagetop)
[![Crates.io](https://img.shields.io/crates/v/pagetop.svg?style=for-the-badge&logo=ipfs)](https://crates.io/crates/pagetop)
[![Descargas](https://img.shields.io/crates/d/pagetop.svg?label=Descargas&style=for-the-badge&logo=transmission)](https://crates.io/crates/pagetop)
[![Licencia](https://img.shields.io/badge/license-MIT%2FApache-blue.svg?label=Licencia&style=for-the-badge)](https://git.cillero.es/manuelcillero/pagetop#licencia)
<br>
</div>
PageTop reivindica la esencia de la web clásica usando [Rust](https://www.rust-lang.org/es) para la
creación de soluciones web SSR (*renderizadas en el servidor*) basadas en HTML, CSS y JavaScript.
Ofrece un conjunto de herramientas que los desarrolladores pueden implementar, extender o adaptar
según las necesidades de cada proyecto, incluyendo:
**PageTop** es un entorno de desarrollo basado en [Rust](https://www.rust-lang.org/es/) que reúne
algunos de los crates más estables y populares para crear soluciones web modulares, extensibles y
configurables.
* **Acciones** (*actions*): alteran la lógica interna de una funcionalidad interceptando su flujo
de ejecución.
* **Componentes** (*components*): encapsulan HTML, CSS y JavaScript en unidades funcionales,
configurables y reutilizables.
* **Extensiones** (*extensions*): añaden, extienden o personalizan funcionalidades usando las APIs
de PageTop o de terceros.
* **Temas** (*themes*): son extensiones que permiten modificar la apariencia de páginas y
componentes.
Incluye **Drust**, un sistema de gestión de contenidos basado en PageTop que permite crear, editar y
mantener sitios web dinámicos, rápidos y seguros.
## ⚡️ Guía rápida
# 🚧 Advertencia
La aplicación más sencilla de PageTop se ve así:
```rust,no_run
use pagetop::prelude::*;
#[pagetop::main]
async fn main() -> std::io::Result<()> {
Application::new().run()?.await
}
```
Este código arranca el servidor de PageTop. Con la configuración por defecto, muestra una página de
bienvenida accesible desde un navegador local en la dirección `http://localhost:8080`.
Para personalizar el servicio, se puede crear una extensión de PageTop de la siguiente manera:
```rust,no_run
use pagetop::prelude::*;
struct HelloWorld;
impl Extension for HelloWorld {
fn configure_service(&self, scfg: &mut service::web::ServiceConfig) {
scfg.route("/", service::web::get().to(hello_world));
}
}
async fn hello_world(request: HttpRequest) -> ResultPage<Markup, ErrorPage> {
Page::new(request)
.add_child(Html::with(|_| html! { h1 { "Hello World!" } }))
.render()
}
#[pagetop::main]
async fn main() -> std::io::Result<()> {
Application::prepare(&HelloWorld).run()?.await
}
```
Este programa implementa una extensión llamada `HelloWorld` que sirve una página web en la ruta raíz
(`/`) mostrando el texto "Hello world!" dentro de un elemento HTML `<h1>`.
**PageTop** es un proyecto personal para aprender Rust y conocer su ecosistema. Sólo se liberan
versiones de desarrollo. En este contexto la API no es estable y los cambios son constantes. No
puede considerarse preparado hasta que se libere la versión **0.1.0**.
## 📂 Proyecto
# 📂 Estructura del código
El código se organiza en un *workspace* donde actualmente se incluyen los siguientes subproyectos:
El repositorio se organiza en un *workspace* con los siguientes subproyectos:
* **[pagetop](https://git.cillero.es/manuelcillero/pagetop/src/branch/main/src)**, con el código
fuente de la librería principal. Reúne algunos de los *crates* más estables y populares del
ecosistema Rust para proporcionar APIs y recursos para la creación avanzada de soluciones web.
* **[pagetop](https://github.com/manuelcillero/pagetop/tree/main/pagetop)**, es la librería esencial
construida con *crates* estables y muy conocidos del ecosistema Rust para proporcionar APIs,
patrones de desarrollo y buenas prácticas para la creación avanzada de soluciones web SSR
(*Server-Side Rendering*).
### Auxiliares
## Auxiliares
* **[pagetop-build](https://git.cillero.es/manuelcillero/pagetop/src/branch/main/helpers/pagetop-build)**,
prepara los archivos estáticos o archivos SCSS compilados para incluirlos en el binario de las
aplicaciones PageTop durante la compilación de los ejecutables.
* **[pagetop-macros](https://github.com/manuelcillero/pagetop/tree/main/pagetop-macros)**, agrupa
las principales macros procedurales para usar desde **PageTop**.
* **[pagetop-macros](https://git.cillero.es/manuelcillero/pagetop/src/branch/main/helpers/pagetop-macros)**,
proporciona una colección de macros procedurales que mejoran la experiencia de desarrollo con
PageTop.
* **[pagetop-build](https://github.com/manuelcillero/pagetop/tree/main/pagetop-build)**, permite
incluir fácilmente recursos en los archivos binarios al compilar aplicaciones creadas con
**PageTop**.
* **[pagetop-minimal](https://git.cillero.es/manuelcillero/pagetop/src/branch/main/helpers/pagetop-minimal)**,
ofrece macros declarativas esenciales para optimizar tareas comunes como la composición de
texto, la concatenación de cadenas y el manejo de colecciones clave-valor.
## Módulos
* **[pagetop-statics](https://git.cillero.es/manuelcillero/pagetop/src/branch/main/helpers/pagetop-statics)**,
permite incluir archivos estáticos en el ejecutable de las aplicaciones PageTop para servirlos
de forma eficiente, con detección de cambios que optimizan el tiempo de compilación.
* **[pagetop-homedemo](https://github.com/manuelcillero/pagetop/tree/main/pagetop-homedemo)**,
módulo que muestra una página de inicio de demostración para presentar **PageTop**.
### Extensiones
* **[pagetop-admin](https://github.com/manuelcillero/pagetop/tree/main/pagetop-admin)**, módulo que
proporciona a otros módulos un lugar común donde presentar a los administradores sus opciones de
configuración.
* **[pagetop-aliner](https://git.cillero.es/manuelcillero/pagetop/src/branch/main/extensions/pagetop-aliner)**,
es un tema para demos y pruebas que muestra esquemáticamente la composición de las páginas HTML.
* **[pagetop-user](https://github.com/manuelcillero/pagetop/tree/main/pagetop-user)**, módulo para
añadir gestión de usuarios, roles, permisos y sesiones en aplicaciones desarrolladas con PageTop.
* **[pagetop-bootsier](https://git.cillero.es/manuelcillero/pagetop/src/branch/main/extensions/pagetop-bootsier)**,
tema basado en [Bootstrap](https://getbootstrap.com) para integrar su catálogo de estilos y
componentes flexibles.
* **[pagetop-node](https://github.com/manuelcillero/pagetop/tree/main/pagetop-node)**, módulo para
crear, extender o personalizar los tipos de contenido que puede administrar un sitio web.
## Temas
* **[pagetop-bootsier](https://github.com/manuelcillero/pagetop/tree/main/pagetop-bootsier)**, tema
que utiliza el *framework* [Bootstrap](https://getbootstrap.com/) para la composición de páginas y
visualización de componentes.
* **[pagetop-bulmix](https://github.com/manuelcillero/pagetop/tree/main/pagetop-bulmix)**, tema que
utiliza el *framework* [Bulma](https://bulma.io/) para la composición de páginas y visualización
de componentes.
## Aplicación
* **[drust](https://github.com/manuelcillero/pagetop/tree/main/drust)**, es una aplicación
inspirada modestamente en [Drupal](https://www.drupal.org) que utiliza PageTop para crear un CMS
(*Content Management System* o sistema de gestión de contenidos) para construir sitios web
dinámicos, administrados y configurables.
## 🧪 Pruebas
# 📜 Licencia
Para simplificar el flujo de trabajo, el repositorio incluye varios **alias de Cargo** declarados en
`.cargo/config.toml`. Basta con ejecutarlos desde la raíz del proyecto:
Este proyecto tiene licencia, de hecho tiene dos, puedes aplicar cualquiera de las siguientes a tu
elección:
| Comando | Descripción |
| ------- | ----------- |
| `cargo ts` | Ejecuta los tests de `pagetop` (*unit + integration*) con la *feature* `testing`. |
| `cargo ts --test util` | Lanza sólo las pruebas de integración del módulo `util`. |
| `cargo ts --doc locale` | Lanza las pruebas de la documentación del módulo `locale`. |
| `cargo tw` | Ejecuta los tests de **todos los paquetes** del *workspace*. |
* Licencia Apache versión 2.0
([LICENSE-APACHE](https://github.com/manuelcillero/pagetop/blob/main/LICENSE-APACHE) o
[http://www.apache.org/licenses/LICENSE-2.0]).
> **Nota**
> Estos alias ya compilan con la configuración adecuada. No requieren `--no-default-features`.
> Si quieres **activar** las trazas del registro de eventos entonces usa simplemente `cargo test`.
## 🚧 Advertencia
**PageTop** es un proyecto personal para aprender [Rust](https://www.rust-lang.org/es) y conocer su
ecosistema. Su API está sujeta a cambios frecuentes. No se recomienda su uso en producción, al menos
hasta que se libere la versión **1.0.0**.
## 📜 Licencia
El código está disponible bajo una doble licencia:
* **Licencia MIT**
([LICENSE-MIT](LICENSE-MIT) o también https://opensource.org/licenses/MIT)
* **Licencia Apache, Versión 2.0**
([LICENSE-APACHE](LICENSE-APACHE) o también https://www.apache.org/licenses/LICENSE-2.0)
Puedes elegir la licencia que prefieras. Este enfoque de doble licencia es el estándar de facto en
el ecosistema Rust.
## ✨ Contribuir
PageTop mantiene **un único repositorio oficial**:
* **Repositorio oficial:** https://git.cillero.es/manuelcillero/pagetop
* **Repositorio espejo:** https://github.com/manuelcillero/pagetop
El repositorio de GitHub actúa como espejo y punto de entrada para:
* dar mayor visibilidad al proyecto,
* facilitar la participación de la comunidad,
* centralizar *issues* y *pull requests* externas.
Aunque GitHub permite abrir *pull requests*, **la integración del código se realiza únicamente en el
repositorio oficial**. El repositorio de GitHub se sincroniza posteriormente para reflejar el mismo
estado.
En todos los casos, se respeta la **autoría original** de las contribuciones integradas, tanto en el
historial como en la documentación asociada al cambio.
Para conocer el proceso completo de participación, revisión e integración de cambios, consulta el
archivo [`CONTRIBUTING.md`](CONTRIBUTING.md).
Cualquier contribución para añadir al proyecto se considerará automáticamente bajo la doble licencia
indicada arriba (MIT o Apache v2.0), sin términos o condiciones adicionales, tal y como permite la
licencia *Apache v2.0*.
* Licencia MIT
([LICENSE-MIT](https://github.com/manuelcillero/pagetop/blob/main/LICENSE-MIT) o
[http://opensource.org/licenses/MIT]).

BIN
banner-pagetop.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

View file

@ -1,2 +0,0 @@
[log]
tracing = "Info,pagetop=Debug"

25
drust/Cargo.toml Normal file
View file

@ -0,0 +1,25 @@
[package]
name = "drust"
version = "0.0.3"
edition = "2021"
authors = [
"Manuel Cillero <manuel@cillero.es>"
]
description = """\
A modern web Content Management System to share your world.\
"""
homepage = "https://pagetop.cillero.es"
repository = "https://github.com/manuelcillero/pagetop"
license = "Apache-2.0 OR MIT"
[dependencies]
pagetop = { version = "0.0", path = "../pagetop", features = ["mysql"], default-features = false }
# Themes.
pagetop-bootsier = { version = "0.0", path = "../pagetop-bootsier" }
pagetop-bulmix = { version = "0.0", path = "../pagetop-bulmix" }
# Modules.
pagetop-homedemo = { version = "0.0", path = "../pagetop-homedemo" }
pagetop-admin = { version = "0.0", path = "../pagetop-admin" }
pagetop-user = { version = "0.0", path = "../pagetop-user" }
pagetop-node = { version = "0.0", path = "../pagetop-node" }

28
drust/README.md Normal file
View file

@ -0,0 +1,28 @@
**Drust** es una aplicación inspirada modestamente en [Drupal](https://www.drupal.org) que utiliza
**PageTop** para crear un CMS (*Content Management System* o sistema de gestión de contenidos) para
construir sitios web dinámicos, administrados y configurables.
[PageTop](https://github.com/manuelcillero/pagetop/tree/main/pagetop), es un entorno de desarrollo
basado en algunos de los *crates* más estables y populares del ecosistema Rust para proporcionar
APIs, patrones de desarrollo y buenas prácticas para la creación de soluciones web SSR (*Server-Side
Rendering*).
# 🚧 Advertencia
**PageTop** sólo libera actualmente versiones de desarrollo. La API no es estable y los cambios son
constantes. No puede considerarse preparado hasta que se libere la versión **0.1.0**.
# 📜 Licencia
Este proyecto tiene licencia, de hecho tiene dos, puedes aplicar cualquiera de las siguientes a tu
elección:
* Licencia Apache versión 2.0
([LICENSE-APACHE](https://github.com/manuelcillero/pagetop/blob/main/LICENSE-APACHE) o
[http://www.apache.org/licenses/LICENSE-2.0]).
* Licencia MIT
([LICENSE-MIT](https://github.com/manuelcillero/pagetop/blob/main/LICENSE-MIT) o
[http://opensource.org/licenses/MIT]).

8
drust/config/common.toml Normal file
View file

@ -0,0 +1,8 @@
[app]
name = "Drust"
description = """\
A modern web Content Management System to share your world.\
"""
[database]
db_type = "mysql"

10
drust/config/default.toml Normal file
View file

@ -0,0 +1,10 @@
[app]
#theme = "Basic"
#theme = "Chassis"
theme = "Inception"
#theme = "Bootsier"
#theme = "Bulmix"
language = "es-ES"
[log]
tracing = "Info,pagetop=Debug,sqlx::query=Warn"

30
drust/src/main.rs Normal file
View file

@ -0,0 +1,30 @@
use pagetop::prelude::*;
#[derive(BindHandle)]
struct Drust;
impl ModuleTrait for Drust {
fn dependencies(&self) -> Vec<ModuleRef> {
vec![
// Themes.
&pagetop_bootsier::Bootsier,
&pagetop_bulmix::Bulmix,
// Modules.
&pagetop_homedemo::HomeDemo,
&pagetop_admin::Admin,
&pagetop_user::User,
&pagetop_node::Node,
]
}
fn drop_modules(&self) -> Vec<ModuleRef> {
vec![
// &pagetop_node::Node
]
}
}
#[pagetop::main]
async fn main() -> std::io::Result<()> {
Application::prepare(&Drust).unwrap().run()?.await
}

4
examples/Cargo.toml Normal file
View file

@ -0,0 +1,4 @@
[workspace]
members = [
"basics/*",
]

View file

@ -1,6 +0,0 @@
use pagetop::prelude::*;
#[pagetop::main]
async fn main() -> std::io::Result<()> {
Application::new().run()?.await
}

View file

@ -0,0 +1,9 @@
[package]
name = "hello_name"
version = "0.0.0"
edition = "2021"
publish = false
[dependencies]
actix-web = "4"
pagetop = { version = "0.0", path = "../../../pagetop" }

View file

@ -1,28 +1,26 @@
use pagetop::prelude::*;
#[derive(BindHandle)]
struct HelloName;
impl Extension for HelloName {
impl ModuleTrait for HelloName {
fn configure_service(&self, scfg: &mut service::web::ServiceConfig) {
scfg.route("/hello/{name}", service::web::get().to(hello_name));
scfg.service(hello_name);
}
}
#[service::get("/hello/{name}")]
async fn hello_name(
request: HttpRequest,
request: service::HttpRequest,
path: service::web::Path<String>,
) -> ResultPage<Markup, ErrorPage> {
let name = path.into_inner();
Page::new(request)
.with_child(Html::with(move |_| {
html! {
h1 style="text-align: center;" { "Hello " (name) "!" }
}
}))
.with_component_in("content", Html::with(html! { h1 { "Hello " (name) "!" } }))
.render()
}
#[pagetop::main]
async fn main() -> std::io::Result<()> {
Application::prepare(&HelloName).run()?.await
Application::prepare(&HelloName).unwrap().run()?.await
}

View file

@ -0,0 +1,8 @@
[package]
name = "hello_world"
version = "0.0.0"
edition = "2021"
publish = false
[dependencies]
pagetop = { version = "0.0", path = "../../../pagetop" }

View file

@ -0,0 +1,21 @@
use pagetop::prelude::*;
#[derive(BindHandle)]
struct HelloWorld;
impl ModuleTrait for HelloWorld {
fn configure_service(&self, scfg: &mut service::web::ServiceConfig) {
scfg.route("/", service::web::get().to(hello_world));
}
}
async fn hello_world(request: service::HttpRequest) -> ResultPage<Markup, ErrorPage> {
Page::new(request)
.with_component_in("content", Html::with(html! { h1 { "Hello World!" } }))
.render()
}
#[pagetop::main]
async fn main() -> std::io::Result<()> {
Application::prepare(&HelloWorld).unwrap().run()?.await
}

View file

@ -1,456 +0,0 @@
use pagetop::prelude::*;
use pagetop_bootsier::prelude::*;
include_locales!(LOC from "examples/locale");
struct FormControls;
impl Extension for FormControls {
fn dependencies(&self) -> Vec<ExtensionRef> {
vec![&pagetop_aliner::Aliner, &pagetop_bootsier::Bootsier]
}
fn configure_service(&self, scfg: &mut service::web::ServiceConfig) {
scfg.route("/", service::web::get().to(form_controls));
}
}
async fn form_controls(request: HttpRequest) -> ResultPage<Markup, ErrorPage> {
Page::new(request)
.with_child(
Intro::default()
.with_opening(IntroOpening::Custom)
.with_title(L10n::t("title", &LOC))
.with_slogan(L10n::t("slogan", &LOC))
.with_button(None::<(L10n, FnPathByContext)>)
// Bloque 1: casillas, interruptores y botones de opción.
.with_child(
Block::new()
.with_title(L10n::t("block_selections", &LOC))
.with_child(
Form::new()
.with_id("form-selections")
.with_action("/")
.with_method(form::Method::Post)
// Casillas e interruptores (form::Checkbox).
.with_child(
form::Fieldset::new()
.with_legend(L10n::t("fieldset_checkbox", &LOC))
.with_description(L10n::t("desc_checkbox", &LOC))
.with_child(
form::Checkbox::new()
.with_name("accept_terms")
.with_label(L10n::t("label_terms", &LOC))
.with_required(true),
)
.with_child(
form::Checkbox::new()
.with_name("accept_marketing")
.with_label(L10n::t("label_marketing", &LOC))
.with_checked(true)
.with_inline(true),
)
.with_child(
form::Checkbox::new()
.with_name("newsletter")
.with_label(L10n::t("label_newsletter", &LOC))
.with_inline(true),
)
.with_child(
form::Checkbox::switch()
.with_name("notifications")
.with_label(L10n::t("label_notifications", &LOC))
.with_checked(true)
.with_reverse(true),
)
.with_child(
form::Checkbox::switch()
.with_name("dark_mode")
.with_label(L10n::t("label_dark_mode", &LOC))
.with_disabled(true),
),
)
// Grupo de casillas de verificación (form::check::Field).
.with_child(
form::Fieldset::new()
.with_legend(L10n::t("fieldset_checkgroup", &LOC))
.with_child(
form::check::Field::new()
.with_name("interests")
.with_label(L10n::t("label_interests", &LOC))
.with_help_text(L10n::t("help_interests", &LOC))
.with_item(
form::check::Item::new(
"rust",
L10n::t("check_rust", &LOC),
)
.with_checked(true),
)
.with_item(form::check::Item::new(
"web",
L10n::t("check_web", &LOC),
))
.with_item(form::check::Item::new(
"ai",
L10n::t("check_ai", &LOC),
))
.with_item(
form::check::Item::new(
"games",
L10n::t("check_games", &LOC),
)
.with_disabled(true),
),
),
)
// Botones de opción (form::radio::Field).
.with_child(
form::Fieldset::new()
.with_legend(L10n::t("fieldset_radio", &LOC))
.with_child(
form::radio::Field::new()
.with_name("frequency")
.with_label(L10n::t("label_frequency", &LOC))
.with_item(form::radio::Item::new(
"daily",
L10n::t("radio_daily", &LOC),
))
.with_item(
form::radio::Item::new(
"weekly",
L10n::t("radio_weekly", &LOC),
)
.with_checked(true),
)
.with_item(form::radio::Item::new(
"monthly",
L10n::t("radio_monthly", &LOC),
))
.with_item(
form::radio::Item::new(
"never",
L10n::t("radio_never", &LOC),
)
.with_disabled(true),
),
),
)
// Campo oculto (form::Hidden).
.with_child(
form::Hidden::new()
.with_name("origin")
.with_value("form-selections"),
)
// Botones de acción.
.with_child(
Button::submit(L10n::t("btn_submit", &LOC))
.with_color(ButtonColor::Background(Color::Primary)),
)
.with_child(
Button::reset(L10n::t("btn_reset", &LOC))
.with_color(ButtonColor::Outline(Color::Secondary)),
)
.with_child(
Button::plain(L10n::t("btn_cancel", &LOC))
.with_color(ButtonColor::Link),
),
),
)
// Bloque 2: campos de texto, multilínea y rango.
.with_child(
Block::new()
.with_title(L10n::t("block_text", &LOC))
.with_child(
Form::new()
.with_id("form-text")
.with_action("/")
.with_method(form::Method::Post)
// Campos de texto (form::input::Field).
.with_child(
form::Fieldset::new()
.with_legend(L10n::t("fieldset_text", &LOC))
.with_child(
form::input::Field::text()
.with_name("name")
.with_label(L10n::t("label_name", &LOC))
.with_placeholder(L10n::t("placeholder_name", &LOC))
.with_required(true),
)
.with_child(
form::input::Field::email()
.with_name("email")
.with_label(L10n::t("label_email", &LOC))
.with_placeholder(L10n::t(
"placeholder_email",
&LOC,
))
.with_autocomplete(
Some(form::Autocomplete::email()),
)
.with_required(true),
)
.with_child(
form::input::Field::password()
.with_name("password")
.with_label(L10n::t("label_password", &LOC))
.with_autocomplete(Some(
form::Autocomplete::new_password(),
))
.with_required(true),
)
.with_child(
form::input::Field::telephone()
.with_name("phone")
.with_label(L10n::t("label_phone", &LOC))
.with_placeholder(L10n::t(
"placeholder_phone",
&LOC,
)),
)
.with_child(
form::input::Field::url()
.with_name("website")
.with_label(L10n::t("label_url", &LOC))
.with_placeholder(L10n::t("placeholder_url", &LOC)),
)
.with_child(
form::input::Field::search()
.with_name("search")
.with_label(L10n::t("label_search", &LOC))
.with_placeholder(L10n::t(
"placeholder_search",
&LOC,
)),
),
)
// Área de texto (form::Textarea).
.with_child(
form::Fieldset::new()
.with_legend(L10n::t("fieldset_textarea", &LOC))
.with_child(
form::Textarea::new()
.with_name("comment")
.with_label(L10n::t("label_comment", &LOC))
.with_placeholder(L10n::t(
"placeholder_comment",
&LOC,
))
.with_rows(Some(4))
.with_help_text(L10n::t("help_comment", &LOC)),
),
)
// Control deslizante (form::Range).
.with_child(
form::Fieldset::new()
.with_legend(L10n::t("fieldset_range", &LOC))
.with_child(
form::Range::new()
.with_name("rating")
.with_label(L10n::t("label_rating", &LOC))
.with_min(Some(1.0))
.with_max(Some(10.0))
.with_step(Some(1.0))
.with_value(Some(5.0))
.with_help_text(L10n::t("help_rating", &LOC)),
),
)
// Campo oculto (form::Hidden).
.with_child(
form::Hidden::new()
.with_name("origin")
.with_value("form-text"),
)
// Botones de acción.
.with_child(
Button::submit(L10n::t("btn_submit", &LOC))
.with_color(ButtonColor::Background(Color::Primary)),
)
.with_child(
Button::reset(L10n::t("btn_reset", &LOC))
.with_color(ButtonColor::Outline(Color::Secondary)),
)
.with_child(
Button::plain(L10n::t("btn_cancel", &LOC))
.with_color(ButtonColor::Link),
),
),
)
// Bloque 3: listas de selección y etiquetas flotantes.
.with_child(
Block::new()
.with_title(L10n::t("block_lists", &LOC))
.with_child(
Form::new()
.with_id("form-lists")
.with_action("/")
.with_method(form::Method::Post)
// Listas de selección (form::select::Field).
.with_child(
form::Fieldset::new()
.with_legend(L10n::t("fieldset_select", &LOC))
.with_child(
form::select::Field::new()
.with_name("language")
.with_label(L10n::t("label_language", &LOC))
.with_item(
form::select::Item::new(
"",
L10n::t("select_choose", &LOC),
)
.with_selected(true),
)
.with_group(
form::select::Group::new(L10n::t(
"select_group_europe",
&LOC,
))
.with_item(form::select::Item::new(
"es",
L10n::t("select_spanish", &LOC),
))
.with_item(form::select::Item::new(
"fr",
L10n::t("select_french", &LOC),
)),
)
.with_group(
form::select::Group::new(L10n::t(
"select_group_americas",
&LOC,
))
.with_item(form::select::Item::new(
"en",
L10n::t("select_english", &LOC),
))
.with_item(form::select::Item::new(
"pt",
L10n::t("select_portuguese", &LOC),
)),
)
.with_item(
form::select::Item::new(
"xx",
L10n::t("select_disabled", &LOC),
)
.with_disabled(true),
)
.with_required(true),
)
.with_child(
form::select::Field::new()
.with_name("technologies")
.with_label(L10n::t("label_technologies", &LOC))
.with_item(
form::select::Item::new(
"rust",
L10n::n("Rust"),
)
.with_selected(true),
)
.with_item(
form::select::Item::new(
"python",
L10n::n("Python"),
)
.with_selected(true),
)
.with_item(form::select::Item::new(
"javascript",
L10n::n("JavaScript"),
))
.with_item(form::select::Item::new(
"go",
L10n::n("Go"),
))
.with_item(form::select::Item::new(
"typescript",
L10n::n("TypeScript"),
))
.with_multiple(true)
.with_rows(Some(4))
.with_help_text(L10n::t("help_technologies", &LOC)),
),
)
// Etiquetas flotantes.
.with_child(
form::Fieldset::new()
.with_legend(L10n::t("fieldset_floating", &LOC))
.with_child(
form::input::Field::text()
.with_name("fl_name")
.with_label(L10n::t("label_name", &LOC))
.with_placeholder(L10n::t("placeholder_name", &LOC))
.with_floating_label(true)
.with_required(true),
)
.with_child(
form::Textarea::new()
.with_name("fl_comment")
.with_label(L10n::t("label_comment", &LOC))
.with_placeholder(L10n::t(
"placeholder_comment",
&LOC,
))
.with_floating_label(true),
)
.with_child(
form::select::Field::new()
.with_name("fl_country")
.with_label(L10n::t("label_country", &LOC))
.with_item(
form::select::Item::new(
"",
L10n::t("select_choose", &LOC),
)
.with_selected(true),
)
.with_item(form::select::Item::new(
"de",
L10n::t("select_germany", &LOC),
))
.with_item(form::select::Item::new(
"es",
L10n::t("select_spain", &LOC),
))
.with_item(form::select::Item::new(
"fr",
L10n::t("select_france", &LOC),
))
.with_item(form::select::Item::new(
"pt",
L10n::t("select_portugal", &LOC),
))
.with_floating_label(true)
.with_required(true),
),
)
// Campo oculto (form::Hidden).
.with_child(
form::Hidden::new()
.with_name("origin")
.with_value("form-lists"),
)
// Botones de acción.
.with_child(
Button::submit(L10n::t("btn_submit", &LOC))
.with_color(ButtonColor::Background(Color::Primary)),
)
.with_child(
Button::reset(L10n::t("btn_reset", &LOC))
.with_color(ButtonColor::Outline(Color::Secondary)),
)
.with_child(
Button::plain(L10n::t("btn_cancel", &LOC))
.with_color(ButtonColor::Link),
),
),
),
)
.render()
}
#[pagetop::main]
async fn main() -> std::io::Result<()> {
Application::prepare(&FormControls).run()?.await
}

View file

@ -1,24 +0,0 @@
use pagetop::prelude::*;
struct HelloWorld;
impl Extension for HelloWorld {
fn configure_service(&self, scfg: &mut service::web::ServiceConfig) {
scfg.route("/", service::web::get().to(hello_world));
}
}
async fn hello_world(request: HttpRequest) -> ResultPage<Markup, ErrorPage> {
Page::new(request)
.with_child(Html::with(|_| {
html! {
h1 style="text-align: center;" { "Hello World!" }
}
}))
.render()
}
#[pagetop::main]
async fn main() -> std::io::Result<()> {
Application::prepare(&HelloWorld).run()?.await
}

View file

@ -1,82 +0,0 @@
use pagetop::prelude::*;
include_locales!(LOC from "examples/locale");
struct IntroColors;
impl Extension for IntroColors {
fn configure_service(&self, scfg: &mut service::web::ServiceConfig) {
scfg.route("/", service::web::get().to(intro_colors));
}
}
async fn intro_colors(request: HttpRequest) -> ResultPage<Markup, ErrorPage> {
Page::new(request)
.with_child(
Intro::default()
.with_opening(IntroOpening::Custom)
.with_title(L10n::n("PageTop"))
.with_slogan(L10n::t("colors_slogan", &LOC))
.with_button(None::<(L10n, FnPathByContext)>)
.with_child(
Block::new()
.with_title(L10n::t("colors_block", &LOC).with_arg("n", "1"))
.with_child(Html::with(|cx| {
html! {
p { (L10n::t("colors_val_1", &LOC).using(cx)) }
}
})),
)
.with_child(
Block::new()
.with_title(L10n::t("colors_block", &LOC).with_arg("n", "2"))
.with_child(Html::with(|cx| {
html! {
p { (L10n::t("colors_val_2", &LOC).using(cx)) }
}
})),
)
.with_child(
Block::new()
.with_title(L10n::t("colors_block", &LOC).with_arg("n", "3"))
.with_child(Html::with(|cx| {
html! {
p { (L10n::t("colors_val_3", &LOC).using(cx)) }
}
})),
)
.with_child(
Block::new()
.with_title(L10n::t("colors_block", &LOC).with_arg("n", "4"))
.with_child(Html::with(|cx| {
html! {
p { (L10n::t("colors_val_4", &LOC).using(cx)) }
}
})),
)
.with_child(
Block::new()
.with_title(L10n::t("colors_block", &LOC).with_arg("n", "5"))
.with_child(Html::with(|cx| {
html! {
p { (L10n::t("colors_val_5", &LOC).using(cx)) }
}
})),
)
.with_child(
Block::new()
.with_title(L10n::t("colors_block", &LOC).with_arg("n", "6"))
.with_child(Html::with(|cx| {
html! {
p { (L10n::t("colors_val_6", &LOC).using(cx)) }
}
})),
),
)
.render()
}
#[pagetop::main]
async fn main() -> std::io::Result<()> {
Application::prepare(&IntroColors).run()?.await
}

View file

@ -1,74 +0,0 @@
title = Form controls
slogan = Bootsier form components showcase
block_selections = Checkboxes, switches and radio buttons
block_text = Text fields, multiline and range
block_lists = Select lists and floating labels
fieldset_text = Text fields
label_name = Full name
placeholder_name = e.g.: Jane Smith
label_email = Email address
placeholder_email = user@example.com
label_password = Password
label_phone = Phone number
placeholder_phone = +1 555 000 0000
label_url = Website
placeholder_url = https://example.com
label_search = Search
placeholder_search = Search term...
fieldset_textarea = Multiline text
label_comment = Comment
placeholder_comment = Write your comment here...
help_comment = Maximum 500 characters.
fieldset_select = Selection lists
label_language = Language
label_country = Country
label_technologies = Preferred technologies
help_technologies = Hold Ctrl (or Cmd on Mac) to select multiple options.
select_choose = — Choose an option —
select_group_europe = Europe
select_spanish = Spanish
select_french = French
select_group_americas = Americas
select_english = English
select_portuguese = Portuguese
select_disabled = Not available
select_germany = Germany
select_spain = Spain
select_france = France
select_portugal = Portugal
fieldset_checkbox = Checkboxes and switches
desc_checkbox = This group shows standard checkboxes, inline checkboxes, reverse-aligned options, and toggle switches for binary choices.
label_terms = I accept the terms and conditions
label_marketing = Commercial emails (inline)
label_newsletter = Newsletter (inline)
label_notifications = Enable notifications (reverse)
label_dark_mode = Dark mode (unavailable)
fieldset_radio = Radio buttons
label_frequency = Newsletter frequency
radio_daily = Daily
radio_weekly = Weekly
radio_monthly = Monthly
radio_never = Never (disabled)
fieldset_checkgroup = Checkbox group
label_interests = Areas of interest
help_interests = Select all options that apply.
check_rust = Rust programming
check_web = Web development
check_ai = Artificial intelligence
check_games = Game development (disabled)
fieldset_floating = Floating labels
fieldset_range = Slider
label_rating = Overall rating
help_rating = From 1 (very poor) to 10 (excellent).
btn_submit = Submit
btn_reset = Reset
btn_cancel = Cancel

View file

@ -1,8 +0,0 @@
colors_slogan = Chromatic intro test
colors_block = Block { $n } — intro-bg-block-{ $n }
colors_val_1 = Background color: <code style="color: #8b2500">#FFB84B</code> — Amber gold.
colors_val_2 = Background color: <code style="color: #8b2500">#FFC66F</code> — Light golden.
colors_val_3 = Background color: <code style="color: #8b2500">#FFD493</code> — Pale golden.
colors_val_4 = Background color: <code style="color: #8b2500">#FFE3B7</code> — Light peach.
colors_val_5 = Background color: <code style="color: #8b2500">#FFF1DB</code> — Cream.
colors_val_6 = Background color: <code style="color: #8b2500">#FFFFFF</code> — White.

View file

@ -1,23 +0,0 @@
menus_item_label = Label
menus_item_link = Link
menus_item_blank = External link
menus_item_disabled = Disabled link
menus_test_title = Dropdown
menus_dev_header = Intro
menus_dev_getting_started = Getting started
menus_dev_guides = Development guides
menus_dev_forum = Developers forum
menus_sdk_header = Software Development Kits
menus_sdk_rust = SDKs Rust
menus_sdk_js = SDKs JavaScript
menus_sdk_python = SDKs Python
menus_plugin_header = Plugins
menus_plugin_auth = Rust Plugin Auth
menus_plugin_cache = Rust Plugin Cache
menus_item_sign_up = Sign up
menus_item_login = Login

View file

@ -1,74 +0,0 @@
title = Controles de formulario
slogan = Componentes Bootsier para formularios
block_selections = Casillas, interruptores y botones de opción
block_text = Campos de texto, multilínea y rango
block_lists = Listas de selección y etiquetas flotantes
fieldset_text = Campos de texto
label_name = Nombre completo
placeholder_name = Ej.: Ana García
label_email = Correo electrónico
placeholder_email = usuario@ejemplo.com
label_password = Contraseña
label_phone = Teléfono
placeholder_phone = +34 600 000
label_url = Sitio web
placeholder_url = https://ejemplo.com
label_search = Búsqueda
placeholder_search = Término de búsqueda...
fieldset_textarea = Texto multilínea
label_comment = Comentario
placeholder_comment = Escribe tu comentario aquí...
help_comment = Máximo 500 caracteres.
fieldset_select = Listas de selección
label_language = Idioma
label_country = País
label_technologies = Tecnologías preferidas
help_technologies = Mantén Ctrl (o Cmd en Mac) para seleccionar varias opciones.
select_choose = — Elige una opción —
select_group_europe = Europa
select_spanish = Español
select_french = Francés
select_group_americas = América
select_english = Inglés
select_portuguese = Portugués
select_disabled = No disponible
select_germany = Alemania
select_spain = España
select_france = Francia
select_portugal = Portugal
fieldset_checkbox = Casillas e interruptores
desc_checkbox = Este grupo muestra casillas de verificación estándar, casillas en línea, opciones alineadas a la derecha e interruptores para elecciones binarias.
label_terms = Acepto los términos y condiciones
label_marketing = Emails comerciales (en línea)
label_newsletter = Boletín (en línea)
label_notifications = Activar notificaciones (invertida)
label_dark_mode = Modo oscuro (no disponible)
fieldset_radio = Botones de opción
label_frequency = Frecuencia del boletín
radio_daily = Diario
radio_weekly = Semanal
radio_monthly = Mensual
radio_never = Nunca (deshabilitado)
fieldset_checkgroup = Grupo de casillas
label_interests = Áreas de interés
help_interests = Selecciona todas las opciones que correspondan.
check_rust = Programación en Rust
check_web = Desarrollo web
check_ai = Inteligencia artificial
check_games = Desarrollo de videojuegos (deshabilitado)
fieldset_floating = Etiquetas flotantes
fieldset_range = Control deslizante
label_rating = Valoración general
help_rating = De 1 (muy malo) a 10 (excelente).
btn_submit = Enviar
btn_reset = Restablecer
btn_cancel = Cancelar

View file

@ -1,8 +0,0 @@
colors_slogan = Prueba de intro cromática
colors_block = Bloque { $n } — intro-bg-block-{ $n }
colors_val_1 = Color de fondo: <code style="color: #8b2500">#FFB84B</code> — Ámbar dorado.
colors_val_2 = Color de fondo: <code style="color: #8b2500">#FFC66F</code> — Dorado claro.
colors_val_3 = Color de fondo: <code style="color: #8b2500">#FFD493</code> — Dorado pálido.
colors_val_4 = Color de fondo: <code style="color: #8b2500">#FFE3B7</code> — Melocotón claro.
colors_val_5 = Color de fondo: <code style="color: #8b2500">#FFF1DB</code> — Crema.
colors_val_6 = Color de fondo: <code style="color: #8b2500">#FFFFFF</code> — Blanco.

View file

@ -1,23 +0,0 @@
menus_item_label = Etiqueta
menus_item_link = Enlace
menus_item_blank = Enlace externo
menus_item_disabled = Enlace deshabilitado
menus_test_title = Desplegable
menus_dev_header = Introducción
menus_dev_getting_started = Primeros pasos
menus_dev_guides = Guías de desarrollo
menus_dev_forum = Foro de desarrolladores
menus_sdk_header = Kits de Desarrollo Software
menus_sdk_rust = SDKs de Rust
menus_sdk_js = SDKs de JavaScript
menus_sdk_python = SDKs de Python
menus_plugin_header = Plugins
menus_plugin_auth = Plugin Rust de autenticación
menus_plugin_cache = Plugin Rust de caché
menus_item_sign_up = Registrarse
menus_item_login = Iniciar sesión

View file

@ -1,102 +0,0 @@
use pagetop::prelude::*;
use pagetop_bootsier::prelude::*;
include_locales!(LOC from "examples/locale");
struct SuperMenu;
impl Extension for SuperMenu {
fn dependencies(&self) -> Vec<ExtensionRef> {
vec![&pagetop_aliner::Aliner, &pagetop_bootsier::Bootsier]
}
fn initialize(&self) {
let navbar_menu = Navbar::brand_left(navbar::Brand::new())
.with_expand(BreakPoint::LG)
.with_item(navbar::Item::nav(
Nav::new()
.with_item(nav::Item::link(L10n::t("menus_item_link", &LOC), |cx| {
cx.route("/")
}))
.with_item(nav::Item::link_blank(
L10n::t("menus_item_blank", &LOC),
|_| "https://docs.rs/pagetop".into(),
))
.with_item(nav::Item::dropdown(
Dropdown::new()
.with_title(L10n::t("menus_test_title", &LOC))
.with_item(dropdown::Item::header(L10n::t("menus_dev_header", &LOC)))
.with_item(dropdown::Item::link(
L10n::t("menus_dev_getting_started", &LOC),
|cx| cx.route("/dev/getting-started"),
))
.with_item(dropdown::Item::link(
L10n::t("menus_dev_guides", &LOC),
|cx| cx.route("/dev/guides"),
))
.with_item(dropdown::Item::link_blank(
L10n::t("menus_dev_forum", &LOC),
|_| "https://forum.example.dev".into(),
))
.with_item(dropdown::Item::divider())
.with_item(dropdown::Item::header(L10n::t("menus_sdk_header", &LOC)))
.with_item(dropdown::Item::link(
L10n::t("menus_sdk_rust", &LOC),
|cx| cx.route("/dev/sdks/rust"),
))
.with_item(dropdown::Item::link(L10n::t("menus_sdk_js", &LOC), |cx| {
cx.route("/dev/sdks/js")
}))
.with_item(dropdown::Item::link(
L10n::t("menus_sdk_python", &LOC),
|cx| cx.route("/dev/sdks/python"),
))
.with_item(dropdown::Item::divider())
.with_item(dropdown::Item::header(L10n::t("menus_plugin_header", &LOC)))
.with_item(dropdown::Item::link(
L10n::t("menus_plugin_auth", &LOC),
|cx| cx.route("/dev/sdks/rust/plugins/auth"),
))
.with_item(dropdown::Item::link(
L10n::t("menus_plugin_cache", &LOC),
|cx| cx.route("/dev/sdks/rust/plugins/cache"),
))
.with_item(dropdown::Item::divider())
.with_item(dropdown::Item::label(L10n::t("menus_item_label", &LOC)))
.with_item(dropdown::Item::link_disabled(
L10n::t("menus_item_disabled", &LOC),
|cx| cx.route("#"),
)),
))
.with_item(nav::Item::link_disabled(
L10n::t("menus_item_disabled", &LOC),
|cx| cx.route("#"),
)),
))
.with_item(navbar::Item::nav(
Nav::new()
.with_classes(
ClassesOp::Add,
classes::Margin::with(Side::Start, ScaleSize::Auto).to_class(),
)
.with_item(nav::Item::link(L10n::t("menus_item_sign_up", &LOC), |cx| {
cx.route("/auth/sign-up")
}))
.with_item(nav::Item::link(L10n::t("menus_item_login", &LOC), |cx| {
cx.route("/auth/login")
})),
));
InRegion::Global(&DefaultRegion::Header).add(
Container::new()
.with_width(container::Width::FluidMax(UnitValue::RelRem(75.0)))
.with_child(navbar_menu),
);
}
}
#[pagetop::main]
async fn main() -> std::io::Result<()> {
Application::prepare(&SuperMenu).run()?.await
}

View file

@ -1,15 +0,0 @@
# CHANGELOG
Este archivo documenta los cambios más relevantes realizados en cada versión. El formato está basado
en [Keep a Changelog](https://keepachangelog.com/es-ES/1.0.0/), y las versiones se numeran siguiendo
las reglas del [Versionado Semántico](https://semver.org/lang/es/).
Resume la evolución del proyecto para usuarios y colaboradores, destacando nuevas funcionalidades,
correcciones, mejoras durante el desarrollo o cambios en la documentación. Cambios menores o
internos pueden omitirse si no afectan al uso del proyecto.
## 0.1.0 (2026-05-03)
### Añadido
- Versión inicial

View file

@ -1,21 +0,0 @@
[package]
name = "pagetop-aliner"
version = "0.1.0"
edition = "2021"
description = """
Tema de PageTop que muestra esquemáticamente la composición de las páginas HTML
"""
categories = ["web-programming", "gui"]
keywords = ["pagetop", "theme", "css"]
repository.workspace = true
homepage.workspace = true
license.workspace = true
authors.workspace = true
[dependencies]
pagetop.workspace = true
[build-dependencies]
pagetop-build.workspace = true

View file

@ -1,201 +0,0 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2022 Manuel Cillero
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View file

@ -1,21 +0,0 @@
MIT License
Copyright (c) 2022 Manuel Cillero
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -1,101 +0,0 @@
<div align="center">
<h1>PageTop Aliner</h1>
<p>Tema de <strong>PageTop</strong> que muestra esquemáticamente la composición de las páginas HTML.</p>
[![Doc API](https://img.shields.io/docsrs/pagetop-aliner?label=Doc%20API&style=for-the-badge&logo=Docs.rs)](https://docs.rs/pagetop-aliner)
[![Crates.io](https://img.shields.io/crates/v/pagetop-aliner.svg?style=for-the-badge&logo=ipfs)](https://crates.io/crates/pagetop-aliner)
[![Descargas](https://img.shields.io/crates/d/pagetop-aliner.svg?label=Descargas&style=for-the-badge&logo=transmission)](https://crates.io/crates/pagetop-aliner)
[![Licencia](https://img.shields.io/badge/license-MIT%2FApache-blue.svg?label=Licencia&style=for-the-badge)](https://git.cillero.es/manuelcillero/pagetop/src/branch/main/extensions/pagetop-aliner#licencia)
<br>
</div>
## 🧭 Sobre PageTop
[PageTop](https://docs.rs/pagetop) es un entorno de desarrollo que reivindica la esencia de la web
clásica para crear soluciones web SSR (*renderizadas en el servidor*) modulares, extensibles y
configurables, basadas en HTML, CSS y JavaScript.
## ⚡️ Guía rápida
Igual que con otras extensiones, **añade la dependencia** a tu `Cargo.toml`:
```toml
[dependencies]
pagetop-aliner = { ... }
```
**Declara la extensión** en tu aplicación (o extensión que la requiera). Recuerda que el orden en
`dependencies()` determina la prioridad relativa frente a las otras extensiones:
```rust,no_run
use pagetop::prelude::*;
struct MyApp;
impl Extension for MyApp {
fn dependencies(&self) -> Vec<ExtensionRef> {
vec![
// ...
&pagetop_aliner::Aliner,
// ...
]
}
}
#[pagetop::main]
async fn main() -> std::io::Result<()> {
Application::prepare(&MyApp).run()?.await
}
```
Y **selecciona el tema en la configuración** de la aplicación:
```toml
[app]
theme = "Aliner"
```
o **fuerza el tema por código** en una página concreta:
```rust,no_run
use pagetop::prelude::*;
use pagetop_aliner::Aliner;
async fn homepage(request: HttpRequest) -> ResultPage<Markup, ErrorPage> {
Page::new(request)
.with_theme(&Aliner)
.add_child(
Block::new()
.with_title(L10n::l("sample_title"))
.add_child(Html::with(|cx| html! {
p { (L10n::l("sample_content").using(cx)) }
})),
)
.render()
}
```
## 🚧 Advertencia
**PageTop** es un proyecto personal para aprender [Rust](https://www.rust-lang.org/es) y conocer su
ecosistema. Su API está sujeta a cambios frecuentes. No se recomienda su uso en producción, al menos
hasta que se libere la versión **1.0.0**.
## 📜 Licencia
El código está disponible bajo una doble licencia:
* **Licencia MIT**
([LICENSE-MIT](LICENSE-MIT) o también https://opensource.org/licenses/MIT)
* **Licencia Apache, Versión 2.0**
([LICENSE-APACHE](LICENSE-APACHE) o también https://www.apache.org/licenses/LICENSE-2.0)
Puedes elegir la licencia que prefieras. Este enfoque de doble licencia es el estándar de facto en
el ecosistema Rust.

View file

@ -1,128 +0,0 @@
/*!
<div align="center">
<h1>PageTop Aliner</h1>
<p>Tema para <strong>PageTop</strong> que muestra esquemáticamente la composición de las páginas HTML.</p>
[![Doc API](https://img.shields.io/docsrs/pagetop-aliner?label=Doc%20API&style=for-the-badge&logo=Docs.rs)](https://docs.rs/pagetop-aliner)
[![Crates.io](https://img.shields.io/crates/v/pagetop-aliner.svg?style=for-the-badge&logo=ipfs)](https://crates.io/crates/pagetop-aliner)
[![Descargas](https://img.shields.io/crates/d/pagetop-aliner.svg?label=Descargas&style=for-the-badge&logo=transmission)](https://crates.io/crates/pagetop-aliner)
[![Licencia](https://img.shields.io/badge/license-MIT%2FApache-blue.svg?label=Licencia&style=for-the-badge)](https://git.cillero.es/manuelcillero/pagetop/src/branch/main/extensions/pagetop-aliner#licencia)
<br>
</div>
## Sobre PageTop
[PageTop](https://docs.rs/pagetop) es un entorno de desarrollo que reivindica la esencia de la web
clásica para crear soluciones web SSR (*renderizadas en el servidor*) modulares, extensibles y
configurables, basadas en HTML, CSS y JavaScript.
# Guía rápida
Igual que con otras extensiones, **añade la dependencia** a tu `Cargo.toml`:
```toml
[dependencies]
pagetop-aliner = { ... }
```
**Declara la extensión** en tu aplicación (o extensión que la requiera). Recuerda que el orden en
`dependencies()` determina la prioridad relativa frente a las otras extensiones:
```rust,no_run
use pagetop::prelude::*;
struct MyApp;
impl Extension for MyApp {
fn dependencies(&self) -> Vec<ExtensionRef> {
vec![
// ...
&pagetop_aliner::Aliner,
// ...
]
}
}
#[pagetop::main]
async fn main() -> std::io::Result<()> {
Application::prepare(&MyApp).run()?.await
}
```
Y **selecciona el tema en la configuración** de la aplicación:
```toml
[app]
theme = "Aliner"
```
o **fuerza el tema por código** en una página concreta:
```rust,no_run
use pagetop::prelude::*;
use pagetop_aliner::Aliner;
async fn homepage(request: HttpRequest) -> ResultPage<Markup, ErrorPage> {
Page::new(request)
.with_theme(&Aliner)
.with_child(
Block::new()
.with_title(L10n::l("sample_title"))
.with_child(Html::with(|cx| html! {
p { (L10n::l("sample_content").using(cx)) }
})),
)
.render()
}
```
*/
use pagetop::prelude::*;
/// Implementa el tema para usar en pruebas que muestran el esquema de páginas HTML.
///
/// Define un tema mínimo útil para:
///
/// - Comprobar el funcionamiento de temas, plantillas y regiones.
/// - Verificar integración de componentes y composiciones (*layouts*) sin estilos complejos.
/// - Realizar pruebas de renderizado rápido con salida estable y predecible.
/// - Preparar ejemplos y documentación, sin dependencias visuales (CSS/JS) innecesarias.
pub struct Aliner;
impl Extension for Aliner {
fn theme(&self) -> Option<ThemeRef> {
Some(&Self)
}
fn configure_service(&self, scfg: &mut service::web::ServiceConfig) {
static_files_service!(scfg, [aliner] => "/aliner");
}
}
impl Theme for Aliner {
fn before_render_page_body(&self, page: &mut Page) {
page.alter_assets(AssetsOp::AddStyleSheet(
StyleSheet::from("/css/normalize.css")
.with_version("8.0.1")
.with_weight(-99),
))
.alter_assets(AssetsOp::AddStyleSheet(
StyleSheet::from("/css/basic.css")
.with_version(PAGETOP_VERSION)
.with_weight(-99),
))
.alter_assets(AssetsOp::AddStyleSheet(
StyleSheet::from("/aliner/css/styles.css")
.with_version(env!("CARGO_PKG_VERSION"))
.with_weight(-99),
))
.alter_child_in(
&DefaultRegion::Footer,
ChildOp::AddIfEmpty(PoweredBy::new().into()),
);
}
}

View file

@ -1 +0,0 @@
static/** linguist-vendored

View file

@ -1,21 +0,0 @@
# CHANGELOG
Este archivo documenta los cambios más relevantes realizados en cada versión. El formato está basado
en [Keep a Changelog](https://keepachangelog.com/es-ES/1.0.0/), y las versiones se numeran siguiendo
las reglas del [Versionado Semántico](https://semver.org/lang/es/).
Resume la evolución del proyecto para usuarios y colaboradores, destacando nuevas funcionalidades,
correcciones, mejoras durante el desarrollo o cambios en la documentación. Cambios menores o
internos pueden omitirse si no afectan al uso del proyecto.
## 0.1.1 (2026-05-07)
### Cambiado
- Renombra módulo `aux` por `attrs` para evitar posibles conflictos en Windows (#11)
## 0.1.0 (2026-05-03)
### Añadido
- Versión inicial

View file

@ -1,22 +0,0 @@
[package]
name = "pagetop-bootsier"
version = "0.1.1"
edition = "2021"
description = """
Tema de PageTop basado en Bootstrap para aplicar su catálogo de estilos y componentes flexibles.
"""
categories = ["web-programming", "gui"]
keywords = ["pagetop", "theme", "bootstrap", "css", "js"]
repository.workspace = true
homepage.workspace = true
license.workspace = true
authors.workspace = true
[dependencies]
pagetop.workspace = true
serde.workspace = true
[build-dependencies]
pagetop-build.workspace = true

View file

@ -1,201 +0,0 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2022 Manuel Cillero
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View file

@ -1,21 +0,0 @@
MIT License
Copyright (c) 2022 Manuel Cillero
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -1,101 +0,0 @@
<div align="center">
<h1>PageTop Bootsier</h1>
<p>Tema de <strong>PageTop</strong> basado en Bootstrap para aplicar su catálogo de estilos y componentes flexibles.</p>
[![Doc API](https://img.shields.io/docsrs/pagetop-bootsier?label=Doc%20API&style=for-the-badge&logo=Docs.rs)](https://docs.rs/pagetop-bootsier)
[![Crates.io](https://img.shields.io/crates/v/pagetop-bootsier.svg?style=for-the-badge&logo=ipfs)](https://crates.io/crates/pagetop-bootsier)
[![Descargas](https://img.shields.io/crates/d/pagetop-bootsier.svg?label=Descargas&style=for-the-badge&logo=transmission)](https://crates.io/crates/pagetop-bootsier)
[![Licencia](https://img.shields.io/badge/license-MIT%2FApache-blue.svg?label=Licencia&style=for-the-badge)](https://git.cillero.es/manuelcillero/pagetop/src/branch/main/extensions/pagetop-bootsier#licencia)
<br>
</div>
## 🧭 Sobre PageTop
[PageTop](https://docs.rs/pagetop) es un entorno de desarrollo que reivindica la esencia de la web
clásica para crear soluciones web SSR (*renderizadas en el servidor*) modulares, extensibles y
configurables, basadas en HTML, CSS y JavaScript.
## ⚡️ Guía rápida
Igual que con otras extensiones, **añade la dependencia** a tu `Cargo.toml`:
```toml
[dependencies]
pagetop-bootsier = { ... }
```
**Declara la extensión** en tu aplicación (o extensión que la requiera). Recuerda que el orden en
`dependencies()` determina la prioridad relativa frente a las otras extensiones:
```rust,no_run
use pagetop::prelude::*;
struct MyApp;
impl Extension for MyApp {
fn dependencies(&self) -> Vec<ExtensionRef> {
vec![
// ...
&pagetop_bootsier::Bootsier,
// ...
]
}
}
#[pagetop::main]
async fn main() -> std::io::Result<()> {
Application::prepare(&MyApp).run()?.await
}
```
Y **selecciona el tema en la configuración** de la aplicación:
```toml
[app]
theme = "Bootsier"
```
o **fuerza el tema por código** en una página concreta:
```rust,no_run
use pagetop::prelude::*;
use pagetop_bootsier::Bootsier;
async fn homepage(request: HttpRequest) -> ResultPage<Markup, ErrorPage> {
Page::new(request)
.with_theme(&Bootsier)
.add_child(
Block::new()
.with_title(L10n::l("sample_title"))
.add_child(Html::with(|cx| html! {
p { (L10n::l("sample_content").using(cx)) }
})),
)
.render()
}
```
## 🚧 Advertencia
**PageTop** es un proyecto personal para aprender [Rust](https://www.rust-lang.org/es) y conocer su
ecosistema. Su API está sujeta a cambios frecuentes. No se recomienda su uso en producción, al menos
hasta que se libere la versión **1.0.0**.
## 📜 Licencia
El código está disponible bajo una doble licencia:
* **Licencia MIT**
([LICENSE-MIT](LICENSE-MIT) o también https://opensource.org/licenses/MIT)
* **Licencia Apache, Versión 2.0**
([LICENSE-APACHE](LICENSE-APACHE) o también https://www.apache.org/licenses/LICENSE-2.0)
Puedes elegir la licencia que prefieras. Este enfoque de doble licencia es el estándar de facto en
el ecosistema Rust.

View file

@ -1,20 +0,0 @@
use pagetop_build::StaticFilesBundle;
use std::env;
use std::path::Path;
fn main() -> std::io::Result<()> {
StaticFilesBundle::from_scss("./static/scss/bootsier.scss", "bootstrap.min.css")
.with_name("bootsier_bs")
.build()?;
StaticFilesBundle::from_dir("./static/js", Some(bootstrap_js_files))
.with_name("bootsier_js")
.build()
}
fn bootstrap_js_files(path: &Path) -> bool {
let bootstrap_js = "bootstrap.bundle.min.js";
// No filtra durante el desarrollo, solo en la compilación "release".
env::var("PROFILE").unwrap_or_else(|_| "release".to_string()) != "release"
|| path.file_name().is_some_and(|f| f == bootstrap_js)
}

View file

@ -1,41 +0,0 @@
//! Opciones de configuración del tema.
//!
//! Ejemplo:
//!
//! ```toml
//! [bootsier]
//! max_width = "90rem"
//! ```
//!
//! Uso:
//!
//! ```rust
//! # use pagetop::prelude::*;
//! use pagetop_bootsier::config;
//!
//! assert_eq!(config::SETTINGS.bootsier.max_width, UnitValue::Px(1440));
//! ```
//!
//! Consulta [`pagetop::config`] para ver cómo PageTop lee los archivos de configuración y aplica
//! los valores a los ajustes.
use pagetop::prelude::*;
use serde::Deserialize;
include_config!(SETTINGS: Settings => [
// [bootsier]
"bootsier.max_width" => "1440px",
]);
#[derive(Debug, Deserialize)]
/// Tipos para la sección [`[bootsier]`](Bootsier) de [`SETTINGS`].
pub struct Settings {
pub bootsier: Bootsier,
}
#[derive(Debug, Deserialize)]
/// Sección `[bootsier]` de la configuración. Forma parte de [`Settings`].
pub struct Bootsier {
/// Ancho máximo predeterminado para la página, por ejemplo "100%" o "90rem".
pub max_width: UnitValue,
}

View file

@ -1,169 +0,0 @@
/*!
<div align="center">
<h1>PageTop Bootsier</h1>
<p>Tema de <strong>PageTop</strong> basado en Bootstrap para aplicar su catálogo de estilos y componentes flexibles.</p>
[![Doc API](https://img.shields.io/docsrs/pagetop-bootsier?label=Doc%20API&style=for-the-badge&logo=Docs.rs)](https://docs.rs/pagetop-bootsier)
[![Crates.io](https://img.shields.io/crates/v/pagetop-bootsier.svg?style=for-the-badge&logo=ipfs)](https://crates.io/crates/pagetop-bootsier)
[![Descargas](https://img.shields.io/crates/d/pagetop-bootsier.svg?label=Descargas&style=for-the-badge&logo=transmission)](https://crates.io/crates/pagetop-bootsier)
[![Licencia](https://img.shields.io/badge/license-MIT%2FApache-blue.svg?label=Licencia&style=for-the-badge)](https://git.cillero.es/manuelcillero/pagetop/src/branch/main/extensions/pagetop-bootsier#licencia)
<br>
</div>
## Sobre PageTop
[PageTop](https://docs.rs/pagetop) es un entorno de desarrollo que reivindica la esencia de la web
clásica para crear soluciones web SSR (*renderizadas en el servidor*) modulares, extensibles y
configurables, basadas en HTML, CSS y JavaScript.
# Guía rápida
Igual que con otras extensiones, **añade la dependencia** a tu `Cargo.toml`:
```toml
[dependencies]
pagetop-bootsier = { ... }
```
**Declara la extensión** en tu aplicación (o extensión que la requiera). Recuerda que el orden en
`dependencies()` determina la prioridad relativa frente a las otras extensiones:
```rust,no_run
use pagetop::prelude::*;
struct MyApp;
impl Extension for MyApp {
fn dependencies(&self) -> Vec<ExtensionRef> {
vec![
// ...
&pagetop_bootsier::Bootsier,
// ...
]
}
}
#[pagetop::main]
async fn main() -> std::io::Result<()> {
Application::prepare(&MyApp).run()?.await
}
```
Y **selecciona el tema en la configuración** de la aplicación:
```toml
[app]
theme = "Bootsier"
```
o **fuerza el tema por código** en una página concreta:
```rust,no_run
use pagetop::prelude::*;
use pagetop_bootsier::Bootsier;
async fn homepage(request: HttpRequest) -> ResultPage<Markup, ErrorPage> {
Page::new(request)
.with_theme(&Bootsier)
.with_child(
Block::new()
.with_title(L10n::l("sample_title"))
.with_child(Html::with(|cx| html! {
p { (L10n::l("sample_content").using(cx)) }
})),
)
.render()
}
```
*/
#![doc(
html_favicon_url = "https://git.cillero.es/manuelcillero/pagetop/raw/branch/main/static/favicon.ico"
)]
use pagetop::prelude::*;
include_locales!(LOCALES_BOOTSIER);
// Versión de la librería Bootstrap.
const BOOTSTRAP_VERSION: &str = "5.3.8";
pub mod config;
pub mod theme;
/// *Prelude* del tema.
pub mod prelude {
pub use crate::config::*;
pub use crate::theme::*;
}
/// Plantillas que Bootsier añade.
#[derive(AutoDefault)]
pub enum BootsierTemplate {
/// Plantilla predeterminada de Bootsier.
#[default]
Standard,
}
impl Template for BootsierTemplate {
fn render(&'static self, cx: &mut Context) -> Markup {
match self {
Self::Standard => theme::Container::new()
.with_classes(ClassesOp::Add, "container-wrapper")
.with_width(theme::container::Width::FluidMax(
config::SETTINGS.bootsier.max_width,
))
.with_child(Html::with(|cx| {
html! {
(DefaultRegion::Header.render(cx))
(DefaultRegion::Content.render(cx))
(DefaultRegion::Footer.render(cx))
}
})),
}
.render(cx)
}
}
/// Implementa el tema.
pub struct Bootsier;
impl Extension for Bootsier {
fn theme(&self) -> Option<ThemeRef> {
Some(&Self)
}
fn configure_service(&self, scfg: &mut service::web::ServiceConfig) {
static_files_service!(scfg, [bootsier_bs] => "/bootsier/bs");
static_files_service!(scfg, [bootsier_js] => "/bootsier/js");
}
}
impl Theme for Bootsier {
#[inline]
fn default_template(&self) -> TemplateRef {
&BootsierTemplate::Standard
}
fn before_render_page_body(&self, page: &mut Page) {
page.alter_assets(AssetsOp::AddStyleSheet(
StyleSheet::from("/bootsier/bs/bootstrap.min.css")
.with_version(BOOTSTRAP_VERSION)
.with_weight(-90),
))
.alter_assets(AssetsOp::AddJavaScript(
JavaScript::defer("/bootsier/js/bootstrap.bundle.min.js")
.with_version(BOOTSTRAP_VERSION)
.with_weight(-90),
))
.alter_child_in(
&DefaultRegion::Footer,
ChildOp::AddIfEmpty(PoweredBy::new().into()),
);
}
}

View file

@ -1,5 +0,0 @@
e404_description = Oops! Page Not Found
e404_message = The page you are looking for may have been removed, had its name changed, or is temporarily unavailable.
e500_description = Oops! Unexpected Error
e500_message = We're having an issue. Please report this error to an administrator.
back_homepage = Back to homepage

View file

@ -1,11 +0,0 @@
# Dropdown
dropdown_toggle = Toggle Dropdown
# form::Input
input_required = This field is required
# Navbar
toggle = Toggle navigation
# Offcanvas
offcanvas_close = Close

View file

@ -1,9 +0,0 @@
region_header = Header
region_nav_branding = Navigation branding region
region_nav_main = Main navigation region
region_nav_additional = Additional navigation region (eg search form, social icons, etc)
region_breadcrumb = Breadcrumb
region_content = Main content
region_sidebar_first = Sidebar first
region_sidebar_second = Sidebar second
region_footer = Footer

View file

@ -1,5 +0,0 @@
e404_description = ¡Vaya! Página No Encontrada
e404_message = La página que está buscando puede haber sido eliminada, cambiada de nombre o no está disponible temporalmente.
e500_description = ¡Vaya! Error Inesperado
e500_message = Está ocurriendo una incidencia. Por favor, informe de este error a un administrador.
back_homepage = Volver al inicio

View file

@ -1,11 +0,0 @@
# Dropdown
dropdown_toggle = Mostrar/ocultar menú
# form::Input
input_required = Este campo es obligatorio
# Navbar
toggle = Mostrar/ocultar navegación
# Offcanvas
offcanvas_close = Cerrar

View file

@ -1,9 +0,0 @@
region_header = Cabecera
region_nav_branding = Navegación y marca
region_nav_main = Navegación principal
region_nav_additional = Navegación adicional (p.e. formulario de búsqueda, iconos sociales, etc.)
region_breadcrumb = Ruta de posicionamiento
region_content = Contenido principal
region_sidebar_first = Barra lateral primera
region_sidebar_second = Barra lateral segunda
region_footer = Pie

View file

@ -1,49 +0,0 @@
//! Definiciones y componentes del tema.
//!
//! En esta página, el apartado **Modules** incluye las definiciones necesarias para los componentes
//! que se muestran en el apartado **Structs**, mientras que en **Enums** se listan los elementos
//! auxiliares del tema utilizados en clases y componentes.
mod attrs;
pub use attrs::*;
pub mod classes;
// Button.
mod button;
pub use button::Button;
// Container.
pub mod container;
#[doc(inline)]
pub use container::Container;
// Dropdown.
pub mod dropdown;
#[doc(inline)]
pub use dropdown::Dropdown;
// Form.
pub mod form;
#[doc(inline)]
pub use form::Form;
// Image.
pub mod image;
#[doc(inline)]
pub use image::Image;
// Nav.
pub mod nav;
#[doc(inline)]
pub use nav::Nav;
// Navbar.
pub mod navbar;
#[doc(inline)]
pub use navbar::Navbar;
// Offcanvas.
pub mod offcanvas;
#[doc(inline)]
pub use offcanvas::Offcanvas;

View file

@ -1,20 +0,0 @@
//! Colección de elementos auxiliares de Bootstrap para Bootsier.
mod breakpoint;
pub use breakpoint::BreakPoint;
mod color;
pub use color::{Color, Opacity};
pub use color::{ColorBg, ColorText};
mod layout;
pub use layout::{ScaleSize, Side};
mod border;
pub use border::BorderColor;
mod rounded;
pub use rounded::RoundedRadius;
mod button;
pub use button::{ButtonAction, ButtonColor, ButtonSize};

View file

@ -1,86 +0,0 @@
use pagetop::prelude::*;
use crate::theme::attrs::Color;
/// Esquema de color para los bordes ([`classes::Border`](crate::theme::classes::Border)).
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
pub enum BorderColor {
/// No define ninguna clase.
#[default]
Default,
/// Genera la clase `border-{color}`.
Theme(Color),
/// Genera la clase `border-{color}-subtle` (un tono suavizado del color).
Subtle(Color),
/// Color negro.
Black,
/// Color blanco.
White,
}
impl BorderColor {
const BORDER: &str = "border";
const BORDER_PREFIX: &str = "border-";
/// Devuelve el sufijo de la clase `border-*`, o `None` si no define ninguna clase.
#[rustfmt::skip]
#[inline]
const fn suffix(self) -> Option<&'static str> {
match self {
Self::Default => None,
Self::Theme(_) => Some(""),
Self::Subtle(_) => Some("-subtle"),
Self::Black => Some("-black"),
Self::White => Some("-white"),
}
}
/// Añade la clase `border-*` a la cadena de clases.
#[inline]
pub(crate) fn push_class(self, classes: &mut String) {
if let Some(suffix) = self.suffix() {
if !classes.is_empty() {
classes.push(' ');
}
match self {
Self::Theme(c) | Self::Subtle(c) => {
classes.push_str(Self::BORDER_PREFIX);
classes.push_str(c.as_str());
}
_ => classes.push_str(Self::BORDER),
}
classes.push_str(suffix);
}
}
/// Devuelve la clase `border-*` correspondiente al color de borde.
///
/// # Ejemplos
///
/// ```rust
/// # use pagetop_bootsier::prelude::*;
/// assert_eq!(BorderColor::Theme(Color::Primary).to_class(), "border-primary");
/// assert_eq!(BorderColor::Subtle(Color::Warning).to_class(), "border-warning-subtle");
/// assert_eq!(BorderColor::Black.to_class(), "border-black");
/// assert_eq!(BorderColor::Default.to_class(), "");
/// ```
pub fn to_class(self) -> String {
if let Some(suffix) = self.suffix() {
let base_len = match self {
Self::Theme(c) | Self::Subtle(c) => Self::BORDER_PREFIX.len() + c.as_str().len(),
_ => Self::BORDER.len(),
};
let mut class = String::with_capacity(base_len + suffix.len());
match self {
Self::Theme(c) | Self::Subtle(c) => {
class.push_str(Self::BORDER_PREFIX);
class.push_str(c.as_str());
}
_ => class.push_str(Self::BORDER),
}
class.push_str(suffix);
return class;
}
String::new()
}
}

View file

@ -1,114 +0,0 @@
use pagetop::prelude::*;
/// Define los puntos de ruptura (*breakpoints*) para aplicar diseño *responsive*.
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
pub enum BreakPoint {
/// **Menos de 576px**. Dispositivos muy pequeños: teléfonos en modo vertical.
#[default]
None,
/// **576px o más** - Dispositivos pequeños: teléfonos en modo horizontal.
SM,
/// **768px o más** - Dispositivos medianos: tabletas.
MD,
/// **992px o más** - Dispositivos grandes: puestos de escritorio.
LG,
/// **1200px o más** - Dispositivos muy grandes: puestos de escritorio grandes.
XL,
/// **1400px o más** - Dispositivos extragrandes: puestos de escritorio más grandes.
XXL,
}
impl BreakPoint {
/// Devuelve la identificación del punto de ruptura.
#[rustfmt::skip]
#[inline]
pub(crate) const fn as_str(self) -> &'static str {
match self {
Self::None => "",
Self::SM => "sm",
Self::MD => "md",
Self::LG => "lg",
Self::XL => "xl",
Self::XXL => "xxl",
}
}
/// Añade el punto de ruptura con un prefijo y un sufijo (opcional) separados por un guion `-` a
/// la cadena de clases.
///
/// - Para `None` - `prefix` o `prefix-suffix` (si `suffix` no está vacío).
/// - Para `SM..XXL` - `prefix-{breakpoint}` o `prefix-{breakpoint}-{suffix}`.
#[inline]
pub(crate) fn push_class(self, classes: &mut String, prefix: &str, suffix: &str) {
if prefix.is_empty() {
return;
}
if !classes.is_empty() {
classes.push(' ');
}
match self {
Self::None => classes.push_str(prefix),
_ => {
classes.push_str(prefix);
classes.push('-');
classes.push_str(self.as_str());
}
}
if !suffix.is_empty() {
classes.push('-');
classes.push_str(suffix);
}
}
/// Devuelve la clase para el punto de ruptura, con un prefijo y un sufijo opcional, separados
/// por un guion `-`.
///
/// - Para `None` - `prefix` o `prefix-suffix` (si `suffix` no está vacío).
/// - Para `SM..XXL` - `prefix-{breakpoint}` o `prefix-{breakpoint}-{suffix}`.
/// - Si `prefix` está vacío devuelve `""`.
///
/// # Ejemplos
///
/// ```rust
/// # use pagetop_bootsier::prelude::*;
/// let bp = BreakPoint::MD;
/// assert_eq!(bp.class_with("col", ""), "col-md");
/// assert_eq!(bp.class_with("col", "6"), "col-md-6");
///
/// let bp = BreakPoint::None;
/// assert_eq!(bp.class_with("offcanvas", ""), "offcanvas");
/// assert_eq!(bp.class_with("col", "12"), "col-12");
///
/// let bp = BreakPoint::LG;
/// assert_eq!(bp.class_with("", "3"), "");
/// ```
#[doc(hidden)]
pub fn class_with(self, prefix: &str, suffix: &str) -> String {
if prefix.is_empty() {
return String::new();
}
let bp = self.as_str();
let has_bp = !bp.is_empty();
let has_suffix = !suffix.is_empty();
let mut len = prefix.len();
if has_bp {
len += 1 + bp.len();
}
if has_suffix {
len += 1 + suffix.len();
}
let mut class = String::with_capacity(len);
class.push_str(prefix);
if has_bp {
class.push('-');
class.push_str(bp);
}
if has_suffix {
class.push('-');
class.push_str(suffix);
}
class
}
}

View file

@ -1,145 +0,0 @@
use pagetop::prelude::*;
use crate::theme::attrs::Color;
// **< ButtonAction >*********************************************************************************
/// Comportamiento de un [`Button`](crate::theme::Button) al activarse.
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
pub enum ButtonAction {
/// Envía un formulario al servidor. Es el **tipo por defecto**.
#[default]
Submit,
/// Restablece todos los campos de un formulario a sus valores iniciales.
Reset,
/// Botón de propósito general, sin efecto predeterminado. Su comportamiento podría definirse
/// mediante JavaScript.
Plain,
}
impl std::fmt::Display for ButtonAction {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
ButtonAction::Submit => "submit",
ButtonAction::Reset => "reset",
ButtonAction::Plain => "button",
})
}
}
// **< ButtonColor >********************************************************************************
/// Esquema de color para [`Button`](crate::theme::Button).
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
pub enum ButtonColor {
/// No define ninguna clase.
#[default]
Default,
/// Genera la clase `btn-{color}` (botón sólido).
Background(Color),
/// Genera la clase `btn-outline-{color}` (fondo transparente con contorno coloreado).
Outline(Color),
/// Aplica estilo de los enlaces (`btn-link`), sin caja ni fondo, heredando el color de texto.
Link,
}
impl ButtonColor {
const BTN_PREFIX: &str = "btn-";
const BTN_OUTLINE_PREFIX: &str = "btn-outline-";
const BTN_LINK: &str = "btn-link";
/// Añade la clase `btn-*` a la cadena de clases.
#[inline]
pub(crate) fn push_class(self, classes: &mut String) {
if let Self::Default = self {
return;
}
if !classes.is_empty() {
classes.push(' ');
}
match self {
Self::Background(c) => {
classes.push_str(Self::BTN_PREFIX);
classes.push_str(c.as_str());
}
Self::Outline(c) => {
classes.push_str(Self::BTN_OUTLINE_PREFIX);
classes.push_str(c.as_str());
}
Self::Link => classes.push_str(Self::BTN_LINK),
Self::Default => unreachable!(),
}
}
/// Devuelve la clase `btn-*` correspondiente al color del botón.
///
/// # Ejemplos
///
/// ```rust
/// # use pagetop_bootsier::prelude::*;
/// assert_eq!(
/// ButtonColor::Background(Color::Primary).to_class(),
/// "btn-primary"
/// );
/// assert_eq!(
/// ButtonColor::Outline(Color::Danger).to_class(),
/// "btn-outline-danger"
/// );
/// assert_eq!(ButtonColor::Link.to_class(), "btn-link");
/// assert_eq!(ButtonColor::Default.to_class(), "");
/// ```
pub fn to_class(self) -> String {
let mut class = String::new();
self.push_class(&mut class);
class
}
}
// **< ButtonSize >*********************************************************************************
/// Tamaño visual de un botón.
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
pub enum ButtonSize {
/// Tamaño por defecto del tema (no añade clase).
#[default]
Default,
/// Botón compacto.
Small,
/// Botón grande.
Large,
}
impl ButtonSize {
const BTN_SM: &str = "btn-sm";
const BTN_LG: &str = "btn-lg";
/// Añade la clase de tamaño `btn-sm` o `btn-lg` a la cadena de clases.
#[inline]
pub(crate) fn push_class(self, classes: &mut String) {
let class = match self {
Self::Default => return,
Self::Small => Self::BTN_SM,
Self::Large => Self::BTN_LG,
};
if !classes.is_empty() {
classes.push(' ');
}
classes.push_str(class);
}
/// Devuelve la clase `btn-sm` o `btn-lg` correspondiente al tamaño del botón.
///
/// # Ejemplos
///
/// ```rust
/// # use pagetop_bootsier::prelude::*;
/// assert_eq!(ButtonSize::Small.to_class(), "btn-sm");
/// assert_eq!(ButtonSize::Large.to_class(), "btn-lg");
/// assert_eq!(ButtonSize::Default.to_class(), "");
/// ```
pub fn to_class(self) -> String {
let mut class = String::new();
self.push_class(&mut class);
class
}
}

View file

@ -1,336 +0,0 @@
use pagetop::prelude::*;
// **< Color >**************************************************************************************
/// Paleta de colores temáticos.
///
/// Equivalen a los nombres estándar definidos por Bootstrap (`primary`, `secondary`, `success`,
/// etc.). Este tipo enumerado sirve de referencia para componer las clases de color para el fondo
/// ([`classes::Background`](crate::theme::classes::Background)), los bordes
/// ([`classes::Border`](crate::theme::classes::Border)) o para el texto
/// ([`classes::Text`](crate::theme::classes::Text)).
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
pub enum Color {
#[default]
Primary,
Secondary,
Success,
Info,
Warning,
Danger,
Light,
Dark,
}
impl Color {
/// Devuelve el nombre del color.
#[rustfmt::skip]
#[inline]
pub(crate) const fn as_str(self) -> &'static str {
match self {
Self::Primary => "primary",
Self::Secondary => "secondary",
Self::Success => "success",
Self::Info => "info",
Self::Warning => "warning",
Self::Danger => "danger",
Self::Light => "light",
Self::Dark => "dark",
}
}
/// Devuelve la clase correspondiente al color.
///
/// # Ejemplos
///
/// ```rust
/// # use pagetop_bootsier::prelude::*;
/// assert_eq!(Color::Primary.to_class(), "primary");
/// assert_eq!(Color::Danger.to_class(), "danger");
/// ```
#[inline]
pub fn to_class(self) -> String {
self.as_str().to_owned()
}
}
// **< Opacity >************************************************************************************
/// Niveles de opacidad (`opacity-*`).
///
/// Se usa normalmente para graduar la transparencia del color de fondo `bg-opacity-*`
/// ([`classes::Background`](crate::theme::classes::Background)), de los bordes `border-opacity-*`
/// ([`classes::Border`](crate::theme::classes::Border)) o del texto `text-opacity-*`
/// ([`classes::Text`](crate::theme::classes::Text)).
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
pub enum Opacity {
/// No define ninguna clase.
#[default]
Default,
/// Permite generar clases `*-opacity-100` (100% de opacidad).
Opaque,
/// Permite generar clases `*-opacity-75` (75%).
SemiOpaque,
/// Permite generar clases `*-opacity-50` (50%).
Half,
/// Permite generar clases `*-opacity-25` (25%).
SemiTransparent,
/// Permite generar clases `*-opacity-10` (10%).
AlmostTransparent,
/// Permite generar clases `*-opacity-0` (0%, totalmente transparente).
Transparent,
}
impl Opacity {
const OPACITY: &str = "opacity";
const OPACITY_PREFIX: &str = "-opacity";
/// Devuelve el sufijo para `*opacity-*`, o `None` si no define ninguna clase.
#[rustfmt::skip]
#[inline]
const fn suffix(self) -> Option<&'static str> {
match self {
Self::Default => None,
Self::Opaque => Some("-100"),
Self::SemiOpaque => Some("-75"),
Self::Half => Some("-50"),
Self::SemiTransparent => Some("-25"),
Self::AlmostTransparent => Some("-10"),
Self::Transparent => Some("-0"),
}
}
/// Añade la opacidad a la cadena de clases usando el prefijo dado (`bg`, `border`, `text`, o
/// vacío para `opacity-*`).
#[inline]
pub(crate) fn push_class(self, classes: &mut String, prefix: &str) {
if let Some(suffix) = self.suffix() {
if !classes.is_empty() {
classes.push(' ');
}
if prefix.is_empty() {
classes.push_str(Self::OPACITY);
} else {
classes.push_str(prefix);
classes.push_str(Self::OPACITY_PREFIX);
}
classes.push_str(suffix);
}
}
/// Devuelve la clase de opacidad con el prefijo dado (`bg`, `border`, `text`, o vacío para
/// `opacity-*`).
///
/// # Ejemplos
///
/// ```rust
/// # use pagetop_bootsier::prelude::*;
/// assert_eq!(Opacity::Opaque.class_with(""), "opacity-100");
/// assert_eq!(Opacity::Half.class_with("bg"), "bg-opacity-50");
/// assert_eq!(Opacity::SemiTransparent.class_with("text"), "text-opacity-25");
/// assert_eq!(Opacity::Default.class_with("bg"), "");
/// ```
#[doc(hidden)]
pub fn class_with(self, prefix: &str) -> String {
if let Some(suffix) = self.suffix() {
let base_len = if prefix.is_empty() {
Self::OPACITY.len()
} else {
prefix.len() + Self::OPACITY_PREFIX.len()
};
let mut class = String::with_capacity(base_len + suffix.len());
if prefix.is_empty() {
class.push_str(Self::OPACITY);
} else {
class.push_str(prefix);
class.push_str(Self::OPACITY_PREFIX);
}
class.push_str(suffix);
return class;
}
String::new()
}
/// Devuelve la clase de opacidad `opacity-*`.
///
/// # Ejemplos
///
/// ```rust
/// # use pagetop_bootsier::prelude::*;
/// assert_eq!(Opacity::Opaque.to_class(), "opacity-100");
/// assert_eq!(Opacity::Half.to_class(), "opacity-50");
/// assert_eq!(Opacity::Default.to_class(), "");
/// ```
#[inline]
pub fn to_class(self) -> String {
self.class_with("")
}
}
// **< ColorBg >************************************************************************************
/// Esquema de color para el fondo.
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
pub enum ColorBg {
/// No define ninguna clase.
#[default]
Default,
/// Fondo predefinido del tema (`bg-body`).
Body,
/// Fondo predefinido del tema (`bg-body-secondary`).
BodySecondary,
/// Fondo predefinido del tema (`bg-body-tertiary`).
BodyTertiary,
/// Genera la clase `bg-{color}` (p. ej., `bg-primary`).
Theme(Color),
/// Genera la clase `bg-{color}-subtle` (un tono suavizado del color).
Subtle(Color),
/// Color negro.
Black,
/// Color blanco.
White,
/// No aplica ningún color de fondo (`bg-transparent`).
Transparent,
}
impl ColorBg {
const BG: &str = "bg";
const BG_PREFIX: &str = "bg-";
/// Devuelve el sufijo de la clase `bg-*`, o `None` si no define ninguna clase.
#[rustfmt::skip]
#[inline]
const fn suffix(self) -> Option<&'static str> {
match self {
Self::Default => None,
Self::Body => Some("-body"),
Self::BodySecondary => Some("-body-secondary"),
Self::BodyTertiary => Some("-body-tertiary"),
Self::Theme(_) => Some(""),
Self::Subtle(_) => Some("-subtle"),
Self::Black => Some("-black"),
Self::White => Some("-white"),
Self::Transparent => Some("-transparent"),
}
}
/// Añade la clase de fondo `bg-*` a la cadena de clases.
#[inline]
pub(crate) fn push_class(self, classes: &mut String) {
if let Some(suffix) = self.suffix() {
if !classes.is_empty() {
classes.push(' ');
}
match self {
Self::Theme(c) | Self::Subtle(c) => {
classes.push_str(Self::BG_PREFIX);
classes.push_str(c.as_str());
}
_ => classes.push_str(Self::BG),
}
classes.push_str(suffix);
}
}
/// Devuelve la clase `bg-*` correspondiente al fondo.
///
/// # Ejemplos
///
/// ```rust
/// # use pagetop_bootsier::prelude::*;
/// assert_eq!(ColorBg::Body.to_class(), "bg-body");
/// assert_eq!(ColorBg::Theme(Color::Primary).to_class(), "bg-primary");
/// assert_eq!(ColorBg::Subtle(Color::Warning).to_class(), "bg-warning-subtle");
/// assert_eq!(ColorBg::Transparent.to_class(), "bg-transparent");
/// assert_eq!(ColorBg::Default.to_class(), "");
/// ```
pub fn to_class(self) -> String {
let mut class = String::new();
self.push_class(&mut class);
class
}
}
// **< ColorText >**********************************************************************************
/// Esquema de color para el texto.
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
pub enum ColorText {
/// No define ninguna clase.
#[default]
Default,
/// Color predefinido del tema (`text-body`).
Body,
/// Color predefinido del tema (`text-body-emphasis`).
BodyEmphasis,
/// Color predefinido del tema (`text-body-secondary`).
BodySecondary,
/// Color predefinido del tema (`text-body-tertiary`).
BodyTertiary,
/// Genera la clase `text-{color}`.
Theme(Color),
/// Genera la clase `text-{color}-emphasis` (mayor contraste acorde al tema).
Emphasis(Color),
/// Color negro.
Black,
/// Color blanco.
White,
}
impl ColorText {
const TEXT: &str = "text";
const TEXT_PREFIX: &str = "text-";
/// Devuelve el sufijo de la clase `text-*`, o `None` si no define ninguna clase.
#[rustfmt::skip]
#[inline]
const fn suffix(self) -> Option<&'static str> {
match self {
Self::Default => None,
Self::Body => Some("-body"),
Self::BodyEmphasis => Some("-body-emphasis"),
Self::BodySecondary => Some("-body-secondary"),
Self::BodyTertiary => Some("-body-tertiary"),
Self::Theme(_) => Some(""),
Self::Emphasis(_) => Some("-emphasis"),
Self::Black => Some("-black"),
Self::White => Some("-white"),
}
}
/// Añade la clase de texto `text-*` a la cadena de clases.
#[inline]
pub(crate) fn push_class(self, classes: &mut String) {
if let Some(suffix) = self.suffix() {
if !classes.is_empty() {
classes.push(' ');
}
match self {
Self::Theme(c) | Self::Emphasis(c) => {
classes.push_str(Self::TEXT_PREFIX);
classes.push_str(c.as_str());
}
_ => classes.push_str(Self::TEXT),
}
classes.push_str(suffix);
}
}
/// Devuelve la clase `text-*` correspondiente al color del texto.
///
/// # Ejemplos
///
/// ```rust
/// # use pagetop_bootsier::prelude::*;
/// assert_eq!(ColorText::Body.to_class(), "text-body");
/// assert_eq!(ColorText::Theme(Color::Primary).to_class(), "text-primary");
/// assert_eq!(ColorText::Emphasis(Color::Danger).to_class(), "text-danger-emphasis");
/// assert_eq!(ColorText::Black.to_class(), "text-black");
/// assert_eq!(ColorText::Default.to_class(), "");
/// ```
pub fn to_class(self) -> String {
let mut class = String::new();
self.push_class(&mut class);
class
}
}

View file

@ -1,104 +0,0 @@
use pagetop::prelude::*;
// **< ScaleSize >**********************************************************************************
/// Escala discreta de tamaños para definir clases utilitarias.
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
pub enum ScaleSize {
/// Sin tamaño (no define ninguna clase).
#[default]
None,
/// Tamaño automático.
Auto,
/// Escala cero.
Zero,
/// Escala uno.
One,
/// Escala dos.
Two,
/// Escala tres.
Three,
/// Escala cuatro.
Four,
/// Escala cinco.
Five,
}
impl ScaleSize {
/// Devuelve el sufijo para el tamaño (`"-0"`, `"-1"`, etc.), o `None` si no define ninguna
/// clase, o `""` para el tamaño automático.
#[rustfmt::skip]
#[inline]
const fn suffix(self) -> Option<&'static str> {
match self {
Self::None => None,
Self::Auto => Some(""),
Self::Zero => Some("-0"),
Self::One => Some("-1"),
Self::Two => Some("-2"),
Self::Three => Some("-3"),
Self::Four => Some("-4"),
Self::Five => Some("-5"),
}
}
/// Añade el tamaño a la cadena de clases usando el prefijo dado.
#[inline]
pub(crate) fn push_class(self, classes: &mut String, prefix: &str) {
if !prefix.is_empty() {
if let Some(suffix) = self.suffix() {
if !classes.is_empty() {
classes.push(' ');
}
classes.push_str(prefix);
classes.push_str(suffix);
}
}
}
/* Devuelve la clase del tamaño para el prefijo, o una cadena vacía si no aplica (reservado).
///
/// # Ejemplo
///
/// ```rust
/// # use pagetop_bootsier::prelude::*;
/// assert_eq!(ScaleSize::Auto.class_with("border"), "border");
/// assert_eq!(ScaleSize::Zero.class_with("m"), "m-0");
/// assert_eq!(ScaleSize::Three.class_with("p"), "p-3");
/// assert_eq!(ScaleSize::None.class_with("border"), "");
/// ```
#[doc(hidden)]
pub fn class_with(self, prefix: &str) -> String {
if !prefix.is_empty() {
if let Some(suffix) = self.suffix() {
let mut class = String::with_capacity(prefix.len() + suffix.len());
class.push_str(prefix);
class.push_str(suffix);
return class;
}
}
String::new()
} */
}
// **< Side >***************************************************************************************
/// Lados sobre los que aplicar una clase utilitaria (respetando LTR/RTL).
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
pub enum Side {
/// Todos los lados.
#[default]
All,
/// Lado superior.
Top,
/// Lado inferior.
Bottom,
/// Lado lógico de inicio (respetando RTL).
Start,
/// Lado lógico de fin (respetando RTL).
End,
/// Lados lógicos laterales (abreviatura *x*).
LeftAndRight,
/// Lados superior e inferior (abreviatura *y*).
TopAndBottom,
}

View file

@ -1,117 +0,0 @@
use pagetop::prelude::*;
/// Radio para el redondeo de esquinas ([`classes::Rounded`](crate::theme::classes::Rounded)).
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
pub enum RoundedRadius {
/// No define ninguna clase.
#[default]
None,
/// Genera `rounded` (radio por defecto del tema).
Default,
/// Genera `rounded-0` (sin redondeo).
Zero,
/// Genera `rounded-1`.
Scale1,
/// Genera `rounded-2`.
Scale2,
/// Genera `rounded-3`.
Scale3,
/// Genera `rounded-4`.
Scale4,
/// Genera `rounded-5`.
Scale5,
/// Genera `rounded-circle`.
Circle,
/// Genera `rounded-pill`.
Pill,
}
impl RoundedRadius {
const ROUNDED: &str = "rounded";
/// Devuelve el sufijo para `*rounded-*`, o `None` si no define ninguna clase, o `""` para el
/// redondeo por defecto.
#[rustfmt::skip]
#[inline]
const fn suffix(self) -> Option<&'static str> {
match self {
Self::None => None,
Self::Default => Some(""),
Self::Zero => Some("-0"),
Self::Scale1 => Some("-1"),
Self::Scale2 => Some("-2"),
Self::Scale3 => Some("-3"),
Self::Scale4 => Some("-4"),
Self::Scale5 => Some("-5"),
Self::Circle => Some("-circle"),
Self::Pill => Some("-pill"),
}
}
/// Añade el redondeo de esquinas a la cadena de clases usando el prefijo dado (`rounded-top`,
/// `rounded-bottom-start`, o vacío para `rounded-*`).
#[inline]
pub(crate) fn push_class(self, classes: &mut String, prefix: &str) {
if let Some(suffix) = self.suffix() {
if !classes.is_empty() {
classes.push(' ');
}
if prefix.is_empty() {
classes.push_str(Self::ROUNDED);
} else {
classes.push_str(prefix);
}
classes.push_str(suffix);
}
}
/// Devuelve la clase para el redondeo de esquinas con el prefijo dado (`rounded-top`,
/// `rounded-bottom-start`, o vacío para `rounded-*`).
///
/// # Ejemplos
///
/// ```rust
/// # use pagetop_bootsier::prelude::*;
/// assert_eq!(RoundedRadius::Scale2.class_with(""), "rounded-2");
/// assert_eq!(RoundedRadius::Zero.class_with("rounded-top"), "rounded-top-0");
/// assert_eq!(RoundedRadius::Scale3.class_with("rounded-top-end"), "rounded-top-end-3");
/// assert_eq!(RoundedRadius::Circle.class_with(""), "rounded-circle");
/// assert_eq!(RoundedRadius::None.class_with("rounded-bottom-start"), "");
/// ```
#[doc(hidden)]
pub fn class_with(self, prefix: &str) -> String {
if let Some(suffix) = self.suffix() {
let base_len = if prefix.is_empty() {
Self::ROUNDED.len()
} else {
prefix.len()
};
let mut class = String::with_capacity(base_len + suffix.len());
if prefix.is_empty() {
class.push_str(Self::ROUNDED);
} else {
class.push_str(prefix);
}
class.push_str(suffix);
return class;
}
String::new()
}
/// Devuelve la clase `rounded-*` para el redondeo de esquinas.
///
/// # Ejemplos
///
/// ```rust
/// # use pagetop_bootsier::prelude::*;
/// assert_eq!(RoundedRadius::Default.to_class(), "rounded");
/// assert_eq!(RoundedRadius::Zero.to_class(), "rounded-0");
/// assert_eq!(RoundedRadius::Scale3.to_class(), "rounded-3");
/// assert_eq!(RoundedRadius::Circle.to_class(), "rounded-circle");
/// assert_eq!(RoundedRadius::None.to_class(), "");
/// ```
#[inline]
pub fn to_class(self) -> String {
self.class_with("")
}
}

View file

@ -1,213 +0,0 @@
use pagetop::prelude::*;
use crate::theme::{ButtonAction, ButtonColor, ButtonSize};
/// Componente para crear un **botón**.
///
/// Renderiza un botón con soporte para las variantes disponibles en [`ButtonAction`] (`submit`,
/// `reset` y botón genérico) y con la variedad de estilos del tema a través de [`ButtonColor`] y
/// [`ButtonSize`].
///
/// El comportamiento del botón se establece al crearlo:
///
/// - [`Button::submit()`]: botón de envío (por defecto).
/// - [`Button::reset()`]: botón de restablecimiento de valores.
/// - [`Button::plain()`]: botón genérico sin comportamiento predeterminado.
///
/// El botón puede usarse dentro o fuera de un formulario.
///
/// # Ejemplo
///
/// ```rust
/// # use pagetop::prelude::*;
/// # use pagetop_bootsier::prelude::*;
/// let save = Button::submit(L10n::n("Save"))
/// .with_color(ButtonColor::Background(Color::Primary));
///
/// let cancel = Button::plain(L10n::n("Cancel"))
/// .with_color(ButtonColor::Outline(Color::Secondary));
///
/// let clear = Button::reset(L10n::n("Clear"))
/// .with_size(ButtonSize::Small);
/// ```
///
/// Cuando el botón activa el envío, el navegador incluye el par `name=value` en los datos del
/// formulario **sólo si** tiene el atributo `name` definido. Es la forma habitual de identificar
/// cuál de los botones de envío fue pulsado. En el servidor se deserializa como `Option<String>`:
///
/// ```rust,ignore
/// #[derive(serde::Deserialize)]
/// struct FormData {
/// #[serde(default)]
/// action: Option<String>, // p. ej., "save" o "delete"; `None` si el botón no tenía `name`.
/// }
/// ```
#[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Button {
#[getters(skip)]
id: AttrId,
/// Devuelve las clases CSS del botón.
classes: Classes,
/// Devuelve el comportamiento del botón al activarse.
kind: ButtonAction,
/// Devuelve el esquema de color del botón.
color: ButtonColor,
/// Devuelve el tamaño visual del botón.
size: ButtonSize,
/// Devuelve el nombre del botón.
name: AttrName,
/// Devuelve el valor del botón.
value: AttrValue,
/// Devuelve la etiqueta del botón.
label: Attr<L10n>,
/// Devuelve si el botón recibe el foco automáticamente al cargar la página.
autofocus: bool,
/// Devuelve si el botón está deshabilitado.
disabled: bool,
}
impl Component for Button {
fn new() -> Self {
Self::default()
}
fn id(&self) -> Option<String> {
self.id.get()
}
fn setup(&mut self, _cx: &Context) {
let mut classes = "btn".to_string();
(*self.color()).push_class(&mut classes);
(*self.size()).push_class(&mut classes);
self.alter_classes(ClassesOp::Prepend, classes);
}
fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
Ok(html! {
button
id=[self.id()]
type=(self.kind())
class=[self.classes().get()]
name=[self.name().get()]
value=[self.value().get()]
autofocus[*self.autofocus()]
disabled[*self.disabled()]
{
@if let Some(label) = self.label().lookup(cx) {
(label)
}
}
})
}
}
impl Button {
/// Crea un botón de **envío** (`type="submit"`).
///
/// Es la acción predeterminada al pulsar un botón en la mayoría de los formularios: envía los
/// datos al servidor.
pub fn submit(label: L10n) -> Self {
Self {
kind: ButtonAction::Submit,
label: Attr::some(label),
..Default::default()
}
}
/// Crea un botón de **restablecimiento** (`type="reset"`).
///
/// Al pulsarlo, devuelve todos los campos del formulario a sus valores iniciales.
pub fn reset(label: L10n) -> Self {
Self {
kind: ButtonAction::Reset,
label: Attr::some(label),
..Default::default()
}
}
/// Crea un **botón genérico** (`type="button"`).
///
/// No tiene un comportamiento predeterminado sobre el formulario. Su comportamiento puede
/// definirse mediante JavaScript.
pub fn plain(label: L10n) -> Self {
Self {
kind: ButtonAction::Plain,
label: Attr::some(label),
..Default::default()
}
}
// **< Button BUILDER >*************************************************************************
/// Establece el identificador único (`id`) del botón.
#[builder_fn]
pub fn with_id(mut self, id: impl AsRef<str>) -> Self {
self.id.alter_id(id);
self
}
/// Modifica la lista de clases CSS aplicadas al botón.
#[builder_fn]
pub fn with_classes(mut self, op: ClassesOp, classes: impl AsRef<str>) -> Self {
self.classes.alter_classes(op, classes);
self
}
/// Establece el esquema de color del botón.
///
/// Usa [`ButtonColor::Background`] para botones sólidos o [`ButtonColor::Outline`] para
/// variantes con contorno.
#[builder_fn]
pub fn with_color(mut self, color: ButtonColor) -> Self {
self.color = color;
self
}
/// Establece el tamaño visual del botón.
#[builder_fn]
pub fn with_size(mut self, size: ButtonSize) -> Self {
self.size = size;
self
}
/// Establece el nombre del botón (atributo `name`).
///
/// Cuando el formulario tiene varios botones de envío, el navegador incluye en el envío el par
/// `name=value` sólo del botón que activó el formulario. Permite identificar cuál fue pulsado.
#[builder_fn]
pub fn with_name(mut self, name: impl AsRef<str>) -> Self {
self.name.alter_name(name);
self
}
/// Establece el valor del botón (atributo `value`).
///
/// Es el dato que el navegador transmite al servidor junto con el `name` cuando este botón
/// activa el envío. Útil para distinguir entre varios botones de envío en un mismo formulario.
#[builder_fn]
pub fn with_value(mut self, value: impl AsRef<str>) -> Self {
self.value.alter_str(value);
self
}
/// Establece o elimina la etiqueta visible del botón (basta pasar `None` para quitarla).
#[builder_fn]
pub fn with_label(mut self, label: impl Into<Option<L10n>>) -> Self {
self.label.alter_opt(label.into());
self
}
/// Establece si el botón recibe el foco automáticamente al cargar la página.
#[builder_fn]
pub fn with_autofocus(mut self, autofocus: bool) -> Self {
self.autofocus = autofocus;
self
}
/// Establece si el botón está deshabilitado.
#[builder_fn]
pub fn with_disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
}

View file

@ -1,13 +0,0 @@
//! Conjunto de clases para aplicar en componentes del tema.
mod color;
pub use color::{Background, Text};
mod border;
pub use border::Border;
mod rounded;
pub use rounded::Rounded;
mod layout;
pub use layout::{Margin, Padding};

View file

@ -1,174 +0,0 @@
use pagetop::prelude::*;
use crate::theme::attrs::{BorderColor, Opacity, ScaleSize, Side};
/// Clases para crear **bordes**.
///
/// Permite:
///
/// - Iniciar un borde sin tamaño inicial (`Border::default()`).
/// - Crear un borde con tamaño por defecto (`Border::new()`).
/// - Ajustar el tamaño de cada **lado lógico** (`side`, respetando LTR/RTL).
/// - Definir un tamaño **global** para todo el borde (`size`).
/// - Aplicar un **color** al borde (`BorderColor`).
/// - Aplicar un nivel de **opacidad** (`Opacity`).
///
/// # Comportamiento aditivo / sustractivo
///
/// - **Aditivo**: basta con crear un borde sin tamaño con `classes::Border::default()` para ir
/// añadiendo cada lado lógico con el tamaño deseado usando `ScaleSize::{One..Five}`.
///
/// - **Sustractivo**: se crea un borde con tamaño predefinido, p. ej. usando
/// `classes::Border::new()` o `classes::Border::with(ScaleSize::Two)` y eliminar los lados
/// deseados con `ScaleSize::Zero`.
///
/// - **Anchos diferentes por lado**: usando `ScaleSize::{Zero..Five}` en cada lado deseado.
///
/// # Ejemplos
///
/// **Borde global:**
/// ```rust
/// # use pagetop_bootsier::prelude::*;
/// let b = classes::Border::with(ScaleSize::Two);
/// assert_eq!(b.to_class(), "border-2");
/// ```
///
/// **Aditivo (solo borde superior):**
/// ```rust
/// # use pagetop_bootsier::prelude::*;
/// let b = classes::Border::default().with_side(Side::Top, ScaleSize::One);
/// assert_eq!(b.to_class(), "border-top-1");
/// ```
///
/// **Sustractivo (borde global menos el superior):**
/// ```rust
/// # use pagetop_bootsier::prelude::*;
/// let b = classes::Border::new().with_side(Side::Top, ScaleSize::Zero);
/// assert_eq!(b.to_class(), "border border-top-0");
/// ```
///
/// **Ancho por lado (lado lógico inicial a 2 y final a 4):**
/// ```rust
/// # use pagetop_bootsier::prelude::*;
/// let b = classes::Border::default()
/// .with_side(Side::Start, ScaleSize::Two)
/// .with_side(Side::End, ScaleSize::Four);
/// assert_eq!(b.to_class(), "border-end-4 border-start-2");
/// ```
///
/// **Combinado (ejemplo completo):**
/// ```rust
/// # use pagetop_bootsier::prelude::*;
/// let b = classes::Border::new() // Borde por defecto.
/// .with_side(Side::Top, ScaleSize::Zero) // Quita borde superior.
/// .with_side(Side::End, ScaleSize::Three) // Ancho 3 para el lado lógico final.
/// .with_color(BorderColor::Theme(Color::Primary))
/// .with_opacity(Opacity::Half);
///
/// assert_eq!(b.to_class(), "border border-top-0 border-end-3 border-primary border-opacity-50");
/// ```
#[rustfmt::skip]
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
pub struct Border {
all : ScaleSize,
top : ScaleSize,
end : ScaleSize,
bottom : ScaleSize,
start : ScaleSize,
color : BorderColor,
opacity: Opacity,
}
impl Border {
/// Prepara un borde del tamaño predefinido. Equivale a `border` (ancho por defecto del tema).
pub fn new() -> Self {
Self::with(ScaleSize::Auto)
}
/// Crea un borde **con un tamaño global** (`size`).
pub fn with(size: ScaleSize) -> Self {
Self::default().with_side(Side::All, size)
}
// **< Border BUILDER >*************************************************************************
pub fn with_side(mut self, side: Side, size: ScaleSize) -> Self {
match side {
Side::All => self.all = size,
Side::Top => self.top = size,
Side::Bottom => self.bottom = size,
Side::Start => self.start = size,
Side::End => self.end = size,
Side::LeftAndRight => {
self.start = size;
self.end = size;
}
Side::TopAndBottom => {
self.top = size;
self.bottom = size;
}
};
self
}
/// Establece el color del borde.
pub fn with_color(mut self, color: BorderColor) -> Self {
self.color = color;
self
}
/// Establece la opacidad del borde.
pub fn with_opacity(mut self, opacity: Opacity) -> Self {
self.opacity = opacity;
self
}
// **< Border HELPERS >*************************************************************************
/// Añade las clases de borde a la cadena de clases.
///
/// Concatena, en este orden, las clases para *global*, `top`, `end`, `bottom`, `start`,
/// *color* y *opacidad*; respetando LTR/RTL y omitiendo las definiciones vacías.
#[rustfmt::skip]
#[inline]
pub(crate) fn push_class(self, classes: &mut String) {
self.all .push_class(classes, "border");
self.top .push_class(classes, "border-top");
self.end .push_class(classes, "border-end");
self.bottom .push_class(classes, "border-bottom");
self.start .push_class(classes, "border-start");
self.color .push_class(classes);
self.opacity.push_class(classes, "border");
}
/// Devuelve las clases de borde como cadena (`"border-2"`,
/// `"border border-top-0 border-end-3 border-primary border-opacity-50"`, etc.).
///
/// Si no se define ningún tamaño, color ni opacidad, devuelve `""`.
pub fn to_class(self) -> String {
let mut classes = String::new();
self.push_class(&mut classes);
classes
}
}
/// Atajo para crear un [`classes::Border`](crate::theme::classes::Border) a partir de un tamaño
/// [`ScaleSize`] aplicado a todo el borde.
///
/// # Ejemplos
///
/// ```rust
/// # use pagetop_bootsier::prelude::*;
/// // Convertir explícitamente con `From::from`:
/// let b = classes::Border::from(ScaleSize::Two);
/// assert_eq!(b.to_class(), "border-2");
///
/// // Convertir implícitamente con `into()`:
/// let b: classes::Border = ScaleSize::Auto.into();
/// assert_eq!(b.to_class(), "border");
/// ```
impl From<ScaleSize> for Border {
fn from(size: ScaleSize) -> Self {
Self::with(size)
}
}

View file

@ -1,228 +0,0 @@
use pagetop::prelude::*;
use crate::theme::attrs::{ColorBg, ColorText, Opacity};
// **< Background >*********************************************************************************
/// Clases para establecer **color/opacidad del fondo**.
///
/// # Ejemplos
///
/// ```
/// # use pagetop_bootsier::prelude::*;
/// // Sin clases.
/// let s = classes::Background::new();
/// assert_eq!(s.to_class(), "");
///
/// // Sólo color de fondo.
/// let s = classes::Background::with(ColorBg::Theme(Color::Primary));
/// assert_eq!(s.to_class(), "bg-primary");
///
/// // Color más opacidad.
/// let s = classes::Background::with(ColorBg::BodySecondary).with_opacity(Opacity::Half);
/// assert_eq!(s.to_class(), "bg-body-secondary bg-opacity-50");
///
/// // Usando `From<ColorBg>`.
/// let s: classes::Background = ColorBg::Black.into();
/// assert_eq!(s.to_class(), "bg-black");
///
/// // Usando `From<(ColorBg, Opacity)>`.
/// let s: classes::Background = (ColorBg::White, Opacity::SemiTransparent).into();
/// assert_eq!(s.to_class(), "bg-white bg-opacity-25");
/// ```
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
pub struct Background {
color: ColorBg,
opacity: Opacity,
}
impl Background {
/// Prepara un nuevo estilo para aplicar al fondo.
pub fn new() -> Self {
Self::default()
}
/// Crea un estilo fijando el color de fondo (`bg-*`).
pub fn with(color: ColorBg) -> Self {
Self::default().with_color(color)
}
// **< Background BUILDER >*********************************************************************
/// Establece el color de fondo (`bg-*`).
pub fn with_color(mut self, color: ColorBg) -> Self {
self.color = color;
self
}
/// Establece la opacidad del fondo (`bg-opacity-*`).
pub fn with_opacity(mut self, opacity: Opacity) -> Self {
self.opacity = opacity;
self
}
// **< Background HELPERS >*********************************************************************
/// Añade las clases de fondo a la cadena de clases.
///
/// Concatena, en este orden, color del fondo (`bg-*`) y opacidad (`bg-opacity-*`),
/// omitiendo los fragmentos vacíos.
#[inline]
pub(crate) fn push_class(self, classes: &mut String) {
self.color.push_class(classes);
self.opacity.push_class(classes, "bg");
}
/// Devuelve las clases de fondo como cadena (`"bg-primary"`, `"bg-body-secondary bg-opacity-50"`, etc.).
///
/// Si no se define ni color ni opacidad, devuelve `""`.
pub fn to_class(self) -> String {
let mut classes = String::new();
self.push_class(&mut classes);
classes
}
}
impl From<(ColorBg, Opacity)> for Background {
/// Atajo para crear un [`classes::Background`](crate::theme::classes::Background) a partir del color de fondo y
/// la opacidad.
///
/// # Ejemplo
///
/// ```
/// # use pagetop_bootsier::prelude::*;
/// let s: classes::Background = (ColorBg::White, Opacity::SemiTransparent).into();
/// assert_eq!(s.to_class(), "bg-white bg-opacity-25");
/// ```
fn from((color, opacity): (ColorBg, Opacity)) -> Self {
Background::with(color).with_opacity(opacity)
}
}
impl From<ColorBg> for Background {
/// Atajo para crear un [`classes::Background`](crate::theme::classes::Background) a partir del color de fondo.
///
/// # Ejemplo
///
/// ```
/// # use pagetop_bootsier::prelude::*;
/// let s: classes::Background = ColorBg::Black.into();
/// assert_eq!(s.to_class(), "bg-black");
/// ```
fn from(color: ColorBg) -> Self {
Background::with(color)
}
}
// **< Text >***************************************************************************************
/// Clases para establecer **color/opacidad del texto**.
///
/// # Ejemplos
///
/// ```
/// # use pagetop_bootsier::prelude::*;
/// // Sin clases.
/// let s = classes::Text::new();
/// assert_eq!(s.to_class(), "");
///
/// // Sólo color del texto.
/// let s = classes::Text::with(ColorText::Theme(Color::Primary));
/// assert_eq!(s.to_class(), "text-primary");
///
/// // Color del texto y opacidad.
/// let s = classes::Text::new().with_color(ColorText::White).with_opacity(Opacity::SemiTransparent);
/// assert_eq!(s.to_class(), "text-white text-opacity-25");
///
/// // Usando `From<ColorText>`.
/// let s: classes::Text = ColorText::Black.into();
/// assert_eq!(s.to_class(), "text-black");
///
/// // Usando `From<(ColorText, Opacity)>`.
/// let s: classes::Text = (ColorText::Theme(Color::Danger), Opacity::Opaque).into();
/// assert_eq!(s.to_class(), "text-danger text-opacity-100");
/// ```
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
pub struct Text {
color: ColorText,
opacity: Opacity,
}
impl Text {
/// Prepara un nuevo estilo para aplicar al texto.
pub fn new() -> Self {
Self::default()
}
/// Crea un estilo fijando el color del texto (`text-*`).
pub fn with(color: ColorText) -> Self {
Self::default().with_color(color)
}
// **< Text BUILDER >***************************************************************************
/// Establece el color del texto (`text-*`).
pub fn with_color(mut self, color: ColorText) -> Self {
self.color = color;
self
}
/// Establece la opacidad del texto (`text-opacity-*`).
pub fn with_opacity(mut self, opacity: Opacity) -> Self {
self.opacity = opacity;
self
}
// **< Text HELPERS >***************************************************************************
/// Añade las clases de texto a la cadena de clases.
///
/// Concatena, en este orden, `text-*` y `text-opacity-*`, omitiendo los fragmentos vacíos.
#[inline]
pub(crate) fn push_class(self, classes: &mut String) {
self.color.push_class(classes);
self.opacity.push_class(classes, "text");
}
/// Devuelve las clases de texto como cadena (`"text-primary"`, `"text-white text-opacity-25"`,
/// etc.).
///
/// Si no se define ni color ni opacidad, devuelve `""`.
pub fn to_class(self) -> String {
let mut classes = String::new();
self.push_class(&mut classes);
classes
}
}
impl From<(ColorText, Opacity)> for Text {
/// Atajo para crear un [`classes::Text`](crate::theme::classes::Text) a partir del color del
/// texto y su opacidad.
///
/// # Ejemplo
///
/// ```
/// # use pagetop_bootsier::prelude::*;
/// let s: classes::Text = (ColorText::Theme(Color::Danger), Opacity::Opaque).into();
/// assert_eq!(s.to_class(), "text-danger text-opacity-100");
/// ```
fn from((color, opacity): (ColorText, Opacity)) -> Self {
Text::with(color).with_opacity(opacity)
}
}
impl From<ColorText> for Text {
/// Atajo para crear un [`classes::Text`](crate::theme::classes::Text) a partir del color del
/// texto.
///
/// # Ejemplo
///
/// ```
/// # use pagetop_bootsier::prelude::*;
/// let s: classes::Text = ColorText::Black.into();
/// assert_eq!(s.to_class(), "text-black");
/// ```
fn from(color: ColorText) -> Self {
Text::with(color)
}
}

View file

@ -1,180 +0,0 @@
use pagetop::prelude::*;
use crate::theme::attrs::{ScaleSize, Side};
use crate::theme::BreakPoint;
// **< Margin >*************************************************************************************
/// Clases para establecer **margin** por lado, tamaño y punto de ruptura.
///
/// # Ejemplos
///
/// ```rust
/// # use pagetop_bootsier::prelude::*;
/// let m = classes::Margin::with(Side::Top, ScaleSize::Three);
/// assert_eq!(m.to_class(), "mt-3");
///
/// let m = classes::Margin::with(Side::Start, ScaleSize::Auto).with_breakpoint(BreakPoint::LG);
/// assert_eq!(m.to_class(), "ms-lg-auto");
///
/// let m = classes::Margin::with(Side::All, ScaleSize::None);
/// assert_eq!(m.to_class(), "");
/// ```
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
pub struct Margin {
side: Side,
size: ScaleSize,
breakpoint: BreakPoint,
}
impl Margin {
/// Crea un **margin** indicando lado(s) y tamaño. Por defecto no se aplica a ningún punto de
/// ruptura.
pub fn with(side: Side, size: ScaleSize) -> Self {
Margin {
side,
size,
breakpoint: BreakPoint::None,
}
}
// **< Margin BUILDER >*************************************************************************
/// Establece el punto de ruptura a partir del cual se empieza a aplicar el **margin**.
pub fn with_breakpoint(mut self, breakpoint: BreakPoint) -> Self {
self.breakpoint = breakpoint;
self
}
// **< Margin HELPERS >*************************************************************************
/// Devuelve el prefijo `m*` según el lado.
#[rustfmt::skip]
#[inline]
const fn side_prefix(&self) -> &'static str {
match self.side {
Side::All => "m",
Side::Top => "mt",
Side::Bottom => "mb",
Side::Start => "ms",
Side::End => "me",
Side::LeftAndRight => "mx",
Side::TopAndBottom => "my",
}
}
/// Devuelve el sufijo del tamaño (`auto`, `0`..`5`), o `None` si no define clase.
#[rustfmt::skip]
#[inline]
const fn size_suffix(&self) -> Option<&'static str> {
match self.size {
ScaleSize::None => None,
ScaleSize::Auto => Some("auto"),
ScaleSize::Zero => Some("0"),
ScaleSize::One => Some("1"),
ScaleSize::Two => Some("2"),
ScaleSize::Three => Some("3"),
ScaleSize::Four => Some("4"),
ScaleSize::Five => Some("5"),
}
}
/// Devuelve la clase de **margin** como cadena (`"mt-3"`, `"ms-lg-auto"`, etc.).
///
/// Si `size` es `ScaleSize::None`, devuelve `""`.
pub fn to_class(self) -> String {
let Some(size) = self.size_suffix() else {
return String::new();
};
self.breakpoint.class_with(self.side_prefix(), size)
}
}
// **< Padding >************************************************************************************
/// Clases para establecer **padding** por lado, tamaño y punto de ruptura.
///
/// # Ejemplos
///
/// ```rust
/// # use pagetop_bootsier::prelude::*;
/// let p = classes::Padding::with(Side::LeftAndRight, ScaleSize::Two);
/// assert_eq!(p.to_class(), "px-2");
///
/// let p = classes::Padding::with(Side::End, ScaleSize::Four).with_breakpoint(BreakPoint::SM);
/// assert_eq!(p.to_class(), "pe-sm-4");
///
/// let p = classes::Padding::with(Side::All, ScaleSize::Auto);
/// assert_eq!(p.to_class(), ""); // `Auto` no aplica a padding.
/// ```
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
pub struct Padding {
side: Side,
size: ScaleSize,
breakpoint: BreakPoint,
}
impl Padding {
/// Crea un **padding** indicando lado(s) y tamaño. Por defecto no se aplica a ningún punto de
/// ruptura.
pub fn with(side: Side, size: ScaleSize) -> Self {
Padding {
side,
size,
breakpoint: BreakPoint::None,
}
}
// **< Padding BUILDER >************************************************************************
/// Establece el punto de ruptura a partir del cual se empieza a aplicar el **padding**.
pub fn with_breakpoint(mut self, breakpoint: BreakPoint) -> Self {
self.breakpoint = breakpoint;
self
}
// **< Padding HELPERS >************************************************************************
/// Devuelve el prefijo `p*` según el lado.
#[rustfmt::skip]
#[inline]
const fn prefix(&self) -> &'static str {
match self.side {
Side::All => "p",
Side::Top => "pt",
Side::Bottom => "pb",
Side::Start => "ps",
Side::End => "pe",
Side::LeftAndRight => "px",
Side::TopAndBottom => "py",
}
}
/// Devuelve el sufijo del tamaño (`0`..`5`), o `None` si no define clase.
///
/// Nota: `ScaleSize::Auto` **no aplica** a padding ⇒ devuelve `None`.
#[rustfmt::skip]
#[inline]
const fn suffix(&self) -> Option<&'static str> {
match self.size {
ScaleSize::None => None,
ScaleSize::Auto => None,
ScaleSize::Zero => Some("0"),
ScaleSize::One => Some("1"),
ScaleSize::Two => Some("2"),
ScaleSize::Three => Some("3"),
ScaleSize::Four => Some("4"),
ScaleSize::Five => Some("5"),
}
}
/// Devuelve la clase de **padding** como cadena (`"px-2"`, `"pe-sm-4"`, etc.).
///
/// Si `size` es `ScaleSize::None` o `ScaleSize::Auto`, devuelve `""`.
pub fn to_class(self) -> String {
let Some(size) = self.suffix() else {
return String::new();
};
self.breakpoint.class_with(self.prefix(), size)
}
}

View file

@ -1,168 +0,0 @@
use pagetop::prelude::*;
use crate::theme::attrs::RoundedRadius;
/// Clases para definir **esquinas redondeadas**.
///
/// Permite:
///
/// - Definir un radio **global para todas las esquinas** (`radius`).
/// - Ajustar el radio asociado a las **esquinas de cada lado lógico** (`top`, `end`, `bottom`,
/// `start`, **en este orden**, respetando LTR/RTL).
/// - Ajustar el radio de las **esquinas concretas** (`top-start`, `top-end`, `bottom-start`,
/// `bottom-end`, **en este orden**, respetando LTR/RTL).
///
/// # Ejemplos
///
/// **Radio global:**
/// ```rust
/// # use pagetop_bootsier::prelude::*;
/// let r = classes::Rounded::with(RoundedRadius::Default);
/// assert_eq!(r.to_class(), "rounded");
/// ```
///
/// **Sin redondeo:**
/// ```rust
/// # use pagetop_bootsier::prelude::*;
/// let r = classes::Rounded::new();
/// assert_eq!(r.to_class(), "");
/// ```
///
/// **Radio en las esquinas de un lado lógico:**
/// ```rust
/// # use pagetop_bootsier::prelude::*;
/// let r = classes::Rounded::new().with_end(RoundedRadius::Scale2);
/// assert_eq!(r.to_class(), "rounded-end-2");
/// ```
///
/// **Radio en una esquina concreta:**
/// ```rust
/// # use pagetop_bootsier::prelude::*;
/// let r = classes::Rounded::new().with_top_start(RoundedRadius::Scale3);
/// assert_eq!(r.to_class(), "rounded-top-start-3");
/// ```
///
/// **Combinado (ejemplo completo):**
/// ```rust
/// # use pagetop_bootsier::prelude::*;
/// let r = classes::Rounded::new()
/// .with_top(RoundedRadius::Default) // Añade redondeo arriba.
/// .with_bottom_start(RoundedRadius::Scale4) // Añade una esquina redondeada concreta.
/// .with_bottom_end(RoundedRadius::Circle); // Añade redondeo extremo en otra esquina.
///
/// assert_eq!(r.to_class(), "rounded-top rounded-bottom-start-4 rounded-bottom-end-circle");
/// ```
#[rustfmt::skip]
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
pub struct Rounded {
radius : RoundedRadius,
top : RoundedRadius,
end : RoundedRadius,
bottom : RoundedRadius,
start : RoundedRadius,
top_start : RoundedRadius,
top_end : RoundedRadius,
bottom_start: RoundedRadius,
bottom_end : RoundedRadius,
}
impl Rounded {
/// Prepara las esquinas **sin redondeo global** de partida.
pub fn new() -> Self {
Self::default()
}
/// Crea las esquinas **con redondeo global** (`radius`).
pub fn with(radius: RoundedRadius) -> Self {
Self::default().with_radius(radius)
}
// **< Rounded BUILDER >************************************************************************
/// Establece el radio global de las esquinas (`rounded*`).
pub fn with_radius(mut self, radius: RoundedRadius) -> Self {
self.radius = radius;
self
}
/// Establece el radio en las esquinas del lado superior (`rounded-top-*`).
pub fn with_top(mut self, radius: RoundedRadius) -> Self {
self.top = radius;
self
}
/// Establece el radio en las esquinas del lado lógico final (`rounded-end-*`). Respeta LTR/RTL.
pub fn with_end(mut self, radius: RoundedRadius) -> Self {
self.end = radius;
self
}
/// Establece el radio en las esquinas del lado inferior (`rounded-bottom-*`).
pub fn with_bottom(mut self, radius: RoundedRadius) -> Self {
self.bottom = radius;
self
}
/// Establece el radio en las esquinas del lado lógico inicial (`rounded-start-*`). Respeta
/// LTR/RTL.
pub fn with_start(mut self, radius: RoundedRadius) -> Self {
self.start = radius;
self
}
/// Establece el radio en la esquina superior-inicial (`rounded-top-start-*`). Respeta LTR/RTL.
pub fn with_top_start(mut self, radius: RoundedRadius) -> Self {
self.top_start = radius;
self
}
/// Establece el radio en la esquina superior-final (`rounded-top-end-*`). Respeta LTR/RTL.
pub fn with_top_end(mut self, radius: RoundedRadius) -> Self {
self.top_end = radius;
self
}
/// Establece el radio en la esquina inferior-inicial (`rounded-bottom-start-*`). Respeta
/// LTR/RTL.
pub fn with_bottom_start(mut self, radius: RoundedRadius) -> Self {
self.bottom_start = radius;
self
}
/// Establece el radio en la esquina inferior-final (`rounded-bottom-end-*`). Respeta LTR/RTL.
pub fn with_bottom_end(mut self, radius: RoundedRadius) -> Self {
self.bottom_end = radius;
self
}
// **< Rounded HELPERS >************************************************************************
/// Añade las clases de redondeo a la cadena de clases.
///
/// Concatena, en este orden, las clases para *global*, `top`, `end`, `bottom`, `start`,
/// `top-start`, `top-end`, `bottom-start` y `bottom-end`; respetando LTR/RTL y omitiendo las
/// definiciones vacías.
#[rustfmt::skip]
#[inline]
pub(crate) fn push_class(self, classes: &mut String) {
self.radius .push_class(classes, "");
self.top .push_class(classes, "rounded-top");
self.end .push_class(classes, "rounded-end");
self.bottom .push_class(classes, "rounded-bottom");
self.start .push_class(classes, "rounded-start");
self.top_start .push_class(classes, "rounded-top-start");
self.top_end .push_class(classes, "rounded-top-end");
self.bottom_start.push_class(classes, "rounded-bottom-start");
self.bottom_end .push_class(classes, "rounded-bottom-end");
}
/// Devuelve las clases de redondeo como cadena (`"rounded"`,
/// `"rounded-top rounded-bottom-start-4 rounded-bottom-end-circle"`, etc.).
///
/// Si no se define ningún radio, devuelve `""`.
pub fn to_class(self) -> String {
let mut classes = String::new();
self.push_class(&mut classes);
classes
}
}

View file

@ -1,24 +0,0 @@
//! Definiciones para crear contenedores de componentes ([`Container`]).
//!
//! Cada contenedor envuelve contenido usando la etiqueta semántica indicada por
//! [`container::Kind`](crate::theme::container::Kind).
//!
//! Con [`container::Width`](crate::theme::container::Width) se puede definir el ancho y el
//! comportamiento *responsive* del contenedor. También permite aplicar utilidades de estilo para el
//! fondo, texto, borde o esquinas redondeadas.
//!
//! # Ejemplo
//!
//! ```rust
//! # use pagetop::prelude::*;
//! # use pagetop_bootsier::prelude::*;
//! let main = Container::main()
//! .with_id("main-page")
//! .with_width(container::Width::From(BreakPoint::LG));
//! ```
mod props;
pub use props::{Kind, Width};
mod component;
pub use component::Container;

View file

@ -1,160 +0,0 @@
use pagetop::prelude::*;
use crate::prelude::*;
/// Componente para crear un **contenedor de componentes**.
///
/// Envuelve un contenido con la etiqueta HTML indicada por [`container::Kind`]. Sólo se renderiza
/// si existen componentes hijos (*children*).
#[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Container {
#[getters(skip)]
id: AttrId,
/// Devuelve las clases CSS asociadas al contenedor.
classes: Classes,
/// Devuelve el tipo semántico del contenedor.
container_kind: container::Kind,
/// Devuelve el comportamiento para el ancho del contenedor.
container_width: container::Width,
/// Devuelve la lista de componentes (`children`) del contenedor.
children: Children,
}
impl Component for Container {
fn new() -> Self {
Self::default()
}
fn id(&self) -> Option<String> {
self.id.get()
}
fn setup(&mut self, _cx: &Context) {
self.alter_classes(ClassesOp::Prepend, self.container_width().to_class());
}
fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
let output = self.children().render(cx);
if output.is_empty() {
return Ok(html! {});
}
let style = match self.container_width() {
container::Width::FluidMax(w) if w.is_measurable() => {
Some(util::join!("max-width: ", w.to_string(), ";"))
}
_ => None,
};
Ok(match self.container_kind() {
container::Kind::Default => html! {
div id=[self.id()] class=[self.classes().get()] style=[style] {
(output)
}
},
container::Kind::Main => html! {
main id=[self.id()] class=[self.classes().get()] style=[style] {
(output)
}
},
container::Kind::Header => html! {
header id=[self.id()] class=[self.classes().get()] style=[style] {
(output)
}
},
container::Kind::Footer => html! {
footer id=[self.id()] class=[self.classes().get()] style=[style] {
(output)
}
},
container::Kind::Section => html! {
section id=[self.id()] class=[self.classes().get()] style=[style] {
(output)
}
},
container::Kind::Article => html! {
article id=[self.id()] class=[self.classes().get()] style=[style] {
(output)
}
},
})
}
}
impl Container {
/// Crea un contenedor de tipo `Main` (`<main>`).
pub fn main() -> Self {
Self {
container_kind: container::Kind::Main,
..Default::default()
}
}
/// Crea un contenedor de tipo `Header` (`<header>`).
pub fn header() -> Self {
Self {
container_kind: container::Kind::Header,
..Default::default()
}
}
/// Crea un contenedor de tipo `Footer` (`<footer>`).
pub fn footer() -> Self {
Self {
container_kind: container::Kind::Footer,
..Default::default()
}
}
/// Crea un contenedor de tipo `Section` (`<section>`).
pub fn section() -> Self {
Self {
container_kind: container::Kind::Section,
..Default::default()
}
}
/// Crea un contenedor de tipo `Article` (`<article>`).
pub fn article() -> Self {
Self {
container_kind: container::Kind::Article,
..Default::default()
}
}
// **< Container BUILDER >**********************************************************************
/// Establece el identificador único (`id`) del contenedor.
#[builder_fn]
pub fn with_id(mut self, id: impl AsRef<str>) -> Self {
self.id.alter_id(id);
self
}
/// Modifica la lista de clases CSS aplicadas al contenedor.
///
/// También acepta clases predefinidas para:
///
/// - Modificar el color de fondo ([`classes::Background`]).
/// - Definir la apariencia del texto ([`classes::Text`]).
/// - Establecer bordes ([`classes::Border`]).
/// - Redondear las esquinas ([`classes::Rounded`]).
#[builder_fn]
pub fn with_classes(mut self, op: ClassesOp, classes: impl AsRef<str>) -> Self {
self.classes.alter_classes(op, classes);
self
}
/// Establece el comportamiento del ancho para el contenedor.
#[builder_fn]
pub fn with_width(mut self, width: container::Width) -> Self {
self.container_width = width;
self
}
/// Añade un nuevo componente al contenedor o modifica la lista de componentes (`children`) con
/// una operación [`ChildOp`].
#[builder_fn]
pub fn with_child(mut self, op: impl Into<ChildOp>) -> Self {
self.children.alter_child(op.into());
self
}
}

View file

@ -1,71 +0,0 @@
use pagetop::prelude::*;
use crate::theme::attrs::BreakPoint;
// **< Kind >***************************************************************************************
/// Tipo de contenedor ([`Container`](crate::theme::Container)).
///
/// Permite aplicar la etiqueta HTML apropiada (`<main>`, `<header>`, etc.) manteniendo una API
/// común a todos los contenedores.
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
pub enum Kind {
/// Contenedor genérico (`<div>`).
#[default]
Default,
/// Contenido principal de la página (`<main>`).
Main,
/// Encabezado de la página o de sección (`<header>`).
Header,
/// Pie de la página o de sección (`<footer>`).
Footer,
/// Sección de contenido (`<section>`).
Section,
/// Artículo de contenido (`<article>`).
Article,
}
// **< Width >**************************************************************************************
/// Define cómo se comporta el ancho de un contenedor ([`Container`](crate::theme::Container)).
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
pub enum Width {
/// Comportamiento por defecto, aplica los anchos máximos predefinidos para cada punto de
/// ruptura. Por debajo del menor punto de ruptura ocupa el 100% del ancho disponible.
#[default]
Default,
/// Aplica los anchos máximos predefinidos a partir del punto de ruptura indicado. Por debajo de
/// ese punto de ruptura ocupa el 100% del ancho disponible.
From(BreakPoint),
/// Ocupa el 100% del ancho disponible siempre.
Fluid,
/// Ocupa el 100% del ancho disponible hasta un ancho máximo explícito.
FluidMax(UnitValue),
}
impl Width {
const CONTAINER: &str = "container";
/* Añade el comportamiento del contenedor a la cadena de clases según ancho (reservado).
#[inline]
pub(crate) fn push_class(self, classes: &mut String) {
match self {
Self::Default => BreakPoint::None.push_class(classes, Self::CONTAINER, ""),
Self::From(bp) => bp.push_class(classes, Self::CONTAINER, ""),
Self::Fluid | Self::FluidMax(_) => {
BreakPoint::None.push_class(classes, Self::CONTAINER, "fluid")
}
}
} */
/// Devuelve la clase asociada al comportamiento del contenedor según el ajuste de su ancho.
pub fn to_class(self) -> String {
match self {
Self::Default => BreakPoint::None.class_with(Self::CONTAINER, ""),
Self::From(bp) => bp.class_with(Self::CONTAINER, ""),
Self::Fluid | Self::FluidMax(_) => {
BreakPoint::None.class_with(Self::CONTAINER, "fluid")
}
}
}
}

View file

@ -1,34 +0,0 @@
//! Definiciones para crear menús desplegables [`Dropdown`].
//!
//! Cada [`dropdown::Item`](crate::theme::dropdown::Item) representa un elemento individual del
//! desplegable [`Dropdown`], con distintos comportamientos según su finalidad, como enlaces de
//! navegación, botones de acción, encabezados o divisores visuales.
//!
//! Los ítems pueden estar activos, deshabilitados o abrirse en nueva ventana según su contexto y
//! configuración, y permiten incluir etiquetas localizables usando [`L10n`](pagetop::locale::L10n).
//!
//! # Ejemplo
//!
//! ```rust
//! # use pagetop::prelude::*;
//! # use pagetop_bootsier::prelude::*;
//! let dd = Dropdown::new()
//! .with_title(L10n::n("Menu"))
//! .with_button_color(ButtonColor::Background(Color::Secondary))
//! .with_auto_close(dropdown::AutoClose::ClickableInside)
//! .with_direction(dropdown::Direction::Dropend)
//! .with_item(dropdown::Item::link(L10n::n("Home"), |_| "/".into()))
//! .with_item(dropdown::Item::link_blank(L10n::n("External"), |_| "https://docs.rs".into()))
//! .with_item(dropdown::Item::divider())
//! .with_item(dropdown::Item::header(L10n::n("User session")))
//! .with_item(dropdown::Item::button(L10n::n("Sign out")));
//! ```
mod props;
pub use props::{AutoClose, Direction, MenuAlign, MenuPosition};
mod component;
pub use component::Dropdown;
mod item;
pub use item::{Item, ItemKind};

View file

@ -1,261 +0,0 @@
use pagetop::prelude::*;
use crate::prelude::*;
use crate::LOCALES_BOOTSIER;
/// Componente para crear un **menú desplegable**.
///
/// Renderiza un botón (único o desdoblado, ver [`with_button_split()`](Self::with_button_split))
/// para mostrar un menú desplegable de elementos [`dropdown::Item`], que se muestra/oculta según la
/// interacción del usuario. Admite variaciones de tamaño/color del botón, también dirección de
/// apertura, alineación o política de cierre.
///
/// Si no tiene título (ver [`with_title()`](Self::with_title)) se muestra únicamente la lista de
/// elementos sin ningún botón para interactuar.
///
/// Si este componente se usa en un menú [`Nav`] (ver [`nav::Item::dropdown()`]) sólo se tendrán en
/// cuenta **el título** (si no existe le asigna uno por defecto) y **la lista de elementos**; el
/// resto de propiedades no afectarán a su representación en [`Nav`].
///
/// Ver ejemplo en el módulo [`dropdown`].
/// Si no contiene elementos, el componente **no se renderiza**.
#[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Dropdown {
#[getters(skip)]
id: AttrId,
/// Devuelve las clases CSS asociadas al menú desplegable.
classes: Classes,
/// Devuelve el título del menú desplegable.
title: L10n,
/// Devuelve el tamaño configurado del botón.
button_size: ButtonSize,
/// Devuelve el color/estilo configurado del botón.
button_color: ButtonColor,
/// Devuelve si se debe desdoblar (*split*) el botón (botón de acción + *toggle*).
button_split: bool,
/// Devuelve si el botón del menú está integrado en un grupo de botones.
button_grouped: bool,
/// Devuelve la política de cierre automático del menú desplegado.
auto_close: dropdown::AutoClose,
/// Devuelve la dirección de despliegue configurada.
direction: dropdown::Direction,
/// Devuelve la configuración de alineación horizontal del menú desplegable.
menu_align: dropdown::MenuAlign,
/// Devuelve la posición configurada para el menú desplegable.
menu_position: dropdown::MenuPosition,
/// Devuelve la lista de elementos del menú.
items: Children,
}
impl Component for Dropdown {
fn new() -> Self {
Self::default()
}
fn id(&self) -> Option<String> {
self.id.get()
}
fn setup(&mut self, _cx: &Context) {
self.alter_classes(
ClassesOp::Prepend,
self.direction().class_with(*self.button_grouped()),
);
}
fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
// Si no hay elementos en el menú, no se prepara.
let items = self.items().render(cx);
if items.is_empty() {
return Ok(html! {});
}
// Título opcional para el menú desplegable.
let title = self.title().using(cx);
Ok(html! {
div id=[self.id()] class=[self.classes().get()] {
@if !title.is_empty() {
@let mut btn_classes = Classes::new({
let mut classes = "btn".to_string();
self.button_size().push_class(&mut classes);
self.button_color().push_class(&mut classes);
classes
});
@let pos = self.menu_position();
@let offset = pos.data_offset();
@let reference = pos.data_reference();
@let auto_close = self.auto_close.as_str();
@let menu_classes = Classes::new({
let mut classes = "dropdown-menu".to_string();
self.menu_align().push_class(&mut classes);
classes
});
// Renderizado en modo split (dos botones) o simple (un botón).
@if *self.button_split() {
// Botón principal (acción/etiqueta).
@let btn = html! {
button
type="button"
class=[btn_classes.get()]
{
(title)
}
};
// Botón *toggle* que abre/cierra el menú asociado.
@let btn_toggle = html! {
button
type="button"
class=[btn_classes.alter_classes(
ClassesOp::Add, "dropdown-toggle dropdown-toggle-split"
).get()]
data-bs-toggle="dropdown"
data-bs-offset=[offset]
data-bs-reference=[reference]
data-bs-auto-close=[auto_close]
aria-expanded="false"
{
span class="visually-hidden" {
(L10n::t("dropdown_toggle", &LOCALES_BOOTSIER).using(cx))
}
}
};
// Orden según dirección (en `dropstart` el *toggle* se sitúa antes).
@match self.direction() {
dropdown::Direction::Dropstart => {
(btn_toggle)
ul class=[menu_classes.get()] { (items) }
(btn)
}
_ => {
(btn)
(btn_toggle)
ul class=[menu_classes.get()] { (items) }
}
}
} @else {
// Botón único con funcionalidad de *toggle*.
button
type="button"
class=[btn_classes.alter_classes(
ClassesOp::Add, "dropdown-toggle"
).get()]
data-bs-toggle="dropdown"
data-bs-offset=[offset]
data-bs-reference=[reference]
data-bs-auto-close=[auto_close]
aria-expanded="false"
{
(title)
}
ul class=[menu_classes.get()] { (items) }
}
} @else {
// Sin botón: sólo el listado como menú contextual.
ul class="dropdown-menu" { (items) }
}
}
})
}
}
impl Dropdown {
// **< Dropdown BUILDER >***********************************************************************
/// Establece el identificador único (`id`) del menú desplegable.
#[builder_fn]
pub fn with_id(mut self, id: impl AsRef<str>) -> Self {
self.id.alter_id(id);
self
}
/// Modifica la lista de clases CSS aplicadas al menú desplegable.
#[builder_fn]
pub fn with_classes(mut self, op: ClassesOp, classes: impl AsRef<str>) -> Self {
self.classes.alter_classes(op, classes);
self
}
/// Establece el título del menú desplegable.
#[builder_fn]
pub fn with_title(mut self, title: L10n) -> Self {
self.title = title;
self
}
/// Ajusta el tamaño del botón.
#[builder_fn]
pub fn with_button_size(mut self, size: ButtonSize) -> Self {
self.button_size = size;
self
}
/// Define el color/estilo del botón.
#[builder_fn]
pub fn with_button_color(mut self, color: ButtonColor) -> Self {
self.button_color = color;
self
}
/// Activa/desactiva el modo *split* (botón de acción + *toggle*).
#[builder_fn]
pub fn with_button_split(mut self, split: bool) -> Self {
self.button_split = split;
self
}
/// Indica si el botón del menú está integrado en un grupo de botones.
#[builder_fn]
pub fn with_button_grouped(mut self, grouped: bool) -> Self {
self.button_grouped = grouped;
self
}
/// Establece la política de cierre automático del menú desplegable.
#[builder_fn]
pub fn with_auto_close(mut self, auto_close: dropdown::AutoClose) -> Self {
self.auto_close = auto_close;
self
}
/// Establece la dirección de despliegue del menú.
#[builder_fn]
pub fn with_direction(mut self, direction: dropdown::Direction) -> Self {
self.direction = direction;
self
}
/// Configura la alineación horizontal (con posible comportamiento *responsive* adicional).
#[builder_fn]
pub fn with_menu_align(mut self, align: dropdown::MenuAlign) -> Self {
self.menu_align = align;
self
}
/// Configura la posición del menú.
#[builder_fn]
pub fn with_menu_position(mut self, position: dropdown::MenuPosition) -> Self {
self.menu_position = position;
self
}
/// Añade un nuevo elemento al menú o modifica la lista de elementos del menú con una operación
/// [`ChildOp`].
///
/// # Ejemplo
///
/// ```rust,ignore
/// dropdown.with_item(dropdown::Item::link("Opción", "/ruta"));
/// dropdown.with_item(ChildOp::AddMany(vec![
/// dropdown::Item::link(...).into(),
/// dropdown::Item::divider().into(),
/// dropdown::Item::link(...).into(),
/// ]));
/// ```
#[builder_fn]
pub fn with_item(mut self, op: impl Into<ChildOp>) -> Self {
self.items.alter_child(op.into());
self
}
}

View file

@ -1,276 +0,0 @@
use pagetop::prelude::*;
// **< ItemKind >***********************************************************************************
/// Tipos de [`dropdown::Item`](crate::theme::dropdown::Item) disponibles en un menú desplegable
/// [`Dropdown`](crate::theme::Dropdown).
///
/// 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(L10n),
/// Elemento de navegación basado en una [`RoutePath`] dinámica devuelta por
/// [`FnPathByContext`]. Opcionalmente, puede abrirse en una nueva ventana y estar inicialmente
/// deshabilitado.
Link {
label: L10n,
route: FnPathByContext,
blank: bool,
disabled: bool,
},
/// Acción ejecutable en la propia página, sin navegación asociada. Inicialmente puede estar
/// deshabilitado.
Button { label: L10n, disabled: bool },
/// Título o encabezado que separa grupos de opciones.
Header(L10n),
/// Separador visual entre bloques de elementos.
Divider,
}
// **< Item >***************************************************************************************
/// Representa un **elemento individual** de un menú desplegable
/// [`Dropdown`](crate::theme::Dropdown).
///
/// Cada instancia de [`dropdown::Item`](crate::theme::dropdown::Item) se traduce en un componente
/// visible que puede comportarse como texto, enlace, botón, encabezado o separador, 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 {
#[getters(skip)]
id: AttrId,
/// Devuelve las clases CSS asociadas al elemento.
classes: Classes,
/// Devuelve el tipo de elemento representado.
item_kind: ItemKind,
}
impl Component for Item {
fn new() -> Self {
Self::default()
}
fn id(&self) -> Option<String> {
self.id.get()
}
fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
Ok(match self.item_kind() {
ItemKind::Void => html! {},
ItemKind::Label(label) => html! {
li id=[self.id()] class=[self.classes().get()] {
span class="dropdown-item-text" {
(label.using(cx))
}
}
},
ItemKind::Link {
label,
route,
blank,
disabled,
} => {
let route_link = route(cx);
let current_path = cx.request().map(|request| request.path());
let is_current = !*disabled && (current_path == Some(route_link.path()));
let mut classes = "dropdown-item".to_string();
if is_current {
classes.push_str(" active");
}
if *disabled {
classes.push_str(" disabled");
}
let href = (!*disabled).then_some(route_link);
let target = (!*disabled && *blank).then_some("_blank");
let rel = (!*disabled && *blank).then_some("noopener noreferrer");
let aria_current = (href.is_some() && is_current).then_some("page");
let aria_disabled = disabled.then_some("true");
let tabindex = disabled.then_some("-1");
html! {
li id=[self.id()] class=[self.classes().get()] {
a
class=(classes)
href=[href]
target=[target]
rel=[rel]
aria-current=[aria_current]
aria-disabled=[aria_disabled]
tabindex=[tabindex]
{
(label.using(cx))
}
}
}
}
ItemKind::Button { label, disabled } => {
let mut classes = "dropdown-item".to_string();
if *disabled {
classes.push_str(" disabled");
}
let aria_disabled = disabled.then_some("true");
let disabled_attr = disabled.then_some("disabled");
html! {
li id=[self.id()] class=[self.classes().get()] {
button
class=(classes)
type="button"
aria-disabled=[aria_disabled]
disabled=[disabled_attr]
{
(label.using(cx))
}
}
}
}
ItemKind::Header(label) => html! {
li id=[self.id()] class=[self.classes().get()] {
h6 class="dropdown-header" {
(label.using(cx))
}
}
},
ItemKind::Divider => html! {
li id=[self.id()] class=[self.classes().get()] { hr class="dropdown-divider" {} }
},
})
}
}
impl Item {
/// Crea un elemento de tipo texto, mostrado sin interacción.
pub fn label(label: L10n) -> Self {
Self {
item_kind: ItemKind::Label(label),
..Default::default()
}
}
/// Crea un enlace para la navegación.
///
/// La ruta se obtiene invocando [`FnPathByContext`], 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: L10n, route: FnPathByContext) -> Self {
Self {
item_kind: ItemKind::Link {
label,
route,
blank: false,
disabled: false,
},
..Default::default()
}
}
/// Crea un enlace deshabilitado que no permite la interacción.
pub fn link_disabled(label: L10n, route: FnPathByContext) -> Self {
Self {
item_kind: ItemKind::Link {
label,
route,
blank: false,
disabled: true,
},
..Default::default()
}
}
/// Crea un enlace que se abre en una nueva ventana o pestaña.
pub fn link_blank(label: L10n, route: FnPathByContext) -> Self {
Self {
item_kind: ItemKind::Link {
label,
route,
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: L10n, route: FnPathByContext) -> Self {
Self {
item_kind: ItemKind::Link {
label,
route,
blank: true,
disabled: true,
},
..Default::default()
}
}
/// Crea un botón de acción local, sin navegación asociada.
pub fn button(label: L10n) -> Self {
Self {
item_kind: ItemKind::Button {
label,
disabled: false,
},
..Default::default()
}
}
/// Crea un botón deshabilitado.
pub fn button_disabled(label: L10n) -> Self {
Self {
item_kind: ItemKind::Button {
label,
disabled: true,
},
..Default::default()
}
}
/// Crea un encabezado para un grupo de elementos dentro del menú.
pub fn header(label: L10n) -> Self {
Self {
item_kind: ItemKind::Header(label),
..Default::default()
}
}
/// Crea un separador visual entre bloques de elementos.
pub fn divider() -> Self {
Self {
item_kind: ItemKind::Divider,
..Default::default()
}
}
// **< Item BUILDER >***************************************************************************
/// Establece el identificador único (`id`) del elemento.
#[builder_fn]
pub fn with_id(mut self, id: impl AsRef<str>) -> Self {
self.id.alter_id(id);
self
}
/// Modifica la lista de clases CSS aplicadas al elemento.
#[builder_fn]
pub fn with_classes(mut self, op: ClassesOp, classes: impl AsRef<str>) -> Self {
self.classes.alter_classes(op, classes);
self
}
}

View file

@ -1,225 +0,0 @@
use pagetop::prelude::*;
use crate::prelude::*;
// **< AutoClose >**********************************************************************************
/// Estrategia para el cierre automático de un menú [`Dropdown`].
///
/// Define cuándo se cierra el menú desplegado según la interacción del usuario.
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
pub enum AutoClose {
/// Comportamiento por defecto, se cierra con clics dentro y fuera del menú, o pulsando `Esc`.
#[default]
Default,
/// Sólo se cierra con clics dentro del menú.
ClickableInside,
/// Sólo se cierra con clics fuera del menú.
ClickableOutside,
/// Cierre manual, no se cierra con clics; sólo al pulsar nuevamente el botón del menú
/// (*toggle*), o pulsando `Esc`.
ManualClose,
}
impl AutoClose {
/// Devuelve el valor para `data-bs-auto-close`, o `None` si es el comportamiento por defecto.
#[rustfmt::skip]
#[inline]
pub(crate) const fn as_str(self) -> Option<&'static str> {
match self {
Self::Default => None,
Self::ClickableInside => Some("inside"),
Self::ClickableOutside => Some("outside"),
Self::ManualClose => Some("false"),
}
}
}
// **< Direction >**********************************************************************************
/// Dirección de despliegue de un menú [`Dropdown`].
///
/// Controla desde qué posición se muestra el menú respecto al botón.
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
pub enum Direction {
/// Comportamiento por defecto (despliega el menú hacia abajo desde la posición inicial,
/// respetando LTR/RTL).
#[default]
Default,
/// Centra horizontalmente el menú respecto al botón.
Centered,
/// Despliega el menú hacia arriba.
Dropup,
/// Despliega el menú hacia arriba y centrado.
DropupCentered,
/// Despliega el menú desde el lateral final, respetando LTR/RTL.
Dropend,
/// Despliega el menú desde el lateral inicial, respetando LTR/RTL.
Dropstart,
}
impl Direction {
/// Mapea la dirección teniendo en cuenta si se agrupa con otros menús [`Dropdown`].
#[rustfmt::skip ]
#[inline]
const fn as_str(self, grouped: bool) -> &'static str {
match self {
Self::Default if grouped => "",
Self::Default => "dropdown",
Self::Centered => "dropdown-center",
Self::Dropup => "dropup",
Self::DropupCentered => "dropup-center",
Self::Dropend => "dropend",
Self::Dropstart => "dropstart",
}
}
/// Añade la dirección de despliegue a la cadena de clases teniendo en cuenta si se agrupa con
/// otros menús [`Dropdown`].
#[inline]
pub(crate) fn push_class(self, classes: &mut String, grouped: bool) {
if grouped {
if !classes.is_empty() {
classes.push(' ');
}
classes.push_str("btn-group");
}
let class = self.as_str(grouped);
if !class.is_empty() {
if !classes.is_empty() {
classes.push(' ');
}
classes.push_str(class);
}
}
/// Devuelve la clase asociada a la dirección teniendo en cuenta si se agrupa con otros menús
/// [`Dropdown`], o `""` si no corresponde ninguna.
#[doc(hidden)]
pub fn class_with(self, grouped: bool) -> String {
let mut classes = String::new();
self.push_class(&mut classes, grouped);
classes
}
}
// **< MenuAlign >**********************************************************************************
/// Alineación horizontal del menú desplegable [`Dropdown`].
///
/// Permite alinear el menú al inicio o al final del botón (respetando LTR/RTL) y añadirle una
/// alineación diferente a partir de un punto de ruptura ([`BreakPoint`]).
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
pub enum MenuAlign {
/// Alineación al inicio (comportamiento por defecto).
#[default]
Start,
/// Alineación al inicio a partir del punto de ruptura indicado.
StartAt(BreakPoint),
/// Alineación al inicio por defecto, y al final a partir de un punto de ruptura válido.
StartAndEnd(BreakPoint),
/// Alineación al final.
End,
/// Alineación al final a partir del punto de ruptura indicado.
EndAt(BreakPoint),
/// Alineación al final por defecto, y al inicio a partir de un punto de ruptura válido.
EndAndStart(BreakPoint),
}
impl MenuAlign {
#[inline]
fn push_one(classes: &mut String, class: &str) {
if class.is_empty() {
return;
}
if !classes.is_empty() {
classes.push(' ');
}
classes.push_str(class);
}
/// Añade las clases de alineación a `classes` (sin incluir la base `dropdown-menu`).
#[inline]
pub(crate) fn push_class(self, classes: &mut String) {
match self {
// Alineación por defecto (start), no añade clases extra.
Self::Start => {}
// `dropdown-menu-{bp}-start`
Self::StartAt(bp) => {
let class = bp.class_with("dropdown-menu", "start");
Self::push_one(classes, &class);
}
// `dropdown-menu-start` + `dropdown-menu-{bp}-end`
Self::StartAndEnd(bp) => {
Self::push_one(classes, "dropdown-menu-start");
let bp_class = bp.class_with("dropdown-menu", "end");
Self::push_one(classes, &bp_class);
}
// `dropdown-menu-end`
Self::End => {
Self::push_one(classes, "dropdown-menu-end");
}
// `dropdown-menu-{bp}-end`
Self::EndAt(bp) => {
let class = bp.class_with("dropdown-menu", "end");
Self::push_one(classes, &class);
}
// `dropdown-menu-end` + `dropdown-menu-{bp}-start`
Self::EndAndStart(bp) => {
Self::push_one(classes, "dropdown-menu-end");
let bp_class = bp.class_with("dropdown-menu", "start");
Self::push_one(classes, &bp_class);
}
}
}
/* Devuelve las clases de alineación sin incluir `dropdown-menu` (reservado).
pub fn to_class(self) -> String {
let mut classes = String::new();
self.push_class(&mut classes);
classes
} */
}
// **< MenuPosition >*******************************************************************************
/// Posición relativa del menú desplegable [`Dropdown`].
///
/// Permite indicar un desplazamiento (*offset*) manual o referenciar al elemento padre para el
/// cálculo de la posición.
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
pub enum MenuPosition {
/// Posicionamiento automático por defecto.
#[default]
Default,
/// Desplazamiento manual en píxeles `(x, y)` aplicado al menú. Se admiten valores negativos.
Offset(i8, i8),
/// Posiciona el menú tomando como referencia el botón padre. Especialmente útil cuando
/// [`button_split()`](crate::theme::Dropdown::button_split) es `true`.
Parent,
}
impl MenuPosition {
/// Devuelve el valor para `data-bs-offset` o `None` si no aplica.
#[inline]
pub(crate) fn data_offset(self) -> Option<String> {
match self {
Self::Offset(x, y) => Some(format!("{x},{y}")),
_ => None,
}
}
/// Devuelve el valor para `data-bs-reference` o `None` si no aplica.
#[inline]
pub(crate) fn data_reference(self) -> Option<&'static str> {
match self {
Self::Parent => Some("parent"),
_ => None,
}
}
}

View file

@ -1,62 +0,0 @@
//! Definiciones para crear formularios ([`Form`]).
//!
//! # Ejemplo
//!
//! ```rust
//! use pagetop::prelude::*;
//! use pagetop_bootsier::prelude::*;
//!
//! let form_login = Form::new()
//! .with_id("login")
//! .with_action("/login")
//! .with_child(
//! form::input::Field::email()
//! .with_name("email")
//! .with_label(L10n::n("Email"))
//! .with_required(true),
//! )
//! .with_child(
//! form::input::Field::password()
//! .with_name("password")
//! .with_label(L10n::n("Password"))
//! .with_required(true),
//! )
//! .with_child(
//! form::Checkbox::check()
//! .with_name("remember")
//! .with_label(L10n::n("Remember me")),
//! )
//! .with_child(
//! Button::submit(L10n::n("Sign in"))
//! .with_color(ButtonColor::Background(Color::Primary)),
//! );
//! ```
mod props;
pub use props::{Autocomplete, AutofillField, CheckboxKind, Method};
mod component;
pub use component::Form;
mod fieldset;
pub use fieldset::Fieldset;
mod checkbox;
pub use checkbox::Checkbox;
pub mod check;
pub mod radio;
pub mod select;
pub mod input;
mod textarea;
pub use textarea::Textarea;
mod range;
pub use range::Range;
mod hidden;
pub use hidden::Hidden;

View file

@ -1,257 +0,0 @@
//! Definiciones para crear grupos de casillas de verificación (*check buttons*).
use pagetop::prelude::*;
// **< Item >***************************************************************************************
/// Casilla de verificación individual de un [`form::check::Field`](Field).
///
/// Representa cada casilla de un grupo de casillas de verificación, con una etiqueta localizable
/// visible. Puede marcarse como seleccionada o deshabilitada de forma independiente al resto.
///
/// El parámetro `name` de [`form::check::Item::new()`](Item::new) se combina con el `name` del
/// grupo para componer el atributo `name` de la casilla. Por ejemplo, si el grupo tiene
/// `name=interests` y el ítem se crea con `name=tech`, la casilla tendrá `name=interests_tech`.
///
/// # Ejemplo
///
/// ```rust
/// # use pagetop::prelude::*;
/// # use pagetop_bootsier::prelude::*;
/// let item = form::check::Item::new("apple", L10n::n("Apple")).with_checked(true);
/// ```
#[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Item {
/// Devuelve el nombre que se combina con el del grupo para componer el atributo `name`.
name: AttrValue,
/// Devuelve la etiqueta de la casilla.
label: L10n,
/// Devuelve si la casilla debe aparecer marcada por defecto.
checked: bool,
/// Devuelve si la casilla está deshabilitada.
disabled: bool,
}
impl Item {
/// Crea una nueva casilla con el nombre y la etiqueta indicados.
///
/// El parámetro `name` se combina con el del grupo para componer el atributo `name` de la
/// casilla.
pub fn new(name: impl AsRef<str>, label: L10n) -> Self {
Self {
name: AttrValue::new(name),
label,
checked: false,
disabled: false,
}
}
// **< Item BUILDER >***************************************************************************
/// Establece si la casilla debe aparecer marcada por defecto.
pub fn with_checked(mut self, checked: bool) -> Self {
self.checked = checked;
self
}
/// Establece si la casilla está deshabilitada.
pub fn with_disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
}
// **< Field >**************************************************************************************
/// Componente para crear un **grupo de casillas de verificación**.
///
/// Renderiza un conjunto de casillas de verificación donde, a diferencia de un grupo de botones
/// [`form::radio::Field`](crate::theme::form::radio::Field), cada casilla puede marcarse de forma
/// independiente.
///
/// Las casillas se añaden mediante [`with_item()`](Field::with_item) usando instancias de
/// [`form::check::Item`](Item). Si se activa el modo en línea con
/// [`with_inline()`](Field::with_inline), las casillas se disponen horizontalmente.
///
/// El atributo `name` de cada casilla se construye automáticamente combinando el `name` del grupo
/// y el `name` del [`form::check::Item`](Item) con un guion bajo. Por ejemplo, para el grupo con
/// `name=interests` y casillas con `name=art` y `name=tech`, se genera `name=interests_art` y
/// `name=interests_tech`.
///
/// # Ejemplo
///
/// ```rust
/// # use pagetop::prelude::*;
/// # use pagetop_bootsier::prelude::*;
/// let interests = form::check::Field::new()
/// .with_name("interests")
/// .with_label(L10n::n("Areas of interest"))
/// .with_item(form::check::Item::new("art", L10n::n("Art")))
/// .with_item(form::check::Item::new("tech", L10n::n("Technology")))
/// .with_item(form::check::Item::new("science", L10n::n("Science")).with_checked(true));
/// ```
///
/// Cada `name` debe ser único y válido como identificador de campo. Cuando el usuario marca una
/// casilla, el navegador envía algo como `interests_tech=true`; mientras que si no la marca, no
/// envía nada. En el servidor cada campo se deserializa como `bool` con `#[serde(default)]`:
///
/// ```rust,ignore
/// #[derive(serde::Deserialize)]
/// struct FormData {
/// #[serde(default)]
/// interests_art: bool,
/// #[serde(default)]
/// interests_tech: bool,
/// #[serde(default)]
/// interests_science: bool,
/// }
/// ```
#[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Field {
#[getters(skip)]
id: AttrId,
/// Devuelve las clases CSS del contenedor del grupo.
classes: Classes,
/// Devuelve el nombre base compartido por todas las casillas del grupo.
name: AttrName,
/// Devuelve la etiqueta del grupo.
label: Attr<L10n>,
/// Devuelve el texto de ayuda del grupo.
help_text: Attr<L10n>,
/// Devuelve las casillas del grupo.
items: Vec<Item>,
/// Devuelve si todo el grupo está deshabilitado.
disabled: bool,
/// Devuelve si las casillas se muestran en línea horizontalmente.
inline: bool,
}
impl Component for Field {
fn new() -> Self {
Self::default()
}
fn id(&self) -> Option<String> {
self.id.get()
}
fn setup(&mut self, _cx: &Context) {
self.alter_classes(ClassesOp::Prepend, "form-field form-field-checkboxes");
}
fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
let name = self
.name()
.get()
.unwrap_or_else(|| cx.required_id::<Self>(self.id(), 3));
let container_id = self.id().unwrap_or_else(|| util::join!("edit-", &name));
Ok(html! {
div id=(&container_id) class=[self.classes().get()] {
@if let Some(label) = self.label().lookup(cx) {
label class="form-label" { (label) }
}
@let item_classes = if *self.inline() {
"form-check form-check-inline"
} else {
"form-check"
};
@for (item, i) in self.items().iter().zip(1..) {
@let i = i.to_string();
@let item_id = util::join!(&container_id, "-check-", &i);
@let item_name = if let Some(item_name) = item.name().get() {
util::join!(&name, "_", &item_name)
} else {
util::join!(&name, "_", &i)
};
div class=(item_classes) {
input
type="checkbox"
id=(&item_id)
class="form-check-input"
name=(&item_name)
value="true"
checked[*item.checked()]
disabled[*item.disabled() || *self.disabled()];
label class="form-check-label" for=(&item_id) {
(item.label().using(cx))
}
}
}
@if let Some(description) = self.help_text().lookup(cx) {
div class="form-text" { (description) }
}
}
})
}
}
impl Field {
// **< Field BUILDER >**************************************************************************
/// Establece el identificador único (`id`) del grupo de casillas.
#[builder_fn]
pub fn with_id(mut self, id: impl AsRef<str>) -> Self {
self.id.alter_id(id);
self
}
/// Modifica la lista de clases CSS aplicadas al contenedor del grupo de casillas.
#[builder_fn]
pub fn with_classes(mut self, op: ClassesOp, classes: impl AsRef<str>) -> Self {
self.classes.alter_classes(op, classes);
self
}
/// Establece el nombre base para el grupo de casillas.
///
/// Se combina con el `name` de cada [`form::check::Item`](Item) para generar el atributo `name`
/// de cada casilla de verificación. Por ejemplo, con `name=interests` en el grupo y `name=tech`
/// en el ítem, se genera `name=interests_tech`.
///
/// Si se omite, se asigna un nombre generado automáticamente. Para deserializar los campos en
/// el servidor es recomendable establecer un `name` explícito.
#[builder_fn]
pub fn with_name(mut self, name: impl AsRef<str>) -> Self {
self.name.alter_name(name);
self
}
/// Establece o elimina la etiqueta visible del grupo (basta pasar `None` para quitarla).
#[builder_fn]
pub fn with_label(mut self, label: impl Into<Option<L10n>>) -> Self {
self.label.alter_opt(label.into());
self
}
/// Establece o elimina el texto de ayuda del grupo (basta pasar `None` para quitarlo).
#[builder_fn]
pub fn with_help_text(mut self, help_text: impl Into<Option<L10n>>) -> Self {
self.help_text.alter_opt(help_text.into());
self
}
/// Añade una casilla al grupo. Las casillas se muestran en el orden en que se añaden.
#[builder_fn]
pub fn with_item(mut self, item: Item) -> Self {
self.items.push(item);
self
}
/// Establece si todo el grupo está deshabilitado.
///
/// Cuando está activo, se combina con el estado `disabled` de cada [`Item`].
#[builder_fn]
pub fn with_disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
/// Establece si las casillas se muestran en línea horizontalmente.
///
/// Al activar este modo, se añade la clase `form-check-inline` al contenedor de cada casilla.
#[builder_fn]
pub fn with_inline(mut self, inline: bool) -> Self {
self.inline = inline;
self
}
}

View file

@ -1,232 +0,0 @@
use pagetop::prelude::*;
use crate::theme::form;
use crate::LOCALES_BOOTSIER;
/// Componente para crear una **casilla de verificación** o un **interruptor** (*toggle switch*).
///
/// Renderiza un control binario (marcado/no marcado) en dos variantes visuales, por defecto se
/// muestra como una casilla de verificación estándar, pero también puede renderizarse como un
/// interruptor de encendido/apagado ([`Checkbox::switch()`]).
///
/// Se puede mostrar en línea con otros controles usando [`with_inline()`](Checkbox::with_inline), o
/// justificar a la derecha del contenedor invirtiendo el orden de la etiqueta y el control usando
/// [`with_reverse()`](Checkbox::with_reverse).
///
/// # Ejemplo
///
/// ```rust
/// # use pagetop::prelude::*;
/// # use pagetop_bootsier::prelude::*;
/// let accept_terms = form::Checkbox::check() // También sirve new() o default().
/// .with_name("terms_accepted")
/// .with_label(L10n::n("I accept the terms and conditions"))
/// .with_required(true);
///
/// let notifications = form::Checkbox::switch()
/// .with_name("notifications_enabled")
/// .with_label(L10n::n("Receive email notifications"))
/// .with_checked(true);
/// ```
///
/// Cuando el control está activo, el navegador envía `name=true`; si no lo está, no envía nada.
/// En el servidor el campo se deserializa como `bool` con `#[serde(default)]`:
///
/// ```rust,ignore
/// #[derive(serde::Deserialize)]
/// struct FormData {
/// #[serde(default)]
/// terms_accepted: bool, // true = marcada, false = no marcada.
/// #[serde(default)]
/// notifications_enabled: bool, // true = activo, false = inactivo.
/// }
/// ```
#[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Checkbox {
#[getters(skip)]
id: AttrId,
/// Devuelve las clases CSS del contenedor del control.
classes: Classes,
/// Devuelve la variante visual del control.
checkbox_kind: form::CheckboxKind,
/// Devuelve el nombre del campo.
name: AttrName,
/// Devuelve la etiqueta del control.
label: Attr<L10n>,
/// Devuelve si el control debe estar marcado/activo por defecto.
checked: bool,
/// Devuelve si el control recibe el foco automáticamente al cargar la página.
autofocus: bool,
/// Devuelve si el campo es obligatorio.
required: bool,
/// Devuelve si el control está deshabilitado.
disabled: bool,
/// Devuelve si el control se muestra en línea con otros controles.
inline: bool,
/// Devuelve si el control y su etiqueta se justifican a la derecha del contenedor.
reverse: bool,
}
impl Component for Checkbox {
fn new() -> Self {
Self::default()
}
fn id(&self) -> Option<String> {
self.id.get()
}
fn setup(&mut self, _cx: &Context) {
let mut classes = "form-field form-check".to_string();
if *self.checkbox_kind() == form::CheckboxKind::Switch {
classes.push_str(" form-switch");
}
if *self.inline() {
classes.push_str(" form-check-inline");
}
if *self.reverse() {
classes.push_str(" form-check-reverse");
}
self.alter_classes(ClassesOp::Prepend, classes);
}
fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
let name = self
.name()
.get()
.unwrap_or_else(|| cx.required_id::<Self>(self.id(), 1));
let container_id = self.id().unwrap_or_else(|| util::join!("edit-", &name));
let checkbox_id = util::join!(&container_id, "-checkbox");
let is_switch = *self.checkbox_kind() == form::CheckboxKind::Switch;
Ok(html! {
div id=(&container_id) class=[self.classes().get()] {
input
type="checkbox"
role=[is_switch.then_some("switch")]
id=(&checkbox_id)
class="form-check-input"
name=(&name)
value="true"
checked[*self.checked()]
autofocus[*self.autofocus()]
required[*self.required()]
disabled[*self.disabled()];
@if let Some(label) = self.label().lookup(cx) {
label class="form-check-label" for=(&checkbox_id) {
(label)
@if *self.required() {
span
class="form-required"
title=(L10n::t("input_required", &LOCALES_BOOTSIER).using(cx))
{
"*"
}
}
}
}
}
})
}
}
impl Checkbox {
/// Crea una casilla de verificación estándar.
pub fn check() -> Self {
Self::default()
}
/// Crea un interruptor de encendido/apagado (*toggle switch*).
pub fn switch() -> Self {
Self {
checkbox_kind: form::CheckboxKind::Switch,
..Self::default()
}
}
// **< Checkbox BUILDER >***********************************************************************
/// Establece el identificador único (`id`) del control.
#[builder_fn]
pub fn with_id(mut self, id: impl AsRef<str>) -> Self {
self.id.alter_id(id);
self
}
/// Modifica la lista de clases CSS aplicadas al contenedor del control.
#[builder_fn]
pub fn with_classes(mut self, op: ClassesOp, classes: impl AsRef<str>) -> Self {
self.classes.alter_classes(op, classes);
self
}
/// Establece la variante visual del control.
#[builder_fn]
pub fn with_kind(mut self, kind: form::CheckboxKind) -> Self {
self.checkbox_kind = kind;
self
}
/// Establece el nombre del campo (atributo `name`).
///
/// Si se omite, se asigna un identificador generado automáticamente. Para deserializar el campo
/// en el servidor es recomendable establecer un `name` explícito.
#[builder_fn]
pub fn with_name(mut self, name: impl AsRef<str>) -> Self {
self.name.alter_name(name);
self
}
/// Establece o elimina la etiqueta visible del control (basta pasar `None` para quitarla).
#[builder_fn]
pub fn with_label(mut self, label: impl Into<Option<L10n>>) -> Self {
self.label.alter_opt(label.into());
self
}
/// Establece si el control debe aparecer marcado/activo por defecto.
#[builder_fn]
pub fn with_checked(mut self, checked: bool) -> Self {
self.checked = checked;
self
}
/// Establece si el control recibe el foco automáticamente al cargar la página.
#[builder_fn]
pub fn with_autofocus(mut self, autofocus: bool) -> Self {
self.autofocus = autofocus;
self
}
/// Establece si el campo es obligatorio.
#[builder_fn]
pub fn with_required(mut self, required: bool) -> Self {
self.required = required;
self
}
/// Establece si el control está deshabilitado.
#[builder_fn]
pub fn with_disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
/// Establece si el control se muestra en línea con otros controles.
///
/// Al activar este modo, se añade la clase `form-check-inline` al contenedor, lo que permite
/// alinear varios controles horizontalmente.
#[builder_fn]
pub fn with_inline(mut self, inline: bool) -> Self {
self.inline = inline;
self
}
/// Establece si el control y su etiqueta se justifican a la derecha del contenedor.
///
/// Al activar este modo, se añade la clase `form-check-reverse` al contenedor.
#[builder_fn]
pub fn with_reverse(mut self, reverse: bool) -> Self {
self.reverse = reverse;
self
}
}

View file

@ -1,127 +0,0 @@
use pagetop::prelude::*;
use crate::theme::form;
/// Componente para crear un **formulario**.
///
/// Este componente renderiza un `<form>` estándar con soporte para los atributos más habituales:
///
/// - `id`: identificador opcional del formulario.
/// - `classes`: clases CSS adicionales (p. ej. utilidades CSS).
/// - `action`: URL/ruta de destino para el envío.
/// - `method`: método usado por el formulario para el envío de los datos (ver explicaciones en
/// [`form::Method`](crate::theme::form::Method)).
/// - `accept-charset`: juego de caracteres aceptado (por defecto es `"UTF-8"`).
/// - `children`: contenido del formulario.
///
/// # Ejemplo
///
/// ```rust
/// # use pagetop::prelude::*;
/// # use pagetop_bootsier::prelude::*;
/// let search = Form::new()
/// .with_id("search")
/// .with_action("/search")
/// .with_method(form::Method::Get)
/// .with_child(form::input::Field::search().with_name("q"));
/// ```
#[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Form {
#[getters(skip)]
id: AttrId,
/// Devuelve las clases CSS del formulario.
classes: Classes,
/// Devuelve la URL/ruta de destino del formulario.
action: AttrValue,
/// Devuelve el método para enviar el formulario.
method: form::Method,
/// Devuelve el juego de caracteres aceptado por el formulario.
#[default(_code = "AttrValue::new(\"UTF-8\")")]
charset: AttrValue,
/// Devuelve la lista de componentes del formulario.
children: Children,
}
impl Component for Form {
fn new() -> Self {
Self::default()
}
fn id(&self) -> Option<String> {
self.id.get()
}
fn setup(&mut self, _cx: &Context) {
self.alter_classes(ClassesOp::Prepend, "form");
}
fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
let method = match self.method() {
form::Method::Post => Some("post"),
form::Method::Get => None,
};
Ok(html! {
form
id=[self.id()]
class=[self.classes().get()]
action=[self.action().get()]
method=[method]
accept-charset=[self.charset().get()]
{
(self.children().render(cx))
}
})
}
}
impl Form {
// **< Form BUILDER >***************************************************************************
/// Establece el identificador único (`id`) del formulario.
#[builder_fn]
pub fn with_id(mut self, id: impl AsRef<str>) -> Self {
self.id.alter_id(id);
self
}
/// Modifica la lista de clases CSS aplicadas al formulario.
#[builder_fn]
pub fn with_classes(mut self, op: ClassesOp, classes: impl AsRef<str>) -> Self {
self.classes.alter_classes(op, classes);
self
}
/// Establece la URL/ruta de destino del formulario.
#[builder_fn]
pub fn with_action(mut self, action: impl AsRef<str>) -> Self {
self.action.alter_str(action);
self
}
/// Establece el método para enviar el formulario.
///
/// - `GET`: el atributo `method` se omite.
/// - `POST`: se establece `method="post"`.
#[builder_fn]
pub fn with_method(mut self, method: form::Method) -> Self {
self.method = method;
self
}
/// Establece el juego de caracteres aceptado por el formulario.
///
/// Por defecto se utiliza `"UTF-8"`.
#[builder_fn]
pub fn with_charset(mut self, charset: impl AsRef<str>) -> Self {
self.charset.alter_str(charset);
self
}
/// Añade un nuevo componente al formulario o modifica la lista de componentes (`children`) con
/// una operación [`ChildOp`].
#[builder_fn]
pub fn with_child(mut self, op: impl Into<ChildOp>) -> Self {
self.children.alter_child(op.into());
self
}
}

View file

@ -1,116 +0,0 @@
use pagetop::prelude::*;
/// Componente para crear un **grupo de controles relacionados** en un formulario.
///
/// Renderiza un `<fieldset>` con una leyenda opcional que sirve de encabezado y una descripción
/// también opcional que aparece justo antes de los controles. Es un elemento semántico que mejora
/// la accesibilidad porque los lectores de pantalla anuncian la leyenda antes de leer cada control
/// del contenido.
///
/// Los componentes del grupo se añaden con [`with_child()`](Fieldset::with_child). Si no hay
/// contenido para renderizar, el `fieldset` no se genera. Si está deshabilitado, todos sus
/// controles hijos quedan deshabilitados automáticamente por el navegador.
///
/// # Ejemplo
///
/// ```rust
/// # use pagetop::prelude::*;
/// # use pagetop_bootsier::prelude::*;
/// let personal_data = form::Fieldset::new()
/// .with_legend(L10n::n("Personal data"))
/// .with_description(L10n::n("Enter your full name and contact email."))
/// .with_child(form::input::Field::text().with_name("name").with_label(L10n::n("Full name")))
/// .with_child(form::input::Field::email().with_name("email").with_label(L10n::n("Email")));
/// ```
#[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Fieldset {
#[getters(skip)]
id: AttrId,
/// Devuelve las clases CSS del `fieldset`.
classes: Classes,
/// Devuelve la leyenda del `fieldset`.
legend: Attr<L10n>,
/// Devuelve la descripción del `fieldset`.
description: Attr<L10n>,
/// Devuelve si el `fieldset` está deshabilitado.
disabled: bool,
/// Devuelve la lista de componentes del `fieldset`.
children: Children,
}
impl Component for Fieldset {
fn new() -> Self {
Self::default()
}
fn id(&self) -> Option<String> {
self.id.get()
}
fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
let children = self.children().render(cx);
if children.is_empty() {
return Ok(html! {});
}
Ok(html! {
fieldset id=[self.id()] class=[self.classes().get()] disabled[*self.disabled()] {
@if let Some(legend) = self.legend().lookup(cx) {
legend { (legend) }
}
@if let Some(description) = self.description().lookup(cx) {
p class="fieldset-description" { (description) }
}
(children)
}
})
}
}
impl Fieldset {
// **< Fieldset BUILDER >***********************************************************************
/// Establece el identificador único (`id`) del `fieldset` (grupo de controles).
#[builder_fn]
pub fn with_id(mut self, id: impl AsRef<str>) -> Self {
self.id.alter_id(id);
self
}
/// Modifica la lista de clases CSS aplicadas al `fieldset`.
#[builder_fn]
pub fn with_classes(mut self, op: ClassesOp, classes: impl AsRef<str>) -> Self {
self.classes.alter_classes(op, classes);
self
}
/// Establece o elimina la leyenda del `fieldset` (basta pasar `None` para quitarla).
#[builder_fn]
pub fn with_legend(mut self, legend: impl Into<Option<L10n>>) -> Self {
self.legend.alter_opt(legend.into());
self
}
/// Establece o elimina la descripción del `fieldset` (basta pasar `None` para quitarla).
#[builder_fn]
pub fn with_description(mut self, description: impl Into<Option<L10n>>) -> Self {
self.description.alter_opt(description.into());
self
}
/// Establece si el `fieldset` está deshabilitado.
#[builder_fn]
pub fn with_disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
/// Añade un nuevo componente al `fieldset`, o aplica una operación [`ChildOp`] sobre la lista
/// de componentes (`children`).
#[builder_fn]
pub fn with_child(mut self, op: impl Into<ChildOp>) -> Self {
self.children.alter_child(op.into());
self
}
}

View file

@ -1,72 +0,0 @@
use pagetop::prelude::*;
/// Componente para crear un **campo oculto** del formulario.
///
/// Renderiza un campo sin ningún marcado visible. Su valor se envía al servidor junto con el resto
/// del formulario, pero el usuario no puede verlo ni modificarlo.
///
/// Es útil para transportar datos de estado, tokens CSRF, identificadores o cualquier valor que
/// deba incluirse en el envío sin ser accesible al usuario.
///
/// # Ejemplo
///
/// ```rust
/// # use pagetop::prelude::*;
/// # use pagetop_bootsier::prelude::*;
/// let token = form::Hidden::new()
/// .with_name("csrf_token")
/// .with_value("a1b2c3d4e5");
/// ```
///
/// Al enviar el formulario el navegador transmite `name=valor`. En el servidor se deserializa
/// como `String`:
///
/// ```rust,ignore
/// #[derive(serde::Deserialize)]
/// struct FormData {
/// csrf_token: String,
/// }
/// ```
#[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Hidden {
/// Devuelve el nombre del campo oculto.
name: AttrName,
/// Devuelve el valor del campo oculto.
value: AttrValue,
}
impl Component for Hidden {
fn new() -> Self {
Self::default()
}
fn prepare(&self, _cx: &mut Context) -> Result<Markup, ComponentError> {
Ok(html! {
input
type="hidden"
name=[self.name().get()]
value=[self.value().get()];
})
}
}
impl Hidden {
// **< Hidden BUILDER >*************************************************************************
/// Establece el nombre del campo oculto (atributo `name`).
///
/// Sin él, el valor del campo no se transmite al servidor al enviar el formulario. Para
/// deserializar el campo en el servidor es recomendable establecer un `name` explícito.
#[builder_fn]
pub fn with_name(mut self, name: impl AsRef<str>) -> Self {
self.name.alter_name(name);
self
}
/// Establece el valor del campo oculto (atributo `value`).
#[builder_fn]
pub fn with_value(mut self, value: impl AsRef<str>) -> Self {
self.value.alter_str(value);
self
}
}

View file

@ -1,453 +0,0 @@
//! Definiciones para crear campos de texto de una línea.
use pagetop::prelude::*;
use crate::theme::form;
use crate::LOCALES_BOOTSIER;
use std::fmt;
// **< Kind >***************************************************************************************
/// Tipo de campo para un [`form::input::Field`].
///
/// Determina el tipo de entrada que acepta, así como el comportamiento del navegador al interactuar
/// con el campo. Implícitamente se aplica al crear el control: [`text()`](Field::text),
/// [`password()`](Field::password), [`search()`](Field::search), [`email()`](Field::email),
/// [`telephone()`](Field::telephone) o [`url()`](Field::url).
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
pub enum Kind {
/// Entrada de texto genérico (`type="text"`). Es el tipo por defecto.
#[default]
Text,
/// Entrada de una contraseña (`type="password"`). El contenido aparece enmascarado.
Password,
/// Campo de búsqueda (`type="search"`). Es un tipo semántico para los cuadros de búsqueda.
Search,
/// Entrada de un correo electrónico (`type="email"`). Permite validar el formato del correo.
Email,
/// Entrada de un teléfono (`type="tel"`). Activa el teclado de llamadas en móviles.
Telephone,
/// Entrada de una URL (`type="url"`). Comprueba que la entrada sea una URL bien formada.
Url,
}
impl fmt::Display for Kind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Kind::Text => "text",
Kind::Password => "password",
Kind::Search => "search",
Kind::Email => "email",
Kind::Telephone => "tel",
Kind::Url => "url",
})
}
}
// **< Mode >***************************************************************************************
/// Sugerencia para el teclado virtual de un [`form::input::Field`].
///
/// Indica al navegador qué tipo de teclado virtual mostrar en dispositivos móviles o táctiles al
/// editar el campo. A diferencia del atributo `type` ([`form::input::Kind`]), no restringe los
/// valores aceptados ni activa la validación del navegador; es sólo una sugerencia de presentación.
///
/// Se establece con [`form::input::Field::with_inputmode()`].
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Mode {
/// Suprime el teclado virtual. Útil en campos con teclado personalizado basado en JavaScript.
None,
/// Teclado de texto genérico.
Text,
/// Teclado decimal, con dígitos y separador decimal.
Decimal,
/// Teclado numérico, con sólo dígitos.
Numeric,
/// Teclado de teléfono, con dígitos y símbolos `+`, `*` y `#`.
Tel,
/// Teclado optimizado para búsquedas (puede incluir tecla de búsqueda).
Search,
/// Teclado optimizado para correo electrónico (incluye `@` y `.`).
Email,
/// Teclado optimizado para URL (incluye `/`, `.` y `.com`).
Url,
}
impl fmt::Display for Mode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Mode::None => "none",
Mode::Text => "text",
Mode::Decimal => "decimal",
Mode::Numeric => "numeric",
Mode::Tel => "tel",
Mode::Search => "search",
Mode::Email => "email",
Mode::Url => "url",
})
}
}
// **< Field >**************************************************************************************
/// Componente para crear un **campo de texto de una línea**.
///
/// Renderiza los tipos más habituales en formularios:
///
/// - [`form::input::Field::text()`]: campo de texto genérico (`type="text"`, por defecto).
/// - [`form::input::Field::password()`]: contraseña (`type="password"`).
/// - [`form::input::Field::search()`]: búsqueda (`type="search"`).
/// - [`form::input::Field::email()`]: correo electrónico (`type="email"`).
/// - [`form::input::Field::telephone()`]: teléfono (`type="tel"`).
/// - [`form::input::Field::url()`]: URL (`type="url"`).
///
/// # Ejemplo
///
/// ```rust
/// # use pagetop::prelude::*;
/// # use pagetop_bootsier::prelude::*;
/// let email = form::input::Field::email()
/// .with_name("email")
/// .with_label(L10n::n("Email address"))
/// .with_placeholder(L10n::n("user@example.com"))
/// .with_autocomplete(Some(form::Autocomplete::email()))
/// .with_required(true);
/// ```
///
/// Al enviar el formulario el navegador transmite `name=valor`. Un campo de texto siempre envía su
/// valor, incluso si está vacío. En el servidor se deserializa como `String`:
///
/// ```rust,ignore
/// #[derive(serde::Deserialize)]
/// struct FormData {
/// email: String, // Siempre presente; cadena vacía si el usuario no escribió nada.
/// }
/// ```
#[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Field {
#[getters(skip)]
id: AttrId,
/// Devuelve las clases CSS del contenedor del campo.
classes: Classes,
/// Devuelve el tipo de campo.
kind: Kind,
/// Devuelve el nombre del campo.
name: AttrName,
/// Devuelve el valor inicial del campo.
value: AttrValue,
/// Devuelve la etiqueta del campo.
label: Attr<L10n>,
/// Devuelve si la etiqueta se muestra flotante sobre el campo.
floating_label: bool,
/// Devuelve el texto de ayuda del campo.
help_text: Attr<L10n>,
/// Devuelve la longitud mínima permitida en caracteres.
minlength: Attr<u16>,
/// Devuelve la longitud máxima permitida en caracteres.
maxlength: Attr<u16>,
/// Devuelve el texto indicativo del campo.
placeholder: Attr<L10n>,
/// Devuelve la configuración de autocompletado del campo.
autocomplete: Attr<form::Autocomplete>,
/// Devuelve si el campo recibe el foco automáticamente al cargar la página.
autofocus: bool,
/// Devuelve si el campo es de sólo lectura.
readonly: bool,
/// Devuelve si el campo es obligatorio.
required: bool,
/// Devuelve si el campo está deshabilitado.
disabled: bool,
/// Devuelve si el campo se muestra como texto plano sin bordes ni fondo.
plaintext: bool,
/// Devuelve la sugerencia de teclado virtual para el campo.
inputmode: Attr<Mode>,
}
impl Component for Field {
fn new() -> Self {
Self::default()
}
fn id(&self) -> Option<String> {
self.id.get()
}
fn setup(&mut self, _cx: &Context) {
if *self.floating_label() {
self.alter_classes(ClassesOp::Prepend, "form-floating");
}
self.alter_classes(
ClassesOp::Prepend,
util::join!("form-field form-field-", self.kind().to_string()),
);
}
fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
let container_id = self
.id()
.or_else(|| self.name().get().map(|n| util::join!("edit-", n)));
let input_id = container_id.as_deref().map(|id| util::join!(id, "-input"));
let input_class = if *self.plaintext() {
"form-control-plaintext"
} else {
"form-control"
};
// La etiqueta flotante requiere el atributo `placeholder` para detectar cuándo el campo
// está vacío y animar la etiqueta; si no está definido, se fuerza `placeholder=""`.
let placeholder = if *self.floating_label() {
Some(self.placeholder().lookup(cx).unwrap_or_default())
} else {
self.placeholder().lookup(cx)
};
let label = match self.label().lookup(cx) {
Some(text) => html! {
label for=[input_id.as_deref()] class="form-label" {
(text)
@if *self.required() {
span
class="form-required"
title=(L10n::t("input_required", &LOCALES_BOOTSIER).using(cx))
{
"*"
}
}
}
},
None => html! {},
};
Ok(html! {
div id=[container_id.as_deref()] class=[self.classes().get()] {
@if !*self.floating_label() {
(label)
}
input
type=(self.kind())
id=[input_id.as_deref()]
class=(input_class)
name=[self.name().get()]
value=[self.value().get()]
minlength=[self.minlength().get()]
maxlength=[self.maxlength().get()]
placeholder=[placeholder]
inputmode=[self.inputmode().get()]
autocomplete=[self.autocomplete().get()]
autofocus[*self.autofocus()]
readonly[*self.readonly() || *self.plaintext()]
required[*self.required()]
disabled[*self.disabled()];
@if *self.floating_label() {
(label)
}
@if let Some(description) = self.help_text().lookup(cx) {
div class="form-text" { (description) }
}
}
})
}
}
impl Field {
/// Crea un campo de **texto genérico** (`type="text"`).
///
/// Es el tipo por defecto. Adecuado para nombres, apellidos, ciudades y cualquier entrada
/// textual sin restricciones de formato específicas.
pub fn text() -> Self {
Self::default()
}
/// Crea un campo de **contraseña** (`type="password"`).
///
/// El navegador oculta los caracteres introducidos. Se recomienda usar con
/// [`with_autocomplete()`](Self::with_autocomplete) para permitir autorrellenar con una
/// contraseña guardada o dejar al usuario recibir sugerencias o crear una nueva.
pub fn password() -> Self {
Self {
kind: Kind::Password,
..Default::default()
}
}
/// Crea un campo de **búsqueda** (`type="search"`).
///
/// Semánticamente equivalente a `text` pero optimizado para búsquedas: algunos
/// navegadores añaden un botón para borrar el contenido.
pub fn search() -> Self {
Self {
kind: Kind::Search,
..Default::default()
}
}
/// Crea un campo de **correo electrónico** (`type="email"`).
///
/// El navegador valida el formato de la dirección antes de enviar el formulario. En
/// dispositivos móviles muestra un teclado adaptado para introducir direcciones de correo.
pub fn email() -> Self {
Self {
kind: Kind::Email,
..Default::default()
}
}
/// Crea un campo de **teléfono** (`type="tel"`).
///
/// No impone ninguna restricción de formato (los formatos de teléfono varían por país), pero
/// en dispositivos móviles muestra el teclado numérico de llamadas.
pub fn telephone() -> Self {
Self {
kind: Kind::Telephone,
..Default::default()
}
}
/// Crea un campo de **URL** (`type="url"`).
///
/// El navegador valida que el valor sea una URL bien formada antes de enviar el formulario.
pub fn url() -> Self {
Self {
kind: Kind::Url,
..Default::default()
}
}
// **< Field BUILDER >**************************************************************************
/// Establece el identificador único (`id`) del contenedor del campo.
#[builder_fn]
pub fn with_id(mut self, id: impl AsRef<str>) -> Self {
self.id.alter_id(id);
self
}
/// Modifica la lista de clases CSS aplicadas al contenedor del campo.
#[builder_fn]
pub fn with_classes(mut self, op: ClassesOp, classes: impl AsRef<str>) -> Self {
self.classes.alter_classes(op, classes);
self
}
/// Establece el nombre del campo (atributo `name`).
///
/// Sin él, el valor del campo no se transmite al servidor al enviar el formulario. Para
/// deserializar el campo en el servidor es recomendable establecer un `name` explícito.
#[builder_fn]
pub fn with_name(mut self, name: impl AsRef<str>) -> Self {
self.name.alter_name(name);
self
}
/// Establece el valor inicial del campo.
#[builder_fn]
pub fn with_value(mut self, value: impl AsRef<str>) -> Self {
self.value.alter_str(value);
self
}
/// Establece o elimina la etiqueta visible del campo (basta pasar `None` para quitarla).
#[builder_fn]
pub fn with_label(mut self, label: impl Into<Option<L10n>>) -> Self {
self.label.alter_opt(label.into());
self
}
/// Establece si la etiqueta se muestra flotante sobre el campo.
///
/// Cuando está activo, la etiqueta se superpone al campo y asciende al enfocarlo o cuando tiene
/// contenido.
#[builder_fn]
pub fn with_floating_label(mut self, floating_label: bool) -> Self {
self.floating_label = floating_label;
self
}
/// Establece o elimina el texto de ayuda del campo (basta pasar `None` para quitarlo).
#[builder_fn]
pub fn with_help_text(mut self, help_text: impl Into<Option<L10n>>) -> Self {
self.help_text.alter_opt(help_text.into());
self
}
/// Establece la longitud mínima permitida en caracteres (`None` para no imponer mínimo).
#[builder_fn]
pub fn with_minlength(mut self, minlength: Option<u16>) -> Self {
self.minlength.alter_opt(minlength);
self
}
/// Establece la longitud máxima permitida en caracteres (`None` para no imponer límite).
#[builder_fn]
pub fn with_maxlength(mut self, maxlength: Option<u16>) -> Self {
self.maxlength.alter_opt(maxlength);
self
}
/// Establece o elimina el texto indicativo del campo (`None` para quitarlo).
///
/// Este texto aparece en el mismo campo y desaparece en cuanto el usuario empieza a escribir.
/// Al ser texto visible para el usuario se acepta [`L10n`] para poder localizarlo.
#[builder_fn]
pub fn with_placeholder(mut self, placeholder: impl Into<Option<L10n>>) -> Self {
self.placeholder.alter_opt(placeholder.into());
self
}
/// Establece la configuración de autocompletado del campo.
///
/// Usar los métodos de [`form::Autocomplete`] para los valores más habituales (p. ej.
/// [`Autocomplete::email()`](form::Autocomplete::email) o
/// [`Autocomplete::current_password()`](form::Autocomplete::current_password)).
#[builder_fn]
pub fn with_autocomplete(mut self, autocomplete: Option<form::Autocomplete>) -> Self {
self.autocomplete.alter_opt(autocomplete);
self
}
/// Establece si el campo recibe el foco automáticamente al cargar la página.
#[builder_fn]
pub fn with_autofocus(mut self, autofocus: bool) -> Self {
self.autofocus = autofocus;
self
}
/// Establece si el campo es de sólo lectura.
#[builder_fn]
pub fn with_readonly(mut self, readonly: bool) -> Self {
self.readonly = readonly;
self
}
/// Establece si el campo es obligatorio.
#[builder_fn]
pub fn with_required(mut self, required: bool) -> Self {
self.required = required;
self
}
/// Establece si el campo está deshabilitado.
#[builder_fn]
pub fn with_disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
/// Establece si el campo se muestra como texto plano (sin bordes ni fondo).
///
/// Útil para mostrar un valor no editable en pantalla que sí se envía al servidor con el
/// formulario.
#[builder_fn]
pub fn with_plaintext(mut self, plaintext: bool) -> Self {
self.plaintext = plaintext;
self
}
/// Establece el modo de entrada sugerido para el teclado virtual en dispositivos móviles.
///
/// A diferencia del atributo `type` ([`form::input::Kind`]), no restringe los valores aceptados
/// ni activa la validación del navegador; es sólo una sugerencia de presentación.
#[builder_fn]
pub fn with_inputmode(mut self, inputmode: Option<Mode>) -> Self {
self.inputmode.alter_opt(inputmode);
self
}
}

View file

@ -1,484 +0,0 @@
use pagetop::prelude::*;
use std::borrow::Cow;
use std::fmt;
// **< CheckboxKind >*******************************************************************************
/// Variante visual para [`form::Checkbox`](crate::theme::form::Checkbox) en un formulario.
///
/// Determina si el control se renderiza como una casilla de verificación estándar o como un
/// interruptor (*toggle switch*).
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
pub enum CheckboxKind {
/// Casilla de verificación estándar. Es el tipo por defecto.
#[default]
Check,
/// Interruptor de encendido/apagado.
Switch,
// TODO: Añadir variante `NativeSwitch` cuando el atributo `switch` de la propuesta WHATWG
// (https://github.com/whatwg/html/issues/9546) sea estándar y tenga soporte amplio. Safari ya
// lo soporta. También se añadiría el constructor `Checkbox::native_switch()`.
}
// **< Autocomplete / AutofillField >***************************************************************
/// Configuración para el autocompletado de controles en un formulario.
///
/// Indica al navegador si puede sugerir o rellenar automáticamente el valor del control usando
/// datos que el usuario haya introducido antes (credenciales guardadas, datos de contacto, etc.).
///
/// Lo habitual es usar uno de los **métodos predefinidos**, que generan el token canónico adecuado
/// para cada tipo de dato:
///
/// - Identidad y credenciales: [`username()`](Autocomplete::username),
/// [`email()`](Autocomplete::email), [`current_password()`](Autocomplete::current_password),
/// [`new_password()`](Autocomplete::new_password), [`otp()`](Autocomplete::otp).
/// - Token o tokens directos: [`token(field)`](Autocomplete::token) con una variante de
/// [`AutofillField`].
/// - Direcciones: [`shipping(field)`](Autocomplete::shipping),
/// [`billing(field)`](Autocomplete::billing).
/// - Datos de contacto: [`home(field)`](Autocomplete::home), [`work(field)`](Autocomplete::work),
/// [`mobile(field)`](Autocomplete::mobile), [`fax(field)`](Autocomplete::fax),
/// [`pager(field)`](Autocomplete::pager).
/// - Sección personalizada: [`section(name, field)`](Autocomplete::section).
///
/// Para activar o inhibir el autocompletado sin especificar el tipo de dato basta con usar las
/// variantes [`form::Autocomplete::On`](Autocomplete::On) o
/// [`form::Autocomplete::Off`](Autocomplete::Off). Para combinaciones no cubiertas por los métodos
/// anteriores, [`custom()`](Autocomplete::custom) acepta cualquier cadena ASCII válida.
///
/// # Ejemplo
///
/// ```rust
/// # use pagetop::prelude::*;
/// # use pagetop_bootsier::prelude::*;
/// // Correo electrónico con sugerencia semántica del navegador.
/// let ac = form::Autocomplete::email();
///
/// // Contraseña nueva en un formulario de registro.
/// let ac = form::Autocomplete::new_password();
///
/// // Teléfono de contacto del trabajo.
/// let ac = form::Autocomplete::work(form::AutofillField::Tel);
/// ```
#[derive(Clone, Debug, PartialEq)]
pub enum Autocomplete {
/// Genera `autocomplete="on"`.
On,
/// Genera `autocomplete="off"`.
Off,
/// Contiene el valor literal del atributo `autocomplete` tal como se enviará al navegador.
///
/// Debe contener un token o lista de tokens separados por espacios (p. ej. `"username"` o
/// `"username webauthn"`).
Custom(CowStr),
}
impl Autocomplete {
// --< Token >----------------------------------------------------------------------------------
/// Genera `autocomplete` a partir del token o tokens del [`AutofillField`] indicado.
#[inline]
pub fn token(field: AutofillField) -> Self {
Self::Custom(Cow::Borrowed(field.as_str()))
}
// --< Secciones >------------------------------------------------------------------------------
/// Construye `autocomplete` con un prefijo de sección y un token o tokens del
/// [`form::AutofillField`](AutofillField) indicado.
///
/// Genera `autocomplete="section-<name> <field>"`. Si `name` no es ASCII o contiene espacios,
/// se ignora la sección y se genera sólo el token indicado.
///
/// El prefijo `section-*` sirve para distinguir entre varios grupos del mismo tipo en una misma
/// página (p. ej. una dirección de envío y otra de facturación).
pub fn section(name: impl AsRef<str>, field: AutofillField) -> Self {
match util::normalize_ascii(name.as_ref()) {
Ok(n) if !n.as_ref().contains(' ') => {
Self::custom(util::join!("section-", n.as_ref(), " ", field.as_str()))
}
_ => Self::token(field),
}
}
// --< Comunes >--------------------------------------------------------------------------------
/// Genera `autocomplete="username"`.
pub fn username() -> Self {
Self::token(AutofillField::Username)
}
/// Genera `autocomplete="username webauthn"` (Passkeys / WebAuthn).
pub fn username_webauthn() -> Self {
Self::custom("username webauthn")
}
/// Genera `autocomplete="email"`.
pub fn email() -> Self {
Self::token(AutofillField::Email)
}
/// Genera `autocomplete="current-password"`.
pub fn current_password() -> Self {
Self::token(AutofillField::CurrentPassword)
}
/// Genera `autocomplete="current-password webauthn"` (Passkeys / WebAuthn).
pub fn current_password_webauthn() -> Self {
Self::custom("current-password webauthn")
}
/// Genera `autocomplete="new-password"`.
pub fn new_password() -> Self {
Self::token(AutofillField::NewPassword)
}
/// Genera `autocomplete="one-time-code"`.
pub fn otp() -> Self {
Self::token(AutofillField::OneTimeCode)
}
// --< Direcciones >----------------------------------------------------------------------------
/// Contexto de dirección de envío. Genera `autocomplete="shipping <field>"`.
pub fn shipping(field: AutofillField) -> Self {
Self::Custom(Cow::Owned(util::join!("shipping ", field.as_str())))
}
/// Contexto de dirección de facturación. Genera `autocomplete="billing <field>"`.
pub fn billing(field: AutofillField) -> Self {
Self::Custom(Cow::Owned(util::join!("billing ", field.as_str())))
}
// --< Contacto >-------------------------------------------------------------------------------
/// Detalle de contacto: `autocomplete="home <field>"`.
pub fn home(field: AutofillField) -> Self {
Self::Custom(Cow::Owned(util::join!("home ", field.as_str())))
}
/// Detalle de contacto: `autocomplete="work <field>"`.
pub fn work(field: AutofillField) -> Self {
Self::Custom(Cow::Owned(util::join!("work ", field.as_str())))
}
/// Detalle de contacto: `autocomplete="mobile <field>"`.
pub fn mobile(field: AutofillField) -> Self {
Self::Custom(Cow::Owned(util::join!("mobile ", field.as_str())))
}
/// Detalle de contacto: `autocomplete="fax <field>"`.
pub fn fax(field: AutofillField) -> Self {
Self::Custom(Cow::Owned(util::join!("fax ", field.as_str())))
}
/// Detalle de contacto: `autocomplete="pager <field>"`.
pub fn pager(field: AutofillField) -> Self {
Self::Custom(Cow::Owned(util::join!("pager ", field.as_str())))
}
// --< Tokens personalizados >------------------------------------------------------------------
/// Crea un valor de `autocomplete` a partir de una cadena de texto libre.
///
/// Normaliza la entrada recortando espacios extra, compactando separadores y convirtiendo a
/// minúsculas. Si el resultado es `"on"` u `"off"`, devuelve la variante correspondiente; si la
/// entrada contiene caracteres no ASCII o queda vacía tras normalizar, devuelve
/// [`form::Autocomplete::On`](Autocomplete::On).
///
/// Para los casos habituales se recomienda usar los métodos predefinidos de
/// [`form::Autocomplete`](Autocomplete).
pub fn custom(autocomplete: impl Into<CowStr>) -> Self {
let value: CowStr = autocomplete.into();
let raw = value.as_ref();
// Normaliza la entrada.
let Some(normalized) = util::normalize_ascii_or_empty(raw, "Autocomplete::custom") else {
return Self::On;
};
let autocomplete = normalized.as_ref();
// Identifica valores especiales.
if autocomplete == "on" {
return Self::On;
}
if autocomplete == "off" {
return Self::Off;
}
// Mantiene el `Cow` original si no cambia nada (no reserva espacio).
if autocomplete == raw {
return Self::Custom(value);
}
// En otro caso asigna espacio para la normalización.
Self::Custom(Cow::Owned(normalized.into_owned()))
}
}
impl fmt::Display for Autocomplete {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Autocomplete::On => f.write_str("on"),
Autocomplete::Off => f.write_str("off"),
Autocomplete::Custom(c) => f.write_str(c),
}
}
}
/// Tokens para el autocompletado de formularios con [`form::Autocomplete`](Autocomplete).
///
/// Representa los tokens de autorrelleno (*autofill field*) definidos por la
/// [especificación WHATWG](https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#autofill-field)
/// para el atributo `autocomplete`. Cada variante corresponde exactamente a un token canónico
/// de dicha especificación.
///
/// Los valores se usan en combinación con [`form::Autocomplete`](Autocomplete) para construir el
/// valor completo del atributo `autocomplete` de un control de formulario. Los métodos de
/// [`form::Autocomplete`](Autocomplete) como [`token()`](Autocomplete::token),
/// [`email()`](Autocomplete::email), [`shipping()`](Autocomplete::shipping) o
/// [`section()`](Autocomplete::section) aceptan variantes de `AutofillField` para generar el token
/// correspondiente.
///
/// # Ejemplo
///
/// ```rust
/// # use pagetop_bootsier::prelude::*;
/// let ac = form::Autocomplete::token(form::AutofillField::Username);
/// let ac = form::Autocomplete::shipping(form::AutofillField::StreetAddress);
/// let ac = form::Autocomplete::section("job", form::AutofillField::Email);
/// ```
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum AutofillField {
// --< Identidad / cuenta >---------------------------------------------------------------------
/// Nombre completo.
Name,
/// Tratamiento o título (p. ej. "Sr.", "Sra.", "Dra.").
HonorificPrefix,
/// Nombre de pila.
GivenName,
/// Nombre adicional (p. ej. segundo nombre).
AdditionalName,
/// Apellidos.
FamilyName,
/// Sufijo honorífico (p. ej. "Jr.", "PhD").
HonorificSuffix,
/// Apodo.
Nickname,
/// Identificador de usuario (login).
Username,
// --< Credenciales >---------------------------------------------------------------------------
/// Contraseña actual.
CurrentPassword,
/// Nueva contraseña.
NewPassword,
/// Código de un solo uso (OTP).
OneTimeCode,
// --< Organización >---------------------------------------------------------------------------
/// Cargo o título dentro de una organización.
OrganizationTitle,
/// Nombre de la organización.
Organization,
// --< Contacto >-------------------------------------------------------------------------------
/// Correo electrónico.
Email,
/// Teléfono.
Tel,
/// Prefijo/código de país del teléfono (incluye `+`).
TelCountryCode,
/// Teléfono sin el código de país.
TelNational,
/// Código de área (si aplica).
TelAreaCode,
/// Teléfono sin código de país ni de área.
TelLocal,
/// Prefijo local (primera parte tras el área).
TelLocalPrefix,
/// Sufijo local (segunda parte tras el área).
TelLocalSuffix,
/// Extensión interna.
TelExtension,
/// URL personal o de contacto.
Url,
/// Referencia de mensajería instantánea (URL).
Impp,
// --< Dirección >------------------------------------------------------------------------------
/// Dirección postal completa (una sola línea/textarea).
StreetAddress,
/// Línea 1 de dirección.
AddressLine1,
/// Línea 2 de dirección.
AddressLine2,
/// Línea 3 de dirección.
AddressLine3,
/// Nivel administrativo 4 (el más específico).
AddressLevel4,
/// Nivel administrativo 3.
AddressLevel3,
/// Nivel administrativo 2 (p. ej. ciudad/municipio).
AddressLevel2,
/// Nivel administrativo 1 (p. ej. provincia/estado).
AddressLevel1,
/// Código postal.
PostalCode,
/// País (el navegador rellena el código de país).
Country,
/// Nombre del país.
CountryName,
// --< Pago >-----------------------------------------------------------------------------------
/// Nombre del titular de la tarjeta.
CcName,
/// Nombre de pila del titular de la tarjeta.
CcGivenName,
/// Nombre adicional del titular de la tarjeta.
CcAdditionalName,
/// Apellidos del titular de la tarjeta.
CcFamilyName,
/// Número de tarjeta.
CcNumber,
/// Fecha de caducidad (completa).
CcExp,
/// Mes de caducidad.
CcExpMonth,
/// Año de caducidad.
CcExpYear,
/// Código de seguridad (CVC/CVV).
CcCsc,
/// Tipo de tarjeta (p. ej. visa/mastercard).
CcType,
// --< Transacción / preferencias >-------------------------------------------------------------
/// Moneda preferida para la transacción (código ISO 4217).
TransactionCurrency,
/// Cantidad de la transacción (número).
TransactionAmount,
/// Idioma preferido (BCP 47).
Language,
// --< Datos personales >-----------------------------------------------------------------------
/// Fecha de nacimiento completa.
Bday,
/// Día de nacimiento.
BdayDay,
/// Mes de nacimiento.
BdayMonth,
/// Año de nacimiento.
BdayYear,
/// Sexo (valor libre guardado por el navegador).
Sex,
/// Foto (URL o referencia guardada por el navegador).
Photo,
}
impl AutofillField {
/// Devuelve el token exacto definido por HTML para `autocomplete`.
pub(crate) fn as_str(&self) -> &'static str {
match self {
AutofillField::Name => "name",
AutofillField::HonorificPrefix => "honorific-prefix",
AutofillField::GivenName => "given-name",
AutofillField::AdditionalName => "additional-name",
AutofillField::FamilyName => "family-name",
AutofillField::HonorificSuffix => "honorific-suffix",
AutofillField::Nickname => "nickname",
AutofillField::Username => "username",
AutofillField::CurrentPassword => "current-password",
AutofillField::NewPassword => "new-password",
AutofillField::OneTimeCode => "one-time-code",
AutofillField::OrganizationTitle => "organization-title",
AutofillField::Organization => "organization",
AutofillField::Email => "email",
AutofillField::Tel => "tel",
AutofillField::TelCountryCode => "tel-country-code",
AutofillField::TelNational => "tel-national",
AutofillField::TelAreaCode => "tel-area-code",
AutofillField::TelLocal => "tel-local",
AutofillField::TelLocalPrefix => "tel-local-prefix",
AutofillField::TelLocalSuffix => "tel-local-suffix",
AutofillField::TelExtension => "tel-extension",
AutofillField::Url => "url",
AutofillField::Impp => "impp",
AutofillField::StreetAddress => "street-address",
AutofillField::AddressLine1 => "address-line1",
AutofillField::AddressLine2 => "address-line2",
AutofillField::AddressLine3 => "address-line3",
AutofillField::AddressLevel4 => "address-level4",
AutofillField::AddressLevel3 => "address-level3",
AutofillField::AddressLevel2 => "address-level2",
AutofillField::AddressLevel1 => "address-level1",
AutofillField::PostalCode => "postal-code",
AutofillField::Country => "country",
AutofillField::CountryName => "country-name",
AutofillField::CcName => "cc-name",
AutofillField::CcGivenName => "cc-given-name",
AutofillField::CcAdditionalName => "cc-additional-name",
AutofillField::CcFamilyName => "cc-family-name",
AutofillField::CcNumber => "cc-number",
AutofillField::CcExp => "cc-exp",
AutofillField::CcExpMonth => "cc-exp-month",
AutofillField::CcExpYear => "cc-exp-year",
AutofillField::CcCsc => "cc-csc",
AutofillField::CcType => "cc-type",
AutofillField::TransactionCurrency => "transaction-currency",
AutofillField::TransactionAmount => "transaction-amount",
AutofillField::Language => "language",
AutofillField::Bday => "bday",
AutofillField::BdayDay => "bday-day",
AutofillField::BdayMonth => "bday-month",
AutofillField::BdayYear => "bday-year",
AutofillField::Sex => "sex",
AutofillField::Photo => "photo",
}
}
}
// **< Method >*************************************************************************************
/// Método HTTP usado por un formulario ([`Form`](crate::theme::Form)) para el envío de los datos.
///
/// En HTML, el atributo `method` del formulario indica **cómo** se envían los datos:
///
/// - **GET**: los pares `name=value` se codifican en la **URL** añadiendo una cadena de consulta
/// como `?a=1&b=2`. Es el método por defecto en HTML cuando no se especifica. Suele ser apropiado
/// para **búsquedas** o formularios que no modifican datos ni el estado del sistema.
///
/// - **POST**: los datos se envían en el **cuerpo** de la petición (*request body*). Es apropiado
/// para acciones que **modifican el estado** o cuando hay formularios grandes. Es el **método por
/// defecto** en PageTop.
///
/// # Consideraciones prácticas
///
/// - **Visibilidad y privacidad**: con GET los datos quedan visibles en la URL (historial, *logs*,
/// marcadores). No se recomienda para datos sensibles. Con POST no van en la URL, pero **no se
/// cifran** por sí mismos; por eso es esencial el uso de HTTPS.
/// - **Tamaño**: GET está limitado por la longitud máxima de URL que acepten el navegador y el
/// servidor. POST es más flexible para cargas grandes.
/// - **Ficheros**: la subida de ficheros requiere `method="post"` y un `enctype` adecuado
/// (habitualmente `multipart/form-data`).
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
pub enum Method {
/// Envía los datos en el cuerpo de la petición.
///
/// Es el **método por defecto** en PageTop. Recomendado para operaciones que modifican el
/// estado o para envíos grandes.
#[default]
Post,
/// Envía los datos en la URL como una cadena *query*.
///
/// Recomendado para búsquedas y operaciones que no modifican datos ni el estado del sistema.
Get,
}

View file

@ -1,269 +0,0 @@
//! Definiciones para crear grupos de botones de opción (*radio buttons*).
use pagetop::prelude::*;
use crate::LOCALES_BOOTSIER;
// **< Item >***************************************************************************************
/// Botón de opción individual de un [`form::radio::Field`](Field).
///
/// Representa cada opción de un grupo de opciones exclusivas entre sí, con un valor (el que se
/// envía al servidor), una etiqueta localizable visible y puede marcarse como seleccionada o
/// inicialmente deshabilitada de forma independiente.
///
/// # Ejemplo
///
/// ```rust
/// # use pagetop::prelude::*;
/// # use pagetop_bootsier::prelude::*;
/// let item = form::radio::Item::new("monthly", L10n::n("Monthly")).with_checked(true);
/// ```
#[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Item {
/// Devuelve el valor enviado al servidor cuando la opción está seleccionada.
value: AttrValue,
/// Devuelve la etiqueta de la opción.
label: L10n,
/// Devuelve si la opción debe aparecer seleccionada por defecto.
checked: bool,
/// Devuelve si la opción está deshabilitada.
disabled: bool,
}
impl Item {
/// Crea una nueva opción con el valor y la etiqueta indicados.
pub fn new(value: impl AsRef<str>, label: L10n) -> Self {
Self {
value: AttrValue::new(value),
label,
checked: false,
disabled: false,
}
}
// **< Item BUILDER >***************************************************************************
/// Establece si la opción aparece seleccionada por defecto.
///
/// Si varias opciones del grupo tienen `checked` activo, sólo la primera se renderizará como
/// seleccionada; las demás se ignorarán.
pub fn with_checked(mut self, checked: bool) -> Self {
self.checked = checked;
self
}
/// Establece si la opción está inicialmente deshabilitada.
pub fn with_disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
}
// **< Field >**************************************************************************************
/// Componente para crear un **grupo de botones de opción**.
///
/// Renderiza un grupo de botones de opción [`form::radio::Item`](Item) que comparten el mismo
/// atributo `name`, por lo que sólo puede seleccionarse uno a la vez. Las opciones se añaden con
/// [`with_item()`](Field::with_item).
///
/// Si se activa el modo en línea [`with_inline()`](Field::with_inline), los botones se disponen
/// horizontalmente. El atributo `required` se propaga a todos los botones del grupo para cumplir
/// con la especificación HTML.
///
/// # Ejemplo
///
/// ```rust
/// # use pagetop::prelude::*;
/// # use pagetop_bootsier::prelude::*;
/// let plan = form::radio::Field::new()
/// .with_name("plan")
/// .with_label(L10n::n("Subscription plan"))
/// .with_item(form::radio::Item::new("monthly", L10n::n("Monthly")))
/// .with_item(form::radio::Item::new("annual", L10n::n("Annual")).with_checked(true))
/// .with_required(true);
/// ```
///
/// Cuando el usuario selecciona un botón, el navegador envía algo como `plan=monthly`; si no
/// selecciona ninguno, no envía nada. En el servidor el campo se deserializa como `Option<String>`:
///
/// ```rust,ignore
/// #[derive(serde::Deserialize)]
/// struct FormData {
/// plan: Option<String>, // Some("monthly"), Some("annual"), ..., o None si no se seleccionó.
/// }
/// ```
#[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Field {
#[getters(skip)]
id: AttrId,
/// Devuelve las clases CSS del contenedor del grupo.
classes: Classes,
/// Devuelve el nombre compartido por todos los botones de opción del grupo.
name: AttrName,
/// Devuelve la etiqueta del grupo.
label: Attr<L10n>,
/// Devuelve el texto de ayuda del grupo.
help_text: Attr<L10n>,
/// Devuelve las opciones del grupo.
items: Vec<Item>,
/// Devuelve si la selección de alguna opción del grupo es obligatoria.
required: bool,
/// Devuelve si todo el grupo está deshabilitado.
disabled: bool,
/// Devuelve si los botones se muestran en línea horizontalmente.
inline: bool,
}
impl Component for Field {
fn new() -> Self {
Self::default()
}
fn id(&self) -> Option<String> {
self.id.get()
}
fn setup(&mut self, _cx: &Context) {
self.alter_classes(ClassesOp::Prepend, "form-field form-field-radios");
}
fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
let name = self
.name()
.get()
.unwrap_or_else(|| cx.required_id::<Self>(self.id(), 3));
let container_id = self.id().unwrap_or_else(|| util::join!("edit-", &name));
Ok(html! {
div id=(&container_id) class=[self.classes().get()] {
@if let Some(label) = self.label().lookup(cx) {
label class="form-label" {
(label)
@if *self.required() {
span
class="form-required"
title=(L10n::t("input_required", &LOCALES_BOOTSIER).using(cx))
{
"*"
}
}
}
}
@let item_classes = if *self.inline() {
"form-check form-check-inline"
} else {
"form-check"
};
@let mut do_check = true;
@for (item, i) in self.items().iter().zip(1..) {
@let checked = {
let c = *item.checked() && do_check;
if c { do_check = false; }
c
};
@let i = i.to_string();
@let item_id = util::join!(&container_id, "-radio-", &i);
div class=(item_classes) {
input
type="radio"
id=(&item_id)
class="form-check-input"
name=(&name)
value=[item.value().get()]
checked[checked]
required[*self.required()]
disabled[*item.disabled() || *self.disabled()];
label class="form-check-label" for=(&item_id) {
(item.label().using(cx))
}
}
}
@if let Some(description) = self.help_text().lookup(cx) {
div class="form-text" { (description) }
}
}
})
}
}
impl Field {
// **< Field BUILDER >**************************************************************************
/// Establece el identificador único (`id`) del grupo de opciones.
#[builder_fn]
pub fn with_id(mut self, id: impl AsRef<str>) -> Self {
self.id.alter_id(id);
self
}
/// Modifica la lista de clases CSS aplicadas al contenedor del grupo de opciones.
#[builder_fn]
pub fn with_classes(mut self, op: ClassesOp, classes: impl AsRef<str>) -> Self {
self.classes.alter_classes(op, classes);
self
}
/// Establece el nombre compartido por todos los botones de opción del grupo.
///
/// Todas las opciones [`form::radio::Item`](Item) del grupo llevarán este mismo `name`, lo que
/// garantiza la exclusividad de la selección. Es imprescindible establecer un `name`; sin él
/// los botones no se envían al servidor.
///
/// Si se omite, se asigna un nombre generado automáticamente. Para deserializar los campos en
/// el servidor es recomendable establecer un `name` explícito.
#[builder_fn]
pub fn with_name(mut self, name: impl AsRef<str>) -> Self {
self.name.alter_name(name);
self
}
/// Establece o elimina la etiqueta visible del grupo (basta pasar `None` para quitarla).
#[builder_fn]
pub fn with_label(mut self, label: impl Into<Option<L10n>>) -> Self {
self.label.alter_opt(label.into());
self
}
/// Establece o elimina el texto de ayuda del grupo (basta pasar `None` para quitarlo).
#[builder_fn]
pub fn with_help_text(mut self, help_text: impl Into<Option<L10n>>) -> Self {
self.help_text.alter_opt(help_text.into());
self
}
/// Añade una opción al grupo. Las opciones se muestran en el orden en que se añaden.
#[builder_fn]
pub fn with_item(mut self, item: Item) -> Self {
self.items.push(item);
self
}
/// Establece si la selección de alguna opción del grupo es obligatoria.
///
/// El atributo `required` se propaga a todos los botones del grupo para cumplir con la
/// especificación HTML.
#[builder_fn]
pub fn with_required(mut self, required: bool) -> Self {
self.required = required;
self
}
/// Establece si todo el grupo está deshabilitado.
///
/// Cuando está activo, se combina con el estado `disabled` de cada [`Item`].
#[builder_fn]
pub fn with_disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
/// Establece si los botones se muestran en línea horizontalmente.
///
/// Al activar este modo, se añade la clase `form-check-inline` al contenedor de cada opción.
#[builder_fn]
pub fn with_inline(mut self, inline: bool) -> Self {
self.inline = inline;
self
}
}

View file

@ -1,192 +0,0 @@
use pagetop::prelude::*;
/// Componente para crear un **control deslizante** de rango.
///
/// Renderiza una barra deslizante con una etiqueta opcional y un texto de ayuda. Permite
/// seleccionar un valor de entre una lista de valores posibles, acotados por un valor mínimo y
/// máximo, con un paso opcional entre valores.
///
/// # Ejemplo
///
/// ```rust
/// # use pagetop::prelude::*;
/// # use pagetop_bootsier::prelude::*;
/// let volume = form::Range::new()
/// .with_name("volume")
/// .with_label(L10n::n("Volume"))
/// .with_min(Some(0.0))
/// .with_max(Some(100.0))
/// .with_step(Some(5.0))
/// .with_value(Some(50.0));
/// ```
///
/// Al enviar el formulario el navegador transmite `name=valor`. Un control deslizante siempre
/// envía su valor. En el servidor se deserializa como `f64`:
///
/// ```rust,ignore
/// #[derive(serde::Deserialize)]
/// struct FormData {
/// volume: f64, // Siempre presente con el valor numérico seleccionado.
/// }
/// ```
#[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Range {
#[getters(skip)]
id: AttrId,
/// Devuelve las clases CSS del contenedor del control deslizante.
classes: Classes,
/// Devuelve el nombre del campo.
name: AttrName,
/// Devuelve la etiqueta del campo.
label: Attr<L10n>,
/// Devuelve el texto de ayuda del campo.
help_text: Attr<L10n>,
/// Devuelve el valor mínimo permitido.
min: Attr<f64>,
/// Devuelve el valor máximo permitido.
max: Attr<f64>,
/// Devuelve el incremento entre valores del campo.
step: Attr<f64>,
/// Devuelve el valor inicial del campo.
value: Attr<f64>,
/// Devuelve si el control recibe el foco automáticamente al cargar la página.
autofocus: bool,
/// Devuelve si el control está deshabilitado.
disabled: bool,
}
impl Component for Range {
fn new() -> Self {
Self::default()
}
fn id(&self) -> Option<String> {
self.id.get()
}
fn setup(&mut self, _cx: &Context) {
self.alter_classes(ClassesOp::Prepend, "form-field form-field-range");
}
fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
let container_id = self
.id()
.or_else(|| self.name().get().map(|n| util::join!("edit-", n)));
let range_id = container_id.as_deref().map(|id| util::join!(id, "-range"));
Ok(html! {
div id=[container_id.as_deref()] class=[self.classes().get()] {
@if let Some(label) = self.label().lookup(cx) {
label for=[range_id.as_deref()] class="form-label" { (label) }
}
input
type="range"
id=[range_id.as_deref()]
class="form-range"
name=[self.name().get()]
min=[self.min().get()]
max=[self.max().get()]
step=[self.step().get()]
value=[self.value().get()]
autofocus[*self.autofocus()]
disabled[*self.disabled()];
@if let Some(description) = self.help_text().lookup(cx) {
div class="form-text" { (description) }
}
}
})
}
}
impl Range {
// **< Range BUILDER >**************************************************************************
/// Establece el identificador único (`id`) del contenedor del control deslizante.
#[builder_fn]
pub fn with_id(mut self, id: impl AsRef<str>) -> Self {
self.id.alter_id(id);
self
}
/// Modifica la lista de clases CSS aplicadas al contenedor del control deslizante.
#[builder_fn]
pub fn with_classes(mut self, op: ClassesOp, classes: impl AsRef<str>) -> Self {
self.classes.alter_classes(op, classes);
self
}
/// Establece el nombre del campo (atributo `name`).
///
/// Sin él, el valor del campo no se transmite al servidor al enviar el formulario. Para
/// deserializar el campo en el servidor es recomendable establecer un `name` explícito.
#[builder_fn]
pub fn with_name(mut self, name: impl AsRef<str>) -> Self {
self.name.alter_name(name);
self
}
/// Establece o elimina la etiqueta visible del campo (basta pasar `None` para quitarla).
#[builder_fn]
pub fn with_label(mut self, label: impl Into<Option<L10n>>) -> Self {
self.label.alter_opt(label.into());
self
}
/// Establece o elimina el texto de ayuda del campo (basta pasar `None` para quitarlo).
#[builder_fn]
pub fn with_help_text(mut self, help_text: impl Into<Option<L10n>>) -> Self {
self.help_text.alter_opt(help_text.into());
self
}
/// Establece el valor mínimo del rango.
///
/// Pasar `None` omite el atributo `min` y deja que el navegador aplique su valor por defecto.
#[builder_fn]
pub fn with_min(mut self, min: Option<f64>) -> Self {
self.min.alter_opt(min);
self
}
/// Establece el valor máximo del rango.
///
/// Pasar `None` omite el atributo `max` y deja que el navegador aplique su valor por defecto.
#[builder_fn]
pub fn with_max(mut self, max: Option<f64>) -> Self {
self.max.alter_opt(max);
self
}
/// Establece el incremento entre valores del campo.
///
/// Pasar `None` omite el atributo `step` y deja que el navegador aplique su valor por defecto
/// (normalmente `1`).
#[builder_fn]
pub fn with_step(mut self, step: Option<f64>) -> Self {
self.step.alter_opt(step);
self
}
/// Establece el valor inicial del campo.
///
/// Pasar `None` omite el atributo `value` y deja que el navegador aplique su valor por defecto
/// (normalmente el punto medio del rango).
#[builder_fn]
pub fn with_value(mut self, value: Option<f64>) -> Self {
self.value.alter_opt(value);
self
}
/// Establece si el control recibe el foco automáticamente al cargar la página.
#[builder_fn]
pub fn with_autofocus(mut self, autofocus: bool) -> Self {
self.autofocus = autofocus;
self
}
/// Establece si el control está deshabilitado.
#[builder_fn]
pub fn with_disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
}

View file

@ -1,459 +0,0 @@
//! Definiciones para crear listas de selección.
use pagetop::prelude::*;
use crate::theme::form;
use crate::LOCALES_BOOTSIER;
// **< Item >***************************************************************************************
/// Elemento individual de [`form::select::Field`] o de [`form::select::Group`].
///
/// Representa un elemento dentro de una lista de selección o de un grupo de elementos de la lista.
/// Cada elemento tiene un valor que se envía al servidor y una etiqueta localizable visible para el
/// usuario.
///
/// Puede marcarse como seleccionado por defecto con [`with_selected()`](Self::with_selected) o
/// deshabilitado de forma independiente al resto usando [`with_disabled()`](Self::with_disabled).
///
/// # Ejemplo
///
/// ```rust
/// # use pagetop::prelude::*;
/// # use pagetop_bootsier::prelude::*;
/// let item = form::select::Item::new("es", L10n::n("Spanish")).with_selected(true);
/// ```
#[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Item {
/// Devuelve el valor enviado al servidor cuando se selecciona el elemento.
value: AttrValue,
/// Devuelve la etiqueta visible del elemento.
label: L10n,
/// Devuelve si el elemento debe aparecer seleccionado por defecto.
selected: bool,
/// Devuelve si el elemento está deshabilitado.
disabled: bool,
}
impl Item {
/// Crea un nuevo elemento con el valor y la etiqueta indicados.
pub fn new(value: impl AsRef<str>, label: L10n) -> Self {
Self {
value: AttrValue::new(value),
label,
selected: false,
disabled: false,
}
}
// **< Item BUILDER >***************************************************************************
/// Establece si el elemento aparece seleccionado por defecto.
///
/// En una lista de selección única, el navegador aplica la selección al último elemento marcado
/// si hay más de uno; mientras que en una lista múltiple se respetan todos los elementos
/// marcados.
pub fn with_selected(mut self, selected: bool) -> Self {
self.selected = selected;
self
}
/// Establece si el elemento está deshabilitado.
pub fn with_disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
}
// **< Group >**************************************************************************************
/// Grupo de elementos dentro de [`form::select::Field`].
///
/// Agrupa un conjunto de elementos dentro de una lista de selección con una etiqueta visible. El
/// grupo completo puede deshabilitarse en bloque con [`with_disabled()`](Self::with_disabled).
///
/// # Ejemplo
///
/// ```rust
/// # use pagetop::prelude::*;
/// # use pagetop_bootsier::prelude::*;
/// let group = form::select::Group::new(L10n::n("Europe"))
/// .with_item(form::select::Item::new("es", L10n::n("Spanish")))
/// .with_item(form::select::Item::new("fr", L10n::n("French")));
/// ```
#[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Group {
/// Devuelve la etiqueta visible del grupo de elementos.
label: L10n,
/// Devuelve los elementos del grupo.
items: Vec<Item>,
/// Devuelve si el grupo de elementos está deshabilitado.
disabled: bool,
}
impl Group {
/// Crea un nuevo grupo con la etiqueta indicada.
pub fn new(label: L10n) -> Self {
Self {
label,
..Self::default()
}
}
// **< Group BUILDER >**************************************************************************
/// Añade un elemento al grupo. Los elementos se muestran en el orden en que se añaden.
pub fn with_item(mut self, item: Item) -> Self {
self.items.push(item);
self
}
/// Establece si el grupo de elementos está deshabilitado en bloque.
pub fn with_disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
}
// **< Entry >**************************************************************************************
/// Entrada de [`form::select::Field`] con un elemento o un grupo de elementos.
///
/// Cada entrada se crea implícitamente cuando se usa [`form::select::Field::with_item()`] para
/// añadir un elemento individual o [`form::select::Field::with_group()`] para añadir un grupo de
/// elementos a una lista de selección.
///
/// Con [`form::select::Field::entries()`] se pueden recuperar todas las entradas para su
/// renderizado.
#[derive(Clone, Debug)]
pub enum Entry {
/// Elemento individual.
Item(Item),
/// Grupo de elementos.
Group(Group),
}
// **< Field >**************************************************************************************
/// Componente para crear una **lista de selección**.
///
/// Renderiza un campo para mostrar una lista de elementos con una etiqueta opcional. Permite elegir
/// uno, o más de uno si se activa la selección múltiple con
/// [`with_multiple()`](Self::with_multiple).
///
/// Los elementos individuales se añaden con [`with_item()`](Self::with_item); los grupos de
/// elementos con un encabezado común se añaden con [`with_group()`](Self::with_group). Ambos
/// métodos pueden combinarse libremente.
///
/// # Ejemplo
///
/// ```rust
/// # use pagetop::prelude::*;
/// # use pagetop_bootsier::prelude::*;
/// let idioma = form::select::Field::new()
/// .with_name("language")
/// .with_label(L10n::n("Language"))
/// .with_item(form::select::Item::new("", L10n::n("— Choose —")).with_selected(true))
/// .with_group(
/// form::select::Group::new(L10n::n("Europe"))
/// .with_item(form::select::Item::new("es", L10n::n("Spanish")))
/// .with_item(form::select::Item::new("fr", L10n::n("French"))),
/// )
/// .with_group(
/// form::select::Group::new(L10n::n("Americas"))
/// .with_item(form::select::Item::new("en", L10n::n("English")))
/// .with_item(form::select::Item::new("pt", L10n::n("Portuguese"))),
/// )
/// .with_required(true);
/// ```
///
/// Cuando el usuario selecciona un elemento y envía el formulario, el navegador transmite
/// `name=valor`. Si el campo es obligatorio el valor siempre estará presente y puede deserializarse
/// como `String`; si es opcional, usa `Option<String>`:
///
/// ```rust,ignore
/// #[derive(serde::Deserialize)]
/// struct FormData {
/// language: String, // Siempre presente (campo obligatorio).
/// // language: Option<String>, // None si no se selecciona ninguna opción.
/// }
/// ```
///
/// Con selección múltiple activa, el navegador envía un valor por cada elemento marcado; si no se
/// marca ninguno, no envía nada. Usa `Vec<String>` con `#[serde(default)]`:
///
/// ```rust,ignore
/// #[derive(serde::Deserialize)]
/// struct FormData {
/// #[serde(default)]
/// interests: Vec<String>, // p. ej. ["art", "tech"] o [] si no se marcó ninguna.
/// }
/// ```
#[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Field {
#[getters(skip)]
id: AttrId,
/// Devuelve las clases CSS del contenedor de la lista de selección.
classes: Classes,
/// Devuelve el nombre del campo.
name: AttrName,
/// Devuelve la etiqueta del campo.
label: Attr<L10n>,
/// Devuelve si la etiqueta se muestra flotante sobre el campo.
floating_label: bool,
/// Devuelve el texto de ayuda del campo.
help_text: Attr<L10n>,
/// Devuelve las entradas de la lista (elementos individuales y grupos de elementos).
entries: Vec<Entry>,
/// Devuelve si la lista permite selección múltiple.
multiple: bool,
/// Devuelve el número de filas visibles de la lista de selección.
rows: Attr<u16>,
/// Devuelve la configuración de autocompletado del campo.
autocomplete: Attr<form::Autocomplete>,
/// Devuelve si la lista recibe el foco automáticamente al cargar la página.
autofocus: bool,
/// Devuelve si la selección de un elemento es obligatoria.
required: bool,
/// Devuelve si la lista está deshabilitada.
disabled: bool,
}
impl Component for Field {
fn new() -> Self {
Self::default()
}
fn id(&self) -> Option<String> {
self.id.get()
}
fn setup(&mut self, _cx: &Context) {
if *self.floating_label() {
self.multiple = false;
self.rows.alter_opt(None::<u16>);
self.alter_classes(ClassesOp::Prepend, "form-floating");
}
self.alter_classes(ClassesOp::Prepend, "form-field form-field-select");
}
fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
let container_id = self
.id()
.or_else(|| self.name().get().map(|n| util::join!("edit-", n)));
let select_id = container_id.as_deref().map(|id| util::join!(id, "-select"));
let label = match self.label().lookup(cx) {
Some(text) => html! {
label for=[select_id.as_deref()] class="form-label" {
(text)
@if *self.required() {
span
class="form-required"
title=(L10n::t("input_required", &LOCALES_BOOTSIER).using(cx))
{
"*"
}
}
}
},
None => html! {},
};
Ok(html! {
div id=[container_id.as_deref()] class=[self.classes().get()] {
@if !*self.floating_label() {
(label)
}
select
id=[select_id.as_deref()]
class="form-select"
name=[self.name().get()]
multiple[*self.multiple()]
size=[self.rows().get()]
autocomplete=[self.autocomplete().get()]
autofocus[*self.autofocus()]
required[*self.required()]
disabled[*self.disabled()]
{
@for entry in self.entries() {
@match entry {
Entry::Item(opt) => {
option
value=(opt.value().as_str().unwrap_or(""))
selected[*opt.selected()]
disabled[*opt.disabled()]
{
(opt.label().using(cx))
}
}
Entry::Group(group) => {
optgroup
label=(group.label().using(cx))
disabled[*group.disabled()]
{
@for opt in group.items() {
option
value=(opt.value().as_str().unwrap_or(""))
selected[*opt.selected()]
disabled[*opt.disabled()]
{
(opt.label().using(cx))
}
}
}
}
}
}
}
@if *self.floating_label() {
(label)
}
@if let Some(description) = self.help_text().lookup(cx) {
div class="form-text" { (description) }
}
}
})
}
}
impl Field {
// **< Field BUILDER >***************************************************************************
/// Establece el identificador único (`id`) del contenedor del campo.
#[builder_fn]
pub fn with_id(mut self, id: impl AsRef<str>) -> Self {
self.id.alter_id(id);
self
}
/// Modifica la lista de clases CSS aplicadas al contenedor de la lista de selección.
#[builder_fn]
pub fn with_classes(mut self, op: ClassesOp, classes: impl AsRef<str>) -> Self {
self.classes.alter_classes(op, classes);
self
}
/// Establece el nombre del campo (atributo `name`).
///
/// Sin él, el valor seleccionado no se transmite al servidor al enviar el formulario. Para
/// deserializar el campo en el servidor es recomendable establecer un `name` explícito.
#[builder_fn]
pub fn with_name(mut self, name: impl AsRef<str>) -> Self {
self.name.alter_name(name);
self
}
/// Establece o elimina la etiqueta visible del campo (basta pasar `None` para quitarla).
#[builder_fn]
pub fn with_label(mut self, label: impl Into<Option<L10n>>) -> Self {
self.label.alter_opt(label.into());
self
}
/// Establece si la etiqueta se muestra flotante sobre el campo.
///
/// Cuando está activo, la etiqueta se superpone al control y permanece flotante siempre que
/// haya una opción visible.
///
/// Si se usa la etiqueta flotante, el [`setup()`](Self::setup) del componente anulará los
/// valores establecidos con [`with_multiple()`](Self::with_multiple) y
/// [`with_rows()`](Self::with_rows) antes del renderizado.
#[builder_fn]
pub fn with_floating_label(mut self, floating_label: bool) -> Self {
self.floating_label = floating_label;
self
}
/// Establece o elimina el texto de ayuda del campo (basta pasar `None` para quitarlo).
#[builder_fn]
pub fn with_help_text(mut self, help_text: impl Into<Option<L10n>>) -> Self {
self.help_text.alter_opt(help_text.into());
self
}
/// Añade un elemento individual a la lista de selección.
///
/// Los elementos y grupos se muestran en el orden en que se añaden.
#[builder_fn]
pub fn with_item(mut self, item: Item) -> Self {
self.entries.push(Entry::Item(item));
self
}
/// Añade un grupo de elementos a la lista de selección.
///
/// Los elementos y grupos se muestran en el orden en que se añaden.
#[builder_fn]
pub fn with_group(mut self, group: Group) -> Self {
self.entries.push(Entry::Group(group));
self
}
/// Establece si el control permite seleccionar varios elementos.
///
/// Al activar la selección múltiple, se muestra una lista en lugar de un desplegable. Se
/// recomienda combinar con [`with_rows()`](Self::with_rows) para controlar el número de filas
/// visibles.
///
/// Para un número reducido de elementos con etiquetas descriptivas considera usar
/// [`form::check::Field`] en su lugar, ofrece una presentación más clara y es más accesible en
/// pantallas pequeñas.
///
/// Se anula si se usa con [`with_floating_label(true)`](Self::with_floating_label).
#[builder_fn]
pub fn with_multiple(mut self, multiple: bool) -> Self {
self.multiple = multiple;
self
}
/// Establece el número de filas visibles de la lista de selección.
///
/// Cuando se establece un valor mayor que 1, el control se muestra como lista en lugar de
/// desplegable, tanto en modo simple como múltiple. Con `None` se omite el atributo y presenta
/// el control como desplegable (comportamiento por defecto).
///
/// Es especialmente útil con selección múltiple para controlar el número de filas visibles sin
/// necesidad de recurrir al desplazamiento.
///
/// Se anula si se usa con [`with_floating_label(true)`](Self::with_floating_label).
#[builder_fn]
pub fn with_rows(mut self, rows: Option<u16>) -> Self {
self.rows.alter_opt(rows);
self
}
/// Establece la configuración de autocompletado del campo.
///
/// Permite al navegador rellenar automáticamente el elemento seleccionado en listas de países
/// (`"country"`), idiomas (`"language"`), sexo (`"sex"`) u otros campos con valores
/// predefinidos. En listas de selección múltiples no es útil en la práctica, ya que los
/// navegadores no gestionan selecciones múltiples con autocompletado.
///
/// Usa los métodos de [`form::Autocomplete`] para los valores más habituales. Pasa `None` para
/// omitir el atributo.
#[builder_fn]
pub fn with_autocomplete(mut self, autocomplete: Option<form::Autocomplete>) -> Self {
self.autocomplete.alter_opt(autocomplete);
self
}
/// Establece si el campo recibe el foco automáticamente al cargar la página.
#[builder_fn]
pub fn with_autofocus(mut self, autofocus: bool) -> Self {
self.autofocus = autofocus;
self
}
/// Establece si el campo es obligatorio.
#[builder_fn]
pub fn with_required(mut self, required: bool) -> Self {
self.required = required;
self
}
/// Establece si el campo está deshabilitado.
#[builder_fn]
pub fn with_disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
}

View file

@ -1,290 +0,0 @@
use pagetop::prelude::*;
use crate::theme::form;
use crate::LOCALES_BOOTSIER;
/// Componente para crear un **área de texto** de formulario.
///
/// Permite escribir en un área de texto de más de una línea, con una etiqueta opcional y atributos
/// como el número de filas a presentar, longitud mínima (`minlength`) y máxima (`maxlength`), texto
/// indicativo (`placeholder`) o autocompletado (`autocomplete`).
///
/// # Ejemplo
///
/// ```rust
/// # use pagetop::prelude::*;
/// # use pagetop_bootsier::prelude::*;
/// let descripcion = form::Textarea::new()
/// .with_name("description")
/// .with_label(L10n::n("Description"))
/// .with_rows(Some(8))
/// .with_maxlength(Some(500))
/// .with_placeholder(L10n::n("Write here..."))
/// .with_required(true);
/// ```
///
/// Al enviar el formulario el navegador transmite `name=valor`. Un área de texto siempre envía su
/// valor, incluso si está vacía. En el servidor se deserializa como `String`:
///
/// ```rust,ignore
/// #[derive(serde::Deserialize)]
/// struct FormData {
/// description: String, // Siempre presente; cadena vacía si el usuario no escribió nada.
/// }
/// ```
#[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Textarea {
#[getters(skip)]
id: AttrId,
/// Devuelve las clases CSS del contenedor del área de texto.
classes: Classes,
/// Devuelve el nombre del campo.
name: AttrName,
/// Devuelve el valor inicial del área de texto.
value: AttrValue,
/// Devuelve la etiqueta del campo.
label: Attr<L10n>,
/// Devuelve si la etiqueta se muestra flotante sobre el campo.
floating_label: bool,
/// Devuelve el texto de ayuda del campo.
help_text: Attr<L10n>,
/// Devuelve el número de filas visibles del área de texto.
rows: Attr<u16>,
/// Devuelve la longitud mínima permitida en caracteres.
minlength: Attr<u16>,
/// Devuelve la longitud máxima permitida en caracteres.
maxlength: Attr<u16>,
/// Devuelve el texto indicativo del área de texto.
placeholder: Attr<L10n>,
/// Devuelve la configuración de autocompletado del campo.
autocomplete: Attr<form::Autocomplete>,
/// Devuelve si el campo recibe el foco automáticamente al cargar la página.
autofocus: bool,
/// Devuelve si el campo es de sólo lectura.
readonly: bool,
/// Devuelve si el campo es obligatorio.
required: bool,
/// Devuelve si el campo está deshabilitado.
disabled: bool,
}
impl Component for Textarea {
fn new() -> Self {
Self::default()
}
fn id(&self) -> Option<String> {
self.id.get()
}
fn setup(&mut self, _cx: &Context) {
if *self.floating_label() {
self.rows.alter_opt(None::<u16>);
self.alter_classes(ClassesOp::Prepend, "form-floating");
}
self.alter_classes(ClassesOp::Prepend, "form-field form-field-textarea");
}
fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
let container_id = self
.id()
.or_else(|| self.name().get().map(|n| util::join!("edit-", n)));
let textarea_id = container_id
.as_deref()
.map(|id| util::join!(id, "-textarea"));
// La etiqueta flotante requiere el atributo `placeholder` para detectar cuándo el campo
// está vacío y animar la etiqueta; si no está definido, se fuerza `placeholder=""`.
let placeholder = if *self.floating_label() {
Some(self.placeholder().lookup(cx).unwrap_or_default())
} else {
self.placeholder().lookup(cx)
};
let label = match self.label().lookup(cx) {
Some(text) => html! {
label for=[textarea_id.as_deref()] class="form-label" {
(text)
@if *self.required() {
span
class="form-required"
title=(L10n::t("input_required", &LOCALES_BOOTSIER).using(cx))
{
"*"
}
}
}
},
None => html! {},
};
Ok(html! {
div id=[container_id.as_deref()] class=[self.classes().get()] {
@if !*self.floating_label() {
(label)
}
textarea
id=[textarea_id.as_deref()]
class="form-control"
name=[self.name().get()]
rows=[self.rows().get()]
minlength=[self.minlength().get()]
maxlength=[self.maxlength().get()]
placeholder=[placeholder]
autocomplete=[self.autocomplete().get()]
autofocus[*self.autofocus()]
readonly[*self.readonly()]
required[*self.required()]
disabled[*self.disabled()]
{
@if let Some(value) = self.value().get() {
(value)
}
}
@if *self.floating_label() {
(label)
}
@if let Some(description) = self.help_text().lookup(cx) {
div class="form-text" { (description) }
}
}
})
}
}
impl Textarea {
// **< Textarea BUILDER >***********************************************************************
/// Establece el identificador único (`id`) del contenedor del campo.
#[builder_fn]
pub fn with_id(mut self, id: impl AsRef<str>) -> Self {
self.id.alter_id(id);
self
}
/// Modifica la lista de clases CSS aplicadas al contenedor del campo.
#[builder_fn]
pub fn with_classes(mut self, op: ClassesOp, classes: impl AsRef<str>) -> Self {
self.classes.alter_classes(op, classes);
self
}
/// Establece el nombre del campo (atributo `name`).
///
/// Sin él, el valor del campo no se transmite al servidor al enviar el formulario. Para
/// deserializar el campo en el servidor es recomendable establecer un `name` explícito.
#[builder_fn]
pub fn with_name(mut self, name: impl AsRef<str>) -> Self {
self.name.alter_name(name);
self
}
/// Establece el valor inicial del área de texto.
#[builder_fn]
pub fn with_value(mut self, value: impl AsRef<str>) -> Self {
self.value.alter_str(value);
self
}
/// Establece o elimina la etiqueta visible del campo (basta pasar `None` para quitarla).
#[builder_fn]
pub fn with_label(mut self, label: impl Into<Option<L10n>>) -> Self {
self.label.alter_opt(label.into());
self
}
/// Establece si la etiqueta se muestra flotante sobre el campo.
///
/// Cuando está activo, la etiqueta se superpone al área de texto y asciende al enfocarlo o
/// cuando tiene contenido.
///
/// Si se usa la etiqueta flotante, el [`setup()`](Self::setup) del componente anulará el valor
/// establecido con [`with_rows()`](Self::with_rows) antes del renderizado. Si es necesario, se
/// puede controlar la altura con estilos aplicados al componente.
#[builder_fn]
pub fn with_floating_label(mut self, floating_label: bool) -> Self {
self.floating_label = floating_label;
self
}
/// Establece o elimina el texto de ayuda del campo (basta pasar `None` para quitarlo).
#[builder_fn]
pub fn with_help_text(mut self, help_text: impl Into<Option<L10n>>) -> Self {
self.help_text.alter_opt(help_text.into());
self
}
/// Establece el número de filas visibles del área de texto.
///
/// Sin valor o pasando `None`, el área muestra su altura predeterminada, dos filas según el
/// estándar.
///
/// Se anula si se usa con [`with_floating_label(true)`](Self::with_floating_label).
#[builder_fn]
pub fn with_rows(mut self, rows: Option<u16>) -> Self {
self.rows.alter_opt(rows);
self
}
/// Establece la longitud mínima permitida en caracteres.
#[builder_fn]
pub fn with_minlength(mut self, minlength: Option<u16>) -> Self {
self.minlength.alter_opt(minlength);
self
}
/// Establece la longitud máxima permitida en caracteres.
#[builder_fn]
pub fn with_maxlength(mut self, maxlength: Option<u16>) -> Self {
self.maxlength.alter_opt(maxlength);
self
}
/// Establece o elimina el texto indicativo del área de texto (`None` para quitarlo).
///
/// Este texto aparece en el área de texto y desaparece en cuanto el usuario empieza a escribir.
/// Al ser texto visible para el usuario se acepta [`L10n`] para poder localizarlo.
#[builder_fn]
pub fn with_placeholder(mut self, placeholder: impl Into<Option<L10n>>) -> Self {
self.placeholder.alter_opt(placeholder.into());
self
}
/// Establece la configuración de autocompletado del campo.
///
/// Permite al navegador sugerir o rellenar automáticamente el contenido del área de texto
/// con valores guardados. Es especialmente útil en áreas con contenido semántico predefinido.
///
/// Usa los métodos de [`form::Autocomplete`] para los valores más habituales. Pasa `None` para
/// omitir el atributo.
#[builder_fn]
pub fn with_autocomplete(mut self, autocomplete: Option<form::Autocomplete>) -> Self {
self.autocomplete.alter_opt(autocomplete);
self
}
/// Establece si el campo recibe el foco automáticamente al cargar la página.
#[builder_fn]
pub fn with_autofocus(mut self, autofocus: bool) -> Self {
self.autofocus = autofocus;
self
}
/// Establece si el campo es de sólo lectura.
#[builder_fn]
pub fn with_readonly(mut self, readonly: bool) -> Self {
self.readonly = readonly;
self
}
/// Establece si el campo es obligatorio.
#[builder_fn]
pub fn with_required(mut self, required: bool) -> Self {
self.required = required;
self
}
/// Establece si el campo está deshabilitado.
#[builder_fn]
pub fn with_disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
}

View file

@ -1,119 +0,0 @@
use crate::prelude::*;
const DEFAULT_VIEWBOX: &str = "0 0 16 16";
#[derive(AutoDefault, Clone)]
pub enum IconKind {
#[default]
None,
Font(FontSize),
Svg {
shapes: Markup,
viewbox: AttrValue,
},
}
#[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Icon {
/// Devuelve las clases CSS asociadas al icono.
classes: Classes,
icon_kind: IconKind,
aria_label: AttrL10n,
}
impl Component for Icon {
fn new() -> Self {
Self::default()
}
fn setup(&mut self, _cx: &Context) {
if !matches!(self.icon_kind(), IconKind::None) {
self.alter_classes(ClassesOp::Prepend, "icon");
}
if let IconKind::Font(font_size) = self.icon_kind() {
self.alter_classes(ClassesOp::Add, font_size.as_str());
}
}
fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
Ok(match self.icon_kind() {
IconKind::None => html! {},
IconKind::Font(_) => {
let aria_label = self.aria_label().lookup(cx);
let has_label = aria_label.is_some();
html! {
i
class=[self.classes().get()]
role=[has_label.then_some("img")]
aria-label=[aria_label]
aria-hidden=[(!has_label).then_some("true")]
{}
}
}
IconKind::Svg { shapes, viewbox } => {
let aria_label = self.aria_label().lookup(cx);
let has_label = aria_label.is_some();
let viewbox = viewbox.get().unwrap_or_else(|| DEFAULT_VIEWBOX.to_string());
html! {
svg
xmlns="http://www.w3.org/2000/svg"
viewBox=(viewbox)
fill="currentColor"
focusable="false"
class=[self.classes().get()]
role=[has_label.then_some("img")]
aria-label=[aria_label]
aria-hidden=[(!has_label).then_some("true")]
{
(shapes)
}
}
}
})
}
}
impl Icon {
pub fn font() -> Self {
Self::default().with_icon_kind(IconKind::Font(FontSize::default()))
}
pub fn font_sized(font_size: FontSize) -> Self {
Self::default().with_icon_kind(IconKind::Font(font_size))
}
pub fn svg(shapes: Markup) -> Self {
Self::default().with_icon_kind(IconKind::Svg {
shapes,
viewbox: AttrValue::default(),
})
}
pub fn svg_with_viewbox(shapes: Markup, viewbox: impl AsRef<str>) -> Self {
Self::default().with_icon_kind(IconKind::Svg {
shapes,
viewbox: AttrValue::new(viewbox),
})
}
// **< Icon BUILDER >***************************************************************************
/// Modifica la lista de clases CSS aplicadas al icono.
#[builder_fn]
pub fn with_classes(mut self, op: ClassesOp, classes: impl AsRef<str>) -> Self {
self.classes.alter_value(op, classes);
self
}
#[builder_fn]
pub fn with_icon_kind(mut self, icon_kind: IconKind) -> Self {
self.icon_kind = icon_kind;
self
}
#[builder_fn]
pub fn with_aria_label(mut self, label: L10n) -> Self {
self.aria_label.alter_value(label);
self
}
}

View file

@ -1,7 +0,0 @@
//! Definiciones para renderizar imágenes ([`Image`]).
mod props;
pub use props::{Size, Source};
mod component;
pub use component::Image;

View file

@ -1,123 +0,0 @@
use pagetop::prelude::*;
use crate::prelude::*;
/// Componente para renderizar una **imagen**.
///
/// - Ajusta su disposición según el origen definido en [`image::Source`].
/// - Permite configurar **dimensiones** ([`with_size()`](Self::with_size)), **borde**
/// ([`classes::Border`](crate::theme::classes::Border)) y **redondeo de esquinas**
/// ([`classes::Rounded`](crate::theme::classes::Rounded)).
/// - Resuelve el texto alternativo `alt` con **localización** mediante [`L10n`].
#[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Image {
#[getters(skip)]
id: AttrId,
/// Devuelve las clases CSS asociadas a la imagen.
classes: Classes,
/// Devuelve las dimensiones de la imagen.
size: image::Size,
/// Devuelve el origen de la imagen.
source: image::Source,
/// Devuelve el texto alternativo localizado.
alternative: Attr<L10n>,
}
impl Component for Image {
fn new() -> Self {
Self::default()
}
fn id(&self) -> Option<String> {
self.id.get()
}
fn setup(&mut self, _cx: &Context) {
self.alter_classes(ClassesOp::Prepend, self.source().to_class());
}
fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
let dimensions = self.size().to_style();
let alt_text = self.alternative().lookup(cx).unwrap_or_default();
let is_decorative = alt_text.is_empty();
let source = match self.source() {
image::Source::Logo(logo) => {
return Ok(html! {
span
id=[self.id()]
class=[self.classes().get()]
style=[dimensions]
role=[(!is_decorative).then_some("img")]
aria-label=[(!is_decorative).then_some(alt_text)]
aria-hidden=[is_decorative.then_some("true")]
{
(logo.render(cx))
}
})
}
image::Source::Responsive(source) => Some(source),
image::Source::Thumbnail(source) => Some(source),
image::Source::Plain(source) => Some(source),
};
Ok(html! {
img
src=[source]
alt=(alt_text)
id=[self.id()]
class=[self.classes().get()]
style=[dimensions] {}
})
}
}
impl Image {
/// Crea rápidamente una imagen especificando su origen.
pub fn with(source: image::Source) -> Self {
Self::default().with_source(source)
}
// **< Image BUILDER >**************************************************************************
/// Establece el identificador único (`id`) de la imagen.
#[builder_fn]
pub fn with_id(mut self, id: impl AsRef<str>) -> Self {
self.id.alter_id(id);
self
}
/// Modifica la lista de clases CSS aplicadas a la imagen.
///
/// También acepta clases predefinidas para:
///
/// - Establecer bordes ([`classes::Border`]).
/// - Redondear las esquinas ([`classes::Rounded`]).
#[builder_fn]
pub fn with_classes(mut self, op: ClassesOp, classes: impl AsRef<str>) -> Self {
self.classes.alter_classes(op, classes);
self
}
/// Define las dimensiones de la imagen (auto, ancho/alto, ambos).
#[builder_fn]
pub fn with_size(mut self, size: image::Size) -> Self {
self.size = size;
self
}
/// Establece el origen de la imagen, influyendo en su disposición en el contenido.
#[builder_fn]
pub fn with_source(mut self, source: image::Source) -> Self {
self.source = source;
self
}
/// Define un *texto localizado* ([`L10n`]) alternativo para la imagen.
///
/// Se recomienda siempre aportar un texto alternativo salvo que la imagen sea puramente
/// decorativa.
#[builder_fn]
pub fn with_alternative(mut self, alt: L10n) -> Self {
self.alternative.alter_value(alt);
self
}
}

View file

@ -1,131 +0,0 @@
use pagetop::prelude::*;
// **< Size >***************************************************************************************
/// Define las **dimensiones** de una imagen ([`Image`](crate::theme::Image)).
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
pub enum Size {
/// Ajuste automático por defecto.
///
/// La imagen usa su tamaño natural o se ajusta al contenedor donde se publica.
#[default]
Auto,
/// Establece explícitamente el **ancho y alto** de la imagen.
///
/// Útil cuando se desea fijar ambas dimensiones de forma exacta. Ten en cuenta que la imagen
/// puede distorsionarse si no se mantiene la proporción original.
Dimensions(UnitValue, UnitValue),
/// Establece sólo el **ancho** de la imagen.
///
/// La altura se ajusta proporcionalmente de manera automática.
Width(UnitValue),
/// Establece sólo la **altura** de la imagen.
///
/// El ancho se ajusta proporcionalmente de manera automática.
Height(UnitValue),
/// Establece **el mismo valor** para el ancho y el alto de la imagen.
///
/// Práctico para forzar rápidamente un área cuadrada. Ten en cuenta que la imagen puede
/// distorsionarse si la original no es cuadrada.
Both(UnitValue),
}
impl Size {
/// Devuelve el valor del atributo `style` en función del tamaño, o `None` si no aplica.
#[inline]
pub(crate) fn to_style(self) -> Option<String> {
match self {
Self::Auto => None,
Self::Dimensions(w, h) => Some(format!("width: {w}; height: {h};")),
Self::Width(w) => Some(format!("width: {w};")),
Self::Height(h) => Some(format!("height: {h};")),
Self::Both(v) => Some(format!("width: {v}; height: {v};")),
}
}
}
// **< Source >*************************************************************************************
/// Especifica la **fuente** para publicar una imagen ([`Image`](crate::theme::Image)).
#[derive(AutoDefault, Clone, Debug, PartialEq)]
pub enum Source {
/// Imagen con el logotipo de PageTop.
#[default]
Logo(PageTopSvg),
/// Imagen que se adapta automáticamente a su contenedor.
///
/// Lleva asociada la URL (o ruta) de la imagen.
Responsive(CowStr),
/// Imagen que aplica el estilo **miniatura** de Bootstrap.
///
/// Lleva asociada la URL (o ruta) de la imagen.
Thumbnail(CowStr),
/// Imagen sin clases específicas de Bootstrap, útil para controlar con CSS propio.
///
/// Lleva asociada la URL (o ruta) de la imagen.
Plain(CowStr),
}
impl Source {
const IMG_FLUID: &str = "img-fluid";
const IMG_THUMBNAIL: &str = "img-thumbnail";
/// Imagen con el logotipo de PageTop.
#[inline]
pub fn logo(svg: PageTopSvg) -> Self {
Self::Logo(svg)
}
/// Imagen responsive (`img-fluid`).
#[inline]
pub fn responsive(url: impl Into<CowStr>) -> Self {
Self::Responsive(url.into())
}
/// Imagen miniatura (`img-thumbnail`).
#[inline]
pub fn thumbnail(url: impl Into<CowStr>) -> Self {
Self::Thumbnail(url.into())
}
/// Imagen sin clases adicionales.
#[inline]
pub fn plain(url: impl Into<CowStr>) -> Self {
Self::Plain(url.into())
}
/// Devuelve la clase base asociada a la imagen según la fuente.
#[inline]
fn as_str(&self) -> &'static str {
match self {
Source::Logo(_) | Source::Responsive(_) => Self::IMG_FLUID,
Source::Thumbnail(_) => Self::IMG_THUMBNAIL,
Source::Plain(_) => "",
}
}
/* Añade la clase base asociada a la imagen según la fuente a la cadena de clases (reservado).
#[inline]
pub(crate) fn push_class(&self, classes: &mut String) {
let s = self.as_str();
if s.is_empty() {
return;
}
if !classes.is_empty() {
classes.push(' ');
}
classes.push_str(s);
} */
/// Devuelve la clase asociada a la imagen según la fuente.
pub fn to_class(&self) -> String {
let s = self.as_str();
if s.is_empty() {
String::new()
} else {
let mut class = String::with_capacity(s.len());
class.push_str(s);
class
}
}
}

View file

@ -1,37 +0,0 @@
//! Definiciones para crear menús [`Nav`] o alguna de sus variantes de presentación.
//!
//! Cada [`nav::Item`](crate::theme::nav::Item) representa un elemento individual del menú [`Nav`],
//! con distintos comportamientos según su finalidad, como enlaces de navegación o menús
//! desplegables [`Dropdown`](crate::theme::Dropdown).
//!
//! Los ítems pueden estar activos, deshabilitados o abrirse en nueva ventana según su contexto y
//! configuración, y permiten incluir etiquetas localizables usando [`L10n`](pagetop::locale::L10n).
//!
//! # Ejemplo
//!
//! ```rust
//! # use pagetop::prelude::*;
//! # use pagetop_bootsier::prelude::*;
//! let nav = Nav::tabs()
//! .with_layout(nav::Layout::End)
//! .with_item(nav::Item::link(L10n::n("Home"), |_| "/".into()))
//! .with_item(nav::Item::link_blank(L10n::n("External"), |_| "https://docs.rs".into()))
//! .with_item(nav::Item::dropdown(
//! Dropdown::new()
//! .with_title(L10n::n("Options"))
//! .with_item(ChildOp::AddMany(vec![
//! dropdown::Item::link(L10n::n("Action"), |_| "/action".into()).into(),
//! dropdown::Item::link(L10n::n("Another"), |_| "/another".into()).into(),
//! ])),
//! ))
//! .with_item(nav::Item::link_disabled(L10n::n("Disabled"), |_| "#".into()));
//! ```
mod props;
pub use props::{Kind, Layout};
mod component;
pub use component::Nav;
mod item;
pub use item::{Item, ItemKind};

View file

@ -1,122 +0,0 @@
use pagetop::prelude::*;
use crate::prelude::*;
/// Componente para crear un **menú** o alguna de sus variantes ([`nav::Kind`]).
///
/// Presenta un menú con una lista de elementos usando una vista básica, o alguna de sus variantes
/// como *pestañas* (`Tabs`), *botones* (`Pills`) o *subrayado* (`Underline`). También permite
/// controlar su distribución y orientación ([`nav::Layout`](crate::theme::nav::Layout)).
///
/// Ver ejemplo en el módulo [`nav`].
/// Si no contiene elementos, el componente **no se renderiza**.
#[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Nav {
#[getters(skip)]
id: AttrId,
/// Devuelve las clases CSS asociadas al menú.
classes: Classes,
/// Devuelve el estilo visual seleccionado.
nav_kind: nav::Kind,
/// Devuelve la distribución y orientación seleccionada.
nav_layout: nav::Layout,
/// Devuelve la lista de elementos del menú.
items: Children,
}
impl Component for Nav {
fn new() -> Self {
Self::default()
}
fn id(&self) -> Option<String> {
self.id.get()
}
fn setup(&mut self, _cx: &Context) {
self.alter_classes(ClassesOp::Prepend, {
let mut classes = "nav".to_string();
self.nav_kind().push_class(&mut classes);
self.nav_layout().push_class(&mut classes);
classes
});
}
fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
let items = self.items().render(cx);
if items.is_empty() {
return Ok(html! {});
}
Ok(html! {
ul id=[self.id()] class=[self.classes().get()] {
(items)
}
})
}
}
impl Nav {
/// Crea un `Nav` usando pestañas para los elementos (*Tabs*).
pub fn tabs() -> Self {
Self::default().with_kind(nav::Kind::Tabs)
}
/// Crea un `Nav` usando botones para los elementos (*Pills*).
pub fn pills() -> Self {
Self::default().with_kind(nav::Kind::Pills)
}
/// Crea un `Nav` usando elementos subrayados (*Underline*).
pub fn underline() -> Self {
Self::default().with_kind(nav::Kind::Underline)
}
// **< Nav BUILDER >****************************************************************************
/// Establece el identificador único (`id`) del menú.
#[builder_fn]
pub fn with_id(mut self, id: impl AsRef<str>) -> Self {
self.id.alter_id(id);
self
}
/// Modifica la lista de clases CSS aplicadas al menú.
#[builder_fn]
pub fn with_classes(mut self, op: ClassesOp, classes: impl AsRef<str>) -> Self {
self.classes.alter_classes(op, classes);
self
}
/// Cambia el estilo del menú (*Tabs*, *Pills*, *Underline* o *Default*).
#[builder_fn]
pub fn with_kind(mut self, kind: nav::Kind) -> Self {
self.nav_kind = kind;
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
/// [`ChildOp`].
///
/// # Ejemplo
///
/// ```rust,ignore
/// nav.with_item(nav::Item::link("Inicio", "/"));
/// nav.with_item(ChildOp::AddMany(vec![
/// nav::Item::link(...).into(),
/// nav::Item::link_disabled(...).into(),
/// ]));
/// ```
#[builder_fn]
pub fn with_item(mut self, op: impl Into<ChildOp>) -> Self {
self.items.alter_child(op.into());
self
}
}

View file

@ -1,299 +0,0 @@
use pagetop::prelude::*;
use crate::prelude::*;
use crate::LOCALES_BOOTSIER;
// **< ItemKind >***********************************************************************************
/// Tipos de [`nav::Item`](crate::theme::nav::Item) disponibles en un menú
/// [`Nav`](crate::theme::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(L10n),
/// Elemento de navegación basado en una [`RoutePath`] dinámica devuelta por
/// [`FnPathByContext`]. Opcionalmente, puede abrirse en una nueva ventana y estar inicialmente
/// deshabilitado.
Link {
label: L10n,
route: FnPathByContext,
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`].
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]
const fn as_str(&self) -> &'static str {
match self {
Self::Void => "",
Self::Dropdown(_) => Self::DROPDOWN,
_ => Self::ITEM,
}
}
/* Añade las clases asociadas al tipo de elemento a la cadena de clases (reservado).
#[inline]
pub(crate) fn push_class(&self, classes: &mut String) {
let class = self.as_str();
if class.is_empty() {
return;
}
if !classes.is_empty() {
classes.push(' ');
}
classes.push_str(class);
} */
/// Devuelve las clases asociadas al tipo de elemento.
#[inline]
pub(crate) fn to_class(&self) -> String {
self.as_str().to_owned()
}
}
// **< Item >***************************************************************************************
/// Representa un **elemento individual** de un menú [`Nav`](crate::theme::Nav).
///
/// Cada instancia de [`nav::Item`](crate::theme::nav::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 {
#[getters(skip)]
id: AttrId,
/// Devuelve las clases CSS asociadas al elemento.
classes: Classes,
/// Devuelve el tipo de elemento representado.
item_kind: ItemKind,
}
impl Component for Item {
fn new() -> Self {
Self::default()
}
fn id(&self) -> Option<String> {
self.id.get()
}
fn setup(&mut self, _cx: &Context) {
self.alter_classes(ClassesOp::Prepend, self.item_kind().to_class());
}
fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
Ok(match self.item_kind() {
ItemKind::Void => html! {},
ItemKind::Label(label) => html! {
li id=[self.id()] class=[self.classes().get()] {
span class="nav-link disabled" aria-disabled="true" {
(label.using(cx))
}
}
},
ItemKind::Link {
label,
route,
blank,
disabled,
} => {
let route_link = route(cx);
let current_path = cx.request().map(|request| request.path());
let is_current = !*disabled && (current_path == Some(route_link.path()));
let mut classes = "nav-link".to_string();
if is_current {
classes.push_str(" active");
}
if *disabled {
classes.push_str(" disabled");
}
let href = (!*disabled).then_some(route_link);
let target = (!*disabled && *blank).then_some("_blank");
let rel = (!*disabled && *blank).then_some("noopener noreferrer");
let aria_current = (href.is_some() && is_current).then_some("page");
let aria_disabled = (*disabled).then_some("true");
html! {
li id=[self.id()] class=[self.classes().get()] {
a
class=(classes)
href=[href]
target=[target]
rel=[rel]
aria-current=[aria_current]
aria-disabled=[aria_disabled]
{
(label.using(cx))
}
}
}
}
ItemKind::Html(html) => html! {
li id=[self.id()] class=[self.classes().get()] {
(html.render(cx))
}
},
ItemKind::Dropdown(menu) => {
if let Some(dd) = menu.get() {
let items = dd.items().render(cx);
if items.is_empty() {
return Ok(html! {});
}
let title = dd.title().lookup(cx).unwrap_or_else(|| {
L10n::t("dropdown", &LOCALES_BOOTSIER)
.lookup(cx)
.unwrap_or_else(|| "Dropdown".to_string())
});
html! {
li id=[self.id()] class=[self.classes().get()] {
a
class="nav-link dropdown-toggle"
data-bs-toggle="dropdown"
href="#"
role="button"
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: L10n) -> Self {
Self {
item_kind: ItemKind::Label(label),
..Default::default()
}
}
/// Crea un enlace para la navegación.
///
/// La ruta se obtiene invocando [`FnPathByContext`], 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: L10n, route: FnPathByContext) -> Self {
Self {
item_kind: ItemKind::Link {
label,
route,
blank: false,
disabled: false,
},
..Default::default()
}
}
/// Crea un enlace deshabilitado que no permite la interacción.
pub fn link_disabled(label: L10n, route: FnPathByContext) -> Self {
Self {
item_kind: ItemKind::Link {
label,
route,
blank: false,
disabled: true,
},
..Default::default()
}
}
/// Crea un enlace que se abre en una nueva ventana o pestaña.
pub fn link_blank(label: L10n, route: FnPathByContext) -> Self {
Self {
item_kind: ItemKind::Link {
label,
route,
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: L10n, route: FnPathByContext) -> Self {
Self {
item_kind: ItemKind::Link {
label,
route,
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`].
///
/// Sólo se tienen en cuenta **el título** (si no existe, se asigna uno por defecto) y **la
/// lista de elementos** del [`Dropdown`]; el resto de propiedades del componente no afectarán
/// a su representación en [`Nav`].
pub fn dropdown(menu: Dropdown) -> Self {
Self {
item_kind: ItemKind::Dropdown(Embed::with(menu)),
..Default::default()
}
}
// **< Item BUILDER >***************************************************************************
/// Establece el identificador único (`id`) del elemento.
#[builder_fn]
pub fn with_id(mut self, id: impl AsRef<str>) -> Self {
self.id.alter_id(id);
self
}
/// Modifica la lista de clases CSS aplicadas al elemento.
#[builder_fn]
pub fn with_classes(mut self, op: ClassesOp, classes: impl AsRef<str>) -> Self {
self.classes.alter_classes(op, classes);
self
}
}

View file

@ -1,120 +0,0 @@
use pagetop::prelude::*;
// **< Kind >***************************************************************************************
/// Define la variante de presentación de un menú [`Nav`](crate::theme::Nav).
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
pub enum Kind {
/// Estilo por defecto, lista de enlaces flexible y minimalista.
#[default]
Default,
/// Pestañas con borde para cambiar entre secciones.
Tabs,
/// Botones con fondo que resaltan el elemento activo.
Pills,
/// Variante con subrayado del elemento activo, estética ligera.
Underline,
}
impl Kind {
const TABS: &str = "nav-tabs";
const PILLS: &str = "nav-pills";
const UNDERLINE: &str = "nav-underline";
/// Devuelve la clase base asociada al tipo de menú, o una cadena vacía si no aplica.
#[rustfmt::skip]
#[inline]
const fn as_str(self) -> &'static str {
match self {
Self::Default => "",
Self::Tabs => Self::TABS,
Self::Pills => Self::PILLS,
Self::Underline => Self::UNDERLINE,
}
}
/// Añade la clase asociada al tipo de menú a la cadena de clases.
#[inline]
pub(crate) fn push_class(self, classes: &mut String) {
let class = self.as_str();
if class.is_empty() {
return;
}
if !classes.is_empty() {
classes.push(' ');
}
classes.push_str(class);
}
/* Devuelve la clase asociada al tipo de menú, o una cadena vacía si no aplica (reservado).
#[inline]
pub(crate) fn to_class(self) -> String {
self.as_str().to_owned()
} */
}
// **< Layout >*************************************************************************************
/// Distribución y orientación de un menú [`Nav`](crate::theme::Nav).
#[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,
}
impl Layout {
const START: &str = "justify-content-start";
const CENTER: &str = "justify-content-center";
const END: &str = "justify-content-end";
const VERTICAL: &str = "flex-column";
const FILL: &str = "nav-fill";
const JUSTIFIED: &str = "nav-justified";
/// Devuelve la clase base asociada a la distribución y orientación del menú.
#[rustfmt::skip]
#[inline]
const fn as_str(self) -> &'static str {
match self {
Self::Default => "",
Self::Start => Self::START,
Self::Center => Self::CENTER,
Self::End => Self::END,
Self::Vertical => Self::VERTICAL,
Self::Fill => Self::FILL,
Self::Justified => Self::JUSTIFIED,
}
}
/// Añade la clase asociada a la distribución y orientación del menú a la cadena de clases.
#[inline]
pub(crate) fn push_class(self, classes: &mut String) {
let class = self.as_str();
if class.is_empty() {
return;
}
if !classes.is_empty() {
classes.push(' ');
}
classes.push_str(class);
}
/* Devuelve la clase asociada a la distribución y orientación del menú, o una cadena vacía si no
/// aplica (reservado).
#[inline]
pub(crate) fn to_class(self) -> String {
self.as_str().to_owned()
} */
}

View file

@ -1,140 +0,0 @@
//! Definiciones para crear barras de navegación [`Navbar`].
//!
//! Cada [`navbar::Item`](crate::theme::navbar::Item) representa un elemento individual de la barra
//! de navegación [`Navbar`], con distintos comportamientos según su finalidad, como menús
//! [`Nav`](crate::theme::Nav) o *textos localizados* usando [`L10n`](pagetop::locale::L10n).
//!
//! También puede mostrar una marca de identidad ([`navbar::Brand`](crate::theme::navbar::Brand))
//! que identifique la compañía, producto o nombre del proyecto asociado a la solución web.
//!
//! # Ejemplos
//!
//! Barra **simple**, sólo con un menú horizontal:
//!
//! ```rust
//! # use pagetop::prelude::*;
//! # use pagetop_bootsier::prelude::*;
//! let navbar = Navbar::simple()
//! .with_item(navbar::Item::nav(
//! Nav::new()
//! .with_item(nav::Item::link(L10n::n("Home"), |_| "/".into()))
//! .with_item(nav::Item::link(L10n::n("About"), |_| "/about".into()))
//! .with_item(nav::Item::link(L10n::n("Contact"), |_| "/contact".into()))
//! ));
//! ```
//!
//! Barra **colapsable**, con botón de despliegue y contenido en el desplegable cuando colapsa:
//!
//! ```rust
//! # use pagetop::prelude::*;
//! # use pagetop_bootsier::prelude::*;
//! let navbar = Navbar::simple_toggle()
//! .with_expand(BreakPoint::MD)
//! .with_item(navbar::Item::nav(
//! Nav::new()
//! .with_item(nav::Item::link(L10n::n("Home"), |_| "/".into()))
//! .with_item(nav::Item::link_blank(L10n::n("Docs"), |_| "https://docs.rs".into()))
//! .with_item(nav::Item::link(L10n::n("Support"), |_| "/support".into()))
//! ));
//! ```
//!
//! Barra con **marca de identidad a la izquierda** y menú a la derecha, típica de una cabecera:
//!
//! ```rust
//! # use pagetop::prelude::*;
//! # use pagetop_bootsier::prelude::*;
//! let brand = navbar::Brand::new()
//! .with_title(L10n::n("PageTop"))
//! .with_route(Some(|cx| cx.route("/")));
//!
//! let navbar = Navbar::brand_left(brand)
//! .with_item(navbar::Item::nav(
//! Nav::new()
//! .with_item(nav::Item::link(L10n::n("Home"), |_| "/".into()))
//! .with_item(nav::Item::dropdown(
//! Dropdown::new()
//! .with_title(L10n::n("Tools"))
//! .with_item(dropdown::Item::link(
//! L10n::n("Generator"), |_| "/tools/gen".into())
//! )
//! .with_item(dropdown::Item::link(
//! L10n::n("Reports"), |_| "/tools/reports".into())
//! )
//! ))
//! .with_item(nav::Item::link_disabled(L10n::n("Disabled"), |_| "#".into()))
//! ));
//! ```
//!
//! Barra con **botón de despliegue a la izquierda** y **marca de identidad a la derecha**:
//!
//! ```rust
//! # use pagetop::prelude::*;
//! # use pagetop_bootsier::prelude::*;
//! let brand = navbar::Brand::new()
//! .with_title(L10n::n("Intranet"))
//! .with_route(Some(|cx| cx.route("/")));
//!
//! let navbar = Navbar::brand_right(brand)
//! .with_expand(BreakPoint::LG)
//! .with_item(navbar::Item::nav(
//! Nav::pills()
//! .with_item(nav::Item::link(L10n::n("Dashboard"), |_| "/dashboard".into()))
//! .with_item(nav::Item::link(L10n::n("Users"), |_| "/users".into()))
//! ));
//! ```
//!
//! Barra con el **contenido en un *offcanvas***, ideal para dispositivos móviles o menús largos:
//!
//! ```rust
//! # use pagetop::prelude::*;
//! # use pagetop_bootsier::prelude::*;
//! let oc = Offcanvas::new()
//! .with_id("main_offcanvas")
//! .with_title(L10n::n("Main menu"))
//! .with_placement(offcanvas::Placement::Start)
//! .with_backdrop(offcanvas::Backdrop::Enabled);
//!
//! let navbar = Navbar::offcanvas(oc)
//! .with_item(navbar::Item::nav(
//! Nav::new()
//! .with_item(nav::Item::link(L10n::n("Home"), |_| "/".into()))
//! .with_item(nav::Item::link(L10n::n("Profile"), |_| "/profile".into()))
//! .with_item(nav::Item::dropdown(
//! Dropdown::new()
//! .with_title(L10n::n("More"))
//! .with_item(dropdown::Item::link(L10n::n("Settings"), |_| "/settings".into()))
//! .with_item(dropdown::Item::link(L10n::n("Help"), |_| "/help".into()))
//! ))
//! ));
//! ```
//!
//! Barra **fija arriba**:
//!
//! ```rust
//! # use pagetop::prelude::*;
//! # use pagetop_bootsier::prelude::*;
//! let brand = navbar::Brand::new()
//! .with_title(L10n::n("Main App"))
//! .with_route(Some(|cx| cx.route("/")));
//!
//! let navbar = Navbar::brand_left(brand)
//! .with_position(navbar::Position::FixedTop)
//! .with_item(navbar::Item::nav(
//! Nav::new()
//! .with_item(nav::Item::link(L10n::n("Dashboard"), |_| "/".into()))
//! .with_item(nav::Item::link(L10n::n("Donors"), |_| "/donors".into()))
//! .with_item(nav::Item::link(L10n::n("Stock"), |_| "/stock".into()))
//! ));
//! ```
mod props;
pub use props::{Layout, Position};
mod brand;
pub use brand::Brand;
mod component;
pub use component::Navbar;
mod item;
pub use item::Item;

View file

@ -1,93 +0,0 @@
use pagetop::prelude::*;
use crate::prelude::*;
/// Marca de identidad para mostrar en una barra de navegación [`Navbar`].
///
/// Representa la identidad del sitio con una imagen, título y eslogan:
///
/// - Si hay URL ([`with_route()`](Self::with_route)), el bloque completo actúa como enlace. Por
/// defecto enlaza a la raíz del sitio (`/`).
/// - Si no hay imagen ([`with_image()`](Self::with_image)) ni título
/// ([`with_title()`](Self::with_title)), la marca de identidad no se renderiza.
/// - El eslogan ([`with_slogan()`](Self::with_slogan)) es opcional; por defecto no tiene contenido.
#[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Brand {
#[getters(skip)]
id: AttrId,
/// Devuelve la imagen de marca (si la hay).
image: Embed<Image>,
/// Devuelve el título de la identidad de marca.
#[default(_code = "L10n::n(&global::SETTINGS.app.name)")]
title: L10n,
/// Devuelve el eslogan de la marca.
slogan: L10n,
/// Devuelve la función que resuelve la URL asociada a la marca (si existe).
#[default(_code = "Some(|cx| cx.route(\"/\"))")]
route: Option<FnPathByContext>,
}
impl Component for Brand {
fn new() -> Self {
Self::default()
}
fn id(&self) -> Option<String> {
self.id.get()
}
fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
let image = self.image().render(cx);
let title = self.title().using(cx);
if title.is_empty() && image.is_empty() {
return Ok(html! {});
}
let slogan = self.slogan().using(cx);
Ok(html! {
@if let Some(route) = self.route() {
a class="navbar-brand" href=(route(cx)) { (image) (title) (slogan) }
} @else {
span class="navbar-brand" { (image) (title) (slogan) }
}
})
}
}
impl Brand {
// **< Brand BUILDER >**************************************************************************
/// Establece el identificador único (`id`) de la marca.
#[builder_fn]
pub fn with_id(mut self, id: impl AsRef<str>) -> Self {
self.id.alter_id(id);
self
}
/// Asigna o quita la imagen de marca. Si se pasa `None`, no se mostrará.
#[builder_fn]
pub fn with_image(mut self, image: Option<Image>) -> Self {
self.image.alter_component(image);
self
}
/// Establece el título de la identidad de marca.
#[builder_fn]
pub fn with_title(mut self, title: L10n) -> Self {
self.title = title;
self
}
/// Define el eslogan de la marca.
#[builder_fn]
pub fn with_slogan(mut self, slogan: L10n) -> Self {
self.slogan = slogan;
self
}
/// Define la URL de destino. Si es `None`, la marca no será un enlace.
#[builder_fn]
pub fn with_route(mut self, route: Option<FnPathByContext>) -> Self {
self.route = route;
self
}
}

View file

@ -1,275 +0,0 @@
use pagetop::prelude::*;
use crate::prelude::*;
use crate::LOCALES_BOOTSIER;
const TOGGLE_COLLAPSE: &str = "collapse";
const TOGGLE_OFFCANVAS: &str = "offcanvas";
/// Componente para crear una **barra de navegación**.
///
/// Permite mostrar enlaces, menús y una marca de identidad en distintas disposiciones (simples, con
/// botón de despliegue o dentro de un [`offcanvas`]), controladas por [`navbar::Layout`]. También
/// puede fijarse en la parte superior o inferior del documento mediante [`navbar::Position`].
///
/// Ver ejemplos en el módulo [`navbar`].
/// Si no contiene elementos, el componente **no se renderiza**.
#[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Navbar {
#[getters(skip)]
id: AttrId,
/// Devuelve las clases CSS asociadas a la barra de navegación.
classes: Classes,
/// Devuelve el punto de ruptura configurado.
expand: BreakPoint,
/// Devuelve la disposición configurada para la barra de navegación.
layout: navbar::Layout,
/// Devuelve la posición configurada para la barra de navegación.
position: navbar::Position,
/// Devuelve la lista de contenidos.
items: Children,
}
impl Component for Navbar {
fn new() -> Self {
Self::default()
}
fn id(&self) -> Option<String> {
self.id.get()
}
fn setup(&mut self, _cx: &Context) {
self.alter_classes(ClassesOp::Prepend, {
let mut classes = "navbar".to_string();
self.expand().push_class(&mut classes, "navbar-expand", "");
self.position().push_class(&mut classes);
classes
});
}
fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
// Botón de despliegue (colapso u offcanvas) para la barra.
fn button(cx: &mut Context, data_bs_toggle: &str, id_content: &str) -> Markup {
let id_content_target = util::join!("#", id_content);
let aria_expanded = if data_bs_toggle == TOGGLE_COLLAPSE {
Some("false")
} else {
None
};
html! {
button
type="button"
class="navbar-toggler"
data-bs-toggle=(data_bs_toggle)
data-bs-target=(id_content_target)
aria-controls=(id_content)
aria-expanded=[aria_expanded]
aria-label=[L10n::t("toggle", &LOCALES_BOOTSIER).lookup(cx)]
{
span class="navbar-toggler-icon" {}
}
}
}
// Si no hay contenidos, no tiene sentido mostrar una barra vacía.
let items = self.items().render(cx);
if items.is_empty() {
return Ok(html! {});
}
// Asegura que la barra tiene un `id` para poder asociarlo al colapso/offcanvas.
let id = cx.required_id::<Self>(self.id(), 1);
Ok(html! {
nav id=(&id) class=[self.classes().get()] {
div class="container-fluid" {
@match self.layout() {
// Barra más sencilla: sólo contenido.
navbar::Layout::Simple => {
(items)
},
// Barra sencilla que se puede contraer/expandir.
navbar::Layout::SimpleToggle => {
@let id_content = util::join!(id, "-content");
(button(cx, TOGGLE_COLLAPSE, &id_content))
div id=(&id_content) class="collapse navbar-collapse" {
(items)
}
},
// Barra con marca a la izquierda, siempre visible.
navbar::Layout::SimpleBrandLeft(brand) => {
(brand.render(cx))
(items)
},
// Barra con marca a la izquierda y botón a la derecha.
navbar::Layout::BrandLeft(brand) => {
@let id_content = util::join!(id, "-content");
(brand.render(cx))
(button(cx, TOGGLE_COLLAPSE, &id_content))
div id=(&id_content) class="collapse navbar-collapse" {
(items)
}
},
// Barra con botón a la izquierda y marca a la derecha.
navbar::Layout::BrandRight(brand) => {
@let id_content = util::join!(id, "-content");
(button(cx, TOGGLE_COLLAPSE, &id_content))
(brand.render(cx))
div id=(&id_content) class="collapse navbar-collapse" {
(items)
}
},
// Barra cuyo contenido se muestra en un offcanvas, sin marca.
navbar::Layout::Offcanvas(offcanvas) => {
@let id_content = offcanvas.id().unwrap_or_default();
(button(cx, TOGGLE_OFFCANVAS, &id_content))
@if let Some(oc) = offcanvas.get() {
(oc.render_offcanvas(cx, Some(self.items())))
}
},
// Barra con marca a la izquierda y contenido en offcanvas.
navbar::Layout::OffcanvasBrandLeft(brand, offcanvas) => {
@let id_content = offcanvas.id().unwrap_or_default();
(brand.render(cx))
(button(cx, TOGGLE_OFFCANVAS, &id_content))
@if let Some(oc) = offcanvas.get() {
(oc.render_offcanvas(cx, Some(self.items())))
}
},
// Barra con contenido en offcanvas y marca a la derecha.
navbar::Layout::OffcanvasBrandRight(brand, offcanvas) => {
@let id_content = offcanvas.id().unwrap_or_default();
(button(cx, TOGGLE_OFFCANVAS, &id_content))
(brand.render(cx))
@if let Some(oc) = offcanvas.get() {
(oc.render_offcanvas(cx, Some(self.items())))
}
},
}
}
}
})
}
}
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 a la izquierda.
pub fn simple_toggle() -> Self {
Self::default().with_layout(navbar::Layout::SimpleToggle)
}
/// Crea una barra de navegación **con marca a la izquierda**, siempre visible.
pub fn simple_brand_left(brand: navbar::Brand) -> Self {
Self::default().with_layout(navbar::Layout::SimpleBrandLeft(Embed::with(brand)))
}
/// Crea una barra de navegación con **marca a la izquierda** y **botón a la derecha**.
pub fn brand_left(brand: navbar::Brand) -> Self {
Self::default().with_layout(navbar::Layout::BrandLeft(Embed::with(brand)))
}
/// Crea una barra de navegación con **botón a la izquierda** y **marca a la derecha**.
pub fn brand_right(brand: navbar::Brand) -> Self {
Self::default().with_layout(navbar::Layout::BrandRight(Embed::with(brand)))
}
/// Crea una barra de navegación cuyo contenido se muestra en un **offcanvas**.
pub fn offcanvas(oc: Offcanvas) -> Self {
Self::default().with_layout(navbar::Layout::Offcanvas(Embed::with(oc)))
}
/// Crea una barra de navegación con **marca a la izquierda** y contenido en **offcanvas**.
pub fn offcanvas_brand_left(brand: navbar::Brand, oc: Offcanvas) -> Self {
Self::default().with_layout(navbar::Layout::OffcanvasBrandLeft(
Embed::with(brand),
Embed::with(oc),
))
}
/// Crea una barra de navegación con **marca a la derecha** y contenido en **offcanvas**.
pub fn offcanvas_brand_right(brand: navbar::Brand, oc: Offcanvas) -> Self {
Self::default().with_layout(navbar::Layout::OffcanvasBrandRight(
Embed::with(brand),
Embed::with(oc),
))
}
// **< Navbar BUILDER >*************************************************************************
/// Establece el identificador único (`id`) de la barra de navegación.
#[builder_fn]
pub fn with_id(mut self, id: impl AsRef<str>) -> Self {
self.id.alter_id(id);
self
}
/// Modifica la lista de clases CSS aplicadas a la barra de navegación.
///
/// También acepta clases predefinidas para:
///
/// - Modificar el color de fondo ([`classes::Background`]).
/// - Definir la apariencia del texto ([`classes::Text`]).
#[builder_fn]
pub fn with_classes(mut self, op: ClassesOp, classes: impl AsRef<str>) -> Self {
self.classes.alter_classes(op, classes);
self
}
/// Define a partir de qué punto de ruptura la barra de navegación deja de colapsar.
#[builder_fn]
pub fn with_expand(mut self, bp: BreakPoint) -> Self {
self.expand = bp;
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
}
/// Define dónde se mostrará la barra de navegación dentro del documento.
#[builder_fn]
pub fn with_position(mut self, position: navbar::Position) -> Self {
self.position = position;
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 [`ChildOp`].
///
/// # Ejemplo
///
/// ```rust,ignore
/// navbar.with_item(navbar::Item::nav(...));
/// navbar.with_item(ChildOp::AddMany(vec![
/// navbar::Item::nav(...).into(),
/// navbar::Item::text(...).into(),
/// ]));
/// ```
#[builder_fn]
pub fn with_item(mut self, op: impl Into<ChildOp>) -> Self {
self.items.alter_child(op.into());
self
}
}

View file

@ -1,95 +0,0 @@
use pagetop::prelude::*;
use crate::prelude::*;
/// Elementos que puede contener una barra de navegación [`Navbar`](crate::theme::Navbar).
///
/// Cada variante determina qué se renderiza y cómo. Estos elementos se colocan **dentro del
/// contenido** de la barra (la parte colapsable, el *offcanvas* o el bloque simple), por lo que son
/// independientes de la marca o del botón que ya pueda definir el propio [`navbar::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`] no incluye marca, y se quiere incluir dentro del área
/// colapsable/*offcanvas*. Si el *layout* ya muestra una marca, esta variante no la sustituye,
/// sólo añade otra dentro del bloque de contenidos.
Brand(Embed<navbar::Brand>),
/// Representa un menú de navegación [`Nav`](crate::theme::Nav).
Nav(Embed<Nav>),
/// Representa un *texto localizado* libre.
Text(L10n),
}
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 {
if let Some(mut nav) = nav.get() {
nav.alter_classes(ClassesOp::Prepend, "navbar-nav");
}
}
}
fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
Ok(match self {
Self::Void => html! {},
Self::Brand(brand) => html! { (brand.render(cx)) },
Self::Nav(nav) => {
if let Some(nav) = nav.get() {
let items = nav.items().render(cx);
if items.is_empty() {
return Ok(html! {});
}
html! {
ul id=[nav.id()] class=[nav.classes().get()] {
(items)
}
}
} else {
html! {}
}
}
Self::Text(text) => html! {
span class="navbar-text" {
(text.using(cx))
}
},
})
}
}
impl Item {
/// Crea un elemento de tipo [`navbar::Brand`] para añadir en el contenido de [`Navbar`].
///
/// Pensado para barras colapsables u offcanvas donde se quiere que la marca aparezca en la zona
/// desplegable.
pub fn brand(brand: navbar::Brand) -> Self {
Self::Brand(Embed::with(brand))
}
/// Crea un elemento de tipo [`Nav`] para añadir al contenido de [`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: L10n) -> Self {
Self::Text(item)
}
}

Some files were not shown because too many files have changed in this diff Show more