feat: pro: pane-aware responsiveness, scoped sidebar overlay, stable pane keys

This commit is contained in:
Linus Rath
2026-05-18 20:42:21 +02:00
parent c3b4707f85
commit 5cdc5997af
5 changed files with 146 additions and 31 deletions
+28 -9
View File
@@ -2121,25 +2121,44 @@ export default function Home() {
<InlineAppView apps={loadedApps} activeAppId={inlineApp!.id} onClose={closeInlineApp} className="flex-1" />
)}
{/* Mobile/Tablet Sidebar Overlay Backdrop */}
{/* Mobile/Tablet Sidebar Overlay Backdrop.
When embedded in a Pro pane the viewport is desktop-wide, so the
`lg:hidden` viewport-variant alone wouldn't gate this overlay;
scope to the pane via `absolute` so the backdrop stays inside
the pane instead of covering the whole window. */}
{(isMobile || isTablet) && sidebarOpen && !inlineApp && (
<div
className="fixed inset-0 bg-black/50 z-40 lg:hidden"
className={cn(
"inset-0 bg-black/50 z-40",
isEmbedded ? "absolute" : "fixed lg:hidden"
)}
onClick={() => setSidebarOpen(false)}
/>
)}
{/* Sidebar - overlay on mobile/tablet, fixed on desktop */}
{/* Sidebar - overlay on mobile/tablet, in-flow on desktop.
When embedded, overlay-mode is driven by pane-aware JS rather
than viewport-variants (which still see the full window). */}
<div
className={cn(
"flex-shrink-0 h-full z-50",
!isResizing && "transition-[width] duration-300",
// Mobile/Tablet: fixed overlay
"max-lg:fixed max-lg:inset-y-0 max-lg:left-0 max-lg:w-72 max-lg:pt-[env(safe-area-inset-top)]",
"max-lg:transform max-lg:transition-transform max-lg:duration-300 max-lg:ease-in-out",
!sidebarOpen && "max-lg:-translate-x-full",
// Desktop: normal flow
"lg:relative lg:translate-x-0",
isEmbedded
? (isMobile || isTablet
? cn(
"absolute inset-y-0 left-0 w-72 pt-[env(safe-area-inset-top)]",
"transform transition-transform duration-300 ease-in-out",
!sidebarOpen && "-translate-x-full"
)
: "relative translate-x-0")
: cn(
// Mobile/Tablet: fixed overlay
"max-lg:fixed max-lg:inset-y-0 max-lg:left-0 max-lg:w-72 max-lg:pt-[env(safe-area-inset-top)]",
"max-lg:transform max-lg:transition-transform max-lg:duration-300 max-lg:ease-in-out",
!sidebarOpen && "max-lg:-translate-x-full",
// Desktop: normal flow
"lg:relative lg:translate-x-0"
),
inlineApp && "hidden"
)}
style={!isMobile && !isTablet ? { width: sidebarCollapsed ? 64 : sidebarWidth } : undefined}
+51 -15
View File
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useMemo, useState, type ComponentType, type DragEvent } from "react";
import { useEffect, useMemo, useRef, useState, type ComponentType, type DragEvent } from "react";
import { useTranslations } from "next-intl";
import { NavigationRail } from "@/components/layout/navigation-rail";
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
@@ -11,6 +11,7 @@ import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
import { useEmailStore } from "@/stores/email-store";
import { useDeviceDetection } from "@/hooks/use-media-query";
import { EmbeddedContext } from "@/hooks/use-is-embedded";
import { PaneSizeContext } from "@/hooks/use-pane-size";
import { ProTabBar, PRO_TAB_DRAG_MIME } from "@/components/pro/pro-tab-bar";
import { useProTabStore, type ProTab, type ProTabKind, type ProPaneId } from "@/stores/pro-tab-store";
import { cn } from "@/lib/utils";
@@ -54,25 +55,50 @@ interface PaneProps {
}
function Pane({ paneId, tabs, activeTabId, loadedTabIds, onPaneFocus, isFocused }: PaneProps) {
const paneRef = useRef<HTMLDivElement | null>(null);
// Measured pane width, published to children via PaneSizeContext so that
// useDeviceDetection / useIsMobile / etc. branch on pane width — not full
// viewport — and inner pages collapse to their mobile/tablet layouts when
// the pane is narrow.
const [paneWidth, setPaneWidth] = useState<number | null>(null);
useEffect(() => {
const el = paneRef.current;
if (!el || typeof ResizeObserver === "undefined") return;
const initialRect = el.getBoundingClientRect();
if (initialRect.width > 0) setPaneWidth(initialRect.width);
const ro = new ResizeObserver((entries) => {
const entry = entries[0];
if (!entry) return;
const w = entry.contentRect.width;
setPaneWidth((prev) => (prev !== null && Math.abs(prev - w) < 0.5 ? prev : w));
});
ro.observe(el);
return () => ro.disconnect();
}, []);
return (
<div
ref={paneRef}
className="relative flex flex-1 flex-col overflow-hidden min-w-0 min-h-0"
onMouseDownCapture={() => { if (!isFocused) onPaneFocus(paneId); }}
>
{tabs
.filter((tab) => loadedTabIds.includes(tab.id))
.map((tab) => {
const isActive = tab.id === activeTabId;
return (
<div
key={tab.id}
className={cn("absolute inset-0 overflow-hidden", !isActive && "hidden")}
aria-hidden={!isActive}
>
{renderTabBody(tab)}
</div>
);
})}
<PaneSizeContext.Provider value={paneWidth}>
{tabs
.filter((tab) => loadedTabIds.includes(tab.id))
.map((tab) => {
const isActive = tab.id === activeTabId;
return (
<div
key={tab.id}
className={cn("absolute inset-0 overflow-hidden", !isActive && "hidden")}
aria-hidden={!isActive}
>
{renderTabBody(tab)}
</div>
);
})}
</PaneSizeContext.Provider>
</div>
);
}
@@ -233,8 +259,16 @@ export default function ProHome() {
if (!isDesktop) return null;
// Stable keys are essential: when the split collapses, the row's child
// list goes from [splitPane, divider, mainPane] (or the leading variant)
// to [mainPane]. Without keys, React would reuse the Pane instance at
// index 0 — repurposing the *split* pane's instance into the main pane,
// which strands the main pane's ResizeObserver/paneWidth on a now-
// unmounted DOM node and reparents the mail tab body (causing remount
// + stale "still-narrow" measurements after the split is closed).
const mainPane = (
<Pane
key="pane-main"
paneId="main"
tabs={mainTabs}
activeTabId={activeMainTabId}
@@ -246,6 +280,7 @@ export default function ProHome() {
const splitPane = isSplit ? (
<Pane
key="pane-split"
paneId="split"
tabs={splitTabs}
activeTabId={activeSplitTabId}
@@ -257,6 +292,7 @@ export default function ProHome() {
const splitDivider = isSplit ? (
<div
key="pane-divider"
aria-hidden="true"
className="flex-shrink-0 w-px bg-transparent"
style={{ borderLeft: '1px solid rgba(128, 128, 128, 0.3)' }}
+9 -2
View File
@@ -3,6 +3,7 @@
import { Menu, ArrowLeft, Plus, Search, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useUIStore } from "@/stores/ui-store";
import { useIsDesktop } from "@/hooks/use-media-query";
import { cn } from "@/lib/utils";
import { useTranslations } from "next-intl";
@@ -25,6 +26,12 @@ export function MobileHeader({
}: MobileHeaderProps) {
const t = useTranslations('sidebar');
const { toggleSidebar, goBack, sidebarOpen } = useUIStore();
// Pane-aware: in Pro split mode the viewport is desktop-wide while the
// pane is narrow. The Tailwind `lg:hidden` variant alone would never fire
// there, so we additionally hide via JS when the surrounding pane is
// desktop-sized. Outside of Pro this still returns the viewport value.
const isPaneDesktop = useIsDesktop();
if (isPaneDesktop) return null;
const handleLeftAction = () => {
if (showBack && onBack) {
@@ -40,7 +47,6 @@ export function MobileHeader({
<header
className={cn(
"flex items-center justify-between px-4 h-14 border-b border-border bg-background shrink-0",
"lg:hidden", // Only visible on mobile/tablet
className
)}
>
@@ -118,12 +124,13 @@ export function MobileViewerHeader({
className,
}: MobileViewerHeaderProps) {
const t = useTranslations('sidebar');
const isPaneDesktop = useIsDesktop();
if (isPaneDesktop) return null;
return (
<header
className={cn(
"flex items-center justify-between px-2 h-14 border-b border-border bg-background shrink-0",
"lg:hidden", // Only visible on mobile/tablet
className
)}
>
+44 -5
View File
@@ -2,6 +2,7 @@
import { useCallback, useEffect, useSyncExternalStore } from "react";
import { useUIStore } from "@/stores/ui-store";
import { usePaneSize } from "@/hooks/use-pane-size";
// Tailwind v4 breakpoints
const BREAKPOINTS = {
@@ -38,12 +39,38 @@ export function useMediaQuery(query: string): boolean {
return useSyncExternalStore(subscribe, getSnapshot, getMediaQueryServerSnapshot);
}
/**
* When the Pro shell renders a page inside a (possibly split) pane, that pane
* publishes its measured width via `PaneSizeContext`. Inner pages should
* branch their layout against the pane width — not the full viewport — so a
* narrow pane gets the mobile/tablet layout instead of overflowing.
*
* Returns `null` when no pane size is published, signalling the caller to
* fall back to `window.matchMedia`.
*/
function classifyPane(paneWidth: number | null) {
if (paneWidth === null) return null;
return {
isMobile: paneWidth < BREAKPOINTS.md,
isTablet: paneWidth >= BREAKPOINTS.md && paneWidth < BREAKPOINTS.lg,
isDesktop: paneWidth >= BREAKPOINTS.lg,
};
}
/**
* Hook to detect device type and sync with UI store
* Uses Tailwind breakpoints: mobile < 768px, tablet 768-1024px, desktop > 1024px
*
* When invoked inside a Pro pane, the returned values reflect the pane's
* width instead of the window's. The global UI store is NOT updated in that
* case — two split panes would otherwise fight to write conflicting values,
* and the store is meant to mirror the actual viewport for callers that read
* it directly (mobile navigation helpers etc.).
*/
export function useDeviceDetection() {
const { setDeviceType, isMobile, isTablet, isDesktop } = useUIStore();
const paneWidth = usePaneSize();
const paneClassification = classifyPane(paneWidth);
const isMobileQuery = useMediaQuery(`(max-width: ${BREAKPOINTS.md - 1}px)`);
const isTabletQuery = useMediaQuery(
@@ -52,9 +79,11 @@ export function useDeviceDetection() {
const isDesktopQuery = useMediaQuery(`(min-width: ${BREAKPOINTS.lg}px)`);
useEffect(() => {
if (paneClassification) return;
setDeviceType(isMobileQuery, isTabletQuery, isDesktopQuery);
}, [isMobileQuery, isTabletQuery, isDesktopQuery, setDeviceType]);
}, [isMobileQuery, isTabletQuery, isDesktopQuery, setDeviceType, paneClassification]);
if (paneClassification) return paneClassification;
return { isMobile, isTablet, isDesktop };
}
@@ -62,19 +91,29 @@ export function useDeviceDetection() {
* Convenience hooks for specific breakpoints
*/
export function useIsMobile() {
return useMediaQuery(`(max-width: ${BREAKPOINTS.md - 1}px)`);
const paneWidth = usePaneSize();
const viewport = useMediaQuery(`(max-width: ${BREAKPOINTS.md - 1}px)`);
return paneWidth !== null ? paneWidth < BREAKPOINTS.md : viewport;
}
export function useIsTablet() {
return useMediaQuery(
const paneWidth = usePaneSize();
const viewport = useMediaQuery(
`(min-width: ${BREAKPOINTS.md}px) and (max-width: ${BREAKPOINTS.lg - 1}px)`
);
return paneWidth !== null
? paneWidth >= BREAKPOINTS.md && paneWidth < BREAKPOINTS.lg
: viewport;
}
export function useIsDesktop() {
return useMediaQuery(`(min-width: ${BREAKPOINTS.lg}px)`);
const paneWidth = usePaneSize();
const viewport = useMediaQuery(`(min-width: ${BREAKPOINTS.lg}px)`);
return paneWidth !== null ? paneWidth >= BREAKPOINTS.lg : viewport;
}
export function useBreakpoint(breakpoint: keyof typeof BREAKPOINTS) {
return useMediaQuery(`(min-width: ${BREAKPOINTS[breakpoint]}px)`);
const paneWidth = usePaneSize();
const viewport = useMediaQuery(`(min-width: ${BREAKPOINTS[breakpoint]}px)`);
return paneWidth !== null ? paneWidth >= BREAKPOINTS[breakpoint] : viewport;
}
+14
View File
@@ -0,0 +1,14 @@
"use client";
import { createContext, useContext } from "react";
/**
* Width of the pane that's hosting the current subtree, in CSS pixels.
* `null` means "no pane is providing a size" — fall back to viewport-based
* media queries. Set by the Pro shell on each split pane via ResizeObserver.
*/
export const PaneSizeContext = createContext<number | null>(null);
export function usePaneSize(): number | null {
return useContext(PaneSizeContext);
}