(bootsier): Añade componentes esenciales

- Añade Dialog como modal de Bootstrap, con el JS de soporte para
  `htmx:confirm` y el saneado de su posición fija.
- Adapta Nav, Navbar y Dropdown al reuso de los tipos movidos al core, y
  traduce BootsierColors/Intent en Button y Badge, con soporte para
  Light/Dark vía with_color().
- Reexporta en el tema los componentes de PageTop que faltaban: Block,
  Breadcrumb, Messages, Pager, Table y form::Number.
This commit is contained in:
Manuel Cillero 2026-08-28 19:46:56 +02:00
parent eb63a7ef37
commit c0a5a8c3ab
47 changed files with 2135 additions and 1818 deletions

View file

@ -1,12 +1,27 @@
//! Componentes proporcionados por el tema.
pub(crate) mod layout;
pub use layout::BootsierRegions;
// Badge.
pub(crate) mod badge;
pub use badge::Badge;
pub use badge::{Badge, BadgeBootsier};
// Block.
pub use pagetop::base::component::Block;
// Brand.
pub(crate) mod brand;
pub use brand::Brand;
// Breadcrumb.
#[doc(inline)]
pub use breadcrumb::Breadcrumb;
pub use pagetop::base::component::breadcrumb;
// Button.
mod button;
pub use button::{Button, ButtonAction};
pub mod button;
pub use button::{Button, ButtonBootsier};
// Container.
pub mod container;
@ -15,10 +30,17 @@ pub use container::Container;
#[doc(inline)]
pub use container::ContainerBootsier;
// Dialog.
pub mod dialog;
#[doc(inline)]
pub use dialog::Dialog;
// Dropdown.
pub mod dropdown;
#[doc(inline)]
pub use dropdown::Dropdown;
#[doc(inline)]
pub use dropdown::DropdownBootsier;
// Form.
pub mod form;
@ -36,20 +58,35 @@ pub mod image;
#[doc(inline)]
pub use image::Image;
// Messages.
pub use pagetop::base::component::Messages;
// Nav.
pub mod nav;
#[doc(inline)]
pub use nav::Nav;
#[doc(inline)]
pub use nav::NavBootsier;
// Navbar.
pub mod navbar;
#[doc(inline)]
pub use navbar::Navbar;
#[doc(inline)]
pub use navbar::NavbarBootsier;
// Offcanvas.
pub mod offcanvas;
#[doc(inline)]
pub use offcanvas::Offcanvas;
// Pager.
pub use pagetop::base::component::{Pager, PagerAlign, PagerVisibility};
// Sidebar (componentes de navegación de AdminLTE).
pub mod sidebar;
// Table.
pub use pagetop::base::component::table;
#[doc(inline)]
pub use table::Table;

View file

@ -1,14 +1,57 @@
use pagetop::prelude::*;
use crate::theme::BootsierColors;
pub use pagetop::base::component::Badge;
const EXTRA_COLOR: &str = "bootsier.badge.color";
/// Extensión de Bootsier para [`Badge`].
///
/// Permite forzar un color de la paleta de Bootsier ([`BootsierColors`]) en vez del que le
/// correspondería por defecto a la [`Intent`] del badge -- por ejemplo, para usar `Light`/`Dark`,
/// que `Intent` no tiene.
///
/// ```rust,no_run
/// use pagetop::prelude::*;
/// use pagetop_bootsier::theme::*;
///
/// let badge = bs::Badge::labeled(Lc::n("Beta")).with_color(BootsierColors::Dark);
/// ```
pub trait BadgeBootsier {
/// Fuerza un color de la paleta de Bootsier, ignorando el que le correspondería a la `Intent`
/// del badge. `None` restablece el comportamiento por defecto (color derivado de la `Intent`).
#[builder_fn]
fn with_color(self, color: impl Into<Option<BootsierColors>>) -> Self;
}
impl BadgeBootsier for Badge {
#[builder_fn]
fn with_color(mut self, color: impl Into<Option<BootsierColors>>) -> Self {
match color.into() {
Some(color) => self.alter_prop(PropsOp::set_extra(EXTRA_COLOR, color)),
None => self.alter_prop(PropsOp::remove_extra(EXTRA_COLOR)),
};
self
}
}
// **< Badge SETUP >********************************************************************************
pub(crate) fn setup(badge: &mut Badge) {
let intent = badge.intent().as_str();
// `Badge::setup()` (core) ya ha traducido la intención con `Theme::intent_color()` -- la clase
// `badge-*` que hay que localizar es siempre la derivada de la `Intent`, con independencia de
// que `BadgeBootsier::with_color()` fuerce un color distinto para el destino `text-bg-*`.
let intent_color = BootsierColors::from(badge.intent()).as_str();
let color = badge
.props()
.extra::<BootsierColors>(EXTRA_COLOR)
.ok()
.copied()
.map_or(intent_color, |color| color.as_str());
badge.alter_prop(PropsOp::replace_classes(
util::join!("badge-", intent),
util::join!("text-bg-", intent),
util::join!("badge-", intent_color),
util::join!("text-bg-", color),
));
}

View file

@ -1,9 +1,136 @@
use pagetop::prelude::*;
pub use pagetop::base::component::{Button, ButtonAction};
use crate::theme::BootsierColors;
pub use pagetop::base::component::button::{Button, Kind, Size, Style};
const EXTRA_ACTIVE: &str = "bootsier.button.active";
const EXTRA_FULL_WIDTH: &str = "bootsier.button.full_width";
const EXTRA_COLOR: &str = "bootsier.button.color";
// **< ButtonBootsier >*****************************************************************************
/// Extensión de Bootsier para [`Button`].
///
/// Añade funcionalidad de Bootstrap que no cubre el componente base: estado activo (`.active`,
/// `aria-pressed`), ancho completo (`w-100`, el reemplazo de `.btn-block` desde Bootstrap 5), y un
/// color de la paleta de Bootsier que fuerza el que le correspondería a la [`Intent`] del botón --
/// por ejemplo, para usar `Light`/`Dark`, que `Intent` no tiene.
///
/// ```rust,no_run
/// use pagetop::prelude::*;
/// use pagetop_bootsier::theme::*;
///
/// let toggle = bs::Button::plain(Lc::n("Bold"))
/// .with_style(button::Style::Outline(Intent::Neutral))
/// .with_active(true);
///
/// let submit = bs::Button::submit(Lc::n("Save"))
/// .with_style(button::Style::Solid(Intent::Primary))
/// .with_full_width(true);
///
/// let subtle = bs::Button::plain(Lc::n("Cancel"))
/// .with_style(button::Style::Solid(Intent::Neutral))
/// .with_color(BootsierColors::Light);
/// ```
pub trait ButtonBootsier {
/// Marca el botón como activo (`.active`, `aria-pressed="true"`).
#[builder_fn]
fn with_active(self, active: bool) -> Self;
/// Expande el botón al ancho completo de su contenedor (`w-100`).
#[builder_fn]
fn with_full_width(self, full_width: bool) -> Self;
/// Fuerza un color de la paleta de Bootsier, ignorando el que le correspondería a la `Intent`
/// del botón. `None` restablece el comportamiento por defecto (color derivado de la `Intent`).
/// Sin efecto si el estilo del botón es [`Style::Link`] o [`Style::None`].
#[builder_fn]
fn with_color(self, color: impl Into<Option<BootsierColors>>) -> Self;
}
impl ButtonBootsier for Button {
#[builder_fn]
fn with_active(mut self, active: bool) -> Self {
self.alter_prop(PropsOp::set_extra(EXTRA_ACTIVE, active));
self
}
#[builder_fn]
fn with_full_width(mut self, full_width: bool) -> Self {
self.alter_prop(PropsOp::set_extra(EXTRA_FULL_WIDTH, full_width));
self
}
#[builder_fn]
fn with_color(mut self, color: impl Into<Option<BootsierColors>>) -> Self {
match color.into() {
Some(color) => self.alter_prop(PropsOp::set_extra(EXTRA_COLOR, color)),
None => self.alter_prop(PropsOp::remove_extra(EXTRA_COLOR)),
};
self
}
}
// **< Button SETUP >*******************************************************************************
pub(crate) fn setup(button: &mut Button) {
button.alter_prop(PropsOp::replace_classes("button", "btn"));
// `Button::setup()` (core) ya ha traducido la intención con `Theme::intent_color()` -- aquí
// sólo queda cambiar el prefijo `button-`/`button-outline-` por el equivalente
// `btn-`/`btn-outline-` de Bootstrap, conservando el mismo nombre de color salvo que
// `with_color()` lo sobrescriba.
let override_color = button
.props()
.extra::<BootsierColors>(EXTRA_COLOR)
.ok()
.copied();
let (core_class, btn_class) = match button.style() {
Style::None => (String::new(), String::new()),
Style::Solid(intent) => {
let intent_color = BootsierColors::from(intent).as_str();
let color = override_color.map_or(intent_color, |color| color.as_str());
(
util::join!("button-", intent_color),
util::join!("btn-", color),
)
}
Style::Outline(intent) => {
let intent_color = BootsierColors::from(intent).as_str();
let color = override_color.map_or(intent_color, |color| color.as_str());
(
util::join!("button-outline-", intent_color),
util::join!("btn-outline-", color),
)
}
Style::Link => ("button-link".to_string(), "btn-link".to_string()),
};
if !core_class.is_empty() {
button.alter_prop(PropsOp::replace_classes(core_class, btn_class));
}
let (size_core, size_btn) = match button.size() {
Size::None => (String::new(), String::new()),
Size::Small => ("button-sm".to_string(), "btn-sm".to_string()),
Size::Large => ("button-lg".to_string(), "btn-lg".to_string()),
};
if !size_core.is_empty() {
button.alter_prop(PropsOp::replace_classes(size_core, size_btn));
}
// Renombra el vocabulario neutro para abrir/cerrar un `Dialog` (común a todos los temas, ver
// `base::component::dialog`) al que reconoce el JS de Bootstrap.
button.alter_prop(PropsOp::rename("data-dialog-toggle", "data-bs-toggle"));
button.alter_prop(PropsOp::rename("data-dialog-target", "data-bs-target"));
button.alter_prop(PropsOp::rename("data-dialog-dismiss", "data-bs-dismiss"));
if button.props().extra_or(EXTRA_ACTIVE, false) {
button.alter_prop(PropsOp::add_classes("active"));
button.alter_prop(PropsOp::set("aria-pressed", "true"));
}
if button.props().extra_or(EXTRA_FULL_WIDTH, false) {
button.alter_prop(PropsOp::add_classes("w-100"));
}
}

View file

@ -27,8 +27,8 @@ const EXTRA_WIDTH: &str = "bootsier.container.width";
/// let main = bs::Container::main()
/// .with_id("main-page")
/// .with_width(bs::container::Width::From(BreakPoint::LG))
/// .with_prop(PropsOp::add_classes(class::Bg::with(ThemeColor::Light)))
/// .with_prop(PropsOp::add_classes(class::Text::with(ThemeColor::Dark)))
/// .with_prop(PropsOp::add_classes(class::Bg::with(BootsierColors::Light)))
/// .with_prop(PropsOp::add_classes(class::Text::with(BootsierColors::Dark)))
/// .with_prop(PropsOp::add_classes(class::Border::with(ScaleSize::One)))
/// .with_prop(PropsOp::add_classes(class::Rounded::new()));
/// ```

View file

@ -0,0 +1,56 @@
//! Definiciones para crear diálogos modales ([`Dialog`]).
use pagetop::prelude::*;
pub use pagetop::base::component::Dialog;
// **< Dialog SETUP >*******************************************************************************
pub(crate) fn setup(dialog: &mut Dialog) {
dialog.alter_prop(PropsOp::prepend_classes("modal fade"));
}
// **< Dialog RENDER >******************************************************************************
pub(crate) async fn render(dialog: &Dialog, cx: &mut Context) -> Result<Markup, ComponentError> {
let body = dialog.body().render(cx).await;
let footer = dialog.footer().render(cx).await;
if body.is_empty() && footer.is_empty() {
return Ok(html! {});
}
let title = dialog.title().using(cx);
// `setup()` del componente garantiza que habrá un `id` antes de renderizar. Sin título no hay
// elemento que etiquete el diálogo, así que `aria-labelledby` se omite en vez de apuntar a un
// `id` inexistente.
let id_label = (!title.is_empty()).then(|| util::join!(dialog.id().unwrap(), "-label"));
Ok(html! {
div
(dialog.props())
tabindex="-1"
aria-hidden="true"
aria-labelledby=[id_label.as_deref()]
{
div class="modal-dialog" {
div class="modal-content" {
div class="modal-header" {
@if let Some(id_label) = &id_label {
h5 id=(id_label) class="modal-title" { (title) }
}
button
type="button"
class="btn-close"
data-bs-dismiss="modal"
aria-label=[Lc::l("dialog_close").lookup(cx)]
{}
}
div class="modal-body" { (body) }
@if !footer.is_empty() {
div class="modal-footer" { (footer) }
}
}
}
}
})
}

View file

@ -1,17 +1,245 @@
//! Definiciones para crear menús desplegables ([`Dropdown`]).
//!
//! Cada [`dropdown::Item`](crate::theme::bs::dropdown::Item) representa un elemento individual del
//! Cada [`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 [`Lc`](pagetop::locale::Lc).
//! configuración, y permiten incluir etiquetas localizables usando [`Lc`].
use pagetop::prelude::*;
use crate::LOCALES_BOOTSIER;
mod props;
pub use props::{AutoClose, Direction, MenuAlign, MenuPosition};
mod component;
pub use component::Dropdown;
pub use pagetop::base::component::Dropdown;
pub use pagetop::base::component::dropdown::{Item, ItemKind};
mod item;
pub use item::{Item, ItemKind};
const EXTRA_BUTTON_GROUPED: &str = "bootsier.dropdown.button_grouped";
const EXTRA_AUTO_CLOSE: &str = "bootsier.dropdown.auto_close";
const EXTRA_DIRECTION: &str = "bootsier.dropdown.direction";
const EXTRA_MENU_ALIGN: &str = "bootsier.dropdown.menu_align";
const EXTRA_MENU_POSITION: &str = "bootsier.dropdown.menu_position";
/// Extensión de Bootsier para [`Dropdown`].
///
/// Admite variaciones para el tamaño y el color del botón, y también para la dirección de
/// apertura, la alineación o la política de cierre del menú.
///
/// ```rust,no_run
/// use pagetop::prelude::*;
/// use pagetop_bootsier::theme::*;
///
/// let dd = bs::Dropdown::new()
/// .with_title(Lc::n("Menu"))
/// .with_button_size(button::Size::Small)
/// .with_button_style(button::Style::Solid(Intent::Neutral))
/// .with_auto_close(bs::dropdown::AutoClose::ClickableInside)
/// .with_direction(bs::dropdown::Direction::Dropend)
/// .with_item(bs::dropdown::Item::link(Lc::n("Home"), "/"))
/// .with_item(bs::dropdown::Item::link_blank(Lc::n("Doc"), "https://docs.rs"))
/// .with_item(bs::dropdown::Item::divider())
/// .with_item(bs::dropdown::Item::header(Lc::n("User session")))
/// .with_item(bs::dropdown::Item::button(Lc::n("Sign out")));
/// ```
pub trait DropdownBootsier {
/// Indica si el botón del menú está integrado en un grupo de botones.
#[builder_fn]
fn with_button_grouped(self, grouped: bool) -> Self;
/// Establece la política de cierre automático del menú desplegable.
#[builder_fn]
fn with_auto_close(self, auto_close: AutoClose) -> Self;
/// Establece la dirección de despliegue del menú.
#[builder_fn]
fn with_direction(self, direction: Direction) -> Self;
/// Configura la alineación horizontal (con posible comportamiento *responsive* adicional).
#[builder_fn]
fn with_menu_align(self, align: MenuAlign) -> Self;
/// Configura la posición del menú.
#[builder_fn]
fn with_menu_position(self, position: MenuPosition) -> Self;
}
impl DropdownBootsier for Dropdown {
#[builder_fn]
fn with_button_grouped(mut self, grouped: bool) -> Self {
self.alter_prop(PropsOp::set_extra(EXTRA_BUTTON_GROUPED, grouped));
self
}
#[builder_fn]
fn with_auto_close(mut self, auto_close: AutoClose) -> Self {
self.alter_prop(PropsOp::set_extra(EXTRA_AUTO_CLOSE, auto_close));
self
}
#[builder_fn]
fn with_direction(mut self, direction: Direction) -> Self {
self.alter_prop(PropsOp::set_extra(EXTRA_DIRECTION, direction));
self
}
#[builder_fn]
fn with_menu_align(mut self, align: MenuAlign) -> Self {
self.alter_prop(PropsOp::set_extra(EXTRA_MENU_ALIGN, align));
self
}
#[builder_fn]
fn with_menu_position(mut self, position: MenuPosition) -> Self {
self.alter_prop(PropsOp::set_extra(EXTRA_MENU_POSITION, position));
self
}
}
// **< Dropdown SETUP >*****************************************************************************
pub(crate) fn setup(dropdown: &mut Dropdown) {
let direction = dropdown
.props()
.extra_or(EXTRA_DIRECTION, Direction::default());
let grouped = dropdown.props().extra_or(EXTRA_BUTTON_GROUPED, false);
dropdown.alter_prop(PropsOp::replace_classes(
"dropdown",
direction.to_class(grouped),
));
}
// **< Dropdown RENDER >****************************************************************************
pub(crate) async fn render(
dropdown: &Dropdown,
cx: &mut Context,
) -> Result<Markup, ComponentError> {
// Si no hay elementos en el menú, no se prepara.
let items = dropdown.items().render(cx).await;
if items.is_empty() {
return Ok(html! {});
}
// Título opcional para el menú desplegable.
let title = dropdown.title().using(cx);
if title.is_empty() {
// Sin título: menú contextual estático, sin botón ni comportamiento de apertura/cierre.
return Ok(html! {
div (dropdown.props()) {
ul class="dropdown-menu" { (items) }
}
});
}
let button_size = dropdown.button_size();
let style = dropdown.button_style();
let auto_close = dropdown
.props()
.extra_or(EXTRA_AUTO_CLOSE, AutoClose::default());
let direction = dropdown
.props()
.extra_or(EXTRA_DIRECTION, Direction::default());
let menu_align = dropdown
.props()
.extra_or(EXTRA_MENU_ALIGN, MenuAlign::default());
let menu_position = dropdown
.props()
.extra_or(EXTRA_MENU_POSITION, MenuPosition::default());
let btn_base = {
let mut classes = String::from("btn");
match button_size {
button::Size::None => {}
button::Size::Small => classes.push_str(" btn-sm"),
button::Size::Large => classes.push_str(" btn-lg"),
}
match style {
button::Style::None => {}
button::Style::Solid(intent) => {
classes.push_str(" btn-");
classes.push_str(intent.color(cx));
}
button::Style::Outline(intent) => {
classes.push_str(" btn-outline-");
classes.push_str(intent.color(cx));
}
button::Style::Link => classes.push_str(" btn-link"),
}
classes
};
let offset = menu_position.data_offset();
let reference = menu_position.data_reference();
let auto_close = auto_close.opt_str();
let menu_classes = {
let mut classes = "dropdown-menu".to_string();
menu_align.push_to(&mut classes);
classes
};
Ok(html! {
div (dropdown.props()) {
// Renderizado en modo split (dos botones) o simple (un botón).
@if *dropdown.button_split() {
// Botón principal (acción/etiqueta).
@let btn = html! {
button
type="button"
class=(&btn_base)
{
(&title)
}
};
// Botón *toggle* que abre/cierra el menú asociado.
@let btn_toggle_classes =
util::join!(&btn_base, " dropdown-toggle dropdown-toggle-split");
@let btn_toggle = html! {
button
type="button"
class=(&btn_toggle_classes)
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" {
(Lc::t("dropdown_toggle", &LOCALES_BOOTSIER).using(cx))
}
}
};
// Orden según dirección (en `dropstart` el *toggle* se sitúa antes).
@match direction {
Direction::Dropstart => {
(btn_toggle)
ul class=(&menu_classes) { (items) }
(btn)
}
_ => {
(btn)
(btn_toggle)
ul class=(&menu_classes) { (items) }
}
}
} @else {
// Botón único con funcionalidad de *toggle*.
@let btn_toggle_classes = util::join!(&btn_base, " dropdown-toggle");
button
type="button"
class=(&btn_toggle_classes)
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) { (items) }
}
}
})
}

View file

@ -1,276 +0,0 @@
use pagetop::prelude::*;
use crate::LOCALES_BOOTSIER;
use crate::theme::*;
/// Componente para crear un **menú desplegable**.
///
/// Renderiza un botón (único o desdoblado, ver [`with_button_split()`](Self::with_button_split))
/// con un menú desplegable de elementos [`dropdown::Item`](crate::theme::bs::dropdown::Item), que
/// se muestra u oculta según la interacción del usuario. Admite variaciones para el tamaño y el
/// color del botón, también para la 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`](crate::theme::bs::Nav) (ver
/// [`nav::Item::dropdown()`](crate::theme::bs::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`](crate::theme::bs::Nav).
///
/// Si no contiene elementos, el componente **no se renderiza**.
///
/// # Ejemplo
///
/// ```rust,no_run
/// use pagetop::prelude::*;
/// use pagetop_bootsier::theme::*;
///
/// let dd = bs::Dropdown::new()
/// .with_title(Lc::n("Menu"))
/// .with_button_color(class::ButtonColor::solid(token::Color::Secondary))
/// .with_auto_close(bs::dropdown::AutoClose::ClickableInside)
/// .with_direction(bs::dropdown::Direction::Dropend)
/// .with_item(bs::dropdown::Item::link(Lc::n("Home"), "/"))
/// .with_item(bs::dropdown::Item::link_blank(Lc::n("Doc"), "https://docs.rs"))
/// .with_item(bs::dropdown::Item::divider())
/// .with_item(bs::dropdown::Item::header(Lc::n("User session")))
/// .with_item(bs::dropdown::Item::button(Lc::n("Sign out")));
/// ```
#[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Dropdown {
/// Devuelve identificador, clases CSS, atributos HTML y valores extra del componente.
props: Props,
/// Devuelve el título del menú desplegable.
title: Lc,
/// Devuelve el tamaño configurado del botón.
button_size: class::ButtonSize,
/// Devuelve el color/estilo configurado del botón.
button_color: class::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: bs::dropdown::AutoClose,
/// Devuelve la dirección de despliegue configurada.
direction: bs::dropdown::Direction,
/// Devuelve la configuración de alineación horizontal del menú desplegable.
menu_align: bs::dropdown::MenuAlign,
/// Devuelve la posición configurada para el menú desplegable.
menu_position: bs::dropdown::MenuPosition,
/// Devuelve la lista de elementos del menú.
items: Children,
}
#[async_trait]
impl Component for Dropdown {
fn new() -> Self {
Self::default()
}
fn id(&self) -> Option<String> {
self.props.get_id()
}
fn setup(&mut self, _cx: &Context) {
self.alter_prop(PropsOp::prepend_classes(
self.direction().to_class(*self.button_grouped()),
));
}
async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
// Si no hay elementos en el menú, no se prepara.
let items = self.items().render(cx).await;
if items.is_empty() {
return Ok(html! {});
}
// Título opcional para el menú desplegable.
let title = self.title().using(cx);
Ok(html! {
div (self.props()) {
@if !title.is_empty() {
@let btn_base = {
let mut classes = String::from("btn");
self.button_size().push_to(&mut classes);
self.button_color().push_to(&mut classes);
classes
};
@let pos = self.menu_position();
@let offset = pos.data_offset();
@let reference = pos.data_reference();
@let auto_close = self.auto_close().opt_str();
@let menu_classes = {
let mut classes = "dropdown-menu".to_string();
self.menu_align().push_to(&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_base)
{
(title)
}
};
// Botón *toggle* que abre/cierra el menú asociado.
@let btn_toggle_classes =
util::join!(&btn_base, " dropdown-toggle dropdown-toggle-split");
@let btn_toggle = html! {
button
type="button"
class=(&btn_toggle_classes)
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" {
(Lc::t("dropdown_toggle", &LOCALES_BOOTSIER).using(cx))
}
}
};
// Orden según dirección (en `dropstart` el *toggle* se sitúa antes).
@match self.direction() {
bs::dropdown::Direction::Dropstart => {
(btn_toggle)
ul class=(&menu_classes) { (items) }
(btn)
}
_ => {
(btn)
(btn_toggle)
ul class=(&menu_classes) { (items) }
}
}
} @else {
// Botón único con funcionalidad de *toggle*.
@let btn_toggle_classes = util::join!(&btn_base, " dropdown-toggle");
button
type="button"
class=(&btn_toggle_classes)
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) { (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 del componente; igual a `with_prop(PropsOp::set_id(id))`.
#[builder_fn]
pub fn with_id(mut self, id: impl Into<CowStr>) -> Self {
self.props.alter_id(id);
self
}
/// Modifica identificador, clases CSS, atributos HTML o valores extra del componente.
#[builder_fn]
pub fn with_prop(mut self, op: PropsOp) -> Self {
self.props.alter_prop(op);
self
}
/// Establece el título del menú desplegable.
#[builder_fn]
pub fn with_title(mut self, title: Lc) -> Self {
self.title = title;
self
}
/// Ajusta el tamaño del botón.
#[builder_fn]
pub fn with_button_size(mut self, size: class::ButtonSize) -> Self {
self.button_size = size;
self
}
/// Define el color/estilo del botón.
#[builder_fn]
pub fn with_button_color(mut self, color: class::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: bs::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: bs::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: bs::dropdown::MenuAlign) -> Self {
self.menu_align = align;
self
}
/// Configura la posición del menú.
#[builder_fn]
pub fn with_menu_position(mut self, position: bs::dropdown::MenuPosition) -> Self {
self.menu_position = position;
self
}
/// Añade un nuevo elemento al menú o modifica la lista de elementos del menú con una operación
/// [`ChildOp`].
///
/// # Ejemplo
///
/// ```rust,ignore
/// dropdown.with_item(dropdown::Item::link("Opción", "/ruta"));
/// dropdown.with_item(ChildOp::AddMany(vec![
/// dropdown::Item::link(...).into(),
/// dropdown::Item::divider().into(),
/// dropdown::Item::link(...).into(),
/// ]));
/// ```
#[builder_fn]
pub fn with_item(mut self, op: impl Into<ChildOp>) -> Self {
self.items.alter_child(op.into());
self
}
}

View file

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

View file

@ -112,22 +112,22 @@ impl Direction {
/// Alineación horizontal del menú desplegable [`Dropdown`](crate::theme::bs::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`](token::BreakPoint)).
/// 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(token::BreakPoint),
StartAt(BreakPoint),
/// Alineación al inicio por defecto, y al final a partir de un punto de ruptura válido.
StartAndEnd(token::BreakPoint),
StartAndEnd(BreakPoint),
/// Alineación al final.
End,
/// Alineación al final a partir del punto de ruptura indicado.
EndAt(token::BreakPoint),
EndAt(BreakPoint),
/// Alineación al final por defecto, y al inicio a partir de un punto de ruptura válido.
EndAndStart(token::BreakPoint),
EndAndStart(BreakPoint),
}
impl MenuAlign {
@ -145,13 +145,13 @@ impl MenuAlign {
// `dropdown-menu-start` + `dropdown-menu-{bp}-end`
Self::StartAndEnd(bp) => {
token::BreakPoint::None.push_to(classes, "dropdown-menu", "start");
BreakPoint::None.push_to(classes, "dropdown-menu", "start");
bp.push_to(classes, "dropdown-menu", "end");
}
// `dropdown-menu-end`
Self::End => {
token::BreakPoint::None.push_to(classes, "dropdown-menu", "end");
BreakPoint::None.push_to(classes, "dropdown-menu", "end");
}
// `dropdown-menu-{bp}-end`
@ -161,7 +161,7 @@ impl MenuAlign {
// `dropdown-menu-end` + `dropdown-menu-{bp}-start`
Self::EndAndStart(bp) => {
token::BreakPoint::None.push_to(classes, "dropdown-menu", "end");
BreakPoint::None.push_to(classes, "dropdown-menu", "end");
bp.push_to(classes, "dropdown-menu", "start");
}
}

View file

@ -25,6 +25,8 @@ pub use textarea::Textarea;
#[doc(inline)]
pub use textarea::TextareaBootsier;
pub use pagetop::base::component::form::Number;
pub use pagetop::base::component::form::Range;
pub use pagetop::base::component::form::Hidden;

View file

@ -18,7 +18,7 @@ pub struct Icon {
/// Devuelve identificador, clases CSS, atributos HTML y valores extra del componente.
props: Props,
icon_kind: IconKind,
aria_label: AttrL10n,
aria_label: AttrLc,
}
#[async_trait]
@ -124,7 +124,7 @@ impl Icon {
}
#[builder_fn]
pub fn with_aria_label(mut self, label: L10n) -> Self {
pub fn with_aria_label(mut self, label: Lc) -> Self {
self.aria_label.alter_value(label);
self
}

View file

@ -0,0 +1,4 @@
pub(crate) mod region;
pub use region::BootsierRegions;
pub(crate) mod template;

View file

@ -0,0 +1,113 @@
use pagetop::prelude::*;
use crate::LOCALES_BOOTSIER;
/// Regiones específicas de la shell de Bootsier.
pub enum BootsierRegions {
/// Barra lateral de navegación (`app-sidebar` de AdminLTE).
///
/// Los componentes registrados aquí se renderizan directamente dentro del
/// `<ul class="sidebar-menu">`, sin el `<div>` envolvente que añade
/// [`Region`](pagetop::base::component::layout::Region) por defecto --
/// [`Bootsier`](crate::Bootsier) intercepta este componente en `handle_component()` para
/// renderizarlo así. Los elementos esperados son
/// [`bs::sidebar::Item`](crate::theme::bs::sidebar::Item) y
/// [`bs::sidebar::Section`](crate::theme::bs::sidebar::Section).
///
/// Sólo se renderiza en la plantilla de administración (`CoreTemplates::Admin`), que se
/// activa creando la página con [`Page::admin()`](pagetop::response::Page::admin). Registrar
/// elementos aquí no tiene efecto en páginas creadas con `Page::new()`.
///
/// # Registro global
///
/// Para que los ítems aparezcan en todas las páginas con shell, regístralos durante
/// el arranque de la aplicación con [`InRegion::Global`]:
///
/// ```rust,no_run
/// use pagetop::prelude::*;
/// use pagetop_bootsier::theme::bs::{BootsierRegions, sidebar};
///
/// InRegion::Global(&BootsierRegions::Sidebar)
/// .add(sidebar::Section::titled(Lc::n("Administración")))
/// .add(sidebar::Item::link(Lc::n("Usuarios"), "/users", "people"))
/// .add(sidebar::Item::link(Lc::n("Roles"), "/roles", "shield-check"));
/// ```
///
/// # Registro por página
///
/// Para añadir ítems sólo en una página concreta, usa [`Contextual::with_child_in`]:
///
/// ```rust,no_run
/// use pagetop::prelude::*;
/// use pagetop_bootsier::theme::bs::{BootsierRegions, sidebar};
///
/// async fn dashboard(request: HttpRequest) -> Result<Markup, ErrorPage> {
/// Page::admin(request)
/// .with_child_in(
/// &BootsierRegions::Sidebar,
/// sidebar::Item::link(Lc::n("Panel"), "/dashboard", "grid"),
/// )
/// .render().await
/// }
/// ```
Sidebar,
/// Elementos adicionales en la barra de navegación superior (`app-header`).
///
/// Los componentes registrados aquí se renderizan en el lado derecho de la barra superior,
/// a continuación de los controles fijos (pantalla completa y selector de tema). Los elementos
/// esperados son típicamente ítems de navegación (`<li class="nav-item">`).
///
/// Esta región es opcional: si no tiene contenido, no añade ningún marcado al navbar.
///
/// # Registro global
///
/// ```rust,no_run
/// use pagetop::prelude::*;
/// use pagetop_bootsier::theme::bs::BootsierRegions;
///
/// InRegion::Global(&BootsierRegions::Navbar)
/// .add(Html::with(|_| html! {
/// li class="nav-item" {
/// a class="nav-link" href="/logout" { "Cerrar sesión" }
/// }
/// }));
/// ```
Navbar,
}
impl RegionName for BootsierRegions {
#[inline]
fn name(&self) -> &'static str {
match self {
Self::Sidebar => "bootsier-sidebar",
Self::Navbar => "bootsier-navbar",
}
}
#[inline]
fn label(&self) -> Lc {
match self {
Self::Sidebar => Lc::t("region_sidebar", &LOCALES_BOOTSIER),
Self::Navbar => Lc::t("region_navbar", &LOCALES_BOOTSIER),
}
}
}
// **< Region RENDER >******************************************************************************
// Regiones de Bootsier: se renderizan sin el `<div role="region">` envolvente que aplica
// `layout::Region::prepare()` por defecto -- sus elementos van directamente dentro del contenedor
// que los gestiona (sidebar-menu o navbar-nav). Devuelve `None` si `component` no envuelve una
// `BootsierRegions`, dejando que el resto de la cadena de temas (o el propio componente) resuelva
// el renderizado por defecto.
pub(crate) async fn render(
component: &layout::Region,
cx: &mut Context,
) -> Option<Result<Markup, ComponentError>> {
match component.region().downcast_ref::<BootsierRegions>()? {
BootsierRegions::Sidebar | BootsierRegions::Navbar => {
Some(Ok(cx.render_region(component.region()).await))
}
}
}

View file

@ -0,0 +1,170 @@
use pagetop::prelude::*;
use crate::config;
use crate::theme::{ContainerBootsier, bs};
use crate::{ADMINLTE_VERSION, LOCALES_BOOTSIER};
// Regiones de Bootsier: se renderiza sin el `<div role="region">` envolvente que aplica
// `layout::Template::prepare()` por defecto -- delega en `render_standard()`/`render_admin()`
// según la variante de `CoreTemplates` que envuelva el componente. Devuelve `None` si
// `component` no envuelve una `CoreTemplates`, dejando que el resto de la cadena de temas (o
// el propio componente) resuelva el renderizado por defecto.
pub(crate) async fn render(
component: &layout::Template,
cx: &mut Context,
) -> Option<Result<Markup, ComponentError>> {
match component.template().downcast_ref::<CoreTemplates>()? {
CoreTemplates::Standard => Some(Ok(render_standard(cx).await)),
CoreTemplates::Admin => Some(Ok(render_admin(cx).await)),
}
}
// Layout estándar: `CoreRegions::Header`, `CoreRegions::Aside`, `CoreRegions::Content` y
// `CoreRegions::Footer` envueltos en un contenedor de ancho configurable.
async fn render_standard(cx: &mut Context) -> Markup {
bs::Container::new()
.with_prop(PropsOp::add_classes("container-wrapper"))
.with_width(bs::container::Width::FluidMax(
config::SETTINGS.bootsier.max_width,
))
.with_child(layout::Region::header())
.with_child(layout::Region::aside())
.with_child(layout::Region::default())
.with_child(layout::Region::footer())
.render(cx)
.await
}
// Layout de administración: shell de AdminLTE 4 (barra superior, barra lateral con el contenido
// de BootsierRegions::Sidebar, área de contenido y pie).
async fn render_admin(cx: &mut Context) -> Markup {
cx.alter_body_props(PropsOp::add_classes(
"layout-fixed sidebar-expand-lg bg-body-tertiary",
));
cx.alter_assets(AssetsOp::AddJavaScript(
JavaScript::defer("/bootsier/js/bootsier.shell.min.js")
.with_version(ADMINLTE_VERSION)
.with_weight(-88),
));
// `CoreRegions::Aside` es una región neutra del core: la usa `pagetop-admin` para su menú de
// secciones sin que este tema tenga que depender de él. `BootsierRegions::Sidebar` sigue
// disponible para que cualquier extensión añada elementos propios a mano.
let aside = layout::Region::of(&CoreRegions::Aside).render(cx).await;
let sidebar = layout::Region::of(&bs::BootsierRegions::Sidebar)
.render(cx)
.await;
render_shell(cx, html! { (aside) (sidebar) }).await
}
async fn render_shell(cx: &mut Context, sidebar: Markup) -> Markup {
let navbar = layout::Region::of(&bs::BootsierRegions::Navbar)
.render(cx)
.await;
let content = layout::Region::default().render(cx).await;
let footer = layout::Region::footer().render(cx).await;
html! {
div class="app-wrapper" {
// Barra de navegación superior (app-header)
nav class="app-header navbar navbar-expand bg-body" {
div class="container-fluid" {
ul class="navbar-nav" {
li class="nav-item" {
a class="nav-link" data-lte-toggle="sidebar" href="#" role="button" {
i class="bi bi-list" {}
}
}
}
ul class="navbar-nav ms-auto" {
// Botón de pantalla completa
li class="nav-item" {
a class="nav-link" href="#" data-lte-toggle="fullscreen"
aria-label=[Lc::t("shell_fullscreen", &LOCALES_BOOTSIER).lookup(cx)]
{
i data-lte-icon="maximize" class="bi bi-fullscreen" {}
i data-lte-icon="minimize" class="bi bi-fullscreen-exit d-none" {}
}
}
// Selector de modo de color (claro / oscuro / automático)
li class="nav-item dropdown" {
a class="nav-link" href="#" id="bd-theme"
data-bs-toggle="dropdown" aria-expanded="false"
aria-label=[Lc::t("shell_theme_toggle", &LOCALES_BOOTSIER).lookup(cx)]
{
i class="bi bi-sun-fill" data-lte-theme-icon="light" {}
i class="bi bi-moon-fill d-none" data-lte-theme-icon="dark" {}
i class="bi bi-circle-half d-none" data-lte-theme-icon="auto" {}
}
ul class="dropdown-menu dropdown-menu-end" aria-labelledby="bd-theme"
style="--bs-dropdown-min-width: 8rem"
{
li {
button type="button"
class="dropdown-item d-flex align-items-center"
data-bs-theme-value="light"
aria-pressed="false"
{
i class="bi bi-sun-fill me-2" {}
(Lc::t("shell_theme_light", &LOCALES_BOOTSIER).using(cx))
i class="bi bi-check-lg ms-auto d-none" {}
}
}
li {
button type="button"
class="dropdown-item d-flex align-items-center"
data-bs-theme-value="dark"
aria-pressed="false"
{
i class="bi bi-moon-fill me-2" {}
(Lc::t("shell_theme_dark", &LOCALES_BOOTSIER).using(cx))
i class="bi bi-check-lg ms-auto d-none" {}
}
}
li {
button type="button"
class="dropdown-item d-flex align-items-center"
data-bs-theme-value="auto"
aria-pressed="false"
{
i class="bi bi-circle-half me-2" {}
(Lc::t("shell_theme_auto", &LOCALES_BOOTSIER).using(cx))
i class="bi bi-check-lg ms-auto d-none" {}
}
}
}
}
(navbar)
}
}
}
// Barra lateral (app-sidebar)
aside class="app-sidebar bg-body-secondary shadow" data-bs-theme="dark" {
div class="sidebar-brand" {
a href="/" class="brand-link" {
span class="brand-text fw-light" { (global::SETTINGS.app.name) }
}
}
div class="sidebar-wrapper" {
nav class="mt-2" {
ul class="nav sidebar-menu flex-column"
data-lte-toggle="treeview" role="menu"
{
(sidebar)
}
}
}
}
// Área de contenido principal (app-main)
main class="app-main" {
div class="app-content" {
div class="container-fluid" {
(content)
}
}
}
// Pie de página (app-footer)
footer class="app-footer" {
(footer)
}
}
}
}

View file

@ -1,17 +1,194 @@
//! Definiciones para crear menús ([`Nav`]).
//!
//! Cada [`nav::Item`](crate::theme::bs::nav::Item) representa un elemento individual del menú
//! Cada [`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::bs::Dropdown).
//! desplegables [`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 [`Lc`](pagetop::locale::Lc).
//! configuración, y permiten incluir etiquetas localizables usando [`Lc`].
use pagetop::prelude::*;
use crate::LOCALES_BOOTSIER;
mod props;
pub use props::{Kind, Layout};
pub use props::Kind;
mod component;
pub use component::Nav;
pub use pagetop::base::component::Nav;
pub use pagetop::base::component::nav::{Item, ItemKind};
mod item;
pub use item::{Item, ItemKind};
const EXTRA_KIND: &str = "bootsier.nav.kind";
// Marca interna (nunca expuesta en `NavBootsier`) que `theme::bs::navbar::item` fija sobre el clon
// de un `Nav` embebido en una `Navbar`, para que use `navbar-nav` en vez de `nav` como clase base.
pub(crate) const EXTRA_IN_NAVBAR: &str = "bootsier.nav.in_navbar";
/// Extensión de Bootsier para [`Nav`].
///
/// Permite establecer el estilo visual usando el método [`with_kind()`](Self::with_kind).
///
/// ```rust,no_run
/// use pagetop::prelude::*;
/// use pagetop_bootsier::theme::*;
///
/// let nav = bs::Nav::new()
/// .with_kind(bs::nav::Kind::Pills)
/// .with_layout(nav::Layout::End)
/// .with_item(bs::nav::Item::link(Lc::n("Home"), "/"))
/// .with_item(bs::nav::Item::link_blank(Lc::n("External"), "https://docs.rs"))
/// .with_item(bs::nav::Item::dropdown(
/// bs::Dropdown::new()
/// .with_title(Lc::n("Options"))
/// .with_item(TypedOp::AddMany(vec![
/// bs::dropdown::Item::link(Lc::n("Action"), "/action"),
/// bs::dropdown::Item::link(Lc::n("Another"), "/another"),
/// ])),
/// ))
/// .with_item(bs::nav::Item::link_disabled(Lc::n("Disabled"), "#"));
/// ```
pub trait NavBootsier {
/// Cambia el estilo del menú (*Tabs*, *Pills*, *Underline* o *Default*).
#[builder_fn]
fn with_kind(self, kind: Kind) -> Self;
}
impl NavBootsier for Nav {
#[builder_fn]
fn with_kind(mut self, kind: Kind) -> Self {
self.alter_prop(PropsOp::set_extra(EXTRA_KIND, kind));
self
}
}
// **< Nav SETUP >**********************************************************************************
pub(crate) fn setup(nav: &mut Nav) {
let kind = nav.props().extra_or(EXTRA_KIND, Kind::default());
let in_navbar = nav.props().extra_or(EXTRA_IN_NAVBAR, false);
let mut classes = if in_navbar { "navbar-nav" } else { "nav" }.to_string();
kind.push_to(&mut classes);
layout_class(*nav.nav_layout(), &mut classes);
nav.alter_prop(PropsOp::prepend_classes(classes));
}
// Traduce el `nav::Layout` semántico de base al vocabulario de utilidades de Bootstrap.
fn layout_class(layout: nav::Layout, classes: &mut String) {
let class = match layout {
nav::Layout::Default => "",
nav::Layout::Start => "justify-content-start",
nav::Layout::Center => "justify-content-center",
nav::Layout::End => "justify-content-end",
nav::Layout::Vertical => "flex-column",
nav::Layout::Fill => "nav-fill",
nav::Layout::Justified => "nav-justified",
};
if class.is_empty() {
return;
}
if !classes.is_empty() {
classes.push(' ');
}
classes.push_str(class);
}
// **< Item RENDER >********************************************************************************
// Idéntico a `nav::Item::prepare()` salvo el disparador del desplegable, que necesita
// `data-bs-toggle="dropdown"` para que el JS de Bootstrap lo reconozca (la clase `dropdown-toggle`
// por sí sola sólo aporta el estilo, no la inicialización).
pub(crate) async fn item_render(item: &Item, cx: &mut Context) -> Result<Markup, ComponentError> {
Ok(match item.item_kind() {
ItemKind::Void => html! {},
ItemKind::Label(label) => html! {
li (item.props()) {
span class="nav-link disabled" aria-disabled="true" {
(label.using(cx))
}
}
},
ItemKind::Link {
label,
route,
blank,
disabled,
} => {
let route_link = route.resolve(cx);
let current_path = cx.request().map(|request| request.path());
let is_current = item
.active_override()
.copied()
.unwrap_or(!*disabled && (current_path == Some(route_link.path())));
let mut classes = "nav-link".to_string();
if is_current {
classes.push_str(" active");
}
if *disabled {
classes.push_str(" disabled");
}
let href = (!*disabled).then_some(route_link);
let target = (!*disabled && *blank).then_some("_blank");
let rel = (!*disabled && *blank).then_some("noopener noreferrer");
let aria_current = (href.is_some() && is_current).then_some("page");
let aria_disabled = (*disabled).then_some("true");
html! {
li (item.props()) {
a
class=(classes)
href=[href]
target=[target]
rel=[rel]
aria-current=[aria_current]
aria-disabled=[aria_disabled]
{
(label.using(cx))
}
}
}
}
ItemKind::Html(html) => html! {
li (item.props()) {
(html.render(cx).await)
}
},
ItemKind::Dropdown(menu) => {
if let Some(dd) = menu.get() {
let items = dd.items().render(cx).await;
if items.is_empty() {
return Ok(html! {});
}
let title = dd.title().lookup(cx).unwrap_or_else(|| {
Lc::t("dropdown", &LOCALES_BOOTSIER)
.lookup(cx)
.unwrap_or_else(|| "Dropdown".to_string())
});
html! {
li (item.props()) {
a
class="nav-link dropdown-toggle"
data-bs-toggle="dropdown"
href="#"
role="button"
aria-haspopup="true"
aria-expanded="false"
{
(title)
}
ul class="dropdown-menu" {
(items)
}
}
}
} else {
html! {}
}
}
})
}

View file

@ -1,144 +0,0 @@
use pagetop::prelude::*;
use crate::theme::*;
/// Componente para crear un **menú**.
///
/// Presenta un menú con una lista de elementos usando una vista básica, o alguna de sus variantes
/// ([`nav::Kind`](crate::theme::bs::nav::Kind)) como *pestañas* (`Tabs`), *botones* (`Pills`) o
/// *subrayado* (`Underline`).
/// También permite controlar su distribución y orientación
/// ([`nav::Layout`](crate::theme::bs::nav::Layout)).
///
/// Si no contiene elementos, el componente **no se renderiza**.
///
/// # Ejemplo
///
/// ```rust,no_run
/// use pagetop::prelude::*;
/// use pagetop_bootsier::theme::*;
///
/// let nav = bs::Nav::tabs()
/// .with_layout(bs::nav::Layout::End)
/// .with_item(bs::nav::Item::link(Lc::n("Home"), "/"))
/// .with_item(bs::nav::Item::link_blank(Lc::n("External"), "https://docs.rs"))
/// .with_item(bs::nav::Item::dropdown(
/// bs::Dropdown::new()
/// .with_title(Lc::n("Options"))
/// .with_item(ChildOp::AddMany(vec![
/// bs::dropdown::Item::link(Lc::n("Action"), "/action").into(),
/// bs::dropdown::Item::link(Lc::n("Another"), "/another").into(),
/// ])),
/// ))
/// .with_item(bs::nav::Item::link_disabled(Lc::n("Disabled"), "#"));
/// ```
#[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Nav {
/// Devuelve identificador, clases CSS, atributos HTML y valores extra del componente.
props: Props,
/// Devuelve el estilo visual seleccionado.
nav_kind: bs::nav::Kind,
/// Devuelve la distribución y orientación seleccionada.
nav_layout: bs::nav::Layout,
/// Devuelve la lista de elementos del menú.
items: Children,
}
#[async_trait]
impl Component for Nav {
fn new() -> Self {
Self::default()
}
fn id(&self) -> Option<String> {
self.props.get_id()
}
fn setup(&mut self, _cx: &Context) {
// Clases CSS por defecto para el menú, según el estilo y la distribución seleccionados.
self.alter_prop(PropsOp::prepend_classes({
let mut classes = "nav".to_string();
self.nav_kind().push_to(&mut classes);
self.nav_layout().push_to(&mut classes);
classes
}));
}
async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
let items = self.items().render(cx).await;
if items.is_empty() {
return Ok(html! {});
}
Ok(html! {
ul (self.props()) {
(items)
}
})
}
}
impl Nav {
/// Crea un `Nav` usando pestañas para los elementos (*Tabs*).
pub fn tabs() -> Self {
Self::default().with_kind(bs::nav::Kind::Tabs)
}
/// Crea un `Nav` usando botones para los elementos (*Pills*).
pub fn pills() -> Self {
Self::default().with_kind(bs::nav::Kind::Pills)
}
/// Crea un `Nav` usando elementos subrayados (*Underline*).
pub fn underline() -> Self {
Self::default().with_kind(bs::nav::Kind::Underline)
}
// **< Nav BUILDER >****************************************************************************
/// Establece el identificador único del componente; igual a `with_prop(PropsOp::set_id(id))`.
#[builder_fn]
pub fn with_id(mut self, id: impl Into<CowStr>) -> Self {
self.props.alter_id(id);
self
}
/// Modifica identificador, clases CSS, atributos HTML o valores extra del componente.
#[builder_fn]
pub fn with_prop(mut self, op: PropsOp) -> Self {
self.props.alter_prop(op);
self
}
/// Cambia el estilo del menú (*Tabs*, *Pills*, *Underline* o *Default*).
#[builder_fn]
pub fn with_kind(mut self, kind: bs::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: bs::nav::Layout) -> Self {
self.nav_layout = layout;
self
}
/// Añade un nuevo elemento al menú o modifica la lista de elementos del menú con una operación
/// [`ChildOp`].
///
/// # Ejemplo
///
/// ```rust,ignore
/// nav.with_item(nav::Item::link("Inicio", "/"));
/// nav.with_item(ChildOp::AddMany(vec![
/// nav::Item::link(...).into(),
/// nav::Item::link_disabled(...).into(),
/// ]));
/// ```
#[builder_fn]
pub fn with_item(mut self, op: impl Into<ChildOp>) -> Self {
self.items.alter_child(op.into());
self
}
}

View file

@ -1,279 +0,0 @@
use pagetop::prelude::*;
use crate::LOCALES_BOOTSIER;
use crate::theme::*;
// **< ItemKind >***********************************************************************************
/// Tipos de [`nav::Item`](crate::theme::bs::nav::Item) disponibles en un menú
/// [`Nav`](crate::theme::bs::Nav).
///
/// Define internamente la naturaleza del elemento y su comportamiento al mostrarse o interactuar
/// con él.
#[derive(AutoDefault, Clone, Debug)]
pub enum ItemKind {
/// Elemento vacío, no produce salida.
#[default]
Void,
/// Etiqueta sin comportamiento interactivo.
Label(Lc),
/// Elemento de navegación basado en una [`RoutePath`] dinámica resuelta por una [`Route`].
/// Opcionalmente, puede abrirse en una nueva ventana y estar inicialmente deshabilitado.
Link {
label: Lc,
route: Route,
blank: bool,
disabled: bool,
},
/// Contenido HTML arbitrario. El componente [`Html`] se renderiza tal cual como elemento del
/// menú, sin añadir ningún comportamiento de navegación adicional.
Html(Embed<Html>),
/// Elemento que despliega un menú [`Dropdown`](crate::theme::bs::Dropdown).
Dropdown(Embed<bs::Dropdown>),
}
impl ItemKind {
const ITEM: &str = "nav-item";
const DROPDOWN: &str = "nav-item dropdown";
/// Devuelve las clases base asociadas al tipo de elemento.
#[inline]
pub const fn as_str(&self) -> &'static str {
match self {
Self::Void => "",
Self::Dropdown(_) => Self::DROPDOWN,
_ => Self::ITEM,
}
}
}
// **< Item >***************************************************************************************
/// Representa un **elemento individual** de un menú [`Nav`](crate::theme::bs::Nav).
///
/// Cada instancia de [`nav::Item`](crate::theme::bs::nav::Item) se traduce en un componente visible que
/// puede comportarse como texto, enlace, contenido HTML o menú desplegable, según su [`ItemKind`].
///
/// Permite definir el identificador, las clases de estilo adicionales y el tipo de interacción
/// asociada, manteniendo una interfaz común para renderizar todos los elementos del menú.
#[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Item {
/// Devuelve identificador, clases CSS, atributos HTML y valores extra del componente.
props: Props,
/// Devuelve el tipo de elemento representado.
item_kind: ItemKind,
}
#[async_trait]
impl Component for Item {
fn new() -> Self {
Self::default()
}
fn id(&self) -> Option<String> {
self.props.get_id()
}
fn setup(&mut self, _cx: &Context) {
self.alter_prop(PropsOp::prepend_classes(self.item_kind().as_str()));
}
async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
Ok(match self.item_kind() {
ItemKind::Void => html! {},
ItemKind::Label(label) => html! {
li (self.props()) {
span class="nav-link disabled" aria-disabled="true" {
(label.using(cx))
}
}
},
ItemKind::Link {
label,
route,
blank,
disabled,
} => {
let route_link = route.resolve(cx);
let current_path = cx.request().map(|request| request.path());
let is_current = !*disabled && (current_path == Some(route_link.path()));
let mut classes = "nav-link".to_string();
if is_current {
classes.push_str(" active");
}
if *disabled {
classes.push_str(" disabled");
}
let href = (!*disabled).then_some(route_link);
let target = (!*disabled && *blank).then_some("_blank");
let rel = (!*disabled && *blank).then_some("noopener noreferrer");
let aria_current = (href.is_some() && is_current).then_some("page");
let aria_disabled = (*disabled).then_some("true");
html! {
li (self.props()) {
a
class=(classes)
href=[href]
target=[target]
rel=[rel]
aria-current=[aria_current]
aria-disabled=[aria_disabled]
{
(label.using(cx))
}
}
}
}
ItemKind::Html(html) => html! {
li (self.props()) {
(html.render(cx).await)
}
},
ItemKind::Dropdown(menu) => {
if let Some(dd) = menu.get() {
let items = dd.items().render(cx).await;
if items.is_empty() {
return Ok(html! {});
}
let title = dd.title().lookup(cx).unwrap_or_else(|| {
Lc::t("dropdown", &LOCALES_BOOTSIER)
.lookup(cx)
.unwrap_or_else(|| "Dropdown".to_string())
});
html! {
li (self.props()) {
a
class="nav-link dropdown-toggle"
data-bs-toggle="dropdown"
href="#"
role="button"
aria-expanded="false"
{
(title)
}
ul class="dropdown-menu" {
(items)
}
}
}
} else {
html! {}
}
}
})
}
}
impl Item {
/// Crea un elemento de tipo texto, mostrado sin interacción.
pub fn label(label: Lc) -> Self {
Self {
item_kind: ItemKind::Label(label),
..Default::default()
}
}
/// Crea un enlace para la navegación.
///
/// La ruta se obtiene invocando [`Route::resolve()`], que devuelve dinámicamente una
/// [`RoutePath`] en función del [`Context`]. El enlace se marca como `active` si la ruta actual
/// del *request* coincide con la ruta de destino (devuelta por `RoutePath::path`).
pub fn link(label: Lc, route: impl Into<Route>) -> Self {
Self {
item_kind: ItemKind::Link {
label,
route: route.into(),
blank: false,
disabled: false,
},
..Default::default()
}
}
/// Crea un enlace deshabilitado que no permite la interacción.
pub fn link_disabled(label: Lc, route: impl Into<Route>) -> Self {
Self {
item_kind: ItemKind::Link {
label,
route: route.into(),
blank: false,
disabled: true,
},
..Default::default()
}
}
/// Crea un enlace que se abre en una nueva ventana o pestaña.
pub fn link_blank(label: Lc, route: impl Into<Route>) -> Self {
Self {
item_kind: ItemKind::Link {
label,
route: route.into(),
blank: true,
disabled: false,
},
..Default::default()
}
}
/// Crea un enlace inicialmente deshabilitado que se abriría en una nueva ventana.
pub fn link_blank_disabled(label: Lc, route: impl Into<Route>) -> Self {
Self {
item_kind: ItemKind::Link {
label,
route: route.into(),
blank: true,
disabled: true,
},
..Default::default()
}
}
/// Crea un elemento con contenido HTML arbitrario.
///
/// El contenido se renderiza tal cual lo devuelve el componente [`Html`], dentro de un `<li>`
/// con las clases de navegación asociadas a [`Item`].
pub fn html(html: Html) -> Self {
Self {
item_kind: ItemKind::Html(Embed::with(html)),
..Default::default()
}
}
/// Crea un elemento de navegación que contiene un menú desplegable
/// [`Dropdown`](crate::theme::bs::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`](crate::theme::bs::Dropdown); el resto de propiedades
/// del componente no afectarán a su representación en [`Nav`](crate::theme::bs::Nav).
pub fn dropdown(menu: bs::Dropdown) -> Self {
Self {
item_kind: ItemKind::Dropdown(Embed::with(menu)),
..Default::default()
}
}
// **< Item BUILDER >***************************************************************************
/// Establece el identificador único del componente; igual a `with_prop(PropsOp::set_id(id))`.
#[builder_fn]
pub fn with_id(mut self, id: impl Into<CowStr>) -> Self {
self.props.alter_id(id);
self
}
/// Modifica identificador, clases CSS, atributos HTML o valores extra del componente.
#[builder_fn]
pub fn with_prop(mut self, op: PropsOp) -> Self {
self.props.alter_prop(op);
self
}
}

View file

@ -53,69 +53,3 @@ impl Kind {
class
}
}
// **< Layout >*************************************************************************************
/// Distribución y orientación de un menú [`Nav`](crate::theme::bs::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]
pub 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 fn push_to(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ú.
pub fn to_class(self) -> String {
let mut class = String::new();
self.push_to(&mut class);
class
}
}

View file

@ -10,11 +10,14 @@
mod props;
pub use props::{Layout, Position};
mod brand;
pub use brand::Brand;
pub use super::Brand;
pub use pagetop::base::component::Navbar;
pub use pagetop::base::component::navbar::Item;
mod component;
pub use component::Navbar;
pub use component::NavbarBootsier;
pub(crate) use component::{render, setup};
mod item;
pub use item::Item;
pub(crate) use item::render as item_render;

View file

@ -6,14 +6,18 @@ use crate::theme::*;
const TOGGLE_COLLAPSE: &str = "collapse";
const TOGGLE_OFFCANVAS: &str = "offcanvas";
/// Componente para crear una **barra de navegación**.
const EXTRA_LAYOUT: &str = "bootsier.navbar.layout";
const EXTRA_POSITION: &str = "bootsier.navbar.position";
const EXTRA_EXPAND: &str = "bootsier.navbar.expand";
/// Extensión de Bootsier para [`Navbar`](crate::theme::bs::Navbar).
///
/// Permite mostrar enlaces, menús y una marca de identidad en distintas disposiciones (simples, con
/// botón de despliegue o dentro de un [`Offcanvas`](crate::theme::bs::Offcanvas)), controladas por
/// [`navbar::Layout`](crate::theme::bs::navbar::Layout). También puede fijarse en la parte superior
/// o inferior del documento mediante [`navbar::Position`](crate::theme::bs::navbar::Position).
///
/// Si no contiene elementos, el componente **no se renderiza**.
/// o inferior del documento mediante [`navbar::Position`](crate::theme::bs::navbar::Position), y
/// definir a partir de qué punto de ruptura deja de colapsar con
/// [`with_expand()`](Self::with_expand).
///
/// # Ejemplos
///
@ -52,9 +56,9 @@ const TOGGLE_OFFCANVAS: &str = "offcanvas";
/// ```rust,no_run
/// # use pagetop::prelude::*;
/// # use pagetop_bootsier::theme::*;
/// let brand = bs::navbar::Brand::new()
/// let brand = Brand::new()
/// .with_title(Lc::n("PageTop"))
/// .with_route(Some("/".into()));
/// .with_route(Route::from("/"));
///
/// let navbar = bs::Navbar::brand_left(brand)
/// .with_item(bs::navbar::Item::nav(
@ -79,14 +83,15 @@ const TOGGLE_OFFCANVAS: &str = "offcanvas";
/// ```rust,no_run
/// # use pagetop::prelude::*;
/// # use pagetop_bootsier::theme::*;
/// let brand = bs::navbar::Brand::new()
/// let brand = Brand::new()
/// .with_title(Lc::n("Intranet"))
/// .with_route(Some("/".into()));
/// .with_route(Route::from("/"));
///
/// let navbar = bs::Navbar::brand_right(brand)
/// .with_expand(BreakPoint::LG)
/// .with_item(bs::navbar::Item::nav(
/// bs::Nav::pills()
/// bs::Nav::new()
/// .with_kind(bs::nav::Kind::Pills)
/// .with_item(bs::nav::Item::link(Lc::n("Dashboard"), "/dashboard"))
/// .with_item(bs::nav::Item::link(Lc::n("Users"), "/users"))
/// ));
@ -122,9 +127,9 @@ const TOGGLE_OFFCANVAS: &str = "offcanvas";
/// ```rust,no_run
/// # use pagetop::prelude::*;
/// # use pagetop_bootsier::theme::*;
/// let brand = bs::navbar::Brand::new()
/// let brand = Brand::new()
/// .with_title(Lc::n("Main App"))
/// .with_route(Some("/".into()));
/// .with_route(Route::from("/"));
///
/// let navbar = bs::Navbar::brand_left(brand)
/// .with_position(bs::navbar::Position::FixedTop)
@ -135,265 +140,224 @@ const TOGGLE_OFFCANVAS: &str = "offcanvas";
/// .with_item(bs::nav::Item::link(Lc::n("Stock"), "/stock"))
/// ));
/// ```
#[derive(AutoDefault, Clone, Debug, Getters)]
pub struct Navbar {
/// Devuelve identificador, clases CSS, atributos HTML y valores extra del componente.
props: Props,
/// Devuelve el punto de ruptura configurado.
expand: BreakPoint,
/// Devuelve la disposición configurada para la barra de navegación.
layout: bs::navbar::Layout,
/// Devuelve la posición configurada para la barra de navegación.
position: bs::navbar::Position,
/// Devuelve la lista de contenidos.
items: Children,
}
#[async_trait]
impl Component for Navbar {
fn new() -> Self {
Self::default()
}
fn id(&self) -> Option<String> {
self.props.get_id()
}
fn setup(&mut self, cx: &Context) {
// Asegura que la barra de navegación tiene un identificador único.
self.alter_prop(PropsOp::ensure_id(cx.build_id::<Self>(1)));
// Clases CSS por defecto para la barra de navegación.
self.alter_prop(PropsOp::prepend_classes({
let mut classes = "navbar".to_string();
self.expand().push_to(&mut classes, "navbar-expand", "");
self.position().push_to(&mut classes);
classes
}));
}
async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
// Botón de despliegue (colapso u offcanvas) para la barra.
fn button(cx: &mut Context, data_bs_toggle: &str, id_content: &str) -> Markup {
let id_content_target = util::join!("#", id_content);
let aria_expanded = if data_bs_toggle == TOGGLE_COLLAPSE {
Some("false")
} else {
None
};
html! {
button
type="button"
class="navbar-toggler"
data-bs-toggle=(data_bs_toggle)
data-bs-target=(id_content_target)
aria-controls=(id_content)
aria-expanded=[aria_expanded]
aria-label=[Lc::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).await;
if items.is_empty() {
return Ok(html! {});
}
// `setup()` garantiza que habrá un `id` antes de renderizar.
let id = self.id().unwrap();
Ok(html! {
nav (self.props()) {
div class="container-fluid" {
@match self.layout() {
// Barra más sencilla: sólo contenido.
bs::navbar::Layout::Simple => {
(items)
},
// Barra sencilla que se puede contraer/expandir.
bs::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.
bs::navbar::Layout::SimpleBrandLeft(brand) => {
(brand.render(cx).await)
(items)
},
// Barra con marca a la izquierda y botón a la derecha.
bs::navbar::Layout::BrandLeft(brand) => {
@let id_content = util::join!(id, "-content");
(brand.render(cx).await)
(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.
bs::navbar::Layout::BrandRight(brand) => {
@let id_content = util::join!(id, "-content");
(button(cx, TOGGLE_COLLAPSE, &id_content))
(brand.render(cx).await)
div id=(&id_content) class="collapse navbar-collapse" {
(items)
}
},
// Barra cuyo contenido se muestra en un offcanvas, sin marca.
bs::navbar::Layout::Offcanvas(offcanvas) => {
@let id_content = offcanvas.id().unwrap_or_default();
(button(cx, TOGGLE_OFFCANVAS, &id_content))
@if let Some(oc) = offcanvas.get() {
(oc.render_offcanvas(cx, Some(self.items())).await)
}
},
// Barra con marca a la izquierda y contenido en offcanvas.
bs::navbar::Layout::OffcanvasBrandLeft(brand, offcanvas) => {
@let id_content = offcanvas.id().unwrap_or_default();
(brand.render(cx).await)
(button(cx, TOGGLE_OFFCANVAS, &id_content))
@if let Some(oc) = offcanvas.get() {
(oc.render_offcanvas(cx, Some(self.items())).await)
}
},
// Barra con contenido en offcanvas y marca a la derecha.
bs::navbar::Layout::OffcanvasBrandRight(brand, offcanvas) => {
@let id_content = offcanvas.id().unwrap_or_default();
(button(cx, TOGGLE_OFFCANVAS, &id_content))
(brand.render(cx).await)
@if let Some(oc) = offcanvas.get() {
(oc.render_offcanvas(cx, Some(self.items())).await)
}
},
}
}
}
})
}
}
impl Navbar {
/// Crea una barra de navegación **simple**, sin marca y sin botón.
pub fn simple() -> Self {
Self::default().with_layout(bs::navbar::Layout::Simple)
}
/// Crea una barra de navegación **simple pero colapsable**, con botón a la izquierda.
pub fn simple_toggle() -> Self {
Self::default().with_layout(bs::navbar::Layout::SimpleToggle)
}
/// Crea una barra de navegación **con marca a la izquierda**, siempre visible.
pub fn simple_brand_left(brand: bs::navbar::Brand) -> Self {
Self::default().with_layout(bs::navbar::Layout::SimpleBrandLeft(Embed::with(brand)))
}
/// Crea una barra de navegación con **marca a la izquierda** y **botón a la derecha**.
pub fn brand_left(brand: bs::navbar::Brand) -> Self {
Self::default().with_layout(bs::navbar::Layout::BrandLeft(Embed::with(brand)))
}
/// Crea una barra de navegación con **botón a la izquierda** y **marca a la derecha**.
pub fn brand_right(brand: bs::navbar::Brand) -> Self {
Self::default().with_layout(bs::navbar::Layout::BrandRight(Embed::with(brand)))
}
pub trait NavbarBootsier {
/// Crea una barra de navegación cuyo contenido se muestra en un **offcanvas**.
pub fn offcanvas(oc: bs::Offcanvas) -> Self {
Self::default().with_layout(bs::navbar::Layout::Offcanvas(Embed::with(oc)))
}
fn offcanvas(oc: bs::Offcanvas) -> Self;
/// Crea una barra de navegación con **marca a la izquierda** y contenido en **offcanvas**.
pub fn offcanvas_brand_left(brand: bs::navbar::Brand, oc: bs::Offcanvas) -> Self {
Self::default().with_layout(bs::navbar::Layout::OffcanvasBrandLeft(
Embed::with(brand),
Embed::with(oc),
))
}
/// Crea una barra de navegación con **marca de identidad** y contenido en **offcanvas**.
fn offcanvas_brand_left(brand: Brand, oc: bs::Offcanvas) -> Self;
/// Crea una barra de navegación con **marca a la derecha** y contenido en **offcanvas**.
pub fn offcanvas_brand_right(brand: bs::navbar::Brand, oc: bs::Offcanvas) -> Self {
Self::default().with_layout(bs::navbar::Layout::OffcanvasBrandRight(
Embed::with(brand),
Embed::with(oc),
))
}
// **< Navbar BUILDER >*************************************************************************
/// Establece el identificador único del componente; igual a `with_prop(PropsOp::set_id(id))`.
#[builder_fn]
pub fn with_id(mut self, id: impl Into<CowStr>) -> Self {
self.props.alter_id(id);
self
}
/// Modifica identificador, clases CSS, atributos HTML o valores extra del componente.
///
/// También acepta clases predefinidas para:
///
/// - Modificar el color de fondo ([`Bg`]).
/// - Definir la apariencia del texto ([`Text`]).
#[builder_fn]
pub fn with_prop(mut self, op: PropsOp) -> Self {
self.props.alter_prop(op);
self
}
/// Crea una barra de navegación con **marca de identidad** y contenido en **offcanvas**.
fn offcanvas_brand_right(brand: Brand, oc: bs::Offcanvas) -> 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: bs::navbar::Layout) -> Self {
self.layout = layout;
self
}
fn with_expand(self, bp: BreakPoint) -> Self;
/// Define dónde se mostrará la barra de navegación dentro del documento.
#[builder_fn]
pub fn with_position(mut self, position: bs::navbar::Position) -> Self {
self.position = position;
fn with_position(self, position: bs::navbar::Position) -> Self;
}
impl NavbarBootsier for Navbar {
fn offcanvas(oc: bs::Offcanvas) -> Self {
let mut navbar = Self::new();
navbar.alter_prop(PropsOp::set_extra(
EXTRA_LAYOUT,
bs::navbar::Layout::Offcanvas(Embed::with(oc)),
));
navbar
}
fn offcanvas_brand_left(brand: Brand, oc: bs::Offcanvas) -> Self {
let mut navbar = Self::new();
navbar.alter_prop(PropsOp::set_extra(
EXTRA_LAYOUT,
bs::navbar::Layout::OffcanvasBrandLeft(Embed::with(brand), Embed::with(oc)),
));
navbar
}
fn offcanvas_brand_right(brand: Brand, oc: bs::Offcanvas) -> Self {
let mut navbar = Self::new();
navbar.alter_prop(PropsOp::set_extra(
EXTRA_LAYOUT,
bs::navbar::Layout::OffcanvasBrandRight(Embed::with(brand), Embed::with(oc)),
));
navbar
}
#[builder_fn]
fn with_expand(mut self, bp: BreakPoint) -> Self {
self.alter_prop(PropsOp::set_extra(EXTRA_EXPAND, bp));
self
}
/// Añade un nuevo contenido a la barra de navegación o modifica la lista de contenidos de la
/// barra con una operación [`ChildOp`].
///
/// # Ejemplo
///
/// ```rust,ignore
/// navbar.with_item(navbar::Item::nav(...));
/// navbar.with_item(ChildOp::AddMany(vec![
/// navbar::Item::nav(...).into(),
/// navbar::Item::text(...).into(),
/// ]));
/// ```
#[builder_fn]
pub fn with_item(mut self, op: impl Into<ChildOp>) -> Self {
self.items.alter_child(op.into());
fn with_position(mut self, position: bs::navbar::Position) -> Self {
self.alter_prop(PropsOp::set_extra(EXTRA_POSITION, position));
self
}
}
// **< Navbar SETUP >*******************************************************************************
pub(crate) fn setup(navbar: &mut Navbar) {
// Sin `with_expand()`, colapsa por debajo de 768px, igual que el tema Basic (que no tiene
// punto de ruptura configurable y siempre usa ese umbral, ver `static/css/basic.css`).
let expand = navbar.props().extra_or(EXTRA_EXPAND, BreakPoint::MD);
let position = navbar
.props()
.extra_or(EXTRA_POSITION, bs::navbar::Position::default());
let mut classes = String::new();
expand.push_to(&mut classes, "navbar-expand", "");
position.push_to(&mut classes);
if !classes.is_empty() {
navbar.alter_prop(PropsOp::add_classes(classes));
}
}
// **< Navbar RENDER >******************************************************************************
pub(crate) async fn render(navbar: &Navbar, cx: &mut Context) -> Result<Markup, ComponentError> {
// Botón de despliegue (colapso u offcanvas) para la barra.
fn button(cx: &mut Context, data_bs_toggle: &str, id_content: &str) -> Markup {
let id_content_target = util::join!("#", id_content);
let aria_expanded = if data_bs_toggle == TOGGLE_COLLAPSE {
Some("false")
} else {
None
};
html! {
button
type="button"
class="navbar-toggler"
data-bs-toggle=(data_bs_toggle)
data-bs-target=(id_content_target)
aria-controls=(id_content)
aria-expanded=[aria_expanded]
aria-label=[Lc::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 = navbar.items().render(cx).await;
if items.is_empty() {
return Ok(html! {});
}
// `Navbar::setup()` (base) garantiza que habrá un `id` antes de renderizar.
let id = navbar.id().unwrap();
// `with_layout()` (extra propio de Bootsier) tiene prioridad; si no se ha usado, se traduce el
// `navbar::Layout` de base que hayan podido fijar los constructores heredados de `Navbar`
// (`simple()`, `brand_left()`...), que Bootsier no puede sobrescribir por nombre -las funciones
// inherentes de base siempre ganan sobre las de un trait con el mismo nombre-.
let layout = navbar
.props()
.extra::<bs::navbar::Layout>(EXTRA_LAYOUT)
.cloned()
.unwrap_or_else(|_| translate_layout(navbar.layout()));
Ok(html! {
nav (navbar.props()) {
div class="container-fluid" {
@match layout {
// Barra más sencilla: sólo contenido.
bs::navbar::Layout::Simple => {
(items)
},
// Barra sencilla que se puede contraer/expandir.
bs::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.
bs::navbar::Layout::SimpleBrandLeft(brand) => {
(brand.render(cx).await)
(items)
},
// Barra con marca a la izquierda y botón a la derecha.
bs::navbar::Layout::BrandLeft(brand) => {
@let id_content = util::join!(&id, "-content");
(brand.render(cx).await)
(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.
bs::navbar::Layout::BrandRight(brand) => {
@let id_content = util::join!(&id, "-content");
(button(cx, TOGGLE_COLLAPSE, &id_content))
(brand.render(cx).await)
div id=(&id_content) class="collapse navbar-collapse" {
(items)
}
},
// Barra cuyo contenido se muestra en un offcanvas, sin marca.
bs::navbar::Layout::Offcanvas(offcanvas) => {
@let id_content = offcanvas.id().unwrap_or_default();
(button(cx, TOGGLE_OFFCANVAS, &id_content))
@if let Some(oc) = offcanvas.get() {
(oc.render_offcanvas(cx, Some(navbar.items())).await)
}
},
// Barra con marca a la izquierda y contenido en offcanvas.
bs::navbar::Layout::OffcanvasBrandLeft(brand, offcanvas) => {
@let id_content = offcanvas.id().unwrap_or_default();
(brand.render(cx).await)
(button(cx, TOGGLE_OFFCANVAS, &id_content))
@if let Some(oc) = offcanvas.get() {
(oc.render_offcanvas(cx, Some(navbar.items())).await)
}
},
// Barra con contenido en offcanvas y marca a la derecha.
bs::navbar::Layout::OffcanvasBrandRight(brand, offcanvas) => {
@let id_content = offcanvas.id().unwrap_or_default();
(button(cx, TOGGLE_OFFCANVAS, &id_content))
(brand.render(cx).await)
@if let Some(oc) = offcanvas.get() {
(oc.render_offcanvas(cx, Some(navbar.items())).await)
}
},
}
}
}
})
}
// Traduce el `navbar::Layout` semántico de base (sin `Offcanvas`, sin `Position`/`expand`) a la
// variante equivalente de `bs::navbar::Layout`, para las barras construidas con los constructores
// heredados de `Navbar` (`simple()`, `simple_toggle()`, `simple_brand_left()`, `brand_left()`,
// `brand_right()`) en vez de con `with_layout()`.
fn translate_layout(layout: &navbar::Layout) -> bs::navbar::Layout {
match layout {
navbar::Layout::Simple => bs::navbar::Layout::Simple,
navbar::Layout::SimpleToggle => bs::navbar::Layout::SimpleToggle,
navbar::Layout::SimpleBrandLeft(brand) => {
bs::navbar::Layout::SimpleBrandLeft(brand.clone())
}
navbar::Layout::BrandLeft(brand) => bs::navbar::Layout::BrandLeft(brand.clone()),
navbar::Layout::BrandRight(brand) => bs::navbar::Layout::BrandRight(brand.clone()),
}
}

View file

@ -1,98 +1,24 @@
use pagetop::prelude::*;
use crate::theme::*;
use crate::theme::bs::nav;
/// Elementos que puede contener una barra de navegación [`Navbar`](crate::theme::bs::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`](crate::theme::bs::navbar::Layout).
#[derive(AutoDefault, Clone, Debug)]
pub enum Item {
/// Sin contenido, no produce salida.
#[default]
Void,
/// Marca de identidad mostrada dentro del contenido de la barra de navegación.
///
/// Útil cuando el [`navbar::Layout`](crate::theme::bs::navbar::Layout) no incluye marca, y se
/// quiere incluir dentro del área
/// colapsable/*offcanvas*. Si el *layout* ya muestra una marca, esta variante no la sustituye,
/// sólo añade otra dentro del bloque de contenidos.
Brand(Embed<bs::navbar::Brand>),
/// Representa un menú de navegación [`Nav`](crate::theme::bs::Nav).
Nav(Embed<bs::Nav>),
/// Representa un *texto localizado* libre.
Text(Lc),
}
#[async_trait]
impl Component for Item {
fn new() -> Self {
Self::default()
}
fn id(&self) -> Option<String> {
match self {
Self::Void => None,
Self::Brand(brand) => brand.id(),
Self::Nav(nav) => nav.id(),
Self::Text(_) => None,
// Idéntico a `navbar::Item::prepare()` de base salvo la variante `Nav`: en vez de reconstruir el
// `<ul>` a mano (lo que se salta la cadena de temas), clona el `Nav` embebido, lo marca como
// "dentro de una Navbar" (para que `theme::bs::nav::setup()` use `navbar-nav` en vez de `nav` como
// clase base) y lo renderiza con su ciclo de vida completo -así recibe también `kind`/`layout`
// traducidos a clases de Bootstrap, y cualquier otro tema que intercepte `Nav` en el futuro-.
pub(crate) async fn render(
item: &navbar::Item,
cx: &mut Context,
) -> Result<Markup, ComponentError> {
match item {
navbar::Item::Nav(embed) => {
let Some(mut nav) = embed.get().cloned() else {
return Ok(html! {});
};
nav.alter_prop(PropsOp::set_extra(nav::EXTRA_IN_NAVBAR, true));
Ok(html! { (nav.render(cx).await) })
}
}
fn setup(&mut self, _cx: &Context) {
if let Self::Nav(nav) = self
&& let Some(nav) = nav.get_mut()
{
nav.alter_prop(PropsOp::prepend_classes("navbar-nav"));
}
}
async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
Ok(match self {
Self::Void => html! {},
Self::Brand(brand) => html! { (brand.render(cx).await) },
Self::Nav(nav) => {
if let Some(nav) = nav.get() {
let items = nav.items().render(cx).await;
if items.is_empty() {
return Ok(html! {});
}
html! {
ul id=[nav.id()] (nav.props()) {
(items)
}
}
} else {
html! {}
}
}
Self::Text(text) => html! {
span class="navbar-text" {
(text.using(cx))
}
},
})
}
}
impl Item {
/// Crea un elemento de tipo [`navbar::Brand`](crate::theme::bs::navbar::Brand) para añadir en el contenido de [`Navbar`](crate::theme::bs::Navbar).
///
/// Pensado para barras colapsables u offcanvas donde se quiere que la marca aparezca en la zona
/// desplegable.
pub fn brand(brand: bs::navbar::Brand) -> Self {
Self::Brand(Embed::with(brand))
}
/// Crea un elemento de tipo [`Nav`](crate::theme::bs::Nav) para añadir al contenido de [`Navbar`](crate::theme::bs::Navbar).
pub fn nav(item: bs::Nav) -> Self {
Self::Nav(Embed::with(item))
}
/// Crea un elemento con un *texto localizado*, mostrado sin interacción.
pub fn text(item: Lc) -> Self {
Self::Text(item)
_ => item.prepare(cx).await,
}
}

View file

@ -20,13 +20,13 @@ pub enum Layout {
/// Barra simple, con marca de identidad a la izquierda y sin botón de despliegue.
///
/// La barra de navegación no se colapsa.
SimpleBrandLeft(Embed<bs::navbar::Brand>),
SimpleBrandLeft(Embed<Brand>),
/// Barra con marca de identidad a la izquierda y botón de despliegue a la derecha.
BrandLeft(Embed<bs::navbar::Brand>),
BrandLeft(Embed<Brand>),
/// Barra con botón de despliegue a la izquierda y marca de identidad a la derecha.
BrandRight(Embed<bs::navbar::Brand>),
BrandRight(Embed<Brand>),
/// Contenido en [`Offcanvas`](crate::theme::bs::Offcanvas), con botón de despliegue a la
/// izquierda y sin marca de identidad.
@ -34,11 +34,11 @@ pub enum Layout {
/// Contenido en [`Offcanvas`](crate::theme::bs::Offcanvas), con marca de identidad a la
/// izquierda y botón de despliegue a la derecha.
OffcanvasBrandLeft(Embed<bs::navbar::Brand>, Embed<bs::Offcanvas>),
OffcanvasBrandLeft(Embed<Brand>, Embed<bs::Offcanvas>),
/// Contenido en [`Offcanvas`](crate::theme::bs::Offcanvas), con botón de despliegue a la
/// izquierda y marca de identidad a la derecha.
OffcanvasBrandRight(Embed<bs::navbar::Brand>, Embed<bs::Offcanvas>),
OffcanvasBrandRight(Embed<Brand>, Embed<bs::Offcanvas>),
}
// **< Position >***********************************************************************************

View file

@ -0,0 +1,43 @@
//! Componentes para la barra lateral de la shell de Bootsier.
//!
//! # Componentes disponibles
//!
//! - [`Section`] - encabezado de grupo (`<li class="nav-header">`).
//! - [`Item`] - enlace de navegación con icono de Bootstrap Icons. Detecta
//! automáticamente si la ruta activa coincide con la del *request* y añade la clase
//! `active` al enlace.
//!
//! # Flujo de uso
//!
//! Los componentes se añaden a la región
//! [`BootsierRegions::Sidebar`](crate::theme::bs::BootsierRegions::Sidebar).
//! El registro global se hace una sola vez en el arranque; el registro por página
//! se hace al construir la página. La región sólo se renderiza en páginas creadas con
//! [`Page::admin()`](pagetop::response::Page::admin); en páginas con
//! [`Page::new()`](pagetop::response::Page::new) no tiene efecto.
//!
//! ```rust,no_run
//! use pagetop::prelude::*;
//! use pagetop_bootsier::theme::bs::{BootsierRegions, sidebar};
//!
//! // Registro global: visible en todas las páginas de administración.
//! fn register_navigation() {
//! InRegion::Global(&BootsierRegions::Sidebar)
//! .add(sidebar::Section::titled(Lc::n("Administración")))
//! .add(sidebar::Item::link(Lc::n("Usuarios"), "/users", "people"))
//! .add(sidebar::Item::link(Lc::n("Roles"), "/roles", "shield-check"));
//! }
//!
//! // Uso en un handler: la página de administración muestra el sidebar registrado.
//! async fn users(request: HttpRequest) -> Result<Markup, ErrorPage> {
//! Page::admin(request)
//! .with_child(Html::with(|_| html! { h3 { "Usuarios" } }))
//! .render().await
//! }
//! ```
mod item;
pub use item::Item;
mod section;
pub use section::Section;

View file

@ -0,0 +1,104 @@
use pagetop::prelude::*;
// **< Item >***************************************************************************************
/// Elemento de navegación individual en la barra lateral de AdminLTE.
///
/// Renderiza un `<li class="nav-item">` con un enlace `<a class="nav-link">`, un icono de
/// Bootstrap Icons y una etiqueta localizable. Si la ruta del ítem coincide con la del *request*
/// actual, el enlace se marca como activo con la clase `active`.
///
/// # Ejemplo
///
/// ```rust,no_run
/// use pagetop::prelude::*;
/// use pagetop_bootsier::theme::bs::sidebar;
///
/// let item = sidebar::Item::link(
/// Lc::n("Usuarios"),
/// "/users",
/// "people",
/// );
/// ```
#[derive(AutoDefault, Clone, Getters)]
pub struct Item {
/// Devuelve el texto localizable del ítem.
label: Lc,
/// Devuelve la ruta de destino en función del contexto.
#[getters(skip)]
route: Option<Route>,
/// Devuelve el nombre del icono de Bootstrap Icons (sin el prefijo `bi-`).
icon: CowStr,
}
#[async_trait]
impl Component for Item {
fn new() -> Self {
Self::default()
}
async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
let Some(route) = self.route.as_ref() else {
return Ok(html! {});
};
let route_link = route.resolve(cx);
let current_path = cx.request().map(|r| r.path());
let is_active = current_path == Some(route_link.path());
let link_class = if is_active {
"nav-link active"
} else {
"nav-link"
};
let aria_current = is_active.then_some("page");
let icon_class = util::join!("nav-icon bi bi-", self.icon());
Ok(html! {
li class="nav-item" {
a href=(route_link) class=(link_class) aria-current=[aria_current] {
i class=(icon_class) {}
p { (self.label().using(cx)) }
}
}
})
}
}
impl Item {
/// Crea un ítem de navegación con etiqueta, ruta e icono.
///
/// * `label` - Texto localizable del ítem.
/// * `route` - Ruta de destino, resuelta según el contexto (ver [`Route`]).
/// * `icon` - Nombre del icono de Bootstrap Icons sin el prefijo `bi-` (p. ej. `"people"`).
pub fn link(label: Lc, route: impl Into<Route>, icon: impl Into<CowStr>) -> Self {
Self {
label,
route: Some(route.into()),
icon: icon.into(),
}
}
// **< Item BUILDER >***************************************************************************
/// Establece el texto localizable del ítem.
#[builder_fn]
pub fn with_label(mut self, label: Lc) -> Self {
self.label = label;
self
}
/// Establece la ruta de destino del ítem.
#[builder_fn]
pub fn with_route(mut self, route: impl Into<Option<Route>>) -> Self {
self.route = route.into();
self
}
/// Establece el nombre del icono de Bootstrap Icons (sin el prefijo `bi-`).
#[builder_fn]
pub fn with_icon(mut self, icon: impl Into<CowStr>) -> Self {
self.icon = icon.into();
self
}
}

View file

@ -0,0 +1,57 @@
use pagetop::prelude::*;
// **< Section >************************************************************************************
/// Encabezado de sección en la barra lateral de AdminLTE.
///
/// Renderiza un `<li class="nav-header">` con el texto localizable de la sección. Se usa para
/// agrupar visualmente los ítems de navegación ([`Item`](super::Item)) en la sidebar.
///
/// # Ejemplo
///
/// ```rust,no_run
/// use pagetop::prelude::*;
/// use pagetop_bootsier::theme::bs::sidebar;
///
/// // Sección con texto fijo.
/// let section = sidebar::Section::titled(Lc::n("Administración"));
///
/// // Sección con texto localizable.
/// let section_i18n = sidebar::Section::titled(Lc::l("nav-admin"));
/// ```
#[derive(AutoDefault, Clone, Getters)]
pub struct Section {
/// Devuelve el título localizable de la sección.
title: Lc,
}
#[async_trait]
impl Component for Section {
fn new() -> Self {
Self::default()
}
async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
Ok(html! {
li class="nav-header" {
(self.title().using(cx))
}
})
}
}
impl Section {
/// Crea un encabezado de sección con el título indicado.
pub fn titled(title: Lc) -> Self {
Self { title }
}
// **< Section BUILDER >************************************************************************
/// Establece el título localizable de la sección.
#[builder_fn]
pub fn with_title(mut self, title: Lc) -> Self {
self.title = title;
self
}
}

View file

@ -6,19 +6,16 @@
//! ```rust,no_run
//! use pagetop_bootsier::theme::*;
//!
//! let bg = class::Bg::with(ThemeColor::Primary);
//! let bg = class::Bg::with(BootsierColors::Primary);
//! let border = class::Border::new()
//! .with_side(BoxSide::Top, ScaleSize::Zero)
//! .with_color(ThemeColor::Danger);
//! .with_color(BootsierColors::Danger);
//! ```
mod color;
pub use color::{Bg, BgColor};
pub use color::{Text, TextColor};
mod button;
pub use button::{ButtonColor, ButtonColorStyle, ButtonSize, ButtonSizeKind};
mod border;
pub use border::{Border, BorderColor};

View file

@ -1,13 +1,13 @@
use pagetop::prelude::*;
use crate::theme::{BoxSide, OpacityLevel, ScaleSize, ThemeColor};
use crate::theme::{BootsierColors, BoxSide, OpacityLevel, ScaleSize};
// **< BorderColor >********************************************************************************
/// Esquema de color para los bordes ([`Border`]).
///
/// - `Solid(ThemeColor)` y `Subtle(ThemeColor)` usan la paleta de colores temáticos
/// ([`ThemeColor`]).
/// - `Solid(BootsierColors)` y `Subtle(BootsierColors)` usan la paleta de colores temáticos
/// ([`BootsierColors`]).
/// - `Black` y `White` son colores fijos independientes del tema.
/// - `Default` no genera ninguna clase.
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
@ -16,9 +16,9 @@ pub enum BorderColor {
#[default]
Default,
/// Genera la clase `border-{color}`.
Solid(ThemeColor),
Solid(BootsierColors),
/// Genera la clase `border-{color}-subtle` (un tono suavizado del color).
Subtle(ThemeColor),
Subtle(BootsierColors),
/// Color negro.
Black,
/// Color blanco.
@ -63,10 +63,10 @@ impl BorderColor {
///
/// ```rust
/// # use pagetop_bootsier::theme::*;
/// let solid = class::BorderColor::Solid(ThemeColor::Primary).to_class();
/// let solid = class::BorderColor::Solid(BootsierColors::Primary).to_class();
/// assert_eq!(solid, "border-primary");
///
/// let subtle = class::BorderColor::Subtle(ThemeColor::Warning).to_class();
/// let subtle = class::BorderColor::Subtle(BootsierColors::Warning).to_class();
/// assert_eq!(subtle, "border-warning-subtle");
///
/// let black = class::BorderColor::Black.to_class();
@ -83,8 +83,8 @@ impl BorderColor {
}
}
impl From<ThemeColor> for BorderColor {
/// Convierte un [`ThemeColor`] en [`BorderColor::Solid`].
impl From<BootsierColors> for BorderColor {
/// Convierte un [`BootsierColors`] en [`BorderColor::Solid`].
///
/// Es el atajo habitual para los colores temáticos. Para los demás esquemas (`Subtle`, `Black`,
/// `White`) sigue usando [`BorderColor`].
@ -93,18 +93,18 @@ impl From<ThemeColor> for BorderColor {
///
/// ```rust
/// # use pagetop_bootsier::theme::*;
/// let border: class::BorderColor = ThemeColor::Success.into();
/// let border: class::BorderColor = BootsierColors::Success.into();
/// assert_eq!(border.to_class(), "border-success");
/// ```
fn from(color: ThemeColor) -> Self {
fn from(color: BootsierColors) -> Self {
Self::Solid(color)
}
}
impl Into<CowStr> for BorderColor {
/// Permite pasar [`BorderColor`] directamente a [`PropsOp`](pagetop::prelude::PropsOp).
fn into(self) -> CowStr {
self.to_class().into()
impl From<BorderColor> for CowStr {
/// Permite pasar [`BorderColor`] directamente a [`PropsOp`].
fn from(val: BorderColor) -> Self {
val.to_class().into()
}
}
@ -159,7 +159,7 @@ impl Into<CowStr> for BorderColor {
/// let b = class::Border::new() // Borde por defecto.
/// .with_side(BoxSide::Top, ScaleSize::Zero) // Quita borde superior.
/// .with_side(BoxSide::End, ScaleSize::Three) // Ancho 3 para lado lógico final.
/// .with_color(ThemeColor::Primary)
/// .with_color(BootsierColors::Primary)
/// .with_opacity(OpacityLevel::Half);
/// assert_eq!(b.to_class(), "border border-top-0 border-end-3 border-primary border-opacity-50");
/// ```
@ -210,7 +210,7 @@ impl Border {
/// Establece el color del borde.
///
/// Acepta un tipo convertible en [`BorderColor`]. Un [`ThemeColor`] se convierte
/// Acepta un tipo convertible en [`BorderColor`]. Un [`BootsierColors`] se convierte
/// automáticamente en [`BorderColor::Solid`].
pub fn with_color(mut self, color: impl Into<BorderColor>) -> Self {
self.color = color.into();
@ -270,9 +270,9 @@ impl From<ScaleSize> for Border {
}
}
impl Into<CowStr> for Border {
/// Permite pasar [`Border`] directamente a [`PropsOp`](pagetop::prelude::PropsOp).
fn into(self) -> CowStr {
self.to_class().into()
impl From<Border> for CowStr {
/// Permite pasar [`Border`] directamente a [`PropsOp`].
fn from(val: Border) -> Self {
val.to_class().into()
}
}

View file

@ -1,221 +0,0 @@
use pagetop::prelude::*;
use crate::theme::ThemeColor;
// **< ButtonColor >********************************************************************************
/// Estilo visual aplicado al color de un botón ([`ButtonColor`]).
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
pub enum ButtonColorStyle {
/// Sin clase de color (estilo por defecto del tema).
#[default]
None,
/// Botón sólido: genera la clase `btn-{color}`.
Solid,
/// Botón con contorno: genera la clase `btn-outline-{color}`.
Outline,
/// Botón tipo enlace: genera la clase `btn-link`.
Link,
}
/// Clases para establecer el **color y estilo** de los botones.
///
/// # Ejemplos
///
/// ```rust,no_run
/// use pagetop::prelude::*;
/// use pagetop_bootsier::theme::*;
///
/// // Botón sólido.
/// let save = bs::Button::submit(Lc::n("Save"))
/// .with_prop(PropsOp::add_classes(class::ButtonColor::solid(ThemeColor::Primary)));
///
/// // Botón con contorno.
/// let cancel = bs::Button::plain(Lc::n("Cancel"))
/// .with_prop(PropsOp::add_classes(class::ButtonColor::outline(ThemeColor::Secondary)));
///
/// // Botón tipo enlace.
/// let back = bs::Button::plain(Lc::n("Back"))
/// .with_prop(PropsOp::add_classes(class::ButtonColor::link()));
/// ```
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
pub struct ButtonColor {
style: ButtonColorStyle,
color: ThemeColor,
}
impl ButtonColor {
/// Sin clase de color (estilo por defecto del tema).
pub fn new() -> Self {
Self::default()
}
/// Botón sólido: genera la clase `btn-{color}`.
pub fn solid(color: ThemeColor) -> Self {
Self {
style: ButtonColorStyle::Solid,
color,
..Default::default()
}
}
/// Botón con contorno: genera la clase `btn-outline-{color}`.
pub fn outline(color: ThemeColor) -> Self {
Self {
style: ButtonColorStyle::Outline,
color,
..Default::default()
}
}
/// Botón tipo enlace: genera la clase `btn-link`.
pub fn link() -> Self {
Self {
style: ButtonColorStyle::Link,
..Default::default()
}
}
// **< ButtonColor BUILDER >********************************************************************
/// Cambia el color aplicado al botón (`btn-*` o `btn-outline-*`).
pub fn with_color(mut self, color: ThemeColor) -> Self {
self.color = color;
self
}
/// Cambia el estilo aplicado al botón (sólido, contorno o enlace).
pub fn with_style(mut self, style: ButtonColorStyle) -> Self {
self.style = style;
self
}
// **< ButtonColor HELPERS >********************************************************************
/// Añade la clase `btn-*` a la cadena de clases.
#[rustfmt::skip]
#[inline]
pub fn push_to(self, classes: &mut String) {
let (prefix, suffix) = match self.style {
ButtonColorStyle::None => return,
ButtonColorStyle::Solid => ("btn-", self.color.as_str()),
ButtonColorStyle::Outline => ("btn-outline-", self.color.as_str()),
ButtonColorStyle::Link => ("btn-link", ""),
};
if !classes.is_empty() {
classes.push(' ');
}
classes.push_str(prefix);
classes.push_str(suffix);
}
/// Devuelve la clase `btn-*` correspondiente al color del botón.
///
/// Si no se ha definido ningún estilo, devuelve `""`.
pub fn to_class(self) -> String {
let mut class = String::new();
self.push_to(&mut class);
class
}
}
impl Into<CowStr> for ButtonColor {
/// Permite pasar [`ButtonColor`] directamente a [`PropsOp`](pagetop::prelude::PropsOp).
fn into(self) -> CowStr {
self.to_class().into()
}
}
// **< ButtonSize >*********************************************************************************
/// Tamaño aplicado a un botón ([`ButtonSize`]).
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
pub enum ButtonSizeKind {
/// Sin clase de tamaño (tamaño por defecto del tema).
#[default]
None,
/// Botón compacto: genera la clase `btn-sm`.
Small,
/// Botón grande: genera la clase `btn-lg`.
Large,
}
/// Clases para establecer el **tamaño** de los botones.
///
/// # Ejemplos
///
/// ```rust,no_run
/// use pagetop::prelude::*;
/// use pagetop_bootsier::theme::*;
///
/// let small = bs::Button::submit(Lc::n("Save"))
/// .with_prop(PropsOp::add_classes(class::ButtonSize::small()));
///
/// let large = bs::Button::submit(Lc::n("Save"))
/// .with_prop(PropsOp::add_classes(class::ButtonSize::large()));
/// ```
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
pub struct ButtonSize {
size: ButtonSizeKind,
}
impl ButtonSize {
/// Sin clase de tamaño (tamaño por defecto del tema).
pub fn new() -> Self {
Self::default()
}
/// Botón compacto: genera la clase `btn-sm`.
pub fn small() -> Self {
Self {
size: ButtonSizeKind::Small,
}
}
/// Botón grande: genera la clase `btn-lg`.
pub fn large() -> Self {
Self {
size: ButtonSizeKind::Large,
}
}
// **< ButtonSize BUILDER >*********************************************************************
/// Cambia el tamaño aplicado al botón.
pub fn with_size(mut self, size: ButtonSizeKind) -> Self {
self.size = size;
self
}
// **< ButtonSize HELPERS >*********************************************************************
/// Añade la clase `btn-sm` o `btn-lg` a la cadena de clases.
#[inline]
pub fn push_to(self, classes: &mut String) {
let class = match self.size {
ButtonSizeKind::None => return,
ButtonSizeKind::Small => "btn-sm",
ButtonSizeKind::Large => "btn-lg",
};
if !classes.is_empty() {
classes.push(' ');
}
classes.push_str(class);
}
/// Devuelve la clase `btn-sm` o `btn-lg` correspondiente al tamaño del botón.
///
/// Si no se ha definido ningún tamaño, devuelve `""`.
pub fn to_class(self) -> String {
let mut class = String::new();
self.push_to(&mut class);
class
}
}
impl Into<CowStr> for ButtonSize {
/// Permite pasar [`ButtonSize`] directamente a [`PropsOp`](pagetop::prelude::PropsOp).
fn into(self) -> CowStr {
self.to_class().into()
}
}

View file

@ -1,14 +1,14 @@
use pagetop::prelude::*;
use crate::theme::{OpacityLevel, ThemeColor};
use crate::theme::{BootsierColors, OpacityLevel};
// **< BgColor >************************************************************************************
/// Esquema de color para el fondo ([`Bg`]).
///
/// - `Body`, `BodySecondary` y `BodyTertiary` siguen el esquema del tema (claro/oscuro).
/// - `Solid(ThemeColor)` y `Subtle(ThemeColor)` usan la paleta de colores temáticos
/// ([`ThemeColor`]).
/// - `Solid(BootsierColors)` y `Subtle(BootsierColors)` usan la paleta de colores temáticos
/// ([`BootsierColors`]).
/// - `Black`, `White`, `Transparent` son colores fijos independientes del tema.
/// - `Default` no genera ninguna clase.
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
@ -23,9 +23,9 @@ pub enum BgColor {
/// Fondo predefinido del tema (`bg-body-tertiary`).
BodyTertiary,
/// Genera la clase `bg-{color}` (p. ej., `bg-primary`).
Solid(ThemeColor),
Solid(BootsierColors),
/// Genera la clase `bg-{color}-subtle` (un tono suavizado del color).
Subtle(ThemeColor),
Subtle(BootsierColors),
/// Color negro.
Black,
/// Color blanco.
@ -79,10 +79,10 @@ impl BgColor {
/// let body = class::BgColor::Body.to_class();
/// assert_eq!(body, "bg-body");
///
/// let solid = class::BgColor::Solid(ThemeColor::Primary).to_class();
/// let solid = class::BgColor::Solid(BootsierColors::Primary).to_class();
/// assert_eq!(solid, "bg-primary");
///
/// let subtle = class::BgColor::Subtle(ThemeColor::Warning).to_class();
/// let subtle = class::BgColor::Subtle(BootsierColors::Warning).to_class();
/// assert_eq!(subtle, "bg-warning-subtle");
///
/// let transparent = class::BgColor::Transparent.to_class();
@ -99,8 +99,8 @@ impl BgColor {
}
}
impl From<ThemeColor> for BgColor {
/// Convierte un [`ThemeColor`] en [`BgColor::Solid`].
impl From<BootsierColors> for BgColor {
/// Convierte un [`BootsierColors`] en [`BgColor::Solid`].
///
/// Es el atajo habitual para los colores temáticos. Para los demás esquemas (`Body`, `Subtle`,
/// `Black`, etc.) sigue usando [`BgColor`].
@ -109,18 +109,18 @@ impl From<ThemeColor> for BgColor {
///
/// ```rust
/// # use pagetop_bootsier::theme::*;
/// let bg: class::BgColor = ThemeColor::Primary.into();
/// let bg: class::BgColor = BootsierColors::Primary.into();
/// assert_eq!(bg.to_class(), "bg-primary");
/// ```
fn from(color: ThemeColor) -> Self {
fn from(color: BootsierColors) -> Self {
Self::Solid(color)
}
}
impl Into<CowStr> for BgColor {
/// Permite pasar [`BgColor`] directamente a [`PropsOp`](pagetop::prelude::PropsOp).
fn into(self) -> CowStr {
self.to_class().into()
impl From<BgColor> for CowStr {
/// Permite pasar [`BgColor`] directamente a [`PropsOp`].
fn from(val: BgColor) -> Self {
val.to_class().into()
}
}
@ -137,8 +137,8 @@ impl Into<CowStr> for BgColor {
/// let s = class::Bg::new();
/// assert_eq!(s.to_class(), "");
///
/// // Sólo color de fondo (forma corta con ThemeColor).
/// let s = class::Bg::with(ThemeColor::Primary);
/// // Sólo color de fondo (forma corta con BootsierColors).
/// let s = class::Bg::with(BootsierColors::Primary);
/// assert_eq!(s.to_class(), "bg-primary");
///
/// // Color más opacidad.
@ -167,13 +167,13 @@ impl Bg {
/// Crea un estilo fijando el color de fondo (`bg-*`).
///
/// Acepta cualquier tipo convertible en [`BgColor`]. Un [`ThemeColor`] se convierte
/// Acepta cualquier tipo convertible en [`BgColor`]. Un [`BootsierColors`] se convierte
/// automáticamente en [`BgColor::Solid`]:
///
/// ```rust
/// # use pagetop_bootsier::theme::*;
/// // Forma corta con ThemeColor:
/// let s = class::Bg::with(ThemeColor::Primary);
/// // Forma corta con BootsierColors:
/// let s = class::Bg::with(BootsierColors::Primary);
/// assert_eq!(s.to_class(), "bg-primary");
///
/// // Forma explícita para variantes no temáticas:
@ -188,7 +188,7 @@ impl Bg {
/// Establece el color de fondo (`bg-*`).
///
/// Acepta cualquier tipo convertible en [`BgColor`]. Un [`ThemeColor`] se convierte
/// Acepta cualquier tipo convertible en [`BgColor`]. Un [`BootsierColors`] se convierte
/// automáticamente en [`BgColor::Solid`].
pub fn with_color(mut self, color: impl Into<BgColor>) -> Self {
self.color = color.into();
@ -253,10 +253,10 @@ impl From<BgColor> for Bg {
}
}
impl Into<CowStr> for Bg {
/// Permite pasar [`Bg`] directamente a [`PropsOp`](pagetop::prelude::PropsOp).
fn into(self) -> CowStr {
self.to_class().into()
impl From<Bg> for CowStr {
/// Permite pasar [`Bg`] directamente a [`PropsOp`].
fn from(val: Bg) -> Self {
val.to_class().into()
}
}
@ -265,10 +265,10 @@ impl Into<CowStr> for Bg {
/// Esquema de color para el texto ([`Text`]).
///
/// - `Body`, `BodyEmphasis`, `BodySecondary` y `BodyTertiary` siguen el tema (claro/oscuro).
/// - `Solid(ThemeColor)` y `Emphasis(ThemeColor)` usan la paleta de colores temáticos
/// ([`ThemeColor`]).
/// - `Bg(ThemeColor)` genera la utilidad combinada `text-bg-{color}` (fondo más un color de texto
/// de contraste garantizado; no es una utilidad puramente de texto).
/// - `Solid(BootsierColors)` y `Emphasis(BootsierColors)` usan la paleta de colores temáticos
/// ([`BootsierColors`]).
/// - `Bg(BootsierColors)` genera la utilidad combinada `text-bg-{color}` (fondo más un color de
/// texto de contraste garantizado; no es una utilidad puramente de texto).
/// - `Black` y `White` son colores fijos independientes del tema.
/// - `Default` no genera ninguna clase.
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
@ -285,11 +285,11 @@ pub enum TextColor {
/// Color predefinido del tema (`text-body-tertiary`).
BodyTertiary,
/// Genera la clase `text-{color}`.
Solid(ThemeColor),
Solid(BootsierColors),
/// Genera la clase `text-{color}-emphasis` (mayor contraste acorde al tema).
Emphasis(ThemeColor),
Emphasis(BootsierColors),
/// Genera la clase `text-bg-{color}` (fondo con color de texto de contraste garantizado).
Bg(ThemeColor),
Bg(BootsierColors),
/// Color negro.
Black,
/// Color blanco.
@ -346,13 +346,13 @@ impl TextColor {
/// let body = class::TextColor::Body.to_class();
/// assert_eq!(body, "text-body");
///
/// let solid = class::TextColor::Solid(ThemeColor::Primary).to_class();
/// let solid = class::TextColor::Solid(BootsierColors::Primary).to_class();
/// assert_eq!(solid, "text-primary");
///
/// let emphasis = class::TextColor::Emphasis(ThemeColor::Danger).to_class();
/// let emphasis = class::TextColor::Emphasis(BootsierColors::Danger).to_class();
/// assert_eq!(emphasis, "text-danger-emphasis");
///
/// let bg = class::TextColor::Bg(ThemeColor::Secondary).to_class();
/// let bg = class::TextColor::Bg(BootsierColors::Secondary).to_class();
/// assert_eq!(bg, "text-bg-secondary");
///
/// let black = class::TextColor::Black.to_class();
@ -369,8 +369,8 @@ impl TextColor {
}
}
impl From<ThemeColor> for TextColor {
/// Convierte un [`ThemeColor`] en [`TextColor::Solid`].
impl From<BootsierColors> for TextColor {
/// Convierte un [`BootsierColors`] en [`TextColor::Solid`].
///
/// Es el atajo habitual para los colores temáticos. Para los demás esquemas (`Body`,
/// `Emphasis`, `Black`, etc.) sigue usando [`TextColor`].
@ -379,18 +379,18 @@ impl From<ThemeColor> for TextColor {
///
/// ```rust
/// # use pagetop_bootsier::theme::*;
/// let text: class::TextColor = ThemeColor::Danger.into();
/// let text: class::TextColor = BootsierColors::Danger.into();
/// assert_eq!(text.to_class(), "text-danger");
/// ```
fn from(color: ThemeColor) -> Self {
fn from(color: BootsierColors) -> Self {
Self::Solid(color)
}
}
impl Into<CowStr> for TextColor {
/// Permite pasar [`TextColor`] directamente a [`PropsOp`](pagetop::prelude::PropsOp).
fn into(self) -> CowStr {
self.to_class().into()
impl From<TextColor> for CowStr {
/// Permite pasar [`TextColor`] directamente a [`PropsOp`].
fn from(val: TextColor) -> Self {
val.to_class().into()
}
}
@ -407,8 +407,8 @@ impl Into<CowStr> for TextColor {
/// let s = class::Text::new();
/// assert_eq!(s.to_class(), "");
///
/// // Sólo color del texto (forma corta con ThemeColor).
/// let s = class::Text::with(ThemeColor::Primary);
/// // Sólo color del texto (forma corta con BootsierColors).
/// let s = class::Text::with(BootsierColors::Primary);
/// assert_eq!(s.to_class(), "text-primary");
///
/// // Color del texto y opacidad.
@ -422,7 +422,7 @@ impl Into<CowStr> for TextColor {
///
/// // Usando `From<(TextColor, OpacityLevel)>`.
/// let s: class::Text = (
/// class::TextColor::Solid(ThemeColor::Danger),
/// class::TextColor::Solid(BootsierColors::Danger),
/// OpacityLevel::Opaque,
/// ).into();
/// assert_eq!(s.to_class(), "text-danger text-opacity-100");
@ -441,13 +441,13 @@ impl Text {
/// Crea un estilo fijando el color del texto (`text-*`).
///
/// Acepta cualquier tipo convertible en [`TextColor`]. Un [`ThemeColor`] se convierte
/// Acepta cualquier tipo convertible en [`TextColor`]. Un [`BootsierColors`] se convierte
/// automáticamente en [`TextColor::Solid`]:
///
/// ```rust
/// # use pagetop_bootsier::theme::*;
/// // Forma corta con ThemeColor:
/// let s = class::Text::with(ThemeColor::Danger);
/// // Forma corta con BootsierColors:
/// let s = class::Text::with(BootsierColors::Danger);
/// assert_eq!(s.to_class(), "text-danger");
///
/// // Forma explícita para variantes no temáticas:
@ -462,7 +462,7 @@ impl Text {
/// Establece el color del texto (`text-*`).
///
/// Acepta cualquier tipo convertible en [`TextColor`]. Un [`ThemeColor`] se convierte
/// Acepta cualquier tipo convertible en [`TextColor`]. Un [`BootsierColors`] se convierte
/// automáticamente en [`TextColor::Solid`].
pub fn with_color(mut self, color: impl Into<TextColor>) -> Self {
self.color = color.into();
@ -504,7 +504,7 @@ impl From<(TextColor, OpacityLevel)> for Text {
/// ```rust
/// # use pagetop_bootsier::theme::*;
/// let s: class::Text = (
/// class::TextColor::Solid(ThemeColor::Danger),
/// class::TextColor::Solid(BootsierColors::Danger),
/// OpacityLevel::Opaque,
/// ).into();
/// assert_eq!(s.to_class(), "text-danger text-opacity-100");
@ -529,9 +529,9 @@ impl From<TextColor> for Text {
}
}
impl Into<CowStr> for Text {
/// Permite pasar [`Text`] directamente a [`PropsOp`](pagetop::prelude::PropsOp).
fn into(self) -> CowStr {
self.to_class().into()
impl From<Text> for CowStr {
/// Permite pasar [`Text`] directamente a [`PropsOp`].
fn from(val: Text) -> Self {
val.to_class().into()
}
}

View file

@ -98,10 +98,10 @@ impl Margin {
}
}
impl Into<CowStr> for Margin {
/// Permite pasar [`Margin`] directamente a [`PropsOp`](pagetop::prelude::PropsOp).
fn into(self) -> CowStr {
self.to_class().into()
impl From<Margin> for CowStr {
/// Permite pasar [`Margin`] directamente a [`PropsOp`].
fn from(val: Margin) -> Self {
val.to_class().into()
}
}
@ -203,9 +203,9 @@ impl Padding {
}
}
impl Into<CowStr> for Padding {
/// Permite pasar [`Padding`] directamente a [`PropsOp`](pagetop::prelude::PropsOp).
fn into(self) -> CowStr {
self.to_class().into()
impl From<Padding> for CowStr {
/// Permite pasar [`Padding`] directamente a [`PropsOp`].
fn from(val: Padding) -> Self {
val.to_class().into()
}
}

View file

@ -85,10 +85,10 @@ impl RoundedRadius {
}
}
impl Into<CowStr> for RoundedRadius {
/// Permite pasar [`RoundedRadius`] directamente a [`PropsOp`](pagetop::prelude::PropsOp).
fn into(self) -> CowStr {
self.to_class().into()
impl From<RoundedRadius> for CowStr {
/// Permite pasar [`RoundedRadius`] directamente a [`PropsOp`].
fn from(val: RoundedRadius) -> Self {
val.to_class().into()
}
}
@ -262,9 +262,9 @@ impl Rounded {
}
}
impl Into<CowStr> for Rounded {
/// Permite pasar [`Rounded`] directamente a [`PropsOp`](pagetop::prelude::PropsOp).
fn into(self) -> CowStr {
self.to_class().into()
impl From<Rounded> for CowStr {
/// Permite pasar [`Rounded`] directamente a [`PropsOp`].
fn from(val: Rounded) -> Self {
val.to_class().into()
}
}

View file

@ -4,7 +4,7 @@ mod breakpoint;
pub use breakpoint::BreakPoint;
mod color;
pub use color::{OpacityLevel, ThemeColor};
pub use color::{BootsierColors, OpacityLevel};
mod layout;
pub use layout::{BoxSide, ScaleSize};

View file

@ -1,17 +1,19 @@
use pagetop::prelude::*;
// **< ThemeColor >*********************************************************************************
// **< BootsierColors >*****************************************************************************
/// Paleta de colores temáticos.
///
/// Equivalen a los nombres estándar definidos por Bootstrap (`primary`, `secondary`, `success`,
/// etc.). Se utiliza para componer las clases de color de [`Bg`], [`Border`] o [`Text`].
/// etc.), incluidos dos que [`Intent`](pagetop::prelude::Intent) no trae por defecto (`light`,
/// `dark`). Se utiliza para componer las clases de color de [`Bg`],
/// [`Border`] o [`Text`]. Enum cerrado, sin depender de ningún trait genérico de color.
///
/// [`Bg`]: crate::theme::class::Bg
/// [`Border`]: crate::theme::class::Border
/// [`Text`]: crate::theme::class::Text
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
pub enum ThemeColor {
pub enum BootsierColors {
#[default]
Primary,
Secondary,
@ -23,11 +25,10 @@ pub enum ThemeColor {
Dark,
}
impl ThemeColor {
impl BootsierColors {
/// Devuelve el nombre del color Bootstrap (`"primary"`, `"danger"`, etc.).
#[rustfmt::skip]
#[inline]
pub const fn as_str(self) -> &'static str {
pub const fn as_str(&self) -> &'static str {
match self {
Self::Primary => "primary",
Self::Secondary => "secondary",
@ -41,6 +42,24 @@ impl ThemeColor {
}
}
/// Traduce el vocabulario semántico de [`Intent`] a la paleta de colores Bootstrap.
///
/// `Neutral` y `Severe` no tienen equivalente literal en Bootstrap; se traducen a `secondary` y
/// `danger` respectivamente, que son los colores que Bootstrap usa para ese mismo propósito.
#[rustfmt::skip]
impl From<Intent> for BootsierColors {
fn from(intent: Intent) -> Self {
match intent {
Intent::Primary => Self::Primary,
Intent::Neutral => Self::Secondary,
Intent::Info => Self::Info,
Intent::Success => Self::Success,
Intent::Warning => Self::Warning,
Intent::Severe => Self::Danger,
}
}
}
// **< OpacityLevel >*******************************************************************************
/// Niveles de opacidad (`opacity-*`).