Revamp the history handling for dialog components

This commit is contained in:
Nicolas Mowen 2025-11-25 10:24:07 -07:00
parent 036a5d84cc
commit 36da8db6d0
6 changed files with 103 additions and 105 deletions

View File

@ -13,7 +13,7 @@ import { cn } from "@/lib/utils";
import { isPWA } from "@/utils/isPWA"; import { isPWA } from "@/utils/isPWA";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useLocation } from "react-router-dom"; import { useHistoryBack } from "@/hooks/use-history-back";
const MobilePageContext = createContext<{ const MobilePageContext = createContext<{
open: boolean; open: boolean;
@ -24,15 +24,16 @@ type MobilePageProps = {
children: React.ReactNode; children: React.ReactNode;
open?: boolean; open?: boolean;
onOpenChange?: (open: boolean) => void; onOpenChange?: (open: boolean) => void;
enableHistoryBack?: boolean;
}; };
export function MobilePage({ export function MobilePage({
children, children,
open: controlledOpen, open: controlledOpen,
onOpenChange, onOpenChange,
enableHistoryBack = true,
}: MobilePageProps) { }: MobilePageProps) {
const [uncontrolledOpen, setUncontrolledOpen] = useState(false); const [uncontrolledOpen, setUncontrolledOpen] = useState(false);
const location = useLocation();
const open = controlledOpen ?? uncontrolledOpen; const open = controlledOpen ?? uncontrolledOpen;
const setOpen = useCallback( const setOpen = useCallback(
@ -46,33 +47,12 @@ export function MobilePage({
[onOpenChange, setUncontrolledOpen], [onOpenChange, setUncontrolledOpen],
); );
useEffect(() => { // Handle browser back button to close mobile page
let isActive = true; useHistoryBack({
enabled: enableHistoryBack,
if (open && isActive) { open,
window.history.pushState({ isMobilePage: true }, "", location.pathname); onClose: () => setOpen(false),
} });
const handlePopState = (event: PopStateEvent) => {
if (open && isActive) {
event.preventDefault();
setOpen(false);
// Delay replaceState to ensure state updates are processed
setTimeout(() => {
if (isActive) {
window.history.replaceState(null, "", location.pathname);
}
}, 0);
}
};
window.addEventListener("popstate", handlePopState);
return () => {
isActive = false;
window.removeEventListener("popstate", handlePopState);
};
}, [open, setOpen, location.pathname]);
return ( return (
<MobilePageContext.Provider value={{ open, onOpenChange: setOpen }}> <MobilePageContext.Provider value={{ open, onOpenChange: setOpen }}>

View File

@ -538,7 +538,7 @@ export default function SearchDetailDialog({
<Overlay <Overlay
open={isOpen} open={isOpen}
onOpenChange={handleOpenChange} onOpenChange={handleOpenChange}
enableHistoryBack={false} enableHistoryBack={true}
> >
{isDesktop && onPrevious && onNext && ( {isDesktop && onPrevious && onNext && (
<DialogPortal> <DialogPortal>

View File

@ -113,7 +113,12 @@ export function PlatformAwareSheet({
} }
return ( return (
<Sheet open={open} onOpenChange={onOpenChange} modal={false}> <Sheet
open={open}
onOpenChange={onOpenChange}
modal={false}
enableHistoryBack
>
<SheetTrigger asChild className={triggerClassName}> <SheetTrigger asChild className={triggerClassName}>
{trigger} {trigger}
</SheetTrigger> </SheetTrigger>

View File

@ -2,6 +2,7 @@ import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog"; import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react"; import { X } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useHistoryBack } from "@/hooks/use-history-back";
// Enhanced Dialog with History Support // Enhanced Dialog with History Support
interface HistoryDialogProps extends DialogPrimitive.DialogProps { interface HistoryDialogProps extends DialogPrimitive.DialogProps {
@ -15,51 +16,28 @@ const Dialog = ({
...props ...props
}: HistoryDialogProps) => { }: HistoryDialogProps) => {
const [internalOpen, setInternalOpen] = React.useState(open || false); const [internalOpen, setInternalOpen] = React.useState(open || false);
const historyStateRef = React.useRef<null | {
listener: (e: PopStateEvent) => void;
}>(null);
// Sync internal state with controlled open prop
React.useEffect(() => { React.useEffect(() => {
if (open !== undefined) { if (open !== undefined) {
setInternalOpen(open); setInternalOpen(open);
} }
}, [open]); }, [open]);
React.useEffect(() => { const handleOpenChange = React.useCallback(
if (enableHistoryBack) { (newOpen: boolean) => {
if (internalOpen) { setInternalOpen(newOpen);
window.history.pushState({ dialogOpen: true }, ""); onOpenChange?.(newOpen);
},
const listener = () => { [onOpenChange],
setInternalOpen(false);
if (onOpenChange) onOpenChange(false);
};
historyStateRef.current = { listener };
window.addEventListener("popstate", listener);
return () => {
if (internalOpen) {
window.removeEventListener("popstate", listener);
historyStateRef.current = null;
}
};
} else if (historyStateRef.current) {
window.removeEventListener(
"popstate",
historyStateRef.current.listener,
); );
historyStateRef.current = null;
}
}
}, [enableHistoryBack, internalOpen, onOpenChange]);
const handleOpenChange = (open: boolean) => { // Handle browser back button to close dialog
setInternalOpen(open); useHistoryBack({
if (onOpenChange) { enabled: enableHistoryBack,
onOpenChange(open); open: internalOpen,
} onClose: () => handleOpenChange(false),
}; });
return ( return (
<DialogPrimitive.Root <DialogPrimitive.Root

View File

@ -4,6 +4,7 @@ import { cva, type VariantProps } from "class-variance-authority";
import { X } from "lucide-react"; import { X } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useHistoryBack } from "@/hooks/use-history-back";
// Enhanced Sheet with History Support // Enhanced Sheet with History Support
interface HistorySheetProps extends SheetPrimitive.DialogProps { interface HistorySheetProps extends SheetPrimitive.DialogProps {
@ -17,51 +18,28 @@ const Sheet = ({
...props ...props
}: HistorySheetProps) => { }: HistorySheetProps) => {
const [internalOpen, setInternalOpen] = React.useState(open || false); const [internalOpen, setInternalOpen] = React.useState(open || false);
const historyStateRef = React.useRef<null | {
listener: (e: PopStateEvent) => void;
}>(null);
// Sync internal state with controlled open prop
React.useEffect(() => { React.useEffect(() => {
if (open !== undefined) { if (open !== undefined) {
setInternalOpen(open); setInternalOpen(open);
} }
}, [open]); }, [open]);
React.useEffect(() => { const handleOpenChange = React.useCallback(
if (enableHistoryBack) { (newOpen: boolean) => {
if (internalOpen) { setInternalOpen(newOpen);
window.history.pushState({ sheetOpen: true }, ""); onOpenChange?.(newOpen);
},
const listener = () => { [onOpenChange],
setInternalOpen(false);
if (onOpenChange) onOpenChange(false);
};
historyStateRef.current = { listener };
window.addEventListener("popstate", listener);
return () => {
if (internalOpen) {
window.removeEventListener("popstate", listener);
historyStateRef.current = null;
}
};
} else if (historyStateRef.current) {
window.removeEventListener(
"popstate",
historyStateRef.current.listener,
); );
historyStateRef.current = null;
}
}
}, [enableHistoryBack, internalOpen, onOpenChange]);
const handleOpenChange = (open: boolean) => { // Handle browser back button to close sheet
setInternalOpen(open); useHistoryBack({
if (onOpenChange) { enabled: enableHistoryBack,
onOpenChange(open); open: internalOpen,
} onClose: () => handleOpenChange(false),
}; });
return ( return (
<SheetPrimitive.Root <SheetPrimitive.Root

View File

@ -0,0 +1,57 @@
import * as React from "react";
interface UseHistoryBackOptions {
enabled: boolean;
open: boolean;
onClose: () => void;
}
/**
* Hook that manages browser history for overlay components (dialogs, sheets, etc.)
* When enabled, pressing the browser back button will close the overlay instead of navigating away.
*/
export function useHistoryBack({
enabled,
open,
onClose,
}: UseHistoryBackOptions): void {
const historyPushedRef = React.useRef(false);
const closedByBackRef = React.useRef(false);
// Keep onClose in a ref to avoid effect re-runs that cause multiple history pushes
const onCloseRef = React.useRef(onClose);
React.useLayoutEffect(() => {
onCloseRef.current = onClose;
});
React.useEffect(() => {
if (!enabled) return;
if (open) {
// Only push history state if we haven't already (prevents duplicates in strict mode)
if (!historyPushedRef.current) {
window.history.pushState({ overlayOpen: true }, "");
historyPushedRef.current = true;
}
const handlePopState = () => {
closedByBackRef.current = true;
historyPushedRef.current = false;
onCloseRef.current();
};
window.addEventListener("popstate", handlePopState);
return () => {
window.removeEventListener("popstate", handlePopState);
};
} else {
// Overlay is closing - clean up history if we pushed and it wasn't via back button
if (historyPushedRef.current && !closedByBackRef.current) {
window.history.back();
}
historyPushedRef.current = false;
closedByBackRef.current = false;
}
}, [enabled, open]);
}