frigate/web/src/components/mobile/MobilePage.tsx
Josh Hawkins b751025339
Some checks are pending
CI / Synaptics Build (push) Blocked by required conditions
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / Assemble and push default build (push) Blocked by required conditions
Mobile UI/UX improvements (#23402)
* increase camera group icon size on mobile

add an animated slider when there is not enough space for all defined camera groups

* change desktop and mobile edit camera groups icon to pencil and add desktop tooltip

* apply safe area insets to mobile layout in PWA mode using viewport-fit=cover

* adaptively size bottom bar nav targets to 48px when they fit, else compact

icon size now targets the standardized 48×48px mobile touch target (Material Design 3 / Android 48dp bottom-nav minimum)
2026-06-04 09:56:11 -06:00

235 lines
5.7 KiB
TypeScript

import {
createContext,
useContext,
useEffect,
useState,
useCallback,
useRef,
} 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 { Button } from "@/components/ui/button";
import { useTranslation } from "react-i18next";
import { useHistoryBack } from "@/hooks/use-history-back";
const MobilePageContext = createContext<{
open: boolean;
onOpenChange: (open: boolean) => void;
} | null>(null);
type MobilePageProps = {
children: React.ReactNode;
open?: boolean;
onOpenChange?: (open: boolean) => void;
enableHistoryBack?: boolean;
};
export function MobilePage({
children,
open: controlledOpen,
onOpenChange,
enableHistoryBack = true,
}: MobilePageProps) {
const [uncontrolledOpen, setUncontrolledOpen] = useState(false);
const open = controlledOpen ?? uncontrolledOpen;
const setOpen = useCallback(
(value: boolean) => {
if (onOpenChange) {
onOpenChange(value);
} else {
setUncontrolledOpen(value);
}
},
[onOpenChange, setUncontrolledOpen],
);
// Handle browser back button to close mobile page
useHistoryBack({
enabled: enableHistoryBack,
open,
onClose: () => setOpen(false),
});
return (
<MobilePageContext value={{ open, onOpenChange: setOpen }}>
{children}
</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);
useEffect(() => {
setMounted(true);
return () => setMounted(false);
}, []);
if (!mounted) return null;
return createPortal(children, container || document.body);
}
type MobilePageContentProps = {
children: React.ReactNode;
className?: string;
scrollerRef?: React.RefObject<HTMLDivElement | null>;
};
export function MobilePageContent({
children,
className,
scrollerRef,
}: MobilePageContentProps) {
const context = useContext(MobilePageContext);
if (!context)
throw new Error("MobilePageContent must be used within MobilePage");
const [isVisible, setIsVisible] = useState(context.open);
const containerRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (context.open) {
setIsVisible(true);
}
}, [context.open]);
const handleAnimationComplete = () => {
if (context.open) {
// After opening animation completes, ensure scroller is at the top
if (scrollerRef?.current) {
scrollerRef.current.scrollTop = 0;
}
} else {
setIsVisible(false);
}
};
useEffect(() => {
if (context.open && scrollerRef?.current) {
scrollerRef.current.scrollTop = 0;
}
}, [context.open, scrollerRef]);
return (
<AnimatePresence>
{isVisible && (
<motion.div
ref={containerRef}
className={cn(
"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,
)}
initial={{ x: "100%" }}
animate={{ x: context.open ? 0 : "100%" }}
exit={{ x: "100%" }}
transition={{ type: "spring", damping: 25, stiffness: 200 }}
onAnimationComplete={handleAnimationComplete}
>
{children}
</motion.div>
)}
</AnimatePresence>
);
}
interface MobilePageHeaderProps extends React.HTMLAttributes<HTMLDivElement> {
onClose?: () => void;
actions?: React.ReactNode;
}
export function MobilePageHeader({
children,
className,
onClose,
actions,
...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);
}
};
return (
<div
className={cn(
"sticky -top-2 z-50 mb-2 flex items-center justify-center bg-background p-4",
className,
)}
{...props}
>
<Button
className="absolute left-0 rounded-lg"
aria-label={t("label.back", { ns: "common" })}
size="sm"
onClick={handleClose}
>
<IoMdArrowRoundBack className="size-5 text-secondary-foreground" />
</Button>
<div className="flex flex-row text-center">{children}</div>
{actions && (
<div className="absolute right-0 flex items-center gap-2">
{actions}
</div>
)}
</div>
);
}
type MobilePageTitleProps = React.HTMLAttributes<HTMLHeadingElement>;
export function MobilePageTitle({ className, ...props }: MobilePageTitleProps) {
return <h2 className={cn("text-lg", className)} {...props} />;
}
type MobilePageDescriptionProps = React.HTMLAttributes<HTMLParagraphElement>;
export function MobilePageDescription({
className,
...props
}: MobilePageDescriptionProps) {
return (
<p className={cn("text-sm text-muted-foreground", className)} {...props} />
);
}