✨ (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
|
|
@ -34,6 +34,7 @@ use pagetop::prelude::*;
|
|||
|
||||
struct MyApp;
|
||||
|
||||
#[async_trait]
|
||||
impl Extension for MyApp {
|
||||
fn dependencies(&self) -> Vec<ExtensionRef> {
|
||||
vec para
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
// Bootsier CSS rules: self-hosted fonts, form components, and regions.
|
||||
// Bootsier CSS rules: self-hosted fonts, form elements, regions and components.
|
||||
|
||||
// Self-hosted Source Sans 3 (SIL OFL 1.1), served from /bootsier/fonts.
|
||||
// Required by AdminLTE 4, which declares it as the primary font family in $font-family-sans-serif.
|
||||
|
|
@ -39,6 +39,15 @@
|
|||
}
|
||||
}
|
||||
|
||||
// Button set (button::ButtonSet): spaces its buttons with a gap instead of relying on Bootstrap's
|
||||
// .btn-group, which merges adjacent buttons into a single joined control.
|
||||
.button-set {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: $spacer * .5;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
// Fieldset with border and floating legend.
|
||||
fieldset {
|
||||
position: relative;
|
||||
|
|
@ -83,3 +92,153 @@ fieldset > legend {
|
|||
padding: 0.75rem 0 3rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
// Messages component. Classes are fixed by pagetop core (`.message`, `.message-info`,
|
||||
// `.message-warning`, `.message-error`); reusing Bootstrap's own `.alert`/`.alert-*` styles keeps
|
||||
// the palette in sync with the active Bootstrap theme instead of duplicating it here.
|
||||
.message {
|
||||
@extend .alert;
|
||||
}
|
||||
.message-info {
|
||||
@extend .alert-info;
|
||||
}
|
||||
.message-warning {
|
||||
@extend .alert-warning;
|
||||
}
|
||||
.message-error {
|
||||
@extend .alert-danger;
|
||||
}
|
||||
|
||||
// Sort indicator on sortable table headers (`table::SortLink`), using Bootstrap Icons'
|
||||
// sort-up/sort-down glyphs instead of the plain triangle from basic.css.
|
||||
.table-sort::after {
|
||||
font-family: $bootstrap-icons-font;
|
||||
content: "\f575"; // sort-down
|
||||
margin-left: 0.25rem;
|
||||
font-size: 0.8em;
|
||||
opacity: 0.35;
|
||||
}
|
||||
.table-sort-asc::after {
|
||||
content: "\f57b"; // sort-up
|
||||
opacity: 1;
|
||||
}
|
||||
.table-sort-desc::after {
|
||||
content: "\f575"; // sort-down
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
// Pager component.
|
||||
// `.pagination`, `.page-item`, `.page-link`, `.active` and `.disabled` already come styled by
|
||||
// Bootstrap, since Pager reuses that same markup (the ellipsis is a disabled `.page-link` span,
|
||||
// same as any other cell). Only what Pager adds on top needs rules here: the <nav> wrapper and
|
||||
// the jump-to-page form.
|
||||
|
||||
// <nav> wrapper: flex row that also holds the jump-to-page form.
|
||||
.pager {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0 $spacer; // row-gap column-gap: no vertical gap when the summary drops to its own line.
|
||||
margin-block: $spacer;
|
||||
}
|
||||
|
||||
// Horizontal alignment set via PagerAlign.
|
||||
.pager-align-start {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.pager-align-center {
|
||||
justify-content: center;
|
||||
}
|
||||
.pager-align-end {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
// Pushes the rest of .pager (pagination, jump form) to the opposite side. With
|
||||
// PagerAlign::Start the summary swaps to the other end instead, since otherwise it would sit
|
||||
// right next to a pagination that's already at the start. With PagerAlign::Center an auto margin
|
||||
// wouldn't work -it always claims all the free space, so the pagination could never be genuinely
|
||||
// centered- so instead the summary drops to its own line below, centered too.
|
||||
.pager-summary {
|
||||
color: var(--bs-secondary-color);
|
||||
font-size: $font-size-sm;
|
||||
align-self: flex-start;
|
||||
margin-inline-end: auto;
|
||||
}
|
||||
.pager-align-start .pager-summary {
|
||||
order: 1;
|
||||
margin-inline-end: 0;
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
.pager-align-center .pager-summary {
|
||||
order: 1;
|
||||
flex-basis: 100%;
|
||||
text-align: center;
|
||||
margin-inline-end: 0;
|
||||
}
|
||||
|
||||
// By default, .page-link-icon shows the text of pager_previous_label/pager_next_label
|
||||
// ("Previous"/"Next"). The full accessible text lives separately, in the link's aria-label, so
|
||||
// replacing the visible text here with a symbol does not affect accessibility. To use "‹"/"›"
|
||||
// instead, uncomment:
|
||||
/*
|
||||
.page-link-icon {
|
||||
display: inline-block;
|
||||
font-size: 0;
|
||||
}
|
||||
.page-previous .page-link-icon::before {
|
||||
content: "‹";
|
||||
font-size: var(--bs-pagination-font-size);
|
||||
display: inline-block;
|
||||
transform: translateY(-0.1em) scale(1.5);
|
||||
}
|
||||
.page-next .page-link-icon::before {
|
||||
content: "›";
|
||||
font-size: var(--bs-pagination-font-size);
|
||||
display: inline-block;
|
||||
transform: translateY(-0.1em) scale(1.5);
|
||||
}
|
||||
*/
|
||||
|
||||
// Jump-to-page field and submit button, presented as a single joined control. Same block margins
|
||||
// as `ol`/`ul`/`dl` in Bootstrap's reboot (literal values there too, not variables).
|
||||
.pager-jump {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-top: 0;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
// One focus ring for the whole control instead of one per element.
|
||||
.pager-jump:focus-within {
|
||||
border-radius: $input-border-radius;
|
||||
box-shadow: $input-focus-box-shadow;
|
||||
}
|
||||
.pager-jump-input > .form-control:focus,
|
||||
.pager-jump-button:focus {
|
||||
box-shadow: none;
|
||||
}
|
||||
.pager-jump-input > .form-control {
|
||||
// Extra width equals twice the field's own horizontal padding.
|
||||
width: calc(var(--pager-jump-width, 1ch) + #{$input-padding-x * 4});
|
||||
text-align: center;
|
||||
appearance: textfield;
|
||||
-moz-appearance: textfield;
|
||||
|
||||
// Logical (not physical) corners, so the shape flips correctly under RTL.
|
||||
border-start-end-radius: 0;
|
||||
border-end-end-radius: 0;
|
||||
|
||||
&::-webkit-outer-spin-button,
|
||||
&::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
.pager-jump-input {
|
||||
margin: 0;
|
||||
}
|
||||
.pager-jump-button {
|
||||
@extend .btn-primary;
|
||||
border-start-start-radius: 0;
|
||||
border-end-start-radius: 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,3 +2,4 @@
|
|||
// so that Bootstrap's !default declarations do not override these values.
|
||||
|
||||
$font-size-base: 1.125rem;
|
||||
$navbar-brand-font-size: 1.75rem;
|
||||
|
|
|
|||
62
extensions/pagetop-bootsier/assets/bootsier.confirm.js
Normal file
62
extensions/pagetop-bootsier/assets/bootsier.confirm.js
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
(function () {
|
||||
'use strict';
|
||||
|
||||
// htmx dispatches `htmx:confirm` in place of `window.confirm()` whenever the element that
|
||||
// would issue a request carries `hx-confirm`. Intercepting it here upgrades every
|
||||
// `hx-confirm` in the project to a Bootstrap modal, without touching the element that
|
||||
// requested it or the request itself: cancelling is a no-op, confirming resumes the exact
|
||||
// same request via `evt.detail.issueRequest(true)`.
|
||||
if (typeof bootstrap === 'undefined' || !bootstrap.Modal) { return; }
|
||||
|
||||
var modalEl = null;
|
||||
var questionEl = null;
|
||||
var pendingEvent = null;
|
||||
|
||||
function ensureModal() {
|
||||
if (modalEl) { return; }
|
||||
|
||||
var okLabel = document.body.dataset.confirmOk || 'OK';
|
||||
var cancelLabel = document.body.dataset.confirmCancel || 'Cancel';
|
||||
|
||||
modalEl = document.createElement('div');
|
||||
modalEl.className = 'modal fade';
|
||||
modalEl.tabIndex = -1;
|
||||
modalEl.setAttribute('aria-hidden', 'true');
|
||||
modalEl.innerHTML =
|
||||
'<div class="modal-dialog">' +
|
||||
'<div class="modal-content">' +
|
||||
'<div class="modal-body"></div>' +
|
||||
'<div class="modal-footer">' +
|
||||
'<button type="button" class="btn btn-secondary" data-bs-dismiss="modal"></button>' +
|
||||
'<button type="button" class="btn btn-danger" data-confirm-accept></button>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
document.body.appendChild(modalEl);
|
||||
|
||||
questionEl = modalEl.querySelector('.modal-body');
|
||||
modalEl.querySelector('[data-bs-dismiss]').textContent = cancelLabel;
|
||||
var acceptBtn = modalEl.querySelector('[data-confirm-accept]');
|
||||
acceptBtn.textContent = okLabel;
|
||||
|
||||
acceptBtn.addEventListener('click', function () {
|
||||
var evt = pendingEvent;
|
||||
pendingEvent = null;
|
||||
bootstrap.Modal.getInstance(modalEl).hide();
|
||||
if (evt) { evt.detail.issueRequest(true); }
|
||||
});
|
||||
|
||||
modalEl.addEventListener('hidden.bs.modal', function () {
|
||||
pendingEvent = null;
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('htmx:confirm', function (evt) {
|
||||
if (!evt.detail.question) { return; }
|
||||
evt.preventDefault();
|
||||
ensureModal();
|
||||
questionEl.textContent = evt.detail.question;
|
||||
pendingEvent = evt;
|
||||
bootstrap.Modal.getOrCreateInstance(modalEl).show();
|
||||
});
|
||||
}());
|
||||
18
extensions/pagetop-bootsier/assets/bootsier.dialog.js
Normal file
18
extensions/pagetop-bootsier/assets/bootsier.dialog.js
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
(function () {
|
||||
'use strict';
|
||||
|
||||
// Bootstrap's `.modal` is a plain `position: fixed` element: it never leaves the CSS stacking
|
||||
// context of whatever ancestor it was rendered inside. If any ancestor sets its own `z-index`
|
||||
// (e.g. pagetop's `Intro` component, for its decorative layering), the modal's `z-index` only
|
||||
// ranks it among the elements of *that* ancestor's context, so `.modal-backdrop` -always a
|
||||
// direct child of `<body>`, added by Bootstrap itself- can end up rendering above it and
|
||||
// swallowing every click, even though `.modal`'s own `z-index` is numerically higher. Basic's
|
||||
// native `<dialog>` escapes this for free via the browser's "top layer"; Bootsier's `.modal`
|
||||
// does not, so it is moved to `<body>` here, exactly like Bootstrap already does with its own
|
||||
// backdrop.
|
||||
document.querySelectorAll('.modal.dialog').forEach(function (modal) {
|
||||
if (modal.parentElement !== document.body) {
|
||||
document.body.appendChild(modal);
|
||||
}
|
||||
});
|
||||
}());
|
||||
|
|
@ -45,6 +45,16 @@ fn main() -> std::io::Result<()> {
|
|||
"assets/bootsier.shell.js",
|
||||
"static/js/bootsier.shell.min.js",
|
||||
)?;
|
||||
// JS: fix de apilamiento para Dialog.
|
||||
minify_js(
|
||||
"assets/bootsier.dialog.js",
|
||||
"static/js/bootsier.dialog.min.js",
|
||||
)?;
|
||||
// JS: sustituye el `confirm()` nativo de htmx por un modal de Bootstrap.
|
||||
minify_js(
|
||||
"assets/bootsier.confirm.js",
|
||||
"static/js/bootsier.confirm.min.js",
|
||||
)?;
|
||||
|
||||
// Fuentes: Bootstrap Icons.
|
||||
copy_file(
|
||||
|
|
@ -81,5 +91,5 @@ fn main() -> std::io::Result<()> {
|
|||
|
||||
// Los archivos .map no se embeben en el binario; solo se sirven desde disco en desarrollo.
|
||||
fn only_js_files(path: &Path) -> bool {
|
||||
path.extension().map_or(false, |ext| ext == "js")
|
||||
path.extension().is_some_and(|ext| ext == "js")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -83,20 +83,13 @@ use pagetop::prelude::*;
|
|||
|
||||
include_locales!(LOCALES_BOOTSIER);
|
||||
|
||||
// Versión de la librería Bootstrap.
|
||||
pub(crate) const ADMINLTE_VERSION: &str = "4.0.0";
|
||||
const BOOTSTRAP_VERSION: &str = "5.3.8";
|
||||
|
||||
pub mod config;
|
||||
|
||||
pub mod theme;
|
||||
|
||||
mod handlers {
|
||||
pub mod button;
|
||||
pub mod input;
|
||||
pub mod select;
|
||||
pub mod textarea;
|
||||
}
|
||||
|
||||
/// Implementa el tema.
|
||||
pub struct Bootsier;
|
||||
|
||||
|
|
@ -132,9 +125,8 @@ impl Extension for Bootsier {
|
|||
|
||||
#[async_trait]
|
||||
impl Theme for Bootsier {
|
||||
#[inline]
|
||||
fn default_template(&self) -> TemplateRef {
|
||||
&BootsierTemplate::Standard
|
||||
fn intent_color(&self, intent: Intent) -> &'static str {
|
||||
theme::BootsierColors::from(intent).as_str()
|
||||
}
|
||||
|
||||
async fn handle_component(
|
||||
|
|
@ -147,12 +139,24 @@ impl Theme for Bootsier {
|
|||
Brand => |c| theme::bs::brand::setup(c),
|
||||
Button => |c| theme::bs::button::setup(c),
|
||||
Container => |c| theme::bs::container::setup(c),
|
||||
Dialog => |c| theme::bs::dialog::setup(c),
|
||||
Dropdown => |c| theme::bs::dropdown::setup(c),
|
||||
Image => |c| theme::bs::image::setup(c),
|
||||
Nav => |c| theme::bs::nav::setup(c),
|
||||
Navbar => |c| theme::bs::navbar::setup(c),
|
||||
form::input::Field => |c| theme::bs::form::input::setup(c),
|
||||
form::select::Field => |c| theme::bs::form::select::setup(c),
|
||||
form::Textarea => |c| theme::bs::form::textarea::setup(c),
|
||||
});
|
||||
|
||||
render_component!(component, {
|
||||
layout::Region => |c| theme::bs::layout::region::render(c, cx).await?,
|
||||
layout::Template => |c| theme::bs::layout::template::render(c, cx).await?,
|
||||
Dialog => |c| theme::bs::dialog::render(c, cx).await,
|
||||
Dropdown => |c| theme::bs::dropdown::render(c, cx).await,
|
||||
nav::Item => |c| theme::bs::nav::item_render(c, cx).await,
|
||||
Navbar => |c| theme::bs::navbar::render(c, cx).await,
|
||||
navbar::Item => |c| theme::bs::navbar::item_render(c, cx).await,
|
||||
form::input::Field => |c| theme::bs::form::input::render(c, cx),
|
||||
form::select::Field => |c| theme::bs::form::select::render(c, cx),
|
||||
form::Textarea => |c| theme::bs::form::textarea::render(c, cx),
|
||||
|
|
@ -160,6 +164,16 @@ impl Theme for Bootsier {
|
|||
}
|
||||
|
||||
fn before_render_page_body(&self, page: &mut Page) {
|
||||
// Etiquetas de los botones del modal de confirmación que sustituye al `confirm()` nativo
|
||||
// de htmx (ver `bootsier.confirm.js`); se leen aquí porque este es el único punto con
|
||||
// acceso al `Context` para resolverlas antes de escribirlas en el `<body>`.
|
||||
let confirm_ok = Lc::l("confirm_ok")
|
||||
.lookup(page.context())
|
||||
.unwrap_or_default();
|
||||
let confirm_cancel = Lc::l("confirm_cancel")
|
||||
.lookup(page.context())
|
||||
.unwrap_or_default();
|
||||
|
||||
// Las URLs de las fuentes deben coincidir exactamente con las declaradas en @font-face de
|
||||
// _bootsier-custom.scss; cualquier discrepancia hace que el navegador descargue dos veces.
|
||||
page.alter_assets(AssetsOp::AddPreload(
|
||||
|
|
@ -183,8 +197,20 @@ impl Theme for Bootsier {
|
|||
.with_version(ADMINLTE_VERSION)
|
||||
.with_weight(-99),
|
||||
))
|
||||
.alter_assets(AssetsOp::AddJavaScript(
|
||||
JavaScript::defer("/bootsier/js/bootsier.dialog.min.js")
|
||||
.with_version(BOOTSTRAP_VERSION)
|
||||
.with_weight(-99),
|
||||
))
|
||||
.alter_assets(AssetsOp::AddJavaScript(
|
||||
JavaScript::defer("/bootsier/js/bootsier.confirm.min.js")
|
||||
.with_version(BOOTSTRAP_VERSION)
|
||||
.with_weight(-99),
|
||||
))
|
||||
.alter_body_props(PropsOp::set("data-confirm-ok", confirm_ok))
|
||||
.alter_body_props(PropsOp::set("data-confirm-cancel", confirm_cancel))
|
||||
.alter_child_in(
|
||||
&DefaultRegion::Footer,
|
||||
&CoreRegions::Footer,
|
||||
ChildOp::AddIfEmpty(PoweredBy::new().into()),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,19 @@
|
|||
# Dropdown
|
||||
dropdown_toggle = Toggle Dropdown
|
||||
|
||||
# form::Input
|
||||
input_required = This field is required
|
||||
|
||||
# Navbar
|
||||
toggle = Toggle navigation
|
||||
|
||||
# Offcanvas
|
||||
offcanvas_close = Close
|
||||
|
||||
# Shell AdminLTE (BootsierTemplates::Admin)
|
||||
shell_fullscreen = Full screen
|
||||
shell_theme_toggle = Color mode selector
|
||||
shell_theme_light = Light
|
||||
shell_theme_dark = Dark
|
||||
shell_theme_auto = Auto
|
||||
|
||||
# Confirm dialog (htmx hx-confirm)
|
||||
confirm_ok = OK
|
||||
confirm_cancel = Cancel
|
||||
|
|
|
|||
|
|
@ -1,9 +1,3 @@
|
|||
region_header = Header
|
||||
region_nav_branding = Navigation branding region
|
||||
region_nav_main = Main navigation region
|
||||
region_nav_additional = Additional navigation region (eg search form, social icons, etc)
|
||||
region_breadcrumb = Breadcrumb
|
||||
region_content = Main content
|
||||
region_sidebar_first = Sidebar first
|
||||
region_sidebar_second = Sidebar second
|
||||
region_footer = Footer
|
||||
# BootsierRegions
|
||||
region_sidebar = Sidebar
|
||||
region_navbar = Navigation bar
|
||||
|
|
|
|||
|
|
@ -1,11 +1,19 @@
|
|||
# Dropdown
|
||||
dropdown_toggle = Mostrar/ocultar menú
|
||||
|
||||
# form::Input
|
||||
input_required = Este campo es obligatorio
|
||||
|
||||
# Navbar
|
||||
toggle = Mostrar/ocultar navegación
|
||||
|
||||
# Offcanvas
|
||||
offcanvas_close = Cerrar
|
||||
|
||||
# Shell AdminLTE (BootsierTemplates::Admin)
|
||||
shell_fullscreen = Pantalla completa
|
||||
shell_theme_toggle = Selector de modo de color
|
||||
shell_theme_light = Claro
|
||||
shell_theme_dark = Oscuro
|
||||
shell_theme_auto = Automático
|
||||
|
||||
# Confirm dialog (htmx hx-confirm)
|
||||
confirm_ok = Aceptar
|
||||
confirm_cancel = Cancelar
|
||||
|
|
|
|||
|
|
@ -1,9 +1,3 @@
|
|||
region_header = Cabecera
|
||||
region_nav_branding = Navegación y marca
|
||||
region_nav_main = Navegación principal
|
||||
region_nav_additional = Navegación adicional (p.e. formulario de búsqueda, iconos sociales, etc.)
|
||||
region_breadcrumb = Ruta de posicionamiento
|
||||
region_content = Contenido principal
|
||||
region_sidebar_first = Barra lateral primera
|
||||
region_sidebar_second = Barra lateral segunda
|
||||
region_footer = Pie
|
||||
# BootsierRegions
|
||||
region_sidebar = Barra lateral
|
||||
region_navbar = Barra de navegación
|
||||
|
|
|
|||
|
|
@ -1,4 +1,92 @@
|
|||
//! Definiciones y plantillas del tema Bootsier.
|
||||
//!
|
||||
//! El módulo [`bs`] expone todos los tipos y componentes disponibles. Para usarlos sin ambigüedad
|
||||
//! junto a `use pagetop::prelude::*`, y cargar también los traits del tema, importa este módulo
|
||||
//! con glob:
|
||||
//!
|
||||
//! ```rust,no_run
|
||||
//! use pagetop::prelude::*;
|
||||
//! use pagetop_bootsier::theme::*;
|
||||
//! ```
|
||||
//!
|
||||
//! # Plantillas
|
||||
//!
|
||||
//! Bootsier maqueta las dos plantillas de PageTop
|
||||
//! ([`CoreTemplates`](pagetop::prelude::CoreTemplates)): `Standard`, con
|
||||
//! cabecera, contenido y pie, que es la plantilla por defecto de cualquier página; y `Admin`, con
|
||||
//! la shell completa de AdminLTE 4 (barra superior + barra lateral + área de contenido), que se
|
||||
//! activa creando la página con [`Page::admin()`](pagetop::response::Page::admin) en lugar de
|
||||
//! [`Page::new()`](pagetop::response::Page::new). No define sus propias variantes de plantilla:
|
||||
//! intercepta el componente `Template` en `handle_component()` (ver `bs::layout`).
|
||||
//!
|
||||
//! ```rust,no_run
|
||||
//! use pagetop::prelude::*;
|
||||
//!
|
||||
//! async fn about(request: HttpRequest) -> Result<Markup, ErrorPage> {
|
||||
//! Page::new(request)
|
||||
//! .with_child(Html::with(|_| html! {
|
||||
//! h1 { "Sobre nosotros" }
|
||||
//! p { "Texto de presentación." }
|
||||
//! }))
|
||||
//! .render().await
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! # Barra lateral
|
||||
//!
|
||||
//! Registra elementos en [`BootsierRegions::Sidebar`](bs::BootsierRegions::Sidebar) para poblar la
|
||||
//! barra lateral de la shell. Los elementos esperados son [`bs::sidebar::Item`] y
|
||||
//! [`bs::sidebar::Section`].
|
||||
//!
|
||||
//! De forma **global** (visibles en todas las páginas de administración):
|
||||
//!
|
||||
//! ```rust,no_run
|
||||
//! use pagetop::prelude::*;
|
||||
//! use pagetop_bootsier::theme::bs::{BootsierRegions, sidebar};
|
||||
//!
|
||||
//! 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"));
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! O de forma **por página**:
|
||||
//!
|
||||
//! ```rust,no_run
|
||||
//! use pagetop::prelude::*;
|
||||
//! use pagetop_bootsier::theme::bs::{BootsierRegions, sidebar};
|
||||
//!
|
||||
//! async fn settings(request: HttpRequest) -> Result<Markup, ErrorPage> {
|
||||
//! Page::admin(request)
|
||||
//! .with_child_in(
|
||||
//! &BootsierRegions::Sidebar,
|
||||
//! sidebar::Item::link(Lc::n("Ajustes"), "/settings", "gear"),
|
||||
//! )
|
||||
//! .with_child(Html::with(|_| html! { h3 { "Ajustes" } }))
|
||||
//! .render().await
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! # Barra de navegación superior
|
||||
//!
|
||||
//! La barra superior incluye por defecto los controles de pantalla completa y selector de tema.
|
||||
//! Para añadir elementos adicionales en el lado derecho (por ejemplo, el dropdown de usuario de
|
||||
//! `pagetop-user`), registra componentes en
|
||||
//! [`BootsierRegions::Navbar`](bs::BootsierRegions::Navbar):
|
||||
//!
|
||||
//! ```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" }
|
||||
//! }
|
||||
//! }));
|
||||
//! ```
|
||||
|
||||
pub mod bs;
|
||||
|
||||
|
|
@ -10,30 +98,18 @@ pub use token::*;
|
|||
#[doc(hidden)]
|
||||
pub use bs::badge::BadgeBootsier;
|
||||
#[doc(hidden)]
|
||||
pub use bs::button::ButtonBootsier;
|
||||
#[doc(hidden)]
|
||||
pub use bs::container::ContainerBootsier;
|
||||
#[doc(hidden)]
|
||||
pub use bs::dropdown::DropdownBootsier;
|
||||
#[doc(hidden)]
|
||||
pub use bs::form::input::InputBootsier;
|
||||
#[doc(hidden)]
|
||||
pub use bs::form::select::SelectBootsier;
|
||||
#[doc(hidden)]
|
||||
pub use bs::form::textarea::TextareaBootsier;
|
||||
|
||||
// Image.
|
||||
pub mod image;
|
||||
#[doc(inline)]
|
||||
pub use image::Image;
|
||||
|
||||
// Nav.
|
||||
pub mod nav;
|
||||
#[doc(inline)]
|
||||
pub use nav::Nav;
|
||||
|
||||
// Navbar.
|
||||
pub mod navbar;
|
||||
#[doc(inline)]
|
||||
pub use navbar::Navbar;
|
||||
|
||||
// Offcanvas.
|
||||
pub mod offcanvas;
|
||||
#[doc(inline)]
|
||||
pub use offcanvas::Offcanvas;
|
||||
#[doc(hidden)]
|
||||
pub use bs::nav::NavBootsier;
|
||||
#[doc(hidden)]
|
||||
pub use bs::navbar::NavbarBootsier;
|
||||
|
|
|
|||
|
|
@ -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,44 +140,86 @@ 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,
|
||||
pub trait NavbarBootsier {
|
||||
/// Crea una barra de navegación cuyo contenido se muestra en un **offcanvas**.
|
||||
fn offcanvas(oc: bs::Offcanvas) -> Self;
|
||||
|
||||
/// 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 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]
|
||||
fn with_expand(self, bp: BreakPoint) -> Self;
|
||||
|
||||
/// Define dónde se mostrará la barra de navegación dentro del documento.
|
||||
#[builder_fn]
|
||||
fn with_position(self, position: bs::navbar::Position) -> Self;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Component for Navbar {
|
||||
fn new() -> Self {
|
||||
Self::default()
|
||||
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 id(&self) -> Option<String> {
|
||||
self.props.get_id()
|
||||
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 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
|
||||
}));
|
||||
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
|
||||
}
|
||||
|
||||
async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
|
||||
#[builder_fn]
|
||||
fn with_expand(mut self, bp: BreakPoint) -> Self {
|
||||
self.alter_prop(PropsOp::set_extra(EXTRA_EXPAND, bp));
|
||||
self
|
||||
}
|
||||
|
||||
#[builder_fn]
|
||||
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);
|
||||
|
|
@ -197,18 +244,28 @@ impl Component for Navbar {
|
|||
}
|
||||
|
||||
// Si no hay contenidos, no tiene sentido mostrar una barra vacía.
|
||||
let items = self.items().render(cx).await;
|
||||
let items = navbar.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();
|
||||
// `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 (self.props()) {
|
||||
nav (navbar.props()) {
|
||||
div class="container-fluid" {
|
||||
@match self.layout() {
|
||||
@match layout {
|
||||
// Barra más sencilla: sólo contenido.
|
||||
bs::navbar::Layout::Simple => {
|
||||
(items)
|
||||
|
|
@ -216,7 +273,7 @@ impl Component for Navbar {
|
|||
|
||||
// Barra sencilla que se puede contraer/expandir.
|
||||
bs::navbar::Layout::SimpleToggle => {
|
||||
@let id_content = util::join!(id, "-content");
|
||||
@let id_content = util::join!(&id, "-content");
|
||||
|
||||
(button(cx, TOGGLE_COLLAPSE, &id_content))
|
||||
div id=(&id_content) class="collapse navbar-collapse" {
|
||||
|
|
@ -232,7 +289,7 @@ impl Component for Navbar {
|
|||
|
||||
// Barra con marca a la izquierda y botón a la derecha.
|
||||
bs::navbar::Layout::BrandLeft(brand) => {
|
||||
@let id_content = util::join!(id, "-content");
|
||||
@let id_content = util::join!(&id, "-content");
|
||||
|
||||
(brand.render(cx).await)
|
||||
(button(cx, TOGGLE_COLLAPSE, &id_content))
|
||||
|
|
@ -243,7 +300,7 @@ impl Component for Navbar {
|
|||
|
||||
// Barra con botón a la izquierda y marca a la derecha.
|
||||
bs::navbar::Layout::BrandRight(brand) => {
|
||||
@let id_content = util::join!(id, "-content");
|
||||
@let id_content = util::join!(&id, "-content");
|
||||
|
||||
(button(cx, TOGGLE_COLLAPSE, &id_content))
|
||||
(brand.render(cx).await)
|
||||
|
|
@ -258,7 +315,7 @@ impl Component for Navbar {
|
|||
|
||||
(button(cx, TOGGLE_OFFCANVAS, &id_content))
|
||||
@if let Some(oc) = offcanvas.get() {
|
||||
(oc.render_offcanvas(cx, Some(self.items())).await)
|
||||
(oc.render_offcanvas(cx, Some(navbar.items())).await)
|
||||
}
|
||||
},
|
||||
|
||||
|
|
@ -269,7 +326,7 @@ impl Component for Navbar {
|
|||
(brand.render(cx).await)
|
||||
(button(cx, TOGGLE_OFFCANVAS, &id_content))
|
||||
@if let Some(oc) = offcanvas.get() {
|
||||
(oc.render_offcanvas(cx, Some(self.items())).await)
|
||||
(oc.render_offcanvas(cx, Some(navbar.items())).await)
|
||||
}
|
||||
},
|
||||
|
||||
|
|
@ -280,120 +337,27 @@ impl Component for Navbar {
|
|||
(button(cx, TOGGLE_OFFCANVAS, &id_content))
|
||||
(brand.render(cx).await)
|
||||
@if let Some(oc) = offcanvas.get() {
|
||||
(oc.render_offcanvas(cx, Some(self.items())).await)
|
||||
(oc.render_offcanvas(cx, Some(navbar.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)
|
||||
// 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())
|
||||
}
|
||||
|
||||
/// 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)))
|
||||
}
|
||||
|
||||
/// 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)))
|
||||
}
|
||||
|
||||
/// 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 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
|
||||
}
|
||||
|
||||
/// 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
|
||||
}
|
||||
|
||||
/// 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;
|
||||
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());
|
||||
self
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
// 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) })
|
||||
}
|
||||
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-*`).
|
||||
|
|
|
|||
46
extensions/pagetop-bootsier/tests/badge_color.rs
Normal file
46
extensions/pagetop-bootsier/tests/badge_color.rs
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
// Verifies `BadgeBootsier::with_color()`: it overrides the Bootstrap color that `Badge` would
|
||||
// otherwise derive from its `Intent`, without disturbing the class that `Badge::setup()` (core)
|
||||
// already generated from that `Intent`.
|
||||
|
||||
use pagetop::prelude::*;
|
||||
use pagetop_bootsier::Bootsier;
|
||||
use pagetop_bootsier::theme::*;
|
||||
|
||||
#[pagetop::test]
|
||||
async fn without_an_override_the_color_comes_from_the_intent() {
|
||||
let mut badge = Badge::labeled(Lc::n("Admin")).with_intent(Intent::Severe);
|
||||
let html = badge
|
||||
.render(&mut Context::default().with_theme(&Bootsier))
|
||||
.await
|
||||
.into_string();
|
||||
|
||||
assert!(html.contains("text-bg-danger"));
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn with_color_overrides_the_intent_derived_color() {
|
||||
let mut badge = Badge::labeled(Lc::n("Beta"))
|
||||
.with_intent(Intent::Severe)
|
||||
.with_color(BootsierColors::Dark);
|
||||
let html = badge
|
||||
.render(&mut Context::default().with_theme(&Bootsier))
|
||||
.await
|
||||
.into_string();
|
||||
|
||||
assert!(html.contains("text-bg-dark"));
|
||||
assert!(!html.contains("text-bg-danger"));
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn with_color_none_restores_the_intent_derived_color() {
|
||||
let mut badge = Badge::labeled(Lc::n("Beta"))
|
||||
.with_intent(Intent::Severe)
|
||||
.with_color(BootsierColors::Dark)
|
||||
.with_color(None);
|
||||
let html = badge
|
||||
.render(&mut Context::default().with_theme(&Bootsier))
|
||||
.await
|
||||
.into_string();
|
||||
|
||||
assert!(html.contains("text-bg-danger"));
|
||||
}
|
||||
29
extensions/pagetop-bootsier/tests/intent_color.rs
Normal file
29
extensions/pagetop-bootsier/tests/intent_color.rs
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
// Verifies that `Bootsier` overrides `Theme::intent_color()` to translate `Intent` to its own
|
||||
// Bootstrap color names, instead of PageTop's own semantic vocabulary.
|
||||
|
||||
use pagetop::prelude::*;
|
||||
use pagetop_bootsier::Bootsier;
|
||||
|
||||
#[pagetop::test]
|
||||
async fn bootsier_translates_button_intent_to_its_bootstrap_color() {
|
||||
let mut button = Button::submit(Lc::n("Save")).with_style(button::Style::Solid(Intent::Severe));
|
||||
let html = button
|
||||
.render(&mut Context::default().with_theme(&Bootsier))
|
||||
.await
|
||||
.into_string();
|
||||
|
||||
assert!(html.contains("btn-danger"));
|
||||
assert!(!html.contains("severe"));
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn bootsier_translates_badge_intent_to_its_bootstrap_color() {
|
||||
let mut badge = Badge::labeled(Lc::n("Admin")).with_intent(Intent::Severe);
|
||||
let html = badge
|
||||
.render(&mut Context::default().with_theme(&Bootsier))
|
||||
.await
|
||||
.into_string();
|
||||
|
||||
assert!(html.contains("text-bg-danger"));
|
||||
assert!(!html.contains("severe"));
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue