fix(library): keep the A-Z jump strip above the mini player
The last few letters of the A-Z index sat behind the mini player and bottom
nav, where they could not be tapped — most visible on an album's track list or
the music library, since that is where audio is usually already playing.
AlphabetScrollBar sized itself as window.innerHeight minus a hardcoded
bottomGap: 5rem, 7rem or 11rem, chosen by platform and by whether the mini
player was showing. That arithmetic dates from when the mini player and bottom
nav were position: fixed overlays. They have been in-flow flex siblings below
the scroller since BottomUi (DR-009), so the scroller's own bottom edge *is*
the top of the mini player and can simply be measured.
The guess was also short on every device with a navigation or gesture bar,
because --safe-bottom is padded inside BottomUi (DR-112) and no guess knew
about it. Measured against a 800px viewport: the strip overran the scrollport
by ~45px with the nav alone, ~18px with the mini player and ~50px in remote
mode, burying one to three letters.
The floor is now the nearest scrollable ancestor's bottom edge. That ancestor
is resolved by computed overflow-y rather than closest("main"): the root shell
scrolls in a plain div, and a miss fell back to the viewport silently, which
would reinstate the bug on any route outside /library. Observing that scroller
for resize is also what re-measures when the mini player appears, so the
component no longer subscribes to player or platform stores at all.
The floor rule is extracted to alphabetStrip.ts so it can be tested; the three
overlap cases above fail against the old arithmetic and pass against the new.
TRACES: UR-007 | DR-262 | UT-235, UT-236, UT-237
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
<!-- TRACES: UR-007 | DR-007 -->
|
||||
<!-- TRACES: UR-007 | DR-007, DR-262 -->
|
||||
<script lang="ts">
|
||||
/**
|
||||
* A vertical A-Z index strip for long, alphabetically-sorted lists.
|
||||
@@ -6,8 +6,14 @@
|
||||
*
|
||||
* The parent owns the actual scrolling: it passes `availableLetters`
|
||||
* (which letters have items) and an `onJump(letter)` callback.
|
||||
*
|
||||
* The strip's floor is the scroll container's own bottom edge, measured — not
|
||||
* the viewport minus a guess at the bottom bars. See alphabetStrip.ts for why
|
||||
* that distinction is the whole bug (DR-262).
|
||||
*/
|
||||
|
||||
import { stripHeightFor } from "./alphabetStrip";
|
||||
|
||||
const ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ".split("");
|
||||
const HASH = "#"; // bucket for names starting with a digit/symbol
|
||||
|
||||
@@ -16,43 +22,45 @@
|
||||
availableLetters: Set<string>;
|
||||
/** Called with the chosen letter when the user picks one. */
|
||||
onJump: (letter: string) => void;
|
||||
/**
|
||||
* CSS length reserved at the bottom of the viewport for the bottom nav /
|
||||
* mini-player bars. The strip stretches to fill the space between the top
|
||||
* sticky offset and this gap, so it ends just above those bars.
|
||||
*/
|
||||
bottomGap?: string;
|
||||
}
|
||||
|
||||
let { availableLetters, onJump, bottomGap = "5rem" }: Props = $props();
|
||||
let { availableLetters, onJump }: Props = $props();
|
||||
|
||||
// The strip stretches from where it sits down to just above the bottom nav /
|
||||
// mini-player bars. Those bars are pinned to the bottom of the screen, so the
|
||||
// hard floor is `window.innerHeight - bottomGap`. We measure the strip's own
|
||||
// top against that floor (clamped to non-negative) and update on scroll/resize
|
||||
// so it never slides under the bars regardless of header or platform.
|
||||
let container = $state<HTMLDivElement | null>(null);
|
||||
let stripHeight = $state(0);
|
||||
|
||||
function measure() {
|
||||
if (!container) return;
|
||||
const top = container.getBoundingClientRect().top;
|
||||
const floor = window.innerHeight - remToPx(bottomGap);
|
||||
stripHeight = Math.max(0, floor - top);
|
||||
/**
|
||||
* Nearest scrollable ancestor. Resolved by computed `overflow-y` rather than
|
||||
* by tag name: the library routes scroll in a `<main>`, but the root shell
|
||||
* scrolls in a plain `<div>`, and a `closest("main")` that misses falls back
|
||||
* to the viewport — which is exactly the too-tall strip this replaced.
|
||||
*/
|
||||
function nearestScroller(el: HTMLElement | null): HTMLElement | null {
|
||||
let node = el?.parentElement ?? null;
|
||||
while (node) {
|
||||
const overflowY = getComputedStyle(node).overflowY;
|
||||
if (overflowY === "auto" || overflowY === "scroll") return node;
|
||||
node = node.parentElement;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function remToPx(len: string): number {
|
||||
const n = parseFloat(len);
|
||||
if (len.trim().endsWith("rem")) {
|
||||
const root = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
|
||||
return n * root;
|
||||
}
|
||||
return n; // assume px otherwise
|
||||
function measure() {
|
||||
if (!container) return;
|
||||
const scroller = nearestScroller(container);
|
||||
stripHeight = stripHeightFor({
|
||||
stripTop: container.getBoundingClientRect().top,
|
||||
scrollerBottom: scroller?.getBoundingClientRect().bottom ?? null,
|
||||
viewportHeight: window.innerHeight,
|
||||
});
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
measure();
|
||||
const scroller = container?.closest("main");
|
||||
const scroller = nearestScroller(container);
|
||||
// Observing the scroller is what makes the mini player showing or hiding
|
||||
// re-measure: it is an in-flow sibling, so the scroller resizes when it
|
||||
// appears. No store subscription and no platform guess needed.
|
||||
const ro = new ResizeObserver(measure);
|
||||
if (scroller) ro.observe(scroller);
|
||||
scroller?.addEventListener("scroll", measure, { passive: true });
|
||||
@@ -64,15 +72,6 @@
|
||||
};
|
||||
});
|
||||
|
||||
// Recompute when the reserved bottom gap changes (mini-player shows/hides).
|
||||
$effect(() => {
|
||||
// Bare read: registers `bottomGap` as a dependency of this effect. Svelte 5
|
||||
// idiom, not a stray expression.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||
bottomGap;
|
||||
measure();
|
||||
});
|
||||
|
||||
const letters = $derived([HASH, ...ALPHABET]);
|
||||
let activeLetter = $state<string | null>(null);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user