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()
|
@state()
|
||||||
private artistHovered = false;
|
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). */
|
/** Whether each field is actively mid-scroll (class toggle). */
|
||||||
@state()
|
@state()
|
||||||
private titleScrolling = false;
|
private titleScrolling = false;
|
||||||
@@ -200,9 +215,19 @@ export class NowPlaying extends LitElement {
|
|||||||
color: var(--yj-text-tertiary, #666);
|
color: var(--yj-text-tertiary, #666);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Static ellipsis when not scrolling */
|
/* Static ellipsis when not scrolling.
|
||||||
.track-title:not(.will-scroll),
|
|
||||||
.track-artist:not(.will-scroll) {
|
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;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,6 +273,12 @@ export class NowPlaying extends LitElement {
|
|||||||
this.updateWidth(DEFAULT_WIDTH);
|
this.updateWidth(DEFAULT_WIDTH);
|
||||||
window.addEventListener(SCROLL_CHANGE_EVENT, this.handleScrollModeEvent);
|
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.resizeObserver = new ResizeObserver(() => {
|
||||||
this.geometryDirty = true;
|
this.geometryDirty = true;
|
||||||
this.requestUpdate();
|
this.requestUpdate();
|
||||||
@@ -259,6 +290,7 @@ export class NowPlaying extends LitElement {
|
|||||||
// A drag interrupted by the bar going away still has to clean up.
|
// A drag interrupted by the bar going away still has to clean up.
|
||||||
this.attachDragListeners(false);
|
this.attachDragListeners(false);
|
||||||
window.removeEventListener(SCROLL_CHANGE_EVENT, this.handleScrollModeEvent);
|
window.removeEventListener(SCROLL_CHANGE_EVENT, this.handleScrollModeEvent);
|
||||||
|
this.reduceMotionQuery?.removeEventListener('change', this.handleReduceMotionChange);
|
||||||
this.resizeObserver?.disconnect();
|
this.resizeObserver?.disconnect();
|
||||||
this.stopScrollCycle('title');
|
this.stopScrollCycle('title');
|
||||||
this.stopScrollCycle('artist');
|
this.stopScrollCycle('artist');
|
||||||
@@ -371,6 +403,7 @@ export class NowPlaying extends LitElement {
|
|||||||
<span
|
<span
|
||||||
class="track-title ${titleScrolling ? 'will-scroll' : ''} ${this.titleScrolling ? 'scrolling' : ''}"
|
class="track-title ${titleScrolling ? 'will-scroll' : ''} ${this.titleScrolling ? 'scrolling' : ''}"
|
||||||
data-testid="now-playing-title"
|
data-testid="now-playing-title"
|
||||||
|
title=${track.title}
|
||||||
@mouseenter=${this.handleTitleMouseEnter}
|
@mouseenter=${this.handleTitleMouseEnter}
|
||||||
@mouseleave=${this.handleTitleMouseLeave}
|
@mouseleave=${this.handleTitleMouseLeave}
|
||||||
@transitionend=${() => this.onScrollCycleEnd('title')}
|
@transitionend=${() => this.onScrollCycleEnd('title')}
|
||||||
@@ -380,6 +413,7 @@ export class NowPlaying extends LitElement {
|
|||||||
<span
|
<span
|
||||||
class="track-artist ${artistScrolling ? 'will-scroll' : ''} ${this.artistScrolling ? 'scrolling' : ''}"
|
class="track-artist ${artistScrolling ? 'will-scroll' : ''} ${this.artistScrolling ? 'scrolling' : ''}"
|
||||||
data-testid="now-playing-artist"
|
data-testid="now-playing-artist"
|
||||||
|
title=${track.artist || 'Unknown Artist'}
|
||||||
@mouseenter=${this.handleArtistMouseEnter}
|
@mouseenter=${this.handleArtistMouseEnter}
|
||||||
@mouseleave=${this.handleArtistMouseLeave}
|
@mouseleave=${this.handleArtistMouseLeave}
|
||||||
@transitionend=${() => this.onScrollCycleEnd('artist')}
|
@transitionend=${() => this.onScrollCycleEnd('artist')}
|
||||||
@@ -429,9 +463,21 @@ export class NowPlaying extends LitElement {
|
|||||||
this.loadScrollMode();
|
this.loadScrollMode();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private handleReduceMotionChange = (e: MediaQueryListEvent): void => {
|
||||||
|
this.reduceMotion = e.matches;
|
||||||
|
};
|
||||||
|
|
||||||
private shouldScroll(field: 'title' | 'artist'): boolean {
|
private shouldScroll(field: 'title' | 'artist'): boolean {
|
||||||
const overflows = field === 'title' ? this.titleOverflows : this.artistOverflows;
|
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 (!overflows || this.scrollMode === 'never') return false;
|
||||||
if (this.scrollMode === 'always') return true;
|
if (this.scrollMode === 'always') return true;
|
||||||
|
|
||||||
@@ -477,9 +523,18 @@ export class NowPlaying extends LitElement {
|
|||||||
const content = el.querySelector<HTMLElement>('.scroll-content');
|
const content = el.querySelector<HTMLElement>('.scroll-content');
|
||||||
const width = el.clientWidth;
|
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 {
|
return {
|
||||||
overflows: el.scrollWidth > width,
|
overflows: full > width,
|
||||||
distance: content ? content.scrollWidth - width : 0,
|
distance: content ? full - width : 0,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -232,6 +232,61 @@ describe('<now-playing>', () => {
|
|||||||
expect(queries()).toBeGreaterThan(0);
|
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 () => {
|
it('looks the way it did last time', async () => {
|
||||||
const el = await fixture('now-playing');
|
const el = await fixture('now-playing');
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user