✨ (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;
|
struct MyApp;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
impl Extension for MyApp {
|
impl Extension for MyApp {
|
||||||
fn dependencies(&self) -> Vec<ExtensionRef> {
|
fn dependencies(&self) -> Vec<ExtensionRef> {
|
||||||
vec para
|
Este *crate* integra la biblioteca de estilos [Bootstrap 5.3.8](https://getbootstrap.com/) 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.
|
// 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.
|
// 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 with border and floating legend.
|
||||||
fieldset {
|
fieldset {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
@ -83,3 +92,153 @@ fieldset > legend {
|
||||||
padding: 0.75rem 0 3rem;
|
padding: 0.75rem 0 3rem;
|
||||||
text-align: center;
|
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.
|
// so that Bootstrap's !default declarations do not override these values.
|
||||||
|
|
||||||
$font-size-base: 1.125rem;
|
$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",
|
"assets/bootsier.shell.js",
|
||||||
"static/js/bootsier.shell.min.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.
|
// Fuentes: Bootstrap Icons.
|
||||||
copy_file(
|
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.
|
// Los archivos .map no se embeben en el binario; solo se sirven desde disco en desarrollo.
|
||||||
fn only_js_files(path: &Path) -> bool {
|
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);
|
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";
|
const BOOTSTRAP_VERSION: &str = "5.3.8";
|
||||||
|
|
||||||
pub mod config;
|
pub mod config;
|
||||||
|
|
||||||
pub mod theme;
|
pub mod theme;
|
||||||
|
|
||||||
mod handlers {
|
|
||||||
pub mod button;
|
|
||||||
pub mod input;
|
|
||||||
pub mod select;
|
|
||||||
pub mod textarea;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Implementa el tema.
|
/// Implementa el tema.
|
||||||
pub struct Bootsier;
|
pub struct Bootsier;
|
||||||
|
|
||||||
|
|
@ -132,9 +125,8 @@ impl Extension for Bootsier {
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl Theme for Bootsier {
|
impl Theme for Bootsier {
|
||||||
#[inline]
|
fn intent_color(&self, intent: Intent) -> &'static str {
|
||||||
fn default_template(&self) -> TemplateRef {
|
theme::BootsierColors::from(intent).as_str()
|
||||||
&BootsierTemplate::Standard
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_component(
|
async fn handle_component(
|
||||||
|
|
@ -147,12 +139,24 @@ impl Theme for Bootsier {
|
||||||
Brand => |c| theme::bs::brand::setup(c),
|
Brand => |c| theme::bs::brand::setup(c),
|
||||||
Button => |c| theme::bs::button::setup(c),
|
Button => |c| theme::bs::button::setup(c),
|
||||||
Container => |c| theme::bs::container::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),
|
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::input::Field => |c| theme::bs::form::input::setup(c),
|
||||||
form::select::Field => |c| theme::bs::form::select::setup(c),
|
form::select::Field => |c| theme::bs::form::select::setup(c),
|
||||||
form::Textarea => |c| theme::bs::form::textarea::setup(c),
|
form::Textarea => |c| theme::bs::form::textarea::setup(c),
|
||||||
});
|
});
|
||||||
|
|
||||||
render_component!(component, {
|
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::input::Field => |c| theme::bs::form::input::render(c, cx),
|
||||||
form::select::Field => |c| theme::bs::form::select::render(c, cx),
|
form::select::Field => |c| theme::bs::form::select::render(c, cx),
|
||||||
form::Textarea => |c| theme::bs::form::textarea::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) {
|
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
|
// 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.
|
// _bootsier-custom.scss; cualquier discrepancia hace que el navegador descargue dos veces.
|
||||||
page.alter_assets(AssetsOp::AddPreload(
|
page.alter_assets(AssetsOp::AddPreload(
|
||||||
|
|
@ -183,8 +197,20 @@ impl Theme for Bootsier {
|
||||||
.with_version(ADMINLTE_VERSION)
|
.with_version(ADMINLTE_VERSION)
|
||||||
.with_weight(-99),
|
.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(
|
.alter_child_in(
|
||||||
&DefaultRegion::Footer,
|
&CoreRegions::Footer,
|
||||||
ChildOp::AddIfEmpty(PoweredBy::new().into()),
|
ChildOp::AddIfEmpty(PoweredBy::new().into()),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,19 @@
|
||||||
# Dropdown
|
# Dropdown
|
||||||
dropdown_toggle = Toggle Dropdown
|
dropdown_toggle = Toggle Dropdown
|
||||||
|
|
||||||
# form::Input
|
|
||||||
input_required = This field is required
|
|
||||||
|
|
||||||
# Navbar
|
# Navbar
|
||||||
toggle = Toggle navigation
|
toggle = Toggle navigation
|
||||||
|
|
||||||
# Offcanvas
|
# Offcanvas
|
||||||
offcanvas_close = Close
|
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
|
# BootsierRegions
|
||||||
region_nav_branding = Navigation branding region
|
region_sidebar = Sidebar
|
||||||
region_nav_main = Main navigation region
|
region_navbar = Navigation bar
|
||||||
region_nav_additional = Additional navigation region (eg search form, social icons, etc)
|
|
||||||
region_breadcrumb = Breadcrumb
|
|
||||||
region_content = Main content
|
|
||||||
region_sidebar_first = Sidebar first
|
|
||||||
region_sidebar_second = Sidebar second
|
|
||||||
region_footer = Footer
|
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,19 @@
|
||||||
# Dropdown
|
# Dropdown
|
||||||
dropdown_toggle = Mostrar/ocultar menú
|
dropdown_toggle = Mostrar/ocultar menú
|
||||||
|
|
||||||
# form::Input
|
|
||||||
input_required = Este campo es obligatorio
|
|
||||||
|
|
||||||
# Navbar
|
# Navbar
|
||||||
toggle = Mostrar/ocultar navegación
|
toggle = Mostrar/ocultar navegación
|
||||||
|
|
||||||
# Offcanvas
|
# Offcanvas
|
||||||
offcanvas_close = Cerrar
|
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
|
# BootsierRegions
|
||||||
region_nav_branding = Navegación y marca
|
region_sidebar = Barra lateral
|
||||||
region_nav_main = Navegación principal
|
region_navbar = Barra de navegación
|
||||||
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
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,92 @@
|
||||||
//! Definiciones y plantillas del tema Bootsier.
|
//! 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;
|
pub mod bs;
|
||||||
|
|
||||||
|
|
@ -10,30 +98,18 @@ pub use token::*;
|
||||||
#[doc(hidden)]
|
#[doc(hidden)]
|
||||||
pub use bs::badge::BadgeBootsier;
|
pub use bs::badge::BadgeBootsier;
|
||||||
#[doc(hidden)]
|
#[doc(hidden)]
|
||||||
|
pub use bs::button::ButtonBootsier;
|
||||||
|
#[doc(hidden)]
|
||||||
pub use bs::container::ContainerBootsier;
|
pub use bs::container::ContainerBootsier;
|
||||||
#[doc(hidden)]
|
#[doc(hidden)]
|
||||||
|
pub use bs::dropdown::DropdownBootsier;
|
||||||
|
#[doc(hidden)]
|
||||||
pub use bs::form::input::InputBootsier;
|
pub use bs::form::input::InputBootsier;
|
||||||
#[doc(hidden)]
|
#[doc(hidden)]
|
||||||
pub use bs::form::select::SelectBootsier;
|
pub use bs::form::select::SelectBootsier;
|
||||||
#[doc(hidden)]
|
#[doc(hidden)]
|
||||||
pub use bs::form::textarea::TextareaBootsier;
|
pub use bs::form::textarea::TextareaBootsier;
|
||||||
|
#[doc(hidden)]
|
||||||
// Image.
|
pub use bs::nav::NavBootsier;
|
||||||
pub mod image;
|
#[doc(hidden)]
|
||||||
#[doc(inline)]
|
pub use bs::navbar::NavbarBootsier;
|
||||||
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;
|
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,27 @@
|
||||||
//! Componentes proporcionados por el tema.
|
//! Componentes proporcionados por el tema.
|
||||||
|
|
||||||
|
pub(crate) mod layout;
|
||||||
|
pub use layout::BootsierRegions;
|
||||||
|
|
||||||
// Badge.
|
// Badge.
|
||||||
pub(crate) mod 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.
|
// Button.
|
||||||
mod button;
|
pub mod button;
|
||||||
pub use button::{Button, ButtonAction};
|
pub use button::{Button, ButtonBootsier};
|
||||||
|
|
||||||
// Container.
|
// Container.
|
||||||
pub mod container;
|
pub mod container;
|
||||||
|
|
@ -15,10 +30,17 @@ pub use container::Container;
|
||||||
#[doc(inline)]
|
#[doc(inline)]
|
||||||
pub use container::ContainerBootsier;
|
pub use container::ContainerBootsier;
|
||||||
|
|
||||||
|
// Dialog.
|
||||||
|
pub mod dialog;
|
||||||
|
#[doc(inline)]
|
||||||
|
pub use dialog::Dialog;
|
||||||
|
|
||||||
// Dropdown.
|
// Dropdown.
|
||||||
pub mod dropdown;
|
pub mod dropdown;
|
||||||
#[doc(inline)]
|
#[doc(inline)]
|
||||||
pub use dropdown::Dropdown;
|
pub use dropdown::Dropdown;
|
||||||
|
#[doc(inline)]
|
||||||
|
pub use dropdown::DropdownBootsier;
|
||||||
|
|
||||||
// Form.
|
// Form.
|
||||||
pub mod form;
|
pub mod form;
|
||||||
|
|
@ -36,20 +58,35 @@ pub mod image;
|
||||||
#[doc(inline)]
|
#[doc(inline)]
|
||||||
pub use image::Image;
|
pub use image::Image;
|
||||||
|
|
||||||
|
// Messages.
|
||||||
|
pub use pagetop::base::component::Messages;
|
||||||
|
|
||||||
// Nav.
|
// Nav.
|
||||||
pub mod nav;
|
pub mod nav;
|
||||||
#[doc(inline)]
|
#[doc(inline)]
|
||||||
pub use nav::Nav;
|
pub use nav::Nav;
|
||||||
|
#[doc(inline)]
|
||||||
|
pub use nav::NavBootsier;
|
||||||
|
|
||||||
// Navbar.
|
// Navbar.
|
||||||
pub mod navbar;
|
pub mod navbar;
|
||||||
#[doc(inline)]
|
#[doc(inline)]
|
||||||
pub use navbar::Navbar;
|
pub use navbar::Navbar;
|
||||||
|
#[doc(inline)]
|
||||||
|
pub use navbar::NavbarBootsier;
|
||||||
|
|
||||||
// Offcanvas.
|
// Offcanvas.
|
||||||
pub mod offcanvas;
|
pub mod offcanvas;
|
||||||
#[doc(inline)]
|
#[doc(inline)]
|
||||||
pub use offcanvas::Offcanvas;
|
pub use offcanvas::Offcanvas;
|
||||||
|
|
||||||
|
// Pager.
|
||||||
|
pub use pagetop::base::component::{Pager, PagerAlign, PagerVisibility};
|
||||||
|
|
||||||
// Sidebar (componentes de navegación de AdminLTE).
|
// Sidebar (componentes de navegación de AdminLTE).
|
||||||
pub mod sidebar;
|
pub mod sidebar;
|
||||||
|
|
||||||
|
// Table.
|
||||||
|
pub use pagetop::base::component::table;
|
||||||
|
#[doc(inline)]
|
||||||
|
pub use table::Table;
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,57 @@
|
||||||
use pagetop::prelude::*;
|
use pagetop::prelude::*;
|
||||||
|
|
||||||
|
use crate::theme::BootsierColors;
|
||||||
|
|
||||||
pub use pagetop::base::component::Badge;
|
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 >********************************************************************************
|
// **< Badge SETUP >********************************************************************************
|
||||||
|
|
||||||
pub(crate) fn setup(badge: &mut Badge) {
|
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(
|
badge.alter_prop(PropsOp::replace_classes(
|
||||||
util::join!("badge-", intent),
|
util::join!("badge-", intent_color),
|
||||||
util::join!("text-bg-", intent),
|
util::join!("text-bg-", color),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,136 @@
|
||||||
use pagetop::prelude::*;
|
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 >*******************************************************************************
|
// **< Button SETUP >*******************************************************************************
|
||||||
|
|
||||||
pub(crate) fn setup(button: &mut Button) {
|
pub(crate) fn setup(button: &mut Button) {
|
||||||
button.alter_prop(PropsOp::replace_classes("button", "btn"));
|
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()
|
/// let main = bs::Container::main()
|
||||||
/// .with_id("main-page")
|
/// .with_id("main-page")
|
||||||
/// .with_width(bs::container::Width::From(BreakPoint::LG))
|
/// .with_width(bs::container::Width::From(BreakPoint::LG))
|
||||||
/// .with_prop(PropsOp::add_classes(class::Bg::with(ThemeColor::Light)))
|
/// .with_prop(PropsOp::add_classes(class::Bg::with(BootsierColors::Light)))
|
||||||
/// .with_prop(PropsOp::add_classes(class::Text::with(ThemeColor::Dark)))
|
/// .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::Border::with(ScaleSize::One)))
|
||||||
/// .with_prop(PropsOp::add_classes(class::Rounded::new()));
|
/// .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`]).
|
//! 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
|
//! desplegable [`Dropdown`], con distintos comportamientos según su finalidad, como enlaces de
|
||||||
//! navegación, botones de acción, encabezados o divisores visuales.
|
//! 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
|
//! 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;
|
mod props;
|
||||||
pub use props::{AutoClose, Direction, MenuAlign, MenuPosition};
|
pub use props::{AutoClose, Direction, MenuAlign, MenuPosition};
|
||||||
|
|
||||||
mod component;
|
pub use pagetop::base::component::Dropdown;
|
||||||
pub use component::Dropdown;
|
pub use pagetop::base::component::dropdown::{Item, ItemKind};
|
||||||
|
|
||||||
mod item;
|
const EXTRA_BUTTON_GROUPED: &str = "bootsier.dropdown.button_grouped";
|
||||||
pub use item::{Item, ItemKind};
|
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).
|
/// 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
|
/// 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)]
|
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
|
||||||
pub enum MenuAlign {
|
pub enum MenuAlign {
|
||||||
/// Alineación al inicio (comportamiento por defecto).
|
/// Alineación al inicio (comportamiento por defecto).
|
||||||
#[default]
|
#[default]
|
||||||
Start,
|
Start,
|
||||||
/// Alineación al inicio a partir del punto de ruptura indicado.
|
/// 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.
|
/// 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.
|
/// Alineación al final.
|
||||||
End,
|
End,
|
||||||
/// Alineación al final a partir del punto de ruptura indicado.
|
/// 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.
|
/// 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 {
|
impl MenuAlign {
|
||||||
|
|
@ -145,13 +145,13 @@ impl MenuAlign {
|
||||||
|
|
||||||
// `dropdown-menu-start` + `dropdown-menu-{bp}-end`
|
// `dropdown-menu-start` + `dropdown-menu-{bp}-end`
|
||||||
Self::StartAndEnd(bp) => {
|
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");
|
bp.push_to(classes, "dropdown-menu", "end");
|
||||||
}
|
}
|
||||||
|
|
||||||
// `dropdown-menu-end`
|
// `dropdown-menu-end`
|
||||||
Self::End => {
|
Self::End => {
|
||||||
token::BreakPoint::None.push_to(classes, "dropdown-menu", "end");
|
BreakPoint::None.push_to(classes, "dropdown-menu", "end");
|
||||||
}
|
}
|
||||||
|
|
||||||
// `dropdown-menu-{bp}-end`
|
// `dropdown-menu-{bp}-end`
|
||||||
|
|
@ -161,7 +161,7 @@ impl MenuAlign {
|
||||||
|
|
||||||
// `dropdown-menu-end` + `dropdown-menu-{bp}-start`
|
// `dropdown-menu-end` + `dropdown-menu-{bp}-start`
|
||||||
Self::EndAndStart(bp) => {
|
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");
|
bp.push_to(classes, "dropdown-menu", "start");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,8 @@ pub use textarea::Textarea;
|
||||||
#[doc(inline)]
|
#[doc(inline)]
|
||||||
pub use textarea::TextareaBootsier;
|
pub use textarea::TextareaBootsier;
|
||||||
|
|
||||||
|
pub use pagetop::base::component::form::Number;
|
||||||
|
|
||||||
pub use pagetop::base::component::form::Range;
|
pub use pagetop::base::component::form::Range;
|
||||||
|
|
||||||
pub use pagetop::base::component::form::Hidden;
|
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.
|
/// Devuelve identificador, clases CSS, atributos HTML y valores extra del componente.
|
||||||
props: Props,
|
props: Props,
|
||||||
icon_kind: IconKind,
|
icon_kind: IconKind,
|
||||||
aria_label: AttrL10n,
|
aria_label: AttrLc,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
|
|
@ -124,7 +124,7 @@ impl Icon {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[builder_fn]
|
#[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.aria_label.alter_value(label);
|
||||||
self
|
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`]).
|
//! 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
|
//! [`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
|
//! 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;
|
mod props;
|
||||||
pub use props::{Kind, Layout};
|
pub use props::Kind;
|
||||||
|
|
||||||
mod component;
|
pub use pagetop::base::component::Nav;
|
||||||
pub use component::Nav;
|
pub use pagetop::base::component::nav::{Item, ItemKind};
|
||||||
|
|
||||||
mod item;
|
const EXTRA_KIND: &str = "bootsier.nav.kind";
|
||||||
pub use item::{Item, ItemKind};
|
|
||||||
|
// 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
|
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;
|
mod props;
|
||||||
pub use props::{Layout, Position};
|
pub use props::{Layout, Position};
|
||||||
|
|
||||||
mod brand;
|
pub use super::Brand;
|
||||||
pub use brand::Brand;
|
|
||||||
|
pub use pagetop::base::component::Navbar;
|
||||||
|
pub use pagetop::base::component::navbar::Item;
|
||||||
|
|
||||||
mod component;
|
mod component;
|
||||||
pub use component::Navbar;
|
pub use component::NavbarBootsier;
|
||||||
|
pub(crate) use component::{render, setup};
|
||||||
|
|
||||||
mod item;
|
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_COLLAPSE: &str = "collapse";
|
||||||
const TOGGLE_OFFCANVAS: &str = "offcanvas";
|
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
|
/// 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
|
/// 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
|
/// [`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).
|
/// 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
|
||||||
/// Si no contiene elementos, el componente **no se renderiza**.
|
/// [`with_expand()`](Self::with_expand).
|
||||||
///
|
///
|
||||||
/// # Ejemplos
|
/// # Ejemplos
|
||||||
///
|
///
|
||||||
|
|
@ -52,9 +56,9 @@ const TOGGLE_OFFCANVAS: &str = "offcanvas";
|
||||||
/// ```rust,no_run
|
/// ```rust,no_run
|
||||||
/// # use pagetop::prelude::*;
|
/// # use pagetop::prelude::*;
|
||||||
/// # use pagetop_bootsier::theme::*;
|
/// # use pagetop_bootsier::theme::*;
|
||||||
/// let brand = bs::navbar::Brand::new()
|
/// let brand = Brand::new()
|
||||||
/// .with_title(Lc::n("PageTop"))
|
/// .with_title(Lc::n("PageTop"))
|
||||||
/// .with_route(Some("/".into()));
|
/// .with_route(Route::from("/"));
|
||||||
///
|
///
|
||||||
/// let navbar = bs::Navbar::brand_left(brand)
|
/// let navbar = bs::Navbar::brand_left(brand)
|
||||||
/// .with_item(bs::navbar::Item::nav(
|
/// .with_item(bs::navbar::Item::nav(
|
||||||
|
|
@ -79,14 +83,15 @@ const TOGGLE_OFFCANVAS: &str = "offcanvas";
|
||||||
/// ```rust,no_run
|
/// ```rust,no_run
|
||||||
/// # use pagetop::prelude::*;
|
/// # use pagetop::prelude::*;
|
||||||
/// # use pagetop_bootsier::theme::*;
|
/// # use pagetop_bootsier::theme::*;
|
||||||
/// let brand = bs::navbar::Brand::new()
|
/// let brand = Brand::new()
|
||||||
/// .with_title(Lc::n("Intranet"))
|
/// .with_title(Lc::n("Intranet"))
|
||||||
/// .with_route(Some("/".into()));
|
/// .with_route(Route::from("/"));
|
||||||
///
|
///
|
||||||
/// let navbar = bs::Navbar::brand_right(brand)
|
/// let navbar = bs::Navbar::brand_right(brand)
|
||||||
/// .with_expand(BreakPoint::LG)
|
/// .with_expand(BreakPoint::LG)
|
||||||
/// .with_item(bs::navbar::Item::nav(
|
/// .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("Dashboard"), "/dashboard"))
|
||||||
/// .with_item(bs::nav::Item::link(Lc::n("Users"), "/users"))
|
/// .with_item(bs::nav::Item::link(Lc::n("Users"), "/users"))
|
||||||
/// ));
|
/// ));
|
||||||
|
|
@ -122,9 +127,9 @@ const TOGGLE_OFFCANVAS: &str = "offcanvas";
|
||||||
/// ```rust,no_run
|
/// ```rust,no_run
|
||||||
/// # use pagetop::prelude::*;
|
/// # use pagetop::prelude::*;
|
||||||
/// # use pagetop_bootsier::theme::*;
|
/// # use pagetop_bootsier::theme::*;
|
||||||
/// let brand = bs::navbar::Brand::new()
|
/// let brand = Brand::new()
|
||||||
/// .with_title(Lc::n("Main App"))
|
/// .with_title(Lc::n("Main App"))
|
||||||
/// .with_route(Some("/".into()));
|
/// .with_route(Route::from("/"));
|
||||||
///
|
///
|
||||||
/// let navbar = bs::Navbar::brand_left(brand)
|
/// let navbar = bs::Navbar::brand_left(brand)
|
||||||
/// .with_position(bs::navbar::Position::FixedTop)
|
/// .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"))
|
/// .with_item(bs::nav::Item::link(Lc::n("Stock"), "/stock"))
|
||||||
/// ));
|
/// ));
|
||||||
/// ```
|
/// ```
|
||||||
#[derive(AutoDefault, Clone, Debug, Getters)]
|
pub trait NavbarBootsier {
|
||||||
pub struct Navbar {
|
/// Crea una barra de navegación cuyo contenido se muestra en un **offcanvas**.
|
||||||
/// Devuelve identificador, clases CSS, atributos HTML y valores extra del componente.
|
fn offcanvas(oc: bs::Offcanvas) -> Self;
|
||||||
props: Props,
|
|
||||||
/// Devuelve el punto de ruptura configurado.
|
/// Crea una barra de navegación con **marca de identidad** y contenido en **offcanvas**.
|
||||||
expand: BreakPoint,
|
fn offcanvas_brand_left(brand: Brand, oc: bs::Offcanvas) -> Self;
|
||||||
/// Devuelve la disposición configurada para la barra de navegación.
|
|
||||||
layout: bs::navbar::Layout,
|
/// Crea una barra de navegación con **marca de identidad** y contenido en **offcanvas**.
|
||||||
/// Devuelve la posición configurada para la barra de navegación.
|
fn offcanvas_brand_right(brand: Brand, oc: bs::Offcanvas) -> Self;
|
||||||
position: bs::navbar::Position,
|
|
||||||
/// Devuelve la lista de contenidos.
|
/// Define a partir de qué punto de ruptura la barra de navegación deja de colapsar.
|
||||||
items: Children,
|
#[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 NavbarBootsier for Navbar {
|
||||||
impl Component for Navbar {
|
fn offcanvas(oc: bs::Offcanvas) -> Self {
|
||||||
fn new() -> Self {
|
let mut navbar = Self::new();
|
||||||
Self::default()
|
navbar.alter_prop(PropsOp::set_extra(
|
||||||
|
EXTRA_LAYOUT,
|
||||||
|
bs::navbar::Layout::Offcanvas(Embed::with(oc)),
|
||||||
|
));
|
||||||
|
navbar
|
||||||
}
|
}
|
||||||
|
|
||||||
fn id(&self) -> Option<String> {
|
fn offcanvas_brand_left(brand: Brand, oc: bs::Offcanvas) -> Self {
|
||||||
self.props.get_id()
|
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) {
|
fn offcanvas_brand_right(brand: Brand, oc: bs::Offcanvas) -> Self {
|
||||||
// Asegura que la barra de navegación tiene un identificador único.
|
let mut navbar = Self::new();
|
||||||
self.alter_prop(PropsOp::ensure_id(cx.build_id::<Self>(1)));
|
navbar.alter_prop(PropsOp::set_extra(
|
||||||
|
EXTRA_LAYOUT,
|
||||||
// Clases CSS por defecto para la barra de navegación.
|
bs::navbar::Layout::OffcanvasBrandRight(Embed::with(brand), Embed::with(oc)),
|
||||||
self.alter_prop(PropsOp::prepend_classes({
|
));
|
||||||
let mut classes = "navbar".to_string();
|
navbar
|
||||||
self.expand().push_to(&mut classes, "navbar-expand", "");
|
|
||||||
self.position().push_to(&mut classes);
|
|
||||||
classes
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
|
#[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.
|
// Botón de despliegue (colapso u offcanvas) para la barra.
|
||||||
fn button(cx: &mut Context, data_bs_toggle: &str, id_content: &str) -> Markup {
|
fn button(cx: &mut Context, data_bs_toggle: &str, id_content: &str) -> Markup {
|
||||||
let id_content_target = util::join!("#", id_content);
|
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.
|
// 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() {
|
if items.is_empty() {
|
||||||
return Ok(html! {});
|
return Ok(html! {});
|
||||||
}
|
}
|
||||||
|
|
||||||
// `setup()` garantiza que habrá un `id` antes de renderizar.
|
// `Navbar::setup()` (base) garantiza que habrá un `id` antes de renderizar.
|
||||||
let id = self.id().unwrap();
|
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! {
|
Ok(html! {
|
||||||
nav (self.props()) {
|
nav (navbar.props()) {
|
||||||
div class="container-fluid" {
|
div class="container-fluid" {
|
||||||
@match self.layout() {
|
@match layout {
|
||||||
// Barra más sencilla: sólo contenido.
|
// Barra más sencilla: sólo contenido.
|
||||||
bs::navbar::Layout::Simple => {
|
bs::navbar::Layout::Simple => {
|
||||||
(items)
|
(items)
|
||||||
|
|
@ -216,7 +273,7 @@ impl Component for Navbar {
|
||||||
|
|
||||||
// Barra sencilla que se puede contraer/expandir.
|
// Barra sencilla que se puede contraer/expandir.
|
||||||
bs::navbar::Layout::SimpleToggle => {
|
bs::navbar::Layout::SimpleToggle => {
|
||||||
@let id_content = util::join!(id, "-content");
|
@let id_content = util::join!(&id, "-content");
|
||||||
|
|
||||||
(button(cx, TOGGLE_COLLAPSE, &id_content))
|
(button(cx, TOGGLE_COLLAPSE, &id_content))
|
||||||
div id=(&id_content) class="collapse navbar-collapse" {
|
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.
|
// Barra con marca a la izquierda y botón a la derecha.
|
||||||
bs::navbar::Layout::BrandLeft(brand) => {
|
bs::navbar::Layout::BrandLeft(brand) => {
|
||||||
@let id_content = util::join!(id, "-content");
|
@let id_content = util::join!(&id, "-content");
|
||||||
|
|
||||||
(brand.render(cx).await)
|
(brand.render(cx).await)
|
||||||
(button(cx, TOGGLE_COLLAPSE, &id_content))
|
(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.
|
// Barra con botón a la izquierda y marca a la derecha.
|
||||||
bs::navbar::Layout::BrandRight(brand) => {
|
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))
|
(button(cx, TOGGLE_COLLAPSE, &id_content))
|
||||||
(brand.render(cx).await)
|
(brand.render(cx).await)
|
||||||
|
|
@ -258,7 +315,7 @@ impl Component for Navbar {
|
||||||
|
|
||||||
(button(cx, TOGGLE_OFFCANVAS, &id_content))
|
(button(cx, TOGGLE_OFFCANVAS, &id_content))
|
||||||
@if let Some(oc) = offcanvas.get() {
|
@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)
|
(brand.render(cx).await)
|
||||||
(button(cx, TOGGLE_OFFCANVAS, &id_content))
|
(button(cx, TOGGLE_OFFCANVAS, &id_content))
|
||||||
@if let Some(oc) = offcanvas.get() {
|
@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))
|
(button(cx, TOGGLE_OFFCANVAS, &id_content))
|
||||||
(brand.render(cx).await)
|
(brand.render(cx).await)
|
||||||
@if let Some(oc) = offcanvas.get() {
|
@if let Some(oc) = offcanvas.get() {
|
||||||
(oc.render_offcanvas(cx, Some(self.items())).await)
|
(oc.render_offcanvas(cx, Some(navbar.items())).await)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Navbar {
|
// Traduce el `navbar::Layout` semántico de base (sin `Offcanvas`, sin `Position`/`expand`) a la
|
||||||
/// Crea una barra de navegación **simple**, sin marca y sin botón.
|
// variante equivalente de `bs::navbar::Layout`, para las barras construidas con los constructores
|
||||||
pub fn simple() -> Self {
|
// heredados de `Navbar` (`simple()`, `simple_toggle()`, `simple_brand_left()`, `brand_left()`,
|
||||||
Self::default().with_layout(bs::navbar::Layout::Simple)
|
// `brand_right()`) en vez de con `with_layout()`.
|
||||||
|
fn translate_layout(layout: &navbar::Layout) -> bs::navbar::Layout {
|
||||||
|
match layout {
|
||||||
|
navbar::Layout::Simple => bs::navbar::Layout::Simple,
|
||||||
|
navbar::Layout::SimpleToggle => bs::navbar::Layout::SimpleToggle,
|
||||||
|
navbar::Layout::SimpleBrandLeft(brand) => {
|
||||||
|
bs::navbar::Layout::SimpleBrandLeft(brand.clone())
|
||||||
}
|
}
|
||||||
|
navbar::Layout::BrandLeft(brand) => bs::navbar::Layout::BrandLeft(brand.clone()),
|
||||||
/// Crea una barra de navegación **simple pero colapsable**, con botón a la izquierda.
|
navbar::Layout::BrandRight(brand) => bs::navbar::Layout::BrandRight(brand.clone()),
|
||||||
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,98 +1,24 @@
|
||||||
use pagetop::prelude::*;
|
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).
|
// 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
|
||||||
/// Cada variante determina qué se renderiza y cómo. Estos elementos se colocan **dentro del
|
// "dentro de una Navbar" (para que `theme::bs::nav::setup()` use `navbar-nav` en vez de `nav` como
|
||||||
/// contenido** de la barra (la parte colapsable, el *offcanvas* o el bloque simple), por lo que son
|
// clase base) y lo renderiza con su ciclo de vida completo -así recibe también `kind`/`layout`
|
||||||
/// independientes de la marca o del botón que ya pueda definir el propio
|
// traducidos a clases de Bootstrap, y cualquier otro tema que intercepte `Nav` en el futuro-.
|
||||||
/// [`navbar::Layout`](crate::theme::bs::navbar::Layout).
|
pub(crate) async fn render(
|
||||||
#[derive(AutoDefault, Clone, Debug)]
|
item: &navbar::Item,
|
||||||
pub enum Item {
|
cx: &mut Context,
|
||||||
/// Sin contenido, no produce salida.
|
) -> Result<Markup, ComponentError> {
|
||||||
#[default]
|
match item {
|
||||||
Void,
|
navbar::Item::Nav(embed) => {
|
||||||
/// Marca de identidad mostrada dentro del contenido de la barra de navegación.
|
let Some(mut nav) = embed.get().cloned() else {
|
||||||
///
|
|
||||||
/// Ú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() {
|
|
||||||
return Ok(html! {});
|
return Ok(html! {});
|
||||||
|
};
|
||||||
|
nav.alter_prop(PropsOp::set_extra(nav::EXTRA_IN_NAVBAR, true));
|
||||||
|
Ok(html! { (nav.render(cx).await) })
|
||||||
}
|
}
|
||||||
html! {
|
_ => item.prepare(cx).await,
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,13 +20,13 @@ pub enum Layout {
|
||||||
/// Barra simple, con marca de identidad a la izquierda y sin botón de despliegue.
|
/// Barra simple, con marca de identidad a la izquierda y sin botón de despliegue.
|
||||||
///
|
///
|
||||||
/// La barra de navegación no se colapsa.
|
/// 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.
|
/// 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.
|
/// 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
|
/// Contenido en [`Offcanvas`](crate::theme::bs::Offcanvas), con botón de despliegue a la
|
||||||
/// izquierda y sin marca de identidad.
|
/// 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
|
/// Contenido en [`Offcanvas`](crate::theme::bs::Offcanvas), con marca de identidad a la
|
||||||
/// izquierda y botón de despliegue a la derecha.
|
/// 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
|
/// Contenido en [`Offcanvas`](crate::theme::bs::Offcanvas), con botón de despliegue a la
|
||||||
/// izquierda y marca de identidad a la derecha.
|
/// izquierda y marca de identidad a la derecha.
|
||||||
OffcanvasBrandRight(Embed<bs::navbar::Brand>, Embed<bs::Offcanvas>),
|
OffcanvasBrandRight(Embed<Brand>, Embed<bs::Offcanvas>),
|
||||||
}
|
}
|
||||||
|
|
||||||
// **< Position >***********************************************************************************
|
// **< 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
|
//! ```rust,no_run
|
||||||
//! use pagetop_bootsier::theme::*;
|
//! use pagetop_bootsier::theme::*;
|
||||||
//!
|
//!
|
||||||
//! let bg = class::Bg::with(ThemeColor::Primary);
|
//! let bg = class::Bg::with(BootsierColors::Primary);
|
||||||
//! let border = class::Border::new()
|
//! let border = class::Border::new()
|
||||||
//! .with_side(BoxSide::Top, ScaleSize::Zero)
|
//! .with_side(BoxSide::Top, ScaleSize::Zero)
|
||||||
//! .with_color(ThemeColor::Danger);
|
//! .with_color(BootsierColors::Danger);
|
||||||
//! ```
|
//! ```
|
||||||
|
|
||||||
mod color;
|
mod color;
|
||||||
pub use color::{Bg, BgColor};
|
pub use color::{Bg, BgColor};
|
||||||
pub use color::{Text, TextColor};
|
pub use color::{Text, TextColor};
|
||||||
|
|
||||||
mod button;
|
|
||||||
pub use button::{ButtonColor, ButtonColorStyle, ButtonSize, ButtonSizeKind};
|
|
||||||
|
|
||||||
mod border;
|
mod border;
|
||||||
pub use border::{Border, BorderColor};
|
pub use border::{Border, BorderColor};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
use pagetop::prelude::*;
|
use pagetop::prelude::*;
|
||||||
|
|
||||||
use crate::theme::{BoxSide, OpacityLevel, ScaleSize, ThemeColor};
|
use crate::theme::{BootsierColors, BoxSide, OpacityLevel, ScaleSize};
|
||||||
|
|
||||||
// **< BorderColor >********************************************************************************
|
// **< BorderColor >********************************************************************************
|
||||||
|
|
||||||
/// Esquema de color para los bordes ([`Border`]).
|
/// Esquema de color para los bordes ([`Border`]).
|
||||||
///
|
///
|
||||||
/// - `Solid(ThemeColor)` y `Subtle(ThemeColor)` usan la paleta de colores temáticos
|
/// - `Solid(BootsierColors)` y `Subtle(BootsierColors)` usan la paleta de colores temáticos
|
||||||
/// ([`ThemeColor`]).
|
/// ([`BootsierColors`]).
|
||||||
/// - `Black` y `White` son colores fijos independientes del tema.
|
/// - `Black` y `White` son colores fijos independientes del tema.
|
||||||
/// - `Default` no genera ninguna clase.
|
/// - `Default` no genera ninguna clase.
|
||||||
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
|
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
|
||||||
|
|
@ -16,9 +16,9 @@ pub enum BorderColor {
|
||||||
#[default]
|
#[default]
|
||||||
Default,
|
Default,
|
||||||
/// Genera la clase `border-{color}`.
|
/// Genera la clase `border-{color}`.
|
||||||
Solid(ThemeColor),
|
Solid(BootsierColors),
|
||||||
/// Genera la clase `border-{color}-subtle` (un tono suavizado del color).
|
/// Genera la clase `border-{color}-subtle` (un tono suavizado del color).
|
||||||
Subtle(ThemeColor),
|
Subtle(BootsierColors),
|
||||||
/// Color negro.
|
/// Color negro.
|
||||||
Black,
|
Black,
|
||||||
/// Color blanco.
|
/// Color blanco.
|
||||||
|
|
@ -63,10 +63,10 @@ impl BorderColor {
|
||||||
///
|
///
|
||||||
/// ```rust
|
/// ```rust
|
||||||
/// # use pagetop_bootsier::theme::*;
|
/// # 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");
|
/// 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");
|
/// assert_eq!(subtle, "border-warning-subtle");
|
||||||
///
|
///
|
||||||
/// let black = class::BorderColor::Black.to_class();
|
/// let black = class::BorderColor::Black.to_class();
|
||||||
|
|
@ -83,8 +83,8 @@ impl BorderColor {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<ThemeColor> for BorderColor {
|
impl From<BootsierColors> for BorderColor {
|
||||||
/// Convierte un [`ThemeColor`] en [`BorderColor::Solid`].
|
/// Convierte un [`BootsierColors`] en [`BorderColor::Solid`].
|
||||||
///
|
///
|
||||||
/// Es el atajo habitual para los colores temáticos. Para los demás esquemas (`Subtle`, `Black`,
|
/// Es el atajo habitual para los colores temáticos. Para los demás esquemas (`Subtle`, `Black`,
|
||||||
/// `White`) sigue usando [`BorderColor`].
|
/// `White`) sigue usando [`BorderColor`].
|
||||||
|
|
@ -93,18 +93,18 @@ impl From<ThemeColor> for BorderColor {
|
||||||
///
|
///
|
||||||
/// ```rust
|
/// ```rust
|
||||||
/// # use pagetop_bootsier::theme::*;
|
/// # 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");
|
/// assert_eq!(border.to_class(), "border-success");
|
||||||
/// ```
|
/// ```
|
||||||
fn from(color: ThemeColor) -> Self {
|
fn from(color: BootsierColors) -> Self {
|
||||||
Self::Solid(color)
|
Self::Solid(color)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Into<CowStr> for BorderColor {
|
impl From<BorderColor> for CowStr {
|
||||||
/// Permite pasar [`BorderColor`] directamente a [`PropsOp`](pagetop::prelude::PropsOp).
|
/// Permite pasar [`BorderColor`] directamente a [`PropsOp`].
|
||||||
fn into(self) -> CowStr {
|
fn from(val: BorderColor) -> Self {
|
||||||
self.to_class().into()
|
val.to_class().into()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -159,7 +159,7 @@ impl Into<CowStr> for BorderColor {
|
||||||
/// let b = class::Border::new() // Borde por defecto.
|
/// let b = class::Border::new() // Borde por defecto.
|
||||||
/// .with_side(BoxSide::Top, ScaleSize::Zero) // Quita borde superior.
|
/// .with_side(BoxSide::Top, ScaleSize::Zero) // Quita borde superior.
|
||||||
/// .with_side(BoxSide::End, ScaleSize::Three) // Ancho 3 para lado lógico final.
|
/// .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);
|
/// .with_opacity(OpacityLevel::Half);
|
||||||
/// assert_eq!(b.to_class(), "border border-top-0 border-end-3 border-primary border-opacity-50");
|
/// 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.
|
/// 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`].
|
/// automáticamente en [`BorderColor::Solid`].
|
||||||
pub fn with_color(mut self, color: impl Into<BorderColor>) -> Self {
|
pub fn with_color(mut self, color: impl Into<BorderColor>) -> Self {
|
||||||
self.color = color.into();
|
self.color = color.into();
|
||||||
|
|
@ -270,9 +270,9 @@ impl From<ScaleSize> for Border {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Into<CowStr> for Border {
|
impl From<Border> for CowStr {
|
||||||
/// Permite pasar [`Border`] directamente a [`PropsOp`](pagetop::prelude::PropsOp).
|
/// Permite pasar [`Border`] directamente a [`PropsOp`].
|
||||||
fn into(self) -> CowStr {
|
fn from(val: Border) -> Self {
|
||||||
self.to_class().into()
|
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 pagetop::prelude::*;
|
||||||
|
|
||||||
use crate::theme::{OpacityLevel, ThemeColor};
|
use crate::theme::{BootsierColors, OpacityLevel};
|
||||||
|
|
||||||
// **< BgColor >************************************************************************************
|
// **< BgColor >************************************************************************************
|
||||||
|
|
||||||
/// Esquema de color para el fondo ([`Bg`]).
|
/// Esquema de color para el fondo ([`Bg`]).
|
||||||
///
|
///
|
||||||
/// - `Body`, `BodySecondary` y `BodyTertiary` siguen el esquema del tema (claro/oscuro).
|
/// - `Body`, `BodySecondary` y `BodyTertiary` siguen el esquema del tema (claro/oscuro).
|
||||||
/// - `Solid(ThemeColor)` y `Subtle(ThemeColor)` usan la paleta de colores temáticos
|
/// - `Solid(BootsierColors)` y `Subtle(BootsierColors)` usan la paleta de colores temáticos
|
||||||
/// ([`ThemeColor`]).
|
/// ([`BootsierColors`]).
|
||||||
/// - `Black`, `White`, `Transparent` son colores fijos independientes del tema.
|
/// - `Black`, `White`, `Transparent` son colores fijos independientes del tema.
|
||||||
/// - `Default` no genera ninguna clase.
|
/// - `Default` no genera ninguna clase.
|
||||||
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
|
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
|
||||||
|
|
@ -23,9 +23,9 @@ pub enum BgColor {
|
||||||
/// Fondo predefinido del tema (`bg-body-tertiary`).
|
/// Fondo predefinido del tema (`bg-body-tertiary`).
|
||||||
BodyTertiary,
|
BodyTertiary,
|
||||||
/// Genera la clase `bg-{color}` (p. ej., `bg-primary`).
|
/// Genera la clase `bg-{color}` (p. ej., `bg-primary`).
|
||||||
Solid(ThemeColor),
|
Solid(BootsierColors),
|
||||||
/// Genera la clase `bg-{color}-subtle` (un tono suavizado del color).
|
/// Genera la clase `bg-{color}-subtle` (un tono suavizado del color).
|
||||||
Subtle(ThemeColor),
|
Subtle(BootsierColors),
|
||||||
/// Color negro.
|
/// Color negro.
|
||||||
Black,
|
Black,
|
||||||
/// Color blanco.
|
/// Color blanco.
|
||||||
|
|
@ -79,10 +79,10 @@ impl BgColor {
|
||||||
/// let body = class::BgColor::Body.to_class();
|
/// let body = class::BgColor::Body.to_class();
|
||||||
/// assert_eq!(body, "bg-body");
|
/// 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");
|
/// 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");
|
/// assert_eq!(subtle, "bg-warning-subtle");
|
||||||
///
|
///
|
||||||
/// let transparent = class::BgColor::Transparent.to_class();
|
/// let transparent = class::BgColor::Transparent.to_class();
|
||||||
|
|
@ -99,8 +99,8 @@ impl BgColor {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<ThemeColor> for BgColor {
|
impl From<BootsierColors> for BgColor {
|
||||||
/// Convierte un [`ThemeColor`] en [`BgColor::Solid`].
|
/// Convierte un [`BootsierColors`] en [`BgColor::Solid`].
|
||||||
///
|
///
|
||||||
/// Es el atajo habitual para los colores temáticos. Para los demás esquemas (`Body`, `Subtle`,
|
/// Es el atajo habitual para los colores temáticos. Para los demás esquemas (`Body`, `Subtle`,
|
||||||
/// `Black`, etc.) sigue usando [`BgColor`].
|
/// `Black`, etc.) sigue usando [`BgColor`].
|
||||||
|
|
@ -109,18 +109,18 @@ impl From<ThemeColor> for BgColor {
|
||||||
///
|
///
|
||||||
/// ```rust
|
/// ```rust
|
||||||
/// # use pagetop_bootsier::theme::*;
|
/// # 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");
|
/// assert_eq!(bg.to_class(), "bg-primary");
|
||||||
/// ```
|
/// ```
|
||||||
fn from(color: ThemeColor) -> Self {
|
fn from(color: BootsierColors) -> Self {
|
||||||
Self::Solid(color)
|
Self::Solid(color)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Into<CowStr> for BgColor {
|
impl From<BgColor> for CowStr {
|
||||||
/// Permite pasar [`BgColor`] directamente a [`PropsOp`](pagetop::prelude::PropsOp).
|
/// Permite pasar [`BgColor`] directamente a [`PropsOp`].
|
||||||
fn into(self) -> CowStr {
|
fn from(val: BgColor) -> Self {
|
||||||
self.to_class().into()
|
val.to_class().into()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -137,8 +137,8 @@ impl Into<CowStr> for BgColor {
|
||||||
/// let s = class::Bg::new();
|
/// let s = class::Bg::new();
|
||||||
/// assert_eq!(s.to_class(), "");
|
/// assert_eq!(s.to_class(), "");
|
||||||
///
|
///
|
||||||
/// // Sólo color de fondo (forma corta con ThemeColor).
|
/// // Sólo color de fondo (forma corta con BootsierColors).
|
||||||
/// let s = class::Bg::with(ThemeColor::Primary);
|
/// let s = class::Bg::with(BootsierColors::Primary);
|
||||||
/// assert_eq!(s.to_class(), "bg-primary");
|
/// assert_eq!(s.to_class(), "bg-primary");
|
||||||
///
|
///
|
||||||
/// // Color más opacidad.
|
/// // Color más opacidad.
|
||||||
|
|
@ -167,13 +167,13 @@ impl Bg {
|
||||||
|
|
||||||
/// Crea un estilo fijando el color de fondo (`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`]:
|
/// automáticamente en [`BgColor::Solid`]:
|
||||||
///
|
///
|
||||||
/// ```rust
|
/// ```rust
|
||||||
/// # use pagetop_bootsier::theme::*;
|
/// # use pagetop_bootsier::theme::*;
|
||||||
/// // Forma corta con ThemeColor:
|
/// // Forma corta con BootsierColors:
|
||||||
/// let s = class::Bg::with(ThemeColor::Primary);
|
/// let s = class::Bg::with(BootsierColors::Primary);
|
||||||
/// assert_eq!(s.to_class(), "bg-primary");
|
/// assert_eq!(s.to_class(), "bg-primary");
|
||||||
///
|
///
|
||||||
/// // Forma explícita para variantes no temáticas:
|
/// // Forma explícita para variantes no temáticas:
|
||||||
|
|
@ -188,7 +188,7 @@ impl Bg {
|
||||||
|
|
||||||
/// Establece el color de fondo (`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`].
|
/// automáticamente en [`BgColor::Solid`].
|
||||||
pub fn with_color(mut self, color: impl Into<BgColor>) -> Self {
|
pub fn with_color(mut self, color: impl Into<BgColor>) -> Self {
|
||||||
self.color = color.into();
|
self.color = color.into();
|
||||||
|
|
@ -253,10 +253,10 @@ impl From<BgColor> for Bg {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Into<CowStr> for Bg {
|
impl From<Bg> for CowStr {
|
||||||
/// Permite pasar [`Bg`] directamente a [`PropsOp`](pagetop::prelude::PropsOp).
|
/// Permite pasar [`Bg`] directamente a [`PropsOp`].
|
||||||
fn into(self) -> CowStr {
|
fn from(val: Bg) -> Self {
|
||||||
self.to_class().into()
|
val.to_class().into()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -265,10 +265,10 @@ impl Into<CowStr> for Bg {
|
||||||
/// Esquema de color para el texto ([`Text`]).
|
/// Esquema de color para el texto ([`Text`]).
|
||||||
///
|
///
|
||||||
/// - `Body`, `BodyEmphasis`, `BodySecondary` y `BodyTertiary` siguen el tema (claro/oscuro).
|
/// - `Body`, `BodyEmphasis`, `BodySecondary` y `BodyTertiary` siguen el tema (claro/oscuro).
|
||||||
/// - `Solid(ThemeColor)` y `Emphasis(ThemeColor)` usan la paleta de colores temáticos
|
/// - `Solid(BootsierColors)` y `Emphasis(BootsierColors)` usan la paleta de colores temáticos
|
||||||
/// ([`ThemeColor`]).
|
/// ([`BootsierColors`]).
|
||||||
/// - `Bg(ThemeColor)` genera la utilidad combinada `text-bg-{color}` (fondo más un color de texto
|
/// - `Bg(BootsierColors)` genera la utilidad combinada `text-bg-{color}` (fondo más un color de
|
||||||
/// de contraste garantizado; no es una utilidad puramente de texto).
|
/// texto de contraste garantizado; no es una utilidad puramente de texto).
|
||||||
/// - `Black` y `White` son colores fijos independientes del tema.
|
/// - `Black` y `White` son colores fijos independientes del tema.
|
||||||
/// - `Default` no genera ninguna clase.
|
/// - `Default` no genera ninguna clase.
|
||||||
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
|
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
|
||||||
|
|
@ -285,11 +285,11 @@ pub enum TextColor {
|
||||||
/// Color predefinido del tema (`text-body-tertiary`).
|
/// Color predefinido del tema (`text-body-tertiary`).
|
||||||
BodyTertiary,
|
BodyTertiary,
|
||||||
/// Genera la clase `text-{color}`.
|
/// Genera la clase `text-{color}`.
|
||||||
Solid(ThemeColor),
|
Solid(BootsierColors),
|
||||||
/// Genera la clase `text-{color}-emphasis` (mayor contraste acorde al tema).
|
/// 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).
|
/// Genera la clase `text-bg-{color}` (fondo con color de texto de contraste garantizado).
|
||||||
Bg(ThemeColor),
|
Bg(BootsierColors),
|
||||||
/// Color negro.
|
/// Color negro.
|
||||||
Black,
|
Black,
|
||||||
/// Color blanco.
|
/// Color blanco.
|
||||||
|
|
@ -346,13 +346,13 @@ impl TextColor {
|
||||||
/// let body = class::TextColor::Body.to_class();
|
/// let body = class::TextColor::Body.to_class();
|
||||||
/// assert_eq!(body, "text-body");
|
/// 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");
|
/// 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");
|
/// 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");
|
/// assert_eq!(bg, "text-bg-secondary");
|
||||||
///
|
///
|
||||||
/// let black = class::TextColor::Black.to_class();
|
/// let black = class::TextColor::Black.to_class();
|
||||||
|
|
@ -369,8 +369,8 @@ impl TextColor {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<ThemeColor> for TextColor {
|
impl From<BootsierColors> for TextColor {
|
||||||
/// Convierte un [`ThemeColor`] en [`TextColor::Solid`].
|
/// Convierte un [`BootsierColors`] en [`TextColor::Solid`].
|
||||||
///
|
///
|
||||||
/// Es el atajo habitual para los colores temáticos. Para los demás esquemas (`Body`,
|
/// Es el atajo habitual para los colores temáticos. Para los demás esquemas (`Body`,
|
||||||
/// `Emphasis`, `Black`, etc.) sigue usando [`TextColor`].
|
/// `Emphasis`, `Black`, etc.) sigue usando [`TextColor`].
|
||||||
|
|
@ -379,18 +379,18 @@ impl From<ThemeColor> for TextColor {
|
||||||
///
|
///
|
||||||
/// ```rust
|
/// ```rust
|
||||||
/// # use pagetop_bootsier::theme::*;
|
/// # 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");
|
/// assert_eq!(text.to_class(), "text-danger");
|
||||||
/// ```
|
/// ```
|
||||||
fn from(color: ThemeColor) -> Self {
|
fn from(color: BootsierColors) -> Self {
|
||||||
Self::Solid(color)
|
Self::Solid(color)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Into<CowStr> for TextColor {
|
impl From<TextColor> for CowStr {
|
||||||
/// Permite pasar [`TextColor`] directamente a [`PropsOp`](pagetop::prelude::PropsOp).
|
/// Permite pasar [`TextColor`] directamente a [`PropsOp`].
|
||||||
fn into(self) -> CowStr {
|
fn from(val: TextColor) -> Self {
|
||||||
self.to_class().into()
|
val.to_class().into()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -407,8 +407,8 @@ impl Into<CowStr> for TextColor {
|
||||||
/// let s = class::Text::new();
|
/// let s = class::Text::new();
|
||||||
/// assert_eq!(s.to_class(), "");
|
/// assert_eq!(s.to_class(), "");
|
||||||
///
|
///
|
||||||
/// // Sólo color del texto (forma corta con ThemeColor).
|
/// // Sólo color del texto (forma corta con BootsierColors).
|
||||||
/// let s = class::Text::with(ThemeColor::Primary);
|
/// let s = class::Text::with(BootsierColors::Primary);
|
||||||
/// assert_eq!(s.to_class(), "text-primary");
|
/// assert_eq!(s.to_class(), "text-primary");
|
||||||
///
|
///
|
||||||
/// // Color del texto y opacidad.
|
/// // Color del texto y opacidad.
|
||||||
|
|
@ -422,7 +422,7 @@ impl Into<CowStr> for TextColor {
|
||||||
///
|
///
|
||||||
/// // Usando `From<(TextColor, OpacityLevel)>`.
|
/// // Usando `From<(TextColor, OpacityLevel)>`.
|
||||||
/// let s: class::Text = (
|
/// let s: class::Text = (
|
||||||
/// class::TextColor::Solid(ThemeColor::Danger),
|
/// class::TextColor::Solid(BootsierColors::Danger),
|
||||||
/// OpacityLevel::Opaque,
|
/// OpacityLevel::Opaque,
|
||||||
/// ).into();
|
/// ).into();
|
||||||
/// assert_eq!(s.to_class(), "text-danger text-opacity-100");
|
/// 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-*`).
|
/// 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`]:
|
/// automáticamente en [`TextColor::Solid`]:
|
||||||
///
|
///
|
||||||
/// ```rust
|
/// ```rust
|
||||||
/// # use pagetop_bootsier::theme::*;
|
/// # use pagetop_bootsier::theme::*;
|
||||||
/// // Forma corta con ThemeColor:
|
/// // Forma corta con BootsierColors:
|
||||||
/// let s = class::Text::with(ThemeColor::Danger);
|
/// let s = class::Text::with(BootsierColors::Danger);
|
||||||
/// assert_eq!(s.to_class(), "text-danger");
|
/// assert_eq!(s.to_class(), "text-danger");
|
||||||
///
|
///
|
||||||
/// // Forma explícita para variantes no temáticas:
|
/// // Forma explícita para variantes no temáticas:
|
||||||
|
|
@ -462,7 +462,7 @@ impl Text {
|
||||||
|
|
||||||
/// Establece el color del texto (`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`].
|
/// automáticamente en [`TextColor::Solid`].
|
||||||
pub fn with_color(mut self, color: impl Into<TextColor>) -> Self {
|
pub fn with_color(mut self, color: impl Into<TextColor>) -> Self {
|
||||||
self.color = color.into();
|
self.color = color.into();
|
||||||
|
|
@ -504,7 +504,7 @@ impl From<(TextColor, OpacityLevel)> for Text {
|
||||||
/// ```rust
|
/// ```rust
|
||||||
/// # use pagetop_bootsier::theme::*;
|
/// # use pagetop_bootsier::theme::*;
|
||||||
/// let s: class::Text = (
|
/// let s: class::Text = (
|
||||||
/// class::TextColor::Solid(ThemeColor::Danger),
|
/// class::TextColor::Solid(BootsierColors::Danger),
|
||||||
/// OpacityLevel::Opaque,
|
/// OpacityLevel::Opaque,
|
||||||
/// ).into();
|
/// ).into();
|
||||||
/// assert_eq!(s.to_class(), "text-danger text-opacity-100");
|
/// assert_eq!(s.to_class(), "text-danger text-opacity-100");
|
||||||
|
|
@ -529,9 +529,9 @@ impl From<TextColor> for Text {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Into<CowStr> for Text {
|
impl From<Text> for CowStr {
|
||||||
/// Permite pasar [`Text`] directamente a [`PropsOp`](pagetop::prelude::PropsOp).
|
/// Permite pasar [`Text`] directamente a [`PropsOp`].
|
||||||
fn into(self) -> CowStr {
|
fn from(val: Text) -> Self {
|
||||||
self.to_class().into()
|
val.to_class().into()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -98,10 +98,10 @@ impl Margin {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Into<CowStr> for Margin {
|
impl From<Margin> for CowStr {
|
||||||
/// Permite pasar [`Margin`] directamente a [`PropsOp`](pagetop::prelude::PropsOp).
|
/// Permite pasar [`Margin`] directamente a [`PropsOp`].
|
||||||
fn into(self) -> CowStr {
|
fn from(val: Margin) -> Self {
|
||||||
self.to_class().into()
|
val.to_class().into()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -203,9 +203,9 @@ impl Padding {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Into<CowStr> for Padding {
|
impl From<Padding> for CowStr {
|
||||||
/// Permite pasar [`Padding`] directamente a [`PropsOp`](pagetop::prelude::PropsOp).
|
/// Permite pasar [`Padding`] directamente a [`PropsOp`].
|
||||||
fn into(self) -> CowStr {
|
fn from(val: Padding) -> Self {
|
||||||
self.to_class().into()
|
val.to_class().into()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -85,10 +85,10 @@ impl RoundedRadius {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Into<CowStr> for RoundedRadius {
|
impl From<RoundedRadius> for CowStr {
|
||||||
/// Permite pasar [`RoundedRadius`] directamente a [`PropsOp`](pagetop::prelude::PropsOp).
|
/// Permite pasar [`RoundedRadius`] directamente a [`PropsOp`].
|
||||||
fn into(self) -> CowStr {
|
fn from(val: RoundedRadius) -> Self {
|
||||||
self.to_class().into()
|
val.to_class().into()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -262,9 +262,9 @@ impl Rounded {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Into<CowStr> for Rounded {
|
impl From<Rounded> for CowStr {
|
||||||
/// Permite pasar [`Rounded`] directamente a [`PropsOp`](pagetop::prelude::PropsOp).
|
/// Permite pasar [`Rounded`] directamente a [`PropsOp`].
|
||||||
fn into(self) -> CowStr {
|
fn from(val: Rounded) -> Self {
|
||||||
self.to_class().into()
|
val.to_class().into()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ mod breakpoint;
|
||||||
pub use breakpoint::BreakPoint;
|
pub use breakpoint::BreakPoint;
|
||||||
|
|
||||||
mod color;
|
mod color;
|
||||||
pub use color::{OpacityLevel, ThemeColor};
|
pub use color::{BootsierColors, OpacityLevel};
|
||||||
|
|
||||||
mod layout;
|
mod layout;
|
||||||
pub use layout::{BoxSide, ScaleSize};
|
pub use layout::{BoxSide, ScaleSize};
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,19 @@
|
||||||
use pagetop::prelude::*;
|
use pagetop::prelude::*;
|
||||||
|
|
||||||
// **< ThemeColor >*********************************************************************************
|
// **< BootsierColors >*****************************************************************************
|
||||||
|
|
||||||
/// Paleta de colores temáticos.
|
/// Paleta de colores temáticos.
|
||||||
///
|
///
|
||||||
/// Equivalen a los nombres estándar definidos por Bootstrap (`primary`, `secondary`, `success`,
|
/// 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
|
/// [`Bg`]: crate::theme::class::Bg
|
||||||
/// [`Border`]: crate::theme::class::Border
|
/// [`Border`]: crate::theme::class::Border
|
||||||
/// [`Text`]: crate::theme::class::Text
|
/// [`Text`]: crate::theme::class::Text
|
||||||
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
|
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
|
||||||
pub enum ThemeColor {
|
pub enum BootsierColors {
|
||||||
#[default]
|
#[default]
|
||||||
Primary,
|
Primary,
|
||||||
Secondary,
|
Secondary,
|
||||||
|
|
@ -23,11 +25,10 @@ pub enum ThemeColor {
|
||||||
Dark,
|
Dark,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ThemeColor {
|
impl BootsierColors {
|
||||||
/// Devuelve el nombre del color Bootstrap (`"primary"`, `"danger"`, etc.).
|
/// Devuelve el nombre del color Bootstrap (`"primary"`, `"danger"`, etc.).
|
||||||
#[rustfmt::skip]
|
#[rustfmt::skip]
|
||||||
#[inline]
|
pub const fn as_str(&self) -> &'static str {
|
||||||
pub const fn as_str(self) -> &'static str {
|
|
||||||
match self {
|
match self {
|
||||||
Self::Primary => "primary",
|
Self::Primary => "primary",
|
||||||
Self::Secondary => "secondary",
|
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 >*******************************************************************************
|
// **< OpacityLevel >*******************************************************************************
|
||||||
|
|
||||||
/// Niveles de opacidad (`opacity-*`).
|
/// 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