fix(v3): scope song search to the library + keep it above the toolbar on scroll (#560)

The topbar search (#v3-search) rendered on every screen and, on the library
screen, was hidden behind the filter toolbar while scrolling (both were
sticky top-0 z-20 in the #v3-main scroller).

- shell.js: wrap the search in #v3-search-wrap (hidden by default) and toggle
  it in syncActive() so it only shows on #v3-songs; bump the topbar to z-30 so
  it always sits above the toolbar.
- songs.js: drop the toolbar's top-0 and pin it beneath the topbar by measuring
  the topbar height (positionToolbar). A ResizeObserver on #v3-topbar keeps the
  offset correct as the topbar height changes (viewport width, search show/hide)
  and fixes the initial position regardless of render()/syncActive() ordering.

Fixes #559

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Byron Gamatos
2026-06-22 11:26:52 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 4dc5936712
commit 530995dd02
2 changed files with 39 additions and 3 deletions
+9 -2
View File
@@ -112,6 +112,10 @@
el.classList.toggle('text-fb-textDim', !on);
});
setTopbarTitle(titleFor(screenId));
// Show the song search only on the library screen. Everywhere else the
// box is irrelevant (and would silently no-op against v3Songs.search).
const searchWrap = document.getElementById('v3-search-wrap');
if (searchWrap) searchWrap.classList.toggle('hidden', screenId !== 'v3-songs');
// NOTE: we deliberately do NOT reflect the screen into location.hash on
// every navigation. app.js's audio 'error' handler suppresses empty-src
// errors only when `audio.src === window.location.href`; a `#/...`
@@ -173,13 +177,16 @@
function renderTopbar() {
const bar = document.getElementById('v3-topbar');
if (!bar) return;
bar.className = 'sticky top-0 z-20 bg-fb-sidebar/80 backdrop-blur';
bar.className = 'sticky top-0 z-30 bg-fb-sidebar/80 backdrop-blur';
bar.innerHTML =
// Row 1 — top utility bar: search.
'<div class="flex items-center gap-4 px-4 md:px-8 pt-4">' +
'<button id="v3-hamburger" class="md:hidden text-fb-textDim hover:text-fb-text shrink-0" aria-label="Menu">' +
'<svg class="w-6 h-6" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h16"/></svg></button>' +
'<div class="flex-1 max-w-md relative">' +
// Search is scoped to the library — syncActive() toggles `hidden` on
// this wrapper so it only shows on the v3-songs screen. It lives in
// the sticky topbar, so it stays put while the song grid scrolls.
'<div id="v3-search-wrap" class="flex-1 max-w-md relative hidden">' +
'<svg class="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-fb-textDim" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="11" cy="11" r="7"/><path stroke-linecap="round" d="M21 21l-4-4"/></svg>' +
'<input id="v3-search" type="search" placeholder="Search songs…" aria-label="Search songs" ' +
'class="w-full bg-gray-800/50 border border-gray-700 rounded-md pl-10 pr-4 py-2 text-sm ' +
+30 -1
View File
@@ -619,6 +619,33 @@
}, { passive: true });
}
// Pin the sticky toolbar directly beneath the sticky topbar. Both live in
// the #v3-main scroller, so without an explicit offset they share top:0 and
// the toolbar covers the topbar's song search. The topbar has two responsive
// rows, so its height is measured (and re-measured on resize) instead of
// hard-coded.
function positionToolbar() {
const topbar = document.getElementById('v3-topbar');
const bar = document.getElementById('v3-songs-toolbar');
if (!topbar || !bar) return;
bar.style.top = topbar.offsetHeight + 'px';
}
function bindToolbarReflow() {
if (state.resizeBound) return;
const topbar = document.getElementById('v3-topbar');
if (!topbar) return;
state.resizeBound = true;
// Observe the topbar itself: its height changes with viewport width AND
// when the song search is toggled in/out on screen changes. ResizeObserver
// fires once on observe(), so this also fixes up the initial position
// regardless of render() vs syncActive() ordering.
if (typeof ResizeObserver === 'function') {
new ResizeObserver(positionToolbar).observe(topbar);
} else {
window.addEventListener('resize', positionToolbar, { passive: true });
}
}
// ── Tree ────────────────────────────────────────────────────────────────
async function loadTree() {
const host = document.getElementById('v3-songs-tree');
@@ -790,7 +817,7 @@
root.innerHTML =
'<div class="max-w-7xl mx-auto px-6 md:px-8 pb-8">' +
'<div class="sticky top-0 z-20 -mx-6 md:-mx-8 px-6 md:px-8 py-3 mb-4 bg-fb-sidebar/95 backdrop-blur border-b border-fb-border/40">' +
'<div id="v3-songs-toolbar" class="sticky z-20 -mx-6 md:-mx-8 px-6 md:px-8 py-3 mb-4 bg-fb-sidebar/95 backdrop-blur border-b border-fb-border/40">' +
'<div class="flex flex-col md:flex-row md:items-end justify-between gap-4">' +
'<div><p class="text-fb-textDim text-sm" id="v3-songs-count"></p></div>' +
'<div class="flex flex-wrap gap-2">' +
@@ -870,6 +897,8 @@
// before it tries to page deeper.
await setView(state.view);
bindScroll();
positionToolbar();
bindToolbarReflow();
updateFilterBadge();
state.built = true;
}