Compare commits

...

224 commits

Author SHA1 Message Date
dd5cdb19cf 📝 Actualiza las guías de contribución 2025-12-21 09:47:35 +01:00
3db798ad3b 📝 Añade guías de contribución y revisa estilos 2025-12-19 11:15:49 +01:00
57f2fa64f4 🎨 Mejora gestión de errores para 403, 404 y otros 2025-12-17 12:40:53 +01:00
3f00b69902 🚨 Ajuste menor sugerido por clippy 2025-12-17 12:36:26 +01:00
11763d097d 🔥 Elimina anotaciones #[inline] para evitar abuso 2025-12-17 12:35:21 +01:00
700bca7258 💡 Repasa comentarios de la cabecera de funciones 2025-12-16 09:06:52 +01:00
b76c4a4d23 🚧 (config): Opciones enumeradas para el log 2025-12-15 20:51:23 +01:00
16d6afbd98 🚧 (config): Nueva gestión de opciones enumeradas 2025-12-15 17:24:00 +01:00
7b340a19f3 (locale): Refactoriza el sistema de localización
- Modulariza la lógica de localización.
- Actualiza la estructura de `Locale` para mejorar la resolución y
  gestión de idiomas.
- Introduce `RequestLocale` para manejar la negociación de idioma basada
  en las peticiones HTTP.
- Mejora `L10n` para ofrecer una gestión más flexible de traducciones
  con argumentos dinámicos.
- Actualiza la implementación de `LangId` en `Page` para garantizar una
  identificación de idioma coherente.
- Elimina código obsoleto y simplifica la gestión de identificadores de
  idioma.
2025-12-14 14:33:35 +01:00
9297f51b42 🔨 Actualiza *script* de generación de CHANGELOGs 2025-12-12 00:20:25 +01:00
476aff1d8e (pagetop): Añade gestión de rutas con parámetros 2025-12-12 00:14:55 +01:00
caa4cf6096 🚚 Renombra LangMatch por Locale 2025-12-10 15:18:07 +01:00
a46cf35fee ♻️ (minimal): Incorpora paste! a las utilidades 2025-12-07 12:49:51 +01:00
6c024da51e (minimal): Añade macros declarativas a utilidades
- Incorpora nuevo *crate* `pagetop-minimal` con macros básicas para
  operaciones con cadenas, bloques de texto o colecciones clave-valor.
- Refactoriza código para usar `util::join!` y `util::join_pair!` en la
  concatenación de cadenas.
- Normaliza la gestión de localización usando `util::kv!` para los
  argumentos con pares clave-valor.
- Actualizada documentación y archivos README para reflejar la nueva
  estructura y funcionalidades.
2025-12-07 11:55:26 +01:00
0351000487 🩹 (bootsier): Corrige más enlaces dinámicos 2025-12-07 11:37:23 +01:00
e271437da8 🩹 Corrige ejemplos con enlaces dinámicos 2025-12-03 23:06:35 +01:00
efd4975a50 🚧 Retoques menores en la documentación 2025-12-03 22:56:04 +01:00
10a8a1136c ♻️ Refactoriza gestión de idiomas en el contexto 2025-12-03 22:55:24 +01:00
c4d0a2f613 Añade config. para activar página de bienvenida 2025-12-03 22:48:05 +01:00
da229e494d Añade soporte a contenido HTML en ítems de menú 2025-12-03 06:47:33 +01:00
498df42b5b 🎨 (pagetop): Mejora gestión de URLs según contexto 2025-12-03 06:41:52 +01:00
33669d90f6 (pagetop): Añade macro Getters para estructuras 2025-11-30 12:51:45 +01:00
af26e6aef9 🌐 Normaliza textos y localización a *snake_case* 2025-11-30 11:42:03 +01:00
1fa1ddf528 💡Retoques menores en comentarios 2025-11-30 11:14:34 +01:00
76b980017d 💄 Mejora alineación del texto en ejemplos 2025-11-30 11:14:08 +01:00
12e617f35b 🚧 Afina el mensaje generado por builder_fn 2025-11-30 11:11:39 +01:00
9c6888e378 (bootsier): Añade plantilla estándar propia 2025-11-30 10:53:49 +01:00
f2733bb250 ♻️ Refactoriza la gestión de regiones y plantillas 2025-11-30 00:16:54 +01:00
bfdc0da407 🚧 Mejora documentación generada por builder_fn 2025-11-29 14:43:37 +01:00
2ce74fec8e 🚧 Retoques menores en los comentarios del código 2025-11-29 14:42:05 +01:00
0849d23e3f 🚧 Añade constante PAGETOP_VERSION 2025-11-29 13:55:35 +01:00
4ac7caddd4 🗑️ Elimina métodos y definiciones obsoletas 2025-11-23 14:37:00 +01:00
a2fe2114cd ♻️ (theme): Refactoriza renderizado de temas base 2025-11-23 14:35:38 +01:00
9657672ffd 📝 Mejora documentación generada por builder_fn 2025-11-23 14:11:13 +01:00
f0e5f50a7f (theme): Añade componentes Region y Template
- Incluye un componente base `Template` para gestionar la estructura
  del documento y sus regiones (`Region`).
- Actualiza el *trait* `Contextual` para permitir la selección de la
  plantilla de renderizado.
- Modifica `Page` y `Context`, y refactoriza el manejo de temas, para
  dar soporte al nuevo sistema de plantillas y eliminar la gestión
  obsoleta de regiones.
2025-11-22 09:11:16 +01:00
4a3244d0e4 📝 Mejora doc de AutoDefault y builder_fn 2025-11-21 05:57:10 +01:00
64264f234d 🚚 Renombra ThemeRegion por DefaultRegions 2025-11-17 22:51:34 +01:00
77c4005b4e Incorpora is_renderable en Component 2025-11-17 22:50:56 +01:00
ceaee54484 🎨 Protege el uso de render en PrepareMarkup 2025-11-17 22:47:47 +01:00
9e960c7573 🚧 Aplica nuevas utilidades para componer el menú 2025-11-15 18:37:30 +01:00
2e39af0856 ♻️ (bootsier): Refactoriza la gestión de clases
- Mejora la legibilidad del código.
- Simplifica las alteraciones de clases en los componentes `Container`,
  `Dropdown`, `Image`, `Nav`, `Navbar` y `Offcanvas` usando métodos
  dedicados para generar clases en función de sus propiedades.
- Mejora los enums añadiendo métodos que devuelven sus clases
  asociadas, reduciendo código repetitivo.
- Elimina el trait `JoinClasses` y su implementación, integrando la
  lógica de unión de clases directamente en los componentes.
2025-11-15 13:16:15 +01:00
748bd81bf1 Añade clases de fondo, texto, bordes y esquinas
- Refactoriza el componente contenedor `Container` para usar estas
  clases y aplicar los nuevos enums `Kind` y `Width` para mejorar el
  comportamiento semántico y *responsive*.
- Actualiza los componentes `Dropdown`, `Image`, `Nav`, `Navbar` y
  `Offcanvas` para usar los nuevos métodos de unión de clases.
- Elimina propiedades de estilo redundantes de los componentes
  `Navbar` e `Image`, simplificando sus interfaces.
2025-11-10 07:45:05 +01:00
5ec69345b3 Añade trait JoinClasses para unir clases CSS
También elimina macros sin uso `join_op!` y `join_strict!` (KISS).
2025-11-08 08:07:59 +01:00
d5f0d83939 ♻️ (bootsier): Refactoriza y renombra estilos aux 2025-11-03 22:43:31 +01:00
9a26d579ed Añade ejemplo de barra de menú de navegación 2025-11-02 20:47:50 +01:00
0d6f975649 📝 Corrige ejemplo de documentación deOffcanvas 2025-11-02 20:47:26 +01:00
1ed5ab86fe ⚰️ (menu): Elimina implementación base de menús 2025-11-02 20:46:43 +01:00
175fc90775 📝 Repasa doc de Dropdown, Nav y Offcanvas 2025-11-02 12:42:36 +01:00
5a00968223 (bootsier): Añade componente Navbar 2025-11-02 12:40:26 +01:00
80b18cc380 (bootsier): Añade componente Nav
- Adapta `Dropdown` para poder integrarlo en los menús `Nav`.
- Actualiza `Children` para soportar operaciones múltiples.
2025-10-29 13:47:59 +01:00
c3fd566025 (bootsier): Añade componente Image 2025-10-26 09:18:33 +01:00
534b1f9615 🍱 Reemplaza imágenes del logo de PageTop con SVG 2025-10-26 06:38:10 +01:00
5ac26c0b06 🩹 Correcciones menores en comentarios y código 2025-10-25 19:04:35 +02:00
1bf7ada607 (bootsier): Añade paneles deslizables Offcanvas 2025-10-25 19:02:47 +02:00
c58b011943 ♻️ Refactoriza Dropdown para separar propiedades 2025-10-25 10:55:34 +02:00
c3a0255d44 🚧 Cambia el uso de BreakPoint para Container 2025-10-25 10:52:33 +02:00
3841d1d3f3 🎨 (bootsier): Mejora menús desplegables Dropdown 2025-10-25 09:02:58 +02:00
d21e1a2168 🔒 Mejora seguridad de enlaces con noopener 2025-10-25 07:13:15 +02:00
bf310bec7e ️ Mejora adición de componentes hijos 2025-10-25 07:03:14 +02:00
8978506c39 (bootsier): Añade más componentes y repasa código
- Se incorpora nuevo componente Dropdown.
- Se crea un componente Navbar con soporte para marca, elementos de
navegación.
- Se implementa el componente Offcanvas con opciones de posición,
visibilidad y fondo personalizables.
- Mejora el manejo de imágenes con un nuevo componente de Image.
- Se reorganizan los componentes del tema para una mejor estructura y
usabilidad.
2025-10-19 21:57:15 +02:00
d6e176cc19 (bootsier): Añade dependencia serde y edita doc 2025-10-19 21:47:19 +02:00
44ddfa4a51 Simplifica Display con f.write_str() 2025-10-19 21:40:42 +02:00
708dc9244b ️ Mejora macro join_opt! y retoca documentación 2025-10-19 21:39:14 +02:00
5eab417b11 🚚 Renombra add_component por add_child 2025-10-18 21:33:29 +02:00
6368e71413 🎨 (pagetop): Mejoras sencillas en doc. y código 2025-10-17 18:14:20 +02:00
d46de78367 (unit): Añade método is_numeric en UnitValue 2025-10-13 18:51:32 +02:00
39a3dabcfb (html): Añade soporte para unidades CSS 2025-10-13 13:13:33 +02:00
bcde17cb9e 🎨 (bootsier): Ajusta estilos para personalizar 2025-10-12 20:37:38 +02:00
e4cd1e56e0 🍱 (bootsier): Actualiza bootstrap v5.3.3 a v5.3.8 2025-10-12 20:04:35 +02:00
f68f33a7a2 📝 depura enlaces de información de licencias 2025-10-12 13:27:05 +02:00
6052b87c9c 📄 Actualiza licencias y revisa *badges* de README 2025-10-12 13:08:33 +02:00
345bac9d25 📝 Repasa doc y cambia caracteres Unicode ambiguos 2025-10-12 12:29:16 +02:00
3e3903b2c7 Añade tema Bootsier basado en Bootstrap 2025-10-12 12:07:02 +02:00
ec60c4ce6f ♻️ Refactoriza página de bienvenida y tema Basic
- Actualiza `Welcome` para usar el nuevo componente `Intro`.
- Simplifica el tema `Basic` apoyándose en la lógica de `Theme`.
- Predefine los *assets* básicos como recursos de `Theme`.
- Refactoriza archivos de localicación para reflejar los cambios de los
componentes.
2025-10-12 09:15:50 +02:00
e38d7a3c4f 🔨 Añade soporte para el tema pagetop-aliner 2025-10-12 08:43:17 +02:00
f25f62dd3c 📝 Evita en los ejemplos use pagetop::prelude::*; 2025-10-12 06:57:04 +02:00
8eafa436cd Añade nuevo tema para pruebas llamado Aliner 2025-10-11 21:36:06 +02:00
fe1a6d1baa 🚧 Revisión del estado de los menús 2025-10-10 10:55:51 +02:00
b076e22c2b 🎨 Mejora el uso de regiones y añade BasicRegion 2025-10-07 05:56:32 +02:00
cac50b21cb 🚧 Depura la estructura y estilos del menú e intro 2025-10-06 04:09:26 +02:00
5f64d50080 🎨 Mejora Region para declarar las regiones 2025-10-04 08:25:04 +02:00
7b941cb02c 🚚 Renombra region_name a region_key 2025-10-03 01:55:03 +02:00
e23bd0294c 🎨 Mejora uso de las regiones en contexto y página 2025-10-02 21:24:19 +02:00
200f8c0f4f 🧑‍💻 Depura atributos #[inline] en builder_fn 2025-10-02 18:48:20 +02:00
843aed86c7 🚚 Renombra ErrorParam por ContextError 2025-09-30 23:45:13 +02:00
075f61bf09 🚧 (core): Mueve Context al ámbito de componentes 2025-09-30 23:36:09 +02:00
6b08b92635 🚚 Renombra AssetsOp por ContextOp 2025-09-30 20:21:06 +02:00
0f1296c216 🎨 Mejora la estructura y estilos del menú
Rrenombra clases, ajusta estilos CSS y actualiza la lógica de JavaScript
para una mejor gestión de submenús.
2025-09-29 02:07:10 +02:00
67a9b057ee 🚧 (base): Añade nuevo componente menu 2025-09-28 13:47:33 +02:00
df1f386241 🚧 (base): Añade nuevo componente Icon 2025-09-28 13:46:02 +02:00
31435f30d3 💡 Mejora legibilidad de comentarios 2025-09-28 08:51:21 +02:00
2ac3c2a9c4 🔥 Elimina definitivamente TypedOpt por Typed 2025-09-27 21:18:54 +02:00
ce4557684e 🚚 Renombra TypedSlot por TypedOpt 2025-09-25 21:36:37 +02:00
f8202aef2e 🔨 (tools): Homogeiniza inputs de distintas fuentes 2025-09-20 18:07:37 +02:00
95777379a9 ⬆️ Actualiza dependencias mirando 2025-09-20 17:55:44 +02:00
9536003bad 🔖 Prepara publicación de pagetop 0.4.0 2025-09-20 14:00:09 +02:00
6fac597a16 🔨 (tools): Fuerza pulsar intro para confirmar input 2025-09-20 13:43:36 +02:00
e4a9a72fa7 🔖 Prepara publicación de pagetop-macros 0.2.0 2025-09-20 13:25:59 +02:00
ccafedaa0e 🔖 Prepara publicación de pagetop-build 0.3.1 2025-09-20 13:22:23 +02:00
adec5c2636 🔖 Prepara publicación de pagetop-statics 0.1.2 2025-09-20 13:19:14 +02:00
b808c00eb1 🩹 Corrige dependencia no actualizada 2025-09-20 13:14:52 +02:00
6d5e1dfdb4 ⬆️ (pagetop): Actualiza dependencias para 0.4.0 (#7)
Reviewed-on: #7
Co-authored-by: Manuel Cillero <manuel@cillero.es>
Co-committed-by: Manuel Cillero <manuel@cillero.es>
2025-09-20 13:08:47 +02:00
2b2ce2c501 🎨 Unifica parámetros y estilos del tema básico
Ofrece dos composiciones de página dirigidas a una introducción de
contenidos neutra o con referencias a PageTop.
2025-09-18 23:38:25 +02:00
25a71bf526 🎨 Mejora la página de bienvenida y el tema básico
Convierte la estructura de la página de bienvenida en una composición
del tema básico para ser usada en otros contextos. Por ejemplo, para la
página de mantenimiento, o la página de inicio de la guía de uso de
PageTop.
2025-09-18 14:17:01 +02:00
c6e323b21d 🚨 Ajustes menores sugeridos por clippy 2025-09-18 14:06:33 +02:00
cc03a0ea32 (util): Añade indoc para indentar código bien 2025-09-12 01:13:17 +02:00
a96a3fdf9f 🔥 Elimina Render para usar siempre el contexto 2025-09-11 19:58:50 +02:00
7179cf0831 🎨 Unifica conversiones a String con to_string()
Como `String::from()` y `.to_string()` son equivalentes, se sustituyen
todas las ocurrencias de `String::from()` por `to_string()` para mayor
coherencia y legibilidad.
2025-09-11 19:03:34 +02:00
b16c9378d0 🩹 Corrige doc y código por cambios en Page 2025-09-09 01:01:18 +02:00
415e524f69 🎨 Generaliza página de bienvenida con parámetros 2025-09-08 23:27:56 +02:00
50ba58ed74 (context): Añade métodos auxiliares de parámetros 2025-09-08 18:57:09 +02:00
6f11207d8e 📝 Mejora la documentación de recursos y contexto 2025-09-08 00:10:23 +02:00
b10d0aadf4 🚧 (context): Define un trait común de contexto 2025-09-07 21:06:50 +02:00
ff76504e78 ♻️ (macros): Majora la validación de builder_fn 2025-09-07 21:06:41 +02:00
da1390c2fe 🚧 (context): Generaliza los parámetros de contexto 2025-09-06 09:41:01 +02:00
c43c8458cc 🚧 (welcome): Crea página de bienvenida desde intro
- Implementa un nuevo *layout* en el tema `Basic` para crear una
  plantilla de páginas de introducción.
- Añade nuevo fichero CSS `intro.css` para los estilos globales de la
  página de introducción.
- Incorpora nuevos recursos gráficos para la cabecera de la página de
  introducción en varios formatos (AVIF, WebP, JPEG).
- Revisa los ficheros de localización.
2025-09-04 01:53:51 +02:00
970442a45a 🚧 Retoques en el código 2025-09-04 01:12:59 +02:00
543f2de333 🚚 Renombra with_component por add_component 2025-09-04 01:11:03 +02:00
fdb8132c37 🎨 (locale): Mejora el uso de lookup / using 2025-09-04 00:27:25 +02:00
fec3c32b8e 📝 (doc): Simplifica documentación de obsoletos 2025-09-03 21:12:19 +02:00
b315e6d08e 📝 (doc): Normaliza referencias al nombre PageTop 2025-09-03 21:05:35 +02:00
ce5a6c07bd 💄 Aplica BEM a estilos de bienvenida y componente 2025-08-25 23:30:53 +02:00
779c16010a 🐛 Corrige nombre de función en prueba de Html 2025-08-25 23:25:39 +02:00
a38f983f09 🐛 (welcome): Corrige centrado del pie de página 2025-08-25 07:12:30 +02:00
bb759e9a38 💄 Añade componente PoweredBy para copyright
Adapta la página de bienvenida al tratamiento revisado de regiones y
añade en el pie el componente `PoweredBy` para la nota de copyright.
2025-08-24 10:19:17 +02:00
551e85b239 🚧 Aplica recomendaciones en componente Html 2025-08-24 10:16:02 +02:00
c8e232d389 🚧 (html): Implementa Default en Context 2025-08-24 10:09:22 +02:00
c63c4e8289 💡 Corrección menor en comentario 2025-08-24 10:05:46 +02:00
fdf7c40da7 📝 (component): Amplía documentación de preparación
Añade un párrafo explicando la mejor manera de que `prepare_component()`
pueda ser útil a los programadores que sobrescriban su comportamiento.
2025-08-24 10:04:51 +02:00
fbdce1035c (tests): Amplía pruebas para `PrepareMarkup' 2025-08-23 19:34:26 +02:00
5ef85154b5 ♻️ (html): Cambia tipos Option... por Attr...
Renombra los tipos para atributos HTML `Id`, `Name`, `Value` (`String`),
`L10n` (`Translate`) y `Classes`. Y mueve `OptionComponent` al *core* de
componentes como `TypedSlot`.
2025-08-23 18:52:45 +02:00
e08c412a36 🎨 (theme): Mejora gestión de regiones en páginas 2025-08-22 08:29:11 +02:00
ba71f8a83f (app): Añade manejo de rutas no encontradas 2025-08-22 07:46:36 +02:00
40c4cabe45 🐛 (welcome): Corrige giro botón con ancho estrecho 2025-08-21 09:40:10 +02:00
2cb9633503 🎨 Retoques al importar fmt para usar Display 2025-08-21 09:36:25 +02:00
3b5c5dd4e7 🦺 Modifica tipos para atributos HTML a minúsculas 2025-08-21 09:31:20 +02:00
313ce391d5 📝 Retoques menores en la documentación 2025-08-21 09:12:22 +02:00
2cf5ce2a70 🔖 Prepara publicación de pagetop 0.3.0 2025-08-16 12:57:00 +02:00
b11b5d97c6 🔖 Prepara publicación de pagetop-macros 0.1.1 2025-08-16 12:51:11 +02:00
85497bf452 🔖 Prepara publicación de pagetop-build 0.3.0 2025-08-16 12:47:02 +02:00
662b269423 🔖 Prepara publicación de pagetop-statics 0.1.1 2025-08-16 12:39:51 +02:00
69fac6d9ea 🧑‍💻 Mejora la integración de archivos estáticos
Elimina el uso de `include_files!` y sustituye `include_files_service!`
por alternativas más completas ofreciadas por `static_files_service!`.
2025-08-16 12:15:16 +02:00
801642faab 🧑‍💻 Redefine función para directorios absolutos 2025-08-16 11:54:48 +02:00
168d5dc648 📝 Cambia el formato para la documentación (#4)
Reviewed-on: #4
Co-authored-by: Manuel Cillero <manuel@cillero.es>
Co-committed-by: Manuel Cillero <manuel@cillero.es>
2025-08-10 01:10:05 +02:00
b77e47d6e4 🧑‍💻 Mejora función from_dir por compatibilidad (#3)
Reviewed-on: #3
Co-authored-by: Manuel Cillero <manuel@cillero.es>
Co-committed-by: Manuel Cillero <manuel@cillero.es>
2025-08-10 00:48:39 +02:00
3bb8b9c051 🔖 Prepara publicación de pagetop 0.2.0 2025-08-09 10:34:38 +02:00
f7c1b56981 🔖 Prepara publicación de pagetop-build 0.2.0 2025-08-09 10:30:53 +02:00
cb1b6cbdac 🔖 Prepara publicación de pagetop-statics 0.1.0 2025-08-09 10:27:39 +02:00
db1ee48875 🔨 Añade soporte a changelog de pagetop-statics (#2)
Reviewed-on: #2
Co-authored-by: Manuel Cillero <manuel@cillero.es>
Co-committed-by: Manuel Cillero <manuel@cillero.es>
2025-08-09 10:17:14 +02:00
0ed4b61880 Añade librería para gestionar recursos estáticos (#1)
Reviewed-on: #1
Co-authored-by: Manuel Cillero <manuel@cillero.es>
Co-committed-by: Manuel Cillero <manuel@cillero.es>
2025-08-08 23:58:07 +02:00
53f42f9a77 🚩 Afina Cargo.toml para buscar la mejor categoría 2025-08-06 11:26:56 +02:00
bbc0a59651 🩹 Corrige enlaces de licencia en la documentación 2025-08-06 11:24:13 +02:00
96bc077616 🔖 Prepara publicación de pagetop 0.1.0 2025-08-06 03:22:20 +02:00
1ee9339bca 🔖 Prepara publicación de pagetop-macros 0.1.0 2025-08-06 03:15:15 +02:00
a5f75cdf5b 🚩 Asegura compilación para publicar nueva versión 2025-08-06 03:14:25 +02:00
605473a683 🚑 Corrige generación de CHANGELOG existentes 2025-08-06 03:01:19 +02:00
806249ea1b 🔖 Prepara publicación de pagetop-build 0.1.1 2025-08-06 02:19:25 +02:00
7a6bbf9065 🚧 Validación en curso 2025-08-06 02:19:01 +02:00
1a3649897e 🔖 Prepara publicación de pagetop-build 0.1.1 2025-08-06 02:16:00 +02:00
9abd67f479 🚧 Validando comparación de versiones de CHANGELOG 2025-08-06 02:15:29 +02:00
652f5adfef 🔖 Prepara publicación de pagetop-build 0.1.1 2025-08-06 02:10:37 +02:00
bab44422a2 🚧 Trabajando sobre la publicación de versiones 2025-08-06 02:09:50 +02:00
9aa8e05ed0 🐛 Corrige comprobación de cambios en CHANGELOG 2025-08-06 02:07:01 +02:00
49f5de9498 🔖 Prepara publicación de pagetop-build 0.1.1 2025-08-06 02:01:32 +02:00
c8976edca4 🐛 Corrige edición de CHANGELOG y nuevas versiones 2025-08-06 01:54:47 +02:00
b03cd746c0 🔖 Prepara publicación de pagetop-build 0.1.0 2025-08-05 23:11:21 +02:00
4928b0f7a6 🩹 El modo dry-run se ejecuta por defecto 2025-08-05 19:02:33 +02:00
5c0c3090b1 📄 Cada crate con su licencia 2025-08-05 18:53:21 +02:00
b01b12f472 🔨 (devops): Scripts para la publicación de crates 2025-08-05 18:53:01 +02:00
4ae919ccd7 🚨 Aplica cambios por sugerencias de clippy 2025-08-05 18:52:00 +02:00
11caad11af 📝 Retoques menores en la documentación 2025-08-05 18:49:04 +02:00
bc1328546a 🚧 Normaliza versiones a las últimas publicaciones
Revisa y completa la documentación general.
2025-08-03 18:00:02 +02:00
867b307fc6 🚧 (pagetop): Preparando liberación de la v0.1.0 2025-08-03 14:17:22 +02:00
dfd100c842 💡 Retoques menores en comentarios para documentar 2025-08-03 13:36:00 +02:00
f03f42f6d5 🏷️ Simplifica nombres de traits esenciales
Los traits `ExtensionTrait`, `ThemeTrait` y `ComponentTrait` pasan a ser
`Extension`, `Theme`y `Component`, respectivamente,
2025-08-03 13:15:08 +02:00
fd5b48811d ♻️ Refactoriza manejo de idiomas para localización 2025-08-03 11:58:47 +02:00
a0c5c20aa1 💡 Corrige ejemplos de uso del componente Html 2025-08-03 11:57:01 +02:00
4d4b654cd8 📝 Retoca documentación generada para los ajustes 2025-08-03 08:32:13 +02:00
3a6dcc5c72 🧑‍💻 Mejora funcionalidad del componente Html
- Amplía la documentación del componente.
- Aplica la nueva funcionalidad en la página de bienvenida usando el
  nuevo renderizado dinámico con contexto.
- Añade pruebas unitarias para el componente.
2025-08-02 20:26:39 +02:00
2bbb37bb5d Añade página de bienvenida y de comprobación
- Añade nueva hoja de estilos (normalize.css) al tema básico.
- Activa la extensión de la página de bienvenida por defecto en el
   registro de extensiones.
- Añade traducciones en inglés y español para el contenido de la página
  de bienvenida.
- Incluye estilos CSS y recursos gráficos para el diseño y disposición
  de la página de bienvenida.
2025-08-02 11:20:39 +02:00
e1265a04b6 🍱 Añade gestión de recursos estáticos de PageTop 2025-07-28 18:33:50 +02:00
a88fcff00f 🚨 (build): Aplica mejora propuesta por clippy 2025-07-28 18:26:30 +02:00
c715935cbb Añade soporte para sesiones y cookies 2025-07-27 22:55:14 +02:00
5d58d2d934 Añade soporte para responder páginas HTML
- Amplia la estructura "Page" para trabajar el renderizado con regiones
  de componentes para componer la página.
- Añade acciones "BeforeRenderBody" y "AfterRenderBody" para alterar el
  contenido de la página antes y después del renderizado.
- Actualiza "Context" para admitir parámetros dinámicos y mejorar la
  gestión de temas.
- Implementa el manejo de errores HTTP respondiendo páginas.
- Mejora la documentación y reorganiza el código en varios módulos.
2025-07-27 21:24:49 +02:00
b837346bf8 💡 Mejora los comentarios para normalizar 2025-07-27 13:55:28 +02:00
94c3d4398a 💡 Simplifica documentación de métodos alter_…() 2025-07-27 13:51:49 +02:00
00563fa268 🐛 Corrige validación "mut self" y retorno de Self 2025-07-27 01:19:16 +02:00
05f523ee51 ✏️ Corrige las referencias "builder" a self/Self 2025-07-27 00:33:32 +02:00
4af558289e ♻️ Reemplaza ContextOp por AssetsOp en el contexto 2025-07-27 00:27:11 +02:00
d7485c8311 💡 (macros): Mejora comentario en métodos "alter_" 2025-07-25 23:29:16 +02:00
cf6bb24ed6 💡 Normaliza comentarios en varios archivos 2025-07-25 23:05:53 +02:00
a1b5155703 Añade soporte a respuestas JSON 2025-07-25 19:18:06 +02:00
2566bd6e58 Añade respuestas web con redirecciones HTTP 2025-07-25 18:25:42 +02:00
3397553ba7 Añade gestión de regiones en temas 2025-07-24 21:20:35 +02:00
c379ac2879 🧑‍💻 Mejora las operaciones de cambio en contexto 2025-07-24 13:16:39 +02:00
cf1e8bbc02 Añade el primer componente básico nativo
Este componente renderiza directamente código HTML durante el
renderizado del documento.
2025-07-24 09:11:29 +02:00
8e67065aae Añade acciones base y renderizado de componentes
- Añade acciones BeforeRender y AfterRender para ejecutar código
  personalizado antes y después de renderizar un componente.
- Introduce la acción PrepareRender para personalizar totalmente el
  renderizado de un componente.
- Se actualizan las definiciones de acciones para utilizar el nuevo
  "trait" ActionDispatcher.
- Se crea un nuevo trait ComponentTrait para definir componentes
  renderizables.
- Se implementan las estructuras Children y Child para gestionar
  componentes hijos dentro de un componente padre.
- Se añade OptionComponent para encapsular de forma segura componentes
  opcionales y poder usarlos en otros componentes.
2025-07-24 08:38:17 +02:00
36ddbd7ecf 🏗️ Añade el tema para renderizar en contexto HTML 2025-07-21 21:17:40 +02:00
4f56d4441f Añade tipos para renderizar atributos HTML 2025-07-21 20:52:45 +02:00
c89d4bb5fc Añade soporte para inyectar acciones en código 2025-07-21 08:58:09 +02:00
861392430a Añade soporte a temas en la API de extensiones
- Incluye una opción de configuración para definir el tema por defecto.
- Añade un tema básico predeterminado.
2025-07-20 23:51:15 +02:00
50b704fb6d 📝 Retoques en la documentación 2025-07-20 14:28:09 +02:00
b3ed8e07af Añade soporte para recursos en documentos HTML
- Incluye los recursos favicon, hojas de estilo y scripts JavaScript.
- Se introduce una estructura de contexto que, además de gestionar el
  idioma y el uso de parámetros contextuales, permite administrar estos
  recursos en documentos HTML.
2025-07-20 14:24:19 +02:00
a19587c39c 🧑‍💻 (build): Más opciones de tipo para funciones
Cambia parámetros de tipo en las funciones `from_dir` y `with_name` para
aceptar referencias a cadenas.
2025-07-20 11:58:17 +02:00
de37724835 🧑‍💻 Mejora uso y doc. de la API de localización 2025-07-19 00:13:49 +02:00
17f52efb72 🚑 (macros): Corrige rutas absolutas en html!
- Mantiene fijas las rutas absolutas en las funciones de la macro html!.
- Introduce un alias interno del crate (`extern crate self as pagetop;`)
  en `src/lib.rs` para que las expansiones funcionen uniformemente en
  tests, ejemplos y dependencias externas.
2025-07-16 02:53:49 +02:00
9bfdf2e9ac 📝 Actualiza documentación con favicon propio 2025-07-16 01:04:36 +02:00
4b281a1e96 🧑‍💻 Añade macros para concatenar cadenas 2025-07-16 00:45:05 +02:00
5f60bc4845 Añade API para extensiones con funcionalidades
Añade el interfaz común que debe implementar cualquier extensión de
PageTop para añadir nuevas funcionalidades a la aplicación en forma de
servicios web y API de uso.
2025-07-15 20:12:15 +02:00
f6b4cb936c 🎨 Mejora definición encapsulando uso de recursos 2025-07-13 11:10:06 +02:00
2b1b31fbf0 (macros): Añade macro builder_fn 2025-07-12 12:04:37 +02:00
4eadc8b257 Añade gestión de recursos en binario o de disco
- Añade dosnuevas macros `include_files` y `include_files_service` para
  incluir los recursos preparados con `pagetop-build`.
- Añade nueva función útil `absolute_dir` y sus tests correspondientes.
2025-07-12 06:35:09 +02:00
f37a128044 🚩 Añade feature "testing"
- Permite desactivar trazas y registro de eventos al ejecutar tests.
- Añade opción de configuración para activar o desactivar las trazas.
2025-07-11 22:17:59 +02:00
8bc1d00cb9 Añade estáticos y SCSS compilados para binarios
Módulo auxiliar para ejecutar durante la compilación de proyectos de
PageTop para incluir archivos estáticos o archivos SCSS compilados en
los binarios de los proyectos.
2025-07-10 22:51:47 +02:00
412524018a 📝 Actualiza el banner en la documentación 2025-07-09 22:05:42 +02:00
835aacf1fa 🍱 Añade el banner de PageTop 2025-07-09 21:59:09 +02:00
22faea7d40 Añade soporte para manejo de fechas y horas 2025-07-09 20:51:43 +02:00
896b066ca9 Añade soporte para localización y traducción
- Incluye recursos Fluent básicos y pruebas asociadas.
- Nueva variable de configuración global para definir el idioma
  predeterminado.
2025-07-09 20:39:39 +02:00
7e03d926bb 🚨 Aplica retoques por ejecución de clippy 2025-07-08 20:57:39 +02:00
7a0d02d57e Añade AutoDefault para derivar Default avanzado 2025-07-07 21:23:06 +02:00
6944938910 ⬆️ Actualiza el código de maud a la versión 0.27.0 2025-07-07 20:31:00 +02:00
1fb8e937d1 Añade macro para componer HTML en código
- Incorpora el código de la versión 0.25.0 de `maud_macros` creado por
  Chris Wong.
- Y reexporta los elementos esenciales desde la librería principal.
2025-07-07 18:21:00 +02:00
84e48a3357 Añade gestión de trazas y registro de eventos
- Reexporta macros esenciales de `tracing` para el registro de eventos.
- Inicializa el gestor de trazas en el servidor web.
2025-07-06 23:03:21 +02:00
60d550a4f9 📝 (config): Amplía y corrige documentación 2025-07-06 20:08:17 +02:00
329 changed files with 40099 additions and 392 deletions

69
.cargo/cliff.toml Normal file
View file

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

3
.cargo/config.toml Normal file
View file

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

25
.cargo/release.toml Normal file
View file

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

3
.gitignore vendored
View file

@ -1,6 +1,9 @@
# Ignora directorios de compilación
**/target
# Archivos de log
**/log/*.log*
# Archivos de configuración locales
**/local.*.toml
**/local.toml

86
CHANGELOG.md Normal file
View file

@ -0,0 +1,86 @@
# CHANGELOG
Este archivo documenta los cambios más relevantes realizados en cada versión. El formato está basado
en [Keep a Changelog](https://keepachangelog.com/es-ES/1.0.0/), y las versiones se numeran siguiendo
las reglas del [Versionado Semántico](https://semver.org/lang/es/).
Resume la evolución del proyecto para usuarios y colaboradores, destacando nuevas funcionalidades,
correcciones, mejoras durante el desarrollo o cambios en la documentación. Cambios menores o
internos pueden omitirse si no afectan al uso del proyecto.
## 0.4.0 (2025-09-20)
### Añadido
- [app] Añade manejo de rutas no encontradas
- [context] Añade métodos auxiliares de parámetros
- [util] Añade `indoc` para indentar código bien
- Añade componente `PoweredBy` para copyright
### Cambiado
- [html] Cambia tipos `Option...` por `Attr...`
- [html] Implementa `Default` en `Context`
- [welcome] Crea página de bienvenida desde intro
- [context] Generaliza los parámetros de contexto
- [context] Define un `trait` común de contexto
- Modifica tipos para atributos HTML a minúsculas
- Renombra `with_component` por `add_child`
### Corregido
- [welcome] Corrige giro botón con ancho estrecho
- [welcome] Corrige centrado del pie de página
- Corrige nombre de función en prueba de `Html`
- Corrige doc y código por cambios en Page
### Dependencias
- Actualiza dependencias para 0.4.0
### Documentado
- [component] Amplía documentación de preparación
- Normaliza referencias al nombre PageTop
- Simplifica documentación de obsoletos
- Mejora la documentación de recursos y contexto
### Otros cambios
- 🎨 [theme] Mejora gestión de regiones en páginas
- ✅ [tests] Amplía pruebas para `PrepareMarkup'
- 🎨 [locale] Mejora el uso de `lookup` / `using`
- 🔨 [tools] Fuerza pulsar intro para confirmar input
- 💄 Aplica BEM a estilos de bienvenida y componente
- 🎨 Unifica conversiones a String con `to_string()`
- 🔥 Elimina `Render` para usar siempre el contexto
## 0.3.0 (2025-08-16)
### Cambiado
- Redefine función para directorios absolutos
- Mejora la integración de archivos estáticos
### Documentado
- Cambia el formato para la documentación
## 0.2.0 (2025-08-09)
### Añadido
- Añade librería para gestionar recursos estáticos
- Añade soporte a changelog de `pagetop-statics`
### Documentado
- Corrige enlace del botón de licencia en la documentación
### Otros cambios
- Afina Cargo.toml para buscar la mejor categoría
## 0.1.0 (2025-08-06)
- Versión inicial

114
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,114 @@
# 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:
`<propósito> (ámbito opcional): <mensaje>`
Ejemplos:
* 📝 Actualiza la guía de contribución
* ✨ (locale): Refactoriza sistema de localización
* ♻️ (bootsier): Simplifica asignación de clases
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.

33
CREDITS.md Normal file
View file

@ -0,0 +1,33 @@
# 🔃 Dependencias
PageTop está basado en [Rust](https://www.rust-lang.org/) y crece a hombros de gigantes aprovechando
algunas de las librerías más robustas y populares del [ecosistema Rust](https://lib.rs) como son:
* [Actix Web](https://actix.rs/) para los servicios web.
* [Config](https://docs.rs/config) para cargar y procesar las opciones de configuración.
* [Tracing](https://github.com/tokio-rs/tracing) para la gestión de trazas y registro de eventos
de la aplicación.
* [Fluent templates](https://github.com/XAMPPRocky/fluent-templates), que integra
[Fluent](https://projectfluent.org/) para internacionalizar las aplicaciones.
* Además de otros *crates* adicionales que se pueden explorar en los archivos `Cargo.toml` de
PageTop y sus extensiones.
# 🗚 FIGfonts
PageTop usa el *crate* [figlet-rs](https://crates.io/crates/figlet-rs) desarrollado por *yuanbohan*
para mostrar un banner de presentación en el terminal con el nombre de la aplicación en caracteres
[FIGlet](http://www.figlet.org). Las fuentes incluidas en `pagetop/src/app` son:
* [slant.flf](http://www.figlet.org/fontdb_example.cgi?font=slant.flf) de *Glenn Chappell*
* [small.flf](http://www.figlet.org/fontdb_example.cgi?font=small.flf) de *Glenn Chappell*
(predeterminada)
* [speed.flf](http://www.figlet.org/fontdb_example.cgi?font=speed.flf) de *Claude Martins*
* [starwars.flf](http://www.figlet.org/fontdb_example.cgi?font=starwars.flf) de *Ryan Youck*
# 🎨 Icono
"La Criatura" 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).

1898
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,12 +1,12 @@
[package]
name = "pagetop"
version = "0.0.2"
version = "0.4.0"
edition = "2021"
description = """\
Un entorno de desarrollo para crear soluciones web modulares, extensibles y configurables.\
description = """
Un entorno de desarrollo para crear soluciones web modulares, extensibles y configurables.
"""
categories = ["web-programming", "gui", "development-tools", "asynchronous"]
categories = ["web-programming::http-server"]
keywords = ["pagetop", "web", "framework", "frontend", "ssr"]
repository.workspace = true
@ -15,23 +15,60 @@ license.workspace = true
authors.workspace = true
[dependencies]
colored = "3.0.0"
config = { version = "0.15.11", default-features = false, features = ["toml"] }
figlet-rs = "0.1.5"
serde.workspace = true
substring = "1.4.5"
terminal_size = "0.4.2"
chrono = "0.4"
colored = "3.0"
config = { version = "0.15", default-features = false, features = ["toml"] }
figlet-rs = "0.1"
getter-methods = "2.0"
itoa = "1.0"
indexmap = "2.12"
parking_lot = "0.12"
substring = "1.4"
terminal_size = "0.4"
actix-web = "4.11.0"
tracing = "0.1"
tracing-appender = "0.2"
tracing-subscriber = { version = "0.3", features = ["json", "env-filter"] }
tracing-actix-web = "0.7"
fluent-templates = "0.13"
unic-langid = { version = "0.9", features = ["macros"] }
actix-web = { workspace = true, default-features = true }
actix-session = { version = "0.11", features = ["cookie-session"] }
actix-web-files = { package = "actix-files", version = "0.6" }
serde.workspace = true
pagetop-macros.workspace = true
pagetop-minimal.workspace = true
pagetop-statics.workspace = true
[features]
default = []
testing = []
[dev-dependencies]
tempfile = "3.23"
serde_json = "1.0"
pagetop-aliner.workspace = true
pagetop-bootsier.workspace = true
[build-dependencies]
pagetop-build.workspace = true
[workspace]
resolver = "2"
members = [
# Helpers
"helpers/pagetop-build",
"helpers/pagetop-macros",
"helpers/pagetop-minimal",
"helpers/pagetop-statics",
# Extensions
"extensions/pagetop-aliner",
"extensions/pagetop-bootsier",
]
[workspace.package]
@ -41,7 +78,15 @@ license = "MIT OR Apache-2.0"
authors = ["Manuel Cillero <manuel@cillero.es>"]
[workspace.dependencies]
actix-web = { version = "4.11", default-features = false }
serde = { version = "1.0", features = ["derive"] }
# Helpers
pagetop-macros = { version = "0.0", path = "helpers/pagetop-macros" }
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" }
pagetop-bootsier = { version = "0.0", path = "extensions/pagetop-bootsier" }
# PageTop
pagetop = { version = "0.4", path = "." }

156
MAINTAINERS.md Normal file
View file

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

145
README.md
View file

@ -1,22 +1,39 @@
<div align="center">
<img src="https://git.cillero.es/manuelcillero/pagetop/raw/branch/main/static/banner.png" />
<h1>PageTop</h1>
<p>Un entorno para el desarrollo de soluciones web modulares, extensibles y configurables.</p>
[![Licencia](https://img.shields.io/badge/license-MIT%2FApache-blue.svg?label=Licencia&style=for-the-badge)](#-license)
[![Doc API](https://img.shields.io/docsrs/pagetop?label=Doc%20API&style=for-the-badge&logo=Docs.rs)](https://docs.rs/pagetop)
[![Crates.io](https://img.shields.io/crates/v/pagetop.svg?style=for-the-badge&logo=ipfs)](https://crates.io/crates/pagetop)
[![Descargas](https://img.shields.io/crates/d/pagetop.svg?label=Descargas&style=for-the-badge&logo=transmission)](https://crates.io/crates/pagetop)
[![Licencia](https://img.shields.io/badge/license-MIT%2FApache-blue.svg?label=Licencia&style=for-the-badge)](https://git.cillero.es/manuelcillero/pagetop#licencia)
<br>
</div>
`PageTop` reivindica la esencia de la web clásica usando [Rust](https://www.rust-lang.org/es) para
la creación de soluciones web SSR (*renderizadas en el servidor*) basadas en HTML, CSS y JavaScript.
PageTop reivindica la esencia de la web clásica usando [Rust](https://www.rust-lang.org/es) para la
creación de soluciones web SSR (*renderizadas en el servidor*) basadas en HTML, CSS y JavaScript.
Ofrece un conjunto de herramientas que los desarrolladores pueden implementar, extender o adaptar
según las necesidades de cada proyecto, incluyendo:
* **Acciones** (*actions*): alteran la lógica interna de una funcionalidad interceptando su flujo
de ejecución.
* **Componentes** (*components*): encapsulan HTML, CSS y JavaScript en unidades funcionales,
configurables y reutilizables.
* **Extensiones** (*extensions*): añaden, extienden o personalizan funcionalidades usando las APIs
de PageTop o de terceros.
* **Temas** (*themes*): son extensiones que permiten modificar la apariencia de páginas y
componentes sin comprometer su funcionalidad.
# ⚡️ Guía rápida
## ⚡️ Guía rápida
La aplicación más sencilla de `PageTop` se ve así:
La aplicación más sencilla de PageTop se ve así:
```rust#ignore
```rust,no_run
use pagetop::prelude::*;
#[pagetop::main]
@ -25,8 +42,99 @@ async fn main() -> std::io::Result<()> {
}
```
Este código arranca el servidor de PageTop. Con la configuración por defecto, muestra una página de
bienvenida accesible desde un navegador local en la dirección `http://localhost:8080`.
# 📜 Licencia
Para personalizar el servicio, se puede crear una extensión de PageTop de la siguiente manera:
```rust,no_run
use pagetop::prelude::*;
struct HelloWorld;
impl Extension for HelloWorld {
fn configure_service(&self, scfg: &mut service::web::ServiceConfig) {
scfg.route("/", service::web::get().to(hello_world));
}
}
async fn hello_world(request: HttpRequest) -> ResultPage<Markup, ErrorPage> {
Page::new(request)
.add_child(Html::with(|_| html! { h1 { "Hello World!" } }))
.render()
}
#[pagetop::main]
async fn main() -> std::io::Result<()> {
Application::prepare(&HelloWorld).run()?.await
}
```
Este programa implementa una extensión llamada `HelloWorld` que sirve una página web en la ruta raíz
(`/`) mostrando el texto "Hello world!" dentro de un elemento HTML `<h1>`.
## 📂 Proyecto
El código se organiza en un *workspace* donde actualmente se incluyen los siguientes subproyectos:
* **[pagetop](https://git.cillero.es/manuelcillero/pagetop/src/branch/main/src)**, con el código
fuente de la librería principal. Reúne algunos de los *crates* más estables y populares del
ecosistema Rust para proporcionar APIs y recursos para la creación avanzada de soluciones web.
### 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 procedurales que mejoran la experiencia de desarrollo con
PageTop.
* **[pagetop-minimal](https://git.cillero.es/manuelcillero/pagetop/src/branch/main/helpers/pagetop-minimal)**,
ofrece macros declarativas esenciales para optimizar tareas comunes como la composición de
texto, la concatenación de cadenas y el manejo de colecciones clave-valor.
* **[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.
* **[pagetop-bootsier](https://git.cillero.es/manuelcillero/pagetop/src/branch/main/extensions/pagetop-bootsier)**,
tema basado en [Bootstrap](https://getbootstrap.com) para integrar su catálogo de estilos y
componentes flexibles.
## 🧪 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:
| Comando | Descripción |
| ------- | ----------- |
| `cargo ts` | Ejecuta los tests de `pagetop` (*unit + integration*) con la *feature* `testing`. |
| `cargo ts --test util` | Lanza sólo las pruebas de integración del módulo `util`. |
| `cargo ts --doc locale` | Lanza las pruebas de la documentación del módulo `locale`. |
| `cargo tw` | Ejecuta los tests de **todos los paquetes** del *workspace*. |
> **Nota**
> Estos alias ya compilan con la configuración adecuada. No requieren `--no-default-features`.
> Si quieres **activar** las trazas del registro de eventos entonces usa simplemente `cargo test`.
## 🚧 Advertencia
**PageTop** es un proyecto personal para aprender [Rust](https://www.rust-lang.org/es) y conocer su
ecosistema. Su API está sujeta a cambios frecuentes. No se recomienda su uso en producción, al menos
hasta que se libere la versión **1.0.0**.
## 📜 Licencia
El código está disponible bajo una doble licencia:
@ -40,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

7
build.rs Normal file
View file

@ -0,0 +1,7 @@
use pagetop_build::StaticFilesBundle;
fn main() -> std::io::Result<()> {
StaticFilesBundle::from_dir("./static", None)
.with_name("assets")
.build()
}

2
config/default.toml Normal file
View file

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

28
examples/hello-name.rs Normal file
View file

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

24
examples/hello-world.rs Normal file
View file

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

103
examples/navbar-menus.rs Normal file
View file

@ -0,0 +1,103 @@
use pagetop::prelude::*;
use pagetop_bootsier::prelude::*;
struct SuperMenu;
impl Extension for SuperMenu {
fn dependencies(&self) -> Vec<ExtensionRef> {
vec![&pagetop_aliner::Aliner, &pagetop_bootsier::Bootsier]
}
fn initialize(&self) {
let navbar_menu = Navbar::brand_left(navbar::Brand::new())
.with_expand(BreakPoint::LG)
.add_item(navbar::Item::nav(
Nav::new()
.add_item(nav::Item::link(L10n::l("sample_menus_item_link"), |cx| {
cx.route("/")
}))
.add_item(nav::Item::link_blank(
L10n::l("sample_menus_item_blank"),
|_| "https://docs.rs/pagetop".into(),
))
.add_item(nav::Item::dropdown(
Dropdown::new()
.with_title(L10n::l("sample_menus_test_title"))
.add_item(dropdown::Item::header(L10n::l("sample_menus_dev_header")))
.add_item(dropdown::Item::link(
L10n::l("sample_menus_dev_getting_started"),
|cx| cx.route("/dev/getting-started"),
))
.add_item(dropdown::Item::link(
L10n::l("sample_menus_dev_guides"),
|cx| cx.route("/dev/guides"),
))
.add_item(dropdown::Item::link_blank(
L10n::l("sample_menus_dev_forum"),
|_| "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"),
|cx| cx.route("/dev/sdks/rust"),
))
.add_item(dropdown::Item::link(L10n::l("sample_menus_sdk_js"), |cx| {
cx.route("/dev/sdks/js")
}))
.add_item(dropdown::Item::link(
L10n::l("sample_menus_sdk_python"),
|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"),
|cx| cx.route("/dev/sdks/rust/plugins/auth"),
))
.add_item(dropdown::Item::link(
L10n::l("sample_menus_plugin_cache"),
|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"),
|cx| cx.route("#"),
)),
))
.add_item(nav::Item::link_disabled(
L10n::l("sample_menus_item_disabled"),
|cx| cx.route("#"),
)),
))
.add_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"),
|cx| cx.route("/auth/sign-up"),
))
.add_item(nav::Item::link(L10n::l("sample_menus_item_login"), |cx| {
cx.route("/auth/login")
})),
));
InRegion::Global(&DefaultRegion::Header).add(Child::with(
Container::new()
.with_width(container::Width::FluidMax(UnitValue::RelRem(75.0)))
.add_child(navbar_menu),
));
}
}
#[pagetop::main]
async fn main() -> std::io::Result<()> {
Application::prepare(&SuperMenu).run()?.await
}

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,7 @@
use pagetop_build::StaticFilesBundle;
fn main() -> std::io::Result<()> {
StaticFilesBundle::from_dir("./static", None)
.with_name("aliner")
.build()
}

View file

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

View file

@ -0,0 +1,356 @@
html {
background-color: white;
padding: 1px 3px;
}
body {
padding: 1px 3px;
}
div {
padding: 1px 3px;
margin: 5px;
}
h1, h2, h3, h4,h5, h6, p {
background-color: snow;
}
* * {
outline: 5px solid rgba(255,0,0,.1);
}
* * * {
outline: 3px dashed rgba(255,0,0,.4);
}
* * * * {
outline: 2px dotted rgba(255,0,0,.6);
}
* * * * * {
outline: 1px dotted rgba(255,0,0,.9);
}
* * * * * * {
outline-color: gray;
}
*::before, *::after {
background: #faa;
border-radius: 3px;
font: normal normal 400 10px/1.2 monospace;
vertical-align: middle;
padding: 1px 3px;
margin: 0 3px;
}
*::before {
content: "(";
}
*::after {
content: ")";
}
a::before { content: "<a>"; }
a::after { content: "</a>"; }
abbr::before { content: "<abbr>"; }
abbr::after { content: "</abbr>"; }
acronym::before { content: "<acronym>"; }
acronym::after { content: "</acronym>"; }
address::before { content: "<address>"; }
address::after { content: "</address>"; }
applet::before { content: "<applet>"; }
applet::after { content: "</applet>"; }
area::before { content: "<area>"; }
area::after { content: "</area>"; }
article::before { content: "<article>"; }
article::after { content: "</article>"; }
aside::before { content: "<aside>"; }
aside::after { content: "</aside>"; }
audio::before { content: "<audio>"; }
audio::after { content: "</audio>"; }
b::before { content: "<b>"; }
b::after { content: "</b>"; }
base::before { content: "<base>"; }
base::after { content: "</base>"; }
basefont::before { content: "<basefont>"; }
basefont::after { content: "</basefont>"; }
bdi::before { content: "<bdi>"; }
bdi::after { content: "</bdi>"; }
bdo::before { content: "<bdo>"; }
bdo::after { content: "</bdo>"; }
bgsound::before { content: "<bgsound>"; }
bgsound::after { content: "</bgsound>"; }
big::before { content: "<big>"; }
big::after { content: "</big>"; }
blink::before { content: "<blink>"; }
blink::after { content: "</blink>"; }
blockquote::before { content: "<blockquote>"; }
blockquote::after { content: "</blockquote>"; }
body::before { content: "<body>"; }
body::after { content: "</body>"; }
br::before { content: "<br>"; }
br::after { content: "</br>"; }
button::before { content: "<button>"; }
button::after { content: "</button>"; }
caption::before { content: "<caption>"; }
caption::after { content: "</caption>"; }
canvas::before { content: "<canvas>"; }
canvas::after { content: "</canvas>"; }
center::before { content: "<center>"; }
center::after { content: "</center>"; }
cite::before { content: "<cite>"; }
cite::after { content: "</cite>"; }
code::before { content: "<code>"; }
code::after { content: "</code>"; }
col::before { content: "<col>"; }
col::after { content: "</col>"; }
colgroup::before { content: "<colgroup>"; }
colgroup::after { content: "</colgroup>"; }
command::before { content: "<command>"; }
command::after { content: "</command>"; }
content::before { content: "<content>"; }
content::after { content: "</content>"; }
data::before { content: "<data>"; }
data::after { content: "</data>"; }
datalist::before { content: "<datalist>"; }
datalist::after { content: "</datalist>"; }
dd::before { content: "<dd>"; }
dd::after { content: "</dd>"; }
del::before { content: "<del>"; }
del::after { content: "</del>"; }
details::before { content: "<details>"; }
details::after { content: "</details>"; }
dfn::before { content: "<dfn>"; }
dfn::after { content: "</dfn>"; }
dialog::before { content: "<dialog>"; }
dialog::after { content: "</dialog>"; }
dir::before { content: "<dir>"; }
dir::after { content: "</dir>"; }
div::before { content: "<div>"; }
div::after { content: "</div>"; }
dl::before { content: "<dl>"; }
dl::after { content: "</dl>"; }
dt::before { content: "<dt>"; }
dt::after { content: "</dt>"; }
element::before { content: "<element>"; }
element::after { content: "</element>"; }
em::before { content: "<em>"; }
em::after { content: "</em>"; }
embed::before { content: "<embed>"; }
embed::after { content: "</embed>"; }
fieldset::before { content: "<fieldset>"; }
fieldset::after { content: "</fieldset>"; }
figcaption::before { content: "<figcaption>"; }
figcaption::after { content: "</figcaption>"; }
figure::before { content: "<figure>"; }
figure::after { content: "</figure>"; }
font::before { content: "<font>"; }
font::after { content: "</font>"; }
footer::before { content: "<footer>"; }
footer::after { content: "</footer>"; }
form::before { content: "<form>"; }
form::after { content: "</form>"; }
frame::before { content: "<frame>"; }
frame::after { content: "</frame>"; }
frameset::before { content: "<frameset>"; }
frameset::after { content: "</frameset>"; }
h1::before { content: "<h1>"; }
h1::after { content: "</h1>"; }
h2::before { content: "<h2>"; }
h2::after { content: "</h2>"; }
h3::before { content: "<h3>"; }
h3::after { content: "</h3>"; }
h4::before { content: "<h4>"; }
h4::after { content: "</h4>"; }
h5::before { content: "<h5>"; }
h5::after { content: "</h5>"; }
h6::before { content: "<h6>"; }
h6::after { content: "</h6>"; }
head::before { content: "<head>"; }
head::after { content: "</head>"; }
header::before { content: "<header>"; }
header::after { content: "</header>"; }
hgroup::before { content: "<hgroup>"; }
hgroup::after { content: "</hgroup>"; }
hr::before { content: "<hr>"; }
hr::after { content: "</hr>"; }
html::before { content: "<html>"; }
html::after { content: "</html>"; }
i::before { content: "<i>"; }
i::after { content: "</i>"; }
iframe::before { content: "<iframe>"; }
iframe::after { content: "</iframe>"; }
image::before { content: "<image>"; }
image::after { content: "</image>"; }
img::before { content: "<img>"; }
img::after { content: "</img>"; }
input::before { content: "<input>"; }
input::after { content: "</input>"; }
ins::before { content: "<ins>"; }
ins::after { content: "</ins>"; }
isindex::before { content: "<isindex>"; }
isindex::after { content: "</isindex>"; }
kbd::before { content: "<kbd>"; }
kbd::after { content: "</kbd>"; }
keygen::before { content: "<keygen>"; }
keygen::after { content: "</keygen>"; }
label::before { content: "<label>"; }
label::after { content: "</label>"; }
legend::before { content: "<legend>"; }
legend::after { content: "</legend>"; }
li::before { content: "<li>"; }
li::after { content: "</li>"; }
link::before { content: "<link>"; }
link::after { content: "</link>"; }
listing::before { content: "<listing>"; }
listing::after { content: "</listing>"; }
main::before { content: "<main>"; }
main::after { content: "</main>"; }
map::before { content: "<map>"; }
map::after { content: "</map>"; }
mark::before { content: "<mark>"; }
mark::after { content: "</mark>"; }
marquee::before { content: "<marquee>"; }
marquee::after { content: "</marquee>"; }
menu::before { content: "<menu>"; }
menu::after { content: "</menu>"; }
menuitem::before { content: "<menuitem>"; }
menuitem::after { content: "</menuitem>"; }
meta::before { content: "<meta>"; }
meta::after { content: "</meta>"; }
meter::before { content: "<meter>"; }
meter::after { content: "</meter>"; }
multicol::before { content: "<multicol>"; }
multicol::after { content: "</multicol>"; }
nav::before { content: "<nav>"; }
nav::after { content: "</nav>"; }
nextid::before { content: "<nextid>"; }
nextid::after { content: "</nextid>"; }
nobr::before { content: "<nobr>"; }
nobr::after { content: "</nobr>"; }
noembed::before { content: "<noembed>"; }
noembed::after { content: "</noembed>"; }
noframes::before { content: "<noframes>"; }
noframes::after { content: "</noframes>"; }
noscript::before { content: "<noscript>"; }
noscript::after { content: "</noscript>"; }
object::before { content: "<object>"; }
object::after { content: "</object>"; }
ol::before { content: "<ol>"; }
ol::after { content: "</ol>"; }
optgroup::before { content: "<optgroup>"; }
optgroup::after { content: "</optgroup>"; }
option::before { content: "<option>"; }
option::after { content: "</option>"; }
output::before { content: "<output>"; }
output::after { content: "</output>"; }
p::before { content: "<p>"; }
p::after { content: "</p>"; }
param::before { content: "<param>"; }
param::after { content: "</param>"; }
picture::before { content: "<picture>"; }
picture::after { content: "</picture>"; }
plaintext::before { content: "<plaintext>"; }
plaintext::after { content: "</plaintext>"; }
pre::before { content: "<pre>"; }
pre::after { content: "</pre>"; }
progress::before { content: "<progress>"; }
progress::after { content: "</progress>"; }
q::before { content: "<q>"; }
q::after { content: "</q>"; }
rb::before { content: "<rb>"; }
rb::after { content: "</rb>"; }
rp::before { content: "<rp>"; }
rp::after { content: "</rp>"; }
rt::before { content: "<rt>"; }
rt::after { content: "</rt>"; }
rtc::before { content: "<rtc>"; }
rtc::after { content: "</rtc>"; }
ruby::before { content: "<ruby>"; }
ruby::after { content: "</ruby>"; }
s::before { content: "<s>"; }
s::after { content: "</s>"; }
samp::before { content: "<samp>"; }
samp::after { content: "</samp>"; }
script::before { content: "<script>"; }
script::after { content: "</script>"; }
section::before { content: "<section>"; }
section::after { content: "</section>"; }
select::before { content: "<select>"; }
select::after { content: "</select>"; }
shadow::before { content: "<shadow>"; }
shadow::after { content: "</shadow>"; }
slot::before { content: "<slot>"; }
slot::after { content: "</slot>"; }
small::before { content: "<small>"; }
small::after { content: "</small>"; }
source::before { content: "<source>"; }
source::after { content: "</source>"; }
spacer::before { content: "<spacer>"; }
spacer::after { content: "</spacer>"; }
span::before { content: "<span>"; }
span::after { content: "</span>"; }
strike::before { content: "<strike>"; }
strike::after { content: "</strike>"; }
strong::before { content: "<strong>"; }
strong::after { content: "</strong>"; }
style::before { content: "<style>"; }
style::after { content: "<\/style>"; }
sub::before { content: "<sub>"; }
sub::after { content: "</sub>"; }
summary::before { content: "<summary>"; }
summary::after { content: "</summary>"; }
sup::before { content: "<sup>"; }
sup::after { content: "</sup>"; }
table::before { content: "<table>"; }
table::after { content: "</table>"; }
tbody::before { content: "<tbody>"; }
tbody::after { content: "</tbody>"; }
td::before { content: "<td>"; }
td::after { content: "</td>"; }
template::before { content: "<template>"; }
template::after { content: "</template>"; }
textarea::before { content: "<textarea>"; }
textarea::after { content: "</textarea>"; }
tfoot::before { content: "<tfoot>"; }
tfoot::after { content: "</tfoot>"; }
th::before { content: "<th>"; }
th::after { content: "</th>"; }
thead::before { content: "<thead>"; }
thead::after { content: "</thead>"; }
time::before { content: "<time>"; }
time::after { content: "</time>"; }
title::before { content: "<title>"; }
title::after { content: "</title>"; }
tr::before { content: "<tr>"; }
tr::after { content: "</tr>"; }
track::before { content: "<track>"; }
track::after { content: "</track>"; }
tt::before { content: "<tt>"; }
tt::after { content: "</tt>"; }
u::before { content: "<u>"; }
u::after { content: "</u>"; }
ul::before { content: "<ul>"; }
ul::after { content: "</ul>"; }
var::before { content: "<var>"; }
var::after { content: "</var>"; }
video::before { content: "<video>"; }
video::after { content: "</video>"; }
wbr::before { content: "<wbr>"; }
wbr::after { content: "</wbr>"; }
xmp::before { content: "<xmp>"; }
xmp::after { content: "</xmp>"; }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,8 @@
# Dropdown
dropdown_toggle = Toggle Dropdown
# Offcanvas
offcanvas_close = Close
# Navbar
toggle = Toggle navigation

View file

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

View file

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

View file

@ -0,0 +1,8 @@
# Dropdown
dropdown_toggle = Mostrar/ocultar menú
# Offcanvas
offcanvas_close = Cerrar
# Navbar
toggle = Mostrar/ocultar navegación

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,141 @@
use pagetop::prelude::*;
use crate::theme::aux::Color;
// **< ButtonColor >********************************************************************************
/// Variantes de color `btn-*` para botones.
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
pub enum ButtonColor {
/// No define ninguna clase.
#[default]
Default,
/// Genera internamente clases `btn-{color}` (botón relleno).
Background(Color),
/// Genera `btn-outline-{color}` (fondo transparente y contorno con borde).
Outline(Color),
/// Aplica estilo de los enlaces (`btn-link`), sin caja ni fondo, heredando el color de texto.
Link,
}
impl ButtonColor {
const BTN_PREFIX: &str = "btn-";
const BTN_OUTLINE_PREFIX: &str = "btn-outline-";
const BTN_LINK: &str = "btn-link";
/// Añade la clase `btn-*` a la cadena de clases.
#[inline]
pub(crate) fn push_class(self, classes: &mut String) {
if let Self::Default = self {
return;
}
if !classes.is_empty() {
classes.push(' ');
}
match self {
Self::Default => unreachable!(),
Self::Background(c) => {
classes.push_str(Self::BTN_PREFIX);
classes.push_str(c.as_str());
}
Self::Outline(c) => {
classes.push_str(Self::BTN_OUTLINE_PREFIX);
classes.push_str(c.as_str());
}
Self::Link => {
classes.push_str(Self::BTN_LINK);
}
}
}
/// Devuelve la clase `btn-*` correspondiente al color del botón.
///
/// # Ejemplos
///
/// ```rust
/// # use pagetop_bootsier::prelude::*;
/// assert_eq!(
/// ButtonColor::Background(Color::Primary).to_class(),
/// "btn-primary"
/// );
/// assert_eq!(
/// ButtonColor::Outline(Color::Danger).to_class(),
/// "btn-outline-danger"
/// );
/// assert_eq!(ButtonColor::Link.to_class(), "btn-link");
/// assert_eq!(ButtonColor::Default.to_class(), "");
/// ```
pub fn to_class(self) -> String {
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(),
}
}
}
// **< ButtonSize >*********************************************************************************
/// Tamaño visual de un botón.
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
pub enum ButtonSize {
/// Tamaño por defecto del tema (no añade clase).
#[default]
Default,
/// Botón compacto.
Small,
/// Botón destacado/grande.
Large,
}
impl ButtonSize {
const BTN_SM: &str = "btn-sm";
const BTN_LG: &str = "btn-lg";
/// Añade la clase de tamaño `btn-sm` o `btn-lg` a la cadena de clases.
#[inline]
pub(crate) fn push_class(self, classes: &mut String) {
if let Self::Default = self {
return;
}
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),
}
}
/// Devuelve la clase `btn-sm` o `btn-lg` correspondiente al tamaño del botón.
///
/// # Ejemplos
///
/// ```rust
/// # use pagetop_bootsier::prelude::*;
/// assert_eq!(ButtonSize::Small.to_class(), "btn-sm");
/// assert_eq!(ButtonSize::Large.to_class(), "btn-lg");
/// assert_eq!(ButtonSize::Default.to_class(), "");
/// ```
pub fn to_class(self) -> String {
match self {
Self::Default => String::new(),
Self::Small => Self::BTN_SM.to_string(),
Self::Large => Self::BTN_LG.to_string(),
}
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,276 @@
use pagetop::prelude::*;
// **< ItemKind >***********************************************************************************
/// Tipos de [`dropdown::Item`](crate::theme::dropdown::Item) disponibles en un menú desplegable
/// [`Dropdown`](crate::theme::Dropdown).
///
/// Define internamente la naturaleza del elemento y su comportamiento al mostrarse o interactuar
/// con él.
#[derive(AutoDefault)]
pub enum ItemKind {
/// Elemento vacío, no produce salida.
#[default]
Void,
/// Etiqueta sin comportamiento interactivo.
Label(L10n),
/// Elemento de navegación basado en una [`RoutePath`] dinámica devuelta por
/// [`FnPathByContext`]. Opcionalmente, puede abrirse en una nueva ventana y estar inicialmente
/// deshabilitado.
Link {
label: L10n,
route: FnPathByContext,
blank: bool,
disabled: bool,
},
/// Acción ejecutable en la propia página, sin navegación asociada. Inicialmente puede estar
/// deshabilitado.
Button { label: L10n, disabled: bool },
/// Título o encabezado que separa grupos de opciones.
Header(L10n),
/// Separador visual entre bloques de elementos.
Divider,
}
// **< Item >***************************************************************************************
/// Representa un **elemento individual** de un menú desplegable
/// [`Dropdown`](crate::theme::Dropdown).
///
/// Cada instancia de [`dropdown::Item`](crate::theme::dropdown::Item) se traduce en un componente
/// visible que puede comportarse como texto, enlace, botón, encabezado o separador, según su
/// [`ItemKind`].
///
/// Permite definir el identificador, las clases de estilo adicionales y el tipo de interacción
/// asociada, manteniendo una interfaz común para renderizar todos los elementos del menú.
#[derive(AutoDefault, Getters)]
pub struct Item {
#[getters(skip)]
id: AttrId,
/// Devuelve las clases CSS asociadas al elemento.
classes: AttrClasses,
/// Devuelve el tipo de elemento representado.
item_kind: ItemKind,
}
impl Component for Item {
fn new() -> Self {
Item::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,
ItemKind::Label(label) => PrepareMarkup::With(html! {
li id=[self.id()] class=[self.classes().get()] {
span class="dropdown-item-text" {
(label.using(cx))
}
}
}),
ItemKind::Link {
label,
route,
blank,
disabled,
} => {
let route_link = route(cx);
let current_path = cx.request().map(|request| request.path());
let is_current = !*disabled && (current_path == Some(route_link.path()));
let mut classes = "dropdown-item".to_string();
if is_current {
classes.push_str(" active");
}
if *disabled {
classes.push_str(" disabled");
}
let href = (!*disabled).then_some(route_link);
let target = (!*disabled && *blank).then_some("_blank");
let rel = (!*disabled && *blank).then_some("noopener noreferrer");
let aria_current = (href.is_some() && is_current).then_some("page");
let aria_disabled = disabled.then_some("true");
let tabindex = disabled.then_some("-1");
PrepareMarkup::With(html! {
li id=[self.id()] class=[self.classes().get()] {
a
class=(classes)
href=[href]
target=[target]
rel=[rel]
aria-current=[aria_current]
aria-disabled=[aria_disabled]
tabindex=[tabindex]
{
(label.using(cx))
}
}
})
}
ItemKind::Button { label, disabled } => {
let mut classes = "dropdown-item".to_string();
if *disabled {
classes.push_str(" disabled");
}
let aria_disabled = disabled.then_some("true");
let disabled_attr = disabled.then_some("disabled");
PrepareMarkup::With(html! {
li id=[self.id()] class=[self.classes().get()] {
button
class=(classes)
type="button"
aria-disabled=[aria_disabled]
disabled=[disabled_attr]
{
(label.using(cx))
}
}
})
}
ItemKind::Header(label) => PrepareMarkup::With(html! {
li id=[self.id()] class=[self.classes().get()] {
h6 class="dropdown-header" {
(label.using(cx))
}
}
}),
ItemKind::Divider => PrepareMarkup::With(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 {
item_kind: ItemKind::Label(label),
..Default::default()
}
}
/// Crea un enlace para la navegación.
///
/// La ruta se obtiene invocando [`FnPathByContext`], que devuelve dinámicamente una
/// [`RoutePath`] en función del [`Context`]. El enlace se marca como `active` si la ruta actual
/// del *request* coincide con la ruta de destino (devuelta por `RoutePath::path`).
pub fn link(label: L10n, route: FnPathByContext) -> Self {
Item {
item_kind: ItemKind::Link {
label,
route,
blank: false,
disabled: false,
},
..Default::default()
}
}
/// Crea un enlace deshabilitado que no permite la interacción.
pub fn link_disabled(label: L10n, route: FnPathByContext) -> Self {
Item {
item_kind: ItemKind::Link {
label,
route,
blank: false,
disabled: true,
},
..Default::default()
}
}
/// Crea un enlace que se abre en una nueva ventana o pestaña.
pub fn link_blank(label: L10n, route: FnPathByContext) -> Self {
Item {
item_kind: ItemKind::Link {
label,
route,
blank: true,
disabled: false,
},
..Default::default()
}
}
/// Crea un enlace inicialmente deshabilitado que se abriría en una nueva ventana.
pub fn link_blank_disabled(label: L10n, route: FnPathByContext) -> Self {
Item {
item_kind: ItemKind::Link {
label,
route,
blank: true,
disabled: true,
},
..Default::default()
}
}
/// Crea un botón de acción local, sin navegación asociada.
pub fn button(label: L10n) -> Self {
Item {
item_kind: ItemKind::Button {
label,
disabled: false,
},
..Default::default()
}
}
/// Crea un botón deshabilitado.
pub fn button_disabled(label: L10n) -> Self {
Item {
item_kind: ItemKind::Button {
label,
disabled: true,
},
..Default::default()
}
}
/// Crea un encabezado para un grupo de elementos dentro del menú.
pub fn header(label: L10n) -> Self {
Item {
item_kind: ItemKind::Header(label),
..Default::default()
}
}
/// Crea un separador visual entre bloques de elementos.
pub fn divider() -> Self {
Item {
item_kind: ItemKind::Divider,
..Default::default()
}
}
// **< Item BUILDER >***************************************************************************
/// Establece el identificador único (`id`) del elemento.
#[builder_fn]
pub fn with_id(mut self, id: impl AsRef<str>) -> Self {
self.id.alter_value(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
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,117 @@
use pagetop::prelude::*;
use crate::prelude::*;
/// Componente para crear un **menú** o alguna de sus variantes ([`nav::Kind`]).
///
/// Presenta un menú con una lista de elementos usando una vista básica, o alguna de sus variantes
/// como *pestañas* (`Tabs`), *botones* (`Pills`) o *subrayado* (`Underline`). También permite
/// controlar su distribución y orientación ([`nav::Layout`](crate::theme::nav::Layout)).
///
/// Ver ejemplo en el módulo [`nav`].
/// Si no contiene elementos, el componente **no se renderiza**.
#[derive(AutoDefault, Getters)]
pub struct Nav {
#[getters(skip)]
id: AttrId,
/// Devuelve las clases CSS asociadas al menú.
classes: AttrClasses,
/// Devuelve el estilo visual seleccionado.
nav_kind: nav::Kind,
/// Devuelve la distribución y orientación seleccionada.
nav_layout: nav::Layout,
/// Devuelve la lista de elementos del menú.
items: Children,
}
impl Component for Nav {
fn new() -> Self {
Nav::default()
}
fn id(&self) -> Option<String> {
self.id.get()
}
fn setup_before_prepare(&mut self, _cx: &mut Context) {
self.alter_classes(ClassesOp::Prepend, {
let mut classes = "nav".to_string();
self.nav_kind().push_class(&mut classes);
self.nav_layout().push_class(&mut classes);
classes
});
}
fn prepare_component(&self, cx: &mut Context) -> PrepareMarkup {
let items = self.items().render(cx);
if items.is_empty() {
return PrepareMarkup::None;
}
PrepareMarkup::With(html! {
ul id=[self.id()] class=[self.classes().get()] {
(items)
}
})
}
}
impl Nav {
/// Crea un `Nav` usando pestañas para los elementos (*Tabs*).
pub fn tabs() -> Self {
Nav::default().with_kind(nav::Kind::Tabs)
}
/// Crea un `Nav` usando botones para los elementos (*Pills*).
pub fn pills() -> Self {
Nav::default().with_kind(nav::Kind::Pills)
}
/// Crea un `Nav` usando elementos subrayados (*Underline*).
pub fn underline() -> Self {
Nav::default().with_kind(nav::Kind::Underline)
}
// **< Nav BUILDER >****************************************************************************
/// Establece el identificador único (`id`) del menú.
#[builder_fn]
pub fn with_id(mut self, id: impl AsRef<str>) -> Self {
self.id.alter_value(id);
self
}
/// Modifica la lista de clases CSS aplicadas al menú.
#[builder_fn]
pub fn with_classes(mut self, op: ClassesOp, classes: impl AsRef<str>) -> Self {
self.classes.alter_value(op, classes);
self
}
/// Cambia el estilo del menú (*Tabs*, *Pills*, *Underline* o *Default*).
#[builder_fn]
pub fn with_kind(mut self, kind: nav::Kind) -> Self {
self.nav_kind = kind;
self
}
/// Selecciona la distribución y orientación del menú.
#[builder_fn]
pub fn with_layout(mut self, layout: nav::Layout) -> Self {
self.nav_layout = layout;
self
}
/// Añade un nuevo elemento hijo al menú.
pub fn add_item(mut self, item: nav::Item) -> Self {
self.items.add(Child::with(item));
self
}
/// Modifica la lista de elementos (`children`) aplicando una operación [`TypedOp`].
#[builder_fn]
pub fn with_items(mut self, op: TypedOp<nav::Item>) -> Self {
self.items.alter_typed(op);
self
}
}

View file

@ -0,0 +1,299 @@
use pagetop::prelude::*;
use crate::prelude::*;
use crate::LOCALES_BOOTSIER;
// **< ItemKind >***********************************************************************************
/// Tipos de [`nav::Item`](crate::theme::nav::Item) disponibles en un menú
/// [`Nav`](crate::theme::Nav).
///
/// Define internamente la naturaleza del elemento y su comportamiento al mostrarse o interactuar
/// con él.
#[derive(AutoDefault)]
pub enum ItemKind {
/// Elemento vacío, no produce salida.
#[default]
Void,
/// Etiqueta sin comportamiento interactivo.
Label(L10n),
/// Elemento de navegación basado en una [`RoutePath`] dinámica devuelta por
/// [`FnPathByContext`]. Opcionalmente, puede abrirse en una nueva ventana y estar inicialmente
/// deshabilitado.
Link {
label: L10n,
route: FnPathByContext,
blank: bool,
disabled: bool,
},
/// Contenido HTML arbitrario. El componente [`Html`] se renderiza tal cual como elemento del
/// menú, sin añadir ningún comportamiento de navegación adicional.
Html(Typed<Html>),
/// Elemento que despliega un menú [`Dropdown`].
Dropdown(Typed<Dropdown>),
}
impl ItemKind {
const ITEM: &str = "nav-item";
const DROPDOWN: &str = "nav-item dropdown";
// Devuelve las clases base asociadas al tipo de elemento.
#[inline]
const fn as_str(&self) -> &'static str {
match self {
Self::Void => "",
Self::Dropdown(_) => Self::DROPDOWN,
_ => Self::ITEM,
}
}
/* Añade las clases asociadas al tipo de elemento a la cadena de clases (reservado).
#[inline]
pub(crate) fn push_class(&self, classes: &mut String) {
let class = self.as_str();
if class.is_empty() {
return;
}
if !classes.is_empty() {
classes.push(' ');
}
classes.push_str(class);
} */
/// Devuelve las clases asociadas al tipo de elemento.
#[inline]
pub(crate) fn to_class(&self) -> String {
self.as_str().to_owned()
}
}
// **< Item >***************************************************************************************
/// Representa un **elemento individual** de un menú [`Nav`](crate::theme::Nav).
///
/// Cada instancia de [`nav::Item`](crate::theme::nav::Item) se traduce en un componente visible que
/// puede comportarse como texto, enlace, contenido HTML o menú desplegable, según su [`ItemKind`].
///
/// Permite definir el identificador, las clases de estilo adicionales y el tipo de interacción
/// asociada, manteniendo una interfaz común para renderizar todos los elementos del menú.
#[derive(AutoDefault, Getters)]
pub struct Item {
#[getters(skip)]
id: AttrId,
/// Devuelve las clases CSS asociadas al elemento.
classes: AttrClasses,
/// Devuelve el tipo de elemento representado.
item_kind: ItemKind,
}
impl Component for Item {
fn new() -> Self {
Item::default()
}
fn id(&self) -> Option<String> {
self.id.get()
}
fn setup_before_prepare(&mut self, _cx: &mut Context) {
self.alter_classes(ClassesOp::Prepend, self.item_kind().to_class());
}
fn prepare_component(&self, cx: &mut Context) -> PrepareMarkup {
match self.item_kind() {
ItemKind::Void => PrepareMarkup::None,
ItemKind::Label(label) => PrepareMarkup::With(html! {
li id=[self.id()] class=[self.classes().get()] {
span class="nav-link disabled" aria-disabled="true" {
(label.using(cx))
}
}
}),
ItemKind::Link {
label,
route,
blank,
disabled,
} => {
let route_link = route(cx);
let current_path = cx.request().map(|request| request.path());
let is_current = !*disabled && (current_path == Some(route_link.path()));
let mut classes = "nav-link".to_string();
if is_current {
classes.push_str(" active");
}
if *disabled {
classes.push_str(" disabled");
}
let href = (!*disabled).then_some(route_link);
let target = (!*disabled && *blank).then_some("_blank");
let rel = (!*disabled && *blank).then_some("noopener noreferrer");
let aria_current = (href.is_some() && is_current).then_some("page");
let aria_disabled = (*disabled).then_some("true");
PrepareMarkup::With(html! {
li id=[self.id()] class=[self.classes().get()] {
a
class=(classes)
href=[href]
target=[target]
rel=[rel]
aria-current=[aria_current]
aria-disabled=[aria_disabled]
{
(label.using(cx))
}
}
})
}
ItemKind::Html(html) => PrepareMarkup::With(html! {
li id=[self.id()] class=[self.classes().get()] {
(html.render(cx))
}
}),
ItemKind::Dropdown(menu) => {
if let Some(dd) = menu.borrow() {
let items = dd.items().render(cx);
if items.is_empty() {
return PrepareMarkup::None;
}
let title = dd.title().lookup(cx).unwrap_or_else(|| {
L10n::t("dropdown", &LOCALES_BOOTSIER)
.lookup(cx)
.unwrap_or_else(|| "Dropdown".to_string())
});
PrepareMarkup::With(html! {
li id=[self.id()] class=[self.classes().get()] {
a
class="nav-link dropdown-toggle"
data-bs-toggle="dropdown"
href="#"
role="button"
aria-expanded="false"
{
(title)
}
ul class="dropdown-menu" {
(items)
}
}
})
} else {
PrepareMarkup::None
}
}
}
}
}
impl Item {
/// Crea un elemento de tipo texto, mostrado sin interacción.
pub fn label(label: L10n) -> Self {
Item {
item_kind: ItemKind::Label(label),
..Default::default()
}
}
/// Crea un enlace para la navegación.
///
/// La ruta se obtiene invocando [`FnPathByContext`], que devuelve dinámicamente una
/// [`RoutePath`] en función del [`Context`]. El enlace se marca como `active` si la ruta actual
/// del *request* coincide con la ruta de destino (devuelta por `RoutePath::path`).
pub fn link(label: L10n, route: FnPathByContext) -> Self {
Item {
item_kind: ItemKind::Link {
label,
route,
blank: false,
disabled: false,
},
..Default::default()
}
}
/// Crea un enlace deshabilitado que no permite la interacción.
pub fn link_disabled(label: L10n, route: FnPathByContext) -> Self {
Item {
item_kind: ItemKind::Link {
label,
route,
blank: false,
disabled: true,
},
..Default::default()
}
}
/// Crea un enlace que se abre en una nueva ventana o pestaña.
pub fn link_blank(label: L10n, route: FnPathByContext) -> Self {
Item {
item_kind: ItemKind::Link {
label,
route,
blank: true,
disabled: false,
},
..Default::default()
}
}
/// Crea un enlace inicialmente deshabilitado que se abriría en una nueva ventana.
pub fn link_blank_disabled(label: L10n, route: FnPathByContext) -> Self {
Item {
item_kind: ItemKind::Link {
label,
route,
blank: true,
disabled: true,
},
..Default::default()
}
}
/// Crea un elemento con contenido HTML arbitrario.
///
/// El contenido se renderiza tal cual lo devuelve el componente [`Html`], dentro de un `<li>`
/// con las clases de navegación asociadas a [`Item`].
pub fn html(html: Html) -> Self {
Item {
item_kind: ItemKind::Html(Typed::with(html)),
..Default::default()
}
}
/// Crea un elemento de navegación que contiene un menú desplegable [`Dropdown`].
///
/// Sólo se tienen en cuenta **el título** (si no existe, se asigna uno por defecto) y **la
/// lista de elementos** del [`Dropdown`]; el resto de propiedades del componente no afectarán
/// a su representación en [`Nav`].
pub fn dropdown(menu: Dropdown) -> Self {
Item {
item_kind: ItemKind::Dropdown(Typed::with(menu)),
..Default::default()
}
}
// **< Item BUILDER >***************************************************************************
/// Establece el identificador único (`id`) del elemento.
#[builder_fn]
pub fn with_id(mut self, id: impl AsRef<str>) -> Self {
self.id.alter_value(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
}
}

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,95 @@
use pagetop::prelude::*;
use crate::prelude::*;
/// Elementos que puede contener una barra de navegación [`Navbar`](crate::theme::Navbar).
///
/// Cada variante determina qué se renderiza y cómo. Estos elementos se colocan **dentro del
/// contenido** de la barra (la parte colapsable, el *offcanvas* o el bloque simple), por lo que son
/// independientes de la marca o del botón que ya pueda definir el propio [`navbar::Layout`].
#[derive(AutoDefault)]
pub enum Item {
/// Sin contenido, no produce salida.
#[default]
Void,
/// Marca de identidad mostrada dentro del contenido de la barra de navegación.
///
/// Útil cuando el [`navbar::Layout`] no incluye marca, y se quiere incluir dentro del área
/// colapsable/*offcanvas*. Si el *layout* ya muestra una marca, esta variante no la sustituye,
/// sólo añade otra dentro del bloque de contenidos.
Brand(Typed<navbar::Brand>),
/// Representa un menú de navegación [`Nav`](crate::theme::Nav).
Nav(Typed<Nav>),
/// Representa un *texto localizado* libre.
Text(L10n),
}
impl Component for Item {
fn new() -> Self {
Item::default()
}
fn id(&self) -> Option<String> {
match self {
Self::Void => None,
Self::Brand(brand) => brand.id(),
Self::Nav(nav) => nav.id(),
Self::Text(_) => None,
}
}
fn setup_before_prepare(&mut self, _cx: &mut Context) {
if let Self::Nav(nav) = self {
if let Some(mut nav) = nav.borrow_mut() {
nav.alter_classes(ClassesOp::Prepend, "navbar-nav");
}
}
}
fn prepare_component(&self, cx: &mut Context) -> PrepareMarkup {
match self {
Self::Void => PrepareMarkup::None,
Self::Brand(brand) => PrepareMarkup::With(html! { (brand.render(cx)) }),
Self::Nav(nav) => {
if let Some(nav) = nav.borrow() {
let items = nav.items().render(cx);
if items.is_empty() {
return PrepareMarkup::None;
}
PrepareMarkup::With(html! {
ul id=[nav.id()] class=[nav.classes().get()] {
(items)
}
})
} else {
PrepareMarkup::None
}
}
Self::Text(text) => PrepareMarkup::With(html! {
span class="navbar-text" {
(text.using(cx))
}
}),
}
}
}
impl Item {
/// Crea un elemento de tipo [`navbar::Brand`] para añadir en el contenido de [`Navbar`].
///
/// Pensado para barras colapsables u offcanvas donde se quiere que la marca aparezca en la zona
/// desplegable.
pub fn brand(brand: navbar::Brand) -> Self {
Self::Brand(Typed::with(brand))
}
/// Crea un elemento de tipo [`Nav`] para añadir al contenido de [`Navbar`].
pub fn nav(item: Nav) -> Self {
Self::Nav(Typed::with(item))
}
/// Crea un elemento con un *texto localizado*, mostrado sin interacción.
pub fn text(item: L10n) -> Self {
Self::Text(item)
}
}

View file

@ -0,0 +1,98 @@
use pagetop::prelude::*;
use crate::prelude::*;
// **< Layout >*************************************************************************************
/// Representa los diferentes tipos de presentación de una barra de navegación [`Navbar`].
#[derive(AutoDefault)]
pub enum Layout {
/// Barra simple, sin marca de identidad y sin botón de despliegue.
///
/// La barra de navegación no se colapsa.
#[default]
Simple,
/// Barra simple, con botón de despliegue a la izquierda y sin marca de identidad.
SimpleToggle,
/// Barra simple, con marca de identidad a la izquierda y sin botón de despliegue.
///
/// La barra de navegación no se colapsa.
SimpleBrandLeft(Typed<navbar::Brand>),
/// Barra con marca de identidad a la izquierda y botón de despliegue a la derecha.
BrandLeft(Typed<navbar::Brand>),
/// Barra con botón de despliegue a la izquierda y marca de identidad a la derecha.
BrandRight(Typed<navbar::Brand>),
/// Contenido en [`Offcanvas`], con botón de despliegue a la izquierda y sin marca de identidad.
Offcanvas(Typed<Offcanvas>),
/// Contenido en [`Offcanvas`], con marca de identidad a la izquierda y botón de despliegue a la
/// derecha.
OffcanvasBrandLeft(Typed<navbar::Brand>, Typed<Offcanvas>),
/// Contenido en [`Offcanvas`], con botón de despliegue a la izquierda y marca de identidad a la
/// derecha.
OffcanvasBrandRight(Typed<navbar::Brand>, Typed<Offcanvas>),
}
// **< Position >***********************************************************************************
/// Posición global de una barra de navegación [`Navbar`] en el documento.
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
pub enum Position {
/// Barra normal, fluye con el documento.
#[default]
Static,
/// Barra fijada en la parte superior, siempre visible.
///
/// Puede ser necesario reservar espacio en la parte superior del contenido que fluye debajo
/// para evitar que quede oculto por la barra.
FixedTop,
/// Barra fijada en la parte inferior, siempre visible.
///
/// Puede ser necesario reservar espacio en la parte inferior del contenido que fluye debajo
/// para evitar que quede oculto por la barra.
FixedBottom,
/// La barra de navegación se fija en la parte superior al hacer *scroll*.
StickyTop,
/// La barra de navegación se fija en la parte inferior al hacer *scroll*.
StickyBottom,
}
impl Position {
/// Devuelve la clase base asociada a la posición de la barra de navegación.
#[inline]
const fn as_str(self) -> &'static str {
match self {
Self::Static => "",
Self::FixedTop => "fixed-top",
Self::FixedBottom => "fixed-bottom",
Self::StickyTop => "sticky-top",
Self::StickyBottom => "sticky-bottom",
}
}
/// Añade la clase asociada a la posición de la barra de navegación a la cadena de clases.
#[inline]
pub(crate) fn push_class(self, classes: &mut String) {
let class = self.as_str();
if class.is_empty() {
return;
}
if !classes.is_empty() {
classes.push(' ');
}
classes.push_str(class);
}
/* Devuelve la clase asociada a la posición de la barra de navegación, o cadena vacía si no
/// aplica (reservado).
#[inline]
pub(crate) fn to_class(self) -> String {
self.as_str().to_string()
} */
}

View file

@ -0,0 +1,27 @@
//! Definiciones para crear paneles laterales deslizantes [`Offcanvas`].
//!
//! # Ejemplo
//!
//! ```rust
//! # use pagetop::prelude::*;
//! # use pagetop_bootsier::prelude::*;
//! let panel = Offcanvas::new()
//! .with_id("offcanvas_example")
//! .with_title(L10n::n("Offcanvas title"))
//! .with_placement(offcanvas::Placement::End)
//! .with_backdrop(offcanvas::Backdrop::Enabled)
//! .with_body_scroll(offcanvas::BodyScroll::Enabled)
//! .with_visibility(offcanvas::Visibility::Default)
//! .add_child(Dropdown::new()
//! .with_title(L10n::n("Menu"))
//! .add_item(dropdown::Item::label(L10n::n("Label")))
//! .add_item(dropdown::Item::link_blank(L10n::n("Google"), |_| "https://google.es".into()))
//! .add_item(dropdown::Item::link(L10n::n("Sign out"), |_| "/signout".into()))
//! );
//! ```
mod props;
pub use props::{Backdrop, BodyScroll, Placement, Visibility};
mod component;
pub use component::Offcanvas;

View file

@ -0,0 +1,206 @@
use pagetop::prelude::*;
use crate::prelude::*;
use crate::LOCALES_BOOTSIER;
/// Componente para crear un **panel lateral deslizante** con contenidos adicionales.
///
/// Útil para navegación, filtros, formularios o menús contextuales. Incluye las siguientes
/// características principales:
///
/// - Puede mostrar una capa de fondo para centrar la atención del usuario en el panel
/// ([`with_backdrop()`](Self::with_backdrop)); o puede bloquear el desplazamiento del documento
/// principal ([`with_body_scroll()`](Self::with_body_scroll)).
/// - Se puede configurar el borde de la ventana desde el que se desliza el panel
/// ([`with_placement()`](Self::with_placement)).
/// - Encabezado con título ([`with_title()`](Self::with_title)) y **botón de cierre** integrado.
/// - Puede cambiar su comportamiento a partir de un punto de ruptura
/// ([`with_breakpoint()`](Self::with_breakpoint)).
/// - Asocia título y controles de accesibilidad a un identificador único y expone atributos
/// adecuados para lectores de pantalla y navegación por teclado.
///
/// Ver ejemplo en el módulo [`offcanvas`].
/// Si no contiene elementos, el componente **no se renderiza**.
#[derive(AutoDefault, Getters)]
pub struct Offcanvas {
#[getters(skip)]
id: AttrId,
/// Devuelve las clases CSS asociadas al panel.
classes: AttrClasses,
/// Devuelve el título del panel.
title: L10n,
/// Devuelve el punto de ruptura configurado para cambiar el comportamiento del panel.
breakpoint: BreakPoint,
/// Devuelve el comportamiento configurado para la capa de fondo.
backdrop: offcanvas::Backdrop,
/// Indica si la página principal puede desplazarse mientras el panel está abierto.
body_scroll: offcanvas::BodyScroll,
/// Devuelve la posición de inicio del panel.
placement: offcanvas::Placement,
/// Devuelve el estado inicial del panel.
visibility: offcanvas::Visibility,
/// Devuelve la lista de componentes (`children`) del panel.
children: Children,
}
impl Component for Offcanvas {
fn new() -> Self {
Offcanvas::default()
}
fn id(&self) -> Option<String> {
self.id.get()
}
fn setup_before_prepare(&mut self, _cx: &mut Context) {
self.alter_classes(ClassesOp::Prepend, {
let mut classes = "offcanvas".to_string();
self.breakpoint().push_class(&mut classes, "offcanvas", "");
self.placement().push_class(&mut classes);
self.visibility().push_class(&mut classes);
classes
});
}
fn prepare_component(&self, cx: &mut Context) -> PrepareMarkup {
PrepareMarkup::With(self.render_offcanvas(cx, None))
}
}
impl Offcanvas {
// **< Offcanvas BUILDER >**********************************************************************
/// Establece el identificador único (`id`) del panel.
#[builder_fn]
pub fn with_id(mut self, id: impl AsRef<str>) -> Self {
self.id.alter_value(id);
self
}
/// Modifica la lista de clases CSS aplicadas al panel.
#[builder_fn]
pub fn with_classes(mut self, op: ClassesOp, classes: impl AsRef<str>) -> Self {
self.classes.alter_value(op, classes);
self
}
/// Establece el título del encabezado.
#[builder_fn]
pub fn with_title(mut self, title: L10n) -> Self {
self.title = title;
self
}
/// Establece el punto de ruptura a partir del cual cambia el comportamiento del panel.
///
/// - **Por debajo** de ese tamaño de pantalla, el componente actúa como panel deslizante
/// ([`Offcanvas`]).
/// - **Por encima**, el contenido del panel se muestra tal cual, integrado en la página.
///
/// Por ejemplo, con `BreakPoint::LG`, será *offcanvas* en móviles y tabletas, y visible
/// directamente en pantallas grandes. Por defecto usa `BreakPoint::None` para que sea
/// *offcanvas* siempre.
#[builder_fn]
pub fn with_breakpoint(mut self, bp: BreakPoint) -> Self {
self.breakpoint = bp;
self
}
/// Ajusta la capa de fondo del panel para definir su comportamiento al hacer clic fuera del
/// panel.
#[builder_fn]
pub fn with_backdrop(mut self, backdrop: offcanvas::Backdrop) -> Self {
self.backdrop = backdrop;
self
}
/// Permite o bloquea el desplazamiento de la página principal mientras el panel está abierto.
#[builder_fn]
pub fn with_body_scroll(mut self, scrolling: offcanvas::BodyScroll) -> Self {
self.body_scroll = scrolling;
self
}
/// Indica desde qué borde de la ventana entra y se ancla el panel.
#[builder_fn]
pub fn with_placement(mut self, placement: offcanvas::Placement) -> Self {
self.placement = placement;
self
}
/// Fija el estado inicial del panel (oculto o visible al cargar).
#[builder_fn]
pub fn with_visibility(mut self, visibility: offcanvas::Visibility) -> Self {
self.visibility = visibility;
self
}
/// Añade un nuevo componente hijo al panel.
#[inline]
pub fn add_child(mut self, child: impl Component) -> Self {
self.children.add(Child::with(child));
self
}
/// Modifica la lista de componentes (`children`) aplicando una operación [`ChildOp`].
#[builder_fn]
pub fn with_children(mut self, op: ChildOp) -> Self {
self.children.alter_child(op);
self
}
// **< Offcanvas HELPERS >**********************************************************************
pub(crate) fn render_offcanvas(&self, cx: &mut Context, extra: Option<&Children>) -> Markup {
let body = self.children().render(cx);
let body_extra = extra.map(|c| c.render(cx)).unwrap_or_else(|| html! {});
if body.is_empty() && body_extra.is_empty() {
return html! {};
}
let id = cx.required_id::<Self>(self.id());
let id_label = util::join!(id, "-label");
let id_target = util::join!("#", id);
let body_scroll = match self.body_scroll() {
offcanvas::BodyScroll::Disabled => None,
offcanvas::BodyScroll::Enabled => Some("true"),
};
let backdrop = match self.backdrop() {
offcanvas::Backdrop::Disabled => Some("false"),
offcanvas::Backdrop::Enabled => None,
offcanvas::Backdrop::Static => Some("static"),
};
let title = self.title().using(cx);
html! {
div
id=(id)
class=[self.classes().get()]
tabindex="-1"
data-bs-scroll=[body_scroll]
data-bs-backdrop=[backdrop]
aria-labelledby=(id_label)
{
div class="offcanvas-header" {
@if !title.is_empty() {
h5 class="offcanvas-title" id=(id_label) { (title) }
}
button
type="button"
class="btn-close"
data-bs-dismiss="offcanvas"
data-bs-target=(id_target)
aria-label=[L10n::t("offcanvas_close", &LOCALES_BOOTSIER).lookup(cx)]
{}
}
div class="offcanvas-body" {
(body)
(body_extra)
}
}
}
}
}

View file

@ -0,0 +1,119 @@
use pagetop::prelude::*;
// **< Backdrop >***********************************************************************************
/// Comportamiento de la capa de fondo (*backdrop*) de un panel
/// [`Offcanvas`](crate::theme::Offcanvas) al deslizarse.
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
pub enum Backdrop {
/// Sin capa de fondo, la página principal permanece visible e interactiva.
Disabled,
/// Opción por defecto, se oscurece el fondo; un clic fuera del panel suele cerrarlo.
#[default]
Enabled,
/// Muestra la capa de fondo pero no se cierra al hacer clic fuera del panel. Útil si se
/// requiere completar una acción antes de salir.
Static,
}
// **< BodyScroll >*********************************************************************************
/// Controla si la página principal puede desplazarse al abrir un panel
/// [`Offcanvas`](crate::theme::Offcanvas).
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
pub enum BodyScroll {
/// Opción por defecto, la página principal se bloquea centrando la interacción en el panel.
#[default]
Disabled,
/// Permite el desplazamiento de la página principal.
Enabled,
}
// **< Placement >**********************************************************************************
/// Posición de aparición de un panel [`Offcanvas`](crate::theme::Offcanvas) al deslizarse.
///
/// Define desde qué borde de la ventana entra y se ancla el panel.
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
pub enum Placement {
/// Opción por defecto, desde el borde inicial según dirección de lectura (respetando LTR/RTL).
#[default]
Start,
/// Desde el borde final según dirección de lectura (respetando LTR/RTL).
End,
/// Desde la parte superior.
Top,
/// Desde la parte inferior.
Bottom,
}
impl Placement {
/// Devuelve la clase base asociada a la posición de aparición del panel.
#[rustfmt::skip]
#[inline]
const fn as_str(self) -> &'static str {
match self {
Placement::Start => "offcanvas-start",
Placement::End => "offcanvas-end",
Placement::Top => "offcanvas-top",
Placement::Bottom => "offcanvas-bottom",
}
}
/// Añade la clase asociada a la posición de aparición del panel a la cadena de clases.
#[inline]
pub(crate) fn push_class(self, classes: &mut String) {
if !classes.is_empty() {
classes.push(' ');
}
classes.push_str(self.as_str());
}
/* Devuelve la clase asociada a la posición de aparición del panel (reservado).
#[inline]
pub(crate) fn to_class(self) -> String {
self.as_str().to_owned()
} */
}
// **< Visibility >*********************************************************************************
/// Estado inicial de un panel [`Offcanvas`](crate::theme::Offcanvas).
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
pub enum Visibility {
/// El panel permanece oculto desde el principio.
#[default]
Default,
/// El panel se muestra abierto al cargar.
Show,
}
impl Visibility {
/// Devuelve la clase base asociada al estado inicial del panel.
#[inline]
const fn as_str(self) -> &'static str {
match self {
Visibility::Default => "",
Visibility::Show => "show",
}
}
/// Añade la clase asociada al estado inicial del panel a la cadena de clases.
#[inline]
pub(crate) fn push_class(self, classes: &mut String) {
let class = self.as_str();
if class.is_empty() {
return;
}
if !classes.is_empty() {
classes.push(' ');
}
classes.push_str(class);
}
/* Devuelve la clase asociada al estado inicial, o una cadena vacía si no aplica (reservado).
#[inline]
pub(crate) fn to_class(self) -> String {
self.as_str().to_owned()
} */
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,114 @@
// Enable CSS Grid
$enable-grid-classes: false;
$enable-cssgrid: true;
// Opacity
.bg-opacity-0 {
--bs-bg-opacity: 0;
}
.border-opacity-0 {
--bs-border-opacity: 0;
}
.text-opacity-0 {
--bs-text-opacity: 0;
}
.text-opacity-10 {
--bs-text-opacity: 0.1;
}
// Extending utilities
$utilities: map-merge(
$utilities,
(
// Individual border widths
"border-top": (
property: border-top-width,
class: border-top,
values: $border-widths
),
"border-end": (
property: border-right-width,
class: border-end,
values: $border-widths
),
"border-bottom": (
property: border-bottom-width,
class: border-bottom,
values: $border-widths
),
"border-start": (
property: border-left-width,
class: border-start,
values: $border-widths
),
// Individual rounded values
"rounded-top-start": (
property: border-top-left-radius,
class: rounded-top-start,
values: (
null: var(--#{$prefix}border-radius),
0: 0,
1: var(--#{$prefix}border-radius-sm),
2: var(--#{$prefix}border-radius),
3: var(--#{$prefix}border-radius-lg),
4: var(--#{$prefix}border-radius-xl),
5: var(--#{$prefix}border-radius-xxl),
circle: 50%,
pill: var(--#{$prefix}border-radius-pill)
)
),
"rounded-top-end": (
property: border-top-right-radius,
class: rounded-top-end,
values: (
null: var(--#{$prefix}border-radius),
0: 0,
1: var(--#{$prefix}border-radius-sm),
2: var(--#{$prefix}border-radius),
3: var(--#{$prefix}border-radius-lg),
4: var(--#{$prefix}border-radius-xl),
5: var(--#{$prefix}border-radius-xxl),
circle: 50%,
pill: var(--#{$prefix}border-radius-pill)
)
),
"rounded-bottom-start": (
property: border-bottom-left-radius,
class: rounded-bottom-start,
values: (
null: var(--#{$prefix}border-radius),
0: 0,
1: var(--#{$prefix}border-radius-sm),
2: var(--#{$prefix}border-radius),
3: var(--#{$prefix}border-radius-lg),
4: var(--#{$prefix}border-radius-xl),
5: var(--#{$prefix}border-radius-xxl),
circle: 50%,
pill: var(--#{$prefix}border-radius-pill)
)
),
"rounded-bottom-end": (
property: border-bottom-right-radius,
class: rounded-bottom-end,
values: (
null: var(--#{$prefix}border-radius),
0: 0,
1: var(--#{$prefix}border-radius-sm),
2: var(--#{$prefix}border-radius),
3: var(--#{$prefix}border-radius-lg),
4: var(--#{$prefix}border-radius-xl),
5: var(--#{$prefix}border-radius-xxl),
circle: 50%,
pill: var(--#{$prefix}border-radius-pill)
)
),
)
);
// Region Footer
.region-footer {
padding: .75rem 0 3rem;
text-align: center;
}

View file

@ -0,0 +1,55 @@
@import "bootstrap-5.3.8/mixins/banner";
@include bsBanner("");
// scss-docs-start import-stack
// Configuration
@import "bootstrap-5.3.8/functions";
@import "bootstrap-5.3.8/variables";
@import "bootstrap-5.3.8/variables-dark";
@import "bootstrap-5.3.8/maps";
@import "bootstrap-5.3.8/mixins";
@import "bootstrap-5.3.8/utilities";
// Layout & components
@import "bootstrap-5.3.8/root";
@import "bootstrap-5.3.8/reboot";
@import "bootstrap-5.3.8/type";
@import "bootstrap-5.3.8/images";
@import "bootstrap-5.3.8/containers";
@import "bootstrap-5.3.8/grid";
@import "bootstrap-5.3.8/tables";
@import "bootstrap-5.3.8/forms";
@import "bootstrap-5.3.8/buttons";
@import "bootstrap-5.3.8/transitions";
@import "bootstrap-5.3.8/dropdown";
@import "bootstrap-5.3.8/button-group";
@import "bootstrap-5.3.8/nav";
@import "bootstrap-5.3.8/navbar";
@import "bootstrap-5.3.8/card";
@import "bootstrap-5.3.8/accordion";
@import "bootstrap-5.3.8/breadcrumb";
@import "bootstrap-5.3.8/pagination";
@import "bootstrap-5.3.8/badge";
@import "bootstrap-5.3.8/alert";
@import "bootstrap-5.3.8/progress";
@import "bootstrap-5.3.8/list-group";
@import "bootstrap-5.3.8/close";
@import "bootstrap-5.3.8/toasts";
@import "bootstrap-5.3.8/modal";
@import "bootstrap-5.3.8/tooltip";
@import "bootstrap-5.3.8/popover";
@import "bootstrap-5.3.8/carousel";
@import "bootstrap-5.3.8/spinners";
@import "bootstrap-5.3.8/offcanvas";
@import "bootstrap-5.3.8/placeholders";
// Helpers
@import "bootstrap-5.3.8/helpers";
// Custom definitions
@import "customs";
// Utilities
@import "bootstrap-5.3.8/utilities/api";
// scss-docs-end import-stack

View file

@ -0,0 +1,153 @@
//
// Base styles
//
.accordion {
// scss-docs-start accordion-css-vars
--#{$prefix}accordion-color: #{$accordion-color};
--#{$prefix}accordion-bg: #{$accordion-bg};
--#{$prefix}accordion-transition: #{$accordion-transition};
--#{$prefix}accordion-border-color: #{$accordion-border-color};
--#{$prefix}accordion-border-width: #{$accordion-border-width};
--#{$prefix}accordion-border-radius: #{$accordion-border-radius};
--#{$prefix}accordion-inner-border-radius: #{$accordion-inner-border-radius};
--#{$prefix}accordion-btn-padding-x: #{$accordion-button-padding-x};
--#{$prefix}accordion-btn-padding-y: #{$accordion-button-padding-y};
--#{$prefix}accordion-btn-color: #{$accordion-button-color};
--#{$prefix}accordion-btn-bg: #{$accordion-button-bg};
--#{$prefix}accordion-btn-icon: #{escape-svg($accordion-button-icon)};
--#{$prefix}accordion-btn-icon-width: #{$accordion-icon-width};
--#{$prefix}accordion-btn-icon-transform: #{$accordion-icon-transform};
--#{$prefix}accordion-btn-icon-transition: #{$accordion-icon-transition};
--#{$prefix}accordion-btn-active-icon: #{escape-svg($accordion-button-active-icon)};
--#{$prefix}accordion-btn-focus-box-shadow: #{$accordion-button-focus-box-shadow};
--#{$prefix}accordion-body-padding-x: #{$accordion-body-padding-x};
--#{$prefix}accordion-body-padding-y: #{$accordion-body-padding-y};
--#{$prefix}accordion-active-color: #{$accordion-button-active-color};
--#{$prefix}accordion-active-bg: #{$accordion-button-active-bg};
// scss-docs-end accordion-css-vars
}
.accordion-button {
position: relative;
display: flex;
align-items: center;
width: 100%;
padding: var(--#{$prefix}accordion-btn-padding-y) var(--#{$prefix}accordion-btn-padding-x);
@include font-size($font-size-base);
color: var(--#{$prefix}accordion-btn-color);
text-align: left; // Reset button style
background-color: var(--#{$prefix}accordion-btn-bg);
border: 0;
@include border-radius(0);
overflow-anchor: none;
@include transition(var(--#{$prefix}accordion-transition));
&:not(.collapsed) {
color: var(--#{$prefix}accordion-active-color);
background-color: var(--#{$prefix}accordion-active-bg);
box-shadow: inset 0 calc(-1 * var(--#{$prefix}accordion-border-width)) 0 var(--#{$prefix}accordion-border-color); // stylelint-disable-line function-disallowed-list
&::after {
background-image: var(--#{$prefix}accordion-btn-active-icon);
transform: var(--#{$prefix}accordion-btn-icon-transform);
}
}
// Accordion icon
&::after {
flex-shrink: 0;
width: var(--#{$prefix}accordion-btn-icon-width);
height: var(--#{$prefix}accordion-btn-icon-width);
margin-left: auto;
content: "";
background-image: var(--#{$prefix}accordion-btn-icon);
background-repeat: no-repeat;
background-size: var(--#{$prefix}accordion-btn-icon-width);
@include transition(var(--#{$prefix}accordion-btn-icon-transition));
}
&:hover {
z-index: 2;
}
&:focus {
z-index: 3;
outline: 0;
box-shadow: var(--#{$prefix}accordion-btn-focus-box-shadow);
}
}
.accordion-header {
margin-bottom: 0;
}
.accordion-item {
color: var(--#{$prefix}accordion-color);
background-color: var(--#{$prefix}accordion-bg);
border: var(--#{$prefix}accordion-border-width) solid var(--#{$prefix}accordion-border-color);
&:first-of-type {
@include border-top-radius(var(--#{$prefix}accordion-border-radius));
> .accordion-header .accordion-button {
@include border-top-radius(var(--#{$prefix}accordion-inner-border-radius));
}
}
&:not(:first-of-type) {
border-top: 0;
}
// Only set a border-radius on the last item if the accordion is collapsed
&:last-of-type {
@include border-bottom-radius(var(--#{$prefix}accordion-border-radius));
> .accordion-header .accordion-button {
&.collapsed {
@include border-bottom-radius(var(--#{$prefix}accordion-inner-border-radius));
}
}
> .accordion-collapse {
@include border-bottom-radius(var(--#{$prefix}accordion-border-radius));
}
}
}
.accordion-body {
padding: var(--#{$prefix}accordion-body-padding-y) var(--#{$prefix}accordion-body-padding-x);
}
// Flush accordion items
//
// Remove borders and border-radius to keep accordion items edge-to-edge.
.accordion-flush {
> .accordion-item {
border-right: 0;
border-left: 0;
@include border-radius(0);
&:first-child { border-top: 0; }
&:last-child { border-bottom: 0; }
// stylelint-disable selector-max-class
> .accordion-collapse,
> .accordion-header .accordion-button,
> .accordion-header .accordion-button.collapsed {
@include border-radius(0);
}
// stylelint-enable selector-max-class
}
}
@if $enable-dark-mode {
@include color-mode(dark) {
.accordion-button::after {
--#{$prefix}accordion-btn-icon: #{escape-svg($accordion-button-icon-dark)};
--#{$prefix}accordion-btn-active-icon: #{escape-svg($accordion-button-active-icon-dark)};
}
}
}

View file

@ -0,0 +1,68 @@
//
// Base styles
//
.alert {
// scss-docs-start alert-css-vars
--#{$prefix}alert-bg: transparent;
--#{$prefix}alert-padding-x: #{$alert-padding-x};
--#{$prefix}alert-padding-y: #{$alert-padding-y};
--#{$prefix}alert-margin-bottom: #{$alert-margin-bottom};
--#{$prefix}alert-color: inherit;
--#{$prefix}alert-border-color: transparent;
--#{$prefix}alert-border: #{$alert-border-width} solid var(--#{$prefix}alert-border-color);
--#{$prefix}alert-border-radius: #{$alert-border-radius};
--#{$prefix}alert-link-color: inherit;
// scss-docs-end alert-css-vars
position: relative;
padding: var(--#{$prefix}alert-padding-y) var(--#{$prefix}alert-padding-x);
margin-bottom: var(--#{$prefix}alert-margin-bottom);
color: var(--#{$prefix}alert-color);
background-color: var(--#{$prefix}alert-bg);
border: var(--#{$prefix}alert-border);
@include border-radius(var(--#{$prefix}alert-border-radius));
}
// Headings for larger alerts
.alert-heading {
// Specified to prevent conflicts of changing $headings-color
color: inherit;
}
// Provide class for links that match alerts
.alert-link {
font-weight: $alert-link-font-weight;
color: var(--#{$prefix}alert-link-color);
}
// Dismissible alerts
//
// Expand the right padding and account for the close button's positioning.
.alert-dismissible {
padding-right: $alert-dismissible-padding-r;
// Adjust close link position
.btn-close {
position: absolute;
top: 0;
right: 0;
z-index: $stretched-link-z-index + 1;
padding: $alert-padding-y * 1.25 $alert-padding-x;
}
}
// scss-docs-start alert-modifiers
// Generate contextual modifier classes for colorizing the alert
@each $state in map-keys($theme-colors) {
.alert-#{$state} {
--#{$prefix}alert-color: var(--#{$prefix}#{$state}-text-emphasis);
--#{$prefix}alert-bg: var(--#{$prefix}#{$state}-bg-subtle);
--#{$prefix}alert-border-color: var(--#{$prefix}#{$state}-border-subtle);
--#{$prefix}alert-link-color: var(--#{$prefix}#{$state}-text-emphasis);
}
}
// scss-docs-end alert-modifiers

View file

@ -0,0 +1,38 @@
// Base class
//
// Requires one of the contextual, color modifier classes for `color` and
// `background-color`.
.badge {
// scss-docs-start badge-css-vars
--#{$prefix}badge-padding-x: #{$badge-padding-x};
--#{$prefix}badge-padding-y: #{$badge-padding-y};
@include rfs($badge-font-size, --#{$prefix}badge-font-size);
--#{$prefix}badge-font-weight: #{$badge-font-weight};
--#{$prefix}badge-color: #{$badge-color};
--#{$prefix}badge-border-radius: #{$badge-border-radius};
// scss-docs-end badge-css-vars
display: inline-block;
padding: var(--#{$prefix}badge-padding-y) var(--#{$prefix}badge-padding-x);
@include font-size(var(--#{$prefix}badge-font-size));
font-weight: var(--#{$prefix}badge-font-weight);
line-height: 1;
color: var(--#{$prefix}badge-color);
text-align: center;
white-space: nowrap;
vertical-align: baseline;
@include border-radius(var(--#{$prefix}badge-border-radius));
@include gradient-bg();
// Empty badges collapse automatically
&:empty {
display: none;
}
}
// Quick fix for badges in buttons
.btn .badge {
position: relative;
top: -1px;
}

View file

@ -0,0 +1,40 @@
.breadcrumb {
// scss-docs-start breadcrumb-css-vars
--#{$prefix}breadcrumb-padding-x: #{$breadcrumb-padding-x};
--#{$prefix}breadcrumb-padding-y: #{$breadcrumb-padding-y};
--#{$prefix}breadcrumb-margin-bottom: #{$breadcrumb-margin-bottom};
@include rfs($breadcrumb-font-size, --#{$prefix}breadcrumb-font-size);
--#{$prefix}breadcrumb-bg: #{$breadcrumb-bg};
--#{$prefix}breadcrumb-border-radius: #{$breadcrumb-border-radius};
--#{$prefix}breadcrumb-divider-color: #{$breadcrumb-divider-color};
--#{$prefix}breadcrumb-item-padding-x: #{$breadcrumb-item-padding-x};
--#{$prefix}breadcrumb-item-active-color: #{$breadcrumb-active-color};
// scss-docs-end breadcrumb-css-vars
display: flex;
flex-wrap: wrap;
padding: var(--#{$prefix}breadcrumb-padding-y) var(--#{$prefix}breadcrumb-padding-x);
margin-bottom: var(--#{$prefix}breadcrumb-margin-bottom);
@include font-size(var(--#{$prefix}breadcrumb-font-size));
list-style: none;
background-color: var(--#{$prefix}breadcrumb-bg);
@include border-radius(var(--#{$prefix}breadcrumb-border-radius));
}
.breadcrumb-item {
// The separator between breadcrumbs (by default, a forward-slash: "/")
+ .breadcrumb-item {
padding-left: var(--#{$prefix}breadcrumb-item-padding-x);
&::before {
float: left; // Suppress inline spacings and underlining of the separator
padding-right: var(--#{$prefix}breadcrumb-item-padding-x);
color: var(--#{$prefix}breadcrumb-divider-color);
content: var(--#{$prefix}breadcrumb-divider, escape-svg($breadcrumb-divider)) #{"/* rtl:"} var(--#{$prefix}breadcrumb-divider, escape-svg($breadcrumb-divider-flipped)) #{"*/"};
}
}
&.active {
color: var(--#{$prefix}breadcrumb-item-active-color);
}
}

View file

@ -0,0 +1,147 @@
// Make the div behave like a button
.btn-group,
.btn-group-vertical {
position: relative;
display: inline-flex;
vertical-align: middle; // match .btn alignment given font-size hack above
> .btn {
position: relative;
flex: 1 1 auto;
}
// Bring the hover, focused, and "active" buttons to the front to overlay
// the borders properly
> .btn-check:checked + .btn,
> .btn-check:focus + .btn,
> .btn:hover,
> .btn:focus,
> .btn:active,
> .btn.active {
z-index: 1;
}
}
// Optional: Group multiple button groups together for a toolbar
.btn-toolbar {
display: flex;
flex-wrap: wrap;
justify-content: flex-start;
.input-group {
width: auto;
}
}
.btn-group {
@include border-radius($btn-border-radius);
// Prevent double borders when buttons are next to each other
> :not(.btn-check:first-child) + .btn,
> .btn-group:not(:first-child) {
margin-left: calc(-1 * #{$btn-border-width}); // stylelint-disable-line function-disallowed-list
}
// Reset rounded corners
> .btn:not(:last-child):not(.dropdown-toggle),
> .btn.dropdown-toggle-split:first-child,
> .btn-group:not(:last-child) > .btn {
@include border-end-radius(0);
}
// The left radius should be 0 if the button is:
// - the "third or more" child
// - the second child and the previous element isn't `.btn-check` (making it the first child visually)
// - part of a btn-group which isn't the first child
> .btn:nth-child(n + 3),
> :not(.btn-check) + .btn,
> .btn-group:not(:first-child) > .btn {
@include border-start-radius(0);
}
}
// Sizing
//
// Remix the default button sizing classes into new ones for easier manipulation.
.btn-group-sm > .btn { @extend .btn-sm; }
.btn-group-lg > .btn { @extend .btn-lg; }
//
// Split button dropdowns
//
.dropdown-toggle-split {
padding-right: $btn-padding-x * .75;
padding-left: $btn-padding-x * .75;
&::after,
.dropup &::after,
.dropend &::after {
margin-left: 0;
}
.dropstart &::before {
margin-right: 0;
}
}
.btn-sm + .dropdown-toggle-split {
padding-right: $btn-padding-x-sm * .75;
padding-left: $btn-padding-x-sm * .75;
}
.btn-lg + .dropdown-toggle-split {
padding-right: $btn-padding-x-lg * .75;
padding-left: $btn-padding-x-lg * .75;
}
// The clickable button for toggling the menu
// Set the same inset shadow as the :active state
.btn-group.show .dropdown-toggle {
@include box-shadow($btn-active-box-shadow);
// Show no shadow for `.btn-link` since it has no other button styles.
&.btn-link {
@include box-shadow(none);
}
}
//
// Vertical button groups
//
.btn-group-vertical {
flex-direction: column;
align-items: flex-start;
justify-content: center;
> .btn,
> .btn-group {
width: 100%;
}
> .btn:not(:first-child),
> .btn-group:not(:first-child) {
margin-top: calc(-1 * #{$btn-border-width}); // stylelint-disable-line function-disallowed-list
}
// Reset rounded corners
> .btn:not(:last-child):not(.dropdown-toggle),
> .btn-group:not(:last-child) > .btn {
@include border-bottom-radius(0);
}
// The top radius should be 0 if the button is:
// - the "third or more" child
// - the second child and the previous element isn't `.btn-check` (making it the first child visually)
// - part of a btn-group which isn't the first child
> .btn:nth-child(n + 3),
> :not(.btn-check) + .btn,
> .btn-group:not(:first-child) > .btn {
@include border-top-radius(0);
}
}

View file

@ -0,0 +1,216 @@
//
// Base styles
//
.btn {
// scss-docs-start btn-css-vars
--#{$prefix}btn-padding-x: #{$btn-padding-x};
--#{$prefix}btn-padding-y: #{$btn-padding-y};
--#{$prefix}btn-font-family: #{$btn-font-family};
@include rfs($btn-font-size, --#{$prefix}btn-font-size);
--#{$prefix}btn-font-weight: #{$btn-font-weight};
--#{$prefix}btn-line-height: #{$btn-line-height};
--#{$prefix}btn-color: #{$btn-color};
--#{$prefix}btn-bg: transparent;
--#{$prefix}btn-border-width: #{$btn-border-width};
--#{$prefix}btn-border-color: transparent;
--#{$prefix}btn-border-radius: #{$btn-border-radius};
--#{$prefix}btn-hover-border-color: transparent;
--#{$prefix}btn-box-shadow: #{$btn-box-shadow};
--#{$prefix}btn-disabled-opacity: #{$btn-disabled-opacity};
--#{$prefix}btn-focus-box-shadow: 0 0 0 #{$btn-focus-width} rgba(var(--#{$prefix}btn-focus-shadow-rgb), .5);
// scss-docs-end btn-css-vars
display: inline-block;
padding: var(--#{$prefix}btn-padding-y) var(--#{$prefix}btn-padding-x);
font-family: var(--#{$prefix}btn-font-family);
@include font-size(var(--#{$prefix}btn-font-size));
font-weight: var(--#{$prefix}btn-font-weight);
line-height: var(--#{$prefix}btn-line-height);
color: var(--#{$prefix}btn-color);
text-align: center;
text-decoration: if($link-decoration == none, null, none);
white-space: $btn-white-space;
vertical-align: middle;
cursor: if($enable-button-pointers, pointer, null);
user-select: none;
border: var(--#{$prefix}btn-border-width) solid var(--#{$prefix}btn-border-color);
@include border-radius(var(--#{$prefix}btn-border-radius));
@include gradient-bg(var(--#{$prefix}btn-bg));
@include box-shadow(var(--#{$prefix}btn-box-shadow));
@include transition($btn-transition);
&:hover {
color: var(--#{$prefix}btn-hover-color);
text-decoration: if($link-hover-decoration == underline, none, null);
background-color: var(--#{$prefix}btn-hover-bg);
border-color: var(--#{$prefix}btn-hover-border-color);
}
.btn-check + &:hover {
// override for the checkbox/radio buttons
color: var(--#{$prefix}btn-color);
background-color: var(--#{$prefix}btn-bg);
border-color: var(--#{$prefix}btn-border-color);
}
&:focus-visible {
color: var(--#{$prefix}btn-hover-color);
@include gradient-bg(var(--#{$prefix}btn-hover-bg));
border-color: var(--#{$prefix}btn-hover-border-color);
outline: 0;
// Avoid using mixin so we can pass custom focus shadow properly
@if $enable-shadows {
box-shadow: var(--#{$prefix}btn-box-shadow), var(--#{$prefix}btn-focus-box-shadow);
} @else {
box-shadow: var(--#{$prefix}btn-focus-box-shadow);
}
}
.btn-check:focus-visible + & {
border-color: var(--#{$prefix}btn-hover-border-color);
outline: 0;
// Avoid using mixin so we can pass custom focus shadow properly
@if $enable-shadows {
box-shadow: var(--#{$prefix}btn-box-shadow), var(--#{$prefix}btn-focus-box-shadow);
} @else {
box-shadow: var(--#{$prefix}btn-focus-box-shadow);
}
}
.btn-check:checked + &,
:not(.btn-check) + &:active,
&:first-child:active,
&.active,
&.show {
color: var(--#{$prefix}btn-active-color);
background-color: var(--#{$prefix}btn-active-bg);
// Remove CSS gradients if they're enabled
background-image: if($enable-gradients, none, null);
border-color: var(--#{$prefix}btn-active-border-color);
@include box-shadow(var(--#{$prefix}btn-active-shadow));
&:focus-visible {
// Avoid using mixin so we can pass custom focus shadow properly
@if $enable-shadows {
box-shadow: var(--#{$prefix}btn-active-shadow), var(--#{$prefix}btn-focus-box-shadow);
} @else {
box-shadow: var(--#{$prefix}btn-focus-box-shadow);
}
}
}
.btn-check:checked:focus-visible + & {
// Avoid using mixin so we can pass custom focus shadow properly
@if $enable-shadows {
box-shadow: var(--#{$prefix}btn-active-shadow), var(--#{$prefix}btn-focus-box-shadow);
} @else {
box-shadow: var(--#{$prefix}btn-focus-box-shadow);
}
}
&:disabled,
&.disabled,
fieldset:disabled & {
color: var(--#{$prefix}btn-disabled-color);
pointer-events: none;
background-color: var(--#{$prefix}btn-disabled-bg);
background-image: if($enable-gradients, none, null);
border-color: var(--#{$prefix}btn-disabled-border-color);
opacity: var(--#{$prefix}btn-disabled-opacity);
@include box-shadow(none);
}
}
//
// Alternate buttons
//
// scss-docs-start btn-variant-loops
@each $color, $value in $theme-colors {
.btn-#{$color} {
@if $color == "light" {
@include button-variant(
$value,
$value,
$hover-background: shade-color($value, $btn-hover-bg-shade-amount),
$hover-border: shade-color($value, $btn-hover-border-shade-amount),
$active-background: shade-color($value, $btn-active-bg-shade-amount),
$active-border: shade-color($value, $btn-active-border-shade-amount)
);
} @else if $color == "dark" {
@include button-variant(
$value,
$value,
$hover-background: tint-color($value, $btn-hover-bg-tint-amount),
$hover-border: tint-color($value, $btn-hover-border-tint-amount),
$active-background: tint-color($value, $btn-active-bg-tint-amount),
$active-border: tint-color($value, $btn-active-border-tint-amount)
);
} @else {
@include button-variant($value, $value);
}
}
}
@each $color, $value in $theme-colors {
.btn-outline-#{$color} {
@include button-outline-variant($value);
}
}
// scss-docs-end btn-variant-loops
//
// Link buttons
//
// Make a button look and behave like a link
.btn-link {
--#{$prefix}btn-font-weight: #{$font-weight-normal};
--#{$prefix}btn-color: #{$btn-link-color};
--#{$prefix}btn-bg: transparent;
--#{$prefix}btn-border-color: transparent;
--#{$prefix}btn-hover-color: #{$btn-link-hover-color};
--#{$prefix}btn-hover-border-color: transparent;
--#{$prefix}btn-active-color: #{$btn-link-hover-color};
--#{$prefix}btn-active-border-color: transparent;
--#{$prefix}btn-disabled-color: #{$btn-link-disabled-color};
--#{$prefix}btn-disabled-border-color: transparent;
--#{$prefix}btn-box-shadow: 0 0 0 #000; // Can't use `none` as keyword negates all values when used with multiple shadows
--#{$prefix}btn-focus-shadow-rgb: #{$btn-link-focus-shadow-rgb};
text-decoration: $link-decoration;
@if $enable-gradients {
background-image: none;
}
&:hover,
&:focus-visible {
text-decoration: $link-hover-decoration;
}
&:focus-visible {
color: var(--#{$prefix}btn-color);
}
&:hover {
color: var(--#{$prefix}btn-hover-color);
}
// No need for an active state here
}
//
// Button Sizes
//
.btn-lg {
@include button-size($btn-padding-y-lg, $btn-padding-x-lg, $btn-font-size-lg, $btn-border-radius-lg);
}
.btn-sm {
@include button-size($btn-padding-y-sm, $btn-padding-x-sm, $btn-font-size-sm, $btn-border-radius-sm);
}

View file

@ -0,0 +1,238 @@
//
// Base styles
//
.card {
// scss-docs-start card-css-vars
--#{$prefix}card-spacer-y: #{$card-spacer-y};
--#{$prefix}card-spacer-x: #{$card-spacer-x};
--#{$prefix}card-title-spacer-y: #{$card-title-spacer-y};
--#{$prefix}card-title-color: #{$card-title-color};
--#{$prefix}card-subtitle-color: #{$card-subtitle-color};
--#{$prefix}card-border-width: #{$card-border-width};
--#{$prefix}card-border-color: #{$card-border-color};
--#{$prefix}card-border-radius: #{$card-border-radius};
--#{$prefix}card-box-shadow: #{$card-box-shadow};
--#{$prefix}card-inner-border-radius: #{$card-inner-border-radius};
--#{$prefix}card-cap-padding-y: #{$card-cap-padding-y};
--#{$prefix}card-cap-padding-x: #{$card-cap-padding-x};
--#{$prefix}card-cap-bg: #{$card-cap-bg};
--#{$prefix}card-cap-color: #{$card-cap-color};
--#{$prefix}card-height: #{$card-height};
--#{$prefix}card-color: #{$card-color};
--#{$prefix}card-bg: #{$card-bg};
--#{$prefix}card-img-overlay-padding: #{$card-img-overlay-padding};
--#{$prefix}card-group-margin: #{$card-group-margin};
// scss-docs-end card-css-vars
position: relative;
display: flex;
flex-direction: column;
min-width: 0; // See https://github.com/twbs/bootstrap/pull/22740#issuecomment-305868106
height: var(--#{$prefix}card-height);
color: var(--#{$prefix}body-color);
word-wrap: break-word;
background-color: var(--#{$prefix}card-bg);
background-clip: border-box;
border: var(--#{$prefix}card-border-width) solid var(--#{$prefix}card-border-color);
@include border-radius(var(--#{$prefix}card-border-radius));
@include box-shadow(var(--#{$prefix}card-box-shadow));
> hr {
margin-right: 0;
margin-left: 0;
}
> .list-group {
border-top: inherit;
border-bottom: inherit;
&:first-child {
border-top-width: 0;
@include border-top-radius(var(--#{$prefix}card-inner-border-radius));
}
&:last-child {
border-bottom-width: 0;
@include border-bottom-radius(var(--#{$prefix}card-inner-border-radius));
}
}
// Due to specificity of the above selector (`.card > .list-group`), we must
// use a child selector here to prevent double borders.
> .card-header + .list-group,
> .list-group + .card-footer {
border-top: 0;
}
}
.card-body {
// Enable `flex-grow: 1` for decks and groups so that card blocks take up
// as much space as possible, ensuring footers are aligned to the bottom.
flex: 1 1 auto;
padding: var(--#{$prefix}card-spacer-y) var(--#{$prefix}card-spacer-x);
color: var(--#{$prefix}card-color);
}
.card-title {
margin-bottom: var(--#{$prefix}card-title-spacer-y);
color: var(--#{$prefix}card-title-color);
}
.card-subtitle {
margin-top: calc(-.5 * var(--#{$prefix}card-title-spacer-y)); // stylelint-disable-line function-disallowed-list
margin-bottom: 0;
color: var(--#{$prefix}card-subtitle-color);
}
.card-text:last-child {
margin-bottom: 0;
}
.card-link {
&:hover {
text-decoration: if($link-hover-decoration == underline, none, null);
}
+ .card-link {
margin-left: var(--#{$prefix}card-spacer-x);
}
}
//
// Optional textual caps
//
.card-header {
padding: var(--#{$prefix}card-cap-padding-y) var(--#{$prefix}card-cap-padding-x);
margin-bottom: 0; // Removes the default margin-bottom of <hN>
color: var(--#{$prefix}card-cap-color);
background-color: var(--#{$prefix}card-cap-bg);
border-bottom: var(--#{$prefix}card-border-width) solid var(--#{$prefix}card-border-color);
&:first-child {
@include border-radius(var(--#{$prefix}card-inner-border-radius) var(--#{$prefix}card-inner-border-radius) 0 0);
}
}
.card-footer {
padding: var(--#{$prefix}card-cap-padding-y) var(--#{$prefix}card-cap-padding-x);
color: var(--#{$prefix}card-cap-color);
background-color: var(--#{$prefix}card-cap-bg);
border-top: var(--#{$prefix}card-border-width) solid var(--#{$prefix}card-border-color);
&:last-child {
@include border-radius(0 0 var(--#{$prefix}card-inner-border-radius) var(--#{$prefix}card-inner-border-radius));
}
}
//
// Header navs
//
.card-header-tabs {
margin-right: calc(-.5 * var(--#{$prefix}card-cap-padding-x)); // stylelint-disable-line function-disallowed-list
margin-bottom: calc(-1 * var(--#{$prefix}card-cap-padding-y)); // stylelint-disable-line function-disallowed-list
margin-left: calc(-.5 * var(--#{$prefix}card-cap-padding-x)); // stylelint-disable-line function-disallowed-list
border-bottom: 0;
.nav-link.active {
background-color: var(--#{$prefix}card-bg);
border-bottom-color: var(--#{$prefix}card-bg);
}
}
.card-header-pills {
margin-right: calc(-.5 * var(--#{$prefix}card-cap-padding-x)); // stylelint-disable-line function-disallowed-list
margin-left: calc(-.5 * var(--#{$prefix}card-cap-padding-x)); // stylelint-disable-line function-disallowed-list
}
// Card image
.card-img-overlay {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
padding: var(--#{$prefix}card-img-overlay-padding);
@include border-radius(var(--#{$prefix}card-inner-border-radius));
}
.card-img,
.card-img-top,
.card-img-bottom {
width: 100%; // Required because we use flexbox and this inherently applies align-self: stretch
}
.card-img,
.card-img-top {
@include border-top-radius(var(--#{$prefix}card-inner-border-radius));
}
.card-img,
.card-img-bottom {
@include border-bottom-radius(var(--#{$prefix}card-inner-border-radius));
}
//
// Card groups
//
.card-group {
// The child selector allows nested `.card` within `.card-group`
// to display properly.
> .card {
margin-bottom: var(--#{$prefix}card-group-margin);
}
@include media-breakpoint-up(sm) {
display: flex;
flex-flow: row wrap;
// The child selector allows nested `.card` within `.card-group`
// to display properly.
> .card {
flex: 1 0 0;
margin-bottom: 0;
+ .card {
margin-left: 0;
border-left: 0;
}
// Handle rounded corners
@if $enable-rounded {
&:not(:last-child) {
@include border-end-radius(0);
> .card-img-top,
> .card-header {
// stylelint-disable-next-line property-disallowed-list
border-top-right-radius: 0;
}
> .card-img-bottom,
> .card-footer {
// stylelint-disable-next-line property-disallowed-list
border-bottom-right-radius: 0;
}
}
&:not(:first-child) {
@include border-start-radius(0);
> .card-img-top,
> .card-header {
// stylelint-disable-next-line property-disallowed-list
border-top-left-radius: 0;
}
> .card-img-bottom,
> .card-footer {
// stylelint-disable-next-line property-disallowed-list
border-bottom-left-radius: 0;
}
}
}
}
}
}

View file

@ -0,0 +1,226 @@
// Notes on the classes:
//
// 1. .carousel.pointer-event should ideally be pan-y (to allow for users to scroll vertically)
// even when their scroll action started on a carousel, but for compatibility (with Firefox)
// we're preventing all actions instead
// 2. The .carousel-item-start and .carousel-item-end is used to indicate where
// the active slide is heading.
// 3. .active.carousel-item is the current slide.
// 4. .active.carousel-item-start and .active.carousel-item-end is the current
// slide in its in-transition state. Only one of these occurs at a time.
// 5. .carousel-item-next.carousel-item-start and .carousel-item-prev.carousel-item-end
// is the upcoming slide in transition.
.carousel {
position: relative;
}
.carousel.pointer-event {
touch-action: pan-y;
}
.carousel-inner {
position: relative;
width: 100%;
overflow: hidden;
@include clearfix();
}
.carousel-item {
position: relative;
display: none;
float: left;
width: 100%;
margin-right: -100%;
backface-visibility: hidden;
@include transition($carousel-transition);
}
.carousel-item.active,
.carousel-item-next,
.carousel-item-prev {
display: block;
}
.carousel-item-next:not(.carousel-item-start),
.active.carousel-item-end {
transform: translateX(100%);
}
.carousel-item-prev:not(.carousel-item-end),
.active.carousel-item-start {
transform: translateX(-100%);
}
//
// Alternate transitions
//
.carousel-fade {
.carousel-item {
opacity: 0;
transition-property: opacity;
transform: none;
}
.carousel-item.active,
.carousel-item-next.carousel-item-start,
.carousel-item-prev.carousel-item-end {
z-index: 1;
opacity: 1;
}
.active.carousel-item-start,
.active.carousel-item-end {
z-index: 0;
opacity: 0;
@include transition(opacity 0s $carousel-transition-duration);
}
}
//
// Left/right controls for nav
//
.carousel-control-prev,
.carousel-control-next {
position: absolute;
top: 0;
bottom: 0;
z-index: 1;
// Use flex for alignment (1-3)
display: flex; // 1. allow flex styles
align-items: center; // 2. vertically center contents
justify-content: center; // 3. horizontally center contents
width: $carousel-control-width;
padding: 0;
color: $carousel-control-color;
text-align: center;
background: none;
filter: var(--#{$prefix}carousel-control-icon-filter);
border: 0;
opacity: $carousel-control-opacity;
@include transition($carousel-control-transition);
// Hover/focus state
&:hover,
&:focus {
color: $carousel-control-color;
text-decoration: none;
outline: 0;
opacity: $carousel-control-hover-opacity;
}
}
.carousel-control-prev {
left: 0;
background-image: if($enable-gradients, linear-gradient(90deg, rgba($black, .25), rgba($black, .001)), null);
}
.carousel-control-next {
right: 0;
background-image: if($enable-gradients, linear-gradient(270deg, rgba($black, .25), rgba($black, .001)), null);
}
// Icons for within
.carousel-control-prev-icon,
.carousel-control-next-icon {
display: inline-block;
width: $carousel-control-icon-width;
height: $carousel-control-icon-width;
background-repeat: no-repeat;
background-position: 50%;
background-size: 100% 100%;
}
.carousel-control-prev-icon {
background-image: escape-svg($carousel-control-prev-icon-bg) #{"/*rtl:" + escape-svg($carousel-control-next-icon-bg) + "*/"};
}
.carousel-control-next-icon {
background-image: escape-svg($carousel-control-next-icon-bg) #{"/*rtl:" + escape-svg($carousel-control-prev-icon-bg) + "*/"};
}
// Optional indicator pips/controls
//
// Add a container (such as a list) with the following class and add an item (ideally a focusable control,
// like a button) with data-bs-target for each slide your carousel holds.
.carousel-indicators {
position: absolute;
right: 0;
bottom: 0;
left: 0;
z-index: 2;
display: flex;
justify-content: center;
padding: 0;
// Use the .carousel-control's width as margin so we don't overlay those
margin-right: $carousel-control-width;
margin-bottom: 1rem;
margin-left: $carousel-control-width;
[data-bs-target] {
box-sizing: content-box;
flex: 0 1 auto;
width: $carousel-indicator-width;
height: $carousel-indicator-height;
padding: 0;
margin-right: $carousel-indicator-spacer;
margin-left: $carousel-indicator-spacer;
text-indent: -999px;
cursor: pointer;
background-color: var(--#{$prefix}carousel-indicator-active-bg);
background-clip: padding-box;
border: 0;
// Use transparent borders to increase the hit area by 10px on top and bottom.
border-top: $carousel-indicator-hit-area-height solid transparent;
border-bottom: $carousel-indicator-hit-area-height solid transparent;
opacity: $carousel-indicator-opacity;
@include transition($carousel-indicator-transition);
}
.active {
opacity: $carousel-indicator-active-opacity;
}
}
// Optional captions
//
//
.carousel-caption {
position: absolute;
right: (100% - $carousel-caption-width) * .5;
bottom: $carousel-caption-spacer;
left: (100% - $carousel-caption-width) * .5;
padding-top: $carousel-caption-padding-y;
padding-bottom: $carousel-caption-padding-y;
color: var(--#{$prefix}carousel-caption-color);
text-align: center;
}
// Dark mode carousel
@mixin carousel-dark() {
--#{$prefix}carousel-indicator-active-bg: #{$carousel-indicator-active-bg-dark};
--#{$prefix}carousel-caption-color: #{$carousel-caption-color-dark};
--#{$prefix}carousel-control-icon-filter: #{$carousel-control-icon-filter-dark};
}
.carousel-dark {
@include carousel-dark();
}
:root,
[data-bs-theme="light"] {
--#{$prefix}carousel-indicator-active-bg: #{$carousel-indicator-active-bg};
--#{$prefix}carousel-caption-color: #{$carousel-caption-color};
--#{$prefix}carousel-control-icon-filter: #{$carousel-control-icon-filter};
}
@if $enable-dark-mode {
@include color-mode(dark, true) {
@include carousel-dark();
}
}

View file

@ -0,0 +1,66 @@
// Transparent background and border properties included for button version.
// iOS requires the button element instead of an anchor tag.
// If you want the anchor version, it requires `href="#"`.
// See https://developer.mozilla.org/en-US/docs/Web/Events/click#Safari_Mobile
.btn-close {
// scss-docs-start close-css-vars
--#{$prefix}btn-close-color: #{$btn-close-color};
--#{$prefix}btn-close-bg: #{ escape-svg($btn-close-bg) };
--#{$prefix}btn-close-opacity: #{$btn-close-opacity};
--#{$prefix}btn-close-hover-opacity: #{$btn-close-hover-opacity};
--#{$prefix}btn-close-focus-shadow: #{$btn-close-focus-shadow};
--#{$prefix}btn-close-focus-opacity: #{$btn-close-focus-opacity};
--#{$prefix}btn-close-disabled-opacity: #{$btn-close-disabled-opacity};
// scss-docs-end close-css-vars
box-sizing: content-box;
width: $btn-close-width;
height: $btn-close-height;
padding: $btn-close-padding-y $btn-close-padding-x;
color: var(--#{$prefix}btn-close-color);
background: transparent var(--#{$prefix}btn-close-bg) center / $btn-close-width auto no-repeat; // include transparent for button elements
filter: var(--#{$prefix}btn-close-filter);
border: 0; // for button elements
@include border-radius();
opacity: var(--#{$prefix}btn-close-opacity);
// Override <a>'s hover style
&:hover {
color: var(--#{$prefix}btn-close-color);
text-decoration: none;
opacity: var(--#{$prefix}btn-close-hover-opacity);
}
&:focus {
outline: 0;
box-shadow: var(--#{$prefix}btn-close-focus-shadow);
opacity: var(--#{$prefix}btn-close-focus-opacity);
}
&:disabled,
&.disabled {
pointer-events: none;
user-select: none;
opacity: var(--#{$prefix}btn-close-disabled-opacity);
}
}
@mixin btn-close-white() {
--#{$prefix}btn-close-filter: #{$btn-close-filter-dark};
}
.btn-close-white {
@include btn-close-white();
}
:root,
[data-bs-theme="light"] {
--#{$prefix}btn-close-filter: #{$btn-close-filter};
}
@if $enable-dark-mode {
@include color-mode(dark, true) {
@include btn-close-white();
}
}

View file

@ -0,0 +1,41 @@
// Container widths
//
// Set the container width, and override it for fixed navbars in media queries.
@if $enable-container-classes {
// Single container class with breakpoint max-widths
.container,
// 100% wide container at all breakpoints
.container-fluid {
@include make-container();
}
// Responsive containers that are 100% wide until a breakpoint
@each $breakpoint, $container-max-width in $container-max-widths {
.container-#{$breakpoint} {
@extend .container-fluid;
}
@include media-breakpoint-up($breakpoint, $grid-breakpoints) {
%responsive-container-#{$breakpoint} {
max-width: $container-max-width;
}
// Extend each breakpoint which is smaller or equal to the current breakpoint
$extend-breakpoint: true;
@each $name, $width in $grid-breakpoints {
@if ($extend-breakpoint) {
.container#{breakpoint-infix($name, $grid-breakpoints)} {
@extend %responsive-container-#{$breakpoint};
}
// Once the current breakpoint is reached, stop extending
@if ($breakpoint == $name) {
$extend-breakpoint: false;
}
}
}
}
}
}

View file

@ -0,0 +1,250 @@
// The dropdown wrapper (`<div>`)
.dropup,
.dropend,
.dropdown,
.dropstart,
.dropup-center,
.dropdown-center {
position: relative;
}
.dropdown-toggle {
white-space: nowrap;
// Generate the caret automatically
@include caret();
}
// The dropdown menu
.dropdown-menu {
// scss-docs-start dropdown-css-vars
--#{$prefix}dropdown-zindex: #{$zindex-dropdown};
--#{$prefix}dropdown-min-width: #{$dropdown-min-width};
--#{$prefix}dropdown-padding-x: #{$dropdown-padding-x};
--#{$prefix}dropdown-padding-y: #{$dropdown-padding-y};
--#{$prefix}dropdown-spacer: #{$dropdown-spacer};
@include rfs($dropdown-font-size, --#{$prefix}dropdown-font-size);
--#{$prefix}dropdown-color: #{$dropdown-color};
--#{$prefix}dropdown-bg: #{$dropdown-bg};
--#{$prefix}dropdown-border-color: #{$dropdown-border-color};
--#{$prefix}dropdown-border-radius: #{$dropdown-border-radius};
--#{$prefix}dropdown-border-width: #{$dropdown-border-width};
--#{$prefix}dropdown-inner-border-radius: #{$dropdown-inner-border-radius};
--#{$prefix}dropdown-divider-bg: #{$dropdown-divider-bg};
--#{$prefix}dropdown-divider-margin-y: #{$dropdown-divider-margin-y};
--#{$prefix}dropdown-box-shadow: #{$dropdown-box-shadow};
--#{$prefix}dropdown-link-color: #{$dropdown-link-color};
--#{$prefix}dropdown-link-hover-color: #{$dropdown-link-hover-color};
--#{$prefix}dropdown-link-hover-bg: #{$dropdown-link-hover-bg};
--#{$prefix}dropdown-link-active-color: #{$dropdown-link-active-color};
--#{$prefix}dropdown-link-active-bg: #{$dropdown-link-active-bg};
--#{$prefix}dropdown-link-disabled-color: #{$dropdown-link-disabled-color};
--#{$prefix}dropdown-item-padding-x: #{$dropdown-item-padding-x};
--#{$prefix}dropdown-item-padding-y: #{$dropdown-item-padding-y};
--#{$prefix}dropdown-header-color: #{$dropdown-header-color};
--#{$prefix}dropdown-header-padding-x: #{$dropdown-header-padding-x};
--#{$prefix}dropdown-header-padding-y: #{$dropdown-header-padding-y};
// scss-docs-end dropdown-css-vars
position: absolute;
z-index: var(--#{$prefix}dropdown-zindex);
display: none; // none by default, but block on "open" of the menu
min-width: var(--#{$prefix}dropdown-min-width);
padding: var(--#{$prefix}dropdown-padding-y) var(--#{$prefix}dropdown-padding-x);
margin: 0; // Override default margin of ul
@include font-size(var(--#{$prefix}dropdown-font-size));
color: var(--#{$prefix}dropdown-color);
text-align: left; // Ensures proper alignment if parent has it changed (e.g., modal footer)
list-style: none;
background-color: var(--#{$prefix}dropdown-bg);
background-clip: padding-box;
border: var(--#{$prefix}dropdown-border-width) solid var(--#{$prefix}dropdown-border-color);
@include border-radius(var(--#{$prefix}dropdown-border-radius));
@include box-shadow(var(--#{$prefix}dropdown-box-shadow));
&[data-bs-popper] {
top: 100%;
left: 0;
margin-top: var(--#{$prefix}dropdown-spacer);
}
@if $dropdown-padding-y == 0 {
> .dropdown-item:first-child,
> li:first-child .dropdown-item {
@include border-top-radius(var(--#{$prefix}dropdown-inner-border-radius));
}
> .dropdown-item:last-child,
> li:last-child .dropdown-item {
@include border-bottom-radius(var(--#{$prefix}dropdown-inner-border-radius));
}
}
}
// scss-docs-start responsive-breakpoints
// We deliberately hardcode the `bs-` prefix because we check
// this custom property in JS to determine Popper's positioning
@each $breakpoint in map-keys($grid-breakpoints) {
@include media-breakpoint-up($breakpoint) {
$infix: breakpoint-infix($breakpoint, $grid-breakpoints);
.dropdown-menu#{$infix}-start {
--bs-position: start;
&[data-bs-popper] {
right: auto;
left: 0;
}
}
.dropdown-menu#{$infix}-end {
--bs-position: end;
&[data-bs-popper] {
right: 0;
left: auto;
}
}
}
}
// scss-docs-end responsive-breakpoints
// Allow for dropdowns to go bottom up (aka, dropup-menu)
// Just add .dropup after the standard .dropdown class and you're set.
.dropup {
.dropdown-menu[data-bs-popper] {
top: auto;
bottom: 100%;
margin-top: 0;
margin-bottom: var(--#{$prefix}dropdown-spacer);
}
.dropdown-toggle {
@include caret(up);
}
}
.dropend {
.dropdown-menu[data-bs-popper] {
top: 0;
right: auto;
left: 100%;
margin-top: 0;
margin-left: var(--#{$prefix}dropdown-spacer);
}
.dropdown-toggle {
@include caret(end);
&::after {
vertical-align: 0;
}
}
}
.dropstart {
.dropdown-menu[data-bs-popper] {
top: 0;
right: 100%;
left: auto;
margin-top: 0;
margin-right: var(--#{$prefix}dropdown-spacer);
}
.dropdown-toggle {
@include caret(start);
&::before {
vertical-align: 0;
}
}
}
// Dividers (basically an `<hr>`) within the dropdown
.dropdown-divider {
height: 0;
margin: var(--#{$prefix}dropdown-divider-margin-y) 0;
overflow: hidden;
border-top: 1px solid var(--#{$prefix}dropdown-divider-bg);
opacity: 1; // Revisit in v6 to de-dupe styles that conflict with <hr> element
}
// Links, buttons, and more within the dropdown menu
//
// `<button>`-specific styles are denoted with `// For <button>s`
.dropdown-item {
display: block;
width: 100%; // For `<button>`s
padding: var(--#{$prefix}dropdown-item-padding-y) var(--#{$prefix}dropdown-item-padding-x);
clear: both;
font-weight: $font-weight-normal;
color: var(--#{$prefix}dropdown-link-color);
text-align: inherit; // For `<button>`s
text-decoration: if($link-decoration == none, null, none);
white-space: nowrap; // prevent links from randomly breaking onto new lines
background-color: transparent; // For `<button>`s
border: 0; // For `<button>`s
@include border-radius(var(--#{$prefix}dropdown-item-border-radius, 0));
&:hover,
&:focus {
color: var(--#{$prefix}dropdown-link-hover-color);
text-decoration: if($link-hover-decoration == underline, none, null);
@include gradient-bg(var(--#{$prefix}dropdown-link-hover-bg));
}
&.active,
&:active {
color: var(--#{$prefix}dropdown-link-active-color);
text-decoration: none;
@include gradient-bg(var(--#{$prefix}dropdown-link-active-bg));
}
&.disabled,
&:disabled {
color: var(--#{$prefix}dropdown-link-disabled-color);
pointer-events: none;
background-color: transparent;
// Remove CSS gradients if they're enabled
background-image: if($enable-gradients, none, null);
}
}
.dropdown-menu.show {
display: block;
}
// Dropdown section headers
.dropdown-header {
display: block;
padding: var(--#{$prefix}dropdown-header-padding-y) var(--#{$prefix}dropdown-header-padding-x);
margin-bottom: 0; // for use with heading elements
@include font-size($font-size-sm);
color: var(--#{$prefix}dropdown-header-color);
white-space: nowrap; // as with > li > a
}
// Dropdown text
.dropdown-item-text {
display: block;
padding: var(--#{$prefix}dropdown-item-padding-y) var(--#{$prefix}dropdown-item-padding-x);
color: var(--#{$prefix}dropdown-link-color);
}
// Dark dropdowns
.dropdown-menu-dark {
// scss-docs-start dropdown-dark-css-vars
--#{$prefix}dropdown-color: #{$dropdown-dark-color};
--#{$prefix}dropdown-bg: #{$dropdown-dark-bg};
--#{$prefix}dropdown-border-color: #{$dropdown-dark-border-color};
--#{$prefix}dropdown-box-shadow: #{$dropdown-dark-box-shadow};
--#{$prefix}dropdown-link-color: #{$dropdown-dark-link-color};
--#{$prefix}dropdown-link-hover-color: #{$dropdown-dark-link-hover-color};
--#{$prefix}dropdown-divider-bg: #{$dropdown-dark-divider-bg};
--#{$prefix}dropdown-link-hover-bg: #{$dropdown-dark-link-hover-bg};
--#{$prefix}dropdown-link-active-color: #{$dropdown-dark-link-active-color};
--#{$prefix}dropdown-link-active-bg: #{$dropdown-dark-link-active-bg};
--#{$prefix}dropdown-link-disabled-color: #{$dropdown-dark-link-disabled-color};
--#{$prefix}dropdown-header-color: #{$dropdown-dark-header-color};
// scss-docs-end dropdown-dark-css-vars
}

View file

@ -0,0 +1,9 @@
@import "forms/labels";
@import "forms/form-text";
@import "forms/form-control";
@import "forms/form-select";
@import "forms/form-check";
@import "forms/form-range";
@import "forms/floating-labels";
@import "forms/input-group";
@import "forms/validation";

View file

@ -0,0 +1,302 @@
// Bootstrap functions
//
// Utility mixins and functions for evaluating source code across our variables, maps, and mixins.
// Ascending
// Used to evaluate Sass maps like our grid breakpoints.
@mixin _assert-ascending($map, $map-name) {
$prev-key: null;
$prev-num: null;
@each $key, $num in $map {
@if $prev-num == null or unit($num) == "%" or unit($prev-num) == "%" {
// Do nothing
} @else if not comparable($prev-num, $num) {
@warn "Potentially invalid value for #{$map-name}: This map must be in ascending order, but key '#{$key}' has value #{$num} whose unit makes it incomparable to #{$prev-num}, the value of the previous key '#{$prev-key}' !";
} @else if $prev-num >= $num {
@warn "Invalid value for #{$map-name}: This map must be in ascending order, but key '#{$key}' has value #{$num} which isn't greater than #{$prev-num}, the value of the previous key '#{$prev-key}' !";
}
$prev-key: $key;
$prev-num: $num;
}
}
// Starts at zero
// Used to ensure the min-width of the lowest breakpoint starts at 0.
@mixin _assert-starts-at-zero($map, $map-name: "$grid-breakpoints") {
@if length($map) > 0 {
$values: map-values($map);
$first-value: nth($values, 1);
@if $first-value != 0 {
@warn "First breakpoint in #{$map-name} must start at 0, but starts at #{$first-value}.";
}
}
}
// Colors
@function to-rgb($value) {
@return red($value), green($value), blue($value);
}
// stylelint-disable scss/dollar-variable-pattern
@function rgba-css-var($identifier, $target) {
@if $identifier == "body" and $target == "bg" {
@return rgba(var(--#{$prefix}#{$identifier}-bg-rgb), var(--#{$prefix}#{$target}-opacity));
} @if $identifier == "body" and $target == "text" {
@return rgba(var(--#{$prefix}#{$identifier}-color-rgb), var(--#{$prefix}#{$target}-opacity));
} @else {
@return rgba(var(--#{$prefix}#{$identifier}-rgb), var(--#{$prefix}#{$target}-opacity));
}
}
@function map-loop($map, $func, $args...) {
$_map: ();
@each $key, $value in $map {
// allow to pass the $key and $value of the map as an function argument
$_args: ();
@each $arg in $args {
$_args: append($_args, if($arg == "$key", $key, if($arg == "$value", $value, $arg)));
}
$_map: map-merge($_map, ($key: call(get-function($func), $_args...)));
}
@return $_map;
}
// stylelint-enable scss/dollar-variable-pattern
@function varify($list) {
$result: null;
@each $entry in $list {
$result: append($result, var(--#{$prefix}#{$entry}), space);
}
@return $result;
}
// Internal Bootstrap function to turn maps into its negative variant.
// It prefixes the keys with `n` and makes the value negative.
@function negativify-map($map) {
$result: ();
@each $key, $value in $map {
@if $key != 0 {
$result: map-merge($result, ("n" + $key: (-$value)));
}
}
@return $result;
}
// Get multiple keys from a sass map
@function map-get-multiple($map, $values) {
$result: ();
@each $key, $value in $map {
@if (index($values, $key) != null) {
$result: map-merge($result, ($key: $value));
}
}
@return $result;
}
// Merge multiple maps
@function map-merge-multiple($maps...) {
$merged-maps: ();
@each $map in $maps {
$merged-maps: map-merge($merged-maps, $map);
}
@return $merged-maps;
}
// Replace `$search` with `$replace` in `$string`
// Used on our SVG icon backgrounds for custom forms.
//
// @author Kitty Giraudel
// @param {String} $string - Initial string
// @param {String} $search - Substring to replace
// @param {String} $replace ('') - New value
// @return {String} - Updated string
@function str-replace($string, $search, $replace: "") {
$index: str-index($string, $search);
@if $index {
@return str-slice($string, 1, $index - 1) + $replace + str-replace(str-slice($string, $index + str-length($search)), $search, $replace);
}
@return $string;
}
// See https://codepen.io/kevinweber/pen/dXWoRw
//
// Requires the use of quotes around data URIs.
@function escape-svg($string) {
@if str-index($string, "data:image/svg+xml") {
@each $char, $encoded in $escaped-characters {
// Do not escape the url brackets
@if str-index($string, "url(") == 1 {
$string: url("#{str-replace(str-slice($string, 6, -3), $char, $encoded)}");
} @else {
$string: str-replace($string, $char, $encoded);
}
}
}
@return $string;
}
// Color contrast
// See https://github.com/twbs/bootstrap/pull/30168
// A list of pre-calculated numbers of pow(divide((divide($value, 255) + .055), 1.055), 2.4). (from 0 to 255)
// stylelint-disable-next-line scss/dollar-variable-default, scss/dollar-variable-pattern
$_luminance-list: .0008 .001 .0011 .0013 .0015 .0017 .002 .0022 .0025 .0027 .003 .0033 .0037 .004 .0044 .0048 .0052 .0056 .006 .0065 .007 .0075 .008 .0086 .0091 .0097 .0103 .011 .0116 .0123 .013 .0137 .0144 .0152 .016 .0168 .0176 .0185 .0194 .0203 .0212 .0222 .0232 .0242 .0252 .0262 .0273 .0284 .0296 .0307 .0319 .0331 .0343 .0356 .0369 .0382 .0395 .0409 .0423 .0437 .0452 .0467 .0482 .0497 .0513 .0529 .0545 .0561 .0578 .0595 .0612 .063 .0648 .0666 .0685 .0704 .0723 .0742 .0762 .0782 .0802 .0823 .0844 .0865 .0887 .0908 .0931 .0953 .0976 .0999 .1022 .1046 .107 .1095 .1119 .1144 .117 .1195 .1221 .1248 .1274 .1301 .1329 .1356 .1384 .1413 .1441 .147 .15 .1529 .1559 .159 .162 .1651 .1683 .1714 .1746 .1779 .1812 .1845 .1878 .1912 .1946 .1981 .2016 .2051 .2086 .2122 .2159 .2195 .2232 .227 .2307 .2346 .2384 .2423 .2462 .2502 .2542 .2582 .2623 .2664 .2705 .2747 .2789 .2831 .2874 .2918 .2961 .3005 .305 .3095 .314 .3185 .3231 .3278 .3325 .3372 .3419 .3467 .3515 .3564 .3613 .3663 .3712 .3763 .3813 .3864 .3916 .3968 .402 .4072 .4125 .4179 .4233 .4287 .4342 .4397 .4452 .4508 .4564 .4621 .4678 .4735 .4793 .4851 .491 .4969 .5029 .5089 .5149 .521 .5271 .5333 .5395 .5457 .552 .5583 .5647 .5711 .5776 .5841 .5906 .5972 .6038 .6105 .6172 .624 .6308 .6376 .6445 .6514 .6584 .6654 .6724 .6795 .6867 .6939 .7011 .7084 .7157 .7231 .7305 .7379 .7454 .7529 .7605 .7682 .7758 .7835 .7913 .7991 .807 .8148 .8228 .8308 .8388 .8469 .855 .8632 .8714 .8796 .8879 .8963 .9047 .9131 .9216 .9301 .9387 .9473 .956 .9647 .9734 .9823 .9911 1;
@function color-contrast($background, $color-contrast-dark: $color-contrast-dark, $color-contrast-light: $color-contrast-light, $min-contrast-ratio: $min-contrast-ratio) {
$foregrounds: $color-contrast-light, $color-contrast-dark, $white, $black;
$max-ratio: 0;
$max-ratio-color: null;
@each $color in $foregrounds {
$contrast-ratio: contrast-ratio($background, $color);
@if $contrast-ratio >= $min-contrast-ratio {
@return $color;
} @else if $contrast-ratio > $max-ratio {
$max-ratio: $contrast-ratio;
$max-ratio-color: $color;
}
}
@warn "Found no color leading to #{$min-contrast-ratio}:1 contrast ratio against #{$background}...";
@return $max-ratio-color;
}
@function contrast-ratio($background, $foreground: $color-contrast-light) {
$l1: luminance($background);
$l2: luminance(opaque($background, $foreground));
@return if($l1 > $l2, divide($l1 + .05, $l2 + .05), divide($l2 + .05, $l1 + .05));
}
// Return WCAG2.2 relative luminance
// See https://www.w3.org/TR/WCAG/#dfn-relative-luminance
// See https://www.w3.org/TR/WCAG/#dfn-contrast-ratio
@function luminance($color) {
$rgb: (
"r": red($color),
"g": green($color),
"b": blue($color)
);
@each $name, $value in $rgb {
$value: if(divide($value, 255) < .04045, divide(divide($value, 255), 12.92), nth($_luminance-list, $value + 1));
$rgb: map-merge($rgb, ($name: $value));
}
@return (map-get($rgb, "r") * .2126) + (map-get($rgb, "g") * .7152) + (map-get($rgb, "b") * .0722);
}
// Return opaque color
// opaque(#fff, rgba(0, 0, 0, .5)) => #808080
@function opaque($background, $foreground) {
@return mix(rgba($foreground, 1), $background, opacity($foreground) * 100%);
}
// scss-docs-start color-functions
// Tint a color: mix a color with white
@function tint-color($color, $weight) {
@return mix(white, $color, $weight);
}
// Shade a color: mix a color with black
@function shade-color($color, $weight) {
@return mix(black, $color, $weight);
}
// Shade the color if the weight is positive, else tint it
@function shift-color($color, $weight) {
@return if($weight > 0, shade-color($color, $weight), tint-color($color, -$weight));
}
// scss-docs-end color-functions
// Return valid calc
@function add($value1, $value2, $return-calc: true) {
@if $value1 == null {
@return $value2;
}
@if $value2 == null {
@return $value1;
}
@if type-of($value1) == number and type-of($value2) == number and comparable($value1, $value2) {
@return $value1 + $value2;
}
@return if($return-calc == true, calc(#{$value1} + #{$value2}), $value1 + unquote(" + ") + $value2);
}
@function subtract($value1, $value2, $return-calc: true) {
@if $value1 == null and $value2 == null {
@return null;
}
@if $value1 == null {
@return -$value2;
}
@if $value2 == null {
@return $value1;
}
@if type-of($value1) == number and type-of($value2) == number and comparable($value1, $value2) {
@return $value1 - $value2;
}
@if type-of($value2) != number {
$value2: unquote("(") + $value2 + unquote(")");
}
@return if($return-calc == true, calc(#{$value1} - #{$value2}), $value1 + unquote(" - ") + $value2);
}
@function divide($dividend, $divisor, $precision: 10) {
$sign: if($dividend > 0 and $divisor > 0 or $dividend < 0 and $divisor < 0, 1, -1);
$dividend: abs($dividend);
$divisor: abs($divisor);
@if $dividend == 0 {
@return 0;
}
@if $divisor == 0 {
@error "Cannot divide by 0";
}
$remainder: $dividend;
$result: 0;
$factor: 10;
@while ($remainder > 0 and $precision >= 0) {
$quotient: 0;
@while ($remainder >= $divisor) {
$remainder: $remainder - $divisor;
$quotient: $quotient + 1;
}
$result: $result * 10 + $quotient;
$factor: $factor * .1;
$remainder: $remainder * 10;
$precision: $precision - 1;
@if ($precision < 0 and $remainder >= $divisor * 5) {
$result: $result + 1;
}
}
$result: $result * $factor * $sign;
$dividend-unit: unit($dividend);
$divisor-unit: unit($divisor);
$unit-map: (
"px": 1px,
"rem": 1rem,
"em": 1em,
"%": 1%
);
@if ($dividend-unit != $divisor-unit and map-has-key($unit-map, $dividend-unit)) {
$result: $result * map-get($unit-map, $dividend-unit);
}
@return $result;
}

View file

@ -0,0 +1,39 @@
// Row
//
// Rows contain your columns.
:root {
@each $name, $value in $grid-breakpoints {
--#{$prefix}breakpoint-#{$name}: #{$value};
}
}
@if $enable-grid-classes {
.row {
@include make-row();
> * {
@include make-col-ready();
}
}
}
@if $enable-cssgrid {
.grid {
display: grid;
grid-template-rows: repeat(var(--#{$prefix}rows, 1), 1fr);
grid-template-columns: repeat(var(--#{$prefix}columns, #{$grid-columns}), 1fr);
gap: var(--#{$prefix}gap, #{$grid-gutter-width});
@include make-cssgrid();
}
}
// Columns
//
// Common styles for small and large grid columns
@if $enable-grid-classes {
@include make-grid-columns();
}

View file

@ -0,0 +1,12 @@
@import "helpers/clearfix";
@import "helpers/color-bg";
@import "helpers/colored-links";
@import "helpers/focus-ring";
@import "helpers/icon-link";
@import "helpers/ratio";
@import "helpers/position";
@import "helpers/stacks";
@import "helpers/visually-hidden";
@import "helpers/stretched-link";
@import "helpers/text-truncation";
@import "helpers/vr";

View file

@ -0,0 +1,42 @@
// Responsive images (ensure images don't scale beyond their parents)
//
// This is purposefully opt-in via an explicit class rather than being the default for all `<img>`s.
// We previously tried the "images are responsive by default" approach in Bootstrap v2,
// and abandoned it in Bootstrap v3 because it breaks lots of third-party widgets (including Google Maps)
// which weren't expecting the images within themselves to be involuntarily resized.
// See also https://github.com/twbs/bootstrap/issues/18178
.img-fluid {
@include img-fluid();
}
// Image thumbnails
.img-thumbnail {
padding: $thumbnail-padding;
background-color: $thumbnail-bg;
border: $thumbnail-border-width solid $thumbnail-border-color;
@include border-radius($thumbnail-border-radius);
@include box-shadow($thumbnail-box-shadow);
// Keep them at most 100% wide
@include img-fluid();
}
//
// Figures
//
.figure {
// Ensures the caption's text aligns with the image.
display: inline-block;
}
.figure-img {
margin-bottom: $spacer * .5;
line-height: 1;
}
.figure-caption {
@include font-size($figure-caption-font-size);
color: $figure-caption-color;
}

View file

@ -0,0 +1,199 @@
// Base class
//
// Easily usable on <ul>, <ol>, or <div>.
.list-group {
// scss-docs-start list-group-css-vars
--#{$prefix}list-group-color: #{$list-group-color};
--#{$prefix}list-group-bg: #{$list-group-bg};
--#{$prefix}list-group-border-color: #{$list-group-border-color};
--#{$prefix}list-group-border-width: #{$list-group-border-width};
--#{$prefix}list-group-border-radius: #{$list-group-border-radius};
--#{$prefix}list-group-item-padding-x: #{$list-group-item-padding-x};
--#{$prefix}list-group-item-padding-y: #{$list-group-item-padding-y};
--#{$prefix}list-group-action-color: #{$list-group-action-color};
--#{$prefix}list-group-action-hover-color: #{$list-group-action-hover-color};
--#{$prefix}list-group-action-hover-bg: #{$list-group-hover-bg};
--#{$prefix}list-group-action-active-color: #{$list-group-action-active-color};
--#{$prefix}list-group-action-active-bg: #{$list-group-action-active-bg};
--#{$prefix}list-group-disabled-color: #{$list-group-disabled-color};
--#{$prefix}list-group-disabled-bg: #{$list-group-disabled-bg};
--#{$prefix}list-group-active-color: #{$list-group-active-color};
--#{$prefix}list-group-active-bg: #{$list-group-active-bg};
--#{$prefix}list-group-active-border-color: #{$list-group-active-border-color};
// scss-docs-end list-group-css-vars
display: flex;
flex-direction: column;
// No need to set list-style: none; since .list-group-item is block level
padding-left: 0; // reset padding because ul and ol
margin-bottom: 0;
@include border-radius(var(--#{$prefix}list-group-border-radius));
}
.list-group-numbered {
list-style-type: none;
counter-reset: section;
> .list-group-item::before {
// Increments only this instance of the section counter
content: counters(section, ".") ". ";
counter-increment: section;
}
}
// Individual list items
//
// Use on `li`s or `div`s within the `.list-group` parent.
.list-group-item {
position: relative;
display: block;
padding: var(--#{$prefix}list-group-item-padding-y) var(--#{$prefix}list-group-item-padding-x);
color: var(--#{$prefix}list-group-color);
text-decoration: if($link-decoration == none, null, none);
background-color: var(--#{$prefix}list-group-bg);
border: var(--#{$prefix}list-group-border-width) solid var(--#{$prefix}list-group-border-color);
&:first-child {
@include border-top-radius(inherit);
}
&:last-child {
@include border-bottom-radius(inherit);
}
&.disabled,
&:disabled {
color: var(--#{$prefix}list-group-disabled-color);
pointer-events: none;
background-color: var(--#{$prefix}list-group-disabled-bg);
}
// Include both here for `<a>`s and `<button>`s
&.active {
z-index: 2; // Place active items above their siblings for proper border styling
color: var(--#{$prefix}list-group-active-color);
background-color: var(--#{$prefix}list-group-active-bg);
border-color: var(--#{$prefix}list-group-active-border-color);
}
// stylelint-disable-next-line scss/selector-no-redundant-nesting-selector
& + .list-group-item {
border-top-width: 0;
&.active {
margin-top: calc(-1 * var(--#{$prefix}list-group-border-width)); // stylelint-disable-line function-disallowed-list
border-top-width: var(--#{$prefix}list-group-border-width);
}
}
}
// Interactive list items
//
// Use anchor or button elements instead of `li`s or `div`s to create interactive
// list items. Includes an extra `.active` modifier class for selected items.
.list-group-item-action {
width: 100%; // For `<button>`s (anchors become 100% by default though)
color: var(--#{$prefix}list-group-action-color);
text-align: inherit; // For `<button>`s (anchors inherit)
&:not(.active) {
// Hover state
&:hover,
&:focus {
z-index: 1; // Place hover/focus items above their siblings for proper border styling
color: var(--#{$prefix}list-group-action-hover-color);
text-decoration: none;
background-color: var(--#{$prefix}list-group-action-hover-bg);
}
&:active {
color: var(--#{$prefix}list-group-action-active-color);
background-color: var(--#{$prefix}list-group-action-active-bg);
}
}
}
// Horizontal
//
// Change the layout of list group items from vertical (default) to horizontal.
@each $breakpoint in map-keys($grid-breakpoints) {
@include media-breakpoint-up($breakpoint) {
$infix: breakpoint-infix($breakpoint, $grid-breakpoints);
.list-group-horizontal#{$infix} {
flex-direction: row;
> .list-group-item {
&:first-child:not(:last-child) {
@include border-bottom-start-radius(var(--#{$prefix}list-group-border-radius));
@include border-top-end-radius(0);
}
&:last-child:not(:first-child) {
@include border-top-end-radius(var(--#{$prefix}list-group-border-radius));
@include border-bottom-start-radius(0);
}
&.active {
margin-top: 0;
}
+ .list-group-item {
border-top-width: var(--#{$prefix}list-group-border-width);
border-left-width: 0;
&.active {
margin-left: calc(-1 * var(--#{$prefix}list-group-border-width)); // stylelint-disable-line function-disallowed-list
border-left-width: var(--#{$prefix}list-group-border-width);
}
}
}
}
}
}
// Flush list items
//
// Remove borders and border-radius to keep list group items edge-to-edge. Most
// useful within other components (e.g., cards).
.list-group-flush {
@include border-radius(0);
> .list-group-item {
border-width: 0 0 var(--#{$prefix}list-group-border-width);
&:last-child {
border-bottom-width: 0;
}
}
}
// scss-docs-start list-group-modifiers
// List group contextual variants
//
// Add modifier classes to change text and background color on individual items.
// Organizationally, this must come after the `:hover` states.
@each $state in map-keys($theme-colors) {
.list-group-item-#{$state} {
--#{$prefix}list-group-color: var(--#{$prefix}#{$state}-text-emphasis);
--#{$prefix}list-group-bg: var(--#{$prefix}#{$state}-bg-subtle);
--#{$prefix}list-group-border-color: var(--#{$prefix}#{$state}-border-subtle);
--#{$prefix}list-group-action-hover-color: var(--#{$prefix}emphasis-color);
--#{$prefix}list-group-action-hover-bg: var(--#{$prefix}#{$state}-border-subtle);
--#{$prefix}list-group-action-active-color: var(--#{$prefix}emphasis-color);
--#{$prefix}list-group-action-active-bg: var(--#{$prefix}#{$state}-border-subtle);
--#{$prefix}list-group-active-color: var(--#{$prefix}#{$state}-bg-subtle);
--#{$prefix}list-group-active-bg: var(--#{$prefix}#{$state}-text-emphasis);
--#{$prefix}list-group-active-border-color: var(--#{$prefix}#{$state}-text-emphasis);
}
}
// scss-docs-end list-group-modifiers

View file

@ -0,0 +1,174 @@
// Re-assigned maps
//
// Placed here so that others can override the default Sass maps and see automatic updates to utilities and more.
// scss-docs-start theme-colors-rgb
$theme-colors-rgb: map-loop($theme-colors, to-rgb, "$value") !default;
// scss-docs-end theme-colors-rgb
// scss-docs-start theme-text-map
$theme-colors-text: (
"primary": $primary-text-emphasis,
"secondary": $secondary-text-emphasis,
"success": $success-text-emphasis,
"info": $info-text-emphasis,
"warning": $warning-text-emphasis,
"danger": $danger-text-emphasis,
"light": $light-text-emphasis,
"dark": $dark-text-emphasis,
) !default;
// scss-docs-end theme-text-map
// scss-docs-start theme-bg-subtle-map
$theme-colors-bg-subtle: (
"primary": $primary-bg-subtle,
"secondary": $secondary-bg-subtle,
"success": $success-bg-subtle,
"info": $info-bg-subtle,
"warning": $warning-bg-subtle,
"danger": $danger-bg-subtle,
"light": $light-bg-subtle,
"dark": $dark-bg-subtle,
) !default;
// scss-docs-end theme-bg-subtle-map
// scss-docs-start theme-border-subtle-map
$theme-colors-border-subtle: (
"primary": $primary-border-subtle,
"secondary": $secondary-border-subtle,
"success": $success-border-subtle,
"info": $info-border-subtle,
"warning": $warning-border-subtle,
"danger": $danger-border-subtle,
"light": $light-border-subtle,
"dark": $dark-border-subtle,
) !default;
// scss-docs-end theme-border-subtle-map
$theme-colors-text-dark: null !default;
$theme-colors-bg-subtle-dark: null !default;
$theme-colors-border-subtle-dark: null !default;
@if $enable-dark-mode {
// scss-docs-start theme-text-dark-map
$theme-colors-text-dark: (
"primary": $primary-text-emphasis-dark,
"secondary": $secondary-text-emphasis-dark,
"success": $success-text-emphasis-dark,
"info": $info-text-emphasis-dark,
"warning": $warning-text-emphasis-dark,
"danger": $danger-text-emphasis-dark,
"light": $light-text-emphasis-dark,
"dark": $dark-text-emphasis-dark,
) !default;
// scss-docs-end theme-text-dark-map
// scss-docs-start theme-bg-subtle-dark-map
$theme-colors-bg-subtle-dark: (
"primary": $primary-bg-subtle-dark,
"secondary": $secondary-bg-subtle-dark,
"success": $success-bg-subtle-dark,
"info": $info-bg-subtle-dark,
"warning": $warning-bg-subtle-dark,
"danger": $danger-bg-subtle-dark,
"light": $light-bg-subtle-dark,
"dark": $dark-bg-subtle-dark,
) !default;
// scss-docs-end theme-bg-subtle-dark-map
// scss-docs-start theme-border-subtle-dark-map
$theme-colors-border-subtle-dark: (
"primary": $primary-border-subtle-dark,
"secondary": $secondary-border-subtle-dark,
"success": $success-border-subtle-dark,
"info": $info-border-subtle-dark,
"warning": $warning-border-subtle-dark,
"danger": $danger-border-subtle-dark,
"light": $light-border-subtle-dark,
"dark": $dark-border-subtle-dark,
) !default;
// scss-docs-end theme-border-subtle-dark-map
}
// Utilities maps
//
// Extends the default `$theme-colors` maps to help create our utilities.
// Come v6, we'll de-dupe these variables. Until then, for backward compatibility, we keep them to reassign.
// scss-docs-start utilities-colors
$utilities-colors: $theme-colors-rgb !default;
// scss-docs-end utilities-colors
// scss-docs-start utilities-text-colors
$utilities-text: map-merge(
$utilities-colors,
(
"black": to-rgb($black),
"white": to-rgb($white),
"body": to-rgb($body-color)
)
) !default;
$utilities-text-colors: map-loop($utilities-text, rgba-css-var, "$key", "text") !default;
$utilities-text-emphasis-colors: (
"primary-emphasis": var(--#{$prefix}primary-text-emphasis),
"secondary-emphasis": var(--#{$prefix}secondary-text-emphasis),
"success-emphasis": var(--#{$prefix}success-text-emphasis),
"info-emphasis": var(--#{$prefix}info-text-emphasis),
"warning-emphasis": var(--#{$prefix}warning-text-emphasis),
"danger-emphasis": var(--#{$prefix}danger-text-emphasis),
"light-emphasis": var(--#{$prefix}light-text-emphasis),
"dark-emphasis": var(--#{$prefix}dark-text-emphasis)
) !default;
// scss-docs-end utilities-text-colors
// scss-docs-start utilities-bg-colors
$utilities-bg: map-merge(
$utilities-colors,
(
"black": to-rgb($black),
"white": to-rgb($white),
"body": to-rgb($body-bg)
)
) !default;
$utilities-bg-colors: map-loop($utilities-bg, rgba-css-var, "$key", "bg") !default;
$utilities-bg-subtle: (
"primary-subtle": var(--#{$prefix}primary-bg-subtle),
"secondary-subtle": var(--#{$prefix}secondary-bg-subtle),
"success-subtle": var(--#{$prefix}success-bg-subtle),
"info-subtle": var(--#{$prefix}info-bg-subtle),
"warning-subtle": var(--#{$prefix}warning-bg-subtle),
"danger-subtle": var(--#{$prefix}danger-bg-subtle),
"light-subtle": var(--#{$prefix}light-bg-subtle),
"dark-subtle": var(--#{$prefix}dark-bg-subtle)
) !default;
// scss-docs-end utilities-bg-colors
// scss-docs-start utilities-border-colors
$utilities-border: map-merge(
$utilities-colors,
(
"black": to-rgb($black),
"white": to-rgb($white)
)
) !default;
$utilities-border-colors: map-loop($utilities-border, rgba-css-var, "$key", "border") !default;
$utilities-border-subtle: (
"primary-subtle": var(--#{$prefix}primary-border-subtle),
"secondary-subtle": var(--#{$prefix}secondary-border-subtle),
"success-subtle": var(--#{$prefix}success-border-subtle),
"info-subtle": var(--#{$prefix}info-border-subtle),
"warning-subtle": var(--#{$prefix}warning-border-subtle),
"danger-subtle": var(--#{$prefix}danger-border-subtle),
"light-subtle": var(--#{$prefix}light-border-subtle),
"dark-subtle": var(--#{$prefix}dark-border-subtle)
) !default;
// scss-docs-end utilities-border-colors
$utilities-links-underline: map-loop($utilities-colors, rgba-css-var, "$key", "link-underline") !default;
$negative-spacers: if($enable-negative-margins, negativify-map($spacers), null) !default;
$gutters: $spacers !default;

View file

@ -0,0 +1,42 @@
// Toggles
//
// Used in conjunction with global variables to enable certain theme features.
// Vendor
@import "vendor/rfs";
// Deprecate
@import "mixins/deprecate";
// Helpers
@import "mixins/breakpoints";
@import "mixins/color-mode";
@import "mixins/color-scheme";
@import "mixins/image";
@import "mixins/resize";
@import "mixins/visually-hidden";
@import "mixins/reset-text";
@import "mixins/text-truncate";
// Utilities
@import "mixins/utilities";
// Components
@import "mixins/backdrop";
@import "mixins/buttons";
@import "mixins/caret";
@import "mixins/pagination";
@import "mixins/lists";
@import "mixins/forms";
@import "mixins/table-variants";
// Skins
@import "mixins/border-radius";
@import "mixins/box-shadow";
@import "mixins/gradients";
@import "mixins/transition";
// Layout
@import "mixins/clearfix";
@import "mixins/container";
@import "mixins/grid";

View file

@ -0,0 +1,240 @@
// stylelint-disable function-disallowed-list
// .modal-open - body class for killing the scroll
// .modal - container to scroll within
// .modal-dialog - positioning shell for the actual modal
// .modal-content - actual modal w/ bg and corners and stuff
// Container that the modal scrolls within
.modal {
// scss-docs-start modal-css-vars
--#{$prefix}modal-zindex: #{$zindex-modal};
--#{$prefix}modal-width: #{$modal-md};
--#{$prefix}modal-padding: #{$modal-inner-padding};
--#{$prefix}modal-margin: #{$modal-dialog-margin};
--#{$prefix}modal-color: #{$modal-content-color};
--#{$prefix}modal-bg: #{$modal-content-bg};
--#{$prefix}modal-border-color: #{$modal-content-border-color};
--#{$prefix}modal-border-width: #{$modal-content-border-width};
--#{$prefix}modal-border-radius: #{$modal-content-border-radius};
--#{$prefix}modal-box-shadow: #{$modal-content-box-shadow-xs};
--#{$prefix}modal-inner-border-radius: #{$modal-content-inner-border-radius};
--#{$prefix}modal-header-padding-x: #{$modal-header-padding-x};
--#{$prefix}modal-header-padding-y: #{$modal-header-padding-y};
--#{$prefix}modal-header-padding: #{$modal-header-padding}; // Todo in v6: Split this padding into x and y
--#{$prefix}modal-header-border-color: #{$modal-header-border-color};
--#{$prefix}modal-header-border-width: #{$modal-header-border-width};
--#{$prefix}modal-title-line-height: #{$modal-title-line-height};
--#{$prefix}modal-footer-gap: #{$modal-footer-margin-between};
--#{$prefix}modal-footer-bg: #{$modal-footer-bg};
--#{$prefix}modal-footer-border-color: #{$modal-footer-border-color};
--#{$prefix}modal-footer-border-width: #{$modal-footer-border-width};
// scss-docs-end modal-css-vars
position: fixed;
top: 0;
left: 0;
z-index: var(--#{$prefix}modal-zindex);
display: none;
width: 100%;
height: 100%;
overflow-x: hidden;
overflow-y: auto;
// Prevent Chrome on Windows from adding a focus outline. For details, see
// https://github.com/twbs/bootstrap/pull/10951.
outline: 0;
// We deliberately don't use `-webkit-overflow-scrolling: touch;` due to a
// gnarly iOS Safari bug: https://bugs.webkit.org/show_bug.cgi?id=158342
// See also https://github.com/twbs/bootstrap/issues/17695
}
// Shell div to position the modal with bottom padding
.modal-dialog {
position: relative;
width: auto;
margin: var(--#{$prefix}modal-margin);
// allow clicks to pass through for custom click handling to close modal
pointer-events: none;
// When fading in the modal, animate it to slide down
.modal.fade & {
transform: $modal-fade-transform;
@include transition($modal-transition);
}
.modal.show & {
transform: $modal-show-transform;
}
// When trying to close, animate focus to scale
.modal.modal-static & {
transform: $modal-scale-transform;
}
}
.modal-dialog-scrollable {
height: calc(100% - var(--#{$prefix}modal-margin) * 2);
.modal-content {
max-height: 100%;
overflow: hidden;
}
.modal-body {
overflow-y: auto;
}
}
.modal-dialog-centered {
display: flex;
align-items: center;
min-height: calc(100% - var(--#{$prefix}modal-margin) * 2);
}
// Actual modal
.modal-content {
position: relative;
display: flex;
flex-direction: column;
width: 100%; // Ensure `.modal-content` extends the full width of the parent `.modal-dialog`
// counteract the pointer-events: none; in the .modal-dialog
color: var(--#{$prefix}modal-color);
pointer-events: auto;
background-color: var(--#{$prefix}modal-bg);
background-clip: padding-box;
border: var(--#{$prefix}modal-border-width) solid var(--#{$prefix}modal-border-color);
@include border-radius(var(--#{$prefix}modal-border-radius));
@include box-shadow(var(--#{$prefix}modal-box-shadow));
// Remove focus outline from opened modal
outline: 0;
}
// Modal background
.modal-backdrop {
// scss-docs-start modal-backdrop-css-vars
--#{$prefix}backdrop-zindex: #{$zindex-modal-backdrop};
--#{$prefix}backdrop-bg: #{$modal-backdrop-bg};
--#{$prefix}backdrop-opacity: #{$modal-backdrop-opacity};
// scss-docs-end modal-backdrop-css-vars
@include overlay-backdrop(var(--#{$prefix}backdrop-zindex), var(--#{$prefix}backdrop-bg), var(--#{$prefix}backdrop-opacity));
}
// Modal header
// Top section of the modal w/ title and dismiss
.modal-header {
display: flex;
flex-shrink: 0;
align-items: center;
padding: var(--#{$prefix}modal-header-padding);
border-bottom: var(--#{$prefix}modal-header-border-width) solid var(--#{$prefix}modal-header-border-color);
@include border-top-radius(var(--#{$prefix}modal-inner-border-radius));
.btn-close {
padding: calc(var(--#{$prefix}modal-header-padding-y) * .5) calc(var(--#{$prefix}modal-header-padding-x) * .5);
// Split properties to avoid invalid calc() function if value is 0
margin-top: calc(-.5 * var(--#{$prefix}modal-header-padding-y));
margin-right: calc(-.5 * var(--#{$prefix}modal-header-padding-x));
margin-bottom: calc(-.5 * var(--#{$prefix}modal-header-padding-y));
margin-left: auto;
}
}
// Title text within header
.modal-title {
margin-bottom: 0;
line-height: var(--#{$prefix}modal-title-line-height);
}
// Modal body
// Where all modal content resides (sibling of .modal-header and .modal-footer)
.modal-body {
position: relative;
// Enable `flex-grow: 1` so that the body take up as much space as possible
// when there should be a fixed height on `.modal-dialog`.
flex: 1 1 auto;
padding: var(--#{$prefix}modal-padding);
}
// Footer (for actions)
.modal-footer {
display: flex;
flex-shrink: 0;
flex-wrap: wrap;
align-items: center; // vertically center
justify-content: flex-end; // Right align buttons with flex property because text-align doesn't work on flex items
padding: calc(var(--#{$prefix}modal-padding) - var(--#{$prefix}modal-footer-gap) * .5);
background-color: var(--#{$prefix}modal-footer-bg);
border-top: var(--#{$prefix}modal-footer-border-width) solid var(--#{$prefix}modal-footer-border-color);
@include border-bottom-radius(var(--#{$prefix}modal-inner-border-radius));
// Place margin between footer elements
// This solution is far from ideal because of the universal selector usage,
// but is needed to fix https://github.com/twbs/bootstrap/issues/24800
> * {
margin: calc(var(--#{$prefix}modal-footer-gap) * .5); // Todo in v6: replace with gap on parent class
}
}
// Scale up the modal
@include media-breakpoint-up(sm) {
.modal {
--#{$prefix}modal-margin: #{$modal-dialog-margin-y-sm-up};
--#{$prefix}modal-box-shadow: #{$modal-content-box-shadow-sm-up};
}
// Automatically set modal's width for larger viewports
.modal-dialog {
max-width: var(--#{$prefix}modal-width);
margin-right: auto;
margin-left: auto;
}
.modal-sm {
--#{$prefix}modal-width: #{$modal-sm};
}
}
@include media-breakpoint-up(lg) {
.modal-lg,
.modal-xl {
--#{$prefix}modal-width: #{$modal-lg};
}
}
@include media-breakpoint-up(xl) {
.modal-xl {
--#{$prefix}modal-width: #{$modal-xl};
}
}
// scss-docs-start modal-fullscreen-loop
@each $breakpoint in map-keys($grid-breakpoints) {
$infix: breakpoint-infix($breakpoint, $grid-breakpoints);
$postfix: if($infix != "", $infix + "-down", "");
@include media-breakpoint-down($breakpoint) {
.modal-fullscreen#{$postfix} {
width: 100vw;
max-width: none;
height: 100%;
margin: 0;
.modal-content {
height: 100%;
border: 0;
@include border-radius(0);
}
.modal-header,
.modal-footer {
@include border-radius(0);
}
.modal-body {
overflow-y: auto;
}
}
}
}
// scss-docs-end modal-fullscreen-loop

View file

@ -0,0 +1,197 @@
// Base class
//
// Kickstart any navigation component with a set of style resets. Works with
// `<nav>`s, `<ul>`s or `<ol>`s.
.nav {
// scss-docs-start nav-css-vars
--#{$prefix}nav-link-padding-x: #{$nav-link-padding-x};
--#{$prefix}nav-link-padding-y: #{$nav-link-padding-y};
@include rfs($nav-link-font-size, --#{$prefix}nav-link-font-size);
--#{$prefix}nav-link-font-weight: #{$nav-link-font-weight};
--#{$prefix}nav-link-color: #{$nav-link-color};
--#{$prefix}nav-link-hover-color: #{$nav-link-hover-color};
--#{$prefix}nav-link-disabled-color: #{$nav-link-disabled-color};
// scss-docs-end nav-css-vars
display: flex;
flex-wrap: wrap;
padding-left: 0;
margin-bottom: 0;
list-style: none;
}
.nav-link {
display: block;
padding: var(--#{$prefix}nav-link-padding-y) var(--#{$prefix}nav-link-padding-x);
@include font-size(var(--#{$prefix}nav-link-font-size));
font-weight: var(--#{$prefix}nav-link-font-weight);
color: var(--#{$prefix}nav-link-color);
text-decoration: if($link-decoration == none, null, none);
background: none;
border: 0;
@include transition($nav-link-transition);
&:hover,
&:focus {
color: var(--#{$prefix}nav-link-hover-color);
text-decoration: if($link-hover-decoration == underline, none, null);
}
&:focus-visible {
outline: 0;
box-shadow: $nav-link-focus-box-shadow;
}
// Disabled state lightens text
&.disabled,
&:disabled {
color: var(--#{$prefix}nav-link-disabled-color);
pointer-events: none;
cursor: default;
}
}
//
// Tabs
//
.nav-tabs {
// scss-docs-start nav-tabs-css-vars
--#{$prefix}nav-tabs-border-width: #{$nav-tabs-border-width};
--#{$prefix}nav-tabs-border-color: #{$nav-tabs-border-color};
--#{$prefix}nav-tabs-border-radius: #{$nav-tabs-border-radius};
--#{$prefix}nav-tabs-link-hover-border-color: #{$nav-tabs-link-hover-border-color};
--#{$prefix}nav-tabs-link-active-color: #{$nav-tabs-link-active-color};
--#{$prefix}nav-tabs-link-active-bg: #{$nav-tabs-link-active-bg};
--#{$prefix}nav-tabs-link-active-border-color: #{$nav-tabs-link-active-border-color};
// scss-docs-end nav-tabs-css-vars
border-bottom: var(--#{$prefix}nav-tabs-border-width) solid var(--#{$prefix}nav-tabs-border-color);
.nav-link {
margin-bottom: calc(-1 * var(--#{$prefix}nav-tabs-border-width)); // stylelint-disable-line function-disallowed-list
border: var(--#{$prefix}nav-tabs-border-width) solid transparent;
@include border-top-radius(var(--#{$prefix}nav-tabs-border-radius));
&:hover,
&:focus {
// Prevents active .nav-link tab overlapping focus outline of previous/next .nav-link
isolation: isolate;
border-color: var(--#{$prefix}nav-tabs-link-hover-border-color);
}
}
.nav-link.active,
.nav-item.show .nav-link {
color: var(--#{$prefix}nav-tabs-link-active-color);
background-color: var(--#{$prefix}nav-tabs-link-active-bg);
border-color: var(--#{$prefix}nav-tabs-link-active-border-color);
}
.dropdown-menu {
// Make dropdown border overlap tab border
margin-top: calc(-1 * var(--#{$prefix}nav-tabs-border-width)); // stylelint-disable-line function-disallowed-list
// Remove the top rounded corners here since there is a hard edge above the menu
@include border-top-radius(0);
}
}
//
// Pills
//
.nav-pills {
// scss-docs-start nav-pills-css-vars
--#{$prefix}nav-pills-border-radius: #{$nav-pills-border-radius};
--#{$prefix}nav-pills-link-active-color: #{$nav-pills-link-active-color};
--#{$prefix}nav-pills-link-active-bg: #{$nav-pills-link-active-bg};
// scss-docs-end nav-pills-css-vars
.nav-link {
@include border-radius(var(--#{$prefix}nav-pills-border-radius));
}
.nav-link.active,
.show > .nav-link {
color: var(--#{$prefix}nav-pills-link-active-color);
@include gradient-bg(var(--#{$prefix}nav-pills-link-active-bg));
}
}
//
// Underline
//
.nav-underline {
// scss-docs-start nav-underline-css-vars
--#{$prefix}nav-underline-gap: #{$nav-underline-gap};
--#{$prefix}nav-underline-border-width: #{$nav-underline-border-width};
--#{$prefix}nav-underline-link-active-color: #{$nav-underline-link-active-color};
// scss-docs-end nav-underline-css-vars
gap: var(--#{$prefix}nav-underline-gap);
.nav-link {
padding-right: 0;
padding-left: 0;
border-bottom: var(--#{$prefix}nav-underline-border-width) solid transparent;
&:hover,
&:focus {
border-bottom-color: currentcolor;
}
}
.nav-link.active,
.show > .nav-link {
font-weight: $font-weight-bold;
color: var(--#{$prefix}nav-underline-link-active-color);
border-bottom-color: currentcolor;
}
}
//
// Justified variants
//
.nav-fill {
> .nav-link,
.nav-item {
flex: 1 1 auto;
text-align: center;
}
}
.nav-justified {
> .nav-link,
.nav-item {
flex-grow: 1;
flex-basis: 0;
text-align: center;
}
}
.nav-fill,
.nav-justified {
.nav-item .nav-link {
width: 100%; // Make sure button will grow
}
}
// Tabbable tabs
//
// Hide tabbable panes to start, show them when `.active`
.tab-content {
> .tab-pane {
display: none;
}
> .active {
display: block;
}
}

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