dbl-components
Version:
Framework based on bootstrap 5
469 lines (422 loc) • 15.8 kB
JavaScript
import React, { createRef } from "react";
import { NavLink } from "react-router-dom";
import PropTypes from 'prop-types';
import Collapse from "bootstrap/js/dist/collapse";
import { eventHandler, deepMerge, splitAndFlat } from "dbl-utils";
import { extractNodeString } from "dbl-utils/esm/extract-react-node-text";
import Icons from "../media/icons";
import Action from "../actions/action";
import JsonRender from "../json-render";
import Component from "../component";
import FloatingContainer from "../containers/floating-container/floating-container";
import { ptClasses } from "../prop-types";
const itemPropTypes = {
active: PropTypes.bool,
classes: PropTypes.oneOfType([
PropTypes.string,
PropTypes.arrayOf(PropTypes.string)
]),
content: PropTypes.oneOfType([
PropTypes.node,
PropTypes.string,
PropTypes.element,
]),
disabled: PropTypes.bool,
end: PropTypes.bool,
floatingClasses: ptClasses,
hasAnActive: PropTypes.bool,
href: PropTypes.string,
icon: PropTypes.string,
iconProps: PropTypes.object,
itemClasses: PropTypes.oneOfType([
PropTypes.string,
PropTypes.arrayOf(PropTypes.string)
]),
itemProps: PropTypes.object,
label: PropTypes.string.isRequired,
menu: PropTypes.oneOfType([
PropTypes.array,
PropTypes.object,
]),
name: PropTypes.string.isRequired,
parent: PropTypes.oneOfType([
PropTypes.string,
PropTypes.object,
]),
path: PropTypes.string,
strict: PropTypes.bool,
to: PropTypes.string
};
export class ToggleTextNavigation extends Action {
static jsClass = 'ToggleTextNavigation';
content() {
return React.createElement(Icons, { icon: this.props.icon });
}
}
export default class Navigation extends Component {
static jsClass = 'Navigation';
static defaultProps = {
...Component.defaultProps,
menu: [],
caretIcons: ['angle-up', 'angle-down'],
navLink: true,
activeClasses: 'active',
inactiveClasses: '',
pendingClasses: 'pending',
transitioningClasses: 'transitioning',
itemTag: 'div',
itemClasses: '',
floatingClasses: '',
iconClasses: 'mx-2'
}
tag = 'nav';
events = [
['location', this.onChangeLocation.bind(this)]
];
activeElements = {};
flatItems = {};
constructor(props) {
super(props);
const open = typeof this.props.open !== 'boolean' || this.props.open;
Object.assign(this.state, {
carets: {},
open,
localClasses: 'nav ' + (open ? 'label-show' : 'label-collapsed')
});
this.collapses = createRef({});
this.itemsRefs = createRef({});
this.itemsRefs.current = {};
this.jsonRender = new JsonRender(props);
this.hide = this.hide.bind(this);
this.link = this.link.bind(this);
this.onToggleBtn = this.onToggleBtn.bind(this);
// Agrega el evento solo si existe el prop `toggle`
if (this.props.toggle) {
this.events.push([this.props.toggle, this.onToggleBtn]);
}
}
componentDidMount() {
this.findFirstActive(this.props.menu);
// Suscribimos a todos los eventos almacenados en `this.events`
this.events.forEach(evt => eventHandler.subscribe(...evt, this.name));
}
componentDidUpdate(prevProps) {
// Verificar si `open` cambió y actualizar
if (typeof this.props.open === 'boolean' && prevProps.open !== this.props.open) {
this.toggleText(this.props.open);
}
// Manejo de cambio en `toggle`
if (this.props.toggle && prevProps.toggle !== this.props.toggle) {
// Desuscribimos del evento anterior solo si existía
if (prevProps.toggle) {
eventHandler.unsubscribe(prevProps.toggle, this.name);
}
// Actualizar el array de eventos con el nuevo toggle
const i = this.events.findIndex(([evtName]) => evtName === prevProps.toggle);
if (i !== -1) {
this.events.splice(i, 1); // Eliminamos el evento anterior
}
// Evitamos duplicar eventos
if (!this.events.some(([evtName]) => evtName === this.props.toggle)) {
this.events.push([this.props.toggle, this.onToggleBtn]); // Añadir el nuevo evento
eventHandler.subscribe(this.props.toggle, this.onToggleBtn, this.name); // Suscribir
}
}
}
componentWillUnmount() {
// Desuscribimos de todos los eventos cuando el componente se desmonta
this.events.forEach(([evt]) => eventHandler.unsubscribe(evt, this.name));
}
findFirstActive(menu, parent) {
let founded;
menu.find(item => {
item.parent = parent;
this.flatItems[item.name] = item;
item.hasAnActive = false;
if (this.props.location.pathname === (item.path || item.to)) {
this.onChangeRoute(this.props.location);
founded = item;
return true;
} else if (item.menu) {
founded = this.findFirstActive(item.menu, item);
return !!founded;
}
return false;
});
this.onChangeLocation(this.props.location);
return founded;
}
onChangeRoute(location, action) {
this.pathname = location.pathname;
eventHandler.dispatch(this.props.name, { pathname: this.pathname, item: this.activeItem, open: this.state.open });
}
onToggleBtn() {
this.toggleText();
}
toggleText(open = !this.state.open) {
this.setState({
open,
localClasses: open ? 'nav label-show' : 'nav label-collapsed'
}, () => eventHandler.dispatch(this.props.name, { pathname: this.pathname, item: this.activeItem, open: this.state.open }));
}
collapseRef(ref, item) {
if (!ref) return;
if (!this.collapses.current) this.collapses.current = {};
if (this.collapses.current[item.name]?.ref === ref) return;
this.collapses.current[item.name] = {
ref,
item,
submenuOpen: false
}
}
onToggleSubmenu(e, item) {
if (!item.menu?.length || !this.state.open) return;
e.stopPropagation();
e.nativeEvent.stopPropagation();
e.nativeEvent.preventDefault();
const itemControl = this.collapses.current[item.name];
if (!itemControl.collapse) {
itemControl.ref.removeEventListener('hidden.bs.collapse', this.hide);
itemControl.collapse = Collapse.getOrCreateInstance(itemControl.ref, { autoClose: false, toggle: false });
itemControl.ref.addEventListener('hidden.bs.collapse', this.hide);
}
if (!itemControl.submenuOpen) {
this.state.carets[item.name] = this.props.caretIcons[0];
this.setState({ carets: this.state.carets }, () => itemControl.collapse.show());
} else {
//Se usa en event hide para ocultar todo
Array.from(itemControl.ref.querySelectorAll('.collapse'))
.reverse().forEach(c => Collapse.getInstance(c)?.hide());
itemControl.collapse.hide();
}
itemControl.submenuOpen = !itemControl.submenuOpen;
}
onToggleFloating(e, item) {
eventHandler.dispatch('update.' + item.name + 'Floating', { open: true });
//load references in this.itemsRefs
setTimeout(() => {
this.forceUpdate();
}, 350);
}
hide(e) {
const itemName = e.target.id.split('-collapse')[0];
const itemControl = this.collapses.current[itemName];
const caretClose = this.props.caretIcons[1];
itemControl.submenuOpen = false;
this.state.carets[itemName] = caretClose;
this.setState({ carets: this.state.carets });
}
setActive(name, isActive) {
this.activeElements[name] = isActive;
return false;
}
hasAnActive(menuItem) {
if (!menuItem.parent) return menuItem.name;
menuItem.parent.hasAnActive = true;
return this.hasAnActive(menuItem.parent);
}
onChangeLocation(location) {
let activeItem;
Object.values(this.flatItems).forEach(i => {
let path = i.path || i.to;
i.hasAnActive = false;
if (path === location.pathname) activeItem = i;
});
if (activeItem) {
this.activeItem = activeItem;
this.hasAnActive(activeItem);
if (!this.state.open && activeItem.parent) {
eventHandler.dispatch(`update.${activeItem.parent.name}Floating`, { open: false });
}
}
this.forceUpdate();
}
link(itemRaw, i, parent) {
if (!itemRaw) return false;
const {
caretIcons, navLink, itemTag,
linkClasses, floatingClasses, activeClasses,
inactiveClasses, pendingClasses, transitioningClasses,
caretClasses, activeCaretClasses
} = this.props;
const { carets, open: stateOpen } = this.state;
const modify = typeof this.props.mutations === 'function'
&& this.props.mutations(`${this.props.name}.${itemRaw.name}`, itemRaw);
this.flatItems[itemRaw.name] = this.flatItems[itemRaw.name] || {};
const item = Object.assign(this.flatItems[itemRaw.name], itemRaw, modify || {});
const open = typeof item.open === 'boolean' ? item.open : stateOpen;
if (item.active === false) return false;
carets[item.name] = carets[item.name] || caretIcons[1];
this.flatItems[item.name] = item;
item.parent = parent;
const iconStyle = {
style: {
fill: 'currentColor'
}
};
const innerNode = React.createElement('span', {},
item.content
? (open
? this.jsonRender.buildContent(item.content[0])
: this.jsonRender.buildContent(item.content[1])
)
: React.createElement(React.Fragment, {},
item.icon !== false && React.createElement(Icons,
{
icon: item.icon,
className: splitAndFlat([item.iconClasses || this.props.iconClasses], ' ').join(' '),
title: item.title || extractNodeString(item.label),
...iconStyle,
...deepMerge(this.props.iconProps || {}, item.iconProps || {})
}),
(open || !!parent) && React.createElement('span',
{ className: "label" },
this.jsonRender.buildContent(item.label)
)
)
)
const disabled = item.disabled || this.props.disabled;
const className = (() => {
const r = [item.classes || linkClasses];
if (!(item.path || item.to)) r.push('cursor-pointer');
if (item.hasAnActive) {
r.push(splitAndFlat(['has-an-active', activeClasses], ' ').join(' '));
}
if (navLink) r.unshift('nav-link');
if (!!item.menu?.length) r.push('has-submenu');
if (disabled) r.push('disabled');
return splitAndFlat(r, ' ').join(' ');
})();
const propsLink = (item.path || item.to)
? {
id: item.name + '-link',
onClick: ((e) => [
!disabled && !!item.menu?.length && this.onToggleSubmenu(e, item)
]),
to: (item.path || item.to),
className: ({ isActive, isPending, isTransitioning }) => splitAndFlat([
isActive ? activeClasses : inactiveClasses,
isPending ? pendingClasses : "",
isTransitioning ? transitioningClasses : "",
className,
this.setActive(item.name, isActive)
], ' ').join(" "),
strict: item.strict,
end: item.end,
disabled,
style: {}
}
: (item.href
? {
tag: 'a',
name: item.name,
classes: splitAndFlat([className, inactiveClasses], ' ').join(' '),
disabled,
style: {},
_props: {
id: item.name + '-link',
href: item.href, target: '_blank',
onClick: (e) => !disabled && !!item.menu?.length && this.onToggleSubmenu(e, item)
}
}
: {
tag: 'span',
name: item.name,
classes: splitAndFlat([className, inactiveClasses], ' ').join(' '),
disabled,
style: {},
_props: {
id: item.name + '-link',
onClick: (e) => !disabled && !!item.menu?.length && this.onToggleSubmenu(e, item),
}
});
const styleWrapCaret = {
position: "relative"
}
if (!!item.menu?.length && open) {
propsLink.style.paddingRight = "2.3rem";
}
const itemProps = {
key: item.name,
...(item.itemProps || {}),
ref: (ref) => (this.itemsRefs.current[item.name] = ref),
className: splitAndFlat([
item.itemClasses || this.props.itemClasses,
this.activeElements[item.name] || item.hasAnActive ? 'active' : ''
], ' ').join(' ')
}
return React.createElement(itemTag, itemProps,
React.createElement('div', { style: styleWrapCaret },
(item.path || item.to)
? React.createElement(NavLink, propsLink, innerNode)
: React.createElement(Component, propsLink, innerNode)
,
!!item.menu?.length && open
&& React.createElement('span',
{
className: splitAndFlat([
"position-absolute top-50 end-0 translate-middle-y caret-icon p-1 cursor-pointer",
this.activeElements[item.name] || item.hasAnActive ? (item.activeCaretClasses || activeCaretClasses) : (item.caretClasses || caretClasses),
], ' ').join(' '),
onClick: e => !disabled && this.onToggleSubmenu(e, item),
},
React.createElement(Icons,
{
icon: carets[item.name], ...iconStyle, inline: false,
style: {
width: "1.8rem", padding: '.5rem'
}, className: "rounded-circle"
}
)
),
!!item.menu?.length && !open
&& React.createElement('span',
{
className: splitAndFlat([
"position-absolute top-50 end-0 translate-middle-y caret-icon p-1 cursor-pointer",
this.activeElements[item.name] || item.hasAnActive ? (item.activeCaretClasses || activeCaretClasses) : (item.caretClasses || caretClasses),
], ' ').join(' '),
onClick: e => !disabled && this.onToggleFloating(e, item),
},
React.createElement(Icons,
{
icon: 'angle-right', ...iconStyle, inline: false,
style: {
width: "1.8rem", padding: '.5rem',
transform: "scale(.8)"
}, className: "rounded-circle"
}
)
)
),
!!item.menu?.length
&& (open
? React.createElement('div',
{
ref: (ref) => this.collapseRef(ref, item),
id: item.name + '-collapse', className: "collapse"
},
//renderizar solo cuando este abierto
this.state.carets[item.name] === this.props.caretIcons[0] &&
item.menu.map((m, i) => this.link(m, i, item)).filter(m => !!m)
)
: this.itemsRefs.current[item.name] && React.createElement(FloatingContainer, {
name: item.name + 'Floating',
floatAround: this.itemsRefs.current[item.name], placement: 'right',
card: false, allowedPlacements: ['right', 'bottom', 'top'],
classes: splitAndFlat([item.floatingClasses || floatingClasses], ' ').join(' ')
},
item.menu.map((m, i) => this.link(m, i, item)).filter(m => !!m)
)
)
);
}
// TODO: agregar submenu dropdown
// y submenu collapsable
content(children = this.props.children) {
return (React.createElement(React.Fragment, {},
...this.props.menu.map((m, i) => this.link(m, i)).filter(m => !!m),
children
));
}
}