Compare commits
2
Commits
9e92721bb7
...
cee19d7ef9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cee19d7ef9 | ||
|
|
e9ca16362f |
@@ -12,9 +12,17 @@ const (
|
|||||||
// MinWidth is the smallest allowed window width in pixels. Wails
|
// MinWidth is the smallest allowed window width in pixels. Wails
|
||||||
// enforces this at runtime; it is also the floor below which a
|
// enforces this at runtime; it is also the floor below which a
|
||||||
// reported size is treated as bogus and not persisted.
|
// reported size is treated as bogus and not persisted.
|
||||||
MinWidth = 512
|
//
|
||||||
|
// 800x600 is where the shell was measured to still work, rather
|
||||||
|
// than a round number: below ~780 the header's subtitle wraps and
|
||||||
|
// pushes the title out of the 4em top bar, and below ~600 tall the
|
||||||
|
// eleven sidebar items no longer fit at once. The previous
|
||||||
|
// 512x384 was aspirational — at 700x480 the sidebar overflowed
|
||||||
|
// behind the player bar with no scroll and Settings and Jobs could
|
||||||
|
// not be reached at all.
|
||||||
|
MinWidth = 800
|
||||||
// MinHeight is the smallest allowed window height in pixels.
|
// MinHeight is the smallest allowed window height in pixels.
|
||||||
MinHeight = 384
|
MinHeight = 600
|
||||||
)
|
)
|
||||||
|
|
||||||
// WindowConfig holds window size preferences.
|
// WindowConfig holds window size preferences.
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import { test, expect } from '../support/fixtures.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* H-7 and H-11, which are one finding seen from two distances: the app
|
||||||
|
* did not fit in its own window.
|
||||||
|
*
|
||||||
|
* `computeDefaultWidths` shared out the track list's whole
|
||||||
|
* `clientWidth` and never subtracted the 24 px favourite column or the
|
||||||
|
* 2×8 px row padding that `colBoundaryPositions` already knew about, so
|
||||||
|
* every row was 40 px wider than the box holding it (`scrollWidth 1280`
|
||||||
|
* against `clientWidth 1240`) and Duration was clipped at every size.
|
||||||
|
*
|
||||||
|
* And the enforced minimum window was 512×384, which the layout had
|
||||||
|
* never supported: at 700×480 the eleven sidebar items needed 406 px of
|
||||||
|
* a 352 px pane, `overflow: hidden` cut the last two off, nothing
|
||||||
|
* scrolled, and **Settings and Jobs could not be reached at all**.
|
||||||
|
*
|
||||||
|
* The three viewports are the two common ones and the new enforced
|
||||||
|
* minimum (`backend/config/window.go`). The minimum is the one that
|
||||||
|
* matters: it is the only size the app is *promising* to work at.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Keep in step with `MinWidth`/`MinHeight` in backend/config/window.go. */
|
||||||
|
const MIN_VIEWPORT = { width: 800, height: 600 };
|
||||||
|
|
||||||
|
const VIEWPORTS = [
|
||||||
|
{ name: '1440×900', width: 1440, height: 900 },
|
||||||
|
{ name: '1024×768', width: 1024, height: 768 },
|
||||||
|
{ name: `the minimum (${MIN_VIEWPORT.width}×${MIN_VIEWPORT.height})`, ...MIN_VIEWPORT },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rows report their own overflow, which is the symptom a user sees: a
|
||||||
|
* column rendered past the edge of the row it belongs to.
|
||||||
|
*/
|
||||||
|
const rowOverflow = (page: import('@playwright/test').Page) =>
|
||||||
|
page.evaluate(() => {
|
||||||
|
const list = document.querySelector('track-list');
|
||||||
|
const root = list?.shadowRoot;
|
||||||
|
|
||||||
|
if (!root) return { rows: 0, overflowing: [] as string[] };
|
||||||
|
|
||||||
|
const boxes = [
|
||||||
|
...root.querySelectorAll<HTMLElement>('.header-row, .track-row'),
|
||||||
|
];
|
||||||
|
|
||||||
|
return {
|
||||||
|
rows: boxes.length,
|
||||||
|
overflowing: boxes
|
||||||
|
.filter((b) => b.scrollWidth > b.clientWidth)
|
||||||
|
.map((b) => `${b.className}: ${b.scrollWidth} > ${b.clientWidth}`),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('the app fits in its own window', () => {
|
||||||
|
for (const vp of VIEWPORTS) {
|
||||||
|
test(`no track row overflows at ${vp.name}`, async ({ app }) => {
|
||||||
|
await app.setViewportSize({ width: vp.width, height: vp.height });
|
||||||
|
await app.getByTestId('nav-tracks').click();
|
||||||
|
await expect(app.getByTestId('main-content')).toHaveAttribute(
|
||||||
|
'data-active-view',
|
||||||
|
'tracks',
|
||||||
|
);
|
||||||
|
|
||||||
|
// The columns are recomputed by a ResizeObserver, so poll rather
|
||||||
|
// than read once: a single read races the resize and passes on
|
||||||
|
// the widths from the previous viewport.
|
||||||
|
await expect.poll(async () => (await rowOverflow(app)).rows).toBeGreaterThan(1);
|
||||||
|
await expect
|
||||||
|
.poll(async () => (await rowOverflow(app)).overflowing)
|
||||||
|
.toEqual([]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('every destination stays reachable at the minimum size', async ({
|
||||||
|
app,
|
||||||
|
}) => {
|
||||||
|
await app.setViewportSize(MIN_VIEWPORT);
|
||||||
|
|
||||||
|
// Below the breakpoint the sidebar collapses to icons; the labels
|
||||||
|
// go, the destinations do not.
|
||||||
|
const sidebar = app.locator('app-sidebar');
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(() =>
|
||||||
|
sidebar.evaluate((el) => el.classList.contains('collapsed')),
|
||||||
|
)
|
||||||
|
.toBe(true);
|
||||||
|
|
||||||
|
// Settings and Jobs are the two that were unreachable: they are
|
||||||
|
// last in the nav, and the pane used to clip rather than scroll.
|
||||||
|
for (const view of ['jobs', 'settings'] as const) {
|
||||||
|
const item = app.getByTestId(`nav-${view}`);
|
||||||
|
|
||||||
|
await item.scrollIntoViewIfNeeded();
|
||||||
|
await item.click();
|
||||||
|
await expect(app.getByTestId('main-content')).toHaveAttribute(
|
||||||
|
'data-active-view',
|
||||||
|
view,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Leave the app where the next spec expects to find it.
|
||||||
|
await app.getByTestId('nav-tracks').click();
|
||||||
|
await expect(app.getByTestId('main-content')).toHaveAttribute(
|
||||||
|
'data-active-view',
|
||||||
|
'tracks',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the sidebar scrolls rather than hiding what does not fit', async ({
|
||||||
|
app,
|
||||||
|
}) => {
|
||||||
|
await app.setViewportSize({ width: 700, height: 480 });
|
||||||
|
|
||||||
|
// Smaller than the enforced minimum on purpose: the window cannot
|
||||||
|
// be dragged here, but a scaled display or a large system font can
|
||||||
|
// still land the layout in it, and clipping the nav with no scroll
|
||||||
|
// is the failure that made Settings unreachable.
|
||||||
|
const reachable = await app.locator('app-sidebar').evaluate((el) => {
|
||||||
|
const settings = el.shadowRoot?.querySelector<HTMLElement>(
|
||||||
|
'[data-testid="nav-settings"]',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!settings) return null;
|
||||||
|
|
||||||
|
el.scrollTop = el.scrollHeight;
|
||||||
|
|
||||||
|
const item = settings.getBoundingClientRect();
|
||||||
|
const pane = el.getBoundingClientRect();
|
||||||
|
|
||||||
|
return item.bottom <= Math.ceil(pane.bottom) && item.top >= Math.floor(pane.top);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(reachable).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -65,4 +65,62 @@ test.describe('library views', () => {
|
|||||||
await expect(app.getByText('Aurora Fields').first()).toBeVisible();
|
await expect(app.getByText('Aurora Fields').first()).toBeVisible();
|
||||||
await expect(app.getByText('Pale Circuit').first()).toBeVisible();
|
await expect(app.getByText('Pale Circuit').first()).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a library can be renamed from its own name', async ({ app }) => {
|
||||||
|
// Found while closing Phase 3: clicking the name opened the rename
|
||||||
|
// editor and closed it in the same click, because the click bubbled
|
||||||
|
// to config-page's own document handler — which exists to close it.
|
||||||
|
// The overflow menu's Rename always worked; it stops propagation.
|
||||||
|
//
|
||||||
|
// The editor is opened and abandoned, never committed: these specs
|
||||||
|
// share one backend process, and a renamed library would fail the
|
||||||
|
// ones after this on fixture content.
|
||||||
|
await app.getByTestId('nav-settings').click();
|
||||||
|
await expect(app.getByTestId('main-content')).toHaveAttribute(
|
||||||
|
'data-active-view',
|
||||||
|
'settings',
|
||||||
|
);
|
||||||
|
|
||||||
|
const page = app.locator('config-page');
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(() =>
|
||||||
|
page.evaluate((el) => {
|
||||||
|
const sections = [
|
||||||
|
...(el.shadowRoot?.querySelectorAll('config-section') ?? []),
|
||||||
|
];
|
||||||
|
const libraries = sections.at(-1);
|
||||||
|
const header =
|
||||||
|
libraries?.shadowRoot?.querySelector<HTMLElement>('.header');
|
||||||
|
|
||||||
|
header?.click();
|
||||||
|
|
||||||
|
return !!el.shadowRoot?.querySelector('.library-name');
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.toBe(true);
|
||||||
|
|
||||||
|
const opened = await page.evaluate(async (el) => {
|
||||||
|
el.shadowRoot
|
||||||
|
?.querySelector<HTMLElement>('.library-name')
|
||||||
|
?.click();
|
||||||
|
|
||||||
|
// Lit renders on a microtask: a synchronous read here reports
|
||||||
|
// "not editing" on a build where it works, which is how this bug
|
||||||
|
// was first "reproduced" against a fix that already worked.
|
||||||
|
await new Promise((r) => setTimeout(r, 100));
|
||||||
|
|
||||||
|
return !!el.shadowRoot?.querySelector('.edit-input');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(opened).toBe(true);
|
||||||
|
|
||||||
|
// Escape the editor without renaming, and leave the app on Tracks.
|
||||||
|
await app.keyboard.press('Escape');
|
||||||
|
await app.getByTestId('nav-tracks').click();
|
||||||
|
await expect(app.getByTestId('main-content')).toHaveAttribute(
|
||||||
|
'data-active-view',
|
||||||
|
'tracks',
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -54,6 +54,16 @@ ul {
|
|||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The same breakpoint the sidebar collapses at (AUTO_COLLAPSE_VIEWPORT
|
||||||
|
in app-sidebar.ts). The subtitle wrapped to two lines below ~780 px,
|
||||||
|
which made the title block 98 px tall inside a 4em bar and pushed it
|
||||||
|
down into the nav (H-11). */
|
||||||
|
@media (max-width: 899px) {
|
||||||
|
.subtitle {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
body div.sidebar {
|
body div.sidebar {
|
||||||
grid-area: sidebar;
|
grid-area: sidebar;
|
||||||
background-color: var(--yj-bg-surface, #212529);
|
background-color: var(--yj-bg-surface, #212529);
|
||||||
|
|||||||
@@ -2096,7 +2096,13 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
|
|||||||
: html`
|
: html`
|
||||||
<span
|
<span
|
||||||
class="library-name"
|
class="library-name"
|
||||||
@click=${() => this.handleStartRename(lib.id, lib.name)}
|
@click=${(e: Event) => {
|
||||||
|
// The document handler closes the
|
||||||
|
// editor, and without this it closes
|
||||||
|
// the one this very click opened.
|
||||||
|
e.stopPropagation();
|
||||||
|
this.handleStartRename(lib.id, lib.name);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
${lib.name}
|
${lib.name}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -18,6 +18,14 @@ const MAX_WIDTH = 400;
|
|||||||
const DEFAULT_WIDTH = 200;
|
const DEFAULT_WIDTH = 200;
|
||||||
const COLLAPSE_WIDTH = 142;
|
const COLLAPSE_WIDTH = 142;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Below this viewport width the sidebar collapses itself to icons.
|
||||||
|
* `.collapsed` existed and only a manual drag ever reached it (H-11),
|
||||||
|
* so a small window kept a 200 px sidebar it could not afford and the
|
||||||
|
* content pane wore the whole loss.
|
||||||
|
*/
|
||||||
|
const AUTO_COLLAPSE_VIEWPORT = 900;
|
||||||
|
|
||||||
@customElement('app-sidebar')
|
@customElement('app-sidebar')
|
||||||
export class AppSidebar extends LitElement {
|
export class AppSidebar extends LitElement {
|
||||||
static override styles = [designTokens, css`
|
static override styles = [designTokens, css`
|
||||||
@@ -28,6 +36,14 @@ export class AppSidebar extends LitElement {
|
|||||||
background-color: var(--yj-bg-surface, #212529);
|
background-color: var(--yj-bg-surface, #212529);
|
||||||
min-width: ${MIN_WIDTH}px;
|
min-width: ${MIN_WIDTH}px;
|
||||||
max-width: ${MAX_WIDTH}px;
|
max-width: ${MAX_WIDTH}px;
|
||||||
|
/* Eleven items need ~406 px and the pane is whatever the
|
||||||
|
window leaves it — 352 px at 700x480, which clipped Jobs
|
||||||
|
and Settings behind the player bar with no way to reach
|
||||||
|
them (H-11). Collapsing to icons does not help: it is a
|
||||||
|
width mode, and this is the height. */
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
scrollbar-width: thin;
|
||||||
}
|
}
|
||||||
|
|
||||||
.resize-handle {
|
.resize-handle {
|
||||||
@@ -147,6 +163,12 @@ export class AppSidebar extends LitElement {
|
|||||||
@state()
|
@state()
|
||||||
private collapsed = false;
|
private collapsed = false;
|
||||||
|
|
||||||
|
/** The width the user chose, restored when the window grows back. */
|
||||||
|
private userWidth = DEFAULT_WIDTH;
|
||||||
|
|
||||||
|
private narrowViewport: MediaQueryList | null =
|
||||||
|
null;
|
||||||
|
|
||||||
/** Whether a track drag is in progress somewhere in the app. */
|
/** Whether a track drag is in progress somewhere in the app. */
|
||||||
@state()
|
@state()
|
||||||
private trackDragActive = false;
|
private trackDragActive = false;
|
||||||
@@ -176,6 +198,14 @@ export class AppSidebar extends LitElement {
|
|||||||
override connectedCallback() {
|
override connectedCallback() {
|
||||||
super.connectedCallback();
|
super.connectedCallback();
|
||||||
this.style.width = `${DEFAULT_WIDTH}px`;
|
this.style.width = `${DEFAULT_WIDTH}px`;
|
||||||
|
this.narrowViewport = window.matchMedia(
|
||||||
|
`(max-width: ${AUTO_COLLAPSE_VIEWPORT - 1}px)`,
|
||||||
|
);
|
||||||
|
this.narrowViewport.addEventListener(
|
||||||
|
'change',
|
||||||
|
this.onViewportChange,
|
||||||
|
);
|
||||||
|
this.applyViewportWidth();
|
||||||
document.addEventListener(
|
document.addEventListener(
|
||||||
'mousemove',
|
'mousemove',
|
||||||
this.handleMouseMove,
|
this.handleMouseMove,
|
||||||
@@ -192,6 +222,11 @@ export class AppSidebar extends LitElement {
|
|||||||
|
|
||||||
override disconnectedCallback() {
|
override disconnectedCallback() {
|
||||||
super.disconnectedCallback();
|
super.disconnectedCallback();
|
||||||
|
this.narrowViewport?.removeEventListener(
|
||||||
|
'change',
|
||||||
|
this.onViewportChange,
|
||||||
|
);
|
||||||
|
this.narrowViewport = null;
|
||||||
document.removeEventListener(
|
document.removeEventListener(
|
||||||
'mousemove',
|
'mousemove',
|
||||||
this.handleMouseMove,
|
this.handleMouseMove,
|
||||||
@@ -284,8 +319,29 @@ export class AppSidebar extends LitElement {
|
|||||||
|
|
||||||
this.style.width = `${clampedWidth}px`;
|
this.style.width = `${clampedWidth}px`;
|
||||||
this.collapsed = clampedWidth < COLLAPSE_WIDTH;
|
this.collapsed = clampedWidth < COLLAPSE_WIDTH;
|
||||||
|
this.userWidth = clampedWidth;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private onViewportChange = () => {
|
||||||
|
this.applyViewportWidth();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Icons below the breakpoint, the user's own width above it. The
|
||||||
|
* width is inline (set here and by the drag handle), so this cannot
|
||||||
|
* be a media query in the stylesheet.
|
||||||
|
*/
|
||||||
|
private applyViewportWidth() {
|
||||||
|
const narrow =
|
||||||
|
this.narrowViewport?.matches ?? false;
|
||||||
|
const width = narrow
|
||||||
|
? MIN_WIDTH
|
||||||
|
: this.userWidth;
|
||||||
|
|
||||||
|
this.style.width = `${width}px`;
|
||||||
|
this.collapsed = width < COLLAPSE_WIDTH;
|
||||||
|
}
|
||||||
|
|
||||||
private handleMouseUp = () => {
|
private handleMouseUp = () => {
|
||||||
this.isDragging = false;
|
this.isDragging = false;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -70,6 +70,18 @@ const SORT_DIR_KEY = 'track-list-sort-direction';
|
|||||||
const MIN_COLUMN_WIDTH = 50;
|
const MIN_COLUMN_WIDTH = 50;
|
||||||
const DEFAULT_FIXED_WIDTH = 80;
|
const DEFAULT_FIXED_WIDTH = 80;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chrome the resizable columns cannot use: the fixed favourite column
|
||||||
|
* and the row's own horizontal padding. Both numbers also appear in
|
||||||
|
* the CSS below — the `24px` first track of `--grid-cols` and
|
||||||
|
* `.track-row`/`.header-row`'s `padding: 8px` — so they live here and
|
||||||
|
* are read from both places rather than written out four times.
|
||||||
|
*/
|
||||||
|
const FAV_COL_WIDTH = 24;
|
||||||
|
const ROW_PADDING_X = 8;
|
||||||
|
const ROW_CHROME_WIDTH =
|
||||||
|
FAV_COL_WIDTH + ROW_PADDING_X * 2;
|
||||||
|
|
||||||
// Inline SVG paths for favorite icons — eliminates wa-icon shadow DOM
|
// Inline SVG paths for favorite icons — eliminates wa-icon shadow DOM
|
||||||
// overhead (30-50 shadow roots during scroll). Font Awesome 6 paths.
|
// overhead (30-50 shadow roots during scroll). Font Awesome 6 paths.
|
||||||
const FAV_ICONS = {
|
const FAV_ICONS = {
|
||||||
@@ -448,7 +460,7 @@ export class TrackList
|
|||||||
|
|
||||||
private get gridTemplateColumns(): string {
|
private get gridTemplateColumns(): string {
|
||||||
const cols = this.activeColumns;
|
const cols = this.activeColumns;
|
||||||
const favCol = '24px';
|
const favCol = `${FAV_COL_WIDTH}px`;
|
||||||
|
|
||||||
if (this.columnWidths.length === 0 || this.columnWidths.length !== cols.length) {
|
if (this.columnWidths.length === 0 || this.columnWidths.length !== cols.length) {
|
||||||
return (
|
return (
|
||||||
@@ -472,10 +484,9 @@ export class TrackList
|
|||||||
private get colBoundaryPositions(): number[] {
|
private get colBoundaryPositions(): number[] {
|
||||||
if (this.columnWidths.length === 0) return [];
|
if (this.columnWidths.length === 0) return [];
|
||||||
|
|
||||||
const padding = 8;
|
|
||||||
const favColWidth = 24;
|
|
||||||
const positions: number[] = [];
|
const positions: number[] = [];
|
||||||
let cumulative = padding + favColWidth;
|
let cumulative =
|
||||||
|
ROW_PADDING_X + FAV_COL_WIDTH;
|
||||||
|
|
||||||
for (
|
for (
|
||||||
let i = 0;
|
let i = 0;
|
||||||
@@ -502,8 +513,18 @@ export class TrackList
|
|||||||
this.computeDefaultWidths();
|
this.computeDefaultWidths();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Width the resizable columns may share. H-7: this was the raw
|
||||||
|
* `clientWidth`, which the columns then summed to exactly — so
|
||||||
|
* every row was `ROW_CHROME_WIDTH` (40 px) wider than the box it
|
||||||
|
* had to fit in and the last column was always clipped.
|
||||||
|
*/
|
||||||
|
private get availableColumnWidth(): number {
|
||||||
|
return this.clientWidth - ROW_CHROME_WIDTH;
|
||||||
|
}
|
||||||
|
|
||||||
private computeDefaultWidths() {
|
private computeDefaultWidths() {
|
||||||
const totalWidth = this.clientWidth;
|
const totalWidth = this.availableColumnWidth;
|
||||||
|
|
||||||
if (totalWidth <= 0) return;
|
if (totalWidth <= 0) return;
|
||||||
|
|
||||||
@@ -631,13 +652,14 @@ export class TrackList
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scale widths so they sum to exactly the container width.
|
* Scale widths so they sum to exactly the width available to the
|
||||||
|
* columns (the host minus the row chrome).
|
||||||
* Every column is guaranteed at least MIN_COLUMN_WIDTH.
|
* Every column is guaranteed at least MIN_COLUMN_WIDTH.
|
||||||
*/
|
*/
|
||||||
private normalizeWidths(
|
private normalizeWidths(
|
||||||
widths: number[],
|
widths: number[],
|
||||||
): number[] {
|
): number[] {
|
||||||
const container = this.clientWidth;
|
const container = this.availableColumnWidth;
|
||||||
|
|
||||||
if (container <= 0 || widths.length === 0) {
|
if (container <= 0 || widths.length === 0) {
|
||||||
return widths;
|
return widths;
|
||||||
@@ -731,7 +753,7 @@ export class TrackList
|
|||||||
private onColResizeMove = (e: MouseEvent) => {
|
private onColResizeMove = (e: MouseEvent) => {
|
||||||
if (this.resizingColumn === null) return;
|
if (this.resizingColumn === null) return;
|
||||||
|
|
||||||
const container = this.clientWidth;
|
const container = this.availableColumnWidth;
|
||||||
|
|
||||||
if (container <= 0) return;
|
if (container <= 0) return;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
/**
|
||||||
|
* `H-7`: `computeDefaultWidths` distributed the host's whole
|
||||||
|
* `clientWidth` across the resizable columns, while every row spends
|
||||||
|
* 24 px on the favourite column and 2×8 px on its own padding before
|
||||||
|
* the first one starts. So the grid was always exactly 40 px wider
|
||||||
|
* than the box it had to fit in, and the last column was clipped on
|
||||||
|
* every row at every window size — measured in the running app at
|
||||||
|
* `scrollWidth 1280` against `clientWidth 1240`.
|
||||||
|
*
|
||||||
|
* The e2e spec (`e2e/specs/layout-overflow.spec.ts`) asserts it in the
|
||||||
|
* real shell at three viewports. This is the cheap guard on the
|
||||||
|
* arithmetic itself, because the failure is silent: the row still
|
||||||
|
* renders, nothing throws, and the only symptom is a column you cannot
|
||||||
|
* read.
|
||||||
|
*/
|
||||||
|
import { describe, expect, it, beforeEach } from 'vitest';
|
||||||
|
import type { LitElement } from 'lit';
|
||||||
|
|
||||||
|
import '@components/track-list/track-list';
|
||||||
|
import { fixture, shadow, shadowAll } from '@test/support/render';
|
||||||
|
|
||||||
|
/** The chrome a row spends before the first resizable column. */
|
||||||
|
const FAV_COL = 24;
|
||||||
|
const ROW_PADDING_X = 8;
|
||||||
|
|
||||||
|
const TRACKS = Array.from({ length: 40 }, (_, i) => ({
|
||||||
|
FilePath: `/music/track-${i}.mp3`,
|
||||||
|
TrackName: `Track ${i}`,
|
||||||
|
ArtistName: 'An Artist',
|
||||||
|
Album: 'An Album',
|
||||||
|
Duration: 180,
|
||||||
|
})) as never[];
|
||||||
|
|
||||||
|
/** Sum the px tracks of a `grid-template-columns` value. */
|
||||||
|
function trackWidths(el: Element | null): number[] {
|
||||||
|
if (el === null) throw new Error('no element to measure');
|
||||||
|
|
||||||
|
return getComputedStyle(el)
|
||||||
|
.gridTemplateColumns.split(/\s+/)
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((t) => parseFloat(t))
|
||||||
|
.filter((n) => !Number.isNaN(n));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('<track-list> column arithmetic', () => {
|
||||||
|
let el: LitElement;
|
||||||
|
/** What the columns may spend: the host, minus the row's padding. */
|
||||||
|
let budget: number;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
// A saved layout is normalised against the same budget, so the
|
||||||
|
// default path is the one worth pinning.
|
||||||
|
localStorage.removeItem('track-list-column-widths');
|
||||||
|
|
||||||
|
el = await fixture<LitElement>('track-list', {
|
||||||
|
externalTracks: TRACKS,
|
||||||
|
});
|
||||||
|
el.style.height = '600px';
|
||||||
|
await el.updateComplete;
|
||||||
|
await new Promise((r) => setTimeout(r, 60));
|
||||||
|
|
||||||
|
budget = el.clientWidth - ROW_PADDING_X * 2;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves room for the favourite column and the row padding', () => {
|
||||||
|
const widths = trackWidths(shadow(el, '.header-row'));
|
||||||
|
const total = widths.reduce((a, b) => a + b, 0);
|
||||||
|
|
||||||
|
expect(total).toBeLessThanOrEqual(budget);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('spends the whole budget and no more', () => {
|
||||||
|
// Not merely "does not overflow": a fix that under-filled would
|
||||||
|
// pass that and leave a gap down the right of every row.
|
||||||
|
const widths = trackWidths(shadow(el, '.header-row'));
|
||||||
|
const total = widths.reduce((a, b) => a + b, 0);
|
||||||
|
|
||||||
|
expect(total).toBe(budget);
|
||||||
|
expect(widths[0]).toBe(FAV_COL);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders no row wider than the row itself', () => {
|
||||||
|
const rows = shadowAll<HTMLElement>(el, '.track-row');
|
||||||
|
|
||||||
|
expect(rows.length).toBeGreaterThan(0);
|
||||||
|
expect(
|
||||||
|
rows.filter((r) => r.scrollWidth > r.clientWidth),
|
||||||
|
).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the header aligned with the rows it heads', () => {
|
||||||
|
// The regression this catches is the one a screenshot found twice
|
||||||
|
// in this plan: columns that no longer line up with their header.
|
||||||
|
expect(trackWidths(shadow(el, '.header-row'))).toEqual(
|
||||||
|
trackWidths(shadow(el, '.track-row')),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('places the resize handles on the boundaries it just measured', () => {
|
||||||
|
// `colBoundaryPositions` starts from the same two numbers
|
||||||
|
// `computeDefaultWidths` subtracts. They were written out
|
||||||
|
// separately, which is how they came to disagree.
|
||||||
|
const widths = trackWidths(shadow(el, '.header-row'));
|
||||||
|
const handles = shadowAll<HTMLElement>(el, '.col-resize-handle');
|
||||||
|
|
||||||
|
expect(handles).toHaveLength(widths.length - 2);
|
||||||
|
expect(parseFloat(handles[0]?.style.left ?? '0')).toBe(
|
||||||
|
ROW_PADDING_X + FAV_COL + (widths[1] ?? 0),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user