Files
yellowjacket/frontend/test/components/touch-targets.test.ts
T
logan 0d331666d6
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m31s
CI / e2e (pull_request) Successful in 9m28s
fix(shell): make the header's touch targets cost no width
The first pass grew the two square controls to 44px as boxes, which
added 22px to the header. That fit at every width Chromium was checked
at and **clipped the overflow trigger at 320x600 in WebKit** -- the
engine closest to what ships, and the one no machine here can run:

    every action is reachable at 320x600 (400% zoom)
    - Array []
    + Array [ "more" ]

Two things were wrong, and only one of them was the code.

**The claim was checked on one engine and stated as a property.** The
previous commit said #69's fit "does not move ... the check rather than
the assumption", on the strength of running that spec against chromium
alone. CI runs both browsers precisely because they are not the same
answer.

**And the box was the wrong thing to grow**, which the issue already
said: "reached by growing the *hit* area rather than the visual weight
where the two can differ -- padding on the control, not size on the
icon". #69's pass measures inline size, so a taller control is free and
a wider one is not.

So height stays a box -- the header has the room and nothing measures
it -- and width is padding with a negative margin handing the space
back, which is the seek bar's shape from #187. Measured in the
component tier at 320px: the arrow's rect is 45x44 and it occupies 29,
the overflow trigger 44x44 occupying 38, the search button 44x44
occupying 40. Those three occupancies are what they were before any of
this, so the fit pass sees a header identical to main's and the
320px case cannot regress.

The arrow's target is lopsided for #187's reason: the select is 6px to
its left and there is open space to its right, so it takes the side
with nothing to steal from. The overflow trigger's can be symmetric,
the actions row having an 8px gap.

`search-trigger` is border-box, so its 44px min-width is the whole
target and the margin alone gives the four pixels back.

The new assertion is the one that would have caught this: every grown
control must carry negative inline margins, because that is what keeps
the box out of the fit. The rect assertions stay -- getBoundingClientRect
includes padding, so the target is still measured directly rather than
inferred.
2026-08-21 20:12:51 -04:00

163 lines
5.8 KiB
TypeScript

/**
* Every control a finger meets is at least 44px (#186).
*
* #56 sized the playback transport for a thumb and named 44px; the
* queue header keeps it; nothing else was resized. So the controls a
* user meets on *every* screen — the sort control, its direction
* button, the page actions, the overflow trigger and the phone's search
* button — sat between a third and two thirds of the app's own floor.
* Measured on the reference device (TLP301, 424x439): `page-sort` 99x23,
* `page-sort-direction` **28x21**, `page-actions-more` 38x27,
* `search-trigger` 40x40.
*
* Unlike the seek bar's target (#187), this one can be measured here
* rather than inferred from the stylesheet. There the painted track had
* to stay thin, so the hit area was grown past its own box and only a
* phone-width layout of a third-party slider could show it. Here the
* control *is* the target, so a real Chromium rendering a real
* `page-header` gives the actual answer — and because it is a `min-size`
* rather than a media query, the answer is the same at every width,
* which is what makes it checkable in this tier at all.
*
* That is also why there is no phone branch to test: a 44px control on
* a desktop is merely large, and a second declaration of what a phone
* shows is a second thing to keep in step.
*/
import { describe, expect, it } from 'vitest';
import type { PageAction, PageHeader } from '@components/page-header/page-header';
import '@components/page-header/page-header';
import { fixture, shadowAll } from '@test/support/render';
/** The app's touch floor, from #56. */
const FLOOR = 44;
const SORTS = [
{ id: 'name', label: 'Name' },
{ id: 'tracks', label: 'Tracks' },
];
function actions(): PageAction[] {
return [
{ id: 'import', label: 'Import', icon: 'file-import', priority: 0, onSelect: () => {} },
{ id: 'new', label: 'New Playlist', icon: 'plus', priority: 2, onSelect: () => {} },
];
}
/** Every visible control in the header's own shadow root. */
function controlsOf(el: PageHeader): { name: string; el: HTMLElement }[] {
return shadowAll<HTMLElement>(el, 'button, select')
.filter((c) => !(c as HTMLButtonElement).hidden)
.map((c) => ({
name: c.dataset.testid ?? (c.className || c.tagName.toLowerCase()),
el: c,
}));
}
function tooSmall(controls: { name: string; el: HTMLElement }[]): string[] {
return controls
.map(({ name, el }) => {
const b = el.getBoundingClientRect();
return { name, w: Math.round(b.width), h: Math.round(b.height) };
})
.filter((c) => c.w < FLOOR || c.h < FLOOR)
.map((c) => `${c.name} ${c.w}x${c.h}`);
}
describe("the page header's controls", () => {
it('all meet the touch floor', async () => {
const el = await fixture<PageHeader>('page-header', {
heading: 'Playlists',
count: 50,
countNoun: 'playlist',
sortOptions: SORTS,
sortField: 'name',
sortDirection: 'asc',
actions: actions(),
});
const controls = controlsOf(el);
// A sweep that found no controls passes vacuously — the same first
// assertion icon-language.test.ts makes, for the same reason.
expect(controls.length).toBeGreaterThan(0);
// The two that were smallest, named so a regression says which.
expect(controls.map((c) => c.name)).toContain('page-sort-direction');
expect(controls.map((c) => c.name)).toContain('page-sort');
expect(tooSmall(controls)).toEqual([]);
});
it('grows the target without growing the box, so the overflow fit is untouched', async () => {
// The regression this exists for, and it was a real one: growing
// the two square controls to 44px *wide* added 22px to the header,
// which fit at every width Chromium was checked at and clipped the
// overflow trigger at 320x600 in WebKit -- the engine closest to
// what ships, and the one no machine here can run. #69's fit pass
// measures inline size, so a taller control is free and a wider one
// is not.
//
// Negative inline margins are what keep the box out of it: the
// padding makes the target, and the margin gives the space back.
const el = await fixture<PageHeader>('page-header', {
heading: 'Playlists',
sortOptions: SORTS,
sortField: 'name',
actions: actions(),
});
el.style.width = '320px';
for (let frame = 0; frame < 3; frame += 1) {
await new Promise((r) => requestAnimationFrame(r));
await el.updateComplete;
}
for (const selector of ['.sort-dir', '.more-button']) {
const control = shadowAll<HTMLElement>(el, selector).filter(
(c) => !(c as HTMLButtonElement).hidden,
)[0];
expect(control, selector).toBeTruthy();
const style = getComputedStyle(control!);
const added =
parseFloat(style.marginInlineStart) + parseFloat(style.marginInlineEnd);
expect(added, `${selector} gives its extra width back`).toBeLessThan(0);
}
});
it('includes the overflow trigger, which is the route to the rest', async () => {
// At 320px the fit pass collapses actions into the menu, so the
// trigger is rendered — and it is then the only way to reach them,
// which makes it the last control that should be hard to hit.
const el = await fixture<PageHeader>('page-header', {
heading: 'Playlists',
sortOptions: SORTS,
sortField: 'name',
actions: actions(),
});
el.style.width = '320px';
for (let frame = 0; frame < 3; frame += 1) {
await new Promise((r) => requestAnimationFrame(r));
await el.updateComplete;
}
const more = shadowAll<HTMLButtonElement>(el, '.more-button').filter(
(b) => !b.hidden,
);
expect(more.length).toBe(1);
const box = more[0]!.getBoundingClientRect();
expect(Math.round(box.width)).toBeGreaterThanOrEqual(FLOOR);
expect(Math.round(box.height)).toBeGreaterThanOrEqual(FLOOR);
});
});