Merge pull request #213 from okieselbach/fix/view-as-bar-layout

View as: keep the navbar and sidebar clear of the view-as bar
This commit is contained in:
Oliver Kieselbach
2026-11-06 18:47:05 +01:00
committed by GitHub
7 changed files with 48 additions and 17 deletions
@@ -22,6 +22,22 @@ html {
drawer + overlay) keep sizing against the viewport. */
overflow-x: clip;
scrollbar-gutter: stable;
/* Anchor jumps and scrollIntoView stop below the view-as bar as well (see the next block). */
scroll-padding-top: var(--view-as-offset);
}
/* ===== View-as bar (components/ViewAsBar.tsx) =====
While a tab views a tenant as its member, or says once why that was refused, the bar sticks above the
navbar at a fixed height. Everything positioned against the top of the viewport adds --view-as-offset —
the navbar's sticky top, the desktop sidebar, sticky content, anchor scrolling — so nothing slides under
the bar: the offset is the bar's height while it is shown and 0 otherwise. Offsets computed in JS read
the bar's height through viewAsBarHeight() (lib/viewAs.ts). */
:root {
--view-as-bar-height: 2.25rem;
--view-as-offset: 0px;
}
:root:has([data-view-as-bar]) {
--view-as-offset: var(--view-as-bar-height);
}
:root {
@@ -12,7 +12,7 @@ const ITEMS = [
export function PushNav() {
const pathname = (usePathname() ?? "/push").replace(/\/$/, "") || "/push";
return (
<header className="bg-white border-b border-gray-200 sticky top-0 z-10">
<header className="bg-white border-b border-gray-200 sticky top-[var(--view-as-offset)] z-10">
<div className="max-w-md mx-auto px-4 h-12 flex items-center justify-between">
<Link href="/push" className="flex items-center gap-2 min-w-0">
<BrandMark className="w-5 h-5 shrink-0" />
@@ -13,6 +13,7 @@ import { useAdminMode } from "../hooks/useAdminMode";
import { useMediaQuery } from "../hooks/useMediaQuery";
import { DOCS_URL } from "@/lib/config";
import { isPushAppPath } from "@/lib/push/pushAppPath";
import { viewAsBarHeight } from "@/lib/viewAs";
// Sidebar pixel widths
export const SIDEBAR_PX: Record<CollapseState, number> = {
@@ -75,7 +76,8 @@ export function GlobalSidebar({ children }: { children: ReactNode }) {
}
}
},
{ rootMargin: "-80px 0px -60% 0px", threshold: 0 },
// The top 80 px are the navbar plus a margin; a view-as bar above the navbar adds its own height.
{ rootMargin: `-${80 + viewAsBarHeight()}px 0px -60% 0px`, threshold: 0 },
);
observerRef.current = observer;
@@ -104,7 +106,8 @@ export function GlobalSidebar({ children }: { children: ReactNode }) {
const scrollTo = useCallback((id: string) => {
const el = document.getElementById(id);
if (el) {
const y = el.getBoundingClientRect().top + window.scrollY - 90;
// 90 px keep the section clear of the navbar; a view-as bar above the navbar adds its own height.
const y = el.getBoundingClientRect().top + window.scrollY - 90 - viewAsBarHeight();
window.scrollTo({ top: y, behavior: "smooth" });
}
setActiveSectionId(id);
@@ -726,7 +729,7 @@ export function GlobalSidebar({ children }: { children: ReactNode }) {
{/* ===== Desktop: fixed sidebar ===== */}
<aside
className={`hidden md:flex fixed left-0 ${hasNavbar ? "top-14" : "top-0"} bottom-0 z-20 flex-col transition-all duration-200 ease-in-out overflow-hidden ${sidebarWidthClass[collapseState]}`}
className={`hidden md:flex fixed left-0 ${hasNavbar ? "top-[calc(3.5rem+var(--view-as-offset))]" : "top-[var(--view-as-offset)]"} bottom-0 z-20 flex-col transition-all duration-200 ease-in-out overflow-hidden ${sidebarWidthClass[collapseState]}`}
>
{collapseState !== "hidden" && (
<nav className="flex flex-col h-full bg-white border-r border-gray-200 dark:bg-gray-800 dark:border-gray-700">
@@ -144,7 +144,7 @@ export default function Navbar() {
// scope → gets the full navbar.
if (!isAdminOrOperator && !isViewer && !hasGlobalScope) {
return (
<nav className="bg-white border-b border-gray-200 shadow-sm sticky top-0 z-30">
<nav className="bg-white border-b border-gray-200 shadow-sm sticky top-[var(--view-as-offset)] z-30">
<div className="px-3">
<div className="flex justify-between h-14">
<div className="flex items-center">
@@ -193,7 +193,7 @@ export default function Navbar() {
}
return (
<nav className="bg-white border-b border-gray-200 shadow-sm sticky top-0 z-30">
<nav className="bg-white border-b border-gray-200 shadow-sm sticky top-[var(--view-as-offset)] z-30">
<div className="px-3">
<div className="flex justify-between h-14">
{/* Logo and Title */}
@@ -272,7 +272,7 @@ export default function Navbar() {
(!!user?.isGlobalAdmin && visibleGlobal.length > 0);
return (
<div className="fixed sm:absolute top-16 sm:top-auto left-2 right-2 sm:left-auto sm:right-0 mt-0 sm:mt-2 sm:w-96 bg-white rounded-lg shadow-lg border border-gray-200 z-50 max-h-[calc(100vh-5rem)] sm:max-h-96 overflow-hidden flex flex-col">
<div className="fixed sm:absolute top-[calc(4rem+var(--view-as-offset))] sm:top-auto left-2 right-2 sm:left-auto sm:right-0 mt-0 sm:mt-2 sm:w-96 bg-white rounded-lg shadow-lg border border-gray-200 z-50 max-h-[calc(100vh-5rem-var(--view-as-offset))] sm:max-h-96 overflow-hidden flex flex-col">
<div className="px-4 py-3 border-b border-gray-200 flex justify-between items-center">
<h3 className="text-sm font-semibold text-gray-900">Notifications</h3>
{(unreadCount > 0 || hasClearable) && (
@@ -10,7 +10,11 @@ const refusedNotice = typeof window === "undefined" ? null : takeViewAsNotice();
const getRefusedNotice = () => refusedNotice;
const noValue = () => null;
const BAR_CLASS = "bg-purple-700 text-white text-sm px-4 py-2 flex flex-wrap items-center justify-center gap-x-2 gap-y-1";
// One line at a fixed height, sticky above the navbar. While an element carrying data-view-as-bar is shown,
// globals.css moves everything positioned against the top of the viewport down by exactly that height, so the
// bar never wraps: a long text truncates instead.
const BAR_CLASS =
"sticky top-0 z-30 h-[var(--view-as-bar-height)] overflow-hidden whitespace-nowrap bg-purple-700 text-white text-sm px-4 flex items-center justify-center gap-x-2";
/**
* The one deliberate difference of a view-as tab from the customer's own portal: a slim bar that names the
@@ -28,17 +32,17 @@ export function ViewAsBar() {
// The backend's name for the tenant once auth/me answered; the id until then (or when it refused).
const tenantName = user?.viewAs?.tenantName ?? selection.tenantId;
return (
<div className={BAR_CLASS} role="status">
<div data-view-as-bar className={BAR_CLASS} role="status">
<svg className="w-4 h-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
<span className="font-medium">Viewing as {tenantName}</span>
<span className="text-purple-300">&mdash; {selection.role}, read-only</span>
<span className="min-w-0 truncate font-medium" title={tenantName}>Viewing as {tenantName}</span>
<span className="flex-shrink-0 text-purple-300">&mdash; {selection.role}, read-only</span>
<button
type="button"
onClick={exitViewAs}
className="ml-2 rounded border border-purple-300 px-2 py-0.5 text-xs font-medium hover:bg-purple-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-white"
className="ml-2 flex-shrink-0 rounded border border-purple-300 px-2 py-0.5 text-xs font-medium hover:bg-purple-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-white"
>
Exit
</button>
@@ -48,14 +52,14 @@ export function ViewAsBar() {
if (notice && !noticeDismissed) {
return (
<div className={BAR_CLASS} role="alert">
<span className="font-medium">View as ended</span>
<span className="text-purple-300">&mdash; {notice}</span>
<div data-view-as-bar className={BAR_CLASS} role="alert">
<span className="flex-shrink-0 font-medium">View as ended</span>
<span className="min-w-0 truncate text-purple-300" title={notice}>&mdash; {notice}</span>
<button
type="button"
onClick={() => setNoticeDismissed(true)}
aria-label="Dismiss"
className="ml-2 rounded px-1.5 text-purple-200 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-white"
className="ml-2 flex-shrink-0 rounded px-1.5 text-purple-200 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-white"
>
&times;
</button>
@@ -41,7 +41,7 @@ export default function SeverityPriorityCard({
return (
<section
aria-label="Severity and remediation priority"
className="bg-white rounded-lg shadow lg:col-span-2 lg:sticky lg:top-20"
className="bg-white rounded-lg shadow lg:col-span-2 lg:sticky lg:top-[calc(5rem+var(--view-as-offset))]"
>
<div className="p-5">
<h3 className="text-base font-semibold text-gray-900">Exposure by severity</h3>
@@ -88,6 +88,14 @@ export function viewAsHeaders(): Record<string, string> {
return selection ? { "X-View-As-Tenant": selection.tenantId, "X-View-As-Role": selection.role } : {};
}
/**
* How far the view-as bar (components/ViewAsBar.tsx) pushes the navbar down right now, in px; 0 while none is
* shown. CSS offsets add --view-as-offset (globals.css); offsets computed in JS add this.
*/
export function viewAsBarHeight(): number {
return document.querySelector<HTMLElement>("[data-view-as-bar]")?.offsetHeight ?? 0;
}
function clearViewAs(): void {
cached = null;
try {