feat(layout): add tablet viewport tier (mobile/tablet/desktop)
Previously the app used a binary matchMedia split at 1023.98px, so iPad portrait + half-screen-on-13"-Mac both fell into the mobile shell — neither is really mobile. The tablet tier fills that gap. - `use-is-mobile.ts` gains `useViewportTier()` returning 'mobile' | 'tablet' | 'desktop' (mobile < 768, tablet 768-1023, desktop ≥ 1024). Backed by useSyncExternalStore so render reads stay pure. `useIsMobile()` retained as a back-compat alias = `tier !== 'desktop'` so existing call sites don't have to change in lockstep. - `app-shell.tsx` now renders three branches. Mobile + desktop unchanged. Tablet renders the desktop shell, but the Sidebar lives inside a left-side `<Sheet>` opened by a new leading logo button in the Topbar. SheetContent width matches `--width-sidebar` so the open state reads consistent. Children subtree position stays invariant across tier flips so inline-edit drafts survive a resize. - `topbar.tsx` accepts an optional `leadingSlot` rendered before the back button + breadcrumbs in the LEFT column. AppShell mounts a port-logo button in that slot on tablet (or a three-bar menu icon when the port has no logo yet) that triggers the sheet. - `page-header.tsx` was the dashboard "title card looks bad on tablet" surface — the actions row was forced no-wrap at sm (640px) which crushed the title on iPad-portrait. Stack point moved from sm to lg, so tablet stacks vertically (title above, actions below); desktop returns to side-by-side. tsc clean, 1454/1454 vitest pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,8 @@ import { MobileTopbar } from '@/components/layout/mobile/mobile-topbar';
|
||||
import { MobileBottomTabs } from '@/components/layout/mobile/mobile-bottom-tabs';
|
||||
import { MoreSheet } from '@/components/layout/mobile/more-sheet';
|
||||
import { MobileSearchOverlay } from '@/components/search/mobile-search-overlay';
|
||||
import { Sheet, SheetContent } from '@/components/ui/sheet';
|
||||
import { useUIStore } from '@/stores/ui-store';
|
||||
|
||||
type SidebarProps = ComponentProps<typeof Sidebar>;
|
||||
type TopbarProps = ComponentProps<typeof Topbar>;
|
||||
@@ -32,30 +34,51 @@ interface AppShellProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const MOBILE_QUERY = '(max-width: 1023.98px)';
|
||||
// Three tiers, aligned with Tailwind and the `useViewportTier` hook:
|
||||
// mobile : < 768px (sm and below) → mobile shell (bottom tabs, slide-over more sheet)
|
||||
// tablet : 768-1023 (md) → desktop shell, sidebar wrapped in Sheet, logo trigger in topbar
|
||||
// desktop : >= 1024 (lg and up) → desktop shell, sidebar always visible
|
||||
//
|
||||
// Previously the app used a binary `(max-width: 1023.98px)` split, which
|
||||
// rendered the mobile shell on iPad portrait AND on a half-screen 13"
|
||||
// browser. Neither is really "mobile"; the tablet tier fills that gap.
|
||||
const MOBILE_QUERY = '(max-width: 767.98px)';
|
||||
const TABLET_QUERY = '(min-width: 768px) and (max-width: 1023.98px)';
|
||||
|
||||
type Tier = 'mobile' | 'tablet' | 'desktop';
|
||||
|
||||
function computeInitialTier(initialFormFactor: 'mobile' | 'desktop'): Tier {
|
||||
// Server UA classification is binary; we can only distinguish "looks
|
||||
// like a phone/tablet UA" from "looks like a desktop UA." Tablets get
|
||||
// classified as 'mobile' by the UA token check (iPad/Android), so
|
||||
// initialFormFactor=mobile on first paint covers both mobile + tablet.
|
||||
// The matchMedia subscription below corrects to the precise tier on
|
||||
// hydration without a flash because useSyncExternalStore is mismatch-safe.
|
||||
return initialFormFactor === 'mobile' ? 'mobile' : 'desktop';
|
||||
}
|
||||
|
||||
/**
|
||||
* #26 + H-09: single-tree responsive shell with stable children subtree.
|
||||
*
|
||||
* The shell renders ONE `<main>` and ONE `<MobileLayoutProvider>` at all
|
||||
* viewports — only the chrome (sidebar+topbar vs mobile-topbar+bottom-tabs)
|
||||
* conditionally renders. Two payoffs:
|
||||
* viewports — only the chrome (sidebar+topbar vs mobile-topbar+bottom-tabs
|
||||
* vs tablet's hidden-sidebar-via-Sheet) conditionally renders. Three payoffs:
|
||||
*
|
||||
* - #26 / first ship: no double-mount of chrome subtrees (Sidebar +
|
||||
* MobileTopbar both running fetches / providers in parallel like the
|
||||
* old layout did).
|
||||
* - #26 / first ship: no double-mount of chrome subtrees.
|
||||
* - H-09: `{children}` stays mounted across viewport flips. A rep
|
||||
* editing an inline field on desktop who resizes through the mobile
|
||||
* breakpoint no longer loses the draft mid-edit — the children tree's
|
||||
* position in the DOM is invariant, so React preserves its state.
|
||||
* breakpoint no longer loses the draft mid-edit.
|
||||
* - Tier-aware sidebar: tablet width gets the desktop shell with
|
||||
* sidebar hidden behind a Sheet (slide-over from left, opened by
|
||||
* the topbar logo button) instead of falling all the way back to
|
||||
* the mobile shell. Closes the half-screen-on-13"-Mac usability gap.
|
||||
*
|
||||
* The mobile-only floating panels (MoreSheet, MobileSearchOverlay) only
|
||||
* mount in the mobile branch — they have no desktop counterpart and would
|
||||
* be wasteful to keep mounted otherwise.
|
||||
* mount in the mobile branch — they have no desktop counterpart.
|
||||
*
|
||||
* 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.
|
||||
* mount, a matchMedia subscription overrides to the precise tier.
|
||||
*/
|
||||
export function AppShell({
|
||||
portRoles,
|
||||
@@ -66,35 +89,69 @@ export function AppShell({
|
||||
initialFormFactor,
|
||||
children,
|
||||
}: AppShellProps) {
|
||||
const [isMobile, setIsMobile] = useState(initialFormFactor === 'mobile');
|
||||
const [tier, setTier] = useState<Tier>(computeInitialTier(initialFormFactor));
|
||||
const [moreOpen, setMoreOpen] = useState(false);
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const [tabletSidebarOpen, setTabletSidebarOpen] = useState(false);
|
||||
const currentPortSlug = useUIStore((s) => s.currentPortSlug);
|
||||
const logoUrl = currentPortSlug ? portLogoUrls[currentPortSlug] : null;
|
||||
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia(MOBILE_QUERY);
|
||||
const update = () => setIsMobile(mq.matches);
|
||||
const mqMobile = window.matchMedia(MOBILE_QUERY);
|
||||
const mqTablet = window.matchMedia(TABLET_QUERY);
|
||||
const update = () => {
|
||||
if (mqMobile.matches) setTier('mobile');
|
||||
else if (mqTablet.matches) setTier('tablet');
|
||||
else setTier('desktop');
|
||||
};
|
||||
update();
|
||||
mq.addEventListener('change', update);
|
||||
return () => mq.removeEventListener('change', update);
|
||||
mqMobile.addEventListener('change', update);
|
||||
mqTablet.addEventListener('change', update);
|
||||
return () => {
|
||||
mqMobile.removeEventListener('change', update);
|
||||
mqTablet.removeEventListener('change', update);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Build the chrome subtree based on form factor; the children's parent
|
||||
// chain (MobileLayoutProvider > div > main) is invariant across both
|
||||
// branches, so React reconciliation keeps the children subtree mounted
|
||||
// when isMobile flips.
|
||||
const chrome = isMobile ? (
|
||||
<>
|
||||
<MobileTopbar />
|
||||
</>
|
||||
) : (
|
||||
<Sidebar
|
||||
portRoles={portRoles}
|
||||
isSuperAdmin={isSuperAdmin}
|
||||
user={user}
|
||||
ports={ports}
|
||||
portLogoUrls={portLogoUrls}
|
||||
/>
|
||||
);
|
||||
const isMobile = tier === 'mobile';
|
||||
const isTablet = tier === 'tablet';
|
||||
|
||||
// Close the tablet sheet when crossing breakpoints so it doesn't stay
|
||||
// "open" after a resize back to desktop (Sheet keeps its open prop).
|
||||
useEffect(() => {
|
||||
if (!isTablet && tabletSidebarOpen) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setTabletSidebarOpen(false);
|
||||
}
|
||||
}, [isTablet, tabletSidebarOpen]);
|
||||
|
||||
const sidebarProps: SidebarProps = {
|
||||
portRoles,
|
||||
isSuperAdmin,
|
||||
user,
|
||||
ports,
|
||||
portLogoUrls,
|
||||
};
|
||||
|
||||
// Chrome subtree per tier.
|
||||
let chrome: ReactNode = null;
|
||||
if (isMobile) {
|
||||
chrome = <MobileTopbar />;
|
||||
} else if (isTablet) {
|
||||
// Tablet: sidebar lives inside a left-side Sheet, opened by the
|
||||
// topbar's leading logo button. SheetContent has its own width;
|
||||
// override to match the inline Sidebar's width so the layout
|
||||
// reads consistent when the sheet is open.
|
||||
chrome = (
|
||||
<Sheet open={tabletSidebarOpen} onOpenChange={setTabletSidebarOpen}>
|
||||
<SheetContent side="left" className="p-0 w-[var(--width-sidebar)]">
|
||||
<Sidebar {...sidebarProps} />
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
} else {
|
||||
chrome = <Sidebar {...sidebarProps} />;
|
||||
}
|
||||
|
||||
const footer = isMobile ? (
|
||||
<>
|
||||
@@ -107,7 +164,46 @@ export function AppShell({
|
||||
</>
|
||||
) : null;
|
||||
|
||||
const desktopTopbar = !isMobile ? <Topbar ports={ports} user={user} /> : null;
|
||||
// Desktop topbar; on tablet it gains a leading logo button that
|
||||
// opens the sidebar Sheet.
|
||||
const desktopTopbar = !isMobile ? (
|
||||
<Topbar
|
||||
ports={ports}
|
||||
user={user}
|
||||
leadingSlot={
|
||||
isTablet ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Open menu"
|
||||
onClick={() => setTabletSidebarOpen(true)}
|
||||
className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-md hover:bg-accent transition-colors"
|
||||
>
|
||||
{logoUrl ? (
|
||||
<img src={logoUrl} alt="" className="h-6 w-6 object-contain" />
|
||||
) : (
|
||||
// Neutral fallback when the port has no branding logo yet —
|
||||
// a three-bar menu icon keeps the affordance discoverable.
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="h-4 w-4"
|
||||
aria-hidden
|
||||
>
|
||||
<line x1="3" y1="6" x2="21" y2="6" />
|
||||
<line x1="3" y1="12" x2="21" y2="12" />
|
||||
<line x1="3" y1="18" x2="21" y2="18" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<MobileLayoutProvider>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { ChevronLeft, Plus } from 'lucide-react';
|
||||
import { useRouter, usePathname } from 'next/navigation';
|
||||
import type { Route } from 'next';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { useUIStore } from '@/stores/ui-store';
|
||||
import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider';
|
||||
@@ -27,9 +28,13 @@ import type { Port } from '@/lib/db/schema/ports';
|
||||
interface TopbarProps {
|
||||
ports: Port[];
|
||||
user?: { name: string; email: string };
|
||||
/** Optional leading slot rendered before the breadcrumbs on tablet
|
||||
* viewports — used by AppShell to mount a sidebar trigger button
|
||||
* (logo) when the sidebar is hidden behind a slide-over Sheet. */
|
||||
leadingSlot?: ReactNode;
|
||||
}
|
||||
|
||||
export function Topbar({ ports, user }: TopbarProps) {
|
||||
export function Topbar({ ports, user, leadingSlot }: TopbarProps) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const currentPortSlug = useUIStore((s) => s.currentPortSlug);
|
||||
@@ -55,8 +60,9 @@ export function Topbar({ ports, user }: TopbarProps) {
|
||||
// The brand logo lives in the sidebar header (per design feedback) so the
|
||||
// topbar center is dedicated to the global search bar.
|
||||
<header className="grid h-14 grid-cols-[minmax(0,1fr)_minmax(420px,800px)_minmax(0,1fr)] items-center border-b border-border bg-background gap-3 px-4 shrink-0">
|
||||
{/* LEFT: optional back button + breadcrumbs / page title */}
|
||||
{/* LEFT: optional sidebar trigger (tablet) + optional back button + breadcrumbs */}
|
||||
<div className="min-w-0 flex items-center gap-1.5">
|
||||
{leadingSlot}
|
||||
{showBackButton && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
Reference in New Issue
Block a user