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"
|
||||
|
||||
@@ -45,13 +45,19 @@ export function PageHeader({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Desktop: full strip with title, eyebrow, description, kpi line, actions. */}
|
||||
{/* Tablet + desktop: full strip with title, eyebrow, description, kpi line, actions.
|
||||
Stacks vertically at sm (640px) up to lg (1024px) so the title
|
||||
doesn't get truncated next to a wide actions row on tablet — the
|
||||
previous `sm:flex-row sm:flex-nowrap` forced four-button toolbars
|
||||
(e.g. the dashboard's DateRange + ExportPdf + Rearrange + Customize)
|
||||
onto one row at 768px, crushing the title. At lg+ the row layout
|
||||
returns. */}
|
||||
<div
|
||||
className={cn(
|
||||
// Removed `sm:mb-6` - the parent shell already provides
|
||||
// appropriate gap-y between header and the next section, and the
|
||||
// double-spacing produced an oversized top margin on dashboards.
|
||||
'hidden sm:flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-4',
|
||||
'hidden sm:flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between lg:gap-4',
|
||||
isGradient &&
|
||||
'rounded-xl border border-slate-200 bg-gradient-brand-soft px-5 py-4 shadow-xs',
|
||||
className,
|
||||
@@ -79,7 +85,7 @@ export function PageHeader({
|
||||
) : null}
|
||||
</div>
|
||||
{actions ? (
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-2 sm:flex-nowrap">{actions}</div>
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-2 lg:flex-nowrap">{actions}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -2,34 +2,83 @@
|
||||
|
||||
import { useSyncExternalStore } from 'react';
|
||||
|
||||
const MOBILE_QUERY = '(max-width: 1023.98px)';
|
||||
// 3-tier breakpoints aligned with Tailwind:
|
||||
// mobile : < 768px (sm and below)
|
||||
// tablet : 768-1023 (md)
|
||||
// desktop : >= 1024 (lg and up)
|
||||
//
|
||||
// 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"
|
||||
// Mac browser — neither is really "mobile." The tablet tier fills that
|
||||
// gap so the desktop shell can render with a hidden-but-accessible
|
||||
// sidebar at those widths.
|
||||
|
||||
function subscribe(callback: () => void): () => void {
|
||||
const mq = window.matchMedia(MOBILE_QUERY);
|
||||
export type ViewportTier = 'mobile' | 'tablet' | 'desktop';
|
||||
|
||||
const TABLET_QUERY = '(min-width: 768px) and (max-width: 1023.98px)';
|
||||
const DESKTOP_QUERY = '(min-width: 1024px)';
|
||||
|
||||
function subscribeTier(callback: () => void): () => void {
|
||||
const mqTablet = window.matchMedia(TABLET_QUERY);
|
||||
const mqDesktop = window.matchMedia(DESKTOP_QUERY);
|
||||
mqTablet.addEventListener('change', callback);
|
||||
mqDesktop.addEventListener('change', callback);
|
||||
return () => {
|
||||
mqTablet.removeEventListener('change', callback);
|
||||
mqDesktop.removeEventListener('change', callback);
|
||||
};
|
||||
}
|
||||
|
||||
function getTierSnapshot(): ViewportTier {
|
||||
if (window.matchMedia(DESKTOP_QUERY).matches) return 'desktop';
|
||||
if (window.matchMedia(TABLET_QUERY).matches) return 'tablet';
|
||||
return 'mobile';
|
||||
}
|
||||
|
||||
function getTierServerSnapshot(): ViewportTier {
|
||||
// Server has no window — default to desktop so the desktop shell
|
||||
// mounts on first paint. Client re-evaluates immediately on hydration
|
||||
// (useSyncExternalStore is server-mismatch-safe).
|
||||
return 'desktop';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the active viewport tier. Backed by useSyncExternalStore so
|
||||
* render reads stay pure (no useEffect → setState cascade); React
|
||||
* Compiler-safe.
|
||||
*/
|
||||
export function useViewportTier(): ViewportTier {
|
||||
return useSyncExternalStore(subscribeTier, getTierSnapshot, getTierServerSnapshot);
|
||||
}
|
||||
|
||||
// ─── Back-compat alias ──────────────────────────────────────────────────────
|
||||
// Every existing call site treats "mobile OR tablet" as one bucket (e.g.
|
||||
// "show short labels", "stack vertically"). Returning `tier !== 'desktop'`
|
||||
// preserves that behaviour so the tier rollout doesn't have to touch
|
||||
// dozens of components in lockstep.
|
||||
|
||||
const LEGACY_QUERY = '(max-width: 1023.98px)';
|
||||
|
||||
function subscribeLegacy(callback: () => void): () => void {
|
||||
const mq = window.matchMedia(LEGACY_QUERY);
|
||||
mq.addEventListener('change', callback);
|
||||
return () => mq.removeEventListener('change', callback);
|
||||
}
|
||||
|
||||
function getSnapshot(): boolean {
|
||||
return window.matchMedia(MOBILE_QUERY).matches;
|
||||
function getLegacySnapshot(): boolean {
|
||||
return window.matchMedia(LEGACY_QUERY).matches;
|
||||
}
|
||||
|
||||
function getServerSnapshot(): boolean {
|
||||
// Server has no window — default to desktop. Client hydrates to the
|
||||
// true viewport state without a flash because useSyncExternalStore
|
||||
// is React 18's "this is server-mismatch safe" primitive.
|
||||
function getLegacyServerSnapshot(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when the viewport is below the `lg` Tailwind breakpoint.
|
||||
* Backed by useSyncExternalStore so render reads stay pure (no
|
||||
* useEffect → setState cascade); React Compiler-safe.
|
||||
*
|
||||
* Not unit-tested: the repo's vitest is configured for environment='node'
|
||||
* (no @testing-library/react / DOM env). Verified through the mobile-shell
|
||||
* Playwright visual snapshots in Task 23.
|
||||
* Returns true when the viewport is below the `lg` Tailwind breakpoint
|
||||
* (i.e. mobile OR tablet). Kept as an alias for backwards compatibility
|
||||
* with call sites that don't care about the mobile-vs-tablet distinction.
|
||||
* New code should prefer `useViewportTier()` for explicit tier checks.
|
||||
*/
|
||||
export function useIsMobile(): boolean {
|
||||
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
||||
return useSyncExternalStore(subscribeLegacy, getLegacySnapshot, getLegacyServerSnapshot);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user