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:
2026-03-10 15:29:37 +03:00
parent 304c4703b9
commit 6349e91e0f
10 changed files with 108 additions and 24 deletions

View File

@@ -84,8 +84,6 @@ body.modal-open {
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}

View File

@@ -2,11 +2,12 @@ header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0 12px;
padding: 8px 0 8px;
position: sticky;
top: 0;
z-index: 100;
background: var(--bg-color);
border-bottom: 2px solid var(--border-color);
}
.header-title {
@@ -148,8 +149,6 @@ h2 {
display: flex;
align-items: center;
gap: 4px;
border-bottom: 2px solid var(--border-color);
margin-bottom: 16px;
}
.tab-btn {

View File

@@ -700,7 +700,7 @@ body.pp-filter-dragging .pp-filter-drag-handle {
/* Sub-tab content sections */
.subtab-section {
margin-bottom: 24px;
scroll-margin-top: var(--header-height, 48px);
scroll-margin-top: var(--sticky-top, 90px);
}
.subtab-section:last-child {
@@ -728,10 +728,6 @@ body.pp-filter-dragging .pp-filter-drag-handle {
gap: 8px;
cursor: pointer;
user-select: none;
position: sticky;
top: var(--header-height, 0px);
z-index: 10;
background: var(--bg-color);
padding: 8px 0;
}

View File

@@ -13,8 +13,8 @@
min-width: 210px;
flex-shrink: 0;
position: sticky;
top: calc(var(--header-height, 48px) + 52px);
max-height: calc(100vh - var(--header-height, 48px) - 80px);
top: calc(var(--sticky-top, 90px) + 8px);
max-height: calc(100vh - var(--sticky-top, 90px) - 24px);
overflow-y: auto;
padding: 4px 0;
}

View File

@@ -524,13 +524,13 @@ document.addEventListener('DOMContentLoaded', async () => {
// Show content now that translations are loaded and tabs are set
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');
if (headerEl) {
const updateHeaderHeight = () => {
document.documentElement.style.setProperty(
'--header-height', headerEl.offsetHeight + 'px'
);
const hh = headerEl.offsetHeight;
document.documentElement.style.setProperty('--header-height', hh + 'px');
document.documentElement.style.setProperty('--sticky-top', hh + 'px');
};
updateHeaderHeight();
window.addEventListener('resize', updateHeaderHeight);

View File

@@ -38,6 +38,14 @@ export class TreeNav {
this._leafMap = new Map(); // key → leaf config
this._activeLeaf = null;
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>`;
}
/**
* 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) {
// Group header toggle
container.querySelectorAll('.tree-group-header').forEach(header => {
@@ -191,6 +267,7 @@ export class TreeNav {
if (el.closest('.tree-group-header')) return;
const key = el.dataset.treeLeaf;
this.setActive(key);
this.suppressObserver();
if (this.onSelect) this.onSelect(key, this._leafMap.get(key));
});
});

View File

@@ -1538,6 +1538,14 @@ function renderPictureSourcesList(streams) {
// 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.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',
});
}
}

View File

@@ -21,8 +21,12 @@ function _setHash(tab, subTab) {
let _suppressHashUpdate = false;
let _activeTab = null;
const _tabScrollPositions = {};
export function switchTab(name, { updateHash = true, skipLoad = false } = {}) {
if (_activeTab === name) return;
// Save scroll position of the tab we're leaving
if (_activeTab) _tabScrollPositions[_activeTab] = window.scrollY;
_activeTab = name;
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}`));
localStorage.setItem('activeTab', name);
// Restore scroll position for this tab
requestAnimationFrame(() => window.scrollTo(0, _tabScrollPositions[name] || 0));
if (updateHash && !_suppressHashUpdate) {
const subTab = name === 'targets'
? (localStorage.getItem('activeTargetSubTab') || 'led')

View File

@@ -723,6 +723,7 @@ export async function loadTargetsTab() {
// 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.update(treeGroups, activeLeaf);
_targetsTree.observeSections('targets-panel-content');
}
// Show/hide stop-all buttons based on running state