The shelves, the top results and the artist page's discography and similar-artists rows scrolled sideways only by a horizontal wheel or a trackpad, so a plain mouse could not reach anything past the fold. <scroll-row> wraps each of them with previous/next arrows that are hidden at the end they cannot move from, revealed on hover where there is hover, and always shown where there is not. The album card is defined once, in albumCardStyles, instead of twice. explore-view clamped its cards to 130-150px, and with square artwork that made cards in one row different heights as well as widths. The width is fixed now and each line under the art reserves its own space. Covers are inset rather than cropped (contain, not cover). The ownership badge sits over the artwork for owned and unowned alike, and catalog cards no longer dim: a grid of dimmed covers read as a page that had failed to load. The album page's tracklist still dims unowned rows, which says something different about something different. Every MusicBrainz link now goes through openMusicBrainz, which pins the origin, including the one on the album page that still called window.open directly. solid/chevron-left is bundled for the left arrow; without it the arrow drew the missing-icon fallback. Closes #264 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017HJiuc3ZZhxsPXz3ozTirT
132 lines
3.9 KiB
TypeScript
132 lines
3.9 KiB
TypeScript
/**
|
|
* A horizontally scrolling row can be moved without a wheel.
|
|
*
|
|
* Until this existed the only way to see the cards past the fold on the
|
|
* shelves, the search results and the artist page's discography was a
|
|
* mousewheel or a trackpad gesture — which is not an affordance. A
|
|
* mouse with no horizontal wheel simply could not reach them.
|
|
*
|
|
* What is asserted here is the state that makes the arrows honest: an
|
|
* arrow is `hidden` at the end it cannot move from, because a control
|
|
* that cannot act is worse than none, and an invisible one still holds
|
|
* a hit area and a tab stop.
|
|
*/
|
|
import { beforeEach, describe, expect, it } from 'vitest';
|
|
import type { LitElement } from 'lit';
|
|
|
|
import '@components/scroll-row/scroll-row';
|
|
import { fixture } from '@test/support/render';
|
|
|
|
/** Six 100px cards in a 320px row — comfortably overflowing. */
|
|
function content(el: Element): void {
|
|
for (let i = 0; i < 6; i += 1) {
|
|
const card = document.createElement('div');
|
|
|
|
card.style.cssText = 'flex: 0 0 100px; height: 40px';
|
|
card.textContent = String(i);
|
|
el.append(card);
|
|
}
|
|
}
|
|
|
|
function arrows(el: LitElement): { prev: HTMLButtonElement; next: HTMLButtonElement } {
|
|
const root = el.shadowRoot!;
|
|
|
|
return {
|
|
prev: root.querySelector('.arrow.prev') as HTMLButtonElement,
|
|
next: root.querySelector('.arrow.next') as HTMLButtonElement,
|
|
};
|
|
}
|
|
|
|
function viewport(el: LitElement): HTMLElement {
|
|
return el.shadowRoot!.querySelector('.viewport') as HTMLElement;
|
|
}
|
|
|
|
async function row(): Promise<LitElement> {
|
|
const el = await fixture<LitElement>('scroll-row');
|
|
|
|
el.style.display = 'block';
|
|
el.style.width = '320px';
|
|
content(el);
|
|
await el.updateComplete;
|
|
// The observer reports on a later frame than a microtask drain.
|
|
await new Promise((r) => setTimeout(r, 60));
|
|
await el.updateComplete;
|
|
|
|
return el;
|
|
}
|
|
|
|
describe('<scroll-row>', () => {
|
|
beforeEach(() => {
|
|
document.body.style.margin = '0';
|
|
});
|
|
|
|
it('draws an arrow for each direction it can still move', async () => {
|
|
const el = await row();
|
|
const { prev, next } = arrows(el);
|
|
|
|
expect(prev).not.toBeNull();
|
|
expect(next).not.toBeNull();
|
|
|
|
// At the start there is nothing behind, so only the forward arrow is
|
|
// offered.
|
|
expect(prev.hasAttribute('hidden')).toBe(true);
|
|
expect(next.hasAttribute('hidden')).toBe(false);
|
|
});
|
|
|
|
it('offers the way back once the row has moved', async () => {
|
|
const el = await row();
|
|
const vp = viewport(el);
|
|
|
|
vp.scrollLeft = 120;
|
|
vp.dispatchEvent(new Event('scroll'));
|
|
await el.updateComplete;
|
|
|
|
expect(arrows(el).prev.hasAttribute('hidden')).toBe(false);
|
|
});
|
|
|
|
it('stands the forward arrow down at the end', async () => {
|
|
const el = await row();
|
|
const vp = viewport(el);
|
|
|
|
vp.scrollLeft = vp.scrollWidth;
|
|
vp.dispatchEvent(new Event('scroll'));
|
|
await el.updateComplete;
|
|
|
|
expect(arrows(el).next.hasAttribute('hidden')).toBe(true);
|
|
expect(arrows(el).prev.hasAttribute('hidden')).toBe(false);
|
|
});
|
|
|
|
it('moves the row when the arrow is pressed', async () => {
|
|
const el = await row();
|
|
const vp = viewport(el);
|
|
|
|
expect(vp.scrollLeft).toBe(0);
|
|
|
|
arrows(el).next.click();
|
|
|
|
await expect.poll(() => vp.scrollLeft).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('shows nothing to scroll when the content fits', async () => {
|
|
const el = await fixture<LitElement>('scroll-row');
|
|
|
|
el.style.cssText = 'display: block; width: 320px';
|
|
|
|
const only = document.createElement('div');
|
|
|
|
only.style.cssText = 'flex: 0 0 100px; height: 40px';
|
|
only.textContent = 'one';
|
|
el.append(only);
|
|
await el.updateComplete;
|
|
await new Promise((r) => setTimeout(r, 60));
|
|
await el.updateComplete;
|
|
await new Promise((r) => requestAnimationFrame(() => r(null)));
|
|
await el.updateComplete;
|
|
|
|
const { prev, next } = arrows(el);
|
|
|
|
expect(prev.hasAttribute('hidden')).toBe(true);
|
|
expect(next.hasAttribute('hidden')).toBe(true);
|
|
});
|
|
});
|