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
+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);
}