fix(ui): keep responsive filter panels reachable

This commit is contained in:
rcourtman
2026-08-06 22:57:04 +01:00
parent b765197426
commit bb75580073
14 changed files with 211 additions and 61 deletions
+63 -31
View File
@@ -1,49 +1,81 @@
{
"version": 1,
"base_sha": "ed0f3613aeda63f41248cd1ef981b5ca2639bd8b",
"verified_at": "2026-08-06T21:47:15Z",
"base_sha": "b7651974260b3bccb8085754b002bd3727040832",
"verified_at": "2026-08-06T20:28:35Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/features/alerts/alertOverridesModel.ts",
"frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsAvailabilityMutations.ts",
"frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsDockerData.ts",
"frontend-modern/src/features/alerts/thresholds/thresholdsResourceModel.ts",
"frontend-modern/src/features/docker/DockerContainersTable.tsx",
"frontend-modern/src/utils/alertThresholdsPresentation.ts"
"frontend-modern/src/components/shared/FilterToolbar.tsx",
"frontend-modern/src/components/shared/FilterBar/SavedViewsMenu.tsx",
"frontend-modern/src/components/shared/FilterBar/ViewOptionsMenu.tsx",
"frontend-modern/src/components/shared/SearchTipsPopover.tsx",
"frontend-modern/src/components/shared/searchTipsPopoverModel.ts",
"frontend-modern/src/components/shared/useSearchTipsPopoverState.ts",
"frontend-modern/src/index.css"
],
"content_sha256": {
"frontend-modern/src/features/alerts/alertOverridesModel.ts": "9729ff9ad413c3b9ece2a1ab3449939cba563bf80acd16e678b52d668751ed63",
"frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsAvailabilityMutations.ts": "b8b89dd3d756b13dc730f6cc7e0c3fe2f4a79525753eeaa6fa203766c7e592ba",
"frontend-modern/src/features/alerts/thresholds/hooks/useThresholdsDockerData.ts": "92b0fd9836d86930cae26e5ccecb3dc56babdba2170a454e820a12dd27549f2b",
"frontend-modern/src/features/alerts/thresholds/thresholdsResourceModel.ts": "e71f230e5fb8ad5f970cf9385afa31b18221aedd81453febbce64a0d0f60783c",
"frontend-modern/src/features/docker/DockerContainersTable.tsx": "45b125474f0d5058e9861c44fab0a33cf93b4a82952001d7f9010f18419dac4e",
"frontend-modern/src/utils/alertThresholdsPresentation.ts": "6605976a9b561c26578f1eb78785ccd5d6739c2a0a3b91246e23828c8433a49c"
"frontend-modern/src/components/shared/FilterToolbar.tsx": "e866a9276b5ab6a901ffea4adab59f9f1438376295017e4290a1e58f47e775f0",
"frontend-modern/src/components/shared/FilterBar/SavedViewsMenu.tsx": "0266c7abf8ab90941d53b977f0fd1af7cd1f795d0c5849fdabe425a01d6cb02b",
"frontend-modern/src/components/shared/FilterBar/ViewOptionsMenu.tsx": "877570b2fbdbd7378a0cd94bb3c65a9edb0a22348dd8ab9a106b0f15b92f0aa8",
"frontend-modern/src/components/shared/SearchTipsPopover.tsx": "d570941c7f089dad7805c514b51c6f7a21647c91d6409f3b85f71f91e8b73d6a",
"frontend-modern/src/components/shared/searchTipsPopoverModel.ts": "b558ce94eea59a645fd2878bc6bd2fd1f0184cc86cfd649ac6991f303f888131",
"frontend-modern/src/components/shared/useSearchTipsPopoverState.ts": "be3c9840f580d1e975096a98f94da6ca6a0e554b9d93b1d0e2aac77403eb5b60",
"frontend-modern/src/index.css": "ac6b159890d180e044153555e6e9299d02d7775991f9aab3f15a6a31f113c509"
},
"routes": [
"/alerts/thresholds/docker",
"/docker"
],
"routes": ["/proxmox/overview", "/standalone/machines"],
"viewports": [
{
"width": 1280,
"height": 800
"width": 390,
"height": 844
},
{
"width": 375,
"height": 812
"width": 568,
"height": 320
},
{
"width": 844,
"height": 390
},
{
"width": 1180,
"height": 650
},
{
"width": 1279,
"height": 650
},
{
"width": 1280,
"height": 500
},
{
"width": 1280,
"height": 650
},
{
"width": 1280,
"height": 768
}
],
"states": [
"Docker thresholds tab with mock container rows grouped by host, ignored-container-patterns card showing the new wildcard copy",
"loki container rows rendering the persisted docker:orion-2-mock/loki name-keyed override as disabled after a full page reload",
"alerts.json converged to name-keyed overrides after live monitor-sync migration re-homed a seeded legacy container-ID key and a unified-hash key and pruned an orphaned 12-hex key",
"Docker page containers table with CPU/memory meters resolving thresholds through the new name-first candidate chain",
"mobile 375px docker thresholds view rendering the wildcard copy and container sections"
"Short-height landscape platform tables with fixed bottom navigation",
"Expanded Proxmox node details and mobile filter actions",
"View preferences with the inline Proxmox column picker expanded",
"View preferences with all eleven Machine column choices expanded",
"Saved Views empty state and save-name form",
"Search Tips with content taller than the available landscape space",
"Exact bottom-navigation to desktop-navigation transition at 1279px and 1280px",
"Short desktop windows below the safe anchored-popover height"
],
"interactions": [
"searched containers for 'loki' in the thresholds table",
"toggled the per-container alert disable switch on 'loki' and clicked Save Changes; verified alerts.json stored docker:orion-2-mock/loki (host+name key)",
"reloaded /alerts/thresholds/docker and confirmed the saved override re-bound to the container rows via the name candidate",
"seeded legacy docker:{host}/{containerID}, docker:{host}/{unified-hash} and orphan hex overrides via PUT /api/alerts/config and watched the monitor sync migrate all of them to the name key on disk"
"Verified the horizontally scrollable mobile platform rail keeps Machines reachable and centres it after activation",
"Expanded a Proxmox node at 568x320 and confirmed the table shell and detail row create no horizontal document overflow",
"Opened View preferences at 568x320, 844x390, 1180x650, 1279x650, 1280x500, and 1280x768",
"Expanded the Machine column picker, scrolled internally to Kernel, toggled it, and reset the saved preference",
"Removed and restored the Proxmox Disk column through the compact View panel",
"Opened Saved Views and its save-name form at compact mobile and short desktop sizes",
"Confirmed View and Saved Views remain fully above the bottom navigation and entirely inside the viewport",
"Confirmed short desktop windows use a viewport-fixed panel while 1280x768 returns to the toolbar anchor",
"Opened Search Tips at 568x320, 844x390, 1280x500, and 1280x768 and verified collision-aware height, internal scrolling, and desktop anchoring",
"Measured zero document overflow throughout the interaction pass"
]
}
@@ -310,6 +310,7 @@ describe('FilterBar', () => {
expect(label).toHaveClass('sr-only');
expect(select).toHaveClass('min-h-11');
expect(select).toHaveClass('sm:h-7');
expect(select).toHaveClass('sm:min-h-0');
expect(select).toHaveClass('w-[7.5rem]');
expect(select).not.toHaveClass('min-w-[7.5rem]');
expect(select.parentElement).not.toHaveClass('p-0.5');
@@ -51,16 +51,13 @@ describe('SavedViewsMenu', () => {
expect(trigger).toHaveFocus();
});
it('anchors from the leading edge on narrow rails and the trailing edge on desktop', () => {
it('stays above mobile navigation and returns to the trailing anchor on desktop', () => {
renderMenu('responsive-anchor-contract');
fireEvent.click(screen.getByRole('button', { name: 'Saved views' }));
expect(screen.getByRole('dialog', { name: 'Saved views' })).toHaveClass(
'left-0',
'right-auto',
'md:left-auto',
'md:right-0',
'filter-bottom-nav-aware-panel',
);
});
@@ -11,7 +11,11 @@ import BookmarkIcon from 'lucide-solid/icons/bookmark';
import PlusIcon from 'lucide-solid/icons/plus';
import StarIcon from 'lucide-solid/icons/star';
import XIcon from 'lucide-solid/icons/x';
import { FilterPopoverTrigger, FilterToolbarPanel } from '@/components/shared/FilterToolbar';
import {
FilterPopoverTrigger,
FilterToolbarPanel,
filterBottomNavAwarePanelClass,
} from '@/components/shared/FilterToolbar';
import { useSavedViews, type SavedView } from './useSavedViews';
interface SavedViewsMenuProps {
@@ -124,7 +128,7 @@ export const SavedViewsMenu: Component<SavedViewsMenuProps> = (props) => {
role="dialog"
aria-label="Saved views"
widthClass="w-64 max-w-[calc(100vw-2rem)]"
class="left-0 right-auto top-[calc(100%+0.25rem)] z-50 p-0 md:left-auto md:right-0"
class={`${filterBottomNavAwarePanelClass} z-50 p-0`}
>
<Show
when={savePromptOpen()}
@@ -19,8 +19,7 @@ describe('ViewOptionsMenu', () => {
expect(screen.getByRole('dialog', { name: 'View preferences' })).toBeInTheDocument();
expect(screen.getByRole('dialog', { name: 'View preferences' })).toHaveClass(
'left-auto',
'right-0',
'filter-bottom-nav-aware-panel',
);
expect(screen.getByRole('button', { name: 'View' }).parentElement).toHaveClass(
'static',
@@ -9,7 +9,11 @@ import {
} from 'solid-js';
import SlidersHorizontalIcon from 'lucide-solid/icons/sliders-horizontal';
import { FilterPopoverTrigger, FilterToolbarPanel } from '@/components/shared/FilterToolbar';
import {
FilterPopoverTrigger,
FilterToolbarPanel,
filterBottomNavAwarePanelClass,
} from '@/components/shared/FilterToolbar';
interface ViewOptionsMenuProps {
children: JSX.Element;
@@ -69,7 +73,7 @@ export const ViewOptionsMenu: Component<ViewOptionsMenuProps> = (props) => {
role="dialog"
aria-label={props.label ?? 'View preferences'}
widthClass="w-80 max-w-[calc(100vw-2rem)]"
class="left-auto right-0 top-[calc(100%+0.25rem)] z-50 max-h-[min(38rem,calc(100vh-8rem))] overflow-y-auto p-3"
class={`${filterBottomNavAwarePanelClass} z-50 p-3`}
>
<div class="mb-3 border-b border-border-subtle pb-2">
<div class="text-xs font-medium text-base-content">
@@ -27,6 +27,7 @@ export const filterDividerClass = 'hidden h-5 w-px bg-surface-hover sm:block';
export const filterPanelClass =
'absolute right-0 top-[calc(100%+0.5rem)] z-[80] rounded-md border border-border bg-surface p-3 shadow-lg';
export const filterPanelDefaultWidthClass = 'w-[min(40rem,calc(100vw-2rem))]';
export const filterBottomNavAwarePanelClass = 'filter-bottom-nav-aware-panel';
export const filterPanelTitleClass = 'text-sm font-medium text-base-content';
export const filterPanelDescriptionClass = 'text-xs text-muted';
export const mobileFiltersButtonClass =
@@ -67,7 +67,7 @@ export const SearchTipsPopover: Component<SearchTipsPopoverProps> = (props) => {
role="dialog"
aria-label={title()}
style={state.popoverStyle()}
class={`fixed ${positionClass()} z-50 w-auto overflow-hidden rounded-md border bg-surface text-left shadow-sm sm:absolute sm:mt-2 sm:w-72`}
class={`!fixed ${positionClass()} z-50 w-auto overflow-y-auto rounded-md border bg-surface text-left shadow-sm xl:!absolute xl:mt-2 xl:w-72`}
>
<div class="flex items-center justify-between border-b border-border-subtle px-3 py-2">
<span class="text-sm font-semibold text-base-content">{title()}</span>
@@ -8275,13 +8275,15 @@ describe('shared primitive guardrails', () => {
expect(savedViewsMenuSource).toContain("from './useSavedViews';");
expect(filterToolbarSource).toContain('export const FilterPopoverTrigger');
expect(filterToolbarSource).toContain('export const filterBottomNavAwarePanelClass');
expect(filterToolbarSource).toContain(
"'min-h-11 rounded-md text-xs font-medium sm:h-7 sm:min-h-0'",
);
expect(viewOptionsMenuSource).toContain('<FilterPopoverTrigger');
expect(viewOptionsMenuSource).toContain('class="static ml-auto shrink-0 sm:relative sm:ml-0"');
expect(viewOptionsMenuSource).toContain('class="left-auto right-0');
expect(viewOptionsMenuSource).toContain('filterBottomNavAwarePanelClass');
expect(savedViewsMenuSource).toContain('<FilterPopoverTrigger');
expect(savedViewsMenuSource).toContain('filterBottomNavAwarePanelClass');
expect(filterCatalogSource).toContain('leadingControls?: JSX.Element;');
expect(filterCatalogSource).toContain('viewOptions?: JSX.Element;');
expect(filterCatalogSource).toContain('trailingControls?: JSX.Element;');
@@ -8310,9 +8312,7 @@ describe('shared primitive guardrails', () => {
expect(savedViewsMenuSource).toContain('queueMicrotask(() => nameInputRef?.focus())');
expect(savedViewsMenuSource).toContain('queueMicrotask(() => triggerRef?.focus())');
expect(savedViewsMenuSource).toContain('for={nameInputId}');
expect(savedViewsMenuSource).toContain(
'class="left-0 right-auto top-[calc(100%+0.25rem)] z-50 p-0 md:left-auto md:right-0"',
);
expect(savedViewsMenuSource).toContain('class={`${filterBottomNavAwarePanelClass} z-50 p-0`}');
expect(savedViewsMenuSource).toContain('opacity-100 transition-opacity');
expect(savedViewsMenuSource).toContain('sm:opacity-0');
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it } from 'vitest';
import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, fireEvent, render, screen, waitFor } from '@solidjs/testing-library';
import { SearchTipsPopover } from '@/components/shared/SearchTipsPopover';
import searchTipsPopoverSource from '@/components/shared/SearchTipsPopover.tsx?raw';
import searchTipsPopoverModelSource from '@/components/shared/searchTipsPopoverModel.ts?raw';
@@ -8,6 +8,7 @@ import searchTipsPopoverStateSource from '@/components/shared/useSearchTipsPopov
describe('SearchTipsPopover', () => {
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
it('keeps search tips popover on shell, runtime, and model owners', () => {
@@ -23,14 +24,16 @@ describe('SearchTipsPopover', () => {
expect(searchTipsPopoverStateSource).toContain('createEffect');
expect(searchTipsPopoverStateSource).toContain('window.addEventListener');
expect(searchTipsPopoverStateSource).toContain('pointerInside');
expect(searchTipsPopoverStateSource).toContain('window.innerWidth >= 640');
expect(searchTipsPopoverStateSource).toContain('window.innerWidth >= 1280');
expect(searchTipsPopoverStateSource).toContain('window.innerHeight >= 768');
expect(searchTipsPopoverStateSource).toContain('window.innerWidth - viewportMargin - width');
expect(searchTipsPopoverStateSource).toContain('nav[aria-label="Mobile navigation"]');
expect(searchTipsPopoverStateSource).toContain(
"window.addEventListener('scroll', updatePopoverPosition, true)",
);
expect(searchTipsPopoverSource).toContain('style={state.popoverStyle()}');
expect(searchTipsPopoverSource).toContain('fixed ${positionClass()}');
expect(searchTipsPopoverSource).toContain('sm:absolute sm:mt-2 sm:w-72');
expect(searchTipsPopoverSource).toContain('!fixed ${positionClass()}');
expect(searchTipsPopoverSource).toContain('xl:!absolute xl:mt-2 xl:w-72');
expect(searchTipsPopoverModelSource).toContain('getSearchTipsPopoverTriggerClass');
expect(searchTipsPopoverModelSource).toContain('getSearchTipsPopoverPositionClass');
@@ -80,4 +83,70 @@ describe('SearchTipsPopover', () => {
fireEvent.keyDown(window, { key: 'Escape' });
expect(screen.queryByRole('dialog', { name: 'Search tips' })).toBeNull();
});
it('bounds landscape tips above the visible mobile navigation', async () => {
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(844);
vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(390);
render(() => (
<>
<nav aria-label="Mobile navigation" />
<SearchTipsPopover tips={[{ code: 'name:web', description: 'Filter by name' }]} />
</>
));
const nav = screen.getByRole('navigation', { name: 'Mobile navigation' });
vi.spyOn(nav, 'getBoundingClientRect').mockReturnValue(
DOMRect.fromRect({ y: 328.5, height: 61.5, width: 844 }),
);
const trigger = screen.getByRole('button', { name: 'Search tips' });
vi.spyOn(trigger, 'getBoundingClientRect').mockReturnValue(
DOMRect.fromRect({ x: 752, y: 150, width: 28, height: 20 }),
);
fireEvent.click(trigger);
const dialog = await screen.findByRole('dialog', { name: 'Search tips' });
vi.spyOn(dialog, 'getBoundingClientRect').mockReturnValue(
DOMRect.fromRect({ x: 492, y: 16, width: 288, height: 326 }),
);
window.dispatchEvent(new Event('resize'));
await waitFor(() => {
expect(dialog.style.position).toBe('fixed');
expect(dialog.style.getPropertyPriority('position')).toBe('important');
expect(dialog.style.top).toBe('16px');
expect(dialog.style.maxHeight).toBe('296.5px');
});
});
it('ignores the hidden mobile nav when bounding a short desktop window', async () => {
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1280);
vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(500);
render(() => (
<>
<nav aria-label="Mobile navigation" />
<SearchTipsPopover tips={[{ code: 'name:web', description: 'Filter by name' }]} />
</>
));
const nav = screen.getByRole('navigation', { name: 'Mobile navigation' });
vi.spyOn(nav, 'getBoundingClientRect').mockReturnValue(DOMRect.fromRect({ height: 0 }));
const trigger = screen.getByRole('button', { name: 'Search tips' });
vi.spyOn(trigger, 'getBoundingClientRect').mockReturnValue(
DOMRect.fromRect({ x: 1173, y: 200, width: 28, height: 20 }),
);
fireEvent.click(trigger);
const dialog = await screen.findByRole('dialog', { name: 'Search tips' });
vi.spyOn(dialog, 'getBoundingClientRect').mockReturnValue(
DOMRect.fromRect({ x: 913, y: 16, width: 288, height: 326 }),
);
window.dispatchEvent(new Event('resize'));
await waitFor(() => {
expect(dialog.style.position).toBe('fixed');
expect(dialog.style.maxHeight).toBe('468px');
});
});
});
@@ -13,15 +13,15 @@ const TRIGGER_BASE_CLASSES =
describe('searchTipsPopoverModel.branchcov0712c', () => {
describe('getSearchTipsPopoverPositionClass', () => {
it("returns 'left-0' when align === 'left' (truthy ternary arm)", () => {
expect(getSearchTipsPopoverPositionClass('left')).toBe('sm:left-0');
expect(getSearchTipsPopoverPositionClass('left')).toBe('xl:left-0');
});
it("returns 'right-0' when align === 'right' (falsy ternary arm)", () => {
expect(getSearchTipsPopoverPositionClass('right')).toBe('sm:right-0');
expect(getSearchTipsPopoverPositionClass('right')).toBe('xl:right-0');
});
it("returns 'right-0' when align is omitted (undefined -> falsy ternary arm)", () => {
expect(getSearchTipsPopoverPositionClass()).toBe('sm:right-0');
expect(getSearchTipsPopoverPositionClass()).toBe('xl:right-0');
});
it('returns the else-arm value for an unexpected align value (defensive)', () => {
@@ -29,7 +29,7 @@ describe('searchTipsPopoverModel.branchcov0712c', () => {
// wrong-typed value that slips past the union at runtime) must fall to
// the 'right-0' arm.
const bogus = 'center' as unknown as Parameters<typeof getSearchTipsPopoverPositionClass>[0];
expect(getSearchTipsPopoverPositionClass(bogus)).toBe('sm:right-0');
expect(getSearchTipsPopoverPositionClass(bogus)).toBe('xl:right-0');
});
});
@@ -18,7 +18,7 @@ export interface SearchTipsPopoverProps {
}
export function getSearchTipsPopoverPositionClass(align?: 'left' | 'right'): string {
return align === 'left' ? 'sm:left-0' : 'sm:right-0';
return align === 'left' ? 'xl:left-0' : 'xl:right-0';
}
export function getSearchTipsPopoverId(popoverId?: string): string {
@@ -30,7 +30,7 @@ export function useSearchTipsPopoverState(
const close = () => setOpen(false);
const updatePopoverPosition = () => {
if (!triggerRef || !popoverRef || window.innerWidth >= 640) {
if (!triggerRef || !popoverRef || (window.innerWidth >= 1280 && window.innerHeight >= 768)) {
setPopoverStyle(undefined);
return;
}
@@ -39,18 +39,29 @@ export function useSearchTipsPopoverState(
const triggerGap = 8;
const width = Math.min(288, window.innerWidth - viewportMargin * 2);
const triggerRect = triggerRef.getBoundingClientRect();
const popoverHeight = popoverRef.getBoundingClientRect().height;
const mobileNavRect = document
.querySelector<HTMLElement>('nav[aria-label="Mobile navigation"]')
?.getBoundingClientRect();
const mobileNavTop = mobileNavRect && mobileNavRect.height > 0 ? mobileNavRect.top : undefined;
const availableBottom = Math.min(
window.innerHeight - viewportMargin,
mobileNavTop == null ? Number.POSITIVE_INFINITY : mobileNavTop - viewportMargin,
);
const availableHeight = Math.max(96, availableBottom - viewportMargin);
const popoverHeight = Math.min(popoverRef.getBoundingClientRect().height, availableHeight);
const left = Math.min(
Math.max(triggerRect.right - width, viewportMargin),
window.innerWidth - viewportMargin - width,
);
const belowTop = triggerRect.bottom + triggerGap;
const top =
belowTop + popoverHeight <= window.innerHeight - viewportMargin
belowTop + popoverHeight <= availableBottom
? belowTop
: Math.max(viewportMargin, triggerRect.top - triggerGap - popoverHeight);
setPopoverStyle(`left:${left}px;top:${top}px;width:${width}px`);
setPopoverStyle(
`position:fixed !important;left:${left}px;top:${top}px;width:${width}px;max-height:${availableHeight}px`,
);
};
createEffect(() => {
+32
View File
@@ -381,6 +381,38 @@
scrollbar-gutter: stable;
}
/*
Filter utility panels must stay reachable in short windows as well as
narrow ones. While the bottom navigation is present they sit above it;
short desktop windows use the viewport edge, and roomy desktop windows
return to the trigger-anchored dropdown.
*/
.filter-bottom-nav-aware-panel {
position: fixed !important;
inset-inline: auto 1rem !important;
top: auto !important;
bottom: 1rem !important;
max-height: calc(100dvh - 2rem);
overflow-y: auto;
}
@media (max-width: 79.999rem) {
.filter-bottom-nav-aware-panel {
bottom: calc(5rem + env(safe-area-inset-bottom)) !important;
max-height: calc(100dvh - 6rem - env(safe-area-inset-bottom));
}
}
@media (min-width: 80rem) and (min-height: 48rem) {
.filter-bottom-nav-aware-panel {
position: absolute !important;
inset-inline: auto 0 !important;
top: calc(100% + 0.25rem) !important;
bottom: auto !important;
max-height: min(38rem, calc(100vh - 8rem));
}
}
.pulse-shell {
width: 100%;
max-width: var(--pulse-shell-max-width);