✨ (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:
parent
eb63a7ef37
commit
c0a5a8c3ab
47 changed files with 2135 additions and 1818 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()));
|
||||
/// ```
|
||||
|
|
|
|||
56
extensions/pagetop-bootsier/src/theme/bs/dialog.rs
Normal file
56
extensions/pagetop-bootsier/src/theme/bs/dialog.rs
Normal 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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
4
extensions/pagetop-bootsier/src/theme/bs/layout.rs
Normal file
4
extensions/pagetop-bootsier/src/theme/bs/layout.rs
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
pub(crate) mod region;
|
||||
pub use region::BootsierRegions;
|
||||
|
||||
pub(crate) mod template;
|
||||
113
extensions/pagetop-bootsier/src/theme/bs/layout/region.rs
Normal file
113
extensions/pagetop-bootsier/src/theme/bs/layout/region.rs
Normal 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
170
extensions/pagetop-bootsier/src/theme/bs/layout/template.rs
Normal file
170
extensions/pagetop-bootsier/src/theme/bs/layout/template.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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! {}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 >***********************************************************************************
|
||||
|
|
|
|||
43
extensions/pagetop-bootsier/src/theme/bs/sidebar.rs
Normal file
43
extensions/pagetop-bootsier/src/theme/bs/sidebar.rs
Normal 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;
|
||||
104
extensions/pagetop-bootsier/src/theme/bs/sidebar/item.rs
Normal file
104
extensions/pagetop-bootsier/src/theme/bs/sidebar/item.rs
Normal 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
|
||||
}
|
||||
}
|
||||
57
extensions/pagetop-bootsier/src/theme/bs/sidebar/section.rs
Normal file
57
extensions/pagetop-bootsier/src/theme/bs/sidebar/section.rs
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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};
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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-*`).
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue