Preparando liberación de la versión 0.5 de PageTop #10

Merged
manuelcillero merged 88 commits from preparing-release-zero-five into main 2026-05-02 18:19:46 +02:00
178 changed files with 11596 additions and 4947 deletions

2
.gitignore vendored
View file

@ -8,3 +8,5 @@
**/local.*.toml
**/local.toml
.env
.cargo
.vscode

130
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,130 @@
# 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,7 +1,7 @@
# 🔃 Dependencias
PageTop está basado en [Rust](https://www.rust-lang.org/) y crece a hombros de gigantes aprovechando
algunas de las librerías más robustas y populares del [ecosistema Rust](https://lib.rs) como son:
algunas de las librerías más robustas y populares del [ecosistema Rust](https://lib.rs/) como son:
* [Actix Web](https://actix.rs/) para los servicios web.
* [Config](https://docs.rs/config) para cargar y procesar las opciones de configuración.
@ -26,8 +26,15 @@ para mostrar un banner de presentación en el terminal con el nombre de la aplic
* [starwars.flf](http://www.figlet.org/fontdb_example.cgi?font=starwars.flf) de *Ryan Youck*
# 🎨 Icono
# 🎨 CSS
"La Criatura" sonriente es una simpática creación de [Webalys](https://www.iconfinder.com/webalys).
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).
# 👾 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).

1235
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -16,14 +16,13 @@ authors.workspace = true
[dependencies]
chrono = "0.4"
colored = "3.0"
concat-string = "1.0"
colored = "3.1"
config = { version = "0.15", default-features = false, features = ["toml"] }
figlet-rs = "0.1"
indoc = "2.0"
figlet-rs = "1.0"
getter-methods = "2.0"
itoa = "1.0"
indexmap = "2.14"
parking_lot = "0.12"
paste = { package = "pastey", version = "0.1" }
substring = "1.4"
terminal_size = "0.4"
@ -32,7 +31,7 @@ tracing-appender = "0.2"
tracing-subscriber = { version = "0.3", features = ["json", "env-filter"] }
tracing-actix-web = "0.7"
fluent-templates = "0.13"
fluent-templates = "0.14"
unic-langid = { version = "0.9", features = ["macros"] }
actix-web = { workspace = true, default-features = true }
@ -42,6 +41,7 @@ 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]
@ -49,7 +49,7 @@ default = []
testing = []
[dev-dependencies]
tempfile = "3.23"
tempfile = "3.27"
serde_json = "1.0"
pagetop-aliner.workspace = true
pagetop-bootsier.workspace = true
@ -64,6 +64,7 @@ members = [
# Helpers
"helpers/pagetop-build",
"helpers/pagetop-macros",
"helpers/pagetop-minimal",
"helpers/pagetop-statics",
# Extensions
"extensions/pagetop-aliner",
@ -77,11 +78,12 @@ license = "MIT OR Apache-2.0"
authors = ["Manuel Cillero <manuel@cillero.es>"]
[workspace.dependencies]
actix-web = { version = "4.11", default-features = false }
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.2", path = "helpers/pagetop-macros" }
pagetop-minimal = { version = "0.0", path = "helpers/pagetop-minimal" }
pagetop-statics = { version = "0.1", path = "helpers/pagetop-statics" }
# Extensions
pagetop-aliner = { version = "0.0", path = "extensions/pagetop-aliner" }

156
MAINTAINERS.md Normal file
View file

@ -0,0 +1,156 @@
# 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.

View file

@ -26,10 +26,10 @@ según las necesidades de cada proyecto, incluyendo:
* **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 sin comprometer su funcionalidad.
componentes.
# ⚡️ Guía rápida
## ⚡️ Guía rápida
La aplicación más sencilla de PageTop se ve así:
@ -74,7 +74,7 @@ Este programa implementa una extensión llamada `HelloWorld` que sirve una pági
(`/`) mostrando el texto "Hello world!" dentro de un elemento HTML `<h1>`.
# 📂 Repositorio
## 📂 Proyecto
El código se organiza en un *workspace* donde actualmente se incluyen los siguientes subproyectos:
@ -82,21 +82,25 @@ El código se organiza en un *workspace* donde actualmente se incluyen los sigui
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.
## Auxiliares
* **[pagetop-statics](https://git.cillero.es/manuelcillero/pagetop/src/branch/main/helpers/pagetop-statics)**,
es la librería que 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.
### 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://git.cillero.es/manuelcillero/pagetop/src/branch/main/helpers/pagetop-macros)**,
proporciona una colección de macros que mejoran la experiencia de desarrollo con PageTop.
proporciona una colección de macros procedurales que mejoran la experiencia de desarrollo con
PageTop.
## Extensiones
* **[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.
* **[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.
### Extensiones
* **[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.
@ -106,7 +110,7 @@ El código se organiza en un *workspace* donde actualmente se incluyen los sigui
componentes flexibles.
# 🧪 Pruebas
## 🧪 Pruebas
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:
@ -123,14 +127,14 @@ Para simplificar el flujo de trabajo, el repositorio incluye varios **alias de C
> Si quieres **activar** las trazas del registro de eventos entonces usa simplemente `cargo test`.
# 🚧 Advertencia
## 🚧 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
## 📜 Licencia
El código está disponible bajo una doble licencia:
@ -144,7 +148,28 @@ Puedes elegir la licencia que prefieras. Este enfoque de doble licencia es el es
el ecosistema Rust.
# ✨ Contribuir
## ✨ 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

456
examples/form-controls.rs Normal file
View file

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

@ -14,7 +14,11 @@ async fn hello_name(
) -> ResultPage<Markup, ErrorPage> {
let name = path.into_inner();
Page::new(request)
.add_child(Html::with(move |_| html! { h1 { "Hello " (name) "!" } }))
.with_child(Html::with(move |_| {
html! {
h1 style="text-align: center;" { "Hello " (name) "!" }
}
}))
.render()
}

View file

@ -10,7 +10,11 @@ impl Extension for HelloWorld {
async fn hello_world(request: HttpRequest) -> ResultPage<Markup, ErrorPage> {
Page::new(request)
.add_child(Html::with(|_| html! { h1 { "Hello World!" } }))
.with_child(Html::with(|_| {
html! {
h1 style="text-align: center;" { "Hello World!" }
}
}))
.render()
}

82
examples/intro-colors.rs Normal file
View file

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

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

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

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

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

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

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

@ -2,6 +2,8 @@ use pagetop::prelude::*;
use pagetop_bootsier::prelude::*;
include_locales!(LOC from "examples/locale");
struct SuperMenu;
impl Extension for SuperMenu {
@ -10,96 +12,87 @@ impl Extension for SuperMenu {
}
fn initialize(&self) {
let home_path = |cx: &Context| match cx.langid().language.as_str() {
"en" => "/en",
_ => "/",
};
let navbar_menu = Navbar::brand_left(navbar::Brand::new().with_path(Some(home_path)))
let navbar_menu = Navbar::brand_left(navbar::Brand::new())
.with_expand(BreakPoint::LG)
.add_item(navbar::Item::nav(
.with_item(navbar::Item::nav(
Nav::new()
.add_item(nav::Item::link(
L10n::l("sample_menus_item_link"),
home_path,
))
.add_item(nav::Item::link_blank(
L10n::l("sample_menus_item_blank"),
|_| "https://docs.rs/pagetop",
))
.add_item(nav::Item::dropdown(
Dropdown::new()
.with_title(L10n::l("sample_menus_test_title"))
.add_item(dropdown::Item::header(L10n::l("sample_menus_dev_header")))
.add_item(dropdown::Item::link(
L10n::l("sample_menus_dev_getting_started"),
|_| "/dev/getting-started",
))
.add_item(dropdown::Item::link(
L10n::l("sample_menus_dev_guides"),
|_| "/dev/guides",
))
.add_item(dropdown::Item::link_blank(
L10n::l("sample_menus_dev_forum"),
|_| "https://forum.example.dev",
))
.add_item(dropdown::Item::divider())
.add_item(dropdown::Item::header(L10n::l("sample_menus_sdk_header")))
.add_item(dropdown::Item::link(
L10n::l("sample_menus_sdk_rust"),
|_| "/dev/sdks/rust",
))
.add_item(dropdown::Item::link(L10n::l("sample_menus_sdk_js"), |_| {
"/dev/sdks/js"
.with_item(nav::Item::link(L10n::t("menus_item_link", &LOC), |cx| {
cx.route("/")
}))
.add_item(dropdown::Item::link(
L10n::l("sample_menus_sdk_python"),
|_| "/dev/sdks/python",
.with_item(nav::Item::link_blank(
L10n::t("menus_item_blank", &LOC),
|_| "https://docs.rs/pagetop".into(),
))
.add_item(dropdown::Item::divider())
.add_item(dropdown::Item::header(L10n::l(
"sample_menus_plugin_header",
)))
.add_item(dropdown::Item::link(
L10n::l("sample_menus_plugin_auth"),
|_| "/dev/sdks/rust/plugins/auth",
.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"),
))
.add_item(dropdown::Item::link(
L10n::l("sample_menus_plugin_cache"),
|_| "/dev/sdks/rust/plugins/cache",
.with_item(dropdown::Item::link(
L10n::t("menus_dev_guides", &LOC),
|cx| cx.route("/dev/guides"),
))
.add_item(dropdown::Item::divider())
.add_item(dropdown::Item::label(L10n::l("sample_menus_item_label")))
.add_item(dropdown::Item::link_disabled(
L10n::l("sample_menus_item_disabled"),
|_| "#",
.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("#"),
)),
))
.add_item(nav::Item::link_disabled(
L10n::l("sample_menus_item_disabled"),
|_| "#",
.with_item(nav::Item::link_disabled(
L10n::t("menus_item_disabled", &LOC),
|cx| cx.route("#"),
)),
))
.add_item(navbar::Item::nav(
.with_item(navbar::Item::nav(
Nav::new()
.with_classes(
ClassesOp::Add,
classes::Margin::with(Side::Start, ScaleSize::Auto).to_class(),
)
.add_item(nav::Item::link(
L10n::l("sample_menus_item_sign_up"),
|_| "/auth/sign-up",
))
.add_item(nav::Item::link(L10n::l("sample_menus_item_login"), |_| {
"/auth/login"
.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::Named("header").add(Child::with(
InRegion::Global(&DefaultRegion::Header).add(
Container::new()
.with_width(container::Width::FluidMax(UnitValue::RelRem(75.0)))
.add_child(navbar_menu),
));
.with_child(navbar_menu),
);
}
}

View file

@ -12,20 +12,20 @@
<br>
</div>
## Sobre PageTop
## 🧭 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
## ⚡️ Guía rápida
Igual que con otras extensiones, **añade la dependencia** a tu `Cargo.toml`:
```toml
[dependencies]
pagetop-aliner = "..."
pagetop-aliner = { ... }
```
**Declara la extensión** en tu aplicación (o extensión que la requiera). Recuerda que el orden en
@ -59,7 +59,7 @@ Y **selecciona el tema en la configuración** de la aplicación:
theme = "Aliner"
```
o **fuerza el tema por código** en una página concreta:
o **fuerza el tema por código** en una página concreta:
```rust,no_run
use pagetop::prelude::*;
@ -80,14 +80,14 @@ async fn homepage(request: HttpRequest) -> ResultPage<Markup, ErrorPage> {
```
# 🚧 Advertencia
## 🚧 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
## 📜 Licencia
El código está disponible bajo una doble licencia:

View file

@ -26,7 +26,7 @@ Igual que con otras extensiones, **añade la dependencia** a tu `Cargo.toml`:
```toml
[dependencies]
pagetop-aliner = "..."
pagetop-aliner = { ... }
```
**Declara la extensión** en tu aplicación (o extensión que la requiera). Recuerda que el orden en
@ -60,7 +60,7 @@ Y **selecciona el tema en la configuración** de la aplicación:
theme = "Aliner"
```
o **fuerza el tema por código** en una página concreta:
o **fuerza el tema por código** en una página concreta:
```rust,no_run
use pagetop::prelude::*;
@ -69,10 +69,10 @@ use pagetop_aliner::Aliner;
async fn homepage(request: HttpRequest) -> ResultPage<Markup, ErrorPage> {
Page::new(request)
.with_theme(&Aliner)
.add_child(
.with_child(
Block::new()
.with_title(L10n::l("sample_title"))
.add_child(Html::with(|cx| html! {
.with_child(Html::with(|cx| html! {
p { (L10n::l("sample_content").using(cx)) }
})),
)
@ -104,12 +104,25 @@ impl Extension for Aliner {
}
impl Theme for Aliner {
fn after_render_page_body(&self, page: &mut Page) {
page.alter_param("include_basic_assets", true)
.alter_assets(ContextOp::AddStyleSheet(
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(-90),
));
.with_weight(-99),
))
.alter_child_in(
&DefaultRegion::Footer,
ChildOp::AddIfEmpty(PoweredBy::new().into()),
);
}
}

View file

@ -29,12 +29,14 @@ h1, h2, h3, h4,h5, h6, p {
}
*::before, *::after {
background: #faa;
border-radius: 3px;
font: normal normal 400 10px/1.2 monospace;
vertical-align: middle;
padding: 1px 3px;
margin: 0 3px;
background: #faa;
color: #fff;
-webkit-text-fill-color: currentColor;
}
*::before {
content: "(";

View file

@ -12,20 +12,20 @@
<br>
</div>
## Sobre PageTop
## 🧭 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
## ⚡️ Guía rápida
Igual que con otras extensiones, **añade la dependencia** a tu `Cargo.toml`:
```toml
[dependencies]
pagetop-bootsier = "..."
pagetop-bootsier = { ... }
```
**Declara la extensión** en tu aplicación (o extensión que la requiera). Recuerda que el orden en
@ -59,7 +59,7 @@ Y **selecciona el tema en la configuración** de la aplicación:
theme = "Bootsier"
```
o **fuerza el tema por código** en una página concreta:
o **fuerza el tema por código** en una página concreta:
```rust,no_run
use pagetop::prelude::*;
@ -80,14 +80,14 @@ async fn homepage(request: HttpRequest) -> ResultPage<Markup, ErrorPage> {
```
# 🚧 Advertencia
## 🚧 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
## 📜 Licencia
El código está disponible bajo una doble licencia:

View file

@ -26,7 +26,7 @@ Igual que con otras extensiones, **añade la dependencia** a tu `Cargo.toml`:
```toml
[dependencies]
pagetop-bootsier = "..."
pagetop-bootsier = { ... }
```
**Declara la extensión** en tu aplicación (o extensión que la requiera). Recuerda que el orden en
@ -60,7 +60,7 @@ Y **selecciona el tema en la configuración** de la aplicación:
theme = "Bootsier"
```
o **fuerza el tema por código** en una página concreta:
o **fuerza el tema por código** en una página concreta:
```rust,no_run
use pagetop::prelude::*;
@ -69,10 +69,10 @@ use pagetop_bootsier::Bootsier;
async fn homepage(request: HttpRequest) -> ResultPage<Markup, ErrorPage> {
Page::new(request)
.with_theme(&Bootsier)
.add_child(
.with_child(
Block::new()
.with_title(L10n::l("sample_title"))
.add_child(Html::with(|cx| html! {
.with_child(Html::with(|cx| html! {
p { (L10n::l("sample_content").using(cx)) }
})),
)
@ -102,6 +102,34 @@ pub mod prelude {
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;
@ -117,16 +145,25 @@ impl Extension for Bootsier {
}
impl Theme for Bootsier {
fn after_render_page_body(&self, page: &mut Page) {
page.alter_assets(ContextOp::AddStyleSheet(
#[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(ContextOp::AddJavaScript(
.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 +1,5 @@
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
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,8 +1,11 @@
# Dropdown
dropdown_toggle = Toggle Dropdown
# Offcanvas
offcanvas_close = Close
# form::Input
input_required = This field is required
# Navbar
toggle = Toggle navigation
# Offcanvas
offcanvas_close = Close

View file

@ -1,9 +1,9 @@
header = Header
nav_branding = Navigation branding region
nav_main = Main navigation region
nav_additional = Additional navigation region (eg search form, social icons, etc)
breadcrumb = Breadcrumb
content = Main content
sidebar_first = Sidebar first
sidebar_second = Sidebar second
footer = Footer
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 +1,5 @@
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
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,8 +1,11 @@
# Dropdown
dropdown_toggle = Mostrar/ocultar menú
# Offcanvas
offcanvas_close = Cerrar
# form::Input
input_required = Este campo es obligatorio
# Navbar
toggle = Mostrar/ocultar navegación
# Offcanvas
offcanvas_close = Cerrar

View file

@ -1,9 +1,9 @@
header = Cabecera
nav_branding = Navegación y marca
nav_main = Navegación principal
nav_additional = Navegación adicional (p.e. formulario de búsqueda, iconos sociales, etc.)
breadcrumb = Ruta de posicionamiento
content = Contenido principal
sidebar_first = Barra lateral primera
sidebar_second = Barra lateral segunda
footer = Pie
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

@ -9,6 +9,10 @@ pub use aux::*;
pub mod classes;
// Button.
mod button;
pub use button::Button;
// Container.
pub mod container;
#[doc(inline)]
@ -19,6 +23,11 @@ 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)]

View file

@ -17,4 +17,4 @@ mod rounded;
pub use rounded::RoundedRadius;
mod button;
pub use button::{ButtonColor, ButtonSize};
pub use button::{ButtonAction, ButtonColor, ButtonSize};

View file

@ -2,15 +2,15 @@ use pagetop::prelude::*;
use crate::theme::aux::Color;
/// Colores `border-*` para los bordes ([`classes::Border`](crate::theme::classes::Border)).
/// 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 internamente clases `border-{color}`.
/// Genera la clase `border-{color}`.
Theme(Color),
/// Genera internamente clases `border-{color}-subtle` (un tono suavizado del color).
/// Genera la clase `border-{color}-subtle` (un tono suavizado del color).
Subtle(Color),
/// Color negro.
Black,
@ -22,7 +22,7 @@ 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.
/// Devuelve el sufijo de la clase `border-*`, o `None` si no define ninguna clase.
#[rustfmt::skip]
#[inline]
const fn suffix(self) -> Option<&'static str> {
@ -35,7 +35,7 @@ impl BorderColor {
}
}
// Añade la clase `border-*` a la cadena de clases.
/// 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() {
@ -64,7 +64,6 @@ impl BorderColor {
/// assert_eq!(BorderColor::Black.to_class(), "border-black");
/// assert_eq!(BorderColor::Default.to_class(), "");
/// ```
#[inline]
pub fn to_class(self) -> String {
if let Some(suffix) = self.suffix() {
let base_len = match self {

View file

@ -19,7 +19,7 @@ pub enum BreakPoint {
}
impl BreakPoint {
// Devuelve la identificación del punto de ruptura.
/// Devuelve la identificación del punto de ruptura.
#[rustfmt::skip]
#[inline]
pub(crate) const fn as_str(self) -> &'static str {
@ -33,11 +33,11 @@ impl BreakPoint {
}
}
// 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}`.
/// 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() {
@ -60,30 +60,30 @@ impl BreakPoint {
}
}
// 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"), "");
// ```
#[inline]
pub(crate) fn class_with(self, prefix: &str, suffix: &str) -> String {
/// 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();
}

View file

@ -2,17 +2,42 @@ use pagetop::prelude::*;
use crate::theme::aux::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 >********************************************************************************
/// Variantes de color `btn-*` para botones.
/// Esquema de color para [`Button`](crate::theme::Button).
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
pub enum ButtonColor {
/// No define ninguna clase.
#[default]
Default,
/// Genera internamente clases `btn-{color}` (botón relleno).
/// Genera la clase `btn-{color}` (botón sólido).
Background(Color),
/// Genera `btn-outline-{color}` (fondo transparente y contorno con borde).
/// 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,
@ -23,7 +48,7 @@ impl ButtonColor {
const BTN_OUTLINE_PREFIX: &str = "btn-outline-";
const BTN_LINK: &str = "btn-link";
// Añade la clase `btn-*` a la cadena de clases.
/// 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 {
@ -33,7 +58,6 @@ impl ButtonColor {
classes.push(' ');
}
match self {
Self::Default => unreachable!(),
Self::Background(c) => {
classes.push_str(Self::BTN_PREFIX);
classes.push_str(c.as_str());
@ -42,9 +66,8 @@ impl ButtonColor {
classes.push_str(Self::BTN_OUTLINE_PREFIX);
classes.push_str(c.as_str());
}
Self::Link => {
classes.push_str(Self::BTN_LINK);
}
Self::Link => classes.push_str(Self::BTN_LINK),
Self::Default => unreachable!(),
}
}
@ -65,27 +88,11 @@ impl ButtonColor {
/// assert_eq!(ButtonColor::Link.to_class(), "btn-link");
/// assert_eq!(ButtonColor::Default.to_class(), "");
/// ```
#[inline]
pub fn to_class(self) -> String {
match self {
Self::Default => String::new(),
Self::Background(c) => {
let color = c.as_str();
let mut class = String::with_capacity(Self::BTN_PREFIX.len() + color.len());
class.push_str(Self::BTN_PREFIX);
class.push_str(color);
let mut class = String::new();
self.push_class(&mut class);
class
}
Self::Outline(c) => {
let color = c.as_str();
let mut class = String::with_capacity(Self::BTN_OUTLINE_PREFIX.len() + color.len());
class.push_str(Self::BTN_OUTLINE_PREFIX);
class.push_str(color);
class
}
Self::Link => Self::BTN_LINK.to_string(),
}
}
}
// **< ButtonSize >*********************************************************************************
@ -98,7 +105,7 @@ pub enum ButtonSize {
Default,
/// Botón compacto.
Small,
/// Botón destacado/grande.
/// Botón grande.
Large,
}
@ -106,20 +113,18 @@ 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.
/// 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) {
if let Self::Default = self {
return;
}
let class = match self {
Self::Default => return,
Self::Small => Self::BTN_SM,
Self::Large => Self::BTN_LG,
};
if !classes.is_empty() {
classes.push(' ');
}
match self {
Self::Default => unreachable!(),
Self::Small => classes.push_str(Self::BTN_SM),
Self::Large => classes.push_str(Self::BTN_LG),
}
classes.push_str(class);
}
/// Devuelve la clase `btn-sm` o `btn-lg` correspondiente al tamaño del botón.
@ -132,12 +137,9 @@ impl ButtonSize {
/// assert_eq!(ButtonSize::Large.to_class(), "btn-lg");
/// assert_eq!(ButtonSize::Default.to_class(), "");
/// ```
#[inline]
pub fn to_class(self) -> String {
match self {
Self::Default => String::new(),
Self::Small => Self::BTN_SM.to_string(),
Self::Large => Self::BTN_LG.to_string(),
}
let mut class = String::new();
self.push_class(&mut class);
class
}
}

View file

@ -5,9 +5,9 @@ use pagetop::prelude::*;
/// Paleta de colores temáticos.
///
/// Equivalen a los nombres estándar definidos por Bootstrap (`primary`, `secondary`, `success`,
/// etc.). Este tipo enumerado sirve de base para componer las clases de color para fondo
/// ([`classes::Background`](crate::theme::classes::Background)), bordes
/// ([`classes::Border`](crate::theme::classes::Border)) y texto
/// 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 {
@ -23,7 +23,7 @@ pub enum Color {
}
impl Color {
// Devuelve el nombre del color.
/// Devuelve el nombre del color.
#[rustfmt::skip]
#[inline]
pub(crate) const fn as_str(self) -> &'static str {
@ -39,15 +39,6 @@ impl Color {
}
}
/* Añade el nombre del color a la cadena de clases (reservado).
#[inline]
pub(crate) fn push_class(self, classes: &mut String) {
if !classes.is_empty() {
classes.push(' ');
}
classes.push_str(self.as_str());
} */
/// Devuelve la clase correspondiente al color.
///
/// # Ejemplos
@ -94,7 +85,7 @@ impl Opacity {
const OPACITY: &str = "opacity";
const OPACITY_PREFIX: &str = "-opacity";
// Devuelve el sufijo para `*opacity-*`, o `None` si no define ninguna clase.
/// Devuelve el sufijo para `*opacity-*`, o `None` si no define ninguna clase.
#[rustfmt::skip]
#[inline]
const fn suffix(self) -> Option<&'static str> {
@ -109,8 +100,8 @@ impl Opacity {
}
}
// Añade la opacidad a la cadena de clases usando el prefijo dado (`bg`, `border`, `text`, o
// vacío para `opacity-*`).
/// 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() {
@ -127,20 +118,20 @@ impl Opacity {
}
}
// 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"), "");
// ```
#[inline]
pub(crate) fn class_with(self, prefix: &str) -> String {
/// 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()
@ -178,7 +169,7 @@ impl Opacity {
// **< ColorBg >************************************************************************************
/// Colores `bg-*` para el fondo.
/// Esquema de color para el fondo.
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
pub enum ColorBg {
/// No define ninguna clase.
@ -190,9 +181,9 @@ pub enum ColorBg {
BodySecondary,
/// Fondo predefinido del tema (`bg-body-tertiary`).
BodyTertiary,
/// Genera internamente clases `bg-{color}` (p. ej., `bg-primary`).
/// Genera la clase `bg-{color}` (p. ej., `bg-primary`).
Theme(Color),
/// Genera internamente clases `bg-{color}-subtle` (un tono suavizado del color).
/// Genera la clase `bg-{color}-subtle` (un tono suavizado del color).
Subtle(Color),
/// Color negro.
Black,
@ -206,7 +197,7 @@ 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.
/// Devuelve el sufijo de la clase `bg-*`, o `None` si no define ninguna clase.
#[rustfmt::skip]
#[inline]
const fn suffix(self) -> Option<&'static str> {
@ -223,7 +214,7 @@ impl ColorBg {
}
}
// Añade la clase de fondo `bg-*` a la cadena de clases.
/// 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() {
@ -253,31 +244,16 @@ impl ColorBg {
/// assert_eq!(ColorBg::Transparent.to_class(), "bg-transparent");
/// assert_eq!(ColorBg::Default.to_class(), "");
/// ```
#[inline]
pub fn to_class(self) -> String {
if let Some(suffix) = self.suffix() {
let base_len = match self {
Self::Theme(c) | Self::Subtle(c) => Self::BG_PREFIX.len() + c.as_str().len(),
_ => Self::BG.len(),
};
let mut class = String::with_capacity(base_len + suffix.len());
match self {
Self::Theme(c) | Self::Subtle(c) => {
class.push_str(Self::BG_PREFIX);
class.push_str(c.as_str());
}
_ => class.push_str(Self::BG),
}
class.push_str(suffix);
return class;
}
String::new()
let mut class = String::new();
self.push_class(&mut class);
class
}
}
// **< ColorText >**********************************************************************************
/// Colores `text-*` para el texto.
/// Esquema de color para el texto.
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
pub enum ColorText {
/// No define ninguna clase.
@ -291,9 +267,9 @@ pub enum ColorText {
BodySecondary,
/// Color predefinido del tema (`text-body-tertiary`).
BodyTertiary,
/// Genera internamente clases `text-{color}`.
/// Genera la clase `text-{color}`.
Theme(Color),
/// Genera internamente clases `text-{color}-emphasis` (mayor contraste acorde al tema).
/// Genera la clase `text-{color}-emphasis` (mayor contraste acorde al tema).
Emphasis(Color),
/// Color negro.
Black,
@ -305,7 +281,7 @@ 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.
/// Devuelve el sufijo de la clase `text-*`, o `None` si no define ninguna clase.
#[rustfmt::skip]
#[inline]
const fn suffix(self) -> Option<&'static str> {
@ -322,7 +298,7 @@ impl ColorText {
}
}
// Añade la clase de texto `text-*` a la cadena de clases.
/// 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() {
@ -352,24 +328,9 @@ impl ColorText {
/// assert_eq!(ColorText::Black.to_class(), "text-black");
/// assert_eq!(ColorText::Default.to_class(), "");
/// ```
#[inline]
pub fn to_class(self) -> String {
if let Some(suffix) = self.suffix() {
let base_len = match self {
Self::Theme(c) | Self::Emphasis(c) => Self::TEXT_PREFIX.len() + c.as_str().len(),
_ => Self::TEXT.len(),
};
let mut class = String::with_capacity(base_len + suffix.len());
match self {
Self::Theme(c) | Self::Emphasis(c) => {
class.push_str(Self::TEXT_PREFIX);
class.push_str(c.as_str());
}
_ => class.push_str(Self::TEXT),
}
class.push_str(suffix);
return class;
}
String::new()
let mut class = String::new();
self.push_class(&mut class);
class
}
}

View file

@ -25,8 +25,8 @@ pub enum ScaleSize {
}
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.
/// 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> {
@ -42,7 +42,7 @@ impl ScaleSize {
}
}
// Añade el tamaño a la cadena de clases usando el prefijo dado.
/// 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() {
@ -57,18 +57,18 @@ impl ScaleSize {
}
/* 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"), "");
// ```
#[inline]
pub(crate) fn class_with(self, prefix: &str) -> String {
///
/// # 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());

View file

@ -29,8 +29,8 @@ pub enum RoundedRadius {
impl RoundedRadius {
const ROUNDED: &str = "rounded";
// Devuelve el sufijo para `*rounded-*`, o `None` si no define ninguna clase, o `""` para el
// redondeo por defecto.
/// 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> {
@ -48,8 +48,8 @@ impl RoundedRadius {
}
}
// 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-*`).
/// 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() {
@ -65,21 +65,21 @@ impl RoundedRadius {
}
}
// 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"), "");
// ```
#[inline]
pub(crate) fn class_with(self, prefix: &str) -> String {
/// 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()

View file

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

@ -145,7 +145,6 @@ impl Border {
/// `"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 `""`.
#[inline]
pub fn to_class(self) -> String {
let mut classes = String::new();
self.push_class(&mut classes);

View file

@ -76,7 +76,6 @@ impl Background {
/// 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 `""`.
#[inline]
pub fn to_class(self) -> String {
let mut classes = String::new();
self.push_class(&mut classes);
@ -189,7 +188,6 @@ impl Text {
/// etc.).
///
/// Si no se define ni color ni opacidad, devuelve `""`.
#[inline]
pub fn to_class(self) -> String {
let mut classes = String::new();
self.push_class(&mut classes);

View file

@ -48,7 +48,7 @@ impl Margin {
// **< Margin HELPERS >*************************************************************************
// Devuelve el prefijo `m*` según el lado.
/// Devuelve el prefijo `m*` según el lado.
#[rustfmt::skip]
#[inline]
const fn side_prefix(&self) -> &'static str {
@ -63,7 +63,7 @@ impl Margin {
}
}
// Devuelve el sufijo del tamaño (`auto`, `0`..`5`), o `None` si no define clase.
/// 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> {
@ -79,22 +79,9 @@ impl Margin {
}
}
/* Añade la clase de **margin** a la cadena de clases (reservado).
//
// No añade nada si `size` es `ScaleSize::None`.
#[inline]
pub(crate) fn push_class(self, classes: &mut String) {
let Some(size) = self.size_suffix() else {
return;
};
self.breakpoint
.push_class(classes, self.side_prefix(), size);
} */
/// Devuelve la clase de **margin** como cadena (`"mt-3"`, `"ms-lg-auto"`, etc.).
///
/// Si `size` es `ScaleSize::None`, devuelve `""`.
#[inline]
pub fn to_class(self) -> String {
let Some(size) = self.size_suffix() else {
return String::new();
@ -148,7 +135,7 @@ impl Padding {
// **< Padding HELPERS >************************************************************************
// Devuelve el prefijo `p*` según el lado.
/// Devuelve el prefijo `p*` según el lado.
#[rustfmt::skip]
#[inline]
const fn prefix(&self) -> &'static str {
@ -163,9 +150,9 @@ impl Padding {
}
}
// Devuelve el sufijo del tamaño (`0`..`5`), o `None` si no define clase.
//
// Nota: `ScaleSize::Auto` **no aplica** a padding ⇒ devuelve `None`.
/// 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> {
@ -181,21 +168,9 @@ impl Padding {
}
}
/* Añade la clase de **padding** a la cadena de clases (reservado).
//
// No añade nada si `size` es `ScaleSize::None` o `ScaleSize::Auto`.
#[inline]
pub(crate) fn push_class(self, classes: &mut String) {
let Some(size) = self.suffix() else {
return;
};
self.breakpoint.push_class(classes, self.prefix(), size);
} */
// Devuelve la clase de **padding** como cadena (`"px-2"`, `"pe-sm-4"`, etc.).
//
// Si `size` es `ScaleSize::None` o `ScaleSize::Auto`, devuelve `""`.
#[inline]
/// 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();

View file

@ -160,7 +160,6 @@ impl Rounded {
/// `"rounded-top rounded-bottom-start-4 rounded-bottom-end-circle"`, etc.).
///
/// Si no se define ningún radio, devuelve `""`.
#[inline]
pub fn to_class(self) -> String {
let mut classes = String::new();
self.push_class(&mut classes);

View file

@ -6,79 +6,83 @@ use crate::prelude::*;
///
/// Envuelve un contenido con la etiqueta HTML indicada por [`container::Kind`]. Sólo se renderiza
/// si existen componentes hijos (*children*).
#[rustfmt::skip]
#[derive(AutoDefault)]
#[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Container {
#[getters(skip)]
id: AttrId,
classes : AttrClasses,
/// 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 {
Container::default()
Self::default()
}
fn id(&self) -> Option<String> {
self.id.get()
}
fn setup_before_prepare(&mut self, _cx: &mut Context) {
self.alter_classes(ClassesOp::Prepend, self.width().to_class());
fn setup(&mut self, _cx: &Context) {
self.alter_classes(ClassesOp::Prepend, self.container_width().to_class());
}
fn prepare_component(&self, cx: &mut Context) -> PrepareMarkup {
fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
let output = self.children().render(cx);
if output.is_empty() {
return PrepareMarkup::None;
return Ok(html! {});
}
let style = match self.width() {
let style = match self.container_width() {
container::Width::FluidMax(w) if w.is_measurable() => {
Some(join!("max-width: ", w.to_string(), ";"))
Some(util::join!("max-width: ", w.to_string(), ";"))
}
_ => None,
};
match self.container_kind() {
container::Kind::Default => PrepareMarkup::With(html! {
Ok(match self.container_kind() {
container::Kind::Default => html! {
div id=[self.id()] class=[self.classes().get()] style=[style] {
(output)
}
}),
container::Kind::Main => PrepareMarkup::With(html! {
},
container::Kind::Main => html! {
main id=[self.id()] class=[self.classes().get()] style=[style] {
(output)
}
}),
container::Kind::Header => PrepareMarkup::With(html! {
},
container::Kind::Header => html! {
header id=[self.id()] class=[self.classes().get()] style=[style] {
(output)
}
}),
container::Kind::Footer => PrepareMarkup::With(html! {
},
container::Kind::Footer => html! {
footer id=[self.id()] class=[self.classes().get()] style=[style] {
(output)
}
}),
container::Kind::Section => PrepareMarkup::With(html! {
},
container::Kind::Section => html! {
section id=[self.id()] class=[self.classes().get()] style=[style] {
(output)
}
}),
container::Kind::Article => PrepareMarkup::With(html! {
},
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 {
Container {
Self {
container_kind: container::Kind::Main,
..Default::default()
}
@ -86,7 +90,7 @@ impl Container {
/// Crea un contenedor de tipo `Header` (`<header>`).
pub fn header() -> Self {
Container {
Self {
container_kind: container::Kind::Header,
..Default::default()
}
@ -94,7 +98,7 @@ impl Container {
/// Crea un contenedor de tipo `Footer` (`<footer>`).
pub fn footer() -> Self {
Container {
Self {
container_kind: container::Kind::Footer,
..Default::default()
}
@ -102,7 +106,7 @@ impl Container {
/// Crea un contenedor de tipo `Section` (`<section>`).
pub fn section() -> Self {
Container {
Self {
container_kind: container::Kind::Section,
..Default::default()
}
@ -110,7 +114,7 @@ impl Container {
/// Crea un contenedor de tipo `Article` (`<article>`).
pub fn article() -> Self {
Container {
Self {
container_kind: container::Kind::Article,
..Default::default()
}
@ -121,7 +125,7 @@ impl Container {
/// Establece el identificador único (`id`) del contenedor.
#[builder_fn]
pub fn with_id(mut self, id: impl AsRef<str>) -> Self {
self.id.alter_value(id);
self.id.alter_id(id);
self
}
@ -135,7 +139,7 @@ impl Container {
/// - Redondear las esquinas ([`classes::Rounded`]).
#[builder_fn]
pub fn with_classes(mut self, op: ClassesOp, classes: impl AsRef<str>) -> Self {
self.classes.alter_value(op, classes);
self.classes.alter_classes(op, classes);
self
}
@ -146,39 +150,11 @@ impl Container {
self
}
/// Añade un nuevo componente hijo al contenedor.
#[inline]
pub fn add_child(mut self, component: impl Component) -> Self {
self.children.add(Child::with(component));
self
}
/// Modifica la lista de componentes (`children`) aplicando una operación [`ChildOp`].
/// 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: ChildOp) -> Self {
self.children.alter_child(op);
pub fn with_child(mut self, op: impl Into<ChildOp>) -> Self {
self.children.alter_child(op.into());
self
}
// **< Container GETTERS >**********************************************************************
/// Devuelve las clases CSS asociadas al contenedor.
pub fn classes(&self) -> &AttrClasses {
&self.classes
}
/// Devuelve el tipo semántico del contenedor.
pub fn container_kind(&self) -> &container::Kind {
&self.container_kind
}
/// Devuelve el comportamiento para el ancho del contenedor.
pub fn width(&self) -> &container::Width {
&self.container_width
}
/// Devuelve la lista de componentes (`children`) del contenedor.
pub fn children(&self) -> &Children {
&self.children
}
}

View file

@ -59,7 +59,6 @@ impl Width {
} */
/// Devuelve la clase asociada al comportamiento del contenedor según el ajuste de su ancho.
#[inline]
pub fn to_class(self) -> String {
match self {
Self::Default => BreakPoint::None.class_with(Self::CONTAINER, ""),

View file

@ -17,11 +17,11 @@
//! .with_button_color(ButtonColor::Background(Color::Secondary))
//! .with_auto_close(dropdown::AutoClose::ClickableInside)
//! .with_direction(dropdown::Direction::Dropend)
//! .add_item(dropdown::Item::link(L10n::n("Home"), |_| "/"))
//! .add_item(dropdown::Item::link_blank(L10n::n("External"), |_| "https://www.google.es"))
//! .add_item(dropdown::Item::divider())
//! .add_item(dropdown::Item::header(L10n::n("User session")))
//! .add_item(dropdown::Item::button(L10n::n("Sign out")));
//! .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;

View file

@ -19,53 +19,64 @@ use crate::LOCALES_BOOTSIER;
///
/// Ver ejemplo en el módulo [`dropdown`].
/// Si no contiene elementos, el componente **no se renderiza**.
#[rustfmt::skip]
#[derive(AutoDefault)]
#[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Dropdown {
#[getters(skip)]
id: AttrId,
classes : AttrClasses,
/// 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 {
Dropdown::default()
Self::default()
}
fn id(&self) -> Option<String> {
self.id.get()
}
fn setup_before_prepare(&mut self, _cx: &mut Context) {
fn setup(&mut self, _cx: &Context) {
self.alter_classes(
ClassesOp::Prepend,
self.direction().class_with(self.button_grouped()),
self.direction().class_with(*self.button_grouped()),
);
}
fn prepare_component(&self, cx: &mut Context) -> PrepareMarkup {
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 PrepareMarkup::None;
return Ok(html! {});
}
// Título opcional para el menú desplegable.
let title = self.title().using(cx);
PrepareMarkup::With(html! {
Ok(html! {
div id=[self.id()] class=[self.classes().get()] {
@if !title.is_empty() {
@let mut btn_classes = AttrClasses::new({
@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);
@ -75,14 +86,14 @@ impl Component for Dropdown {
@let offset = pos.data_offset();
@let reference = pos.data_reference();
@let auto_close = self.auto_close.as_str();
@let menu_classes = AttrClasses::new({
@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() {
@if *self.button_split() {
// Botón principal (acción/etiqueta).
@let btn = html! {
button
@ -96,7 +107,7 @@ impl Component for Dropdown {
@let btn_toggle = html! {
button
type="button"
class=[btn_classes.alter_value(
class=[btn_classes.alter_classes(
ClassesOp::Add, "dropdown-toggle dropdown-toggle-split"
).get()]
data-bs-toggle="dropdown"
@ -127,7 +138,7 @@ impl Component for Dropdown {
// Botón único con funcionalidad de *toggle*.
button
type="button"
class=[btn_classes.alter_value(
class=[btn_classes.alter_classes(
ClassesOp::Add, "dropdown-toggle"
).get()]
data-bs-toggle="dropdown"
@ -155,14 +166,14 @@ impl Dropdown {
/// Establece el identificador único (`id`) del menú desplegable.
#[builder_fn]
pub fn with_id(mut self, id: impl AsRef<str>) -> Self {
self.id.alter_value(id);
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_value(op, classes);
self.classes.alter_classes(op, classes);
self
}
@ -229,74 +240,22 @@ impl Dropdown {
self
}
/// Añade un nuevo elemento hijo al menú.
#[inline]
pub fn add_item(mut self, item: dropdown::Item) -> Self {
self.items.add(Child::with(item));
self
}
/// Modifica la lista de elementos (`children`) aplicando una operación [`TypedOp`].
/// 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_items(mut self, op: TypedOp<dropdown::Item>) -> Self {
self.items.alter_typed(op);
pub fn with_item(mut self, op: impl Into<ChildOp>) -> Self {
self.items.alter_child(op.into());
self
}
// **< Dropdown GETTERS >***********************************************************************
/// Devuelve las clases CSS asociadas al menú desplegable.
pub fn classes(&self) -> &AttrClasses {
&self.classes
}
/// Devuelve el título del menú desplegable.
pub fn title(&self) -> &L10n {
&self.title
}
/// Devuelve el tamaño configurado del botón.
pub fn button_size(&self) -> &ButtonSize {
&self.button_size
}
/// Devuelve el color/estilo configurado del botón.
pub fn button_color(&self) -> &ButtonColor {
&self.button_color
}
/// Devuelve si se debe desdoblar (*split*) el botón (botón de acción + *toggle*).
pub fn button_split(&self) -> bool {
self.button_split
}
/// Devuelve si el botón del menú está integrado en un grupo de botones.
pub fn button_grouped(&self) -> bool {
self.button_grouped
}
/// Devuelve la política de cierre automático del menú desplegado.
pub fn auto_close(&self) -> &dropdown::AutoClose {
&self.auto_close
}
/// Devuelve la dirección de despliegue configurada.
pub fn direction(&self) -> &dropdown::Direction {
&self.direction
}
/// Devuelve la configuración de alineación horizontal del menú desplegable.
pub fn menu_align(&self) -> &dropdown::MenuAlign {
&self.menu_align
}
/// Devuelve la posición configurada para el menú desplegable.
pub fn menu_position(&self) -> &dropdown::MenuPosition {
&self.menu_position
}
/// Devuelve la lista de elementos (`children`) del menú.
pub fn items(&self) -> &Children {
&self.items
}
}

View file

@ -7,18 +7,19 @@ use pagetop::prelude::*;
///
/// Define internamente la naturaleza del elemento y su comportamiento al mostrarse o interactuar
/// con él.
#[derive(AutoDefault)]
#[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. Opcionalmente puede abrirse en una nueva ventana y estar
/// inicialmente deshabilitado.
/// 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,
path: FnPathByContext,
route: FnPathByContext,
blank: bool,
disabled: bool,
},
@ -40,46 +41,48 @@ pub enum ItemKind {
/// visible que puede comportarse como texto, enlace, botón, encabezado o separador, según su
/// [`ItemKind`].
///
/// Permite definir identificador, clases de estilo adicionales o tipo de interacción asociada,
/// manteniendo una interfaz común para renderizar todos los elementos del menú.
#[rustfmt::skip]
#[derive(AutoDefault)]
/// 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,
classes : AttrClasses,
/// 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 {
Item::default()
Self::default()
}
fn id(&self) -> Option<String> {
self.id.get()
}
fn prepare_component(&self, cx: &mut Context) -> PrepareMarkup {
match self.item_kind() {
ItemKind::Void => PrepareMarkup::None,
fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
Ok(match self.item_kind() {
ItemKind::Void => html! {},
ItemKind::Label(label) => PrepareMarkup::With(html! {
ItemKind::Label(label) => html! {
li id=[self.id()] class=[self.classes().get()] {
span class="dropdown-item-text" {
(label.using(cx))
}
}
}),
},
ItemKind::Link {
label,
path,
route,
blank,
disabled,
} => {
let path = path(cx);
let route_link = route(cx);
let current_path = cx.request().map(|request| request.path());
let is_current = !*disabled && (current_path == Some(path));
let is_current = !*disabled && (current_path == Some(route_link.path()));
let mut classes = "dropdown-item".to_string();
if is_current {
@ -89,15 +92,15 @@ impl Component for Item {
classes.push_str(" disabled");
}
let href = (!disabled).then_some(path);
let target = (!disabled && *blank).then_some("_blank");
let rel = (!disabled && *blank).then_some("noopener noreferrer");
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");
PrepareMarkup::With(html! {
html! {
li id=[self.id()] class=[self.classes().get()] {
a
class=(classes)
@ -111,7 +114,7 @@ impl Component for Item {
(label.using(cx))
}
}
})
}
}
ItemKind::Button { label, disabled } => {
@ -123,7 +126,7 @@ impl Component for Item {
let aria_disabled = disabled.then_some("true");
let disabled_attr = disabled.then_some("disabled");
PrepareMarkup::With(html! {
html! {
li id=[self.id()] class=[self.classes().get()] {
button
class=(classes)
@ -134,39 +137,43 @@ impl Component for Item {
(label.using(cx))
}
}
})
}
}
ItemKind::Header(label) => PrepareMarkup::With(html! {
ItemKind::Header(label) => html! {
li id=[self.id()] class=[self.classes().get()] {
h6 class="dropdown-header" {
(label.using(cx))
}
}
}),
},
ItemKind::Divider => PrepareMarkup::With(html! {
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 {
Item {
Self {
item_kind: ItemKind::Label(label),
..Default::default()
}
}
/// Crea un enlace para la navegación.
pub fn link(label: L10n, path: FnPathByContext) -> Self {
Item {
///
/// 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,
path,
route,
blank: false,
disabled: false,
},
@ -175,11 +182,11 @@ impl Item {
}
/// Crea un enlace deshabilitado que no permite la interacción.
pub fn link_disabled(label: L10n, path: FnPathByContext) -> Self {
Item {
pub fn link_disabled(label: L10n, route: FnPathByContext) -> Self {
Self {
item_kind: ItemKind::Link {
label,
path,
route,
blank: false,
disabled: true,
},
@ -188,11 +195,11 @@ impl Item {
}
/// Crea un enlace que se abre en una nueva ventana o pestaña.
pub fn link_blank(label: L10n, path: FnPathByContext) -> Self {
Item {
pub fn link_blank(label: L10n, route: FnPathByContext) -> Self {
Self {
item_kind: ItemKind::Link {
label,
path,
route,
blank: true,
disabled: false,
},
@ -201,11 +208,11 @@ impl Item {
}
/// Crea un enlace inicialmente deshabilitado que se abriría en una nueva ventana.
pub fn link_blank_disabled(label: L10n, path: FnPathByContext) -> Self {
Item {
pub fn link_blank_disabled(label: L10n, route: FnPathByContext) -> Self {
Self {
item_kind: ItemKind::Link {
label,
path,
route,
blank: true,
disabled: true,
},
@ -215,7 +222,7 @@ impl Item {
/// Crea un botón de acción local, sin navegación asociada.
pub fn button(label: L10n) -> Self {
Item {
Self {
item_kind: ItemKind::Button {
label,
disabled: false,
@ -226,7 +233,7 @@ impl Item {
/// Crea un botón deshabilitado.
pub fn button_disabled(label: L10n) -> Self {
Item {
Self {
item_kind: ItemKind::Button {
label,
disabled: true,
@ -237,7 +244,7 @@ impl Item {
/// Crea un encabezado para un grupo de elementos dentro del menú.
pub fn header(label: L10n) -> Self {
Item {
Self {
item_kind: ItemKind::Header(label),
..Default::default()
}
@ -245,7 +252,7 @@ impl Item {
/// Crea un separador visual entre bloques de elementos.
pub fn divider() -> Self {
Item {
Self {
item_kind: ItemKind::Divider,
..Default::default()
}
@ -256,26 +263,14 @@ impl Item {
/// Establece el identificador único (`id`) del elemento.
#[builder_fn]
pub fn with_id(mut self, id: impl AsRef<str>) -> Self {
self.id.alter_value(id);
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_value(op, classes);
self.classes.alter_classes(op, classes);
self
}
// **< Item GETTERS >***************************************************************************
/// Devuelve las clases CSS asociadas al elemento.
pub fn classes(&self) -> &AttrClasses {
&self.classes
}
/// Devuelve el tipo de elemento representado.
pub fn item_kind(&self) -> &ItemKind {
&self.item_kind
}
}

View file

@ -22,7 +22,7 @@ pub enum AutoClose {
}
impl AutoClose {
// Devuelve el valor para `data-bs-auto-close`, o `None` si es el comportamiento por defecto.
/// 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> {
@ -59,7 +59,7 @@ pub enum Direction {
}
impl Direction {
// Mapea la dirección teniendo en cuenta si se agrupa con otros menús [`Dropdown`].
/// 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 {
@ -74,8 +74,8 @@ impl Direction {
}
}
// Añade la dirección de despliegue a la cadena de clases teniendo en cuenta si se agrupa con
// otros menús [`Dropdown`].
/// 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 {
@ -93,10 +93,10 @@ impl Direction {
}
}
// Devuelve la clase asociada a la dirección teniendo en cuenta si se agrupa con otros menús
// [`Dropdown`], o `""` si no corresponde ninguna.
#[inline]
pub(crate) fn class_with(self, grouped: bool) -> String {
/// 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
@ -138,7 +138,7 @@ impl MenuAlign {
classes.push_str(class);
}
// Añade las clases de alineación a `classes` (sin incluir la base `dropdown-menu`).
/// 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 {
@ -179,8 +179,7 @@ impl MenuAlign {
}
/* Devuelve las clases de alineación sin incluir `dropdown-menu` (reservado).
#[inline]
pub(crate) fn to_class(self) -> String {
pub fn to_class(self) -> String {
let mut classes = String::new();
self.push_class(&mut classes);
classes
@ -206,7 +205,7 @@ pub enum MenuPosition {
}
impl MenuPosition {
// Devuelve el valor para `data-bs-offset` o `None` si no aplica.
/// Devuelve el valor para `data-bs-offset` o `None` si no aplica.
#[inline]
pub(crate) fn data_offset(self) -> Option<String> {
match self {
@ -215,7 +214,7 @@ impl MenuPosition {
}
}
// Devuelve el valor para `data-bs-reference` o `None` si no aplica.
/// Devuelve el valor para `data-bs-reference` o `None` si no aplica.
#[inline]
pub(crate) fn data_reference(self) -> Option<&'static str> {
match self {

View file

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

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

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

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

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

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

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

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

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

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

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

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

@ -2,7 +2,7 @@ use crate::prelude::*;
const DEFAULT_VIEWBOX: &str = "0 0 16 16";
#[derive(AutoDefault)]
#[derive(AutoDefault, Clone)]
pub enum IconKind {
#[default]
None,
@ -13,20 +13,20 @@ pub enum IconKind {
},
}
#[rustfmt::skip]
#[derive(AutoDefault)]
#[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Icon {
classes : AttrClasses,
/// Devuelve las clases CSS asociadas al icono.
classes: Classes,
icon_kind: IconKind,
aria_label: AttrL10n,
}
impl Component for Icon {
fn new() -> Self {
Icon::default()
Self::default()
}
fn setup_before_prepare(&mut self, _cx: &mut Context) {
fn setup(&mut self, _cx: &Context) {
if !matches!(self.icon_kind(), IconKind::None) {
self.alter_classes(ClassesOp::Prepend, "icon");
}
@ -35,26 +35,26 @@ impl Component for Icon {
}
}
fn prepare_component(&self, cx: &mut Context) -> PrepareMarkup {
match self.icon_kind() {
IconKind::None => PrepareMarkup::None,
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();
PrepareMarkup::With(html! {
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());
PrepareMarkup::With(html! {
html! {
svg
xmlns="http://www.w3.org/2000/svg"
viewBox=(viewbox)
@ -67,30 +67,30 @@ impl Component for Icon {
{
(shapes)
}
}
}
})
}
}
}
}
impl Icon {
pub fn font() -> Self {
Icon::default().with_icon_kind(IconKind::Font(FontSize::default()))
Self::default().with_icon_kind(IconKind::Font(FontSize::default()))
}
pub fn font_sized(font_size: FontSize) -> Self {
Icon::default().with_icon_kind(IconKind::Font(font_size))
Self::default().with_icon_kind(IconKind::Font(font_size))
}
pub fn svg(shapes: Markup) -> Self {
Icon::default().with_icon_kind(IconKind::Svg {
Self::default().with_icon_kind(IconKind::Svg {
shapes,
viewbox: AttrValue::default(),
})
}
pub fn svg_with_viewbox(shapes: Markup, viewbox: impl AsRef<str>) -> Self {
Icon::default().with_icon_kind(IconKind::Svg {
Self::default().with_icon_kind(IconKind::Svg {
shapes,
viewbox: AttrValue::new(viewbox),
})
@ -116,19 +116,4 @@ impl Icon {
self.aria_label.alter_value(label);
self
}
// **< Icon GETTERS >***************************************************************************
/// Devuelve las clases CSS asociadas al icono.
pub fn classes(&self) -> &AttrClasses {
&self.classes
}
pub fn icon_kind(&self) -> &IconKind {
&self.icon_kind
}
pub fn aria_label(&self) -> &AttrL10n {
&self.aria_label
}
}

View file

@ -9,36 +9,40 @@ use crate::prelude::*;
/// ([`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`].
#[rustfmt::skip]
#[derive(AutoDefault)]
#[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Image {
#[getters(skip)]
id: AttrId,
classes: AttrClasses,
/// 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,
alt : AttrL10n,
/// Devuelve el texto alternativo localizado.
alternative: Attr<L10n>,
}
impl Component for Image {
fn new() -> Self {
Image::default()
Self::default()
}
fn id(&self) -> Option<String> {
self.id.get()
}
fn setup_before_prepare(&mut self, _cx: &mut Context) {
fn setup(&mut self, _cx: &Context) {
self.alter_classes(ClassesOp::Prepend, self.source().to_class());
}
fn prepare_component(&self, cx: &mut Context) -> PrepareMarkup {
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 PrepareMarkup::With(html! {
return Ok(html! {
span
id=[self.id()]
class=[self.classes().get()]
@ -55,7 +59,7 @@ impl Component for Image {
image::Source::Thumbnail(source) => Some(source),
image::Source::Plain(source) => Some(source),
};
PrepareMarkup::With(html! {
Ok(html! {
img
src=[source]
alt=(alt_text)
@ -69,7 +73,7 @@ impl Component for Image {
impl Image {
/// Crea rápidamente una imagen especificando su origen.
pub fn with(source: image::Source) -> Self {
Image::default().with_source(source)
Self::default().with_source(source)
}
// **< Image BUILDER >**************************************************************************
@ -77,7 +81,7 @@ impl Image {
/// Establece el identificador único (`id`) de la imagen.
#[builder_fn]
pub fn with_id(mut self, id: impl AsRef<str>) -> Self {
self.id.alter_value(id);
self.id.alter_id(id);
self
}
@ -89,7 +93,7 @@ impl Image {
/// - Redondear las esquinas ([`classes::Rounded`]).
#[builder_fn]
pub fn with_classes(mut self, op: ClassesOp, classes: impl AsRef<str>) -> Self {
self.classes.alter_value(op, classes);
self.classes.alter_classes(op, classes);
self
}
@ -107,35 +111,13 @@ impl Image {
self
}
/// Define el texto alternativo localizado ([`L10n`]) para la imagen.
/// 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.alt.alter_value(alt);
self.alternative.alter_value(alt);
self
}
// **< Image GETTERS >**************************************************************************
/// Devuelve las clases CSS asociadas a la imagen.
pub fn classes(&self) -> &AttrClasses {
&self.classes
}
/// Devuelve las dimensiones de la imagen.
pub fn size(&self) -> &image::Size {
&self.size
}
/// Devuelve el origen de la imagen.
pub fn source(&self) -> &image::Source {
&self.source
}
/// Devuelve el texto alternativo localizado.
pub fn alternative(&self) -> &AttrL10n {
&self.alt
}
}

View file

@ -31,7 +31,7 @@ pub enum Size {
}
impl Size {
// Devuelve el valor del atributo `style` en función del tamaño, o `None` si no aplica.
/// 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 {
@ -54,23 +54,47 @@ pub enum Source {
Logo(PageTopSvg),
/// Imagen que se adapta automáticamente a su contenedor.
///
/// El `String` asociado es la URL (o ruta) de la imagen.
Responsive(String),
/// Lleva asociada la URL (o ruta) de la imagen.
Responsive(CowStr),
/// Imagen que aplica el estilo **miniatura** de Bootstrap.
///
/// El `String` asociado es la URL (o ruta) de la imagen.
Thumbnail(String),
/// Lleva asociada la URL (o ruta) de la imagen.
Thumbnail(CowStr),
/// Imagen sin clases específicas de Bootstrap, útil para controlar con CSS propio.
///
/// El `String` asociado es la URL (o ruta) de la imagen.
Plain(String),
/// 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";
// Devuelve la clase base asociada a la imagen según la fuente.
/// 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 {
@ -93,9 +117,8 @@ impl Source {
classes.push_str(s);
} */
// Devuelve la clase asociada a la imagen según la fuente.
#[inline]
pub(crate) fn to_class(&self) -> String {
/// 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()

View file

@ -14,17 +14,17 @@
//! # use pagetop_bootsier::prelude::*;
//! let nav = Nav::tabs()
//! .with_layout(nav::Layout::End)
//! .add_item(nav::Item::link(L10n::n("Home"), |_| "/"))
//! .add_item(nav::Item::link_blank(L10n::n("External"), |_| "https://www.google.es"))
//! .add_item(nav::Item::dropdown(
//! .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_items(TypedOp::AddMany(vec![
//! Typed::with(dropdown::Item::link(L10n::n("Action"), |_| "/action")),
//! Typed::with(dropdown::Item::link(L10n::n("Another action"), |_| "/another")),
//! .with_item(ChildOp::AddMany(vec![
//! dropdown::Item::link(L10n::n("Action"), |_| "/action".into()).into(),
//! dropdown::Item::link(L10n::n("Another"), |_| "/another".into()).into(),
//! ])),
//! ))
//! .add_item(nav::Item::link_disabled(L10n::n("Disabled"), |_| "#"));
//! .with_item(nav::Item::link_disabled(L10n::n("Disabled"), |_| "#".into()));
//! ```
mod props;

View file

@ -10,26 +10,30 @@ use crate::prelude::*;
///
/// Ver ejemplo en el módulo [`nav`].
/// Si no contiene elementos, el componente **no se renderiza**.
#[rustfmt::skip]
#[derive(AutoDefault)]
#[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Nav {
#[getters(skip)]
id: AttrId,
classes : AttrClasses,
items : Children,
/// 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 {
Nav::default()
Self::default()
}
fn id(&self) -> Option<String> {
self.id.get()
}
fn setup_before_prepare(&mut self, _cx: &mut Context) {
fn setup(&mut self, _cx: &Context) {
self.alter_classes(ClassesOp::Prepend, {
let mut classes = "nav".to_string();
self.nav_kind().push_class(&mut classes);
@ -38,13 +42,13 @@ impl Component for Nav {
});
}
fn prepare_component(&self, cx: &mut Context) -> PrepareMarkup {
fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
let items = self.items().render(cx);
if items.is_empty() {
return PrepareMarkup::None;
return Ok(html! {});
}
PrepareMarkup::With(html! {
Ok(html! {
ul id=[self.id()] class=[self.classes().get()] {
(items)
}
@ -55,17 +59,17 @@ impl Component for Nav {
impl Nav {
/// Crea un `Nav` usando pestañas para los elementos (*Tabs*).
pub fn tabs() -> Self {
Nav::default().with_kind(nav::Kind::Tabs)
Self::default().with_kind(nav::Kind::Tabs)
}
/// Crea un `Nav` usando botones para los elementos (*Pills*).
pub fn pills() -> Self {
Nav::default().with_kind(nav::Kind::Pills)
Self::default().with_kind(nav::Kind::Pills)
}
/// Crea un `Nav` usando elementos subrayados (*Underline*).
pub fn underline() -> Self {
Nav::default().with_kind(nav::Kind::Underline)
Self::default().with_kind(nav::Kind::Underline)
}
// **< Nav BUILDER >****************************************************************************
@ -73,14 +77,14 @@ impl Nav {
/// Establece el identificador único (`id`) del menú.
#[builder_fn]
pub fn with_id(mut self, id: impl AsRef<str>) -> Self {
self.id.alter_value(id);
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_value(op, classes);
self.classes.alter_classes(op, classes);
self
}
@ -98,38 +102,21 @@ impl Nav {
self
}
/// Añade un nuevo elemento hijo al menú.
pub fn add_item(mut self, item: nav::Item) -> Self {
self.items.add(Child::with(item));
self
}
/// Modifica la lista de elementos (`children`) aplicando una operación [`TypedOp`].
/// 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_items(mut self, op: TypedOp<nav::Item>) -> Self {
self.items.alter_typed(op);
pub fn with_item(mut self, op: impl Into<ChildOp>) -> Self {
self.items.alter_child(op.into());
self
}
// **< Nav GETTERS >****************************************************************************
/// Devuelve las clases CSS asociadas al menú.
pub fn classes(&self) -> &AttrClasses {
&self.classes
}
/// Devuelve el estilo visual seleccionado.
pub fn nav_kind(&self) -> &nav::Kind {
&self.nav_kind
}
/// Devuelve la distribución y orientación seleccionada.
pub fn nav_layout(&self) -> &nav::Layout {
&self.nav_layout
}
/// Devuelve la lista de elementos (`children`) del menú.
pub fn items(&self) -> &Children {
&self.items
}
}

View file

@ -10,23 +10,27 @@ use crate::LOCALES_BOOTSIER;
///
/// Define internamente la naturaleza del elemento y su comportamiento al mostrarse o interactuar
/// con él.
#[derive(AutoDefault)]
#[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. Opcionalmente puede abrirse en una nueva ventana y estar
/// inicialmente deshabilitado.
/// 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,
path: FnPathByContext,
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(Typed<Dropdown>),
Dropdown(Embed<Dropdown>),
}
impl ItemKind {
@ -56,7 +60,7 @@ impl ItemKind {
classes.push_str(class);
} */
// Devuelve las clases asociadas al tipo de elemento.
/// Devuelve las clases asociadas al tipo de elemento.
#[inline]
pub(crate) fn to_class(&self) -> String {
self.as_str().to_owned()
@ -68,52 +72,54 @@ impl ItemKind {
/// 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, botón o menú desplegable según su [`ItemKind`].
/// puede comportarse como texto, enlace, contenido HTML o menú desplegable, según su [`ItemKind`].
///
/// Permite definir identificador, clases de estilo adicionales o tipo de interacción asociada,
/// manteniendo una interfaz común para renderizar todos los elementos del menú.
#[rustfmt::skip]
#[derive(AutoDefault)]
/// 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,
classes : AttrClasses,
/// 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 {
Item::default()
Self::default()
}
fn id(&self) -> Option<String> {
self.id.get()
}
fn setup_before_prepare(&mut self, _cx: &mut Context) {
fn setup(&mut self, _cx: &Context) {
self.alter_classes(ClassesOp::Prepend, self.item_kind().to_class());
}
fn prepare_component(&self, cx: &mut Context) -> PrepareMarkup {
match self.item_kind() {
ItemKind::Void => PrepareMarkup::None,
fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
Ok(match self.item_kind() {
ItemKind::Void => html! {},
ItemKind::Label(label) => PrepareMarkup::With(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,
path,
route,
blank,
disabled,
} => {
let path = path(cx);
let route_link = route(cx);
let current_path = cx.request().map(|request| request.path());
let is_current = !*disabled && (current_path == Some(path));
let is_current = !*disabled && (current_path == Some(route_link.path()));
let mut classes = "nav-link".to_string();
if is_current {
@ -123,14 +129,14 @@ impl Component for Item {
classes.push_str(" disabled");
}
let href = (!*disabled).then_some(path);
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");
PrepareMarkup::With(html! {
html! {
li id=[self.id()] class=[self.classes().get()] {
a
class=(classes)
@ -143,21 +149,27 @@ impl Component for Item {
(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.borrow() {
if let Some(dd) = menu.get() {
let items = dd.items().render(cx);
if items.is_empty() {
return PrepareMarkup::None;
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())
});
PrepareMarkup::With(html! {
html! {
li id=[self.id()] class=[self.classes().get()] {
a
class="nav-link dropdown-toggle"
@ -172,30 +184,34 @@ impl Component for Item {
(items)
}
}
})
}
} else {
PrepareMarkup::None
}
html! {}
}
}
})
}
}
impl Item {
/// Crea un elemento de tipo texto, mostrado sin interacción.
pub fn label(label: L10n) -> Self {
Item {
Self {
item_kind: ItemKind::Label(label),
..Default::default()
}
}
/// Crea un enlace para la navegación.
pub fn link(label: L10n, path: FnPathByContext) -> Self {
Item {
///
/// 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,
path,
route,
blank: false,
disabled: false,
},
@ -204,11 +220,11 @@ impl Item {
}
/// Crea un enlace deshabilitado que no permite la interacción.
pub fn link_disabled(label: L10n, path: FnPathByContext) -> Self {
Item {
pub fn link_disabled(label: L10n, route: FnPathByContext) -> Self {
Self {
item_kind: ItemKind::Link {
label,
path,
route,
blank: false,
disabled: true,
},
@ -217,11 +233,11 @@ impl Item {
}
/// Crea un enlace que se abre en una nueva ventana o pestaña.
pub fn link_blank(label: L10n, path: FnPathByContext) -> Self {
Item {
pub fn link_blank(label: L10n, route: FnPathByContext) -> Self {
Self {
item_kind: ItemKind::Link {
label,
path,
route,
blank: true,
disabled: false,
},
@ -230,11 +246,11 @@ impl Item {
}
/// Crea un enlace inicialmente deshabilitado que se abriría en una nueva ventana.
pub fn link_blank_disabled(label: L10n, path: FnPathByContext) -> Self {
Item {
pub fn link_blank_disabled(label: L10n, route: FnPathByContext) -> Self {
Self {
item_kind: ItemKind::Link {
label,
path,
route,
blank: true,
disabled: true,
},
@ -242,14 +258,25 @@ impl Item {
}
}
/// 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 le 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`].
/// 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 {
Item {
item_kind: ItemKind::Dropdown(Typed::with(menu)),
Self {
item_kind: ItemKind::Dropdown(Embed::with(menu)),
..Default::default()
}
}
@ -259,26 +286,14 @@ impl Item {
/// Establece el identificador único (`id`) del elemento.
#[builder_fn]
pub fn with_id(mut self, id: impl AsRef<str>) -> Self {
self.id.alter_value(id);
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_value(op, classes);
self.classes.alter_classes(op, classes);
self
}
// **< Item GETTERS >***************************************************************************
/// Devuelve las clases CSS asociadas al elemento.
pub fn classes(&self) -> &AttrClasses {
&self.classes
}
/// Devuelve el tipo de elemento representado.
pub fn item_kind(&self) -> &ItemKind {
&self.item_kind
}
}

View file

@ -21,7 +21,7 @@ impl Kind {
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.
/// 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 {
@ -33,7 +33,7 @@ impl Kind {
}
}
// Añade la clase asociada al tipo de menú a la cadena de clases.
/// 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();
@ -83,7 +83,7 @@ impl Layout {
const FILL: &str = "nav-fill";
const JUSTIFIED: &str = "nav-justified";
// Devuelve la clase base asociada a la distribución y orientación del menú.
/// Devuelve la clase base asociada a la distribución y orientación del menú.
#[rustfmt::skip]
#[inline]
const fn as_str(self) -> &'static str {
@ -98,7 +98,7 @@ impl Layout {
}
}
// Añade la clase asociada a la distribución y orientación del menú a la cadena de clases.
/// 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();
@ -112,7 +112,7 @@ impl Layout {
}
/* Devuelve la clase asociada a la distribución y orientación del menú, o una cadena vacía si no
// aplica (reservado).
/// aplica (reservado).
#[inline]
pub(crate) fn to_class(self) -> String {
self.as_str().to_owned()

View file

@ -2,7 +2,7 @@
//!
//! 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).
//! [`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.
@ -15,11 +15,11 @@
//! # use pagetop::prelude::*;
//! # use pagetop_bootsier::prelude::*;
//! let navbar = Navbar::simple()
//! .add_item(navbar::Item::nav(
//! .with_item(navbar::Item::nav(
//! Nav::new()
//! .add_item(nav::Item::link(L10n::n("Home"), |_| "/"))
//! .add_item(nav::Item::link(L10n::n("About"), |_| "/about"))
//! .add_item(nav::Item::link(L10n::n("Contact"), |_| "/contact"))
//! .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()))
//! ));
//! ```
//!
@ -30,11 +30,11 @@
//! # use pagetop_bootsier::prelude::*;
//! let navbar = Navbar::simple_toggle()
//! .with_expand(BreakPoint::MD)
//! .add_item(navbar::Item::nav(
//! .with_item(navbar::Item::nav(
//! Nav::new()
//! .add_item(nav::Item::link(L10n::n("Home"), |_| "/"))
//! .add_item(nav::Item::link_blank(L10n::n("Docs"), |_| "https://docs.example.com"))
//! .add_item(nav::Item::link(L10n::n("Support"), |_| "/support"))
//! .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()))
//! ));
//! ```
//!
@ -45,19 +45,23 @@
//! # use pagetop_bootsier::prelude::*;
//! let brand = navbar::Brand::new()
//! .with_title(L10n::n("PageTop"))
//! .with_path(Some(|_| "/"));
//! .with_route(Some(|cx| cx.route("/")));
//!
//! let navbar = Navbar::brand_left(brand)
//! .add_item(navbar::Item::nav(
//! .with_item(navbar::Item::nav(
//! Nav::new()
//! .add_item(nav::Item::link(L10n::n("Home"), |_| "/"))
//! .add_item(nav::Item::dropdown(
//! .with_item(nav::Item::link(L10n::n("Home"), |_| "/".into()))
//! .with_item(nav::Item::dropdown(
//! Dropdown::new()
//! .with_title(L10n::n("Tools"))
//! .add_item(dropdown::Item::link(L10n::n("Generator"), |_| "/tools/gen"))
//! .add_item(dropdown::Item::link(L10n::n("Reports"), |_| "/tools/reports"))
//! .with_item(dropdown::Item::link(
//! L10n::n("Generator"), |_| "/tools/gen".into())
//! )
//! .with_item(dropdown::Item::link(
//! L10n::n("Reports"), |_| "/tools/reports".into())
//! )
//! ))
//! .add_item(nav::Item::link_disabled(L10n::n("Disabled"), |_| "#"))
//! .with_item(nav::Item::link_disabled(L10n::n("Disabled"), |_| "#".into()))
//! ));
//! ```
//!
@ -68,14 +72,14 @@
//! # use pagetop_bootsier::prelude::*;
//! let brand = navbar::Brand::new()
//! .with_title(L10n::n("Intranet"))
//! .with_path(Some(|_| "/"));
//! .with_route(Some(|cx| cx.route("/")));
//!
//! let navbar = Navbar::brand_right(brand)
//! .with_expand(BreakPoint::LG)
//! .add_item(navbar::Item::nav(
//! .with_item(navbar::Item::nav(
//! Nav::pills()
//! .add_item(nav::Item::link(L10n::n("Dashboard"), |_| "/dashboard"))
//! .add_item(nav::Item::link(L10n::n("Users"), |_| "/users"))
//! .with_item(nav::Item::link(L10n::n("Dashboard"), |_| "/dashboard".into()))
//! .with_item(nav::Item::link(L10n::n("Users"), |_| "/users".into()))
//! ));
//! ```
//!
@ -91,15 +95,15 @@
//! .with_backdrop(offcanvas::Backdrop::Enabled);
//!
//! let navbar = Navbar::offcanvas(oc)
//! .add_item(navbar::Item::nav(
//! .with_item(navbar::Item::nav(
//! Nav::new()
//! .add_item(nav::Item::link(L10n::n("Home"), |_| "/"))
//! .add_item(nav::Item::link(L10n::n("Profile"), |_| "/profile"))
//! .add_item(nav::Item::dropdown(
//! .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"))
//! .add_item(dropdown::Item::link(L10n::n("Settings"), |_| "/settings"))
//! .add_item(dropdown::Item::link(L10n::n("Help"), |_| "/help"))
//! .with_item(dropdown::Item::link(L10n::n("Settings"), |_| "/settings".into()))
//! .with_item(dropdown::Item::link(L10n::n("Help"), |_| "/help".into()))
//! ))
//! ));
//! ```
@ -111,15 +115,15 @@
//! # use pagetop_bootsier::prelude::*;
//! let brand = navbar::Brand::new()
//! .with_title(L10n::n("Main App"))
//! .with_path(Some(|_| "/"));
//! .with_route(Some(|cx| cx.route("/")));
//!
//! let navbar = Navbar::brand_left(brand)
//! .with_position(navbar::Position::FixedTop)
//! .add_item(navbar::Item::nav(
//! .with_item(navbar::Item::nav(
//! Nav::new()
//! .add_item(nav::Item::link(L10n::n("Dashboard"), |_| "/"))
//! .add_item(nav::Item::link(L10n::n("Donors"), |_| "/donors"))
//! .add_item(nav::Item::link(L10n::n("Stock"), |_| "/stock"))
//! .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()))
//! ));
//! ```

View file

@ -6,42 +6,46 @@ use crate::prelude::*;
///
/// Representa la identidad del sitio con una imagen, título y eslogan:
///
/// - Si hay URL ([`with_path()`](Self::with_path)), el bloque completo actúa como enlace. Por
/// - 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.
#[rustfmt::skip]
#[derive(AutoDefault)]
#[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Brand {
#[getters(skip)]
id: AttrId,
image : Typed<Image>,
/// 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,
#[default(_code = "Some(|_| \"/\")")]
path : Option<FnPathByContext>,
/// 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 {
Brand::default()
Self::default()
}
fn id(&self) -> Option<String> {
self.id.get()
}
fn prepare_component(&self, cx: &mut Context) -> PrepareMarkup {
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 PrepareMarkup::None;
return Ok(html! {});
}
let slogan = self.slogan().using(cx);
PrepareMarkup::With(html! {
@if let Some(path) = self.path() {
a class="navbar-brand" href=(path(cx)) { (image) (title) (slogan) }
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) }
}
@ -55,7 +59,7 @@ impl Brand {
/// Establece el identificador único (`id`) de la marca.
#[builder_fn]
pub fn with_id(mut self, id: impl AsRef<str>) -> Self {
self.id.alter_value(id);
self.id.alter_id(id);
self
}
@ -82,30 +86,8 @@ impl Brand {
/// Define la URL de destino. Si es `None`, la marca no será un enlace.
#[builder_fn]
pub fn with_path(mut self, path: Option<FnPathByContext>) -> Self {
self.path = path;
pub fn with_route(mut self, route: Option<FnPathByContext>) -> Self {
self.route = route;
self
}
// **< Brand GETTERS >**************************************************************************
/// Devuelve la imagen de marca (si la hay).
pub fn image(&self) -> &Typed<Image> {
&self.image
}
/// Devuelve el título de la identidad de marca.
pub fn title(&self) -> &L10n {
&self.title
}
/// Devuelve el eslogan de la marca.
pub fn slogan(&self) -> &L10n {
&self.slogan
}
/// Devuelve la función que resuelve la URL asociada a la marca (si existe).
pub fn path(&self) -> &Option<FnPathByContext> {
&self.path
}
}

View file

@ -14,27 +14,32 @@ const TOGGLE_OFFCANVAS: &str = "offcanvas";
///
/// Ver ejemplos en el módulo [`navbar`].
/// Si no contiene elementos, el componente **no se renderiza**.
#[rustfmt::skip]
#[derive(AutoDefault)]
#[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Navbar {
#[getters(skip)]
id: AttrId,
classes : AttrClasses,
/// 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 {
Navbar::default()
Self::default()
}
fn id(&self) -> Option<String> {
self.id.get()
}
fn setup_before_prepare(&mut self, _cx: &mut Context) {
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", "");
@ -43,10 +48,10 @@ impl Component for Navbar {
});
}
fn prepare_component(&self, cx: &mut Context) -> PrepareMarkup {
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 = join!("#", id_content);
let id_content_target = util::join!("#", id_content);
let aria_expanded = if data_bs_toggle == TOGGLE_COLLAPSE {
Some("false")
} else {
@ -70,14 +75,14 @@ impl Component for Navbar {
// Si no hay contenidos, no tiene sentido mostrar una barra vacía.
let items = self.items().render(cx);
if items.is_empty() {
return PrepareMarkup::None;
return Ok(html! {});
}
// Asegura que la barra tiene un id estable para poder asociarlo al colapso/offcanvas.
let id = cx.required_id::<Self>(self.id());
// Asegura que la barra tiene un `id` para poder asociarlo al colapso/offcanvas.
let id = cx.required_id::<Self>(self.id(), 1);
PrepareMarkup::With(html! {
nav id=(id) class=[self.classes().get()] {
Ok(html! {
nav id=(&id) class=[self.classes().get()] {
div class="container-fluid" {
@match self.layout() {
// Barra más sencilla: sólo contenido.
@ -87,10 +92,10 @@ impl Component for Navbar {
// Barra sencilla que se puede contraer/expandir.
navbar::Layout::SimpleToggle => {
@let id_content = join!(id, "-content");
@let id_content = util::join!(id, "-content");
(button(cx, TOGGLE_COLLAPSE, &id_content))
div id=(id_content) class="collapse navbar-collapse" {
div id=(&id_content) class="collapse navbar-collapse" {
(items)
}
},
@ -103,22 +108,22 @@ impl Component for Navbar {
// Barra con marca a la izquierda y botón a la derecha.
navbar::Layout::BrandLeft(brand) => {
@let id_content = join!(id, "-content");
@let id_content = util::join!(id, "-content");
(brand.render(cx))
(button(cx, TOGGLE_COLLAPSE, &id_content))
div id=(id_content) class="collapse navbar-collapse" {
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 = join!(id, "-content");
@let id_content = util::join!(id, "-content");
(button(cx, TOGGLE_COLLAPSE, &id_content))
(brand.render(cx))
div id=(id_content) class="collapse navbar-collapse" {
div id=(&id_content) class="collapse navbar-collapse" {
(items)
}
},
@ -128,7 +133,7 @@ impl Component for Navbar {
@let id_content = offcanvas.id().unwrap_or_default();
(button(cx, TOGGLE_OFFCANVAS, &id_content))
@if let Some(oc) = offcanvas.borrow() {
@if let Some(oc) = offcanvas.get() {
(oc.render_offcanvas(cx, Some(self.items())))
}
},
@ -139,7 +144,7 @@ impl Component for Navbar {
(brand.render(cx))
(button(cx, TOGGLE_OFFCANVAS, &id_content))
@if let Some(oc) = offcanvas.borrow() {
@if let Some(oc) = offcanvas.get() {
(oc.render_offcanvas(cx, Some(self.items())))
}
},
@ -150,7 +155,7 @@ impl Component for Navbar {
(button(cx, TOGGLE_OFFCANVAS, &id_content))
(brand.render(cx))
@if let Some(oc) = offcanvas.borrow() {
@if let Some(oc) = offcanvas.get() {
(oc.render_offcanvas(cx, Some(self.items())))
}
},
@ -164,47 +169,47 @@ impl Component for Navbar {
impl Navbar {
/// Crea una barra de navegación **simple**, sin marca y sin botón.
pub fn simple() -> Self {
Navbar::default().with_layout(navbar::Layout::Simple)
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 {
Navbar::default().with_layout(navbar::Layout::SimpleToggle)
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 {
Navbar::default().with_layout(navbar::Layout::SimpleBrandLeft(Typed::with(brand)))
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 {
Navbar::default().with_layout(navbar::Layout::BrandLeft(Typed::with(brand)))
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 {
Navbar::default().with_layout(navbar::Layout::BrandRight(Typed::with(brand)))
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 {
Navbar::default().with_layout(navbar::Layout::Offcanvas(Typed::with(oc)))
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 {
Navbar::default().with_layout(navbar::Layout::OffcanvasBrandLeft(
Typed::with(brand),
Typed::with(oc),
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 {
Navbar::default().with_layout(navbar::Layout::OffcanvasBrandRight(
Typed::with(brand),
Typed::with(oc),
Self::default().with_layout(navbar::Layout::OffcanvasBrandRight(
Embed::with(brand),
Embed::with(oc),
))
}
@ -213,7 +218,7 @@ impl Navbar {
/// 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_value(id);
self.id.alter_id(id);
self
}
@ -225,7 +230,7 @@ impl Navbar {
/// - 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_value(op, classes);
self.classes.alter_classes(op, classes);
self
}
@ -250,44 +255,21 @@ impl Navbar {
self
}
/// Añade un nuevo contenido hijo.
#[inline]
pub fn add_item(mut self, item: navbar::Item) -> Self {
self.items.add(Child::with(item));
self
}
/// Modifica la lista de contenidos (`children`) aplicando una operación [`TypedOp`].
/// 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_items(mut self, op: TypedOp<navbar::Item>) -> Self {
self.items.alter_typed(op);
pub fn with_item(mut self, op: impl Into<ChildOp>) -> Self {
self.items.alter_child(op.into());
self
}
// **< Navbar GETTERS >*************************************************************************
/// Devuelve las clases CSS asociadas a la barra de navegación.
pub fn classes(&self) -> &AttrClasses {
&self.classes
}
/// Devuelve el punto de ruptura configurado.
pub fn expand(&self) -> &BreakPoint {
&self.expand
}
/// Devuelve la disposición configurada para la barra de navegación.
pub fn layout(&self) -> &navbar::Layout {
&self.layout
}
/// Devuelve la posición configurada para la barra de navegación.
pub fn position(&self) -> &navbar::Position {
&self.position
}
/// Devuelve la lista de contenidos (`children`).
pub fn items(&self) -> &Children {
&self.items
}
}

View file

@ -7,7 +7,7 @@ use crate::prelude::*;
/// 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)]
#[derive(AutoDefault, Clone, Debug)]
pub enum Item {
/// Sin contenido, no produce salida.
#[default]
@ -17,16 +17,16 @@ pub enum Item {
/// Ú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(Typed<navbar::Brand>),
Brand(Embed<navbar::Brand>),
/// Representa un menú de navegación [`Nav`](crate::theme::Nav).
Nav(Typed<Nav>),
/// Representa un texto libre localizado.
Nav(Embed<Nav>),
/// Representa un *texto localizado* libre.
Text(L10n),
}
impl Component for Item {
fn new() -> Self {
Item::default()
Self::default()
}
fn id(&self) -> Option<String> {
@ -38,39 +38,39 @@ impl Component for Item {
}
}
fn setup_before_prepare(&mut self, _cx: &mut Context) {
fn setup(&mut self, _cx: &Context) {
if let Self::Nav(nav) = self {
if let Some(mut nav) = nav.borrow_mut() {
if let Some(mut nav) = nav.get() {
nav.alter_classes(ClassesOp::Prepend, "navbar-nav");
}
}
}
fn prepare_component(&self, cx: &mut Context) -> PrepareMarkup {
match self {
Self::Void => PrepareMarkup::None,
Self::Brand(brand) => PrepareMarkup::With(html! { (brand.render(cx)) }),
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.borrow() {
if let Some(nav) = nav.get() {
let items = nav.items().render(cx);
if items.is_empty() {
return PrepareMarkup::None;
return Ok(html! {});
}
PrepareMarkup::With(html! {
html! {
ul id=[nav.id()] class=[nav.classes().get()] {
(items)
}
})
}
} else {
PrepareMarkup::None
html! {}
}
}
Self::Text(text) => PrepareMarkup::With(html! {
Self::Text(text) => html! {
span class="navbar-text" {
(text.using(cx))
}
}),
}
},
})
}
}
@ -80,15 +80,15 @@ impl Item {
/// 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(Typed::with(brand))
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(Typed::with(item))
Self::Nav(Embed::with(item))
}
/// Crea un elemento de texto localizado, mostrado sin interacción.
/// Crea un elemento con un *texto localizado*, mostrado sin interacción.
pub fn text(item: L10n) -> Self {
Self::Text(item)
}

View file

@ -5,7 +5,7 @@ use crate::prelude::*;
// **< Layout >*************************************************************************************
/// Representa los diferentes tipos de presentación de una barra de navegación [`Navbar`].
#[derive(AutoDefault)]
#[derive(AutoDefault, Clone, Debug)]
pub enum Layout {
/// Barra simple, sin marca de identidad y sin botón de despliegue.
///
@ -19,24 +19,24 @@ pub enum Layout {
/// Barra simple, con marca de identidad a la izquierda y sin botón de despliegue.
///
/// La barra de navegación no se colapsa.
SimpleBrandLeft(Typed<navbar::Brand>),
SimpleBrandLeft(Embed<navbar::Brand>),
/// Barra con marca de identidad a la izquierda y botón de despliegue a la derecha.
BrandLeft(Typed<navbar::Brand>),
BrandLeft(Embed<navbar::Brand>),
/// Barra con botón de despliegue a la izquierda y marca de identidad a la derecha.
BrandRight(Typed<navbar::Brand>),
BrandRight(Embed<navbar::Brand>),
/// Contenido en [`Offcanvas`], con botón de despliegue a la izquierda y sin marca de identidad.
Offcanvas(Typed<Offcanvas>),
Offcanvas(Embed<Offcanvas>),
/// Contenido en [`Offcanvas`], con marca de identidad a la izquierda y botón de despliegue a la
/// derecha.
OffcanvasBrandLeft(Typed<navbar::Brand>, Typed<Offcanvas>),
OffcanvasBrandLeft(Embed<navbar::Brand>, Embed<Offcanvas>),
/// Contenido en [`Offcanvas`], con botón de despliegue a la izquierda y marca de identidad a la
/// derecha.
OffcanvasBrandRight(Typed<navbar::Brand>, Typed<Offcanvas>),
OffcanvasBrandRight(Embed<navbar::Brand>, Embed<Offcanvas>),
}
// **< Position >***********************************************************************************
@ -64,7 +64,7 @@ pub enum Position {
}
impl Position {
// Devuelve la clase base asociada a la posición de la barra de navegación.
/// Devuelve la clase base asociada a la posición de la barra de navegación.
#[inline]
const fn as_str(self) -> &'static str {
match self {
@ -76,7 +76,7 @@ impl Position {
}
}
// Añade la clase asociada a la posición de la barra de navegación a la cadena de clases.
/// Añade la clase asociada a la posición de la barra de navegación a la cadena de clases.
#[inline]
pub(crate) fn push_class(self, classes: &mut String) {
let class = self.as_str();
@ -90,7 +90,7 @@ impl Position {
}
/* Devuelve la clase asociada a la posición de la barra de navegación, o cadena vacía si no
// aplica (reservado).
/// aplica (reservado).
#[inline]
pub(crate) fn to_class(self) -> String {
self.as_str().to_string()

View file

@ -12,11 +12,11 @@
//! .with_backdrop(offcanvas::Backdrop::Enabled)
//! .with_body_scroll(offcanvas::BodyScroll::Enabled)
//! .with_visibility(offcanvas::Visibility::Default)
//! .add_child(Dropdown::new()
//! .with_child(Dropdown::new()
//! .with_title(L10n::n("Menu"))
//! .add_item(dropdown::Item::label(L10n::n("Label")))
//! .add_item(dropdown::Item::link_blank(L10n::n("Google"), |_| "https://www.google.es"))
//! .add_item(dropdown::Item::link(L10n::n("Sign out"), |_| "/signout"))
//! .with_item(dropdown::Item::label(L10n::n("Label")))
//! .with_item(dropdown::Item::link_blank(L10n::n("Docs"), |_| "https://docs.rs".into()))
//! .with_item(dropdown::Item::link(L10n::n("Sign out"), |_| "/signout".into()))
//! );
//! ```

View file

@ -21,30 +21,38 @@ use crate::LOCALES_BOOTSIER;
///
/// Ver ejemplo en el módulo [`offcanvas`].
/// Si no contiene elementos, el componente **no se renderiza**.
#[rustfmt::skip]
#[derive(AutoDefault)]
#[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Offcanvas {
#[getters(skip)]
id: AttrId,
classes : AttrClasses,
/// Devuelve las clases CSS asociadas al panel.
classes: Classes,
/// Devuelve el título del panel.
title: L10n,
/// Devuelve el punto de ruptura configurado para cambiar el comportamiento del panel.
breakpoint: BreakPoint,
/// Devuelve el comportamiento configurado para la capa de fondo.
backdrop: offcanvas::Backdrop,
scrolling : offcanvas::BodyScroll,
/// Indica si la página principal puede desplazarse mientras el panel está abierto.
body_scroll: offcanvas::BodyScroll,
/// Devuelve la posición de inicio del panel.
placement: offcanvas::Placement,
/// Devuelve el estado inicial del panel.
visibility: offcanvas::Visibility,
/// Devuelve la lista de componentes (`children`) del panel.
children: Children,
}
impl Component for Offcanvas {
fn new() -> Self {
Offcanvas::default()
Self::default()
}
fn id(&self) -> Option<String> {
self.id.get()
}
fn setup_before_prepare(&mut self, _cx: &mut Context) {
fn setup(&mut self, _cx: &Context) {
self.alter_classes(ClassesOp::Prepend, {
let mut classes = "offcanvas".to_string();
self.breakpoint().push_class(&mut classes, "offcanvas", "");
@ -54,8 +62,8 @@ impl Component for Offcanvas {
});
}
fn prepare_component(&self, cx: &mut Context) -> PrepareMarkup {
PrepareMarkup::With(self.render_offcanvas(cx, None))
fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
Ok(self.render_offcanvas(cx, None))
}
}
@ -65,14 +73,14 @@ impl Offcanvas {
/// Establece el identificador único (`id`) del panel.
#[builder_fn]
pub fn with_id(mut self, id: impl AsRef<str>) -> Self {
self.id.alter_value(id);
self.id.alter_id(id);
self
}
/// Modifica la lista de clases CSS aplicadas al panel.
#[builder_fn]
pub fn with_classes(mut self, op: ClassesOp, classes: impl AsRef<str>) -> Self {
self.classes.alter_value(op, classes);
self.classes.alter_classes(op, classes);
self
}
@ -109,7 +117,7 @@ impl Offcanvas {
/// Permite o bloquea el desplazamiento de la página principal mientras el panel está abierto.
#[builder_fn]
pub fn with_body_scroll(mut self, scrolling: offcanvas::BodyScroll) -> Self {
self.scrolling = scrolling;
self.body_scroll = scrolling;
self
}
@ -127,62 +135,14 @@ impl Offcanvas {
self
}
/// Añade un nuevo componente hijo al panel.
#[inline]
pub fn add_child(mut self, child: impl Component) -> Self {
self.children.add(Child::with(child));
self
}
/// Modifica la lista de componentes (`children`) aplicando una operación [`ChildOp`].
/// Añade un nuevo componente al panel o modifica la lista de componentes (`children`) con una
/// operación [`ChildOp`].
#[builder_fn]
pub fn with_children(mut self, op: ChildOp) -> Self {
self.children.alter_child(op);
pub fn with_child(mut self, op: impl Into<ChildOp>) -> Self {
self.children.alter_child(op.into());
self
}
// **< Offcanvas GETTERS >**********************************************************************
/// Devuelve las clases CSS asociadas al panel.
pub fn classes(&self) -> &AttrClasses {
&self.classes
}
/// Devuelve el título del panel.
pub fn title(&self) -> &L10n {
&self.title
}
/// Devuelve el punto de ruptura configurado para cambiar el comportamiento del panel.
pub fn breakpoint(&self) -> &BreakPoint {
&self.breakpoint
}
/// Devuelve el comportamiento configurado para la capa de fondo.
pub fn backdrop(&self) -> &offcanvas::Backdrop {
&self.backdrop
}
/// Indica si la página principal puede desplazarse mientras el panel está abierto.
pub fn body_scroll(&self) -> &offcanvas::BodyScroll {
&self.scrolling
}
/// Devuelve la posición de inicio del panel.
pub fn placement(&self) -> &offcanvas::Placement {
&self.placement
}
/// Devuelve el estado inicial del panel.
pub fn visibility(&self) -> &offcanvas::Visibility {
&self.visibility
}
/// Devuelve la lista de componentes (`children`) del panel.
pub fn children(&self) -> &Children {
&self.children
}
// **< Offcanvas HELPERS >**********************************************************************
pub(crate) fn render_offcanvas(&self, cx: &mut Context, extra: Option<&Children>) -> Markup {
@ -192,9 +152,9 @@ impl Offcanvas {
return html! {};
}
let id = cx.required_id::<Self>(self.id());
let id_label = join!(id, "-label");
let id_target = join!("#", id);
let id = cx.required_id::<Self>(self.id(), 1);
let id_label = util::join!(id, "-label");
let id_target = util::join!("#", id);
let body_scroll = match self.body_scroll() {
offcanvas::BodyScroll::Disabled => None,
@ -211,7 +171,7 @@ impl Offcanvas {
html! {
div
id=(id)
id=(&id)
class=[self.classes().get()]
tabindex="-1"
data-bs-scroll=[body_scroll]
@ -220,7 +180,7 @@ impl Offcanvas {
{
div class="offcanvas-header" {
@if !title.is_empty() {
h5 class="offcanvas-title" id=(id_label) { (title) }
h5 id=(&id_label) class="offcanvas-title" { (title) }
}
button
type="button"

View file

@ -48,7 +48,7 @@ pub enum Placement {
}
impl Placement {
// Devuelve la clase base asociada a la posición de aparición del panel.
/// Devuelve la clase base asociada a la posición de aparición del panel.
#[rustfmt::skip]
#[inline]
const fn as_str(self) -> &'static str {
@ -60,7 +60,7 @@ impl Placement {
}
}
// Añade la clase asociada a la posición de aparición del panel a la cadena de clases.
/// Añade la clase asociada a la posición de aparición del panel a la cadena de clases.
#[inline]
pub(crate) fn push_class(self, classes: &mut String) {
if !classes.is_empty() {
@ -89,7 +89,7 @@ pub enum Visibility {
}
impl Visibility {
// Devuelve la clase base asociada al estado inicial del panel.
/// Devuelve la clase base asociada al estado inicial del panel.
#[inline]
const fn as_str(self) -> &'static str {
match self {
@ -98,7 +98,7 @@ impl Visibility {
}
}
// Añade la clase asociada al estado inicial del panel a la cadena de clases.
/// Añade la clase asociada al estado inicial del panel a la cadena de clases.
#[inline]
pub(crate) fn push_class(self, classes: &mut String) {
let class = self.as_str();

View file

@ -18,6 +18,58 @@ $enable-cssgrid: true;
--bs-text-opacity: 0.1;
}
// FORMS
// Required field indicator
.form-required {
color: var(--bs-danger);
margin: 0 0.25rem;
}
// Form fields
.form-field {
margin-bottom: 1rem;
&:last-child {
margin-bottom: 0;
}
}
// Fieldset
fieldset {
position: relative;
background-color: var(--bs-body-bg);
border: var(--bs-border-width) solid var(--bs-border-color);
border-radius: var(--bs-border-radius);
padding: 2rem 1rem 1rem;
margin: 2rem 0 1rem;
}
fieldset > legend {
position: absolute;
top: 0;
left: 1rem;
transform: translateY(-50%);
background-color: var(--bs-body-bg);
border: var(--bs-border-width) solid var(--bs-border-color);
border-radius: var(--bs-border-radius);
padding: 0.125rem 0.75rem;
font-size: $font-size-sm;
line-height: 1.25;
width: fit-content;
max-width: 75%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.fieldset-description {
margin-bottom: 1rem;
}
// Check buttons, gap between label and first inline check
.form-label + .form-check-inline {
margin-left: 1rem;
}
// Extending utilities
$utilities: map-merge(
$utilities,
@ -106,3 +158,9 @@ $utilities: map-merge(
),
)
);
// Region Footer
.region-footer {
padding: .75rem 0 3rem;
text-align: center;
}

View file

@ -11,14 +11,14 @@
</div>
## Sobre PageTop
## 🧭 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
## ⚡️ Guía rápida
Añadir en el archivo `Cargo.toml` del proyecto:
@ -30,7 +30,7 @@ pagetop-build = { ... }
Y crear un archivo `build.rs` a la altura de `Cargo.toml` para indicar cómo se van a incluir los
archivos estáticos o cómo se van a compilar los archivos SCSS para el proyecto. Casos de uso:
## Incluir archivos estáticos desde un directorio
### Incluir archivos estáticos desde un directorio
Hay que preparar una carpeta en el proyecto con todos los archivos que se quieren incluir, por
ejemplo `static`, y añadir el siguiente código en `build.rs` para crear el conjunto de recursos:
@ -64,7 +64,7 @@ fn main() -> std::io::Result<()> {
}
```
## Compilar archivos SCSS a CSS
### Compilar archivos SCSS a CSS
Se puede compilar un archivo SCSS, que podría importar otros a su vez, para preparar un recurso con
el archivo CSS minificado obtenido. Por ejemplo:
@ -83,7 +83,7 @@ Este código compila el archivo `main.scss` de la carpeta `static` del proyecto,
llamado `main_styles` que contiene el archivo `styles.min.css` obtenido.
# 📦 Archivos generados
## 📦 Archivos generados
Cada conjunto de recursos [`StaticFilesBundle`] genera un archivo en el directorio estándar
[OUT_DIR](https://doc.rust-lang.org/cargo/reference/environment-variables.html#environment-variables-cargo-sets-for-build-scripts)
@ -111,14 +111,14 @@ impl Extension for MyExtension {
```
# 🚧 Advertencia
## 🚧 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
## 📜 Licencia
El código está disponible bajo una doble licencia:

View file

@ -104,7 +104,7 @@ use pagetop::prelude::*;
pub struct MyExtension;
impl Extension for MyExtension {
// Servicio web que publica los recursos de `guides` en `/ruta/a/guides`.
/// Servicio web que publica los recursos de `guides` en `/ruta/a/guides`.
fn configure_service(&self, scfg: &mut service::web::ServiceConfig) {
static_files_service!(scfg, guides => "/ruta/a/guides");
}

View file

@ -11,15 +11,16 @@
</div>
## Sobre PageTop
## 🧭 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.
## Créditos
Esta librería incluye entre sus macros una adaptación de
## 📚 Créditos
Este *crate* incluye entre sus macros una adaptación de
[maud-macros](https://crates.io/crates/maud_macros)
([0.27.0](https://github.com/lambda-fairy/maud/tree/v0.27.0/maud_macros)) de
[Chris Wong](https://crates.io/users/lambda-fairy) y una versión renombrada de
@ -29,14 +30,14 @@ necesidad de referenciar `maud` o `smart_default` en las dependencias del archiv
cada proyecto PageTop.
# 🚧 Advertencia
## 🚧 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
## 📜 Licencia
El código está disponible bajo una doble licencia:

View file

@ -20,7 +20,7 @@ configurables, basadas en HTML, CSS y JavaScript.
## Créditos
Esta librería incluye entre sus macros una adaptación de
Este *crate* incluye entre sus macros una adaptación de
[maud-macros](https://crates.io/crates/maud_macros)
([0.27.0](https://github.com/lambda-fairy/maud/tree/v0.27.0/maud_macros)) de
[Chris Wong](https://crates.io/users/lambda-fairy) y una versión renombrada de
@ -85,7 +85,7 @@ pub fn html(input: TokenStream) -> TokenStream {
/// b: 0,
/// c: Some(0),
/// d: vec![1, 2, 3],
/// e: "four".to_owned(),
/// e: "four".to_string(),
/// });
/// # }
/// ```
@ -138,7 +138,7 @@ pub fn derive_auto_default(input: TokenStream) -> TokenStream {
/// # }
/// ```
///
/// la macro rescribirá el método `with_` y generará un nuevo método `alter_`:
/// la macro reescribirá el método `with_` y generará un nuevo método `alter_`:
///
/// ```rust
/// # struct Example {value: Option<String>};
@ -157,7 +157,11 @@ pub fn derive_auto_default(input: TokenStream) -> TokenStream {
/// ```
///
/// De esta forma, cada método *builder* `with_...()` generará automáticamente su correspondiente
/// método `alter_...()` para dejar modificar instancias existentes.
/// método `alter_...()` para modificar instancias existentes.
///
/// La documentación del método `with_...()` incluirá también la firma resumida del método
/// `alter_...()` y un alias de búsqueda con su nombre, de tal manera que buscando `alter_...` en la
/// documentación se mostrará la entrada del método `with_...()`.
#[proc_macro_attribute]
pub fn builder_fn(_: TokenStream, item: TokenStream) -> TokenStream {
use syn::{parse2, FnArg, Ident, ImplItemFn, Pat, ReturnType, TraitItemFn, Type};
@ -282,11 +286,11 @@ pub fn builder_fn(_: TokenStream, item: TokenStream) -> TokenStream {
}
}
// Genera el nombre del método alter_...().
// Genera el nombre del método `alter_...()`.
let stem = with_name_str.strip_prefix("with_").expect("validated");
let alter_ident = Ident::new(&format!("alter_{stem}"), with_name.span());
// Extrae genéricos y cláusulas where.
// Extrae genéricos y cláusulas `where`.
let generics = &sig.generics;
let where_clause = &sig.generics.where_clause;
@ -319,28 +323,75 @@ pub fn builder_fn(_: TokenStream, item: TokenStream) -> TokenStream {
v
};
// Filtra los atributos descartando `#[doc]` y `#[inline]` para el método `alter_...()`.
let non_doc_or_inline_attrs: Vec<_> = attrs
.iter()
.filter(|a| {
let p = a.path();
!p.is_ident("doc") && !p.is_ident("inline")
})
.cloned()
.collect();
// Separa atributos de documentación y resto.
let mut doc_attrs = Vec::new();
let mut other_attrs = Vec::new();
let mut non_doc_or_inline_attrs = Vec::new();
// Documentación del método alter_...().
let doc = format!("Equivale a [`Self::{with_name_str}()`], pero fuera del patrón *builder*.");
for a in attrs.iter() {
let p = a.path();
if p.is_ident("doc") {
doc_attrs.push(a.clone());
} else {
other_attrs.push(a.clone());
if !p.is_ident("inline") {
non_doc_or_inline_attrs.push(a.clone());
}
}
}
// Firma resumida de la función `alter_...()` para mostrarla en la doc de `with_...()`.
let alter_sig_tokens = if args.is_empty() {
// Sin argumentos sólo se muestra `&mut self` (puede que no tenga mucho sentido).
quote! { #vis_pub fn #alter_ident #generics (&mut self) -> &mut Self #where_clause }
} else {
// Con argumentos se muestra `&mut self, ...`.
quote! { #vis_pub fn #alter_ident #generics (&mut self, ...) -> &mut Self #where_clause }
};
// Normaliza espacios raros tipo `& mut`.
let alter_sig_str = alter_sig_tokens.to_string().replace("& mut", "&mut");
// Nombre de la función `alter_...()` como alias de búsqueda.
let alter_name_str = alter_ident.to_string();
// Texto introductorio para la documentación adicional de `with_...()`.
let with_alter_title = format!(
"# {} el método `{}()` generado por [`#[builder_fn]`](pagetop_macros::builder_fn)",
if doc_attrs.is_empty() {
"Añade"
} else {
"También añade"
},
alter_name_str
);
let with_alter_doc = concat!(
"Permite modificar la instancia (`&mut self`) con los mismos argumentos ",
"pero sin consumirla."
);
// Atributos completos que se aplican siempre a `with_...()`.
let with_prefix = quote! {
#(#other_attrs)*
#(#doc_attrs)*
#[doc(alias = #alter_name_str)]
#[doc = ""]
#[doc = #with_alter_title]
#[doc = #with_alter_doc]
#[doc = "```text"]
#[doc = #alter_sig_str]
#[doc = "```"]
};
// Genera el código final.
let expanded = match body_opt {
None => {
quote! {
#(#attrs)*
#with_prefix
fn #with_name #generics (self, #(#args),*) -> Self #where_clause;
#(#non_doc_or_inline_attrs)*
#[doc = #doc]
#[doc(hidden)]
fn #alter_ident #generics (&mut self, #(#args),*) -> &mut Self #where_clause;
}
}
@ -351,8 +402,10 @@ pub fn builder_fn(_: TokenStream, item: TokenStream) -> TokenStream {
} else {
quote! { #[inline] }
};
let with_fn = if is_trait {
quote! {
#with_prefix
#force_inline
#vis_pub fn #with_name #generics (self, #(#args),*) -> Self #where_clause {
let mut s = self;
@ -362,6 +415,7 @@ pub fn builder_fn(_: TokenStream, item: TokenStream) -> TokenStream {
}
} else {
quote! {
#with_prefix
#force_inline
#vis_pub fn #with_name #generics (mut self, #(#args),*) -> Self #where_clause {
self.#alter_ident(#(#call_idents),*);
@ -369,12 +423,12 @@ pub fn builder_fn(_: TokenStream, item: TokenStream) -> TokenStream {
}
}
};
quote! {
#(#attrs)*
#with_fn
#(#non_doc_or_inline_attrs)*
#[doc = #doc]
#[doc(hidden)]
#vis_pub fn #alter_ident #generics (&mut self, #(#args),*) -> &mut Self #where_clause {
#body
}

View file

@ -0,0 +1,21 @@
[package]
name = "pagetop-minimal"
version = "0.0.10"
edition = "2021"
description = """
Reúne un conjunto mínimo de macros para mejorar el formato y la eficiencia de operaciones
básicas en PageTop.
"""
categories = ["development-tools::build-utils"]
keywords = ["pagetop", "build", "assets", "resources", "static"]
repository.workspace = true
homepage.workspace = true
license.workspace = true
authors.workspace = true
[dependencies]
concat-string = "1.0"
indoc = "2.0"
pastey = "0.2"

View file

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

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

@ -0,0 +1,61 @@
<div align="center">
<h1>PageTop Minimal</h1>
<p>Reúne un conjunto mínimo de macros para mejorar el formato y la eficiencia de operaciones básicas en <strong>PageTop</strong>.</p>
[![Doc API](https://img.shields.io/docsrs/pagetop-minimal?label=Doc%20API&style=for-the-badge&logo=Docs.rs)](https://docs.rs/pagetop-minimal)
[![Crates.io](https://img.shields.io/crates/v/pagetop-minimal.svg?style=for-the-badge&logo=ipfs)](https://crates.io/crates/pagetop-minimal)
[![Descargas](https://img.shields.io/crates/d/pagetop-minimal.svg?label=Descargas&style=for-the-badge&logo=transmission)](https://crates.io/crates/pagetop-minimal)
[![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/helpers/pagetop-minimal#licencia)
</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.
## 🗺️ Descripción general
Este *crate* proporciona un conjunto básico de macros que se integran en las utilidades de PageTop
para optimizar operaciones habituales relacionadas con la composición estructurada de texto, la
concatenación de cadenas y el uso rápido de colecciones clave-valor.
## 📚 Créditos
Las macros para texto multilínea **`indoc!`**, **`formatdoc!`** y **`concatdoc!`** se reexportan del
*crate* [indoc](https://crates.io/crates/indoc) de [David Tolnay](https://crates.io/users/dtolnay).
Las macros para la concatenación de cadenas **`join!`** y **`join_pair!`** se apoyan internamente en
el *crate* [concat-string](https://crates.io/crates/concat_string), desarrollado por
[FaultyRAM](https://crates.io/users/FaultyRAM), para evitar el formato de cadenas cuando la
eficiencia pueda ser relevante.
La macro para generar identificadores dinámicos **`paste!`** se reexporta del *crate*
[pastey](https://crates.io/crates/pastey), una implementación avanzada y soportada del popular
`paste!` de [David Tolnay](https://crates.io/users/dtolnay).
## 🚧 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

@ -0,0 +1,161 @@
/*!
<div align="center">
<h1>PageTop Minimal</h1>
<p>Reúne un conjunto mínimo de macros para mejorar el formato y la eficiencia de operaciones básicas en <strong>PageTop</strong>.</p>
[![Doc API](https://img.shields.io/docsrs/pagetop-minimal?label=Doc%20API&style=for-the-badge&logo=Docs.rs)](https://docs.rs/pagetop-minimal)
[![Crates.io](https://img.shields.io/crates/v/pagetop-minimal.svg?style=for-the-badge&logo=ipfs)](https://crates.io/crates/pagetop-minimal)
[![Descargas](https://img.shields.io/crates/d/pagetop-minimal.svg?label=Descargas&style=for-the-badge&logo=transmission)](https://crates.io/crates/pagetop-minimal)
[![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/helpers/pagetop-minimal#licencia)
</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.
## Descripción general
Este *crate* proporciona un conjunto básico de macros que se integran en las utilidades de PageTop
para optimizar operaciones habituales relacionadas con la composición estructurada de texto, la
concatenación de cadenas y el uso rápido de colecciones clave-valor.
## Créditos
Las macros para texto multilínea **`indoc!`**, **`formatdoc!`** y **`concatdoc!`** se reexportan del
*crate* [indoc](https://crates.io/crates/indoc) de [David Tolnay](https://crates.io/users/dtolnay).
Las macros para la concatenación de cadenas **`join!`** y **`join_pair!`** se apoyan internamente en
el *crate* [concat-string](https://crates.io/crates/concat_string), desarrollado por
[FaultyRAM](https://crates.io/users/FaultyRAM), para evitar el formato de cadenas cuando la
eficiencia pueda ser relevante.
La macro para generar identificadores dinámicos **`paste!`** se reexporta del *crate*
[pastey](https://crates.io/crates/pastey), una implementación avanzada y soportada del popular
`paste!` de [David Tolnay](https://crates.io/users/dtolnay).
*/
#![doc(
html_favicon_url = "https://git.cillero.es/manuelcillero/pagetop/raw/branch/main/static/favicon.ico"
)]
#[doc(hidden)]
pub use concat_string::concat_string;
pub use indoc::{concatdoc, formatdoc, indoc};
/// Permite *pegar* tokens y generar identificadores a partir de otros.
///
/// Dentro de `paste!`, los identificadores escritos como `[< ... >]` se combinan en uno solo que
/// puede reutilizarse para referirse a items existentes o para definir nuevos (funciones,
/// estructuras, métodos, etc.).
///
/// También admite modificadores de estilo (`lower`, `upper`, `snake`, `camel`, etc.) para
/// transformar fragmentos interpolados antes de construir el nuevo identificador.
pub use pastey::paste;
// La documentación anterior se copia en `pagetop::util::paste!` porque el *crate* original no la
// define y `pagetop` no la hereda automáticamente.
/// Concatena eficientemente varios fragmentos en un [`String`].
///
/// Esta macro exporta [`concat_string!`](https://docs.rs/concat-string). Acepta cualquier número de
/// fragmentos que implementen [`AsRef<str>`] y construye un [`String`] con el tamaño óptimo, de
/// forma eficiente y evitando el uso de cadenas de formato que penalicen el rendimiento.
///
/// # Ejemplo
///
/// ```rust
/// # use pagetop_minimal::join;
/// // Concatena todos los fragmentos directamente.
/// let result = join!("Hello", " ", "World");
/// assert_eq!(result, "Hello World".to_string());
///
/// // También funciona con valores vacíos.
/// let result_with_empty = join!("Hello", "", "World");
/// assert_eq!(result_with_empty, "HelloWorld".to_string());
///
/// // Un único fragmento devuelve el mismo valor.
/// let single_result = join!("Hello");
/// assert_eq!(single_result, "Hello".to_string());
/// ```
#[macro_export]
macro_rules! join {
($($arg:expr),+) => {
$crate::concat_string!($($arg),+)
};
}
/// Concatena dos fragmentos en un [`String`] usando un separador inteligente.
///
/// Une los dos fragmentos, que deben implementar [`AsRef<str>`], usando el separador proporcionado.
/// Si uno de ellos está vacío, devuelve directamente el otro; y si ambos están vacíos devuelve un
/// [`String`] vacío.
///
/// # Ejemplo
///
/// ```rust
/// # use pagetop_minimal::join_pair;
/// let first = "Hello";
/// let separator = "-";
/// let second = "World";
///
/// // Concatena los dos fragmentos cuando ambos no están vacíos.
/// let result = join_pair!(first, separator, second);
/// assert_eq!(result, "Hello-World".to_string());
///
/// // Si el primer fragmento está vacío, devuelve el segundo.
/// let result_empty_first = join_pair!("", separator, second);
/// assert_eq!(result_empty_first, "World".to_string());
///
/// // Si el segundo fragmento está vacío, devuelve el primero.
/// let result_empty_second = join_pair!(first, separator, "");
/// assert_eq!(result_empty_second, "Hello".to_string());
///
/// // Si ambos fragmentos están vacíos, devuelve una cadena vacía.
/// let result_both_empty = join_pair!("", separator, "");
/// assert_eq!(result_both_empty, "".to_string());
/// ```
#[macro_export]
macro_rules! join_pair {
($first:expr, $separator:expr, $second:expr) => {{
let first_val = $first;
let second_val = $second;
let separator_val = $separator;
let first = AsRef::<str>::as_ref(&first_val);
let second = AsRef::<str>::as_ref(&second_val);
let separator = if first.is_empty() || second.is_empty() {
""
} else {
AsRef::<str>::as_ref(&separator_val)
};
$crate::concat_string!(first, separator, second)
}};
}
/// Macro para construir una colección de pares clave-valor.
///
/// ```rust
/// # use pagetop_minimal::kv;
/// # use std::collections::HashMap;
/// let args:HashMap<&str, String> = kv![
/// "userName" => "Roberto",
/// "photoCount" => "3",
/// "userGender" => "male",
/// ];
/// ```
#[macro_export]
macro_rules! kv {
( $($key:expr => $value:expr),* $(,)? ) => {{
let mut a = std::collections::HashMap::new();
$(
a.insert($key.into(), $value.into());
)*
a
}};
}

View file

@ -11,19 +11,21 @@
</div>
## Sobre PageTop
## 🧭 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.
## Descripción general
Esta librería permite incluir archivos estáticos en el ejecutable de las aplicaciones PageTop para
## 🗺️ Descripción general
Este *crate* permite incluir archivos estáticos en el ejecutable de las aplicaciones PageTop para
servirlos de forma eficiente vía web, con detección de cambios que optimizan el tiempo de
compilación.
## Créditos
## 📚 Créditos
Para ello, adapta el código de los *crates* [static-files](https://crates.io/crates/static_files)
(versión [0.2.5](https://github.com/static-files-rs/static-files/tree/v0.2.5)) y
@ -35,14 +37,14 @@ Estas implementaciones se integran en PageTop para evitar que cada proyecto teng
`static-files` manualmente como dependencia en su `Cargo.toml`.
# 🚧 Advertencia
## 🚧 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
## 📜 Licencia
El código está disponible bajo una doble licencia:

View file

@ -20,7 +20,7 @@ configurables, basadas en HTML, CSS y JavaScript.
## Descripción general
Esta librería permite incluir archivos estáticos en el ejecutable de las aplicaciones PageTop para
Este *crate* permite incluir archivos estáticos en el ejecutable de las aplicaciones PageTop para
servirlos de forma eficiente vía web, con detección de cambios que optimizan el tiempo de
compilación.

View file

@ -4,9 +4,10 @@ mod figfont;
use crate::core::{extension, extension::ExtensionRef};
use crate::html::Markup;
use crate::locale::Locale;
use crate::response::page::{ErrorPage, ResultPage};
use crate::service::HttpRequest;
use crate::{global, locale, service, trace};
use crate::{global, service, trace, PAGETOP_VERSION};
use actix_session::config::{BrowserSession, PersistentSession, SessionLifecycle};
use actix_session::storage::CookieSessionStore;
@ -49,7 +50,7 @@ impl Application {
Self::internal_prepare(Some(root_extension))
}
// Método interno para preparar la aplicación, opcionalmente con una extensión.
/// Método interno para preparar la aplicación, opcionalmente con una extensión.
fn internal_prepare(root_extension: Option<ExtensionRef>) -> Self {
// Al arrancar muestra una cabecera para la aplicación.
Self::show_banner();
@ -57,8 +58,8 @@ impl Application {
// Inicia gestión de trazas y registro de eventos (logging).
LazyLock::force(&trace::TRACING);
// Valida el identificador de idioma por defecto.
LazyLock::force(&locale::DEFAULT_LANGID);
// Inicializa el idioma predeterminado.
Locale::init();
// Registra las extensiones de la aplicación.
extension::all::register_extensions(root_extension);
@ -72,12 +73,12 @@ impl Application {
Self
}
// Muestra una cabecera para la aplicación basada en la configuración.
/// Muestra una cabecera para la aplicación basada en la configuración.
fn show_banner() {
use colored::Colorize;
use terminal_size::{terminal_size, Width};
if global::SETTINGS.app.startup_banner.to_lowercase() != "off" {
if global::SETTINGS.app.startup_banner != global::StartupBanner::Off {
// Nombre de la aplicación, ajustado al ancho del terminal si es necesario.
let mut app_ff = String::new();
let app_name = &global::SETTINGS.app.name;
@ -108,7 +109,7 @@ impl Application {
println!(
"{} {}\n",
"Powered by PageTop".yellow(),
env!("CARGO_PKG_VERSION").yellow()
PAGETOP_VERSION.yellow()
);
}
}
@ -163,7 +164,7 @@ impl Application {
Self::service_app()
}
// Configura el servicio web de la aplicación.
/// Configura el servicio web de la aplicación.
fn service_app() -> service::App<
impl service::Factory<
service::Request,

View file

@ -2,29 +2,19 @@ use crate::global;
use std::sync::LazyLock;
use figlet_rs::FIGfont;
use figlet_rs::FIGlet;
pub static FIGFONT: LazyLock<FIGfont> = LazyLock::new(|| {
pub static FIGFONT: LazyLock<FIGlet> = LazyLock::new(|| {
let slant = include_str!("slant.flf");
let small = include_str!("small.flf");
let speed = include_str!("speed.flf");
let starwars = include_str!("starwars.flf");
FIGfont::from_content(
match global::SETTINGS.app.startup_banner.to_lowercase().as_str() {
"off" => slant,
"slant" => slant,
"small" => small,
"speed" => speed,
"starwars" => starwars,
_ => {
println!(
"\n FIGfont \"{}\" not found for banner. Using \"Slant\". Check settings.",
global::SETTINGS.app.startup_banner,
);
slant
}
},
)
FIGlet::from_content(match global::SETTINGS.app.startup_banner {
global::StartupBanner::Off | global::StartupBanner::Slant => slant,
global::StartupBanner::Small => small,
global::StartupBanner::Speed => speed,
global::StartupBanner::Starwars => starwars,
})
.unwrap()
});

View file

@ -1,17 +1,5 @@
//! Acciones predefinidas para alterar el funcionamiento interno de PageTop.
use crate::prelude::*;
/// Tipo de función para manipular componentes y su contexto de renderizado.
///
/// Se usa en acciones definidas en [`component`] y [`theme`] para alterar el comportamiento de los
/// componentes.
///
/// Recibe referencias mutables (`&mut`) del componente `component` y del contexto `cx`.
pub type FnActionWithComponent<C> = fn(component: &mut C, cx: &mut Context);
pub mod component;
pub mod theme;
pub mod page;

View file

@ -1,7 +1,35 @@
//! Acciones que operan sobre componentes.
use pagetop::prelude::*;
/// Tipo de función para manipular componentes y su contexto de renderizado.
///
/// Se usa en [`action::component::BeforeRender`] y [`action::component::AfterRender`] para alterar
/// el comportamiento predefinido de los componentes antes y después de renderizarlos.
///
/// Recibe referencias mutables del componente `component` y del contexto `cx`.
pub type FnActionWithComponent<C> = fn(component: &mut C, cx: &mut Context);
/// Tipo de función para alterar el [`Markup`] generado por un componente.
///
/// Se usa en [`action::component::TransformMarkup`] para permitir a las extensiones alterar el HTML
/// final producido por el renderizado de un componente. La edición trabaja a nivel de texto: el
/// [`Markup`] recibido expone su contenido como [`String`], lo que permite aplicar búsquedas,
/// sustituciones, concatenaciones y cualquier otra primitiva de trabajo con cadenas.
///
/// La función recibe una referencia inmutable al componente `component` (el renderizado ya ha
/// concluido, solo se necesita leer su estado), y al contexto `cx` (solo para consulta), y toma
/// posesión del `markup` producido hasta ese momento.
///
/// Devuelve el nuevo [`Markup`] transformado, que se encadena como entrada para la siguiente acción
/// registrada, si la hay.
pub type FnActionTransformMarkup<C> = fn(component: &C, cx: &Context, markup: Markup) -> Markup;
mod before_render_component;
pub use before_render_component::*;
mod after_render_component;
pub use after_render_component::*;
mod transform_markup_component;
pub use transform_markup_component::*;

View file

@ -1,6 +1,6 @@
use crate::prelude::*;
use crate::base::action::FnActionWithComponent;
use super::FnActionWithComponent;
/// Ejecuta [`FnActionWithComponent`] después de renderizar un componente.
pub struct AfterRender<C: Component> {
@ -42,7 +42,7 @@ impl<C: Component> AfterRender<C> {
/// Afina el registro para ejecutar la acción [`FnActionWithComponent`] sólo para el componente
/// `C` con identificador `id`.
pub fn filter_by_referer_id(mut self, id: impl AsRef<str>) -> Self {
self.referer_id.alter_value(id);
self.referer_id.alter_id(id);
self
}
@ -52,28 +52,19 @@ impl<C: Component> AfterRender<C> {
self
}
// Despacha las acciones.
/// Despacha las acciones.
#[inline]
pub(crate) fn dispatch(component: &mut C, cx: &mut Context) {
// Primero despacha las acciones para el tipo de componente.
dispatch_actions(
&ActionKey::new(
UniqueId::of::<Self>(),
None,
Some(UniqueId::of::<C>()),
None,
),
&ActionKey::new(UniqueId::of::<Self>(), Some(UniqueId::of::<C>()), None),
|action: &Self| (action.f)(component, cx),
);
// Y luego despacha las acciones para el tipo de componente con un identificador dado.
if let Some(id) = component.id() {
dispatch_actions(
&ActionKey::new(
UniqueId::of::<Self>(),
None,
Some(UniqueId::of::<C>()),
Some(id),
),
&ActionKey::new(UniqueId::of::<Self>(), Some(UniqueId::of::<C>()), Some(id)),
|action: &Self| (action.f)(component, cx),
);
}

View file

@ -1,6 +1,6 @@
use crate::prelude::*;
use crate::base::action::FnActionWithComponent;
use super::FnActionWithComponent;
/// Ejecuta [`FnActionWithComponent`] antes de renderizar el componente.
pub struct BeforeRender<C: Component> {
@ -42,7 +42,7 @@ impl<C: Component> BeforeRender<C> {
/// Afina el registro para ejecutar la acción [`FnActionWithComponent`] sólo para el componente
/// `C` con identificador `id`.
pub fn filter_by_referer_id(mut self, id: impl AsRef<str>) -> Self {
self.referer_id.alter_value(id);
self.referer_id.alter_id(id);
self
}
@ -52,28 +52,19 @@ impl<C: Component> BeforeRender<C> {
self
}
// Despacha las acciones.
/// Despacha las acciones.
#[inline]
pub(crate) fn dispatch(component: &mut C, cx: &mut Context) {
// Primero despacha las acciones para el tipo de componente.
dispatch_actions(
&ActionKey::new(
UniqueId::of::<Self>(),
None,
Some(UniqueId::of::<C>()),
None,
),
&ActionKey::new(UniqueId::of::<Self>(), Some(UniqueId::of::<C>()), None),
|action: &Self| (action.f)(component, cx),
);
// Y luego despacha las aciones para el tipo de componente con un identificador dado.
if let Some(id) = component.id() {
dispatch_actions(
&ActionKey::new(
UniqueId::of::<Self>(),
None,
Some(UniqueId::of::<C>()),
Some(id),
),
&ActionKey::new(UniqueId::of::<Self>(), Some(UniqueId::of::<C>()), Some(id)),
|action: &Self| (action.f)(component, cx),
);
}

View file

@ -0,0 +1,82 @@
use crate::prelude::*;
use super::FnActionTransformMarkup;
/// Ejecuta [`FnActionTransformMarkup`] para alterar el renderizado de componentes.
pub struct TransformMarkup<C: Component> {
f: FnActionTransformMarkup<C>,
referer_type_id: Option<UniqueId>,
referer_id: AttrId,
weight: Weight,
}
/// Filtro para despachar [`FnActionTransformMarkup`] sobre el renderizado de un componente `C`.
impl<C: Component> ActionDispatcher for TransformMarkup<C> {
/// Devuelve el identificador de tipo ([`UniqueId`]) del componente `C`.
fn referer_type_id(&self) -> Option<UniqueId> {
self.referer_type_id
}
/// Devuelve el identificador del componente.
fn referer_id(&self) -> Option<String> {
self.referer_id.get()
}
/// Devuelve el peso para definir el orden de ejecución.
fn weight(&self) -> Weight {
self.weight
}
}
impl<C: Component> TransformMarkup<C> {
/// Permite [registrar](Extension::actions) una nueva acción [`FnActionTransformMarkup`].
pub fn new(f: FnActionTransformMarkup<C>) -> Self {
TransformMarkup {
f,
referer_type_id: Some(UniqueId::of::<C>()),
referer_id: AttrId::default(),
weight: 0,
}
}
/// Afina el registro para ejecutar la acción [`FnActionTransformMarkup`] sólo para el
/// componente `C` con identificador `id`.
pub fn filter_by_referer_id(mut self, id: impl AsRef<str>) -> Self {
self.referer_id.alter_id(id);
self
}
/// Opcional. Acciones con pesos más bajos se aplican antes. Se pueden usar valores negativos.
pub fn with_weight(mut self, value: Weight) -> Self {
self.weight = value;
self
}
/// Despacha las acciones encadenando el [`Markup`] entre cada una.
#[inline]
pub(crate) fn dispatch(component: &C, cx: &Context, markup: Markup) -> Markup {
let mut output = markup;
// Primero despacha las acciones para el tipo de componente.
dispatch_actions(
&ActionKey::new(UniqueId::of::<Self>(), Some(UniqueId::of::<C>()), None),
|action: &Self| {
let taken = std::mem::replace(&mut output, html! {});
output = (action.f)(component, cx, taken);
},
);
// Y luego despacha las acciones para el tipo de componente con un identificador dado.
if let Some(id) = component.id() {
dispatch_actions(
&ActionKey::new(UniqueId::of::<Self>(), Some(UniqueId::of::<C>()), Some(id)),
|action: &Self| {
let taken = std::mem::replace(&mut output, html! {});
output = (action.f)(component, cx, taken);
},
);
}
output
}
}

View file

@ -1,6 +1,6 @@
use crate::prelude::*;
use crate::base::action::page::FnActionWithPage;
use super::FnActionWithPage;
/// Ejecuta [`FnActionWithPage`](crate::base::action::page::FnActionWithPage) después de renderizar
/// el cuerpo de la página.
@ -34,12 +34,12 @@ impl AfterRenderBody {
self
}
// Despacha las acciones.
/// Despacha las acciones.
#[inline(always)]
#[allow(clippy::inline_always)]
pub(crate) fn dispatch(page: &mut Page) {
dispatch_actions(
&ActionKey::new(UniqueId::of::<Self>(), None, None, None),
&ActionKey::new(UniqueId::of::<Self>(), None, None),
|action: &Self| (action.f)(page),
);
}

View file

@ -1,6 +1,6 @@
use crate::prelude::*;
use crate::base::action::page::FnActionWithPage;
use super::FnActionWithPage;
/// Ejecuta [`FnActionWithPage`](crate::base::action::page::FnActionWithPage) antes de renderizar
/// el cuerpo de la página.
@ -34,12 +34,12 @@ impl BeforeRenderBody {
self
}
// Despacha las acciones.
/// Despacha las acciones.
#[inline(always)]
#[allow(clippy::inline_always)]
pub(crate) fn dispatch(page: &mut Page) {
dispatch_actions(
&ActionKey::new(UniqueId::of::<Self>(), None, None, None),
&ActionKey::new(UniqueId::of::<Self>(), None, None),
|action: &Self| (action.f)(page),
);
}

View file

@ -1,10 +0,0 @@
//! Acciones lanzadas desde los temas.
mod before_render_component;
pub use before_render_component::*;
mod after_render_component;
pub use after_render_component::*;
mod prepare_render;
pub use prepare_render::*;

View file

@ -1,50 +0,0 @@
use crate::prelude::*;
use crate::base::action::FnActionWithComponent;
/// Ejecuta [`FnActionWithComponent`] después de que un tema renderice el componente.
pub struct AfterRender<C: Component> {
f: FnActionWithComponent<C>,
theme_type_id: Option<UniqueId>,
referer_type_id: Option<UniqueId>,
}
/// Filtro para despachar [`FnActionWithComponent`] después de que un tema renderice el componente
/// `C`.
impl<C: Component> ActionDispatcher for AfterRender<C> {
/// Devuelve el identificador de tipo ([`UniqueId`]) del tema.
fn theme_type_id(&self) -> Option<UniqueId> {
self.theme_type_id
}
/// Devuelve el identificador de tipo ([`UniqueId`]) del componente `C`.
fn referer_type_id(&self) -> Option<UniqueId> {
self.referer_type_id
}
}
impl<C: Component> AfterRender<C> {
/// Permite [registrar](Extension::actions) una nueva acción [`FnActionWithComponent`] para un
/// tema dado.
pub fn new(theme: ThemeRef, f: FnActionWithComponent<C>) -> Self {
AfterRender {
f,
theme_type_id: Some(theme.type_id()),
referer_type_id: Some(UniqueId::of::<C>()),
}
}
// Despacha las acciones.
#[inline]
pub(crate) fn dispatch(component: &mut C, cx: &mut Context) {
dispatch_actions(
&ActionKey::new(
UniqueId::of::<Self>(),
Some(cx.theme().type_id()),
Some(UniqueId::of::<C>()),
None,
),
|action: &Self| (action.f)(component, cx),
);
}
}

View file

@ -1,50 +0,0 @@
use crate::prelude::*;
use crate::base::action::FnActionWithComponent;
/// Ejecuta [`FnActionWithComponent`] antes de que un tema renderice el componente.
pub struct BeforeRender<C: Component> {
f: FnActionWithComponent<C>,
theme_type_id: Option<UniqueId>,
referer_type_id: Option<UniqueId>,
}
/// Filtro para despachar [`FnActionWithComponent`] antes de que un tema renderice el componente
/// `C`.
impl<C: Component> ActionDispatcher for BeforeRender<C> {
/// Devuelve el identificador de tipo ([`UniqueId`]) del tema.
fn theme_type_id(&self) -> Option<UniqueId> {
self.theme_type_id
}
/// Devuelve el identificador de tipo ([`UniqueId`]) del componente `C`.
fn referer_type_id(&self) -> Option<UniqueId> {
self.referer_type_id
}
}
impl<C: Component> BeforeRender<C> {
/// Permite [registrar](Extension::actions) una nueva acción [`FnActionWithComponent`] para un
/// tema dado.
pub fn new(theme: ThemeRef, f: FnActionWithComponent<C>) -> Self {
BeforeRender {
f,
theme_type_id: Some(theme.type_id()),
referer_type_id: Some(UniqueId::of::<C>()),
}
}
// Despacha las acciones.
#[inline]
pub(crate) fn dispatch(component: &mut C, cx: &mut Context) {
dispatch_actions(
&ActionKey::new(
UniqueId::of::<Self>(),
Some(cx.theme().type_id()),
Some(UniqueId::of::<C>()),
None,
),
|action: &Self| (action.f)(component, cx),
);
}
}

View file

@ -1,63 +0,0 @@
use crate::prelude::*;
/// Tipo de función para alterar el renderizado de un componente.
///
/// Permite a un [tema](crate::base::action::theme) sobreescribir el renderizado predeterminado de
/// los componentes.
///
/// Recibe una referencia al componente `component` y una referencia mutable al contexto `cx`.
pub type FnPrepareRender<C> = fn(component: &C, cx: &mut Context) -> PrepareMarkup;
/// Ejecuta [`FnPrepareRender`] para preparar el renderizado de un componente.
///
/// Permite a un tema hacer una implementación nueva del renderizado de un componente.
pub struct PrepareRender<C: Component> {
f: FnPrepareRender<C>,
theme_type_id: Option<UniqueId>,
referer_type_id: Option<UniqueId>,
}
/// Filtro para despachar [`FnPrepareRender`] que modifica el renderizado de un componente `C`.
impl<C: Component> ActionDispatcher for PrepareRender<C> {
/// Devuelve el identificador de tipo ([`UniqueId`]) del tema.
fn theme_type_id(&self) -> Option<UniqueId> {
self.theme_type_id
}
/// Devuelve el identificador de tipo ([`UniqueId`]) del componente `C`.
fn referer_type_id(&self) -> Option<UniqueId> {
self.referer_type_id
}
}
impl<C: Component> PrepareRender<C> {
/// Permite [registrar](Extension::actions) una nueva acción [`FnPrepareRender`] para un tema
/// dado.
pub fn new(theme: ThemeRef, f: FnPrepareRender<C>) -> Self {
PrepareRender {
f,
theme_type_id: Some(theme.type_id()),
referer_type_id: Some(UniqueId::of::<C>()),
}
}
// Despacha las acciones. Se detiene en cuanto una renderiza.
#[inline]
pub(crate) fn dispatch(component: &C, cx: &mut Context) -> PrepareMarkup {
let mut render_component = PrepareMarkup::None;
dispatch_actions(
&ActionKey::new(
UniqueId::of::<Self>(),
Some(cx.theme().type_id()),
Some(UniqueId::of::<C>()),
None,
),
|action: &Self| {
if render_component.is_empty() {
render_component = (action.f)(component, cx);
}
},
);
render_component
}
}

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