/** * OTS Signage Modern Theme - Client-Side Utilities * Sidebar toggle, dropdown menus, and UI interactions */ (function() { 'use strict'; const STORAGE_KEYS = { sidebarCollapsed: 'otsTheme:sidebarCollapsed' }; /** * Initialize sidebar toggle functionality */ function initSidebarToggle() { const toggleBtn = document.querySelector('[data-action="toggle-sidebar"]'); const sidebar = document.querySelector('.ots-sidebar'); const closeBtn = document.querySelector('.ots-sidebar-close'); const collapseBtn = document.querySelector('.sidebar-collapse-btn-visible'); const expandBtn = document.querySelector('.sidebar-expand-btn'); const body = document.body; if (!sidebar) return; // Handle sidebar close button if (closeBtn) { closeBtn.addEventListener('click', function(e) { e.preventDefault(); sidebar.classList.remove('active'); }); } // Mobile-aware toggle: add backdrop, aria-expanded, and focus management if (toggleBtn) { let lastFocus = null; function ensureBackdrop() { let bd = document.querySelector('.ots-backdrop'); if (!bd) { bd = document.createElement('div'); bd.className = 'ots-backdrop'; bd.addEventListener('click', function() { sidebar.classList.remove('active'); bd.classList.remove('show'); toggleBtn.setAttribute('aria-expanded', 'false'); if (lastFocus) lastFocus.focus(); }); document.body.appendChild(bd); } return bd; } toggleBtn.setAttribute('role', 'button'); toggleBtn.setAttribute('aria-controls', 'ots-sidebar'); toggleBtn.setAttribute('aria-expanded', 'false'); toggleBtn.addEventListener('click', function(e) { e.preventDefault(); const isNowActive = !sidebar.classList.contains('active'); sidebar.classList.toggle('active'); // On small screens show backdrop and manage focus if (window.innerWidth <= 768) { const bd = ensureBackdrop(); if (isNowActive) { bd.classList.add('show'); toggleBtn.setAttribute('aria-expanded', 'true'); lastFocus = document.activeElement; // move focus into the sidebar const firstFocusable = sidebar.querySelector('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'); if (firstFocusable) firstFocusable.focus(); else sidebar.setAttribute('tabindex', '-1'), sidebar.focus(); // add escape handler document.addEventListener('keydown', escHandler); } else { bd.classList.remove('show'); toggleBtn.setAttribute('aria-expanded', 'false'); if (lastFocus) lastFocus.focus(); document.removeEventListener('keydown', escHandler); } } }); function escHandler(e) { if (e.key === 'Escape' || e.key === 'Esc') { const bd = document.querySelector('.ots-backdrop'); if (sidebar.classList.contains('active')) { sidebar.classList.remove('active'); if (bd) bd.classList.remove('show'); toggleBtn.setAttribute('aria-expanded', 'false'); if (lastFocus) lastFocus.focus(); document.removeEventListener('keydown', escHandler); } } } } if (collapseBtn) { const isCollapsed = localStorage.getItem(STORAGE_KEYS.sidebarCollapsed) === 'true'; if (isCollapsed) { sidebar.classList.add('collapsed'); body.classList.add('ots-sidebar-collapsed'); document.documentElement.classList.add('ots-sidebar-collapsed'); updateSidebarStateClass(); } collapseBtn.addEventListener('click', function(e) { e.preventDefault(); const nowCollapsed = !sidebar.classList.contains('collapsed'); sidebar.classList.toggle('collapsed'); body.classList.toggle('ots-sidebar-collapsed', nowCollapsed); document.documentElement.classList.toggle('ots-sidebar-collapsed', nowCollapsed); localStorage.setItem(STORAGE_KEYS.sidebarCollapsed, nowCollapsed ? 'true' : 'false'); syncSubmenuDisplayForState(nowCollapsed); updateSidebarNavOffset(); updateSidebarStateClass(); updateSidebarWidth(); setTimeout(updateSidebarWidth, 250); }); } if (expandBtn) { expandBtn.addEventListener('click', function(e) { e.preventDefault(); sidebar.classList.remove('collapsed'); body.classList.remove('ots-sidebar-collapsed'); document.documentElement.classList.remove('ots-sidebar-collapsed'); localStorage.setItem(STORAGE_KEYS.sidebarCollapsed, 'false'); syncSubmenuDisplayForState(false); updateSidebarNavOffset(); updateSidebarStateClass(); updateSidebarWidth(); setTimeout(updateSidebarWidth, 250); }); } // Initialize sidebar section toggles initSidebarSectionToggles(); // Inject flyout headers (icon + label) into each submenu for collapsed state buildFlyoutHeaders(); // Close sidebar when clicking outside on mobile document.addEventListener('click', function(e) { if (window.innerWidth <= 768) { const isClickInsideSidebar = sidebar.contains(e.target); const isClickOnToggle = toggleBtn && toggleBtn.contains(e.target); if (!isClickInsideSidebar && !isClickOnToggle && sidebar.classList.contains('active')) { sidebar.classList.remove('active'); } } }); } /** * Sidebar width is now handled purely by CSS classes (.ots-sidebar-collapsed). * This function is kept as a no-op for backward compatibility. */ function updateSidebarWidth() { const sidebar = document.querySelector('.ots-sidebar'); if (!sidebar) return; const w = sidebar.offsetWidth; document.documentElement.style.setProperty('--ots-sidebar-actual-width', w + 'px'); } /** * Measure the sidebar header bottom and set the top padding of the nav list * so nav items always begin below the header (logo + buttons). */ function updateSidebarNavOffset() { /* No-op: sidebar uses flex-direction:column so the header and nav content are separate flex children that never overlap. Previously this set padding-top:~72px which created a huge gap. */ var nav = document.querySelector('.ots-sidebar .sidebar-nav, .ots-sidebar .ots-sidebar-nav'); if (nav) { try { nav.style.removeProperty('padding-top'); } catch(e) { nav.style.paddingTop = ''; } } } // simple debounce helper function debounce(fn, wait) { let t; return function () { clearTimeout(t); t = setTimeout(() => fn.apply(this, arguments), wait); }; } function updateSidebarStateClass() { const sidebar = document.querySelector('.ots-sidebar'); if (!sidebar) return; const body = document.body; const isCollapsed = sidebar.classList.contains('collapsed'); if (!isCollapsed) { body.classList.add('ots-sidebar-open'); } else { body.classList.remove('ots-sidebar-open'); } } /** * Build flyout headers for each sidebar-submenu. * Pulls the icon class(es) and label from the parent group toggle * and injects a styled header
  • at the top of the submenu. * Idempotent — skips submenus that already have a header. */ function buildFlyoutHeaders() { var groups = document.querySelectorAll('.sidebar-group'); groups.forEach(function(group) { var submenu = group.querySelector('.sidebar-submenu'); if (!submenu) return; // Don't inject twice if (submenu.querySelector('.flyout-header')) return; var toggle = group.querySelector('.sidebar-group-toggle'); if (!toggle) return; // Grab the icon element's class list and the label text var iconEl = toggle.querySelector('.ots-nav-icon'); var textEl = toggle.querySelector('.ots-nav-text'); if (!textEl) return; var label = textEl.textContent.trim(); // Build the header
  • var header = document.createElement('li'); header.className = 'flyout-header'; header.setAttribute('aria-hidden', 'true'); // Clone the icon if (iconEl) { var icon = document.createElement('span'); icon.className = iconEl.className; // copies all fa classes icon.classList.add('flyout-header-icon'); icon.setAttribute('aria-hidden', 'true'); header.appendChild(icon); } var text = document.createElement('span'); text.className = 'flyout-header-text'; text.textContent = label; header.appendChild(text); submenu.insertBefore(header, submenu.firstChild); }); } /** * When toggling between collapsed/expanded, sync all submenu inline * display styles so that: * - Collapsed: no inline display → CSS :hover handles flyouts * - Expanded: inline display block/none based on is-open state */ function syncSubmenuDisplayForState(isCollapsed) { var groups = document.querySelectorAll('.sidebar-group'); groups.forEach(function(group) { var submenu = group.querySelector('.sidebar-submenu'); if (!submenu) return; if (isCollapsed) { // Remove inline display so CSS visibility/opacity hover rules work submenu.style.removeProperty('display'); } else { // Expanded mode: show/hide based on is-open class var isOpen = group.classList.contains('is-open'); submenu.style.display = isOpen ? 'block' : 'none'; } }); } /** * Initialize sidebar section collapse/expand functionality */ function initSidebarSectionToggles() { const groupToggles = document.querySelectorAll('.sidebar-group-toggle'); syncSidebarActiveStates(); groupToggles.forEach(toggle => { const group = toggle.closest('.sidebar-group'); const submenu = group ? group.querySelector('.sidebar-submenu') : null; const caret = toggle.querySelector('.sidebar-group-caret'); if (submenu) { const isOpen = group.classList.contains('is-open'); // Only set inline display when sidebar is NOT collapsed; // collapsed state uses CSS :hover to show flyout menus. const sidebarEl = document.querySelector('.ots-sidebar'); const isCollapsed = sidebarEl && sidebarEl.classList.contains('collapsed'); if (!isCollapsed) { submenu.style.display = isOpen ? 'block' : 'none'; } else { // Clear any leftover inline display so CSS :hover can work submenu.style.removeProperty('display'); } toggle.setAttribute('aria-expanded', isOpen.toString()); } toggle.addEventListener('click', function(e) { e.preventDefault(); const group = toggle.closest('.sidebar-group'); const submenu = group ? group.querySelector('.sidebar-submenu') : null; if (!submenu) return; const sidebarEl = document.querySelector('.ots-sidebar'); const isCollapsed = sidebarEl && sidebarEl.classList.contains('collapsed'); // When collapsed, don't toggle submenus on click — hover handles it if (isCollapsed) return; const isOpen = group.classList.contains('is-open'); group.classList.toggle('is-open', !isOpen); toggle.setAttribute('aria-expanded', (!isOpen).toString()); submenu.style.display = isOpen ? 'none' : 'block'; syncSidebarActiveStates(); }); if (caret) { caret.addEventListener('click', function(e) { e.preventDefault(); e.stopPropagation(); toggle.click(); }); } }); } function syncSidebarActiveStates() { const groups = document.querySelectorAll('.sidebar-group'); groups.forEach(group => { const toggle = group.querySelector('.sidebar-group-toggle'); if (!toggle) return; const hasActiveChild = Boolean( group.querySelector('.sidebar-list.active') || group.querySelector('.sidebar-list > a.active') ); toggle.classList.toggle('active', hasActiveChild); if (hasActiveChild) { group.classList.add('is-open'); const submenu = group.querySelector('.sidebar-submenu'); if (submenu) submenu.style.display = 'block'; toggle.setAttribute('aria-expanded', 'true'); } }); } /** * Initialize dropdown menus */ function initDropdowns() { // Only handle OTS-specific dropdowns (notifications, etc.). // The user menu (#navbarUserMenu) is handled by theme-scripts.twig's // initDropdowns() which uses floatMenu() for proper positioning. // Let Bootstrap handle topbar nav dropdowns (Schedule, Design, etc.) natively // so that links like Dayparting can navigate normally. const otsDropdowns = Array.from( document.querySelectorAll('.ots-topbar-action .dropdown, .ots-page-actions .dropdown') ).filter(function(el) { // Exclude the user menu — it has its own dedicated handler in theme-scripts.twig return !el.querySelector('#navbarUserMenu'); }); otsDropdowns.forEach(dropdown => { const toggle = dropdown.querySelector('.dropdown-toggle, [data-toggle="dropdown"]'); const menu = dropdown.querySelector('.dropdown-menu'); if (!toggle || !menu) return; // Toggle menu on toggle click toggle.addEventListener('click', function(e) { e.preventDefault(); e.stopPropagation(); const isNowActive = dropdown.classList.toggle('active'); // Close other OTS dropdowns otsDropdowns.forEach(other => { if (other !== dropdown) other.classList.remove('active'); }); }); // Close menu when clicking outside document.addEventListener('click', function(e) { if (!dropdown.contains(e.target)) { dropdown.classList.remove('active'); } }); }); // Support DataTables Buttons collections which are not wrapped by .dropdown document.addEventListener('click', function(e) { const btn = e.target.closest('.dt-button'); if (btn) { e.preventDefault(); e.stopPropagation(); const wrapper = btn.closest('.dt-buttons') || btn.parentElement; // close other open dt-buttons collections document.querySelectorAll('.dt-buttons.active').forEach(w => { if (w !== wrapper) w.classList.remove('active'); }); wrapper.classList.toggle('active'); // If DataTables placed the collection on the body, find it and position it under the clicked button const allCollections = Array.from(document.querySelectorAll('.dt-button-collection')); let collection = wrapper.querySelector('.dt-button-collection') || allCollections.find(c => !wrapper.contains(c)); // If DataTables didn't create a collection element, create one as a fallback if (!collection) { collection = document.createElement('div'); collection.className = 'dt-button-collection'; // prefer to append near wrapper for positioning; fallback to body (wrapper || document.body).appendChild(collection); } if (collection) { // hide other collections allCollections.forEach(c => { if (c !== collection) { c.classList.remove('show'); c.style.display = 'none'; } }); const rect = btn.getBoundingClientRect(); const top = rect.bottom + window.scrollY; const left = rect.left + window.scrollX; collection.style.position = 'absolute'; collection.style.top = `${top}px`; collection.style.left = `${left}px`; collection.style.display = 'block'; collection.classList.add('show'); // DEBUG: log collection contents try { console.log('dt-button-collection opened, children:', collection.children.length, collection); } catch (err) {} // If the collection is empty or visually empty, build a fallback column list from the nearest table const isEmpty = collection.children.length === 0 || collection.textContent.trim() === '' || collection.offsetHeight < 10; if (isEmpty) { try { let table = btn.closest('table') || wrapper.querySelector('table') || document.querySelector('table'); if (table && window.jQuery && jQuery.fn && jQuery.fn.dataTable && jQuery.fn.dataTable.isDataTable(table)) { const dt = jQuery(table).DataTable(); // clear existing collection.innerHTML = ''; const thead = table.querySelectorAll('thead th'); thead.forEach((th, idx) => { const text = (th.textContent || th.innerText || `Column ${idx+1}`).trim(); const item = document.createElement('div'); item.style.padding = '6px 12px'; item.style.display = 'flex'; item.style.alignItems = 'center'; item.style.gap = '8px'; const checkbox = document.createElement('input'); checkbox.type = 'checkbox'; checkbox.checked = dt.column(idx).visible(); checkbox.addEventListener('change', function() { dt.column(idx).visible(this.checked); }); const label = document.createElement('span'); label.textContent = text; label.style.color = 'var(--color-text-primary)'; item.appendChild(checkbox); item.appendChild(label); collection.appendChild(item); }); console.log('Fallback: populated collection with', collection.children.length, 'items'); } else { console.log('Fallback: no DataTable instance found to populate column visibility'); } } catch (err) { console.warn('Error building fallback column list', err); } } } return; } // click outside dt-button -> close any open collections document.querySelectorAll('.dt-buttons.active').forEach(w => w.classList.remove('active')); }); } /** * Initialize search functionality */ function initSearch() { const searchForm = document.querySelector('.topbar-search'); if (!searchForm) return; const input = searchForm.querySelector('.search-input'); if (input) { input.addEventListener('focus', function() { searchForm.style.borderColor = 'var(--color-primary)'; }); input.addEventListener('blur', function() { searchForm.style.borderColor = 'var(--color-border)'; }); } } /** * Initialize page specific interactions */ function initPageInteractions() { // Displays page - folder selection const folderItems = document.querySelectorAll('.folder-item'); folderItems.forEach(item => { item.addEventListener('click', function() { folderItems.forEach(f => f.classList.remove('active')); this.classList.add('active'); }); }); // Media page - item selection const mediaItems = document.querySelectorAll('.media-item'); mediaItems.forEach(item => { item.addEventListener('click', function() { this.style.opacity = '0.7'; setTimeout(() => this.style.opacity = '1', 200); }); }); } /** * Make sidebar responsive */ function makeResponsive() { const sidebar = document.querySelector('.ots-sidebar'); const main = document.querySelector('.ots-main'); if (!sidebar) return; // Add toggle button for mobile if (window.innerWidth <= 768) { sidebar.classList.add('mobile'); } window.addEventListener('resize', function() { if (window.innerWidth > 768) { sidebar.classList.remove('mobile', 'active'); } else { sidebar.classList.add('mobile'); } // Recompute sidebar width on resize updateSidebarWidth(); }); } /** * Prevent Chart.js errors when chart elements are missing */ function initChartSafeguard() { if (!window.Chart) return; if (typeof window.Chart.acquireContext === 'function') { window.Chart.acquireContext = function(item) { if (!item) return null; const candidate = item.length ? item[0] : item; if (candidate && typeof candidate.getContext === 'function') { return candidate.getContext('2d'); } return null; }; return; } if (window.Chart.prototype && typeof window.Chart.prototype.acquireContext === 'function') { window.Chart.prototype.acquireContext = function(item) { if (!item) return null; const candidate = item.length ? item[0] : item; if (candidate && typeof candidate.getContext === 'function') { return candidate.getContext('2d'); } return null; }; } } /** * Enhance tables: wrap in card, add per-table search box, client-side filtering * Non-destructive: skips tables already enhanced */ function enhanceTables() { const selector = '.ots-content table, .content table, .container table, .card table, table'; const tables = Array.from(document.querySelectorAll(selector)); let counter = 0; tables.forEach(table => { // only enhance tables that have a thead and tbody if (!table || table.classList.contains('modern-table')) return; if (!table.querySelector('thead') || !table.querySelector('tbody')) return; counter += 1; table.classList.add('modern-table'); // Build wrapper structure const wrapper = document.createElement('div'); wrapper.className = 'modern-table-card'; const controls = document.createElement('div'); controls.className = 'table-controls'; const input = document.createElement('input'); input.type = 'search'; input.placeholder = 'Search…'; input.className = 'table-search-input'; input.setAttribute('aria-label', 'Table search'); input.style.minWidth = '180px'; controls.appendChild(input); const tableWrapper = document.createElement('div'); tableWrapper.className = 'table-wrapper'; tableWrapper.style.overflow = 'auto'; // Insert wrapper into DOM in place of the table const parent = table.parentNode; parent.replaceChild(wrapper, table); wrapper.appendChild(controls); wrapper.appendChild(tableWrapper); tableWrapper.appendChild(table); // Simple, light-weight search filtering for this table only input.addEventListener('input', function (e) { const term = (e.target.value || '').toLowerCase(); table.querySelectorAll('tbody tr').forEach(tr => { const text = tr.textContent.toLowerCase(); tr.style.display = term === '' || text.includes(term) ? '' : 'none'; }); }); }); } /** * Initialize DataTables for enhanced behavior when available. * Falls back gracefully if DataTables or jQuery are not present. */ function initDataTables() { if (!window.jQuery) return; const $ = window.jQuery; if (!$.fn || !$.fn.dataTable) return; $('.modern-table, table').each(function () { try { if (!$.fn.dataTable.isDataTable(this)) { $(this).DataTable({ responsive: true, lengthChange: false, pageLength: 10, autoWidth: false, dom: '<"table-controls"f>rt<"table-meta"ip>', language: { search: '' } }); } } catch (err) { // If initialization fails, ignore and allow fallback enhancer console.warn('DataTables init failed for table', this, err); } }); } /** * Close every open dropdown / popover on the page. * Covers: Bootstrap 4 native dropdowns, OTS custom dropdowns, * the user-menu, notification drawer, and DataTable row menus. */ function closeAllDropdowns() { try { // Row dropdown menus appended to body document.querySelectorAll('.ots-row-dropdown').forEach(function(m) { m.classList.remove('show', 'ots-row-dropdown'); m.style.cssText = ''; }); document.querySelectorAll('.dropdown.show, .btn-group.show').forEach(function(el) { el.classList.remove('show'); var m = el.querySelector('.dropdown-menu.show'); if (m) m.classList.remove('show'); }); document.querySelectorAll('.dropdown-menu.show').forEach(function(m) { m.classList.remove('show'); }); document.querySelectorAll('.dropdown.active, .ots-topbar-action .dropdown.active, .ots-page-actions .dropdown.active').forEach(function(el) { el.classList.remove('active'); }); var userMenu = document.querySelector('.ots-user-menu-body.ots-user-menu-open'); if (userMenu) { userMenu.classList.remove('ots-user-menu-open'); var userToggle = document.querySelector('#navbarUserMenu'); if (userToggle) { var dd = userToggle.closest('.dropdown'); if (dd) dd.classList.remove('active', 'show'); } } document.querySelectorAll('.dt-buttons.active').forEach(function(w) { w.classList.remove('active'); }); document.querySelectorAll('.dt-button-collection.show').forEach(function(c) { c.classList.remove('show'); c.style.display = 'none'; }); if (window.jQuery) { window.jQuery('.dropdown-toggle[aria-expanded="true"]').attr('aria-expanded', 'false'); window.jQuery('.dropdown-menu.show').removeClass('show'); window.jQuery('.dropdown.show, .btn-group.show').removeClass('show'); } } catch (err) {} } /** * Wire up global listeners that trigger closeAllDropdowns(). */ function initGlobalDropdownDismiss() { document.addEventListener('show.bs.modal', closeAllDropdowns, true); try { if (window.jQuery) { window.jQuery(document).on('show.bs.modal', closeAllDropdowns); window.jQuery(document).on('click', '.XiboFormButton, .XiboAjaxSubmit, .XiboFormRender', function() { closeAllDropdowns(); }); } } catch (e) {} document.addEventListener('click', function(e) { var link = e.target.closest('.dropdown-menu a, .ots-topbar a.nav-link, .ots-topbar a.dropdown-item, .XiboFormButton'); if (link && !e.defaultPrevented) { closeAllDropdowns(); } }); window.addEventListener('popstate', closeAllDropdowns); try { var origPush = history.pushState; var origReplace = history.replaceState; history.pushState = function() { origPush.apply(this, arguments); closeAllDropdowns(); }; history.replaceState = function() { origReplace.apply(this, arguments); closeAllDropdowns(); }; } catch (e) {} try { var content = document.getElementById('content') || document.querySelector('.ots-content'); if (content) { var contentObs = new MutationObserver(debounce(closeAllDropdowns, 80)); contentObs.observe(content, { childList: true }); } } catch (e) {} } /** * Initialize all features when DOM is ready */ function init() { initSidebarToggle(); initDropdowns(); initGlobalDropdownDismiss(); initSearch(); initPageInteractions(); initDataTables(); enhanceTables(); makeResponsive(); initChartSafeguard(); // Set initial sidebar width variable and keep it updated updateSidebarWidth(); // Set initial nav offset and keep it updated on resize updateSidebarNavOffset(); const debouncedUpdateNavOffset = debounce(function() { updateSidebarNavOffset(); updateSidebarWidth(); }, 120); window.addEventListener('resize', debouncedUpdateNavOffset); } // Wait for DOM to be ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } })();