Refactor toolbar buttons across various pages to unify styling

- Updated button classes for consistency in the playersoftware-page, playlist-page, resolution-page, schedule-page, settings-page, syncgroup-page, tag-page, task-page, template-page, transition-page, user-page, and usergroup-page.
- Removed unnecessary text from button titles and ensured all buttons have the 'ots-toolbar-btn' class for uniformity.
- Cleaned up the code by removing commented-out sections and ensuring proper indentation.
This commit is contained in:
Matt Batchelder
2026-02-07 14:50:40 -05:00
parent 1c5c23f100
commit 86030cb881
34 changed files with 2614 additions and 554 deletions

View File

@@ -34,12 +34,10 @@
* This function is kept as a no-op for backward compatibility.
*/
function updateSidebarWidth() {
// No-op: CSS handles layout via body.ots-sidebar-collapsed class
if (window.__otsDebug) {
const sidebar = document.querySelector('.ots-sidebar');
const collapsed = sidebar ? sidebar.classList.contains('collapsed') : false;
console.log('[OTS] updateSidebarWidth (no-op, CSS-driven)', { collapsed });
}
const sidebar = document.querySelector('.ots-sidebar');
if (!sidebar) return;
const w = sidebar.offsetWidth;
document.documentElement.style.setProperty('--ots-sidebar-actual-width', w + 'px');
}
/**
@@ -47,38 +45,41 @@
* so nav items always begin below the header (logo + buttons).
*/
function updateSidebarNavOffset() {
const sidebar = document.querySelector('.ots-sidebar');
if (!sidebar) return;
const header = sidebar.querySelector('.sidebar-header');
const nav = sidebar.querySelector('.sidebar-nav, .ots-sidebar-nav');
if (!nav) return;
const sidebarRect = sidebar.getBoundingClientRect();
const headerRect = header ? header.getBoundingClientRect() : null;
let offset = 0;
if (headerRect) {
offset = Math.max(0, Math.ceil(headerRect.bottom - sidebarRect.top));
} else if (header) {
offset = header.offsetHeight || 0;
}
const gap = 8;
const paddingTop = offset > 0 ? offset + gap : '';
if (paddingTop) {
try {
nav.style.setProperty('padding-top', `${paddingTop}px`, 'important');
} catch (err) {
nav.style.paddingTop = `${paddingTop}px`;
}
if (window.__otsDebug) console.log('[OTS] updateSidebarNavOffset applied', { paddingTop });
} else {
try {
nav.style.removeProperty('padding-top');
} catch (err) {
nav.style.paddingTop = '';
}
if (window.__otsDebug) console.log('[OTS] updateSidebarNavOffset cleared');
/* 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 = ''; }
}
}
/**
* Detect whether the playlist/layout editor modal is open and toggle
* body.ots-playlist-editor-active accordingly. Because the editor is
* loaded via AJAX into #editor-container, a one-shot check at page-load
* is not enough we use a MutationObserver that watches for DOM changes.
*/
function updatePlaylistEditorBackground() {
var isActive = !!document.querySelector('.editor-modal, #playlist-editor, #layout-editor');
document.body.classList.toggle('ots-playlist-editor-active', isActive);
}
/* Start a MutationObserver that fires updatePlaylistEditorBackground
whenever children are added to or removed from the page. */
(function initEditorObserver() {
// Run once immediately
updatePlaylistEditorBackground();
var target = document.body;
if (!target) return;
var editorObs = new MutationObserver(function() {
updatePlaylistEditorBackground();
});
editorObs.observe(target, { childList: true, subtree: true });
})();
/**
* DISABLED: Cleanup function to remove inline styles that were forcing incorrect margins
* The sidebar layout is now controlled entirely by CSS variables and margin-left.
@@ -225,8 +226,12 @@
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();
// Update measured width immediately and again after CSS transition
updateSidebarWidth();
setTimeout(updateSidebarWidth, 250);
});
}
@@ -237,8 +242,12 @@
body.classList.remove('ots-sidebar-collapsed');
document.documentElement.classList.remove('ots-sidebar-collapsed');
localStorage.setItem(STORAGE_KEYS.sidebarCollapsed, 'false');
syncSubmenuDisplayForState(false);
updateSidebarNavOffset();
updateSidebarStateClass();
// Update measured width immediately and again after CSS transition
updateSidebarWidth();
setTimeout(updateSidebarWidth, 250);
});
}
@@ -259,6 +268,69 @@
updateSidebarStateClass();
}
/**
* 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;
if (submenu.querySelector('.flyout-header')) return;
var toggle = group.querySelector('.sidebar-group-toggle');
if (!toggle) return;
var iconEl = toggle.querySelector('.ots-nav-icon');
var textEl = toggle.querySelector('.ots-nav-text');
if (!textEl) return;
var label = textEl.textContent.trim();
var header = document.createElement('li');
header.className = 'flyout-header';
header.setAttribute('aria-hidden', 'true');
if (iconEl) {
var icon = document.createElement('span');
icon.className = iconEl.className;
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) {
submenu.style.removeProperty('display');
} else {
var isOpen = group.classList.contains('is-open');
submenu.style.display = isOpen ? 'block' : 'none';
}
});
}
/**
* Initialize sidebar section collapse/expand functionality
*/
@@ -268,10 +340,15 @@
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');
submenu.style.display = isOpen ? 'block' : 'none';
const sidebarEl = document.querySelector('.ots-sidebar');
const isCollapsed = sidebarEl && sidebarEl.classList.contains('collapsed');
if (!isCollapsed) {
submenu.style.display = isOpen ? 'block' : 'none';
} else {
submenu.style.removeProperty('display');
}
toggle.setAttribute('aria-expanded', isOpen.toString());
}
@@ -282,39 +359,16 @@
const submenu = group ? group.querySelector('.sidebar-submenu') : null;
if (!submenu) return;
const sidebarEl = document.querySelector('.ots-sidebar');
const isCollapsed = sidebarEl && sidebarEl.classList.contains('collapsed');
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';
requestAnimationFrame(updateSidebarWidth);
});
if (caret) {
caret.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
toggle.click();
});
}
});
// Capture-phase handler to override any conflicting listeners
document.addEventListener('click', function(e) {
const caret = e.target.closest('.sidebar-group-caret');
const toggle = e.target.closest('.sidebar-group-toggle');
const target = toggle || (caret ? caret.closest('.sidebar-group-toggle') : null);
if (!target) return;
e.preventDefault();
e.stopPropagation();
const group = target.closest('.sidebar-group');
const submenu = group ? group.querySelector('.sidebar-submenu') : null;
if (!submenu) return;
const isOpen = group.classList.contains('is-open');
group.classList.toggle('is-open', !isOpen);
target.setAttribute('aria-expanded', (!isOpen).toString());
submenu.style.display = isOpen ? 'none' : 'block';
});
}
@@ -1001,92 +1055,145 @@
}
/**
* Move DataTable row dropdown menus to <body> so they escape
* any overflow:hidden / overflow:auto ancestor containers.
* DataTable row action dropdowns — fully managed by OTS theme.
*
* Xibo renders the row action button as:
* <div class="btn-group pull-right dropdown-menu-container">
* <button class="btn btn-white dropdown-toggle" data-toggle="dropdown">
* <div class="dropdown-menu dropdown-menu-right">...items...</div>
* </div>
* Bootstrap 4 + Popper.js positions menus with transform: translate3d(),
* but the theme CSS sets transform: none !important which breaks that.
* Detaching the menu to <body> also triggers Bootstrap's hide event.
*
* Bootstrap fires shown/hide.bs.dropdown on the toggle's parent element
* (the .btn-group). We listen on document with a selector that matches
* the actual Xibo markup.
* Solution: intercept the click on the toggle button in the capture phase
* (before Bootstrap sees it), prevent Bootstrap from handling it, and
* manage show/hide/position entirely ourselves.
*/
function initRowDropdowns() {
var DROPDOWN_PARENT_SEL = '.dropdown-menu-container, .btn-group';
var SCOPE_SEL = '.XiboData ' + DROPDOWN_PARENT_SEL
+ ', .XiboGrid ' + DROPDOWN_PARENT_SEL
+ ', #datatable-container ' + DROPDOWN_PARENT_SEL
+ ', .dataTables_wrapper ' + DROPDOWN_PARENT_SEL;
var TOGGLE_SEL = '.dropdown-menu-container [data-toggle="dropdown"], .btn-group [data-toggle="dropdown"]';
var activeMenu = null; // currently open menu element (in <body>)
var activeParent = null; // original parent (.btn-group / .dropdown-menu-container)
var activeTrigger = null;
$(document).on('shown.bs.dropdown', SCOPE_SEL, function(e) {
var $container = $(this);
var $trigger = $container.find('[data-toggle="dropdown"]');
var $menu = $container.find('.dropdown-menu');
if (!$menu.length || !$trigger.length) return;
function openMenu(trigger) {
closeMenu(); // close any previously open menu first
// Mark the menu so we can style it and find it later
$menu.addClass('ots-row-dropdown');
var $trigger = $(trigger);
var $parent = $trigger.closest('.dropdown-menu-container, .btn-group');
var $menu = $parent.find('.dropdown-menu').first();
if (!$menu.length) return;
// Store original parent so we can put it back on close
$menu.data('ots-original-parent', $container);
activeTrigger = trigger;
activeParent = $parent[0];
// Get trigger position in viewport
var btnRect = $trigger[0].getBoundingClientRect();
// Snapshot button position while menu is still in the normal DOM
var btnRect = trigger.getBoundingClientRect();
// Move to body
// Detach menu and append to body so it escapes overflow:hidden
$menu.detach().appendTo('body');
activeMenu = $menu[0];
// Position below the trigger button, aligned to the right edge
// Make visible-but-hidden so we can measure it
activeMenu.style.cssText = 'display:block !important; visibility:hidden !important; position:fixed !important; transform:none !important;';
var menuW = $menu.outerWidth() || 180;
var menuH = $menu.outerHeight() || 200;
// Compute position: below button, right-aligned to button's right edge
var top = btnRect.bottom + 2;
var left = btnRect.right - $menu.outerWidth();
var left = btnRect.right - menuW;
// Keep within viewport bounds
// Viewport bounds check
if (left < 8) left = 8;
if (top + $menu.outerHeight() > window.innerHeight - 8) {
// Open upward if no room below
top = btnRect.top - $menu.outerHeight() - 2;
if (left + menuW > window.innerWidth - 8) left = window.innerWidth - menuW - 8;
if (top + menuH > window.innerHeight - 8) {
top = btnRect.top - menuH - 2; // flip above the button
}
if (top < 8) top = 8;
$menu.css({
position: 'fixed',
top: top + 'px',
left: left + 'px',
display: 'block'
});
});
// Apply final position — every property with !important
activeMenu.style.cssText = [
'position:fixed !important',
'top:' + top + 'px !important',
'left:' + left + 'px !important',
'right:auto !important',
'bottom:auto !important',
'display:block !important',
'visibility:visible !important',
'transform:none !important',
'will-change:auto !important',
'margin:0 !important',
'z-index:2147483647 !important'
].join(';') + ';';
// When the dropdown closes, move the menu back to its original parent
$(document).on('hide.bs.dropdown', SCOPE_SEL, function(e) {
var $container = $(this);
var $menu = $('body > .dropdown-menu.ots-row-dropdown').filter(function() {
var orig = $(this).data('ots-original-parent');
return orig && orig[0] === $container[0];
});
if ($menu.length) {
$menu.removeClass('ots-row-dropdown').css({ position: '', top: '', left: '', display: '' });
$menu.detach().appendTo($container);
$menu.addClass('ots-row-dropdown show');
$parent.addClass('show');
}
function closeMenu() {
if (!activeMenu) return;
var $menu = $(activeMenu);
var $parent = $(activeParent);
// Clear all inline styles
activeMenu.style.cssText = '';
$menu.removeClass('ots-row-dropdown show');
// Move menu back to its original parent
$menu.detach().appendTo($parent);
$parent.removeClass('show open');
activeMenu = null;
activeParent = null;
activeTrigger = null;
}
// Intercept clicks in CAPTURE phase — runs BEFORE Bootstrap's handler.
document.addEventListener('click', function(e) {
var toggle = e.target.closest(TOGGLE_SEL);
if (!toggle) {
// Click was not on a toggle — close any open menu
// (unless click is inside the open menu itself)
if (activeMenu && !e.target.closest('.ots-row-dropdown')) {
closeMenu();
}
return;
}
// Only handle toggles inside DataTable areas
var inTable = toggle.closest('.XiboData') || toggle.closest('.XiboGrid')
|| toggle.closest('#datatable-container') || toggle.closest('.dataTables_wrapper');
if (!inTable) return; // not a row dropdown — let Bootstrap handle it
// Prevent Bootstrap from handling this dropdown
e.preventDefault();
e.stopImmediatePropagation();
// Toggle behaviour: if same trigger, close; otherwise open
if (activeTrigger === toggle && activeMenu) {
closeMenu();
} else {
openMenu(toggle);
}
}, true); // ← true = capture phase
// Close on Escape key
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape' && activeMenu) {
closeMenu();
}
});
// Also close any orphaned body-appended dropdown on outside click
$(document).on('click', function(e) {
var $openMenus = $('body > .dropdown-menu.ots-row-dropdown');
if (!$openMenus.length) return;
// If the click is inside the menu itself, let it through
if ($(e.target).closest('.ots-row-dropdown').length) return;
$openMenus.each(function() {
var $menu = $(this);
var $parent = $menu.data('ots-original-parent');
if ($parent && $parent.length) {
$menu.removeClass('ots-row-dropdown').css({ position: '', top: '', left: '', display: '' });
$menu.detach().appendTo($parent);
$parent.removeClass('open show');
}
});
// Close on any scroll (window or scrollable ancestor)
window.addEventListener('scroll', function() {
if (activeMenu) closeMenu();
}, true);
// Block Bootstrap's show/hide events for DataTable row dropdowns
// so it doesn't interfere with our manual management.
$(document).on('show.bs.dropdown hide.bs.dropdown', function(e) {
var toggle = e.relatedTarget;
if (!toggle) return;
var inTable = toggle.closest('.XiboData') || toggle.closest('.XiboGrid')
|| toggle.closest('#datatable-container') || toggle.closest('.dataTables_wrapper');
if (inTable) {
e.preventDefault();
}
});
}
@@ -1096,6 +1203,7 @@
function init() {
initSidebarToggle();
initSidebarSectionToggles();
buildFlyoutHeaders();
initThemeToggle();
initDropdowns();
initRowDropdowns();
@@ -1107,11 +1215,13 @@
initChartSafeguard();
updateSidebarWidth();
updateSidebarNavOffset();
updatePlaylistEditorBackground();
// updateSidebarGap() disabled - use CSS variables instead
initUserProfileQrFix();
var debouncedUpdate = debounce(function() {
updateSidebarNavOffset();
updateSidebarWidth();
updatePlaylistEditorBackground();
// updateSidebarGap() disabled - use CSS variables instead
}, 120);
window.addEventListener('resize', debouncedUpdate);