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" />
|
<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
@@ -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)' }}
|
||||||
|
|||||||
@@ -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
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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