mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 10:35:51 +00:00
fix(ui): measure the mobile nav instead of guessing its height
The Assistant overlay and its backdrop reserved a hardcoded calc(5rem + env(safe-area-inset-bottom)) for a bar that measures 45px. At 375x812 the backdrop ended at y=732 while the bar starts at 767, leaving a 35px band that was neither dimmed nor click-blocked: page content showed through and stayed interactive outside an open modal. The bar's height is content-driven and already includes the safe-area inset via pb-safe, so no caller can hardcode it correctly. Five sites had tried: the Assistant panel, its backdrop, the star banner, and both halves of the .filter-bottom-nav-aware-panel rule, which also carried a derived 6rem. MobileNavBar now publishes its measured height as --pulse-mobile-nav-height and those five sites read it. Publishing happens in onMount rather than the ref, because Solid runs refs before the node is in the document where offsetHeight is 0; reading after mount forces layout and gets the real value on first paint. A ResizeObserver covers content-driven changes and a resize listener covers the xl breakpoint, where the bar swaps between its height and 0 by display alone. The declared :root value is only a pre-measurement fallback and deliberately under-estimates. Over-reserving is what produces the un-dimmed band; reserving slightly less is harmless because the opaque bar paints over it. Verified at 375x812 and 1280x800: backdrop and panel now both end at 767 against a bar top of 767, gap 0; the point 5px above the bar hit-tests to the Assistant panel rather than page content; the filter panel resolves to 45px on mobile; and the star banner's md:bottom-4 still wins at 16px. Adds the regression proofs the shape guard asks for, including an architecture test that fails if any runtime source reintroduces a literal bar height, and records the ownership boundary in the frontend-primitives and ai-runtime contracts.
This commit is contained in:
@@ -7659,3 +7659,13 @@ then owns deduplication and persistence. A submitted ID that identifies any
|
|||||||
real out-of-scope resource, an ambiguous name, a type mismatch, or a name that
|
real out-of-scope resource, an ambiguous name, a type mismatch, or a name that
|
||||||
does not resolve inside the lifecycle scope remains rejected. No fuzzy name or
|
does not resolve inside the lifecycle scope remains rejected. No fuzzy name or
|
||||||
ID matching is permitted at this boundary.
|
ID matching is permitted at this boundary.
|
||||||
|
|
||||||
|
The Assistant overlay clears the mobile navigation bar from the published bar
|
||||||
|
height rather than a literal of its own. In overlay layout both the panel in
|
||||||
|
`frontend-modern/src/components/AI/Chat/index.tsx` and its closing backdrop end
|
||||||
|
at `--pulse-mobile-nav-height`, so the dimmed, click-blocking backdrop reaches
|
||||||
|
the top edge of the bar with no gap. Reserving more than the bar's real height
|
||||||
|
leaves page content visible and interactive below the backdrop while the
|
||||||
|
Assistant is open, which is a modal-containment defect rather than a cosmetic
|
||||||
|
one. The bar owns that value; see the frontend-primitives contract. Proofs live
|
||||||
|
in `frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx`.
|
||||||
|
|||||||
@@ -6185,3 +6185,32 @@ alert surfaces use the same typed monitoring and lifecycle vocabulary and
|
|||||||
provider-ownership presentation helper. They must not create local alert mute,
|
provider-ownership presentation helper. They must not create local alert mute,
|
||||||
archive, or removal state. The menu remains keyboard reachable, uses ordinary
|
archive, or removal state. The menu remains keyboard reachable, uses ordinary
|
||||||
shared surface and border tokens, and retains usable controls at phone width.
|
shared surface and border tokens, and retains usable controls at phone width.
|
||||||
|
|
||||||
|
### The mobile navigation bar publishes its own height
|
||||||
|
|
||||||
|
`frontend-modern/src/components/shared/MobileNavBar.tsx` is the only owner of
|
||||||
|
the bottom navigation bar's height. That height is content-driven and already
|
||||||
|
includes the safe-area inset through `pb-safe`, so no consumer may derive it.
|
||||||
|
The bar measures itself after mount, publishes the result as the
|
||||||
|
`--pulse-mobile-nav-height` custom property on the document element, keeps it
|
||||||
|
current through a resize observer and a window resize listener, and removes it
|
||||||
|
when the bar unmounts. Measurement happens in `onMount` rather than in the
|
||||||
|
element ref, because a ref runs before the node is in the document, where the
|
||||||
|
measured height is zero.
|
||||||
|
|
||||||
|
Every surface that must sit on top of the bar reads that property: the
|
||||||
|
Assistant overlay panel and its backdrop, the global GitHub star banner, and
|
||||||
|
the `.filter-bottom-nav-aware-panel` rule in
|
||||||
|
`frontend-modern/src/index.css`. Consumers must not add
|
||||||
|
`env(safe-area-inset-bottom)` on top of the published value, and must not
|
||||||
|
reintroduce a literal bar height. The declared `:root` value is a
|
||||||
|
pre-measurement fallback only and deliberately under-estimates: reserving more
|
||||||
|
than the bar's real height leaves a band between an overlay's backdrop and the
|
||||||
|
bar that is neither dimmed nor click-blocked, while reserving slightly less is
|
||||||
|
covered by the opaque bar. Surfaces with their own placement at wider
|
||||||
|
viewports, such as the star banner's `md:bottom-4`, keep it.
|
||||||
|
|
||||||
|
Proofs live in `frontend-modern/src/components/shared/__tests__/MobileNavBar.test.tsx`,
|
||||||
|
`frontend-modern/src/components/__tests__/GitHubStarBanner.test.tsx`, and
|
||||||
|
`frontend-modern/src/__tests__/App.architecture.test.ts`, which fails if any
|
||||||
|
runtime source reintroduces a literal bar height.
|
||||||
|
|||||||
@@ -1,46 +1,46 @@
|
|||||||
{
|
{
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"base_sha": "4de1ede6030836d09c1e0a118c26c6413fd9f112",
|
"base_sha": "66c87fb6e8595af47c360573bb8efa51d46492d1",
|
||||||
"verified_at": "2026-08-19T18:37:46Z",
|
"verified_at": "2026-08-19T18:57:18Z",
|
||||||
"result": "passed",
|
"result": "passed",
|
||||||
"changed_paths": [
|
"changed_paths": [
|
||||||
"frontend-modern/src/components/Workloads/WorkloadsFilter.tsx",
|
"frontend-modern/src/components/AI/Chat/index.tsx",
|
||||||
"frontend-modern/src/components/Workloads/WorkloadsInventorySummary.tsx",
|
"frontend-modern/src/components/GitHubStarBanner.tsx",
|
||||||
"frontend-modern/src/components/Workloads/workloadsFilterModel.ts",
|
"frontend-modern/src/components/shared/MobileNavBar.tsx",
|
||||||
"frontend-modern/src/components/shared/FilterBar/FilterBar.tsx",
|
"frontend-modern/src/index.css"
|
||||||
"frontend-modern/src/components/shared/FilterBar/filterCatalog.ts",
|
|
||||||
"frontend-modern/src/components/shared/FilterButtonGroup.tsx",
|
|
||||||
"frontend-modern/src/components/shared/filterButtonGroupModel.ts",
|
|
||||||
"frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx",
|
|
||||||
"frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx"
|
|
||||||
],
|
],
|
||||||
"content_sha256": {
|
"content_sha256": {
|
||||||
"frontend-modern/src/components/Workloads/WorkloadsFilter.tsx": "05dc8ff16997ab1f7663a13b9df71ab8219607466933a46e5f2009f96ec80562",
|
"frontend-modern/src/components/AI/Chat/index.tsx": "67d3dc20ea1100c3047f27052c35aaa4b1e15a8d2ebeda1022fecae9c772835d",
|
||||||
"frontend-modern/src/components/Workloads/WorkloadsInventorySummary.tsx": "deleted",
|
"frontend-modern/src/components/GitHubStarBanner.tsx": "ea65775ad40dc08d54d6e08f6f3c736c545917c11e8e7fbddb71d856afc11d79",
|
||||||
"frontend-modern/src/components/Workloads/workloadsFilterModel.ts": "d2b4e7e74c81ba69e7f9c4003568108f02c0dc8bc9071555a1a46bddb4af3908",
|
"frontend-modern/src/components/shared/MobileNavBar.tsx": "16a6917b7fdc25d77bd9cd6760f132f08a0f13f833ede6d278237e37378549e6",
|
||||||
"frontend-modern/src/components/shared/FilterBar/FilterBar.tsx": "98f8573ace4b7bf5a8ad5bdf6b33e0b6a75ebaabff83f6cd947c233b5a8b9843",
|
"frontend-modern/src/index.css": "1b195e474bdf5cfa33af971d1a01ed6b7e7e58eb998d823616a292c0ecc6fb1f"
|
||||||
"frontend-modern/src/components/shared/FilterBar/filterCatalog.ts": "b3267ab57bcdf6ba7735168608772785a8445e54ccc2c6746c5ea9ff011f98c8",
|
|
||||||
"frontend-modern/src/components/shared/FilterButtonGroup.tsx": "7661c28b8abbc2c01c37a3e96175753df7276e8d3b67a1ae8f283f8563dc787d",
|
|
||||||
"frontend-modern/src/components/shared/filterButtonGroupModel.ts": "06576097404a56f66d6ae0ef23fc913dbff11ceb5833140d00df58ad9711fe41",
|
|
||||||
"frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx": "29d2892a9b16e926cdafc35372b2ba7f7f8fe38b8099f7bb51d92319a4995f09",
|
|
||||||
"frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx": "2fd5c8176ea82f7c39dfe1fab8c8500001fd48c37b0fa17118d9febf3853ff0f"
|
|
||||||
},
|
},
|
||||||
"routes": ["/proxmox/overview", "/vmware/overview"],
|
"routes": [
|
||||||
|
"/",
|
||||||
|
"/proxmox"
|
||||||
|
],
|
||||||
"viewports": [
|
"viewports": [
|
||||||
{ "width": 1440, "height": 900 },
|
{
|
||||||
{ "width": 390, "height": 844 }
|
"width": 375,
|
||||||
|
"height": 812
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"width": 1280,
|
||||||
|
"height": 800
|
||||||
|
}
|
||||||
],
|
],
|
||||||
"states": [
|
"states": [
|
||||||
"Proxmox type and status totals shown as centred 11px tabular numbers beside their 12px labels",
|
"authenticated mobile 375x812, bottom navigation mounted and measuring 45px, --pulse-mobile-nav-height published as 45px",
|
||||||
"Proxmox Nodes total and cluster/standalone topology attached to the existing table heading",
|
"authenticated mobile with the Assistant overlay open, backdrop and panel both ending at the bar's top edge",
|
||||||
"Inventory totals hidden from both filter controls and the Nodes heading through one shared preference, then restored",
|
"authenticated desktop 1280x800, bar display:none and the variable published as 0px",
|
||||||
"Selected Attention filter with inline count and clear-filter action",
|
"re-verified after rebase onto 66c87fb6e, which reworked the FilterBar and Proxmox estate surfaces on the same route"
|
||||||
"Phone filter collapsed and expanded at 390px with inline totals and no horizontal page overflow",
|
|
||||||
"vSphere workload status totals rendered through the same shared filter option-count contract"
|
|
||||||
],
|
],
|
||||||
"interactions": [
|
"interactions": [
|
||||||
"Opened View, selected Hide for Inventory totals, confirmed workload and node/topology totals disappeared immediately, then restored Show",
|
"opened the Assistant overlay and measured backdrop bottom 767 against bar top 767, gap 0px, previously a 35px un-dimmed clickable band",
|
||||||
"Expanded the phone Filters control and inspected both count-bearing segmented controls",
|
"hit-tested 5px above the bar and found the Assistant panel covering it rather than page content",
|
||||||
"Navigated from Proxmox to vSphere and confirmed shared workload totals beside provider-specific status labels"
|
"probed .filter-bottom-nav-aware-panel computed bottom, resolving to 45px on mobile from the shared variable",
|
||||||
|
"probed the star banner class combination at 800px and confirmed md:bottom-4 still wins at 16px",
|
||||||
|
"dispatched a resize event and confirmed the published value republishes from 0px to 45px",
|
||||||
|
"reloaded at 1280x800 and confirmed the bar is display:none with the variable published as 0px"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -845,3 +845,46 @@ describe('App architecture', () => {
|
|||||||
expect(appRuntimeStateSource).not.toContain('/api/license/entitlements');
|
expect(appRuntimeStateSource).not.toContain('/api/license/entitlements');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('mobile bottom navigation clearance', () => {
|
||||||
|
const NAV_HEIGHT_VARIABLE = '--pulse-mobile-nav-height';
|
||||||
|
// Matches the shape every drifted copy used, spaced or not. The lookbehind
|
||||||
|
// keeps the declared 2.5rem fallback from matching its own guard.
|
||||||
|
const HARDCODED_BAR_HEIGHT = /(?<![\d.])5rem\s*\+\s*env\(\s*safe-area-inset-bottom/;
|
||||||
|
|
||||||
|
function collectFrontendSources(dir: string): Array<{ path: string; source: string }> {
|
||||||
|
return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
|
||||||
|
const entryPath = join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
return collectFrontendSources(entryPath);
|
||||||
|
}
|
||||||
|
// Only runtime sources matter; a test may quote the old shape in order
|
||||||
|
// to assert against it, as this one does.
|
||||||
|
if (!/\.(css|ts|tsx)$/.test(entry.name)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
if (entryPath.includes('__tests__') || /\.(test|spec)\./.test(entry.name)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return [{ path: entryPath, source: readFileSync(entryPath, 'utf8') }];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it('declares the bar height as a single custom property', () => {
|
||||||
|
expect(appStylesSource).toContain(NAV_HEIGHT_VARIABLE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps every consumer reading that property instead of its own copy', () => {
|
||||||
|
// The Assistant panel, its backdrop, the star banner and both halves of
|
||||||
|
// the .filter-bottom-nav-aware-panel rule each carried their own
|
||||||
|
// calc(5rem + env(safe-area-inset-bottom)) for a bar that measures ~45px.
|
||||||
|
// The overshoot left a band below the Assistant backdrop that was neither
|
||||||
|
// dimmed nor click-blocked, so page content stayed interactive outside an
|
||||||
|
// open modal. Read the published height rather than adding a sixth copy.
|
||||||
|
const offenders = collectFrontendSources(join(process.cwd(), 'src'))
|
||||||
|
.filter(({ source }) => HARDCODED_BAR_HEIGHT.test(source))
|
||||||
|
.map(({ path }) => path.slice(process.cwd().length + 1));
|
||||||
|
|
||||||
|
expect(offenders).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -2003,12 +2003,12 @@ describe('AIChat', () => {
|
|||||||
const panel = screen.getByText('Pulse Assistant').closest('[data-layout-mode]');
|
const panel = screen.getByText('Pulse Assistant').closest('[data-layout-mode]');
|
||||||
expect(panel).toHaveAttribute('data-layout-mode', 'overlay');
|
expect(panel).toHaveAttribute('data-layout-mode', 'overlay');
|
||||||
expect(panel).toHaveClass('top-0');
|
expect(panel).toHaveClass('top-0');
|
||||||
expect(panel).toHaveClass('bottom-[calc(5rem+env(safe-area-inset-bottom,0px))]');
|
expect(panel).toHaveClass('bottom-[var(--pulse-mobile-nav-height)]');
|
||||||
expect(panel).toHaveClass('h-auto');
|
expect(panel).toHaveClass('h-auto');
|
||||||
expect(panel).not.toHaveClass('inset-y-0');
|
expect(panel).not.toHaveClass('inset-y-0');
|
||||||
expect(panel).not.toHaveClass('h-full');
|
expect(panel).not.toHaveClass('h-full');
|
||||||
const backdrop = screen.getByRole('button', { name: 'Close Pulse Assistant backdrop' });
|
const backdrop = screen.getByRole('button', { name: 'Close Pulse Assistant backdrop' });
|
||||||
expect(backdrop).toHaveClass('bottom-[calc(5rem+env(safe-area-inset-bottom,0px))]');
|
expect(backdrop).toHaveClass('bottom-[var(--pulse-mobile-nav-height)]');
|
||||||
expect(backdrop).not.toHaveClass('inset-0');
|
expect(backdrop).not.toHaveClass('inset-0');
|
||||||
expect(screen.queryByTitle('Collapse Pulse Assistant')).not.toBeInTheDocument();
|
expect(screen.queryByTitle('Collapse Pulse Assistant')).not.toBeInTheDocument();
|
||||||
expect(screen.getByLabelText('Close Pulse Assistant')).toBeInTheDocument();
|
expect(screen.getByLabelText('Close Pulse Assistant')).toBeInTheDocument();
|
||||||
|
|||||||
@@ -2265,7 +2265,7 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
|||||||
onCleanup(stopPanelResize);
|
onCleanup(stopPanelResize);
|
||||||
const rootClassName = createMemo(() => {
|
const rootClassName = createMemo(() => {
|
||||||
if (isOverlayLayout()) {
|
if (isOverlayLayout()) {
|
||||||
return `fixed top-0 right-0 bottom-[calc(5rem+env(safe-area-inset-bottom,0px))] z-50 flex h-auto w-full flex-col bg-surface transition-transform duration-300 sm:w-[560px] sm:max-w-[calc(100vw-1rem)] ${
|
return `fixed top-0 right-0 bottom-[var(--pulse-mobile-nav-height)] z-50 flex h-auto w-full flex-col bg-surface transition-transform duration-300 sm:w-[560px] sm:max-w-[calc(100vw-1rem)] ${
|
||||||
isOpen()
|
isOpen()
|
||||||
? 'translate-x-0 overflow-visible border-l border-border shadow-2xl'
|
? 'translate-x-0 overflow-visible border-l border-border shadow-2xl'
|
||||||
: 'translate-x-full overflow-hidden border-l-0'
|
: 'translate-x-full overflow-hidden border-l-0'
|
||||||
@@ -4248,7 +4248,7 @@ export const AIChat: Component<AIChatProps> = (props) => {
|
|||||||
<Show when={isOpen() && isOverlayLayout()}>
|
<Show when={isOpen() && isOverlayLayout()}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="fixed inset-x-0 top-0 bottom-[calc(5rem+env(safe-area-inset-bottom,0px))] z-40 bg-slate-950/45 backdrop-blur-[1px]"
|
class="fixed inset-x-0 top-0 bottom-[var(--pulse-mobile-nav-height)] z-40 bg-slate-950/45 backdrop-blur-[1px]"
|
||||||
onClick={props.onClose}
|
onClick={props.onClose}
|
||||||
aria-label="Close Pulse Assistant backdrop"
|
aria-label="Close Pulse Assistant backdrop"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ export function GitHubStarBanner() {
|
|||||||
return (
|
return (
|
||||||
<Show when={showModal()}>
|
<Show when={showModal()}>
|
||||||
<section
|
<section
|
||||||
class="fixed left-4 right-20 bottom-[calc(5rem+env(safe-area-inset-bottom,0px))] z-30 max-w-md overflow-hidden rounded-lg border border-border bg-surface text-base-content shadow-xl md:right-auto md:bottom-4"
|
class="fixed left-4 right-20 bottom-[var(--pulse-mobile-nav-height)] z-30 max-w-md overflow-hidden rounded-lg border border-border bg-surface text-base-content shadow-xl md:right-auto md:bottom-4"
|
||||||
aria-labelledby="github-star-title"
|
aria-labelledby="github-star-title"
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -360,3 +360,19 @@ describe('GitHubStarBanner', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('GitHubStarBanner mobile navigation clearance', () => {
|
||||||
|
it('reads the published bottom navigation height', () => {
|
||||||
|
expect(gitHubStarBannerSource).toContain('bottom-[var(--pulse-mobile-nav-height)]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not keep its own copy of the bar height', () => {
|
||||||
|
// This banner was one of five sites hardcoding 5rem for a bar that
|
||||||
|
// measures ~45px. The bar publishes its measured height instead.
|
||||||
|
expect(gitHubStarBannerSource).not.toMatch(/5rem\s*\+\s*env\(safe-area-inset-bottom/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps its own desktop placement once the bar is gone', () => {
|
||||||
|
expect(gitHubStarBannerSource).toContain('md:bottom-4');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { For, Show } from 'solid-js';
|
import { For, Show, onCleanup, onMount } from 'solid-js';
|
||||||
import ChevronsUpDownIcon from 'lucide-solid/icons/chevrons-up-down';
|
import ChevronsUpDownIcon from 'lucide-solid/icons/chevrons-up-down';
|
||||||
import EllipsisIcon from 'lucide-solid/icons/ellipsis';
|
import EllipsisIcon from 'lucide-solid/icons/ellipsis';
|
||||||
import { type MobileNavBarProps, useMobileNavBarState } from './useMobileNavBarState';
|
import { type MobileNavBarProps, useMobileNavBarState } from './useMobileNavBarState';
|
||||||
@@ -98,6 +98,45 @@ function MobileNavDestinationContent(props: {
|
|||||||
export function MobileNavBar(props: MobileNavBarProps) {
|
export function MobileNavBar(props: MobileNavBarProps) {
|
||||||
const mobileNav = useMobileNavBarState(props);
|
const mobileNav = useMobileNavBarState(props);
|
||||||
const tabIconClass = 'h-3.5 w-3.5 shrink-0';
|
const tabIconClass = 'h-3.5 w-3.5 shrink-0';
|
||||||
|
|
||||||
|
// This bar's height is content-driven and includes the safe-area inset via
|
||||||
|
// pb-safe, so anything that has to sit on top of it cannot hardcode a value.
|
||||||
|
// Five call sites did, all at 5rem against a bar that measures ~45px, which
|
||||||
|
// left a band below the Assistant backdrop that was neither dimmed nor
|
||||||
|
// click-blocked. Publish the measured height instead and let them read it.
|
||||||
|
// Reports 0 when the bar is hidden at xl, which is the correct clearance
|
||||||
|
// there.
|
||||||
|
let navElement: HTMLElement | undefined;
|
||||||
|
let navObserver: ResizeObserver | undefined;
|
||||||
|
const publishNavHeight = () => {
|
||||||
|
if (!navElement) return;
|
||||||
|
document.documentElement.style.setProperty(
|
||||||
|
'--pulse-mobile-nav-height',
|
||||||
|
`${navElement.offsetHeight}px`,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
// Publish from onMount, not from the ref: Solid runs refs before the node is
|
||||||
|
// in the document, where offsetHeight is 0. Reading it after mount forces
|
||||||
|
// layout and yields the real height, so the first paint is already correct
|
||||||
|
// without waiting for an observer callback.
|
||||||
|
onMount(() => {
|
||||||
|
publishNavHeight();
|
||||||
|
if (typeof ResizeObserver !== 'undefined' && navElement) {
|
||||||
|
navObserver = new ResizeObserver(publishNavHeight);
|
||||||
|
navObserver.observe(navElement);
|
||||||
|
}
|
||||||
|
// The observer covers content-driven height changes, but the bar is
|
||||||
|
// xl:hidden, so crossing that breakpoint swaps between its real height and
|
||||||
|
// 0 via display alone. Observing a display:none element is the least
|
||||||
|
// consistent case across engines, so listen for resize too rather than
|
||||||
|
// leaving a stale clearance behind after a rotation or window drag.
|
||||||
|
window.addEventListener('resize', publishNavHeight);
|
||||||
|
});
|
||||||
|
onCleanup(() => {
|
||||||
|
navObserver?.disconnect();
|
||||||
|
window.removeEventListener('resize', publishNavHeight);
|
||||||
|
document.documentElement.style.removeProperty('--pulse-mobile-nav-height');
|
||||||
|
});
|
||||||
const overflowHasBadge = () =>
|
const overflowHasBadge = () =>
|
||||||
mobileNav.overflowDestinations().some(mobileNavDestinationHasBadge);
|
mobileNav.overflowDestinations().some(mobileNavDestinationHasBadge);
|
||||||
|
|
||||||
@@ -148,6 +187,7 @@ export function MobileNavBar(props: MobileNavBarProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<nav
|
<nav
|
||||||
|
ref={(element) => (navElement = element)}
|
||||||
class="fixed inset-x-0 bottom-0 z-40 border-t border-border bg-surface pb-safe xl:hidden"
|
class="fixed inset-x-0 bottom-0 z-40 border-t border-border bg-surface pb-safe xl:hidden"
|
||||||
aria-label="Mobile navigation"
|
aria-label="Mobile navigation"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -291,3 +291,48 @@ describe('MobileNavBar', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('MobileNavBar clearance publishing', () => {
|
||||||
|
const NAV_HEIGHT_VARIABLE = '--pulse-mobile-nav-height';
|
||||||
|
|
||||||
|
const renderBar = () =>
|
||||||
|
render(() => (
|
||||||
|
<MobileNavBar
|
||||||
|
activeTab={() => 'proxmox'}
|
||||||
|
primaryTabs={() => [makePrimary('proxmox', 'Proxmox')]}
|
||||||
|
utilityTabs={() => [makeUtility('alerts', 'Alerts')]}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
|
||||||
|
it('publishes its measured height so overlays can clear it', () => {
|
||||||
|
const { unmount } = renderBar();
|
||||||
|
|
||||||
|
// Anything sitting on top of this bar reads the published value rather
|
||||||
|
// than hardcoding one. Five call sites once hardcoded 5rem against a bar
|
||||||
|
// that measures ~45px, leaving a band below the Assistant backdrop that
|
||||||
|
// was neither dimmed nor click-blocked.
|
||||||
|
expect(document.documentElement.style.getPropertyValue(NAV_HEIGHT_VARIABLE)).toMatch(
|
||||||
|
/^\d+(\.\d+)?px$/,
|
||||||
|
);
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stops publishing once the bar is gone', () => {
|
||||||
|
const { unmount } = renderBar();
|
||||||
|
expect(document.documentElement.style.getPropertyValue(NAV_HEIGHT_VARIABLE)).not.toBe('');
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
|
||||||
|
// A stale height would keep reserving space for a bar that is no longer
|
||||||
|
// rendered, so consumers must fall back to the declared default.
|
||||||
|
expect(document.documentElement.style.getPropertyValue(NAV_HEIGHT_VARIABLE)).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('measures after mount rather than from the ref', () => {
|
||||||
|
// Solid runs refs before the node is in the document, where offsetHeight
|
||||||
|
// is 0, so publishing from the ref reports a bar of no height.
|
||||||
|
expect(mobileNavBarSource).toContain('onMount(');
|
||||||
|
expect(mobileNavBarSource).not.toMatch(/ref=\{setNavRef\}/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -428,8 +428,8 @@
|
|||||||
|
|
||||||
@media (max-width: 79.999rem) {
|
@media (max-width: 79.999rem) {
|
||||||
.filter-bottom-nav-aware-panel {
|
.filter-bottom-nav-aware-panel {
|
||||||
bottom: calc(5rem + env(safe-area-inset-bottom)) !important;
|
bottom: var(--pulse-mobile-nav-height) !important;
|
||||||
max-height: calc(100dvh - 6rem - env(safe-area-inset-bottom));
|
max-height: calc(100dvh - var(--pulse-mobile-nav-height) - 1rem);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1159,6 +1159,21 @@ body,
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Height of the mobile bottom navigation bar. MobileNavBar overwrites this at
|
||||||
|
runtime with its measured height, which already includes the safe-area inset
|
||||||
|
via pb-safe - consumers must NOT add env(safe-area-inset-bottom) on top.
|
||||||
|
|
||||||
|
The declared value is only a pre-measurement fallback, and it deliberately
|
||||||
|
UNDER-estimates. Reserving more than the bar's real height leaves a band
|
||||||
|
between an overlay's backdrop and the bar that is neither dimmed nor
|
||||||
|
click-blocked, which is the defect this variable exists to prevent; reserving
|
||||||
|
slightly less is harmless because the opaque bar paints over it.
|
||||||
|
*/
|
||||||
|
:root {
|
||||||
|
--pulse-mobile-nav-height: calc(2.5rem + env(safe-area-inset-bottom, 0px));
|
||||||
|
}
|
||||||
|
|
||||||
.pb-safe {
|
.pb-safe {
|
||||||
padding-bottom: env(safe-area-inset-bottom, 0px);
|
padding-bottom: env(safe-area-inset-bottom, 0px);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user