Sticky header with centered tabs, scroll-spy, and full-width layout
- Move tab bar into header (centered between title and toolbar) - Make entire header sticky with border-bottom separator - Remove container max-width for full-width layout - Add scroll-spy: tree sidebar tracks visible section on scroll - Remember scroll position per tab when switching - Remove sticky section headers, use scroll-margin-top instead - Update sticky offsets to use --sticky-top CSS variable Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -84,8 +84,6 @@ body.modal-open {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
max-width: 1200px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,11 +2,12 @@ header {
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 8px 0 12px;
|
padding: 8px 0 8px;
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
background: var(--bg-color);
|
background: var(--bg-color);
|
||||||
|
border-bottom: 2px solid var(--border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-title {
|
.header-title {
|
||||||
@@ -148,8 +149,6 @@ h2 {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
border-bottom: 2px solid var(--border-color);
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab-btn {
|
.tab-btn {
|
||||||
|
|||||||
@@ -700,7 +700,7 @@ body.pp-filter-dragging .pp-filter-drag-handle {
|
|||||||
/* Sub-tab content sections */
|
/* Sub-tab content sections */
|
||||||
.subtab-section {
|
.subtab-section {
|
||||||
margin-bottom: 24px;
|
margin-bottom: 24px;
|
||||||
scroll-margin-top: var(--header-height, 48px);
|
scroll-margin-top: var(--sticky-top, 90px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.subtab-section:last-child {
|
.subtab-section:last-child {
|
||||||
@@ -728,10 +728,6 @@ body.pp-filter-dragging .pp-filter-drag-handle {
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
position: sticky;
|
|
||||||
top: var(--header-height, 0px);
|
|
||||||
z-index: 10;
|
|
||||||
background: var(--bg-color);
|
|
||||||
padding: 8px 0;
|
padding: 8px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,8 +13,8 @@
|
|||||||
min-width: 210px;
|
min-width: 210px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: calc(var(--header-height, 48px) + 52px);
|
top: calc(var(--sticky-top, 90px) + 8px);
|
||||||
max-height: calc(100vh - var(--header-height, 48px) - 80px);
|
max-height: calc(100vh - var(--sticky-top, 90px) - 24px);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 4px 0;
|
padding: 4px 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -524,13 +524,13 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
// Show content now that translations are loaded and tabs are set
|
// Show content now that translations are loaded and tabs are set
|
||||||
document.body.style.visibility = 'visible';
|
document.body.style.visibility = 'visible';
|
||||||
|
|
||||||
// Set CSS variable for sticky header height so section headers stack below it
|
// Set CSS variable for sticky header height (header now includes tab bar)
|
||||||
const headerEl = document.querySelector('header');
|
const headerEl = document.querySelector('header');
|
||||||
if (headerEl) {
|
if (headerEl) {
|
||||||
const updateHeaderHeight = () => {
|
const updateHeaderHeight = () => {
|
||||||
document.documentElement.style.setProperty(
|
const hh = headerEl.offsetHeight;
|
||||||
'--header-height', headerEl.offsetHeight + 'px'
|
document.documentElement.style.setProperty('--header-height', hh + 'px');
|
||||||
);
|
document.documentElement.style.setProperty('--sticky-top', hh + 'px');
|
||||||
};
|
};
|
||||||
updateHeaderHeight();
|
updateHeaderHeight();
|
||||||
window.addEventListener('resize', updateHeaderHeight);
|
window.addEventListener('resize', updateHeaderHeight);
|
||||||
|
|||||||
@@ -38,6 +38,14 @@ export class TreeNav {
|
|||||||
this._leafMap = new Map(); // key → leaf config
|
this._leafMap = new Map(); // key → leaf config
|
||||||
this._activeLeaf = null;
|
this._activeLeaf = null;
|
||||||
this._extraHtml = '';
|
this._extraHtml = '';
|
||||||
|
this._observerSuppressed = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Temporarily suppress scroll-spy (e.g. during programmatic scroll). */
|
||||||
|
suppressObserver(ms = 800) {
|
||||||
|
this._observerSuppressed = true;
|
||||||
|
clearTimeout(this._suppressTimer);
|
||||||
|
this._suppressTimer = setTimeout(() => { this._observerSuppressed = false; }, ms);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -170,6 +178,74 @@ export class TreeNav {
|
|||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start observing card-section elements so the active tree leaf
|
||||||
|
* follows whichever section is currently visible on screen.
|
||||||
|
* @param {string} contentId - ID of the scrollable content container
|
||||||
|
* @param {Object<string,string>} [sectionMap] - optional { data-card-section → leafKey } override
|
||||||
|
*/
|
||||||
|
observeSections(contentId, sectionMap) {
|
||||||
|
this.stopObserving();
|
||||||
|
const content = document.getElementById(contentId);
|
||||||
|
if (!content) return;
|
||||||
|
|
||||||
|
// Build sectionKey → leafKey mapping
|
||||||
|
const sectionToLeaf = new Map();
|
||||||
|
if (sectionMap) {
|
||||||
|
for (const [sk, lk] of Object.entries(sectionMap)) sectionToLeaf.set(sk, lk);
|
||||||
|
} else {
|
||||||
|
for (const [key, leaf] of this._leafMap) {
|
||||||
|
sectionToLeaf.set(leaf.sectionKey || key, key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const stickyTop = parseInt(getComputedStyle(document.documentElement)
|
||||||
|
.getPropertyValue('--sticky-top')) || 90;
|
||||||
|
|
||||||
|
// Track which sections are currently intersecting
|
||||||
|
const visible = new Set();
|
||||||
|
|
||||||
|
this._observer = new IntersectionObserver((entries) => {
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.isIntersecting) visible.add(entry.target);
|
||||||
|
else visible.delete(entry.target);
|
||||||
|
}
|
||||||
|
if (this._observerSuppressed) return;
|
||||||
|
// Read fresh rects to find the topmost visible section
|
||||||
|
let bestEl = null;
|
||||||
|
let bestTop = Infinity;
|
||||||
|
for (const el of visible) {
|
||||||
|
const top = el.getBoundingClientRect().top;
|
||||||
|
if (top < bestTop) { bestTop = top; bestEl = el; }
|
||||||
|
}
|
||||||
|
if (bestEl) {
|
||||||
|
const sectionKey = bestEl.dataset.cardSection;
|
||||||
|
const leafKey = sectionToLeaf.get(sectionKey);
|
||||||
|
if (leafKey && leafKey !== this._activeLeaf) {
|
||||||
|
this._activeLeaf = leafKey;
|
||||||
|
this.setActive(leafKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
rootMargin: `-${stickyTop}px 0px -40% 0px`,
|
||||||
|
threshold: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
content.querySelectorAll('[data-card-section]').forEach(section => {
|
||||||
|
if (sectionToLeaf.has(section.dataset.cardSection)) {
|
||||||
|
this._observer.observe(section);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stop the scroll-spy observer. */
|
||||||
|
stopObserving() {
|
||||||
|
if (this._observer) {
|
||||||
|
this._observer.disconnect();
|
||||||
|
this._observer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
_bindEvents(container) {
|
_bindEvents(container) {
|
||||||
// Group header toggle
|
// Group header toggle
|
||||||
container.querySelectorAll('.tree-group-header').forEach(header => {
|
container.querySelectorAll('.tree-group-header').forEach(header => {
|
||||||
@@ -191,6 +267,7 @@ export class TreeNav {
|
|||||||
if (el.closest('.tree-group-header')) return;
|
if (el.closest('.tree-group-header')) return;
|
||||||
const key = el.dataset.treeLeaf;
|
const key = el.dataset.treeLeaf;
|
||||||
this.setActive(key);
|
this.setActive(key);
|
||||||
|
this.suppressObserver();
|
||||||
if (this.onSelect) this.onSelect(key, this._leafMap.get(key));
|
if (this.onSelect) this.onSelect(key, this._leafMap.get(key));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1538,6 +1538,14 @@ function renderPictureSourcesList(streams) {
|
|||||||
// Render tree sidebar with expand/collapse buttons
|
// Render tree sidebar with expand/collapse buttons
|
||||||
_streamsTree.setExtraHtml(`<button class="btn-expand-collapse" onclick="expandAllStreamSections()" data-i18n-title="section.expand_all" title="${t('section.expand_all')}">⊞</button><button class="btn-expand-collapse" onclick="collapseAllStreamSections()" data-i18n-title="section.collapse_all" title="${t('section.collapse_all')}">⊟</button><button class="tutorial-trigger-btn" onclick="startSourcesTutorial()" data-i18n-title="tour.restart" title="${t('tour.restart')}">${ICON_HELP}</button>`);
|
_streamsTree.setExtraHtml(`<button class="btn-expand-collapse" onclick="expandAllStreamSections()" data-i18n-title="section.expand_all" title="${t('section.expand_all')}">⊞</button><button class="btn-expand-collapse" onclick="collapseAllStreamSections()" data-i18n-title="section.collapse_all" title="${t('section.collapse_all')}">⊟</button><button class="tutorial-trigger-btn" onclick="startSourcesTutorial()" data-i18n-title="tour.restart" title="${t('tour.restart')}">${ICON_HELP}</button>`);
|
||||||
_streamsTree.update(treeGroups, activeTab);
|
_streamsTree.update(treeGroups, activeTab);
|
||||||
|
_streamsTree.observeSections('streams-list', {
|
||||||
|
'raw-streams': 'raw', 'raw-templates': 'raw',
|
||||||
|
'static-streams': 'static_image',
|
||||||
|
'proc-streams': 'processed', 'proc-templates': 'processed',
|
||||||
|
'audio-multi': 'audio', 'audio-mono': 'audio', 'audio-templates': 'audio',
|
||||||
|
'value-sources': 'value',
|
||||||
|
'sync-clocks': 'sync',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,8 +21,12 @@ function _setHash(tab, subTab) {
|
|||||||
let _suppressHashUpdate = false;
|
let _suppressHashUpdate = false;
|
||||||
let _activeTab = null;
|
let _activeTab = null;
|
||||||
|
|
||||||
|
const _tabScrollPositions = {};
|
||||||
|
|
||||||
export function switchTab(name, { updateHash = true, skipLoad = false } = {}) {
|
export function switchTab(name, { updateHash = true, skipLoad = false } = {}) {
|
||||||
if (_activeTab === name) return;
|
if (_activeTab === name) return;
|
||||||
|
// Save scroll position of the tab we're leaving
|
||||||
|
if (_activeTab) _tabScrollPositions[_activeTab] = window.scrollY;
|
||||||
_activeTab = name;
|
_activeTab = name;
|
||||||
|
|
||||||
document.querySelectorAll('.tab-btn').forEach(btn => {
|
document.querySelectorAll('.tab-btn').forEach(btn => {
|
||||||
@@ -33,6 +37,9 @@ export function switchTab(name, { updateHash = true, skipLoad = false } = {}) {
|
|||||||
document.querySelectorAll('.tab-panel').forEach(panel => panel.classList.toggle('active', panel.id === `tab-${name}`));
|
document.querySelectorAll('.tab-panel').forEach(panel => panel.classList.toggle('active', panel.id === `tab-${name}`));
|
||||||
localStorage.setItem('activeTab', name);
|
localStorage.setItem('activeTab', name);
|
||||||
|
|
||||||
|
// Restore scroll position for this tab
|
||||||
|
requestAnimationFrame(() => window.scrollTo(0, _tabScrollPositions[name] || 0));
|
||||||
|
|
||||||
if (updateHash && !_suppressHashUpdate) {
|
if (updateHash && !_suppressHashUpdate) {
|
||||||
const subTab = name === 'targets'
|
const subTab = name === 'targets'
|
||||||
? (localStorage.getItem('activeTargetSubTab') || 'led')
|
? (localStorage.getItem('activeTargetSubTab') || 'led')
|
||||||
|
|||||||
@@ -723,6 +723,7 @@ export async function loadTargetsTab() {
|
|||||||
// Render tree sidebar with expand/collapse buttons
|
// Render tree sidebar with expand/collapse buttons
|
||||||
_targetsTree.setExtraHtml(`<button class="btn-expand-collapse" onclick="expandAllTargetSections()" data-i18n-title="section.expand_all" title="${t('section.expand_all')}">⊞</button><button class="btn-expand-collapse" onclick="collapseAllTargetSections()" data-i18n-title="section.collapse_all" title="${t('section.collapse_all')}">⊟</button><button class="tutorial-trigger-btn" onclick="startTargetsTutorial()" data-i18n-title="tour.restart" title="${t('tour.restart')}">${ICON_HELP}</button>`);
|
_targetsTree.setExtraHtml(`<button class="btn-expand-collapse" onclick="expandAllTargetSections()" data-i18n-title="section.expand_all" title="${t('section.expand_all')}">⊞</button><button class="btn-expand-collapse" onclick="collapseAllTargetSections()" data-i18n-title="section.collapse_all" title="${t('section.collapse_all')}">⊟</button><button class="tutorial-trigger-btn" onclick="startTargetsTutorial()" data-i18n-title="tour.restart" title="${t('tour.restart')}">${ICON_HELP}</button>`);
|
||||||
_targetsTree.update(treeGroups, activeLeaf);
|
_targetsTree.update(treeGroups, activeLeaf);
|
||||||
|
_targetsTree.observeSections('targets-panel-content');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show/hide stop-all buttons based on running state
|
// Show/hide stop-all buttons based on running state
|
||||||
|
|||||||
@@ -36,6 +36,12 @@
|
|||||||
<h1 data-i18n="app.title">LED Grab</h1>
|
<h1 data-i18n="app.title">LED Grab</h1>
|
||||||
<span id="server-version"><span id="version-number"></span></span>
|
<span id="server-version"><span id="version-number"></span></span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="tab-bar" role="tablist">
|
||||||
|
<button class="tab-btn" data-tab="dashboard" onclick="switchTab('dashboard')" role="tab" aria-selected="true" aria-controls="tab-dashboard" id="tab-btn-dashboard" title="Ctrl+1"><svg class="icon" viewBox="0 0 24 24"><rect width="7" height="9" x="3" y="3" rx="1"/><rect width="7" height="5" x="14" y="3" rx="1"/><rect width="7" height="9" x="14" y="12" rx="1"/><rect width="7" height="5" x="3" y="16" rx="1"/></svg> <span data-i18n="dashboard.title">Dashboard</span></button>
|
||||||
|
<button class="tab-btn" data-tab="automations" onclick="switchTab('automations')" role="tab" aria-selected="false" aria-controls="tab-automations" id="tab-btn-automations" title="Ctrl+2"><svg class="icon" viewBox="0 0 24 24"><rect width="8" height="4" x="8" y="2" rx="1" ry="1"/><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><path d="M12 11h4"/><path d="M12 16h4"/><path d="M8 11h.01"/><path d="M8 16h.01"/></svg> <span data-i18n="automations.title">Automations</span><span class="tab-badge" id="tab-badge-automations" style="display:none"></span></button>
|
||||||
|
<button class="tab-btn" data-tab="targets" onclick="switchTab('targets')" role="tab" aria-selected="false" aria-controls="tab-targets" id="tab-btn-targets" title="Ctrl+3"><svg class="icon" viewBox="0 0 24 24"><path d="M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z"/></svg> <span data-i18n="targets.title">Targets</span><span class="tab-badge" id="tab-badge-targets" style="display:none"></span></button>
|
||||||
|
<button class="tab-btn" data-tab="streams" onclick="switchTab('streams')" role="tab" aria-selected="false" aria-controls="tab-streams" id="tab-btn-streams" title="Ctrl+4"><svg class="icon" viewBox="0 0 24 24"><path d="m17 2-5 5-5-5"/><rect width="20" height="15" x="2" y="7" rx="2"/></svg> <span data-i18n="streams.title">Sources</span></button>
|
||||||
|
</div>
|
||||||
<div class="header-toolbar">
|
<div class="header-toolbar">
|
||||||
<a href="/docs" target="_blank" class="header-link" data-i18n-title="app.api_docs" title="API Docs">API</a>
|
<a href="/docs" target="_blank" class="header-link" data-i18n-title="app.api_docs" title="API Docs">API</a>
|
||||||
<span class="header-toolbar-sep"></span>
|
<span class="header-toolbar-sep"></span>
|
||||||
@@ -88,15 +94,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="tabs">
|
<div class="tabs">
|
||||||
<div class="tab-bar" role="tablist">
|
|
||||||
<button class="tab-btn" data-tab="dashboard" onclick="switchTab('dashboard')" role="tab" aria-selected="true" aria-controls="tab-dashboard" id="tab-btn-dashboard" title="Ctrl+1"><svg class="icon" viewBox="0 0 24 24"><rect width="7" height="9" x="3" y="3" rx="1"/><rect width="7" height="5" x="14" y="3" rx="1"/><rect width="7" height="9" x="14" y="12" rx="1"/><rect width="7" height="5" x="3" y="16" rx="1"/></svg> <span data-i18n="dashboard.title">Dashboard</span></button>
|
|
||||||
<button class="tab-btn" data-tab="automations" onclick="switchTab('automations')" role="tab" aria-selected="false" aria-controls="tab-automations" id="tab-btn-automations" title="Ctrl+2"><svg class="icon" viewBox="0 0 24 24"><rect width="8" height="4" x="8" y="2" rx="1" ry="1"/><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><path d="M12 11h4"/><path d="M12 16h4"/><path d="M8 11h.01"/><path d="M8 16h.01"/></svg> <span data-i18n="automations.title">Automations</span><span class="tab-badge" id="tab-badge-automations" style="display:none"></span></button>
|
|
||||||
<button class="tab-btn" data-tab="targets" onclick="switchTab('targets')" role="tab" aria-selected="false" aria-controls="tab-targets" id="tab-btn-targets" title="Ctrl+3"><svg class="icon" viewBox="0 0 24 24"><path d="M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z"/></svg> <span data-i18n="targets.title">Targets</span><span class="tab-badge" id="tab-badge-targets" style="display:none"></span></button>
|
|
||||||
<button class="tab-btn" data-tab="streams" onclick="switchTab('streams')" role="tab" aria-selected="false" aria-controls="tab-streams" id="tab-btn-streams" title="Ctrl+4"><svg class="icon" viewBox="0 0 24 24"><path d="m17 2-5 5-5-5"/><rect width="20" height="15" x="2" y="7" rx="2"/></svg> <span data-i18n="streams.title">Sources</span></button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="tab-panel" id="tab-dashboard" role="tabpanel" aria-labelledby="tab-btn-dashboard">
|
<div class="tab-panel" id="tab-dashboard" role="tabpanel" aria-labelledby="tab-btn-dashboard">
|
||||||
<div id="dashboard-content">
|
<div id="dashboard-content">
|
||||||
<div class="loading-spinner"></div>
|
<div class="loading-spinner"></div>
|
||||||
|
|||||||
Reference in New Issue
Block a user