feat: pro: pane-aware responsiveness, scoped sidebar overlay, stable pane keys
This commit is contained in:
+28
-9
@@ -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
@@ -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)' }}
|
||||
|
||||
@@ -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
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user