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" /> <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 && ( {(isMobile || isTablet) && sidebarOpen && !inlineApp && (
<div <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)} 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 <div
className={cn( className={cn(
"flex-shrink-0 h-full z-50", "flex-shrink-0 h-full z-50",
!isResizing && "transition-[width] duration-300", !isResizing && "transition-[width] duration-300",
// Mobile/Tablet: fixed overlay isEmbedded
"max-lg:fixed max-lg:inset-y-0 max-lg:left-0 max-lg:w-72 max-lg:pt-[env(safe-area-inset-top)]", ? (isMobile || isTablet
"max-lg:transform max-lg:transition-transform max-lg:duration-300 max-lg:ease-in-out", ? cn(
!sidebarOpen && "max-lg:-translate-x-full", "absolute inset-y-0 left-0 w-72 pt-[env(safe-area-inset-top)]",
// Desktop: normal flow "transform transition-transform duration-300 ease-in-out",
"lg:relative lg:translate-x-0", !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" inlineApp && "hidden"
)} )}
style={!isMobile && !isTablet ? { width: sidebarCollapsed ? 64 : sidebarWidth } : undefined} style={!isMobile && !isTablet ? { width: sidebarCollapsed ? 64 : sidebarWidth } : undefined}
+51 -15
View File
@@ -1,6 +1,6 @@
"use client"; "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 { useTranslations } from "next-intl";
import { NavigationRail } from "@/components/layout/navigation-rail"; import { NavigationRail } from "@/components/layout/navigation-rail";
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal"; 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 { useEmailStore } from "@/stores/email-store";
import { useDeviceDetection } from "@/hooks/use-media-query"; import { useDeviceDetection } from "@/hooks/use-media-query";
import { EmbeddedContext } from "@/hooks/use-is-embedded"; 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 { 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 { useProTabStore, type ProTab, type ProTabKind, type ProPaneId } from "@/stores/pro-tab-store";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -54,25 +55,50 @@ interface PaneProps {
} }
function Pane({ paneId, tabs, activeTabId, loadedTabIds, onPaneFocus, isFocused }: 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 ( return (
<div <div
ref={paneRef}
className="relative flex flex-1 flex-col overflow-hidden min-w-0 min-h-0" className="relative flex flex-1 flex-col overflow-hidden min-w-0 min-h-0"
onMouseDownCapture={() => { if (!isFocused) onPaneFocus(paneId); }} onMouseDownCapture={() => { if (!isFocused) onPaneFocus(paneId); }}
> >
{tabs <PaneSizeContext.Provider value={paneWidth}>
.filter((tab) => loadedTabIds.includes(tab.id)) {tabs
.map((tab) => { .filter((tab) => loadedTabIds.includes(tab.id))
const isActive = tab.id === activeTabId; .map((tab) => {
return ( const isActive = tab.id === activeTabId;
<div return (
key={tab.id} <div
className={cn("absolute inset-0 overflow-hidden", !isActive && "hidden")} key={tab.id}
aria-hidden={!isActive} className={cn("absolute inset-0 overflow-hidden", !isActive && "hidden")}
> aria-hidden={!isActive}
{renderTabBody(tab)} >
</div> {renderTabBody(tab)}
); </div>
})} );
})}
</PaneSizeContext.Provider>
</div> </div>
); );
} }
@@ -233,8 +259,16 @@ export default function ProHome() {
if (!isDesktop) return null; 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 = ( const mainPane = (
<Pane <Pane
key="pane-main"
paneId="main" paneId="main"
tabs={mainTabs} tabs={mainTabs}
activeTabId={activeMainTabId} activeTabId={activeMainTabId}
@@ -246,6 +280,7 @@ export default function ProHome() {
const splitPane = isSplit ? ( const splitPane = isSplit ? (
<Pane <Pane
key="pane-split"
paneId="split" paneId="split"
tabs={splitTabs} tabs={splitTabs}
activeTabId={activeSplitTabId} activeTabId={activeSplitTabId}
@@ -257,6 +292,7 @@ export default function ProHome() {
const splitDivider = isSplit ? ( const splitDivider = isSplit ? (
<div <div
key="pane-divider"
aria-hidden="true" aria-hidden="true"
className="flex-shrink-0 w-px bg-transparent" className="flex-shrink-0 w-px bg-transparent"
style={{ borderLeft: '1px solid rgba(128, 128, 128, 0.3)' }} 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 { Menu, ArrowLeft, Plus, Search, X } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useUIStore } from "@/stores/ui-store"; import { useUIStore } from "@/stores/ui-store";
import { useIsDesktop } from "@/hooks/use-media-query";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
@@ -25,6 +26,12 @@ export function MobileHeader({
}: MobileHeaderProps) { }: MobileHeaderProps) {
const t = useTranslations('sidebar'); const t = useTranslations('sidebar');
const { toggleSidebar, goBack, sidebarOpen } = useUIStore(); 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 = () => { const handleLeftAction = () => {
if (showBack && onBack) { if (showBack && onBack) {
@@ -40,7 +47,6 @@ export function MobileHeader({
<header <header
className={cn( className={cn(
"flex items-center justify-between px-4 h-14 border-b border-border bg-background shrink-0", "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 className
)} )}
> >
@@ -118,12 +124,13 @@ export function MobileViewerHeader({
className, className,
}: MobileViewerHeaderProps) { }: MobileViewerHeaderProps) {
const t = useTranslations('sidebar'); const t = useTranslations('sidebar');
const isPaneDesktop = useIsDesktop();
if (isPaneDesktop) return null;
return ( return (
<header <header
className={cn( className={cn(
"flex items-center justify-between px-2 h-14 border-b border-border bg-background shrink-0", "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 className
)} )}
> >
+44 -5
View File
@@ -2,6 +2,7 @@
import { useCallback, useEffect, useSyncExternalStore } from "react"; import { useCallback, useEffect, useSyncExternalStore } from "react";
import { useUIStore } from "@/stores/ui-store"; import { useUIStore } from "@/stores/ui-store";
import { usePaneSize } from "@/hooks/use-pane-size";
// Tailwind v4 breakpoints // Tailwind v4 breakpoints
const BREAKPOINTS = { const BREAKPOINTS = {
@@ -38,12 +39,38 @@ export function useMediaQuery(query: string): boolean {
return useSyncExternalStore(subscribe, getSnapshot, getMediaQueryServerSnapshot); 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 * Hook to detect device type and sync with UI store
* Uses Tailwind breakpoints: mobile < 768px, tablet 768-1024px, desktop > 1024px * 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() { export function useDeviceDetection() {
const { setDeviceType, isMobile, isTablet, isDesktop } = useUIStore(); const { setDeviceType, isMobile, isTablet, isDesktop } = useUIStore();
const paneWidth = usePaneSize();
const paneClassification = classifyPane(paneWidth);
const isMobileQuery = useMediaQuery(`(max-width: ${BREAKPOINTS.md - 1}px)`); const isMobileQuery = useMediaQuery(`(max-width: ${BREAKPOINTS.md - 1}px)`);
const isTabletQuery = useMediaQuery( const isTabletQuery = useMediaQuery(
@@ -52,9 +79,11 @@ export function useDeviceDetection() {
const isDesktopQuery = useMediaQuery(`(min-width: ${BREAKPOINTS.lg}px)`); const isDesktopQuery = useMediaQuery(`(min-width: ${BREAKPOINTS.lg}px)`);
useEffect(() => { useEffect(() => {
if (paneClassification) return;
setDeviceType(isMobileQuery, isTabletQuery, isDesktopQuery); setDeviceType(isMobileQuery, isTabletQuery, isDesktopQuery);
}, [isMobileQuery, isTabletQuery, isDesktopQuery, setDeviceType]); }, [isMobileQuery, isTabletQuery, isDesktopQuery, setDeviceType, paneClassification]);
if (paneClassification) return paneClassification;
return { isMobile, isTablet, isDesktop }; return { isMobile, isTablet, isDesktop };
} }
@@ -62,19 +91,29 @@ export function useDeviceDetection() {
* Convenience hooks for specific breakpoints * Convenience hooks for specific breakpoints
*/ */
export function useIsMobile() { 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() { export function useIsTablet() {
return useMediaQuery( const paneWidth = usePaneSize();
const viewport = useMediaQuery(
`(min-width: ${BREAKPOINTS.md}px) and (max-width: ${BREAKPOINTS.lg - 1}px)` `(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() { 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) { 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);
}