Restructure, add README license and copyright
This commit is contained in:
778
ots-signs/js/theme.js
Normal file
778
ots-signs/js/theme.js
Normal file
@@ -0,0 +1,778 @@
|
||||
/**
|
||||
* 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 <li> 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 <li>
|
||||
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();
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user