diff --git a/web/src/components/mobile/MobilePage.tsx b/web/src/components/mobile/MobilePage.tsx index cd1b27493..37e54a49c 100644 --- a/web/src/components/mobile/MobilePage.tsx +++ b/web/src/components/mobile/MobilePage.tsx @@ -1,29 +1,101 @@ +import { createContext, useContext, useEffect, useState } from "react"; +import { createPortal } from "react-dom"; +import { motion, AnimatePresence } from "framer-motion"; +import { IoMdArrowRoundBack } from "react-icons/io"; import { cn } from "@/lib/utils"; import { isPWA } from "@/utils/isPWA"; -import { ReactNode, useEffect, useState } from "react"; -import { Button } from "../ui/button"; -import { IoMdArrowRoundBack } from "react-icons/io"; -import { motion, AnimatePresence } from "framer-motion"; +import { Button } from "@/components/ui/button"; -type MobilePageProps = { - children: ReactNode; +const MobilePageContext = createContext<{ open: boolean; onOpenChange: (open: boolean) => void; +} | null>(null); + +type MobilePageProps = { + children: React.ReactNode; + open?: boolean; + onOpenChange?: (open: boolean) => void; }; -export function MobilePage({ children, open, onOpenChange }: MobilePageProps) { - const [isVisible, setIsVisible] = useState(open); +export function MobilePage({ + children, + open: controlledOpen, + onOpenChange, +}: MobilePageProps) { + const [uncontrolledOpen, setUncontrolledOpen] = useState(false); + + const open = controlledOpen ?? uncontrolledOpen; + const setOpen = onOpenChange ?? setUncontrolledOpen; + + return ( + + {children} + + ); +} + +type MobilePageTriggerProps = React.HTMLAttributes; + +export function MobilePageTrigger({ + children, + ...props +}: MobilePageTriggerProps) { + const context = useContext(MobilePageContext); + if (!context) + throw new Error("MobilePageTrigger must be used within MobilePage"); + + return ( +
context.onOpenChange(true)} {...props}> + {children} +
+ ); +} + +type MobilePagePortalProps = { + children: React.ReactNode; + container?: HTMLElement; +}; + +export function MobilePagePortal({ + children, + container, +}: MobilePagePortalProps) { + const [mounted, setMounted] = useState(false); useEffect(() => { - if (open) { + setMounted(true); + return () => setMounted(false); + }, []); + + if (!mounted) return null; + + return createPortal(children, container || document.body); +} + +type MobilePageContentProps = { + children: React.ReactNode; + className?: string; +}; + +export function MobilePageContent({ + children, + className, +}: MobilePageContentProps) { + const context = useContext(MobilePageContext); + if (!context) + throw new Error("MobilePageContent must be used within MobilePage"); + + const [isVisible, setIsVisible] = useState(context.open); + + useEffect(() => { + if (context.open) { setIsVisible(true); } - }, [open]); + }, [context.open]); const handleAnimationComplete = () => { - if (!open) { + if (!context.open) { setIsVisible(false); - onOpenChange(false); } }; @@ -35,9 +107,10 @@ export function MobilePage({ children, open, onOpenChange }: MobilePageProps) { "fixed inset-0 z-50 mb-12 bg-background", isPWA && "mb-16", "landscape:mb-14 landscape:md:mb-16", + className, )} initial={{ x: "100%" }} - animate={{ x: open ? 0 : "100%" }} + animate={{ x: context.open ? 0 : "100%" }} exit={{ x: "100%" }} transition={{ type: "spring", damping: 25, stiffness: 200 }} onAnimationComplete={handleAnimationComplete} @@ -49,37 +122,8 @@ export function MobilePage({ children, open, onOpenChange }: MobilePageProps) { ); } -type MobileComponentProps = { - children: ReactNode; - className?: string; -}; - -export function MobilePageContent({ - children, - className, - ...props -}: MobileComponentProps) { - return ( -
- {children} -
- ); -} - -export function MobilePageDescription({ - children, - className, - ...props -}: MobileComponentProps) { - return ( -

- {children} -

- ); -} - interface MobilePageHeaderProps extends React.HTMLAttributes { - onClose: () => void; + onClose?: () => void; } export function MobilePageHeader({ @@ -88,6 +132,18 @@ export function MobilePageHeader({ onClose, ...props }: MobilePageHeaderProps) { + const context = useContext(MobilePageContext); + if (!context) + throw new Error("MobilePageHeader must be used within MobilePage"); + + const handleClose = () => { + if (onClose) { + onClose(); + } else { + context.onOpenChange(false); + } + }; + return (
@@ -108,14 +164,19 @@ export function MobilePageHeader({ ); } -export function MobilePageTitle({ - children, +type MobilePageTitleProps = React.HTMLAttributes; + +export function MobilePageTitle({ className, ...props }: MobilePageTitleProps) { + return

; +} + +type MobilePageDescriptionProps = React.HTMLAttributes; + +export function MobilePageDescription({ className, ...props -}: MobileComponentProps) { +}: MobilePageDescriptionProps) { return ( -

- {children} -

+

); } diff --git a/web/src/components/overlay/detail/SearchDetailDialog.tsx b/web/src/components/overlay/detail/SearchDetailDialog.tsx index 47af3e309..37813645b 100644 --- a/web/src/components/overlay/detail/SearchDetailDialog.tsx +++ b/web/src/components/overlay/detail/SearchDetailDialog.tsx @@ -150,7 +150,14 @@ export default function SearchDetailDialog({ const Description = isDesktop ? DialogDescription : MobilePageDescription; return ( - setIsOpen(!isOpen)}> + { + if (search) { + setSearch(undefined); + } + }} + > -

onOpenChange(true)}>{trigger}
- + + onOpenChange(true)}> + {trigger} + +
{content}
-
-

+ + ); }