Files
jellytau/src/lib/components/library/alphabetStrip.test.ts
T
dtourolle 8a2b484e36 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
2026-08-25 22:47:08 +02:00

89 lines
3.2 KiB
TypeScript

import { describe, it, expect } from "vitest";
import { stripHeightFor, type StripHeightInput } from "./alphabetStrip";
/**
* Regression: the A-Z jump strip ran under the mini player, so the tail of the
* alphabet could not be tapped.
*
* The strip used to size itself against `window.innerHeight` minus a hardcoded
* guess at the bottom bars' height (5rem / 7rem / 11rem, chosen by platform and
* whether the mini player was showing). Those bars stopped being fixed overlays
* when BottomUi became an in-flow flex sibling below the scroller, so the guess
* has no relationship to the real stack — and it is short on any device with a
* navigation/gesture bar, because `--safe-bottom` is padded *inside* BottomUi.
*
* The invariant every case below asserts: the strip must end at or above the
* scroller's own bottom edge, which is exactly the top of the mini player.
*
* TRACES: UR-007 | DR-007 | UT-235, UT-236, UT-237
*/
/** 800px-tall phone viewport; the library scroller starts 120px down. */
const VIEWPORT = 800;
const STRIP_TOP = 120;
/** Measured heights of the real bottom UI, in CSS px. */
const NAV = 77; // BottomNav: py-2 + icon 24 + gap 4 + label 16 + py-2, + 1px border
const MINI = 69; // MiniPlayer: 4px progress bar + 48px artwork row + py-2, + 1px border
const REMOTE_ROW = 32; // "Playing on <device>" banner, remote mode only
const GESTURE_BAR = 48; // --safe-bottom on a 3-button nav device
function bounds(bottomUiHeight: number): StripHeightInput {
return {
stripTop: STRIP_TOP,
scrollerBottom: VIEWPORT - bottomUiHeight,
viewportHeight: VIEWPORT,
};
}
describe("stripHeightFor", () => {
it("keeps the last letter above the bottom nav when nothing is playing", () => {
const input = bounds(NAV + GESTURE_BAR);
const bottom = STRIP_TOP + stripHeightFor(input);
expect(bottom).toBeLessThanOrEqual(input.scrollerBottom!);
});
it("keeps the last letter above the mini player while audio plays", () => {
const input = bounds(MINI + NAV + GESTURE_BAR);
const bottom = STRIP_TOP + stripHeightFor(input);
expect(bottom).toBeLessThanOrEqual(input.scrollerBottom!);
});
it("survives the taller mini player of remote mode", () => {
const input = bounds(REMOTE_ROW + MINI + NAV + GESTURE_BAR);
const bottom = STRIP_TOP + stripHeightFor(input);
expect(bottom).toBeLessThanOrEqual(input.scrollerBottom!);
});
it("still fills the space it does have, rather than stopping short", () => {
const input = bounds(MINI + NAV + GESTURE_BAR);
const available = input.scrollerBottom! - STRIP_TOP;
// Within one letter's worth of the space available (letters are ~16px).
expect(stripHeightFor(input)).toBeGreaterThan(available - 16);
});
it("falls back to the viewport when the strip has no scroll container", () => {
const height = stripHeightFor({
stripTop: STRIP_TOP,
scrollerBottom: null,
viewportHeight: VIEWPORT,
});
expect(STRIP_TOP + height).toBeLessThanOrEqual(VIEWPORT);
});
it("never returns a negative height when the strip is scrolled past the floor", () => {
const height = stripHeightFor({
stripTop: 900,
scrollerBottom: 600,
viewportHeight: VIEWPORT,
});
expect(height).toBe(0);
});
});