The row is deleted from the grid template below 600px, not the header hidden. That is 3.25em of a 439 CSS px viewport -- the single biggest vertical win the reference device has to give, and the reason the issue asks for the row rather than for a smaller bar. Each of the five things the bar held has somewhere else to be there: nav-history is the platform's own back gesture and was already gone from 899 down, the job indicator is <job-band> (#62, which is what this was blocked on), the search box is a modal opened from the view's own header, the library filter is Settings -> Libraries, and the wordmark stays where it is. Three things are load-bearing. **The header is visually hidden rather than display: none**, because that h1 is the document's top-level heading and several pages have no other one -- page-header renders no h1 when its heading is empty, and Settings has no page-header at all. Its four controls are display: none *inside* it, which is what keeps them out of the tab order: a visually-hidden container is still focusable, and tabbing into a search box nobody can see is worse than not having one. **The fit pass stands down**, from the bar's computed position rather than from a width. With the bar out of flow there is no content box to measure children against, and a pass that ran would collapse the wordmark on every resize and report success about a 1px box. **top-bar-fit.spec.ts keeps 390 and asserts the stronger property.** "Nothing hangs out of the bar" is trivially true of a bar with no row and would pass on a build that merely broke it, so what that width asks now is that the content starts where the row above it ends. Measuring against the window instead would have been asserting "and no background job is running", which that spec is not about and cannot arrange. Closes #57
270 lines
9.8 KiB
TypeScript
270 lines
9.8 KiB
TypeScript
import { test, expect } from '../support/fixtures.js';
|
|
|
|
/**
|
|
* The top bar fits the window it is in (#143).
|
|
*
|
|
* **This is measured per child, not on the shell**, which is #69's
|
|
* lesson repeated one component over: `layout-overflow.spec.ts` asserts
|
|
* the *document* needs no sideways scrolling, and clipping inside a
|
|
* component is invisible to it — which is exactly why that spec was
|
|
* green throughout this defect. What a user sees is a control rendered
|
|
* past the edge of the bar it belongs to, so that is what is asserted.
|
|
*
|
|
* **And it is measured with a job running**, which is the half the
|
|
* original report missed. `job-indicator` is `hidden` while idle and up
|
|
* to 235px wide when it is not, so the bar was 611px inside 600 sitting
|
|
* still and 862px during a scan — 171 to 262px of overflow, arriving
|
|
* exactly when a user has reason to look at that bar. Nothing else in
|
|
* this suite has ever measured a layout with work in flight;
|
|
* `/__test/emit` stages it without staging the scan.
|
|
*/
|
|
type Page = import('@playwright/test').Page;
|
|
|
|
/**
|
|
* The widths this asks about.
|
|
*
|
|
* 600 is the bottom of the Compact band (#24) and where the defect
|
|
* lands; 899 and 900 straddle `nav-history` appearing (68px more to
|
|
* find, at the width that just gained the sidebar's labels); 800 is the
|
|
* enforced minimum.
|
|
*
|
|
* **390 is kept, and what it asks changed with #57.** There is no bar
|
|
* to fit below 600px any more — it is out of the grid and visually
|
|
* hidden — so "nothing hangs out of it" is a claim about an element
|
|
* with no row, and would pass on a build that had merely broken the
|
|
* bar. Dropping the width would be dropping the one place this file
|
|
* can still say something true about a phone, so it asserts the
|
|
* *stronger* property instead, below: the bar is out of the layout
|
|
* altogether, which is the thing #57 wanted and the thing that makes
|
|
* fitting moot.
|
|
*/
|
|
const WIDTHS = [600, 800, 899, 900, 1440];
|
|
|
|
/** Where #57 leaves the bar, and where the desktop still has one. */
|
|
const PHONE_WIDTH = 390;
|
|
|
|
/**
|
|
* A scan whose title is as long as a real one gets. The label is capped
|
|
* at 12rem by the component, so this is the widest the indicator can
|
|
* be — measuring with "Scanning" instead reports a bar that fits and a
|
|
* defect that is 100px smaller than it is.
|
|
*/
|
|
const LONG_JOB = {
|
|
id: 'top-bar-fit',
|
|
kind: 'library-scan',
|
|
state: 'running',
|
|
title: 'Scanning Music from the external drive',
|
|
current: 40,
|
|
total: 100,
|
|
};
|
|
|
|
/**
|
|
* Every child's right edge against the bar's own content box.
|
|
*
|
|
* The content box, not `clientWidth`: the bar has a 2em right gutter,
|
|
* and a control sitting in the padding is already the failure — it is
|
|
* simply one that `scrollWidth` under-reports, because `scrollWidth`
|
|
* counts the left padding and not the right.
|
|
*/
|
|
const overflowingChildren = (page: Page) =>
|
|
page.evaluate(() => {
|
|
const bar = document.querySelector<HTMLElement>('header.top-bar')!;
|
|
const style = getComputedStyle(bar);
|
|
const box = bar.getBoundingClientRect();
|
|
const left = box.left + parseFloat(style.paddingLeft);
|
|
const right = box.right - parseFloat(style.paddingRight);
|
|
|
|
return [...bar.children]
|
|
.filter((child) => {
|
|
const cs = getComputedStyle(child);
|
|
|
|
// Out of flow is out of the question: a collapsed wordmark is
|
|
// `position: absolute` and 1px wide precisely so it costs the
|
|
// row nothing.
|
|
if (cs.display === 'none' || cs.position === 'absolute') return false;
|
|
|
|
const r = child.getBoundingClientRect();
|
|
|
|
return r.width > 0 && (r.right > right + 0.5 || r.left < left - 0.5);
|
|
})
|
|
.map((child) => {
|
|
const r = child.getBoundingClientRect();
|
|
|
|
return `${child.tagName.toLowerCase()}: ${Math.round(r.left)}..${Math.round(r.right)} outside ${Math.round(left)}..${Math.round(right)}`;
|
|
});
|
|
});
|
|
|
|
/** What the fit pass gave up, read back off the DOM it changed. */
|
|
const collapsed = (page: Page) =>
|
|
page.evaluate(() => ({
|
|
wordmark: !!document.querySelector('header.top-bar hgroup.yj-collapsed'),
|
|
jobLabel: !!document.querySelector('job-indicator[compact]'),
|
|
}));
|
|
|
|
test.describe('the top bar fits the window', () => {
|
|
/**
|
|
* The phone's answer, which is not "it fits" (#57).
|
|
*
|
|
* The bar has no grid row below 600px, so measuring its children
|
|
* against its content box is measuring a 1px box that is already
|
|
* invisible — a fit pass would collapse the wordmark every time and
|
|
* report success about nothing, which is why `measureTopBarFit`
|
|
* declines to run at all when the bar is out of flow. What is worth
|
|
* asserting here is that the fit pass has not quietly started
|
|
* *undoing* that: a rule that put the bar back in the layout would
|
|
* pass every assertion in this file and cost a 439px screen 12% of
|
|
* its height.
|
|
*/
|
|
test(`the bar is out of the layout at ${PHONE_WIDTH}px, with a job running`, async ({
|
|
app,
|
|
testctl,
|
|
}) => {
|
|
await app.setViewportSize({ width: PHONE_WIDTH, height: 600 });
|
|
await testctl.emit('JobsChanged', [LONG_JOB]);
|
|
|
|
// Not merely hidden: `display: none` on the header would satisfy
|
|
// "invisible" and leave the 3.25em row exactly where it was. So
|
|
// the assertion is that the content starts where the row above it
|
|
// ends -- and with a job staged, the row above it is the jobs
|
|
// band, which is the whole reason this row could go.
|
|
await expect
|
|
.poll(() =>
|
|
app.evaluate(() => {
|
|
const bar = document.querySelector<HTMLElement>('header.top-bar')!;
|
|
const main = document.querySelector<HTMLElement>('.main-panel')!;
|
|
const band = document.querySelector<HTMLElement>('job-band')!;
|
|
|
|
return {
|
|
position: getComputedStyle(bar).position,
|
|
gap:
|
|
Math.round(main.getBoundingClientRect().top) -
|
|
Math.round(band.getBoundingClientRect().bottom),
|
|
};
|
|
}),
|
|
)
|
|
.toEqual({ position: 'absolute', gap: 0 });
|
|
|
|
// And the work is still visible, in the band that replaced the
|
|
// indicator (#62) — which is what made this row removable at all.
|
|
await expect(app.locator('job-indicator')).toBeHidden();
|
|
await expect(app.locator('job-band').locator('job-row')).toHaveCount(1);
|
|
|
|
await app.setViewportSize({ width: 1440, height: 900 });
|
|
});
|
|
|
|
for (const width of WIDTHS) {
|
|
test(`no control sits outside the bar at ${width}px, idle`, async ({
|
|
app,
|
|
}) => {
|
|
await app.setViewportSize({ width, height: 600 });
|
|
|
|
await expect.poll(() => overflowingChildren(app)).toEqual([]);
|
|
});
|
|
|
|
test(`no control sits outside the bar at ${width}px, with a job running`, async ({
|
|
app,
|
|
testctl,
|
|
}) => {
|
|
await app.setViewportSize({ width, height: 600 });
|
|
await testctl.emit('JobsChanged', [LONG_JOB]);
|
|
|
|
// The indicator has to actually be up, or this test passes by
|
|
// measuring the idle case under another name.
|
|
await expect(app.locator('job-indicator')).toBeVisible();
|
|
|
|
await expect.poll(() => overflowingChildren(app)).toEqual([]);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* The other half of "measured, never breakpointed": a rule that
|
|
* collapses defensively at every narrow width fits just as well and
|
|
* is a worse app. 1440 is roomy at any job title; 899 was measured to
|
|
* fit with the longest one, because `nav-history` is not there yet.
|
|
*/
|
|
test('nothing is given up where there is room for it', async ({
|
|
app,
|
|
testctl,
|
|
}) => {
|
|
await app.setViewportSize({ width: 1440, height: 900 });
|
|
await testctl.emit('JobsChanged', [LONG_JOB]);
|
|
await expect(app.locator('job-indicator')).toBeVisible();
|
|
|
|
await expect.poll(() => collapsed(app)).toEqual({
|
|
wordmark: false,
|
|
jobLabel: false,
|
|
});
|
|
});
|
|
|
|
/**
|
|
* And it gives them back. The pass starts from all-visible every
|
|
* time, so this is the property that a rule which only ever *added*
|
|
* to the collapsed set would fail — the wordmark would be gone for
|
|
* the rest of the session after one narrow moment.
|
|
*/
|
|
test('the wordmark comes back when the window does', async ({
|
|
app,
|
|
testctl,
|
|
}) => {
|
|
await testctl.emit('JobsChanged', [LONG_JOB]);
|
|
await app.setViewportSize({ width: 600, height: 600 });
|
|
|
|
await expect.poll(() => collapsed(app)).toEqual({
|
|
wordmark: true,
|
|
jobLabel: true,
|
|
});
|
|
|
|
await app.setViewportSize({ width: 1440, height: 900 });
|
|
|
|
await expect.poll(() => collapsed(app)).toEqual({
|
|
wordmark: false,
|
|
jobLabel: false,
|
|
});
|
|
});
|
|
|
|
/**
|
|
* The wordmark yields its width and not its existence: `display:
|
|
* none` would take the document from one top-level heading to none.
|
|
*/
|
|
test('the collapsed wordmark is still the document heading', async ({
|
|
app,
|
|
testctl,
|
|
}) => {
|
|
await testctl.emit('JobsChanged', [LONG_JOB]);
|
|
await app.setViewportSize({ width: 600, height: 600 });
|
|
|
|
await expect.poll(() => collapsed(app)).toMatchObject({ wordmark: true });
|
|
|
|
await expect(
|
|
app.getByRole('heading', { name: 'YellowJacket', level: 1 }),
|
|
).toHaveCount(1);
|
|
});
|
|
|
|
/**
|
|
* And the indicator keeps saying what it is doing after its visible
|
|
* label goes — the `sr-only` live region is what announces the state,
|
|
* which is the same argument the phone's own rule was written on.
|
|
*/
|
|
test('the job indicator still announces its state without its label', async ({
|
|
app,
|
|
testctl,
|
|
}) => {
|
|
await testctl.emit('JobsChanged', [LONG_JOB]);
|
|
await app.setViewportSize({ width: 600, height: 600 });
|
|
|
|
await expect.poll(() => collapsed(app)).toMatchObject({ jobLabel: true });
|
|
|
|
const spoken = await app
|
|
.locator('job-indicator')
|
|
.evaluate(
|
|
(el) =>
|
|
el.shadowRoot?.querySelector('[aria-live]')?.textContent?.trim() ?? '',
|
|
);
|
|
|
|
expect(spoken).toContain('Scanning Music from the external drive');
|
|
|
|
// Leave the app as the next spec expects to find it.
|
|
await app.setViewportSize({ width: 1440, height: 900 });
|
|
});
|
|
});
|