fix(switcher): keep the search field and type filter whole with many sources

The switcher popover is a height-capped flex column. With a long list
(hundreds of sources) the browser shrank every part in proportion to its
size, not only the list: the type filter, which clips its overflow,
collapsed to 2px and showed no labels, and the search field dropped from
36px to its 21px text line. The header, search, type filter, empty state
and footer are now flex: none, so only the list shrinks and scrolls.

The search field now matches the header and side-panel search fields:
the shared --app-search-* tokens, a 10px radius, an 18px icon, the same
text size and focus halo.

The Electron E2E opens the switcher in a 320px-tall window and checks the
search height, the type toggles and the scrolling list; it fails without
the fix (search 21px).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-10-13 08:59:49 +02:00
1 parent 8f36c55e72
commit 7f462208b1
3 files changed
+68 -12

No files matched your search

@@ -530,6 +530,41 @@ test.describe('Electron Playlist Switcher', () => {
await expect(panel).toHaveCount(0);
await expect(trigger).toBeFocused();
// In a short window only the list shrinks and scrolls; the
// search field and the type filter keep their height.
const viewport = page.viewportSize();
await page.setViewportSize({
width: viewport?.width ?? 1280,
height: 320,
});
await page.keyboard.press('ControlOrMeta+p');
await expect(search).toBeFocused();
const chrome = await panel.evaluate((host) => {
const height = (element: Element | null) =>
element?.getBoundingClientRect().height ?? 0;
const list = host.querySelector('.switcher__list');
return {
search: height(host.querySelector('.switcher__search')),
toggles: Array.from(
host.querySelectorAll('.mat-button-toggle'),
height
),
listScrolls:
!!list && list.scrollHeight > list.clientHeight,
};
});
expect(chrome.search, JSON.stringify(chrome)).toBeCloseTo(36, 0);
expect(chrome.toggles.length, JSON.stringify(chrome)).toBe(3);
for (const toggle of chrome.toggles) {
expect(toggle, JSON.stringify(chrome)).toBeGreaterThanOrEqual(
30
);
}
expect(chrome.listScrolls, JSON.stringify(chrome)).toBe(true);
await page.keyboard.press('Escape');
await expect(panel).toHaveCount(0);
if (viewport) await page.setViewportSize(viewport);
await openSources(page);
await expect(
page.locator('app-workspace-sources app-source-row')
+5 -1
View File
@@ -1386,7 +1386,11 @@ in `@iptvnator/playlist/shared/util` (`source-display`, `source-format`,
the highlighted row's menu, Escape closes and returns focus to the
trigger. The compact row's ⋯ is pointer-only (`aria-hidden`, not
focusable), since an option may not contain a control. "Recent" lists the
three most recently opened sources once there are six or more.
three most recently opened sources once there are six or more. The search
field is the shared 36px search field (`--app-search-*`). The popover is a
height-capped column in which only the list shrinks and scrolls: every
other part is `flex: none`. Otherwise a long list (hundreds of sources)
squeezes them in proportion to its size, and the type filter collapses.
### Network source indicators (Electron)
@@ -18,6 +18,19 @@
color: var(--app-heading-color);
}
// Only the list shrinks and scrolls. A column flex container shrinks its
// items in proportion to their size, so with a long list (hundreds of
// sources) the fixed parts lost nearly all their height too: the type
// filter, which clips its overflow, collapsed to 2px and the search field
// to its text line.
.switcher__header,
.switcher__search,
.switcher__types,
.switcher__empty,
.switcher__footer {
flex: none;
}
.switcher__header {
display: flex;
align-items: center;
@@ -46,32 +59,36 @@
@include controls.ghost-icon-button(32px, 18px);
}
// The search field is one step darker than the popover. Its focus shows on
// the field (token border and halo), not as an outline on the input.
// The same search field as the header and the side panels: 36px, the
// shared search tokens, focus on the field (token border and halo) rather
// than as an outline on the input.
.switcher__search {
display: flex;
align-items: center;
gap: 9px;
gap: 8px;
height: 36px;
margin: 0 14px 10px;
padding: 0 10px;
box-sizing: border-box;
border: 1px solid var(--app-line-strong);
border-radius: 9px;
background: var(--app-content-bg);
border: 1px solid var(--app-search-border, var(--app-line-strong));
border-radius: 10px;
background: var(--app-search-bg, var(--app-content-bg));
color: var(--app-muted-color);
transition:
border-color 0.15s ease,
box-shadow 0.15s ease;
&:focus-within {
border-color: var(--app-focus-ring);
box-shadow: 0 0 0 3px
color-mix(in srgb, var(--app-focus-ring) 18%, transparent);
color-mix(in srgb, var(--app-focus-ring) 12%, transparent);
}
.mat-icon {
flex: none;
width: 15px;
height: 15px;
font-size: 15px;
width: 18px;
height: 18px;
font-size: 18px;
}
input {
@@ -82,7 +99,7 @@
background: none;
color: var(--app-heading-color);
font: inherit;
font-size: 13px;
font-size: 0.84rem;
&::placeholder {
color: var(--app-muted-color);