250 lines
6.9 KiB
TypeScript

import { type AsChild, Slot, type VariantProps, vcn } from "@pswui-lib";
import React, { useContext, useEffect, useRef } from "react";
interface IPopoverContext {
controlled: boolean;
opened: boolean;
}
const PopoverContext = React.createContext<
[IPopoverContext, React.Dispatch<React.SetStateAction<IPopoverContext>>]
>([
{
opened: false,
controlled: false,
},
() => {
if (process.env.NODE_ENV && process.env.NODE_ENV === "development") {
console.warn(
"It seems like you're using PopoverContext outside of a provider.",
);
}
},
]);
interface PopoverProps extends AsChild {
children: React.ReactNode;
opened?: boolean;
}
const Popover = ({ children, opened, asChild }: PopoverProps) => {
const [state, setState] = React.useState<IPopoverContext>({
opened: opened ?? false,
controlled: opened !== undefined,
});
useEffect(() => {
setState((p) => ({
...p,
controlled: opened !== undefined,
opened: opened !== undefined ? opened : p.opened,
}));
}, [opened]);
const Comp = asChild ? Slot : "div";
return (
<PopoverContext.Provider value={[state, setState]}>
<Comp className="relative">{children}</Comp>
</PopoverContext.Provider>
);
};
const PopoverTrigger = ({ children }: { children: React.ReactNode }) => {
const [_, setState] = React.useContext(PopoverContext);
function setOpen() {
setState((prev) => ({ ...prev, opened: true }));
}
return <Slot onClick={setOpen}>{children}</Slot>;
};
const popoverColors = {
background: "bg-white dark:bg-black",
border: "border-neutral-200 dark:border-neutral-800",
};
const [popoverContentVariant, resolvePopoverContentVariantProps] = vcn({
base: `absolute transition-all duration-150 border rounded-lg p-0.5 z-10 *:w-full ${popoverColors.background} ${popoverColors.border}`,
variants: {
direction: {
row: "",
col: "",
},
anchor: {
start: "",
middle: "",
end: "",
},
align: {
start: "",
middle: "",
end: "",
},
position: {
start: "",
end: "",
},
offset: {
sm: "[--popover-offset:2px]",
md: "[--popover-offset:4px]",
lg: "[--popover-offset:8px]",
},
opened: {
true: "opacity-1 scale-100 pointer-events-auto select-auto touch-auto",
false: "opacity-0 scale-75 pointer-events-none select-none touch-none",
},
},
defaults: {
direction: "col",
anchor: "middle",
align: "middle",
position: "end",
opened: false,
offset: "md",
},
dynamics: [
function originClass({ direction, anchor, position }) {
switch (`${direction} ${position} ${anchor}` as const) {
// left
case "row start start":
return "origin-top-right";
case "row start middle":
return "origin-right";
case "row start end":
return "origin-bottom-right";
// right
case "row end start":
return "origin-top-left";
case "row end middle":
return "origin-left";
case "row end end":
return "origin-bottom-left";
// top
case "col start start":
return "origin-bottom-left";
case "col start middle":
return "origin-bottom";
case "col start end":
return "origin-bottom-right";
// bottom
case "col end start":
return "origin-top-left";
case "col end middle":
return "origin-top";
case "col end end":
return "origin-top-right";
}
},
function basePositionClass({ position, direction }) {
switch (`${direction} ${position}` as const) {
case "col start":
return "bottom-[calc(100%+var(--popover-offset))]";
case "col end":
return "top-[calc(100%+var(--popover-offset))]";
case "row start":
return "right-[calc(100%+var(--popover-offset))]";
case "row end":
return "left-[calc(100%+var(--popover-offset))]";
}
},
function directionPositionClass({ direction, anchor, align }) {
switch (`${direction} ${anchor} ${align}` as const) {
case "col start start":
return "left-0";
case "col start middle":
return "left-1/2";
case "col start end":
return "left-full";
case "col middle start":
return "left-0 -translate-x-1/2";
case "col middle middle":
return "left-1/2 -translate-x-1/2";
case "col middle end":
return "right-0 translate-x-1/2";
case "col end start":
return "right-full";
case "col end middle":
return "right-1/2";
case "col end end":
return "right-0";
case "row start start":
return "top-0";
case "row start middle":
return "top-1/2";
case "row start end":
return "top-full";
case "row middle start":
return "top-0 -translate-y-1/2";
case "row middle middle":
return "top-1/2 -translate-y-1/2";
case "row middle end":
return "bottom-0 translate-y-1/2";
case "row end start":
return "bottom-full";
case "row end middle":
return "bottom-1/2";
case "row end end":
return "bottom-0";
}
},
],
});
interface PopoverContentProps
extends Omit<VariantProps<typeof popoverContentVariant>, "opened">,
React.ComponentPropsWithoutRef<"div">,
AsChild {}
const PopoverContent = React.forwardRef<HTMLDivElement, PopoverContentProps>(
(props, ref) => {
const [variantProps, otherPropsCompressed] =
resolvePopoverContentVariantProps(props);
const { children, asChild, ...otherPropsExtracted } = otherPropsCompressed;
const [state, setState] = useContext(PopoverContext);
const internalRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
function handleOutsideClick(e: MouseEvent) {
if (
internalRef.current &&
!internalRef.current.contains(e.target as Node | null)
) {
setState((prev) => ({ ...prev, opened: false }));
}
}
!state.controlled &&
document.addEventListener("mousedown", handleOutsideClick);
return () => {
document.removeEventListener("mousedown", handleOutsideClick);
};
}, [state.controlled, setState]);
const Comp = asChild ? Slot : "div";
return (
<Comp
{...otherPropsExtracted}
className={popoverContentVariant({
...variantProps,
opened: state.opened,
})}
ref={(el: HTMLDivElement) => {
internalRef.current = el;
if (typeof ref === "function") {
ref(el);
} else if (ref) {
ref.current = el;
}
}}
>
{children}
</Comp>
);
},
);
PopoverContent.displayName = "PopoverContent";
export { Popover, PopoverTrigger, PopoverContent };