Files
frigate/web/src/components/mobile/MobilePage.tsx
T

235 lines
5.7 KiB
TypeScript
Raw Normal View History

2025-04-13 13:08:47 -05:00
import {
createContext,
useContext,
useEffect,
useState,
useCallback,
2025-11-07 07:53:27 -06:00
useRef,
2025-04-13 13:08:47 -05:00
} from "react";
import { createPortal } from "react-dom";
import { motion, AnimatePresence } from "framer-motion";
import { IoMdArrowRoundBack } from "react-icons/io";
2024-09-12 14:39:35 -05:00
import { cn } from "@/lib/utils";
import { isPWA } from "@/utils/isPWA";
import { Button } from "@/components/ui/button";
import { useTranslation } from "react-i18next";
2025-11-26 06:23:51 -07:00
import { useHistoryBack } from "@/hooks/use-history-back";
2024-09-12 14:39:35 -05:00
const MobilePageContext = createContext<{
2024-09-12 14:39:35 -05:00
open: boolean;
onOpenChange: (open: boolean) => void;
} | null>(null);
type MobilePageProps = {
children: React.ReactNode;
open?: boolean;
onOpenChange?: (open: boolean) => void;
2025-11-26 06:23:51 -07:00
enableHistoryBack?: boolean;
2024-09-12 14:39:35 -05:00
};
export function MobilePage({
children,
open: controlledOpen,
onOpenChange,
2025-11-26 06:23:51 -07:00
enableHistoryBack = true,
}: MobilePageProps) {
const [uncontrolledOpen, setUncontrolledOpen] = useState(false);
const open = controlledOpen ?? uncontrolledOpen;
2025-04-13 13:08:47 -05:00
const setOpen = useCallback(
(value: boolean) => {
if (onOpenChange) {
onOpenChange(value);
} else {
setUncontrolledOpen(value);
}
},
[onOpenChange, setUncontrolledOpen],
);
2025-11-26 06:23:51 -07:00
// Handle browser back button to close mobile page
useHistoryBack({
enabled: enableHistoryBack,
open,
onClose: () => setOpen(false),
});
return (
2026-03-05 08:42:38 -06:00
<MobilePageContext value={{ open, onOpenChange: setOpen }}>
{children}
2026-03-05 08:42:38 -06:00
</MobilePageContext>
);
}
type MobilePageTriggerProps = React.HTMLAttributes<HTMLDivElement>;
export function MobilePageTrigger({
children,
...props
}: MobilePageTriggerProps) {
const context = useContext(MobilePageContext);
if (!context)
throw new Error("MobilePageTrigger must be used within MobilePage");
return (
<div onClick={() => context.onOpenChange(true)} {...props}>
{children}
</div>
);
}
type MobilePagePortalProps = {
children: React.ReactNode;
container?: HTMLElement;
};
export function MobilePagePortal({
children,
container,
}: MobilePagePortalProps) {
const [mounted, setMounted] = useState(false);
2024-09-12 14:39:35 -05:00
useEffect(() => {
setMounted(true);
return () => setMounted(false);
}, []);
if (!mounted) return null;
return createPortal(children, container || document.body);
}
type MobilePageContentProps = {
children: React.ReactNode;
className?: string;
2026-03-05 08:42:38 -06:00
scrollerRef?: React.RefObject<HTMLDivElement | null>;
};
export function MobilePageContent({
children,
className,
2025-11-07 07:53:27 -06:00
scrollerRef,
}: MobilePageContentProps) {
const context = useContext(MobilePageContext);
if (!context)
throw new Error("MobilePageContent must be used within MobilePage");
const [isVisible, setIsVisible] = useState(context.open);
2025-11-07 07:53:27 -06:00
const containerRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (context.open) {
2024-09-12 14:39:35 -05:00
setIsVisible(true);
}
}, [context.open]);
2024-09-12 14:39:35 -05:00
const handleAnimationComplete = () => {
2025-11-07 07:53:27 -06:00
if (context.open) {
// After opening animation completes, ensure scroller is at the top
if (scrollerRef?.current) {
scrollerRef.current.scrollTop = 0;
}
} else {
2024-09-12 14:39:35 -05:00
setIsVisible(false);
}
};
2025-11-07 07:53:27 -06:00
useEffect(() => {
if (context.open && scrollerRef?.current) {
scrollerRef.current.scrollTop = 0;
}
}, [context.open, scrollerRef]);
2024-09-12 14:39:35 -05:00
return (
<AnimatePresence>
{isVisible && (
<motion.div
2025-11-07 07:53:27 -06:00
ref={containerRef}
2024-09-12 14:39:35 -05:00
className={cn(
2026-06-04 10:56:11 -05:00
"fixed inset-0 z-50 bg-background",
isPWA
? "mb-[calc(3rem+env(safe-area-inset-bottom))] md:mb-[calc(4rem+env(safe-area-inset-bottom))]"
: "mb-12 md:mb-16",
className,
2024-09-12 14:39:35 -05:00
)}
initial={{ x: "100%" }}
animate={{ x: context.open ? 0 : "100%" }}
2024-09-12 14:39:35 -05:00
exit={{ x: "100%" }}
transition={{ type: "spring", damping: 25, stiffness: 200 }}
onAnimationComplete={handleAnimationComplete}
>
{children}
</motion.div>
)}
</AnimatePresence>
);
}
interface MobilePageHeaderProps extends React.HTMLAttributes<HTMLDivElement> {
onClose?: () => void;
2025-10-08 14:59:21 -05:00
actions?: React.ReactNode;
2024-09-12 14:39:35 -05:00
}
export function MobilePageHeader({
children,
className,
onClose,
2025-10-08 14:59:21 -05:00
actions,
2024-09-12 14:39:35 -05:00
...props
}: MobilePageHeaderProps) {
const { t } = useTranslation(["common"]);
const context = useContext(MobilePageContext);
if (!context)
throw new Error("MobilePageHeader must be used within MobilePage");
const handleClose = () => {
if (onClose) {
onClose();
} else {
context.onOpenChange(false);
}
};
2024-09-12 14:39:35 -05:00
return (
<div
className={cn(
2024-10-07 07:18:09 -06:00
"sticky -top-2 z-50 mb-2 flex items-center justify-center bg-background p-4",
2024-09-12 14:39:35 -05:00
className,
)}
{...props}
>
<Button
className="absolute left-0 rounded-lg"
aria-label={t("label.back", { ns: "common" })}
2024-09-12 14:39:35 -05:00
size="sm"
onClick={handleClose}
2024-09-12 14:39:35 -05:00
>
<IoMdArrowRoundBack className="size-5 text-secondary-foreground" />
</Button>
<div className="flex flex-row text-center">{children}</div>
2025-10-08 14:59:21 -05:00
{actions && (
<div className="absolute right-0 flex items-center gap-2">
{actions}
</div>
)}
2024-09-12 14:39:35 -05:00
</div>
);
}
type MobilePageTitleProps = React.HTMLAttributes<HTMLHeadingElement>;
export function MobilePageTitle({ className, ...props }: MobilePageTitleProps) {
2025-10-09 07:23:03 -05:00
return <h2 className={cn("text-lg", className)} {...props} />;
}
type MobilePageDescriptionProps = React.HTMLAttributes<HTMLParagraphElement>;
export function MobilePageDescription({
2024-09-12 14:39:35 -05:00
className,
...props
}: MobilePageDescriptionProps) {
2024-09-12 14:39:35 -05:00
return (
<p className={cn("text-sm text-muted-foreground", className)} {...props} />
2024-09-12 14:39:35 -05:00
);
}