Centre the offline banner over the content column

This commit is contained in:
2026-08-22 12:09:18 +02:00
parent 09e2e5e133
commit 3674099758
2 changed files with 25 additions and 25 deletions
@@ -1,7 +1,6 @@
import { WifiOff } from "lucide-react" import { WifiOff } from "lucide-react"
import { AnimatePresence, motion } from "motion/react" import { AnimatePresence, motion } from "motion/react"
import { useSyncExternalStore } from "react" import { useSyncExternalStore } from "react"
import { createPortal } from "react-dom"
import { ago } from "@/components/Health/queries" import { ago } from "@/components/Health/queries"
import { connectionStore } from "@/lib/connectionStore" import { connectionStore } from "@/lib/connectionStore"
@@ -17,10 +16,11 @@ import { isPortal } from "@/lib/portal"
* stale readings with a timestamp are more use than an empty page, which is * stale readings with a timestamp are more use than an empty page, which is
* why the banner leads with when we last heard anything. * why the banner leads with when we last heard anything.
* *
* Drawn on `document.body` rather than where it is mounted. `position: fixed` * Centred over the content column rather than the window: it belongs to what
* resolves against the nearest ancestor carrying a transform, a filter or a * is being read, not to the shell around it. That is what `sticky` inside the
* `backdrop-filter`, and the shell floats several of those — inside them the * content column buys — no sidebar width to track and nothing to recompute
* banner centres on the content column instead of the viewport. * when the sidebar collapses — and `h-0` keeps it out of the flow, so nothing
* below it moves when the banner appears.
*/ */
export function ConnectionBanner() { export function ConnectionBanner() {
const connection = useSyncExternalStore( const connection = useSyncExternalStore(
@@ -30,7 +30,8 @@ export function ConnectionBanner() {
) )
if (!isPortal()) return null if (!isPortal()) return null
return createPortal( return (
<div className="pointer-events-none sticky top-4 z-50 flex h-0 justify-center px-4">
<AnimatePresence> <AnimatePresence>
{connection.offline && ( {connection.offline && (
<motion.div <motion.div
@@ -40,9 +41,8 @@ export function ConnectionBanner() {
exit="hidden" exit="hidden"
role="status" role="status"
aria-live="polite" aria-live="polite"
className="pointer-events-none fixed inset-x-0 top-4 z-50 flex justify-center px-4" className="flex items-center gap-2 rounded-full border border-border bg-card/80 px-4 py-2 text-sm shadow-e2 backdrop-blur-md"
> >
<div className="flex items-center gap-2 rounded-full border border-border bg-card/80 px-4 py-2 text-sm shadow-e2 backdrop-blur-md">
<WifiOff className="size-4 shrink-0 text-muted-foreground" /> <WifiOff className="size-4 shrink-0 text-muted-foreground" />
<span className="font-medium">Installation offline</span> <span className="font-medium">Installation offline</span>
<span className="text-muted-foreground"> <span className="text-muted-foreground">
@@ -50,10 +50,9 @@ export function ConnectionBanner() {
? `last seen ${ago(connection.lastSeen / 1000)} — reconnecting…` ? `last seen ${ago(connection.lastSeen / 1000)} — reconnecting…`
: "reconnecting…"} : "reconnecting…"}
</span> </span>
</div>
</motion.div> </motion.div>
)} )}
</AnimatePresence>, </AnimatePresence>
document.body, </div>
) )
} }
+3 -2
View File
@@ -29,10 +29,11 @@ function Layout() {
return ( return (
<SidebarProvider className="bg-card"> <SidebarProvider className="bg-card">
{/* Renders nothing unless this page is served through a portal. */}
<ConnectionBanner />
<AppSidebar /> <AppSidebar />
<SidebarInset className="bg-card"> <SidebarInset className="bg-card">
{/* Renders nothing unless this page is served through a portal.
Inside the inset so it centres over the content, not the window. */}
<ConnectionBanner />
{/* The sidebar carries its own collapse control; a phone has no {/* The sidebar carries its own collapse control; a phone has no
sidebar on screen to carry it. */} sidebar on screen to carry it. */}
<header className="sticky top-0 z-10 flex h-16 shrink-0 items-center gap-2 px-4 md:hidden"> <header className="sticky top-0 z-10 flex h-16 shrink-0 items-center gap-2 px-4 md:hidden">