refactor(layout): single-tree responsive shell (#26)
Pre-fix the dashboard layout mounted BOTH the desktop and mobile shells
to the DOM on every page, hidden via CSS data-shell rules. Two Tabs
providers had data-state="active" concurrently, every fetch fired twice,
every component piece of state lived in two trees, a11y landmarks
duplicated, and half the click attempts hit the wrong layer.
New <AppShell> client wrapper mounts exactly ONE tree based on the
server-classified User-Agent (no hydration mismatch, no first-paint
flash on real mobile devices) plus a runtime matchMedia subscription
that swaps shells when the viewport crosses 1024px (e.g. desktop
browser resized).
Knock-on changes:
- Dashboard layout fetches once and hands the data to AppShell;
AppShell picks Desktop (Sidebar + Topbar + main) or MobileLayout
- Stripped the now-orphan data-shell CSS rules from globals.css —
nothing emits the attribute any more
- MobileLayout drops its data-shell="mobile" attribute (was the lever
the dead CSS rules pulled)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
71
src/components/layout/app-shell.tsx
Normal file
71
src/components/layout/app-shell.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, type ComponentProps, type ReactNode } from 'react';
|
||||
|
||||
import { Sidebar } from '@/components/layout/sidebar';
|
||||
import { Topbar } from '@/components/layout/topbar';
|
||||
import { MobileLayout } from '@/components/layout/mobile/mobile-layout';
|
||||
|
||||
type SidebarProps = ComponentProps<typeof Sidebar>;
|
||||
type TopbarProps = ComponentProps<typeof Topbar>;
|
||||
|
||||
interface AppShellProps {
|
||||
portRoles: SidebarProps['portRoles'];
|
||||
isSuperAdmin: boolean;
|
||||
user: NonNullable<SidebarProps['user']>;
|
||||
ports: TopbarProps['ports'];
|
||||
/**
|
||||
* Server-rendered form-factor hint (from the request User-Agent). The
|
||||
* shell mounts the matching tree on first render so we never paint the
|
||||
* wrong shell, and only switches if the runtime viewport matchMedia
|
||||
* disagrees (e.g. desktop browser resized below lg).
|
||||
*/
|
||||
initialFormFactor: 'mobile' | 'desktop';
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const MOBILE_QUERY = '(max-width: 1023.98px)';
|
||||
|
||||
/**
|
||||
* #26: single-tree responsive shell. Pre-fix the layout mounted BOTH
|
||||
* desktop and mobile shells in the DOM and CSS-hid one — doubling React
|
||||
* state, fetches, Tabs providers, and a11y landmarks. AppShell decides
|
||||
* once per render which tree to mount, so a page only ever runs the
|
||||
* effects + queries it actually displays.
|
||||
*
|
||||
* SSR safety: the server passes its UA-classified hint via `initialFormFactor`;
|
||||
* the first client render uses the same value so hydration matches. After
|
||||
* mount, a matchMedia subscription overrides if the viewport disagrees.
|
||||
*/
|
||||
export function AppShell({
|
||||
portRoles,
|
||||
isSuperAdmin,
|
||||
user,
|
||||
ports,
|
||||
initialFormFactor,
|
||||
children,
|
||||
}: AppShellProps) {
|
||||
const [isMobile, setIsMobile] = useState(initialFormFactor === 'mobile');
|
||||
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia(MOBILE_QUERY);
|
||||
const update = () => setIsMobile(mq.matches);
|
||||
update();
|
||||
mq.addEventListener('change', update);
|
||||
return () => mq.removeEventListener('change', update);
|
||||
}, []);
|
||||
|
||||
if (isMobile) {
|
||||
return <MobileLayout>{children}</MobileLayout>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden bg-background">
|
||||
<Sidebar portRoles={portRoles} isSuperAdmin={isSuperAdmin} user={user} ports={ports} />
|
||||
<div className="flex-1 flex flex-col overflow-hidden min-w-0">
|
||||
<Topbar ports={ports} user={user} />
|
||||
<main className="flex-1 overflow-y-auto bg-background px-6 pt-3 pb-6">{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user