fix(a11y): stop the now-playing marquee under reduced motion
a11y.15 / WCAG 2.2.2: the bottom bar's title and artist scrolled for as long as a track played, re-armed in a loop by transitionend, with no pause mechanism and no reduced-motion guard. Reproduced under an emulated prefers-reduced-motion before the fix: the title still carried will-scroll with a 15s transition and the transform was still moving. That read landed in the snap-back half of the cycle, which is why a CSS-only 'transition: none' is the wrong fix -- it leaves the text translated off its own box and transitionend never fires to bring it back. The scroll is not armed at all instead, which is a decision shouldScroll() already owned, and it covers hover as well as always: reduce is a request about motion, not about autoplay. Two things came out of looking at the result rather than asserting on it. The non-scrolling fallback was hard-clipping, not ellipsising, in every mode including the default -- text-overflow was on the outer span while the overflowing box is the inline-block child. And moving it to the child then broke overflow *detection*, because the parent stops overflowing once the child hides its own; both measurements come from the child now. The second was caught by the new test's positive case, which is why it has one.
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
import { test, expect, callBinding, waitForEvent } from '../support/fixtures.js';
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* `a11y.15` — WCAG 2.2.2. The bottom bar's title and artist scroll
|
||||
* continuously while a track plays, re-armed in a loop by
|
||||
* `transitionend`, with no pause mechanism and no reduced-motion guard.
|
||||
*
|
||||
* The component test for this fakes `window.matchMedia`, which is a
|
||||
* stub of the thing being tested. This spec sets the real context
|
||||
* option, so the real media query answers.
|
||||
*
|
||||
* Both directions are here on purpose. A guard that suppressed
|
||||
* everything would pass the reduce case for free, and so would a bar
|
||||
* whose text simply does not overflow at this viewport — which is what
|
||||
* the component test failed on first.
|
||||
*/
|
||||
|
||||
/** The fixture track whose title is long enough to overflow the bar. */
|
||||
const LONG_TITLE = 'An Exhaustively Overlong Track Title';
|
||||
|
||||
/**
|
||||
* Read the title line's classes from inside `now-playing`'s shadow root.
|
||||
*
|
||||
* `will-scroll` is the class that carries both the transition and the
|
||||
* `padding-right` the scroll distance is measured against, so its
|
||||
* absence is the whole fix: suppressing only the animation leaves the
|
||||
* text translated off its own box with nothing to bring it back.
|
||||
*/
|
||||
async function titleClasses(app: Page): Promise<string> {
|
||||
return app.evaluate(() => {
|
||||
const np = document.querySelector('now-playing');
|
||||
const title = np?.shadowRoot?.querySelector('.track-title');
|
||||
|
||||
return title?.className ?? '';
|
||||
});
|
||||
}
|
||||
|
||||
async function playTheLongOne(app: Page): Promise<void> {
|
||||
await app.getByTestId('nav-tracks').click();
|
||||
|
||||
// The scroll mode defaults to `hover`, and a fresh context has no
|
||||
// persisted setting — so without this the positive case never
|
||||
// scrolls and reports the same thing a broken build would. Set it
|
||||
// rather than hovering, because `always` is also the mode the
|
||||
// finding is about: continuous motion for as long as the track
|
||||
// plays, with nothing the user has to do to provoke it.
|
||||
await app.evaluate(() => {
|
||||
localStorage.setItem('yj-now-playing-scroll-mode', 'always');
|
||||
window.dispatchEvent(new CustomEvent('yj-scroll-mode-changed'));
|
||||
});
|
||||
|
||||
const paths: string[] = await app.evaluate(async (needle) => {
|
||||
const tracks = await window.__yjEvents.call(
|
||||
'library.Library.GetAllTracks',
|
||||
[],
|
||||
10_000,
|
||||
);
|
||||
|
||||
return (tracks as { TrackName: string; FilePath: string }[])
|
||||
.filter((t) => t.TrackName.startsWith(needle))
|
||||
.map((t) => t.FilePath);
|
||||
}, LONG_TITLE);
|
||||
|
||||
expect(paths.length).toBeGreaterThan(0);
|
||||
|
||||
await callBinding(app, 'queue.Queue.SetQueue', [paths, 0, false]);
|
||||
await waitForEvent(app, 'TrackChanged');
|
||||
|
||||
// The scroll cycle is armed 1500 ms after the geometry is measured,
|
||||
// and the geometry is measured after the render that puts the title
|
||||
// on screen. Reading before that reports "not scrolling" on a build
|
||||
// that scrolls — the same shape as every probe read too early in
|
||||
// plan 007.
|
||||
await expect
|
||||
.poll(() => titleClasses(app), { timeout: 10_000 })
|
||||
.toContain('track-title');
|
||||
}
|
||||
|
||||
test.describe('the marquee under prefers-reduced-motion', () => {
|
||||
test.use({ contextOptions: { reducedMotion: 'reduce' } });
|
||||
|
||||
test('does not scroll the now-playing text at all', async ({ app }) => {
|
||||
await playTheLongOne(app);
|
||||
|
||||
// Give the cycle longer than the 1500 ms arming delay to prove it
|
||||
// never arms, rather than catching it before it would have.
|
||||
await app.waitForTimeout(2500);
|
||||
|
||||
expect(await titleClasses(app)).not.toContain('will-scroll');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('the marquee without a motion preference', () => {
|
||||
test.use({ contextOptions: { reducedMotion: 'no-preference' } });
|
||||
|
||||
test('still scrolls an overflowing title', async ({ app }) => {
|
||||
await playTheLongOne(app);
|
||||
|
||||
await expect
|
||||
.poll(() => titleClasses(app), { timeout: 10_000 })
|
||||
.toContain('will-scroll');
|
||||
});
|
||||
});
|
||||
@@ -57,6 +57,21 @@ export class NowPlaying extends LitElement {
|
||||
@state()
|
||||
private artistHovered = false;
|
||||
|
||||
/**
|
||||
* `prefers-reduced-motion: reduce`, live (a11y.15).
|
||||
*
|
||||
* It is a `@state` and not a CSS query because suppressing the
|
||||
* *transition* is not enough: the cycle is a transition out, a
|
||||
* `transitionend`, and a transition back, so removing the animation
|
||||
* leaves the text translated off its own box and `onScrollCycleEnd`
|
||||
* never fires to bring it back. The scroll has to not be armed at
|
||||
* all, which is a decision `shouldScroll()` already owns.
|
||||
*/
|
||||
@state()
|
||||
private reduceMotion = false;
|
||||
|
||||
private reduceMotionQuery?: MediaQueryList;
|
||||
|
||||
/** Whether each field is actively mid-scroll (class toggle). */
|
||||
@state()
|
||||
private titleScrolling = false;
|
||||
@@ -200,9 +215,19 @@ export class NowPlaying extends LitElement {
|
||||
color: var(--yj-text-tertiary, #666);
|
||||
}
|
||||
|
||||
/* Static ellipsis when not scrolling */
|
||||
.track-title:not(.will-scroll),
|
||||
.track-artist:not(.will-scroll) {
|
||||
/* Static ellipsis when not scrolling.
|
||||
|
||||
It has to be on .scroll-content, not on the outer span: the child
|
||||
is an inline-block, so it is the box that overflows, and
|
||||
text-overflow on an ancestor does not ellipsise an overflowing
|
||||
inline-block descendant — it clips it. The outer rule was there
|
||||
from the start and never produced an ellipsis in any mode; the
|
||||
default mode is hover, so what every user saw when not hovering
|
||||
was a title cut mid-glyph. Only visible in a screenshot. */
|
||||
.track-title:not(.will-scroll) .scroll-content,
|
||||
.track-artist:not(.will-scroll) .scroll-content {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
@@ -248,6 +273,12 @@ export class NowPlaying extends LitElement {
|
||||
this.updateWidth(DEFAULT_WIDTH);
|
||||
window.addEventListener(SCROLL_CHANGE_EVENT, this.handleScrollModeEvent);
|
||||
|
||||
// Looked up here rather than at module load so a test can install
|
||||
// its own matchMedia before the element is created.
|
||||
this.reduceMotionQuery = window.matchMedia?.('(prefers-reduced-motion: reduce)');
|
||||
this.reduceMotion = this.reduceMotionQuery?.matches ?? false;
|
||||
this.reduceMotionQuery?.addEventListener('change', this.handleReduceMotionChange);
|
||||
|
||||
this.resizeObserver = new ResizeObserver(() => {
|
||||
this.geometryDirty = true;
|
||||
this.requestUpdate();
|
||||
@@ -259,6 +290,7 @@ export class NowPlaying extends LitElement {
|
||||
// A drag interrupted by the bar going away still has to clean up.
|
||||
this.attachDragListeners(false);
|
||||
window.removeEventListener(SCROLL_CHANGE_EVENT, this.handleScrollModeEvent);
|
||||
this.reduceMotionQuery?.removeEventListener('change', this.handleReduceMotionChange);
|
||||
this.resizeObserver?.disconnect();
|
||||
this.stopScrollCycle('title');
|
||||
this.stopScrollCycle('artist');
|
||||
@@ -371,6 +403,7 @@ export class NowPlaying extends LitElement {
|
||||
<span
|
||||
class="track-title ${titleScrolling ? 'will-scroll' : ''} ${this.titleScrolling ? 'scrolling' : ''}"
|
||||
data-testid="now-playing-title"
|
||||
title=${track.title}
|
||||
@mouseenter=${this.handleTitleMouseEnter}
|
||||
@mouseleave=${this.handleTitleMouseLeave}
|
||||
@transitionend=${() => this.onScrollCycleEnd('title')}
|
||||
@@ -380,6 +413,7 @@ export class NowPlaying extends LitElement {
|
||||
<span
|
||||
class="track-artist ${artistScrolling ? 'will-scroll' : ''} ${this.artistScrolling ? 'scrolling' : ''}"
|
||||
data-testid="now-playing-artist"
|
||||
title=${track.artist || 'Unknown Artist'}
|
||||
@mouseenter=${this.handleArtistMouseEnter}
|
||||
@mouseleave=${this.handleArtistMouseLeave}
|
||||
@transitionend=${() => this.onScrollCycleEnd('artist')}
|
||||
@@ -429,9 +463,21 @@ export class NowPlaying extends LitElement {
|
||||
this.loadScrollMode();
|
||||
};
|
||||
|
||||
private handleReduceMotionChange = (e: MediaQueryListEvent): void => {
|
||||
this.reduceMotion = e.matches;
|
||||
};
|
||||
|
||||
private shouldScroll(field: 'title' | 'artist'): boolean {
|
||||
const overflows = field === 'title' ? this.titleOverflows : this.artistOverflows;
|
||||
|
||||
// A stated OS-level preference outranks an app default the user
|
||||
// may never have touched, so this comes before the mode — and it
|
||||
// covers `hover` as well as `always`. Hover-scrolling is
|
||||
// user-initiated and so arguably passes WCAG 2.2.2 on its own,
|
||||
// but `reduce` is a request about motion, not about autoplay.
|
||||
// The text falls back to the ellipsis every other mode uses.
|
||||
if (this.reduceMotion) return false;
|
||||
|
||||
if (!overflows || this.scrollMode === 'never') return false;
|
||||
if (this.scrollMode === 'always') return true;
|
||||
|
||||
@@ -477,9 +523,18 @@ export class NowPlaying extends LitElement {
|
||||
const content = el.querySelector<HTMLElement>('.scroll-content');
|
||||
const width = el.clientWidth;
|
||||
|
||||
// Both numbers come from the *child*, which is the box that
|
||||
// holds the text. Asking the outer span whether it overflows
|
||||
// only works while the child is an overflowing inline-block:
|
||||
// once the non-scrolling state gives the child its own
|
||||
// `overflow: hidden` (for the ellipsis), the parent stops
|
||||
// overflowing and nothing ever arms the scroll again.
|
||||
// `scrollWidth` reports the content size either way.
|
||||
const full = content?.scrollWidth ?? 0;
|
||||
|
||||
return {
|
||||
overflows: el.scrollWidth > width,
|
||||
distance: content ? content.scrollWidth - width : 0,
|
||||
overflows: full > width,
|
||||
distance: content ? full - width : 0,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -232,6 +232,61 @@ describe('<now-playing>', () => {
|
||||
expect(queries()).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// a11y.15 (WCAG 2.2.2). Reproduced in the running app first: under an
|
||||
// emulated `prefers-reduced-motion: reduce` the title still carried
|
||||
// `will-scroll` with a 15s transition and the transform was still
|
||||
// moving — the read landed in the *snap-back* half of the cycle,
|
||||
// which is why suppressing the transition alone is not the fix.
|
||||
//
|
||||
// Both directions are asserted because a guard that suppresses
|
||||
// everything passes the negative case for free, and a component that
|
||||
// never scrolls at this width would too.
|
||||
const LONG =
|
||||
'An Exhaustively Overlong Track Title That Exists Solely To Find Out ' +
|
||||
'Whether The Bottom Bar Truncates Or Overflows';
|
||||
|
||||
async function mountScrolling(reduce: boolean) {
|
||||
const real = window.matchMedia.bind(window);
|
||||
|
||||
window.matchMedia = ((q: string) =>
|
||||
q.includes('prefers-reduced-motion')
|
||||
? {
|
||||
matches: reduce,
|
||||
media: q,
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
}
|
||||
: real(q)) as typeof window.matchMedia;
|
||||
|
||||
try {
|
||||
localStorage.setItem('yj-now-playing-scroll-mode', 'always');
|
||||
|
||||
const el = await fixture('now-playing');
|
||||
|
||||
// The real host is sized by `--now-playing-width` on `.bottom-bar`,
|
||||
// which the fixture does not have — so it is document-width here
|
||||
// and nothing overflows, which made the positive case fail first.
|
||||
el.style.width = '320px';
|
||||
|
||||
emit(Events.TrackChanged, { ...TRACK, title: LONG, trackChangeId: 10 });
|
||||
await flush();
|
||||
await settle(el);
|
||||
|
||||
return shadow(el, '.track-title')?.className ?? '';
|
||||
} finally {
|
||||
window.matchMedia = real;
|
||||
localStorage.removeItem('yj-now-playing-scroll-mode');
|
||||
}
|
||||
}
|
||||
|
||||
it('scrolls an overflowing title when motion is not a problem', async () => {
|
||||
expect(await mountScrolling(false)).toContain('will-scroll');
|
||||
});
|
||||
|
||||
it('does not scroll at all under prefers-reduced-motion', async () => {
|
||||
expect(await mountScrolling(true)).not.toContain('will-scroll');
|
||||
});
|
||||
|
||||
it('looks the way it did last time', async () => {
|
||||
const el = await fixture('now-playing');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user