Playlists slotted three buttons totalling 390px into a header that gets 700px at 900x600, so "New Smart Playlist" rendered 114 of its 162px with the queue closed, and 158 of 162 at the 800x600 enforced minimum. On a phone none of the three could be reached at all, which is what the Android report said. Plan 018's size matrix promises the opposite: no action is ever unreachable at any supported size. The header could not fix that for slotted markup, and that is a fact about the API rather than an effort estimate — a component cannot move another component's light-DOM children into a dropdown and keep their behaviour, and arbitrary markup offers nothing generic to render as a menu item. So a host passes `PageAction[]` and the header chooses the rendering; the slot survives for markup a data list cannot express, at the stated cost that a slotted action does not collapse. All three hosts that slot actions migrated, which also normalises the plain-<button>/<wa-button> split between them onto one shape the header styles — and lets it measure a button that has already upgraded, rather than a wa-button whose shadow DOM arrives in its own first update. Four things in it are load-bearing: - Every measuring pass starts from all-visible, so the collapsed set is a pure function of the current width and an action comes back when the window grows. It flips `hidden` imperatively rather than re-rendering between steps, or the intermediate state paints and the fix flashes the overflow it exists to prevent. - "Fits" means nothing is clipped, not that the header does not overflow. Once the title can ellipsis it absorbs the pressure and scrollWidth reports a perfect fit while the heading reads "Playlis…" — this bug moved from the button to the title, and invisible to the same measurement that missed it the first time. - New Playlist has the highest priority because it is the drop target and a closed menu cannot be one. `PageAction.drop` therefore carries the host's own handlers; the affordance is absent from the overflow rather than approximated there. - The overflow trigger is a named button with aria-expanded and an aria-controls naming a panel that is always in the DOM, and the keyboard model is the shared `MenuKeyboard`. `layout-overflow.spec.ts` passes on the broken build — it asserts the shell needs no sideways scrolling, and clipping inside a component is invisible to it, which is why this defect survived a spec named for it. The new spec measures each button against its own header at four viewports and asserts buttons plus menu account for every declared action, without which it would pass vacuously on a build rendering none. Closes #69
144 lines
4.9 KiB
TypeScript
144 lines
4.9 KiB
TypeScript
/**
|
|
* The icon vocabulary is one table, and nothing writes around it.
|
|
*
|
|
* A wrong-but-real icon name renders perfectly: no error, no fallback,
|
|
* no failing assertion anywhere. That is how `plus` came to mean "add
|
|
* to the queue", "add to a playlist", "make a new playlist" and "you do
|
|
* not own this" — the first two adjacent in the same context menu —
|
|
* while `list` meant the queue, the Playlists destination *and* adding
|
|
* to the queue.
|
|
*
|
|
* `src/icons/index.ts` catches a name that is not *bundled*. Nothing
|
|
* catches a name that is bundled and means something else, so this
|
|
* sweeps the source for the governed ones. It is the same shape as
|
|
* `TestNoDirectRuntimeEmits` and `TestNoWritesOnTheReadPool` in the
|
|
* backend, and exists for the same reason: the rule is about every call
|
|
* site, so checking one is checking nothing.
|
|
*/
|
|
import { describe, expect, it } from 'vitest';
|
|
|
|
import { bundledIconNames } from '../../src/icons';
|
|
import * as icons from '@utils/icon-language';
|
|
|
|
/** Every component source, as text. */
|
|
const SOURCES = import.meta.glob<string>('../../src/**/*.ts', {
|
|
eager: true,
|
|
query: '?raw',
|
|
import: 'default',
|
|
});
|
|
|
|
/**
|
|
* The names that carry a meaning the table owns.
|
|
*
|
|
* Deliberately not every bundled name. `check` is `ICON_IN_LIBRARY`
|
|
* here and also the "Copied" confirmation in `job-log-view`, which is
|
|
* a different, perfectly good meaning — governing it would force a
|
|
* false rename. What belongs on this list is a name that was actually
|
|
* overloaded.
|
|
*/
|
|
const GOVERNED = [
|
|
'plus',
|
|
'list',
|
|
'bookmark',
|
|
'solid/bookmark',
|
|
'regular/bookmark',
|
|
'bars-staggered',
|
|
'tag',
|
|
'filter',
|
|
'ellipsis',
|
|
];
|
|
|
|
/** The one file allowed to say them, plus its own test. */
|
|
const DEFINITION = /icon-language\.(ts|test\.ts)$/;
|
|
|
|
describe('the icon vocabulary', () => {
|
|
/**
|
|
* A sweep over nothing passes. This is the assertion that makes the
|
|
* rest of the file mean something, and it is the first thing that
|
|
* breaks if the glob pattern stops matching after a move.
|
|
*/
|
|
it('actually reads the source', () => {
|
|
const paths = Object.keys(SOURCES);
|
|
|
|
expect(paths.length).toBeGreaterThan(100);
|
|
expect(paths.some((p) => p.endsWith('/track-list.ts'))).toBe(true);
|
|
expect(SOURCES[paths[0]!]).toContain('import');
|
|
});
|
|
|
|
it.each(GOVERNED)('is not written around for %s', (name) => {
|
|
const offenders: string[] = [];
|
|
|
|
for (const [path, source] of Object.entries(SOURCES)) {
|
|
if (DEFINITION.test(path)) continue;
|
|
|
|
// Both spellings: an icon in a template, and an icon name in a
|
|
// data table (which is how the sidebar and bottom-nav carry
|
|
// theirs).
|
|
const literal = new RegExp(
|
|
`(name="${name}"|icon: '${name}'|name=\\$\\{[^}]*'${name}')`,
|
|
);
|
|
|
|
if (literal.test(source)) offenders.push(path);
|
|
}
|
|
|
|
expect(offenders).toEqual([]);
|
|
});
|
|
|
|
/**
|
|
* A meaning with no icon behind it is the state the badge's `queued`
|
|
* spent a year in — declared, styled, and produced by nothing.
|
|
*/
|
|
it('gives every meaning a name', () => {
|
|
const values = Object.entries(icons).filter(([k]) => k.startsWith('ICON_'));
|
|
|
|
expect(values.length).toBeGreaterThan(0);
|
|
|
|
for (const [key, value] of values) {
|
|
expect(`${key}=${value}`).toMatch(/^ICON_[A-Z_]+=[a-z]+[a-z/-]*$/);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Every name in the table is a name the app actually ships.
|
|
*
|
|
* This is the loop the vocabulary closes. A name that is not bundled
|
|
* renders a circled question mark and reports itself to
|
|
* `__yjIconMisses` — at *runtime*, from a state something has to
|
|
* reach first. `bookmark-check` is Font Awesome **Pro**, and it was
|
|
* on `explore-artist-details`'s Follow button, drawn for every
|
|
* followed artist, invisible to `offline-icons.spec.ts` because no
|
|
* spec had ever followed one. Reaching the state is no longer how
|
|
* this is found.
|
|
*/
|
|
it('names only icons that are bundled', () => {
|
|
const bundled = new Set(bundledIconNames());
|
|
const missing = Object.entries(icons)
|
|
.filter(([k]) => k.startsWith('ICON_'))
|
|
.filter(([, v]) => !bundled.has(v as string))
|
|
.map(([k, v]) => `${k} (${v})`);
|
|
|
|
expect(missing).toEqual([]);
|
|
});
|
|
|
|
/**
|
|
* The two states of the request toggle have to be the same glyph in
|
|
* two weights, or they do not read as each other's opposite — which
|
|
* is what a plus against a bookmark was.
|
|
*/
|
|
it('makes the request toggle an outline/solid pair', () => {
|
|
expect(icons.ICON_CAN_REQUEST).toBe(`regular/${icons.ICON_REQUESTED.replace('solid/', '')}`);
|
|
});
|
|
|
|
/**
|
|
* The queue and the Playlists destination wore the same icon, and
|
|
* "add to queue" and "add to playlist" sat next to each other wearing
|
|
* a third same one. Whatever the table says, these three have to
|
|
* differ from each other.
|
|
*/
|
|
it('keeps the queue, playlists and creating something apart', () => {
|
|
const three = [icons.ICON_QUEUE, icons.ICON_PLAYLIST, icons.ICON_NEW];
|
|
|
|
expect(new Set(three).size).toBe(3);
|
|
});
|
|
});
|