uikit/dist/remote/AppSidebar.js
2026-10-03 19:53:24 +03:30

935 lines
23 KiB
JavaScript

// Remote AppSidebar.
// Visuals and interaction shell are self-contained in Shadow DOM.
// Only React/createRoot are borrowed from the host so React icon nodes can be rendered.
// Font family is inherited from the host application.
// Routing, permissions and logout logic stay in the host and arrive as callbacks/flags.
const REACT_REMOTE_DEPS_KEY = "__UIKIT_REACT_REMOTE_DEPS__";
const TAG_NAME = "uikit-app-sidebar";
const EXIT_ICON_SVG = `
<svg fill="currentColor" width="16" height="16" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false">
<title>ionicons-v5-i</title>
<path d="M336,376V272H191a16,16,0,0,1,0-32H336V136a56.06,56.06,0,0,0-56-56H88a56.06,56.06,0,0,0-56,56V376a56.06,56.06,0,0,0,56,56H280A56.06,56.06,0,0,0,336,376Z"/>
<path d="M425.37,272l-52.68,52.69a16,16,0,0,0,22.62,22.62l80-80a16,16,0,0,0,0-22.62l-80-80a16,16,0,0,0-22.62,22.62L425.37,240H336v32Z"/>
</svg>`;
const MORE_ICON_SVG = `
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false">
<circle cx="5" cy="12" r="2"/>
<circle cx="12" cy="12" r="2"/>
<circle cx="19" cy="12" r="2"/>
</svg>`;
const CLOSE_ICON_SVG = `
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" focusable="false">
<path d="M18 6 6 18"/>
<path d="m6 6 12 12"/>
</svg>`;
const CSS = `
:host {
box-sizing: border-box;
display: block;
width: 72px;
min-width: 72px;
max-width: 72px;
height: calc(100dvh - 110px);
min-height: calc(100dvh - 110px);
max-height: calc(100dvh - 110px);
flex: 0 0 72px;
font-family: inherit;
color: #1a202c;
direction: rtl;
}
*, *::before, *::after { box-sizing: border-box; }
.desktop-nav {
width: 100%;
height: 100%;
min-height: 0;
padding: 16px 0;
overflow-y: auto;
overflow-x: hidden;
overscroll-behavior: contain;
background: #fff;
border-left: 1px solid #e2e8f0;
scrollbar-width: thin;
scrollbar-color: #cbd5e0 transparent;
}
.desktop-nav::-webkit-scrollbar { width: 4px; }
.desktop-nav::-webkit-scrollbar-track { background: transparent; }
.desktop-nav::-webkit-scrollbar-thumb {
border-radius: 999px;
background: #cbd5e0;
}
.items {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
width: 100%;
}
.item-wrap {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 56px;
height: 42px;
flex: 0 0 42px;
}
.item {
appearance: none;
-webkit-appearance: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 56px;
height: 42px;
min-width: 56px;
min-height: 42px;
margin: 0;
padding: 0;
border: 0;
border-radius: 6px;
outline: 0;
background: transparent;
color: #718096;
text-decoration: none;
font: inherit;
line-height: 1;
cursor: pointer;
transition: background-color 120ms ease, box-shadow 120ms ease, opacity 120ms ease;
}
.item:hover:not([aria-disabled="true"]) { background: #edf2f7; }
.item[data-active="true"] { background: #e2e8f0; }
.item[data-active="true"]:hover { background: #e2e8f0; }
.item:focus-visible { box-shadow: 0 0 0 3px rgba(49, 130, 206, 0.45); }
.item[aria-disabled="true"] { opacity: 0.4; cursor: not-allowed; }
.icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
font-size: 16px;
color: #314E89 !important;
pointer-events: none;
}
.icon > *,
.icon svg {
display: block;
width: 16px;
height: 16px;
max-width: 16px;
max-height: 16px;
color: #314E89 !important;
}
.exit-icon svg {
width: 16px;
height: 16px;
fill: currentColor;
stroke: none;
}
.floating-tooltip {
position: fixed;
z-index: 2147483000;
display: none;
min-width: max-content;
max-width: 280px;
padding: 8px 16px;
border-radius: 6px;
background: #2d3748;
color: #fff;
font-family: inherit;
font-size: 14px;
font-weight: 700;
line-height: 1.2;
white-space: nowrap;
pointer-events: none;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.14);
}
.floating-tooltip[data-open="true"] { display: block; }
.floating-tooltip::after {
content: "";
position: absolute;
top: 16px;
left: 100%;
width: 0;
height: 0;
border-top: 6px solid transparent;
border-bottom: 6px solid transparent;
border-left: 6px solid #2d3748;
}
.mobile-nav,
.drawer-layer {
display: none;
}
@media (max-width: 61.999em) {
:host {
display: block;
width: 0;
min-width: 0;
max-width: none;
height: 0;
min-height: 0;
flex: 0 0 0;
}
.desktop-nav,
.floating-tooltip {
display: none !important;
}
.mobile-nav {
position: fixed;
inset-inline: 0;
bottom: 0;
z-index: 1200;
display: block;
background: #fff;
border-top: 1px solid #e2e8f0;
box-shadow: 0 -6px 18px rgba(0, 0, 0, 0.08);
padding-bottom: env(safe-area-inset-bottom);
}
.mobile-bar {
display: flex;
align-items: stretch;
width: 100%;
height: 56px;
padding-inline: 6px;
gap: 4px;
}
.mobile-slot {
display: flex;
flex: 1 1 0;
min-width: 0;
align-items: center;
}
.mobile-control {
appearance: none;
-webkit-appearance: none;
display: flex;
width: 100%;
min-width: 0;
height: 100%;
margin: 0;
padding: 0;
align-items: center;
justify-content: center;
border: 0;
outline: 0;
background: transparent;
color: #718096;
text-decoration: none;
font: inherit;
cursor: pointer;
}
.mobile-control[aria-disabled="true"] {
cursor: not-allowed;
}
.mobile-content {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
min-width: 0;
gap: 2px;
padding: 4px;
border-radius: 12px;
color: #718096;
background: transparent;
opacity: 1;
transition: background-color 150ms ease, color 150ms ease;
}
.mobile-control[data-active="true"] .mobile-content {
color: #2b6cb0;
background: #ebf8ff;
}
.mobile-control[aria-disabled="true"] .mobile-content {
opacity: 0.4;
}
.mobile-control.mobile-exit .mobile-content {
color: #c53030;
}
.mobile-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
flex: 0 0 16px;
color: #314E89 !important;
pointer-events: none;
}
.mobile-icon > *,
.mobile-icon svg {
display: block;
width: 16px;
height: 16px;
max-width: 16px;
max-height: 16px;
color: #314E89 !important;
}
.mobile-label {
display: block;
width: 100%;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: currentColor;
font-size: 10px;
font-weight: 500;
line-height: 1.375;
text-align: center;
}
.mobile-control[data-active="true"] .mobile-label {
font-weight: 700;
}
.drawer-layer {
position: fixed;
inset: 0;
z-index: 1300;
display: none;
align-items: flex-end;
justify-content: stretch;
}
.drawer-layer[data-open="true"] {
display: flex;
}
.drawer-overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.48);
}
.drawer-panel {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
width: 100%;
max-height: min(78dvh, 620px);
padding-bottom: env(safe-area-inset-bottom);
overflow: hidden;
border-radius: 16px 16px 0 0;
background: #fff;
box-shadow: 0 -12px 32px rgba(0, 0, 0, 0.16);
}
.drawer-header {
position: relative;
display: flex;
min-height: 52px;
padding: 14px 48px 14px 16px;
align-items: center;
border-bottom: 1px solid #e2e8f0;
color: #1a202c;
font-size: 16px;
font-weight: 700;
line-height: 1.3;
}
.drawer-close {
appearance: none;
-webkit-appearance: none;
position: absolute;
top: 10px;
inset-inline-end: 10px;
display: inline-flex;
width: 32px;
height: 32px;
padding: 0;
align-items: center;
justify-content: center;
border: 0;
border-radius: 8px;
background: transparent;
color: #4a5568;
cursor: pointer;
}
.drawer-body {
padding: 16px;
overflow-y: auto;
overscroll-behavior: contain;
}
.drawer-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
}
.drawer-item-wrap {
min-width: 0;
}
.drawer-item {
appearance: none;
-webkit-appearance: none;
display: flex;
width: 100%;
height: 70px;
min-width: 0;
padding: 4px;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #fff;
color: #4a5568;
text-decoration: none;
font: inherit;
cursor: pointer;
}
.drawer-item[data-active="true"] {
border-color: #63b3ed;
background: #ebf8ff;
color: #2c5282;
}
.drawer-item[aria-disabled="true"] {
opacity: 0.4;
cursor: not-allowed;
}
.drawer-item .mobile-label {
display: -webkit-box;
overflow: hidden;
white-space: normal;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
font-size: 10px;
line-height: 1.25;
}
.drawer-item[data-active="true"] .mobile-label {
font-weight: 700;
}
.drawer-exit-section {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid #e2e8f0;
}
.drawer-exit {
appearance: none;
-webkit-appearance: none;
display: flex;
width: 100%;
min-height: 44px;
padding: 12px;
align-items: center;
justify-content: center;
gap: 8px;
border: 0;
border-radius: 12px;
background: #fff5f5;
color: #c53030;
font: inherit;
font-weight: 700;
cursor: pointer;
}
.drawer-exit[aria-disabled="true"] {
opacity: 0.4;
cursor: not-allowed;
}
}
@media (min-width: 62em) {
.mobile-nav,
.drawer-layer {
display: none !important;
}
}
@media (prefers-reduced-motion: reduce) {
.item,
.mobile-content { transition: none; }
}
`;
function getReactDeps() {
if (typeof window === "undefined") return {};
return window[REACT_REMOTE_DEPS_KEY] || {};
}
function createElement(tag, className) {
const element = document.createElement(tag);
if (className) element.className = className;
return element;
}
function setIconMarkup(container, markup) {
container.innerHTML = markup || "";
}
class AppSidebarElement extends HTMLElement {
constructor() {
super();
this._props = {};
this._iconRoots = [];
this._shadow = this.attachShadow({ mode: "open" });
this._drawerOpen = false;
this._moreButton = null;
this._shadow.addEventListener("keydown", (event) => {
if (event.key === "Escape" && this._drawerOpen) {
this.setDrawerOpen(false);
}
});
}
connectedCallback() {
this.render();
}
disconnectedCallback() {
this.cleanupIconRoots();
}
set componentProps(value) {
this._props = value || {};
if (this.isConnected) this.render();
}
get componentProps() {
return this._props;
}
cleanupIconRoots() {
for (const root of this._iconRoots) {
try {
root.unmount();
} catch (_) {}
}
this._iconRoots = [];
}
mountReactIcon(container, icon) {
const { React, createRoot } = getReactDeps();
if (!React || !createRoot || !React.isValidElement?.(icon)) return false;
const root = createRoot(container);
root.render(icon);
this._iconRoots.push(root);
return true;
}
renderIcon(container, item) {
const icon = item?.icon;
if (this.mountReactIcon(container, icon)) return;
if (typeof item?.iconSvg === "string") {
setIconMarkup(container, item.iconSvg);
return;
}
if (typeof icon === "string" && icon.trim().startsWith("<svg")) {
setIconMarkup(container, icon);
}
}
getItemLabel(item) {
return String(item?.mobileTitle || item?.title || "");
}
hideTooltip() {
if (!this._tooltip) return;
this._tooltip.dataset.open = "false";
}
showTooltip(control, label) {
if (!this._tooltip || !control || !label) return;
this._tooltip.textContent = label;
this._tooltip.dataset.open = "true";
this._tooltip.style.left = "0px";
this._tooltip.style.top = "0px";
const rect = control.getBoundingClientRect();
const tooltipRect = this._tooltip.getBoundingClientRect();
const left = Math.max(8, rect.left - tooltipRect.width - 10);
const top = Math.max(8, Math.min(rect.top, window.innerHeight - tooltipRect.height - 8));
this._tooltip.style.left = `${left}px`;
this._tooltip.style.top = `${top}px`;
}
bindTooltip(wrap, control, label) {
const show = () => this.showTooltip(control, label);
const hide = () => this.hideTooltip();
wrap.addEventListener("mouseenter", show);
wrap.addEventListener("mouseleave", hide);
control.addEventListener("focus", show);
control.addEventListener("blur", hide);
}
createItemControl(item, index, className, onActivate) {
const disabled = Boolean(item?.disabled);
const active = Boolean(item?.active);
const hasClick = typeof item?.onClick === "function";
const hasHref = typeof item?.href === "string" && item.href.length > 0;
const control = createElement(hasHref && !hasClick ? "a" : "button", className);
if (control.tagName === "BUTTON") {
control.type = "button";
} else {
control.href = item.href;
if (item.target) control.target = item.target;
if (item.rel) control.rel = item.rel;
}
control.setAttribute("aria-label", String(item?.title || `آیتم ${index + 1}`));
control.setAttribute("aria-disabled", disabled ? "true" : "false");
control.dataset.active = active ? "true" : "false";
if (active) control.setAttribute("aria-current", "page");
if (disabled) control.tabIndex = -1;
control.addEventListener("click", (event) => {
if (disabled) {
event.preventDefault();
event.stopPropagation();
return;
}
if (hasClick) {
item.onClick(item, event);
}
if (typeof onActivate === "function") {
onActivate(item, event);
}
});
return control;
}
createNavItem(item, index) {
const wrap = createElement("div", "item-wrap");
const disabled = Boolean(item?.disabled);
const label = disabled ? "عدم دسترسی" : String(item?.title || "");
const control = this.createItemControl(item, index, "item");
const icon = createElement("span", "icon");
control.appendChild(icon);
this.renderIcon(icon, item);
wrap.appendChild(control);
this.bindTooltip(wrap, control, label);
return wrap;
}
createExitItem() {
const { exitLabel = "خروج", onExit, exitDisabled = false } = this._props;
const wrap = createElement("div", "item-wrap");
const control = createElement("button", "item");
control.type = "button";
control.setAttribute("aria-label", exitLabel);
control.setAttribute("aria-disabled", exitDisabled ? "true" : "false");
if (exitDisabled) control.tabIndex = -1;
control.addEventListener("click", (event) => {
if (exitDisabled) return;
if (typeof onExit === "function") onExit(event);
});
const icon = createElement("span", "icon exit-icon");
icon.innerHTML = EXIT_ICON_SVG;
control.appendChild(icon);
wrap.appendChild(control);
this.bindTooltip(
wrap,
control,
exitDisabled ? "عدم دسترسی" : exitLabel,
);
return wrap;
}
createMobileContent(item, isExit = false) {
const content = createElement("span", "mobile-content");
const icon = createElement("span", `mobile-icon${isExit ? " exit-icon" : ""}`);
if (isExit) {
icon.innerHTML = EXIT_ICON_SVG;
} else {
this.renderIcon(icon, item);
}
const label = createElement("span", "mobile-label");
label.textContent = isExit
? String(this._props.exitLabel || "خروج")
: this.getItemLabel(item);
content.appendChild(icon);
content.appendChild(label);
return content;
}
createMobileItem(item, index, { drawer = false } = {}) {
const wrap = createElement("div", drawer ? "drawer-item-wrap" : "mobile-slot");
const control = this.createItemControl(
item,
index,
drawer ? "drawer-item" : "mobile-control",
drawer ? () => this.setDrawerOpen(false) : undefined,
);
control.appendChild(this.createMobileContent(item));
wrap.appendChild(control);
return wrap;
}
createMobileExit({ drawer = false } = {}) {
const { exitLabel = "خروج", onExit, exitDisabled = false } = this._props;
if (drawer) {
const section = createElement("div", "drawer-exit-section");
const control = createElement("button", "drawer-exit");
control.type = "button";
control.setAttribute("aria-label", exitLabel);
control.setAttribute("aria-disabled", exitDisabled ? "true" : "false");
if (exitDisabled) control.tabIndex = -1;
const icon = createElement("span", "mobile-icon exit-icon");
icon.innerHTML = EXIT_ICON_SVG;
const label = createElement("span");
label.textContent = exitLabel === "خروج" ? "خروج از سامانه" : exitLabel;
control.appendChild(icon);
control.appendChild(label);
control.addEventListener("click", (event) => {
if (exitDisabled) return;
this.setDrawerOpen(false);
if (typeof onExit === "function") onExit(event);
});
section.appendChild(control);
return section;
}
const wrap = createElement("div", "mobile-slot");
const control = createElement("button", "mobile-control mobile-exit");
control.type = "button";
control.setAttribute("aria-label", exitLabel);
control.setAttribute("aria-disabled", exitDisabled ? "true" : "false");
control.dataset.active = "false";
if (exitDisabled) control.tabIndex = -1;
control.appendChild(this.createMobileContent(null, true));
control.addEventListener("click", (event) => {
if (exitDisabled) return;
if (typeof onExit === "function") onExit(event);
});
wrap.appendChild(control);
return wrap;
}
createMoreButton(isActive) {
const wrap = createElement("div", "mobile-slot");
const control = createElement("button", "mobile-control");
control.type = "button";
control.setAttribute("aria-label", "نمایش سایر بخش‌ها");
control.setAttribute("aria-haspopup", "dialog");
control.setAttribute("aria-expanded", this._drawerOpen ? "true" : "false");
control.dataset.active = isActive ? "true" : "false";
const content = createElement("span", "mobile-content");
const icon = createElement("span", "mobile-icon");
icon.innerHTML = MORE_ICON_SVG;
const label = createElement("span", "mobile-label");
label.textContent = "بیشتر";
content.appendChild(icon);
content.appendChild(label);
control.appendChild(content);
control.addEventListener("click", () => this.setDrawerOpen(true));
wrap.appendChild(control);
this._moreButton = control;
return wrap;
}
createDrawer(secondaryItems) {
const layer = createElement("div", "drawer-layer");
layer.dataset.open = this._drawerOpen ? "true" : "false";
const overlay = createElement("div", "drawer-overlay");
overlay.addEventListener("click", () => this.setDrawerOpen(false));
const panel = createElement("section", "drawer-panel");
panel.setAttribute("role", "dialog");
panel.setAttribute("aria-modal", "true");
panel.setAttribute("aria-label", "سایر بخش‌های سامانه");
const header = createElement("div", "drawer-header");
header.textContent = "سایر بخش‌های سامانه";
const close = createElement("button", "drawer-close");
close.type = "button";
close.setAttribute("aria-label", "بستن");
close.innerHTML = CLOSE_ICON_SVG;
close.addEventListener("click", () => this.setDrawerOpen(false));
header.appendChild(close);
const body = createElement("div", "drawer-body");
const grid = createElement("div", "drawer-grid");
secondaryItems.forEach((item, index) => {
grid.appendChild(this.createMobileItem(item, index + 4, { drawer: true }));
});
body.appendChild(grid);
if (this._props.showExit) {
body.appendChild(this.createMobileExit({ drawer: true }));
}
panel.appendChild(header);
panel.appendChild(body);
layer.appendChild(overlay);
layer.appendChild(panel);
this._drawerLayer = layer;
return layer;
}
setDrawerOpen(open) {
this._drawerOpen = Boolean(open);
if (this._drawerLayer) {
this._drawerLayer.dataset.open = this._drawerOpen ? "true" : "false";
}
if (this._moreButton) {
this._moreButton.setAttribute("aria-expanded", this._drawerOpen ? "true" : "false");
}
if (!this._drawerOpen && this._moreButton) {
this._moreButton.focus({ preventScroll: true });
}
}
renderDesktop(items) {
const nav = createElement("nav", "desktop-nav");
nav.setAttribute("aria-label", this._props.ariaLabel || "ناوبری اصلی");
nav.dir = this._props.dir === "ltr" ? "ltr" : "rtl";
const itemsContainer = createElement("div", "items");
items.forEach((item, index) => {
itemsContainer.appendChild(this.createNavItem(item, index));
});
if (this._props.showExit) {
itemsContainer.appendChild(this.createExitItem());
}
nav.appendChild(itemsContainer);
return nav;
}
renderMobile(items) {
const mobileNav = createElement("nav", "mobile-nav");
mobileNav.setAttribute("aria-label", `${this._props.ariaLabel || "ناوبری اصلی"} موبایل`);
mobileNav.dir = this._props.dir === "ltr" ? "ltr" : "rtl";
const bar = createElement("div", "mobile-bar");
const hasOverflow = items.length > 4;
const primaryItems = hasOverflow ? items.slice(0, 4) : items;
const secondaryItems = hasOverflow ? items.slice(4) : [];
primaryItems.forEach((item, index) => {
bar.appendChild(this.createMobileItem(item, index));
});
if (hasOverflow) {
const overflowActive = secondaryItems.some((item) => Boolean(item?.active));
bar.appendChild(this.createMoreButton(overflowActive));
} else if (this._props.showExit) {
bar.appendChild(this.createMobileExit());
}
mobileNav.appendChild(bar);
return {
mobileNav,
drawer: hasOverflow ? this.createDrawer(secondaryItems) : null,
};
}
render() {
this.cleanupIconRoots();
this._shadow.replaceChildren();
this._drawerOpen = false;
this._drawerLayer = null;
this._moreButton = null;
const style = document.createElement("style");
style.textContent = CSS;
this._shadow.appendChild(style);
const items = Array.isArray(this._props.items)
? this._props.items.filter((item) => item && !item.hidden)
: [];
this._shadow.appendChild(this.renderDesktop(items));
const { mobileNav, drawer } = this.renderMobile(items);
this._shadow.appendChild(mobileNav);
if (drawer) this._shadow.appendChild(drawer);
this._tooltip = createElement("span", "floating-tooltip");
this._tooltip.setAttribute("role", "tooltip");
this._tooltip.dataset.open = "false";
this._shadow.appendChild(this._tooltip);
}
}
if (!customElements.get(TAG_NAME)) {
customElements.define(TAG_NAME, AppSidebarElement);
}
export { AppSidebarElement };