// 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 = ` `; const MORE_ICON_SVG = ` `; const CLOSE_ICON_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(" 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 };