feat(ui): a shell a phone can be held in
Plan 016 B2, phase 1. Below 600px the grid drops its sidebar column, `bottom-nav` becomes the primary navigation, and the shell fits the viewport instead of scrolling sideways out of it. 600 rather than the sidebar's own 900, because 900 is a laptop and the answer there is a narrower sidebar, which is still a sidebar. Under 600 there is no room for one at all: 360px of viewport over a 200px nav is not a layout. **The tab bar is four destinations and a way to everything else.** Three to five is where touch targets stop being thumb-sized -- eleven over 360px is 32px each -- so the four are the ones plan 016's subset says a phone is for, and "More" opens the *existing* `app-sidebar` in a drawer rather than listing the destinations a second time. Two lists is two places to add the next view to. That reuse has a cost this found the hard way: a shared component brings its `data-testid`s with it, so rendering the drawer's sidebar unconditionally put a second `nav-home` (and ten siblings) in the DOM and **failed 30 existing specs** with "resolved to 2 elements" -- on a desktop viewport, where this element is `display: none` and the drawer can never open. It renders only while the drawer is open, and the component test asserts the absence, because the failure is invisible from inside the component and lands in files nobody touched. **What made the shell overflow was minimums, not padding.** Measured at 360px: the body was 652px wide, because a `min-width` in a flex row is a hard floor and a grid item's implicit minimum is its content. So `min-width: 0` on the boxes between the viewport and the content, and each component stands its own non-essential parts down in its *own* stylesheet -- search-bar's 200px floor, job-indicator's label (the visible one; the live region that announces it is untouched), audio-player's seek bar and volume. A media query inside a shadow root is answered by the viewport, so this is the component saying what it drops rather than the shell reaching in. Volume goes because the hardware keys own it on a phone, which is the same reason mediacontrols' Android handler implements no volume callback. Seeking goes because 4px is not a thumb target; it belongs to the full-screen now-playing view, which is the next phase. An existing spec therefore asserts the opposite of what it did: layout-overflow's 320px case used to require that the 464px behind `overflow: hidden` could be *scrolled to*, which was the remedy available while the shell had one layout. It reflows now -- 320px in a 320px viewport, exactly -- and reflow is what WCAG 1.4.10 asked for.
This commit is contained in:
@@ -199,35 +199,39 @@ test.describe('the shell reflows rather than hiding what does not fit', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('what does not fit sideways can be scrolled to', async ({ app }) => {
|
||||
test('nothing needs scrolling to at 320px, because it all fits', async ({ app }) => {
|
||||
// 320 CSS px is 400% page zoom of a 1280px viewport, which is the
|
||||
// size 1.4.10 names. The shell is 784px wide there, so 464px of the
|
||||
// app — the job indicator and the queue button among it — used to
|
||||
// be behind `overflow: hidden` with no way to reach it.
|
||||
// size 1.4.10 names.
|
||||
//
|
||||
// **This assertion is the inverse of the one it replaces, and that
|
||||
// is the fix landing rather than the test being weakened.** The
|
||||
// shell used to be 784px wide here, so 464px of the app — the job
|
||||
// indicator and the queue button among it — sat behind
|
||||
// `overflow: hidden` with no way to reach it; making the axis
|
||||
// scrollable was the remedy available at the time. 016 B2's phone
|
||||
// layout reflows instead: below 600px the sidebar becomes a bottom
|
||||
// tab bar, the header's controls shrink, and the shell measures
|
||||
// exactly 320px in a 320px viewport. Reflow is what 1.4.10 asks
|
||||
// for; being able to scroll to the overflow was the concession.
|
||||
await app.setViewportSize({ width: 320, height: 256 });
|
||||
|
||||
// A *gesture*, not `scrollLeft = 9999`: `overflow: hidden` still
|
||||
// permits programmatic scrolling, so the obvious probe passes on
|
||||
// the build that has the bug. It did, first time.
|
||||
await app.mouse.move(160, 20);
|
||||
await app.mouse.wheel(400, 400);
|
||||
await app.waitForTimeout(200);
|
||||
|
||||
const reach = await app.evaluate(() => {
|
||||
const fit = await app.evaluate(() => {
|
||||
const se = document.scrollingElement!;
|
||||
|
||||
return { left: se.scrollLeft, top: se.scrollTop };
|
||||
return {
|
||||
scrollWidth: se.scrollWidth,
|
||||
clientWidth: document.documentElement.clientWidth,
|
||||
scrollHeight: se.scrollHeight,
|
||||
clientHeight: document.documentElement.clientHeight,
|
||||
};
|
||||
});
|
||||
|
||||
expect(reach.left).toBeGreaterThan(0);
|
||||
expect(fit.scrollWidth).toBeLessThanOrEqual(fit.clientWidth);
|
||||
|
||||
// And the vertical axis stays fixed, which is what keeps the
|
||||
// transport where a desktop player's transport belongs.
|
||||
expect(reach.top).toBe(0);
|
||||
// transport where a player's transport belongs.
|
||||
expect(fit.scrollHeight).toBeLessThanOrEqual(fit.clientHeight);
|
||||
|
||||
await app.evaluate(() => {
|
||||
document.scrollingElement!.scrollLeft = 0;
|
||||
});
|
||||
await app.setViewportSize({ width: 1440, height: 900 });
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { test, expect } from '../support/fixtures.js';
|
||||
|
||||
/**
|
||||
* The phone shell (plan 016 B2, phase 1).
|
||||
*
|
||||
* This is the tier that can actually answer the question. Wails v3's
|
||||
* server mode serves the real frontend, so a Chromium at 390×844 is the
|
||||
* same document an Android WebView renders — the only thing a device
|
||||
* adds here is the WebView's own quirks, and CI runs the WebKit half
|
||||
* for exactly that reason.
|
||||
*
|
||||
* The assertions are the three things B2 is *for*: the eleven-item
|
||||
* sidebar is gone, the four destinations plan 016 committed to are
|
||||
* reachable with a thumb, and nothing scrolls sideways. The last one is
|
||||
* the one that hides: `overflow-x: auto` on `body` means a shell that
|
||||
* does not fit produces a scrollbar rather than a broken layout, which
|
||||
* looks survivable in a screenshot and is not.
|
||||
*/
|
||||
|
||||
/** A common small phone. Narrower than any device this is likely to meet. */
|
||||
const PHONE = { width: 390, height: 844 };
|
||||
|
||||
/** The narrowest thing still sold, near enough. */
|
||||
const SMALL_PHONE = { width: 360, height: 780 };
|
||||
|
||||
const horizontalOverflow = (page: import('@playwright/test').Page) =>
|
||||
page.evaluate(() => ({
|
||||
scrollWidth: document.body.scrollWidth,
|
||||
clientWidth: document.body.clientWidth,
|
||||
}));
|
||||
|
||||
test.describe('the shell on a phone', () => {
|
||||
test.beforeEach(async ({ app }) => {
|
||||
await app.setViewportSize(PHONE);
|
||||
});
|
||||
|
||||
test('replaces the sidebar with a bottom tab bar', async ({ app }) => {
|
||||
await expect(app.locator('div.sidebar')).toBeHidden();
|
||||
|
||||
const nav = app.locator('bottom-nav');
|
||||
|
||||
await expect(nav).toBeVisible();
|
||||
|
||||
// Four tabs and a way to everything else, which is the shape the
|
||||
// plan argues for: a tab bar is 3-5 items before the targets stop
|
||||
// being thumb-sized.
|
||||
for (const id of ['home', 'albums', 'tracks', 'playlists', 'more']) {
|
||||
await expect(app.getByTestId(`tab-${id}`)).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('navigates from a tab', async ({ app }) => {
|
||||
await app.getByTestId('tab-albums').click();
|
||||
|
||||
await expect(app.getByTestId('main-content'))
|
||||
.toHaveAttribute('data-active-view', 'albums');
|
||||
|
||||
await app.getByTestId('tab-home').click();
|
||||
|
||||
await expect(app.getByTestId('main-content'))
|
||||
.toHaveAttribute('data-active-view', 'home');
|
||||
});
|
||||
|
||||
test('reaches the views with no tab through the drawer', async ({ app }) => {
|
||||
await app.getByTestId('tab-more').click();
|
||||
|
||||
// Scoped to the drawer: the desktop sidebar is still in the DOM
|
||||
// (hidden by the media query, not removed), so an unscoped testid
|
||||
// matches two elements and Playwright's strict mode refuses --
|
||||
// which is the right complaint, since the two really are different
|
||||
// buttons.
|
||||
//
|
||||
// The drawer holds the *same* sidebar the desktop uses, so Settings
|
||||
// -- which a phone still needs occasionally -- is reachable without
|
||||
// a second list of destinations to keep in step.
|
||||
const settings = app
|
||||
.getByTestId('nav-drawer')
|
||||
.getByTestId('nav-settings');
|
||||
|
||||
await expect(settings).toBeVisible();
|
||||
await settings.click();
|
||||
|
||||
await expect(app.getByTestId('main-content'))
|
||||
.toHaveAttribute('data-active-view', 'settings');
|
||||
|
||||
// And the drawer gets out of the way once it has done its job.
|
||||
await expect(app.getByTestId('nav-drawer')).toBeHidden();
|
||||
});
|
||||
|
||||
test('has a named drawer', async ({ app }) => {
|
||||
await app.getByTestId('tab-more').click();
|
||||
|
||||
// The a11y snapshot never prints a dialog's name, so this asks for
|
||||
// the role and the name together -- which is the check that caught
|
||||
// eleven unnamed dialogs.
|
||||
await expect(
|
||||
app.getByRole('dialog', { name: 'All views' }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
for (const vp of [PHONE, SMALL_PHONE]) {
|
||||
test(`does not scroll sideways at ${vp.width}×${vp.height}`, async ({ app }) => {
|
||||
await app.setViewportSize(vp);
|
||||
await app.getByTestId('tab-tracks').click();
|
||||
await expect(app.getByTestId('main-content'))
|
||||
.toHaveAttribute('data-active-view', 'tracks');
|
||||
|
||||
const { scrollWidth, clientWidth } = await horizontalOverflow(app);
|
||||
|
||||
expect(scrollWidth, `body overflows by ${scrollWidth - clientWidth}px`)
|
||||
.toBeLessThanOrEqual(clientWidth);
|
||||
});
|
||||
}
|
||||
|
||||
test('keeps the transport, minus what a thumb cannot use', async ({ app }) => {
|
||||
// The player bar stays: this is a music player, and what is playing
|
||||
// has to be visible and pausable from every view.
|
||||
await expect(app.locator('audio-player')).toBeVisible();
|
||||
await expect(app.locator('now-playing')).toBeVisible();
|
||||
|
||||
// Volume is the hardware keys' job on a phone, and a 4px seek bar
|
||||
// is not a thumb target -- both belong to a later phase's
|
||||
// full-screen now-playing view.
|
||||
await expect(app.locator('audio-player volume-control')).toBeHidden();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('the desktop shell is unchanged', () => {
|
||||
test('keeps the sidebar and hides the tab bar', async ({ app }) => {
|
||||
await app.setViewportSize({ width: 1440, height: 900 });
|
||||
|
||||
await expect(app.locator('div.sidebar')).toBeVisible();
|
||||
await expect(app.locator('bottom-nav')).toBeHidden();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user