Family Popover Menu
November 2024
TSX
import { AnimatePresence, motion } from 'framer-motion';
import React, { useState } from 'react';
import { useClickOutside } from '@/hook/useClickOutside';
import { useMediaQuery } from '@/hook/useMediaQuery';
interface IconProps {
className?: string;
}
const FamilyPopoverMenu = () => {
const refMenu = React.useRef<HTMLDivElement>(null);
const [openMenu, setOpenMenu] = useState(false);
const isScreenSizeSm = useMediaQuery('(max-width: 640px)');
const duration = 0.2;
const transition = { duration, ease: [0.32, 0.72, 0, 1] };
const menuVariants = {
open: {
opacity: 1,
width: isScreenSizeSm ? '100%' : '320px',
height: 220,
borderRadius: '16px',
bottom: -44,
transition,
},
closed: {
bottom: 0,
opacity: 1,
width: '48px',
height: 48,
borderRadius: '50%',
transition,
},
};
const contentVariants = {
open: { opacity: 1, scale: 1, transition },
closed: { opacity: 0, scale: 1, transition },
};
const buttonVariants = {
open: {
opacity: 0,
transition: { duration: duration / 2 },
},
closed: {
opacity: 1,
transition: { duration },
},
};
const items = [
{ title: 'Settings', text: 'Adjust your preferences', icon: GearIcon },
{ title: 'Messages', text: 'View your messages', icon: MessagesIcon },
{ title: 'Favorites', text: 'Manage your favorites', icon: HeartIcon },
];
useClickOutside<HTMLDivElement>(refMenu, () => setOpenMenu(false));
return (
<div className="relative mx-6 mb-16 flex h-[300px] w-full items-end justify-start">
<AnimatePresence>
{openMenu && (
<motion.div
className="absolute bottom-0 left-0 flex flex-col items-center overflow-hidden bg-white p-1 text-black"
initial="closed"
animate="open"
exit="closed"
variants={menuVariants}
onClick={(e) => e.stopPropagation()}
ref={refMenu}
>
<motion.ul variants={contentVariants} className="relative flex w-full flex-col space-y-1 pl-0 mb-0">
{items.map((item, index) => (
<li
key={index}
className="w-full select-none rounded-b-[4px] rounded-t-[4px] bg-white transition-transform first:rounded-t-[12px] last:rounded-b-[12px] active:scale-[0.98]"
>
<div className="flex items-center py-3">
<div className="px-4">
<item.icon className="h-6 w-6 text-black" />
</div>
<div>
<h3 className="text-base text-black m-0 font-medium">{item.title}</h3>
<p className="text-sm text-black/50 m-0">{item.text}</p>
</div>
</div>
</li>
))}
</motion.ul>
</motion.div>
)}
</AnimatePresence>
<motion.button
className="absolute bottom-0 left-0 flex h-12 w-12 items-center justify-center rounded-full outline-none bg-white text-black"
disabled={openMenu}
onClick={(e) => {
e.stopPropagation();
setOpenMenu(true);
}}
variants={buttonVariants}
initial="closed"
animate={openMenu ? 'open' : 'closed'}
whileTap={{ scale: 0.95 }}
>
<PlusIcon className="h-7 w-7" />
</motion.button>
</div>
);
};
const MessagesIcon = ({ className }: IconProps) => (
<svg className={className} xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<g fill="none" stroke="black">
<rect width="16" height="12" x="4" y="6" rx="2" />
<path d="m4 9l7.106 3.553a2 2 0 0 0 1.788 0L20 9" />
</g>
</svg>
);
const GearIcon = ({ className }: IconProps) => (
<svg className={className} xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
<g fill="none" stroke="black" strokeWidth="1.5">
<circle cx="12" cy="12" r="3" />
<path d="M13.765 2.152C13.398 2 12.932 2 12 2s-1.398 0-1.765.152a2 2 0 0 0-1.083 1.083c-.092.223-.129.484-.143.863a1.62 1.62 0 0 1-.79 1.353a1.62 1.62 0 0 1-1.567.008c-.336-.178-.579-.276-.82-.308a2 2 0 0 0-1.478.396C4.04 5.79 3.806 6.193 3.34 7s-.7 1.21-.751 1.605a2 2 0 0 0 .396 1.479c.148.192.355.353.676.555c.473.297.777.803.777 1.361s-.304 1.064-.777 1.36c-.321.203-.529.364-.676.556a2 2 0 0 0-.396 1.479c.052.394.285.798.75 1.605c.467.807.7 1.21 1.015 1.453a2 2 0 0 0 1.479.396c.24-.032.483-.13.819-.308a1.62 1.62 0 0 1 1.567.008c.483.28.77.795.79 1.353c.014.38.05.64.143.863a2 2 0 0 0 1.083 1.083C10.602 22 11.068 22 12 22s1.398 0 1.765-.152a2 2 0 0 0 1.083-1.083c.092-.223.129-.483.143-.863c.02-.558.307-1.074.79-1.353a1.62 1.62 0 0 1 1.567-.008c.336.178.579.276.819.308a2 2 0 0 0 1.479-.396c.315-.242.548-.646 1.014-1.453s.7-1.21.751-1.605a2 2 0 0 0-.396-1.479c-.148-.192-.355-.353-.676-.555A1.62 1.62 0 0 1 19.562 12c0-.558.304-1.064.777-1.36c.321-.203.529-.364.676-.556a2 2 0 0 0 .396-1.479c-.052-.394-.285-.798-.75-1.605c-.467-.807-.7-1.21-1.015-1.453a2 2 0 0 0-1.479-.396c-.24.032-.483.13-.82.308a1.62 1.62 0 0 1-1.566-.008a1.62 1.62 0 0 1-.79-1.353c-.014-.38-.05-.64-.143-.863a2 2 0 0 0-1.083-1.083Z" />
</g>
</svg>
);
const HeartIcon = (props: IconProps) => {
return (
<svg className={props.className} xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
<path fill="black" fillRule="evenodd" d="M5.624 4.424C3.965 5.182 2.75 6.986 2.75 9.137c0 2.197.9 3.891 2.188 5.343c1.063 1.196 2.349 2.188 3.603 3.154q.448.345.885.688c.526.415.995.778 1.448 1.043s.816.385 1.126.385s.674-.12 1.126-.385c.453-.265.922-.628 1.448-1.043q.437-.344.885-.687c1.254-.968 2.54-1.959 3.603-3.155c1.289-1.452 2.188-3.146 2.188-5.343c0-2.15-1.215-3.955-2.874-4.713c-1.612-.737-3.778-.542-5.836 1.597a.75.75 0 0 1-1.08 0C9.402 3.882 7.236 3.687 5.624 4.424M12 4.46C9.688 2.39 7.099 2.1 5 3.059C2.786 4.074 1.25 6.426 1.25 9.138c0 2.665 1.11 4.699 2.567 6.339c1.166 1.313 2.593 2.412 3.854 3.382q.43.33.826.642c.513.404 1.063.834 1.62 1.16s1.193.59 1.883.59s1.326-.265 1.883-.59c.558-.326 1.107-.756 1.62-1.16q.396-.312.826-.642c1.26-.97 2.688-2.07 3.854-3.382c1.457-1.64 2.567-3.674 2.567-6.339c0-2.712-1.535-5.064-3.75-6.077c-2.099-.96-4.688-.67-7 1.399" clipRule="evenodd" />
</svg>
)
}
const PlusIcon = (props: IconProps) => {
return (
<svg className={props.className} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path fill="none" stroke="black" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 12h14m-7-7v14" />
</svg>
)
}
export default FamilyPopoverMenu;
// hook/useClickOutside.tsx
//import type { RefObject } from 'react'
// import { useEffect } from 'react';
// type EventType =
// | 'mousedown'
// | 'mouseup'
// | 'touchstart'
// | 'touchend'
// | 'focusin'
// | 'focusout'
// export function useClickOutside<T extends HTMLElement = HTMLElement>(
// ref: RefObject<T> | RefObject<T>[],
// handler: (event: MouseEvent | TouchEvent | FocusEvent) => void,
// eventType: EventType = 'mousedown',
// eventListenerOptions: AddEventListenerOptions = {},
// ): void {
// useEffect(() => {
// if (typeof window === 'undefined') return;
// const listener = (event: MouseEvent | TouchEvent | FocusEvent) => {
// const target = event.target as Node;
// if (!target || !target.isConnected) return;
// const isOutside = Array.isArray(ref)
// ? ref.filter(r => Boolean(r.current)).every(r => r.current && !r.current.contains(target))
// : ref.current && !ref.current.contains(target);
// if (isOutside) {
// handler(event);
// }
// };
// window.addEventListener(eventType, listener, eventListenerOptions);
// return () => {
// window.removeEventListener(eventType, listener, eventListenerOptions);
// };
// }, [ref, handler, eventType, eventListenerOptions]);
// }
// hook/useMediaQuery.tsx
//import React from "react";
//import { useState, useCallback } from "react";
//const useIsomorphicLayoutEffect =
//typeof window !== "undefined" ? React.useLayoutEffect : React.useEffect;
//export const useMediaQuery = (width: unknown) => {
//const [targetReached, setTargetReached] = useState(false);
//const updateTarget = useCallback((e: { matches: boolean | ((prevState: boolean) => boolean); }) => {
//setTargetReached(e.matches);
//}, []);
//useIsomorphicLayoutEffect(() => {
// const media = window.matchMedia(`(max-width: ${width}px)`);
// media.addListener(updateTarget);
// if (media.matches) {
// setTargetReached(true);
// }
// return () => media.removeListener(updateTarget);
//}, [updateTarget, width]);
//return targetReached;
//};