commit a1612f527a5af9b3934be89eddbb78e28f3aa09a Author: faisolavolut Date: Thu Jan 2 10:16:29 2025 +0700 first commit diff --git a/components/Popover/Popover.css b/components/Popover/Popover.css new file mode 100644 index 0000000..36ac889 --- /dev/null +++ b/components/Popover/Popover.css @@ -0,0 +1,4 @@ +[data-floating-ui-portal] > div { + z-index: 100; + } + \ No newline at end of file diff --git a/components/Popover/Popover.tsx b/components/Popover/Popover.tsx new file mode 100644 index 0000000..f90a991 --- /dev/null +++ b/components/Popover/Popover.tsx @@ -0,0 +1,376 @@ +import { + FloatingFocusManager, + FloatingOverlay, + FloatingPortal, + Placement, + arrow, + autoUpdate, + flip, + offset, + shift, + useClick, + useDismiss, + useFloating, + useId, + useInteractions, + useMergeRefs, + useRole, +} from "@floating-ui/react"; +import * as React from "react"; +import "./Popover.css"; +import { css } from "@emotion/css"; + +interface PopoverOptions { + initialOpen?: boolean; + placement?: Placement; + modal?: boolean; + open?: boolean; + offset?: number; + onOpenChange?: (open: boolean) => void; + autoFocus?: boolean; + backdrop?: boolean | "self"; + root?: HTMLElement; +} + +export function usePopover({ + initialOpen = false, + placement = "bottom", + modal = false, + open: controlledOpen, + offset: popoverOffset = 5, + onOpenChange: setControlledOpen, + autoFocus = false, + backdrop = true, + root, +}: PopoverOptions = {}) { + const arrowRef = React.useRef(null); + const [uncontrolledOpen, setUncontrolledOpen] = React.useState(initialOpen); + const [labelId, setLabelId] = React.useState(); + const [descriptionId, setDescriptionId] = React.useState(); + + // Determine whether the popover is open + const open = controlledOpen ?? uncontrolledOpen; + const setOpen = setControlledOpen ?? setUncontrolledOpen; + + // Floating UI setup + const data = useFloating({ + placement, + open, + onOpenChange: setOpen, + whileElementsMounted: autoUpdate, + middleware: [ + offset(popoverOffset), + flip({ + fallbackAxisSideDirection: "end", + padding: 8, + }), + shift({ padding: 5 }), + arrow({ element: arrowRef }), + ], + }); + + const context = data.context; + + // Add interactions (click, dismiss, role) + const click = useClick(context, { + enabled: controlledOpen == null, // Only enable if not controlled + }); + const dismiss = useDismiss(context); + const role = useRole(context); + + // Combine all interactions + const interactions = useInteractions([click, dismiss, role]); + + // Return memoized popover properties + return React.useMemo( + () => ({ + open, + setOpen, + ...interactions, + ...data, + arrowRef, + modal, + labelId, + descriptionId, + setLabelId, + setDescriptionId, + backdrop, + autoFocus, + root, + }), + [ + open, + setOpen, + interactions, + data, + modal, + labelId, + descriptionId, + backdrop, + autoFocus, + root, + ] + ); +} + + +function mapPlacementSideToCSSProperty(placement: Placement) { + const staticPosition = placement.split("-")[0]; + + const staticSide = { + top: "bottom", + right: "left", + bottom: "top", + left: "right", + }[staticPosition]; + + return staticSide; +} +function PopoverArrow() { + const context = usePopoverContext(); + const { x: arrowX, y: arrowY } = context.middlewareData.arrow || { + x: 0, + y: 0, + }; + const staticSide = mapPlacementSideToCSSProperty(context.placement) as string; + + return ( +
+ ); +} + +type ContextType = + | (ReturnType & { + setLabelId: React.Dispatch>; + setDescriptionId: React.Dispatch< + React.SetStateAction + >; + }) + | null; + +const PopoverContext = React.createContext(null); + +export const usePopoverContext = () => { + const context = React.useContext(PopoverContext); + + if (context == null) { + throw new Error("Popover components must be wrapped in "); + } + + return context; +}; + +export function Popover({ + children, + content, + modal = false, + className, + classNameTrigger, + arrow, + ...restOptions +}: { + root?: HTMLElement; + className?: string; + classNameTrigger?: string; + children: React.ReactNode; + content?: React.ReactNode; + arrow?: boolean; +} & PopoverOptions) { + const popover = usePopover({ modal, ...restOptions }); + + let _content = content; + if (!content) _content =
; + return ( + + { + popover.setOpen(!popover.open); + } + : undefined + } + > + {[children]} + + + {_content} + {(typeof arrow === "undefined" || arrow) && } + + + ); +} + +interface PopoverTriggerProps { + children: React.ReactNode; + asChild?: boolean; +} + +export const PopoverTrigger = React.forwardRef< + HTMLElement, + React.HTMLProps & PopoverTriggerProps +>(function PopoverTrigger({ children, asChild = false, ...props }, propRef) { + const context = usePopoverContext(); + + // Gabungkan refs dari popover context dan ref prop + const ref = useMergeRefs([context.refs.setReference, propRef]); + + // `asChild` memungkinkan elemen anak digunakan sebagai anchor + if (asChild && React.isValidElement(children)) { + return React.cloneElement(children, { + ref, + ...context.getReferenceProps({ + ...props, + ...children.props, + "data-state": context.open ? "open" : "closed", + }), + } as any); + } + + return ( +
+ {children} +
+ ); +}); + + +export const PopoverContent = React.forwardRef< + HTMLDivElement, + React.HTMLProps +>(function PopoverContent(props, propRef) { + const { context: floatingContext, ...context } = usePopoverContext(); + const ref = useMergeRefs([context.refs.setFloating, propRef]); + + if (!floatingContext.open) return null; + + const _content = ( +
+ {props.children} +
+ ); + + const content = context.autoFocus ? ( + + {_content} + + ) : ( + _content + ); + + return ( + + {context.backdrop ? ( + + {content} + + ) : ( + content + )} + + ); +}); + +export const PopoverHeading = React.forwardRef< + HTMLHeadingElement, + React.HTMLProps +>(function PopoverHeading({ children, ...props }, ref) { + const { setLabelId } = usePopoverContext(); + const id = useId(); + + // Only sets `aria-labelledby` on the Popover root element + // if this component is mounted inside it. + React.useLayoutEffect(() => { + setLabelId(id); + return () => setLabelId(undefined); + }, [id, setLabelId]); + + return ( +

+ {children} +

+ ); +}); + +export const PopoverDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLProps +>(function PopoverDescription({ children, ...props }, ref) { + const { setDescriptionId } = usePopoverContext(); + const id = useId(); + + // Only sets `aria-describedby` on the Popover root element + // if this component is mounted inside it. + React.useLayoutEffect(() => { + setDescriptionId(id); + return () => setDescriptionId(undefined); + }, [id, setDescriptionId]); + + return ( +

+ {children} +

+ ); +}); + +export const PopoverClose = React.forwardRef< + HTMLButtonElement, + React.ButtonHTMLAttributes +>(function PopoverClose(props, ref) { + const { setOpen } = usePopoverContext(); + return ( +