Compare commits
86 commits
f0e5f50a7f
...
c046014d04
| Author | SHA1 | Date | |
|---|---|---|---|
| c046014d04 | |||
| 8856699c3b | |||
| 4b50e043e0 | |||
| 3ceb8892a2 | |||
| 863e7de3df | |||
| cb3448add2 | |||
| 3436f94b18 | |||
| 9173aee22c | |||
| 566e024f64 | |||
| 82dbe56d8a | |||
| 2f4ceb0127 | |||
| a53e512021 | |||
| 412446bb5a | |||
| f7e7ce469d | |||
| f1b8280e6f | |||
| 90bc9d56f1 | |||
| 2ac3988213 | |||
| 76ef339b42 | |||
| 8717d24ea3 | |||
| 2fbafce0e7 | |||
| cc7ddd3d06 | |||
| 59dbc2b710 | |||
| 75d4ff81cc | |||
| edc809aadd | |||
| b3292c32f7 | |||
| f26df464d9 | |||
| 97ea454040 | |||
| b118b43408 | |||
| f087c457cc | |||
| 2626234163 | |||
| f1d3218a68 | |||
| afd98e2c03 | |||
| e4349c45ac | |||
| 9f8640d6bf | |||
| 0684472df2 | |||
| 72580d91b6 | |||
| f51d90dd66 | |||
| 015b31733d | |||
| c5eeb67463 | |||
| 908fd969e4 | |||
| 6203a02b89 | |||
| bfaf2e569f | |||
| b39ed38d0d | |||
| cf7aba2b53 | |||
| e9d326cd99 | |||
| 41d5091348 | |||
| 00d4de840b | |||
| e9565bf70b | |||
| 4bf2c18b24 | |||
| fa32833ffa | |||
| 25d32ec5de | |||
| 47c47ba9a0 | |||
| dd5cdb19cf | |||
| 3db798ad3b | |||
| 57f2fa64f4 | |||
| 3f00b69902 | |||
| 11763d097d | |||
| 700bca7258 | |||
| b76c4a4d23 | |||
| 16d6afbd98 | |||
| 7b340a19f3 | |||
| 9297f51b42 | |||
| 476aff1d8e | |||
| caa4cf6096 | |||
| a46cf35fee | |||
| 6c024da51e | |||
| 0351000487 | |||
| e271437da8 | |||
| efd4975a50 | |||
| 10a8a1136c | |||
| c4d0a2f613 | |||
| da229e494d | |||
| 498df42b5b | |||
| 33669d90f6 | |||
| af26e6aef9 | |||
| 1fa1ddf528 | |||
| 76b980017d | |||
| 12e617f35b | |||
| 9c6888e378 | |||
| f2733bb250 | |||
| bfdc0da407 | |||
| 2ce74fec8e | |||
| 0849d23e3f | |||
| 4ac7caddd4 | |||
| a2fe2114cd | |||
| 9657672ffd |
178 changed files with 11596 additions and 4947 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -8,3 +8,5 @@
|
|||
**/local.*.toml
|
||||
**/local.toml
|
||||
.env
|
||||
.cargo
|
||||
.vscode
|
||||
|
|
|
|||
130
CONTRIBUTING.md
Normal file
130
CONTRIBUTING.md
Normal 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.
|
||||
13
CREDITS.md
13
CREDITS.md
|
|
@ -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
1235
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
18
Cargo.toml
18
Cargo.toml
|
|
@ -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
156
MAINTAINERS.md
Normal 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.
|
||||
55
README.md
55
README.md
|
|
@ -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
456
examples/form-controls.rs
Normal 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
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
82
examples/intro-colors.rs
Normal 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
|
||||
}
|
||||
74
examples/locale/en-US/form-controls.ftl
Normal file
74
examples/locale/en-US/form-controls.ftl
Normal 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
|
||||
8
examples/locale/en-US/intro-colors.ftl
Normal file
8
examples/locale/en-US/intro-colors.ftl
Normal 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.
|
||||
23
examples/locale/en-US/navbar-menus.ftl
Normal file
23
examples/locale/en-US/navbar-menus.ftl
Normal 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
|
||||
74
examples/locale/es-ES/form-controls.ftl
Normal file
74
examples/locale/es-ES/form-controls.ftl
Normal 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
|
||||
8
examples/locale/es-ES/intro-colors.ftl
Normal file
8
examples/locale/es-ES/intro-colors.ftl
Normal 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.
|
||||
23
examples/locale/es-ES/navbar-menus.ftl
Normal file
23
examples/locale/es-ES/navbar-menus.ftl
Normal 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
|
||||
|
|
@ -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,
|
||||
.with_item(nav::Item::link(L10n::t("menus_item_link", &LOC), |cx| {
|
||||
cx.route("/")
|
||||
}))
|
||||
.with_item(nav::Item::link_blank(
|
||||
L10n::t("menus_item_blank", &LOC),
|
||||
|_| "https://docs.rs/pagetop".into(),
|
||||
))
|
||||
.add_item(nav::Item::link_blank(
|
||||
L10n::l("sample_menus_item_blank"),
|
||||
|_| "https://docs.rs/pagetop",
|
||||
))
|
||||
.add_item(nav::Item::dropdown(
|
||||
.with_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",
|
||||
.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_dev_guides"),
|
||||
|_| "/dev/guides",
|
||||
.with_item(dropdown::Item::link(
|
||||
L10n::t("menus_dev_guides", &LOC),
|
||||
|cx| cx.route("/dev/guides"),
|
||||
))
|
||||
.add_item(dropdown::Item::link_blank(
|
||||
L10n::l("sample_menus_dev_forum"),
|
||||
|_| "https://forum.example.dev",
|
||||
.with_item(dropdown::Item::link_blank(
|
||||
L10n::t("menus_dev_forum", &LOC),
|
||||
|_| "https://forum.example.dev".into(),
|
||||
))
|
||||
.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",
|
||||
.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"),
|
||||
))
|
||||
.add_item(dropdown::Item::link(L10n::l("sample_menus_sdk_js"), |_| {
|
||||
"/dev/sdks/js"
|
||||
.with_item(dropdown::Item::link(L10n::t("menus_sdk_js", &LOC), |cx| {
|
||||
cx.route("/dev/sdks/js")
|
||||
}))
|
||||
.add_item(dropdown::Item::link(
|
||||
L10n::l("sample_menus_sdk_python"),
|
||||
|_| "/dev/sdks/python",
|
||||
.with_item(dropdown::Item::link(
|
||||
L10n::t("menus_sdk_python", &LOC),
|
||||
|cx| cx.route("/dev/sdks/python"),
|
||||
))
|
||||
.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(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"),
|
||||
))
|
||||
.add_item(dropdown::Item::link(
|
||||
L10n::l("sample_menus_plugin_cache"),
|
||||
|_| "/dev/sdks/rust/plugins/cache",
|
||||
.with_item(dropdown::Item::link(
|
||||
L10n::t("menus_plugin_cache", &LOC),
|
||||
|cx| cx.route("/dev/sdks/rust/plugins/cache"),
|
||||
))
|
||||
.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::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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
StyleSheet::from("/aliner/css/styles.css")
|
||||
.with_version(env!("CARGO_PKG_VERSION"))
|
||||
.with_weight(-90),
|
||||
));
|
||||
fn before_render_page_body(&self, page: &mut Page) {
|
||||
page.alter_assets(AssetsOp::AddStyleSheet(
|
||||
StyleSheet::from("/css/normalize.css")
|
||||
.with_version("8.0.1")
|
||||
.with_weight(-99),
|
||||
))
|
||||
.alter_assets(AssetsOp::AddStyleSheet(
|
||||
StyleSheet::from("/css/basic.css")
|
||||
.with_version(PAGETOP_VERSION)
|
||||
.with_weight(-99),
|
||||
))
|
||||
.alter_assets(AssetsOp::AddStyleSheet(
|
||||
StyleSheet::from("/aliner/css/styles.css")
|
||||
.with_version(env!("CARGO_PKG_VERSION"))
|
||||
.with_weight(-99),
|
||||
))
|
||||
.alter_child_in(
|
||||
&DefaultRegion::Footer,
|
||||
ChildOp::AddIfEmpty(PoweredBy::new().into()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: "(";
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -17,4 +17,4 @@ mod rounded;
|
|||
pub use rounded::RoundedRadius;
|
||||
|
||||
mod button;
|
||||
pub use button::{ButtonColor, ButtonSize};
|
||||
pub use button::{ButtonAction, ButtonColor, ButtonSize};
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,26 +88,10 @@ 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);
|
||||
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(),
|
||||
}
|
||||
let mut class = String::new();
|
||||
self.push_class(&mut class);
|
||||
class
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
213
extensions/pagetop-bootsier/src/theme/button.rs
Normal file
213
extensions/pagetop-bootsier/src/theme/button.rs
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
id : AttrId,
|
||||
classes : AttrClasses,
|
||||
container_kind : container::Kind,
|
||||
#[getters(skip)]
|
||||
id: AttrId,
|
||||
/// Devuelve las clases CSS asociadas al contenedor.
|
||||
classes: Classes,
|
||||
/// Devuelve el tipo semántico del contenedor.
|
||||
container_kind: container::Kind,
|
||||
/// Devuelve el comportamiento para el ancho del contenedor.
|
||||
container_width: container::Width,
|
||||
children : Children,
|
||||
/// 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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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, ""),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
id : AttrId,
|
||||
classes : AttrClasses,
|
||||
title : L10n,
|
||||
button_size : ButtonSize,
|
||||
button_color : ButtonColor,
|
||||
button_split : bool,
|
||||
#[getters(skip)]
|
||||
id: AttrId,
|
||||
/// Devuelve las clases CSS asociadas al menú desplegable.
|
||||
classes: Classes,
|
||||
/// Devuelve el título del menú desplegable.
|
||||
title: L10n,
|
||||
/// Devuelve el tamaño configurado del botón.
|
||||
button_size: ButtonSize,
|
||||
/// Devuelve el color/estilo configurado del botón.
|
||||
button_color: ButtonColor,
|
||||
/// Devuelve si se debe desdoblar (*split*) el botón (botón de acción + *toggle*).
|
||||
button_split: bool,
|
||||
/// Devuelve si el botón del menú está integrado en un grupo de botones.
|
||||
button_grouped: bool,
|
||||
auto_close : dropdown::AutoClose,
|
||||
direction : dropdown::Direction,
|
||||
menu_align : dropdown::MenuAlign,
|
||||
menu_position : dropdown::MenuPosition,
|
||||
items : Children,
|
||||
/// 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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
id : AttrId,
|
||||
classes : AttrClasses,
|
||||
#[getters(skip)]
|
||||
id: AttrId,
|
||||
/// Devuelve las clases CSS asociadas al elemento.
|
||||
classes: Classes,
|
||||
/// Devuelve el tipo de elemento representado.
|
||||
item_kind: ItemKind,
|
||||
}
|
||||
|
||||
impl Component for Item {
|
||||
fn new() -> Self {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
62
extensions/pagetop-bootsier/src/theme/form.rs
Normal file
62
extensions/pagetop-bootsier/src/theme/form.rs
Normal 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;
|
||||
257
extensions/pagetop-bootsier/src/theme/form/check.rs
Normal file
257
extensions/pagetop-bootsier/src/theme/form/check.rs
Normal 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
|
||||
}
|
||||
}
|
||||
232
extensions/pagetop-bootsier/src/theme/form/checkbox.rs
Normal file
232
extensions/pagetop-bootsier/src/theme/form/checkbox.rs
Normal 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
|
||||
}
|
||||
}
|
||||
127
extensions/pagetop-bootsier/src/theme/form/component.rs
Normal file
127
extensions/pagetop-bootsier/src/theme/form/component.rs
Normal 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
|
||||
}
|
||||
}
|
||||
116
extensions/pagetop-bootsier/src/theme/form/fieldset.rs
Normal file
116
extensions/pagetop-bootsier/src/theme/form/fieldset.rs
Normal 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
|
||||
}
|
||||
}
|
||||
72
extensions/pagetop-bootsier/src/theme/form/hidden.rs
Normal file
72
extensions/pagetop-bootsier/src/theme/form/hidden.rs
Normal 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
|
||||
}
|
||||
}
|
||||
453
extensions/pagetop-bootsier/src/theme/form/input.rs
Normal file
453
extensions/pagetop-bootsier/src/theme/form/input.rs
Normal 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
|
||||
}
|
||||
}
|
||||
484
extensions/pagetop-bootsier/src/theme/form/props.rs
Normal file
484
extensions/pagetop-bootsier/src/theme/form/props.rs
Normal 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,
|
||||
}
|
||||
269
extensions/pagetop-bootsier/src/theme/form/radio.rs
Normal file
269
extensions/pagetop-bootsier/src/theme/form/radio.rs
Normal 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
|
||||
}
|
||||
}
|
||||
192
extensions/pagetop-bootsier/src/theme/form/range.rs
Normal file
192
extensions/pagetop-bootsier/src/theme/form/range.rs
Normal 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
|
||||
}
|
||||
}
|
||||
459
extensions/pagetop-bootsier/src/theme/form/select.rs
Normal file
459
extensions/pagetop-bootsier/src/theme/form/select.rs
Normal 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
|
||||
}
|
||||
}
|
||||
290
extensions/pagetop-bootsier/src/theme/form/textarea.rs
Normal file
290
extensions/pagetop-bootsier/src/theme/form/textarea.rs
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
icon_kind : IconKind,
|
||||
/// 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 | ||||