import * as React from 'react'; import { useFloating, autoUpdate, offset, flip, shift, useClick, useDismiss, useRole, useInteractions, useMergeRefs, Placement, FloatingPortal, FloatingFocusManager, useId, } from '@floating-ui/react'; interface PopoverOptions { initialOpen?: boolean; placement?: Placement; modal?: boolean; open?: boolean; onOpenChange?: (open: boolean) => void; } export function usePopover({ initialOpen = false, placement = 'bottom', modal, open: controlledOpen, onOpenChange: setControlledOpen, }: PopoverOptions = {}): { open: boolean; setOpen: (open: boolean) => void; interactions: ReturnType<typeof useInteractions>; data: ReturnType<typeof useFloating>; floatingContext: ReturnType<typeof useFloating>['context']; labelId: string | undefined; descriptionId: string | undefined; setLabelId: React.Dispatch<React.SetStateAction<string | undefined>>; setDescriptionId: React.Dispatch<React.SetStateAction<string | undefined>>; modal: boolean; } { const [uncontrolledOpen, setUncontrolledOpen] = React.useState(initialOpen); const [labelId, setLabelId] = React.useState<string | undefined>(); const [descriptionId, setDescriptionId] = React.useState<string | undefined>(); const open = controlledOpen ?? uncontrolledOpen; const setOpen = setControlledOpen ?? setUncontrolledOpen; const data = useFloating({ placement, open, onOpenChange: setOpen, whileElementsMounted: autoUpdate, middleware: [ offset(5), flip({ crossAxis: placement.includes('-'), fallbackAxisSideDirection: 'end', padding: 5, }), shift({ padding: 5 }), ], }); const context = data.context; const click = useClick(context, { enabled: controlledOpen == null, }); const dismiss = useDismiss(context); const role = useRole(context); const interactions = useInteractions([click, dismiss, role]); return React.useMemo( () => ({ open, setOpen, interactions: interactions, data: data, floatingContext: context, modal: modal || false, labelId, descriptionId, setLabelId, setDescriptionId, }), [open, setOpen, interactions, data, modal, labelId, descriptionId], ); } type ContextType = | (ReturnType<typeof usePopover> & { setLabelId: React.Dispatch<React.SetStateAction<string | undefined>>; setDescriptionId: React.Dispatch<React.SetStateAction<string | undefined>>; }) | null; const PopoverContext = React.createContext<ContextType>(null); export const usePopoverContext = () => { const context = React.useContext(PopoverContext); if (context == null) { throw new Error('Popover components must be wrapped in <Popover />'); } return context; }; export function Popover({ children, modal = false, ...restOptions }: { children: React.ReactNode; } & PopoverOptions) { // This can accept any props as options, e.g. `placement`, // or other positioning options. const popover = usePopover({ modal, ...restOptions }); return <PopoverContext.Provider value={popover}>{children}</PopoverContext.Provider>; } interface PopoverTriggerProps { children: React.ReactNode; asChild?: boolean; } export const PopoverTrigger = React.forwardRef<HTMLElement, React.HTMLProps<HTMLElement> & PopoverTriggerProps>(function PopoverTrigger( { children, asChild = false, ...props }, propRef, ) { const context = usePopoverContext(); const childrenRef = (children as any).ref; const ref = useMergeRefs([context.data.refs.setReference, propRef, childrenRef]); // `asChild` allows the user to pass any element as the anchor if (asChild && React.isValidElement(children)) { return React.cloneElement( children, context.interactions.getReferenceProps({ ref, ...props, ...children.props, 'data-state': context.open ? 'open' : 'closed', }), ); } return ( <div ref={ref} // The user can style the trigger based on the state data-state={context.open ? 'open' : 'closed'} {...context.interactions.getReferenceProps(props)} > {children} </div> ); }); export const PopoverContent = React.forwardRef<HTMLDivElement, React.HTMLProps<HTMLDivElement>>(function PopoverContent( { style, className, ...props }, propRef, ) { const context = usePopoverContext(); const ref = useMergeRefs([context.data.refs.setFloating, propRef]); if (!context.floatingContext.open) return null; return ( <FloatingPortal> <FloatingFocusManager context={context.floatingContext} modal={context.modal}> <div ref={ref} style={{ ...context.data.floatingStyles, ...style }} aria-labelledby={context.labelId} aria-describedby={context.descriptionId} {...context.interactions.getFloatingProps(props)} className={`w-fit ${className || ''}`} > {props.children} </div> </FloatingFocusManager> </FloatingPortal> ); }); export const PopoverHeading = React.forwardRef<HTMLHeadingElement, React.HTMLProps<HTMLHeadingElement>>( function PopoverHeading(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 ( <h2 {...props} ref={ref} id={id}> {props.children} </h2> ); }, ); export const PopoverDescription = React.forwardRef<HTMLParagraphElement, React.HTMLProps<HTMLParagraphElement>>( function PopoverDescription(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 <p {...props} ref={ref} id={id} />; }, ); export const PopoverClose = React.forwardRef<HTMLButtonElement, React.ButtonHTMLAttributes<HTMLButtonElement>>( function PopoverClose(props, ref) { const { setOpen } = usePopoverContext(); return ( <button type="button" ref={ref} {...props} onClick={(event) => { props.onClick?.(event); setOpen(false); }} /> ); }, );