From 266e7032ddd721b22512aba222fac8dd4b61a9ec Mon Sep 17 00:00:00 2001 From: Logan Date: Tue, 18 Aug 2026 11:06:14 -0400 Subject: [PATCH] fix(explore): stop labelling the album tracklist "TRACKLIST" A list of numbered titles with durations, under the album's cover, was the one thing on the page carrying a word above it saying what it is. What goes is the ink and not the element: the section is a landmark and the page's heading structure runs through it, so the h3 stays and is clipped the way sr-only clips -- never display:none, which would take it out of the accessibility tree along with the layout. Refs #9 --- .../explore-album-details.ts | 20 ++++- .../album-tracklist-heading.test.ts | 89 +++++++++++++++++++ 2 files changed, 105 insertions(+), 4 deletions(-) create mode 100644 frontend/test/components/album-tracklist-heading.test.ts diff --git a/frontend/src/components/explore-album-details/explore-album-details.ts b/frontend/src/components/explore-album-details/explore-album-details.ts index 45a02d5..b42f665 100644 --- a/frontend/src/components/explore-album-details/explore-album-details.ts +++ b/frontend/src/components/explore-album-details/explore-album-details.ts @@ -2,6 +2,7 @@ import { LitElement, html, css, nothing } from 'lit'; import { customElement, property, state, query } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; import { designTokens } from '../../styles/tokens.css'; +import { srOnly } from '../../styles/sr-only.css'; import { LookupReleaseGroup, BrowseReleases, @@ -289,6 +290,7 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost { designTokens, exploreLinkStyles, contextMenuStyles, + srOnly, css` :host { display: flex; @@ -3033,11 +3035,21 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost { /* ── Tracklist ── */ + /** + * The heading is there and is not drawn. + * + * A list of numbered titles with durations under an album's cover + * does not need a word above it saying what it is — it was the + * only thing on this page labelling something already obvious. But + * the section is a landmark and the page's heading structure runs + * through it, so what goes is the *ink*, not the element: a reader + * jumping by heading still finds the tracklist. + */ private renderTracklist() { if (this.loadingReleases) { return html`
-

Tracklist

+

Tracklist

Loading tracks\u2026
`; @@ -3050,7 +3062,7 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost { if (!current) { return html`
-

Tracklist

+

Tracklist

No release data available. @@ -3063,7 +3075,7 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost { if (tracks.length === 0) { return html`
-

Tracklist

+

Tracklist

@@ -3079,7 +3091,7 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost { return html`
-

Tracklist

+

Tracklist

${discNumbers.map((discNum) => { const discTracks = discMap.get(discNum) ?? []; diff --git a/frontend/test/components/album-tracklist-heading.test.ts b/frontend/test/components/album-tracklist-heading.test.ts new file mode 100644 index 0000000..ce1e633 --- /dev/null +++ b/frontend/test/components/album-tracklist-heading.test.ts @@ -0,0 +1,89 @@ +/** + * The tracklist's own heading. + * + * A list of numbered titles with durations, under the album's cover, is + * the one thing on this page that did not need a word above it saying + * what it was — "TRACKLIST" labelled the only thing already obvious. + * + * What goes is the *ink*, not the element. The section is a landmark + * and the page's heading structure runs through it, so a reader moving + * by heading still has to be able to find it, and it is hidden the way + * `sr-only` hides things: `clip-path`, never `display: none`, which + * would take it out of the accessibility tree along with the layout. + */ +import { describe, expect, it, beforeEach } from 'vitest'; +import type { LitElement } from 'lit'; + +import '@components/explore-album-details/explore-album-details'; +import { stub, flush, resetHarness } from '@test/support/harness'; +import { fixture, shadowAll } from '@test/support/render'; + +function track(n: number) { + return { + position: n, + discNumber: 1, + title: `Track ${n}`, + length: 200000, + mbid: `mbid-${n}`, + inLibrary: true, + }; +} + +async function albumPage(): Promise { + const el = await fixture('explore-album-details', { + albumName: 'Glass Harbour', + releaseGroupMBID: 'rg-1', + }); + + Object.assign(el, { + versionEntries: [ + { + key: 'v1', + label: '2019', + sublabel: '2 tracks', + tracks: [track(1), track(2)], + }, + ], + selectedVersionKey: 'v1', + loadingReleases: false, + loadingInfo: false, + }); + el.requestUpdate(); + await flush(); + await el.updateComplete; + + return el; +} + +const tracklistHeading = (el: LitElement) => + shadowAll(el, 'h3').find((h) => h.textContent?.trim() === 'Tracklist'); + +describe('the album tracklist heading', () => { + beforeEach(() => { + resetHarness(); + stub('library.Library.GetFilePathsByRecordingMBIDs', {}); + stub('library.Library.GetFilePathsByAlbums', {}); + stub('library.Library.GetAlbumTracks', []); + stub('library.Library.GetAllLibrariesWithTrackCounts', []); + stub('download.Service.ProviderKinds', []); + stub('download.Service.ListProviders', []); + stub('download.Service.ListDownloads', []); + stub('download.Service.ListRequests', []); + }); + + it('is still in the tree', async () => { + expect(tracklistHeading(await albumPage())).toBeTruthy(); + }); + + it('takes up no room on the page', async () => { + const heading = tracklistHeading(await albumPage())!; + const box = heading.getBoundingClientRect(); + + expect(box.width).toBeLessThanOrEqual(1); + expect(box.height).toBeLessThanOrEqual(1); + // Hidden by clipping, not by removal: display:none and + // visibility:hidden both take it out of the accessibility tree. + expect(getComputedStyle(heading).display).not.toBe('none'); + expect(getComputedStyle(heading).visibility).not.toBe('hidden'); + }); +});