// // Copyright 2023 DXOS.org // // This is based upon `@radix-ui/react-popover` fetched 25 Oct 2024 at https://github.com/radix-ui/primitives at commit 374c7d7. import { composeEventHandlers } from '@radix-ui/primitive'; import { useComposedRefs } from '@radix-ui/react-compose-refs'; import { createContextScope } from '@radix-ui/react-context'; import type { Scope } from '@radix-ui/react-context'; import { DismissableLayer } from '@radix-ui/react-dismissable-layer'; import { useFocusGuards } from '@radix-ui/react-focus-guards'; import { FocusScope } from '@radix-ui/react-focus-scope'; import { useId } from '@radix-ui/react-id'; import * as PopperPrimitive from '@radix-ui/react-popper'; import { createPopperScope } from '@radix-ui/react-popper'; import { Portal as PortalPrimitive } from '@radix-ui/react-portal'; import { Presence } from '@radix-ui/react-presence'; import { Primitive } from '@radix-ui/react-primitive'; import { Slot } from '@radix-ui/react-slot'; import { useControllableState } from '@radix-ui/react-use-controllable-state'; import { hideOthers } from 'aria-hidden'; import React, { type ComponentPropsWithRef, forwardRef, type ElementRef, type RefObject, type ReactNode, useRef, useCallback, type ComponentPropsWithoutRef, type FC, useState, useEffect, type MutableRefObject, } from 'react'; import { RemoveScroll } from 'react-remove-scroll'; import { useElevationContext, useThemeContext } from '../../hooks'; import { useSafeCollisionPadding } from '../../hooks/useSafeCollisionPadding'; import { type ThemedClassName } from '../../util'; /* ------------------------------------------------------------------------------------------------- * Popover * ----------------------------------------------------------------------------------------------- */ const POPOVER_NAME = 'Popover'; type ScopedProps

= P & { __scopePopover?: Scope }; const [createPopoverContext, createPopoverScope] = createContextScope(POPOVER_NAME, [createPopperScope]); const usePopperScope = createPopperScope(); type PopoverContextValue = { triggerRef: MutableRefObject; contentId: string; open: boolean; onOpenChange(open: boolean): void; onOpenToggle(): void; hasCustomAnchor: boolean; onCustomAnchorAdd(): void; onCustomAnchorRemove(): void; modal: boolean; }; const [PopoverProvider, usePopoverContext] = createPopoverContext(POPOVER_NAME); interface PopoverRootProps { children?: ReactNode; open?: boolean; defaultOpen?: boolean; onOpenChange?: (open: boolean) => void; modal?: boolean; } const PopoverRoot: FC = (props: ScopedProps) => { const { __scopePopover, children, open: openProp, defaultOpen, onOpenChange, modal = false } = props; const popperScope = usePopperScope(__scopePopover); const triggerRef = useRef(null); const [hasCustomAnchor, setHasCustomAnchor] = useState(false); const [open = false, setOpen] = useControllableState({ prop: openProp, defaultProp: defaultOpen, onChange: onOpenChange, }); return ( } open={open} onOpenChange={setOpen} onOpenToggle={useCallback(() => setOpen((prevOpen) => !prevOpen), [setOpen])} hasCustomAnchor={hasCustomAnchor} onCustomAnchorAdd={useCallback(() => setHasCustomAnchor(true), [])} onCustomAnchorRemove={useCallback(() => setHasCustomAnchor(false), [])} modal={modal} > {children} ); }; PopoverRoot.displayName = POPOVER_NAME; /* ------------------------------------------------------------------------------------------------- * PopoverAnchor * ----------------------------------------------------------------------------------------------- */ const ANCHOR_NAME = 'PopoverAnchor'; type PopoverAnchorElement = ElementRef; type PopperAnchorProps = ComponentPropsWithoutRef; interface PopoverAnchorProps extends PopperAnchorProps {} const PopoverAnchor = forwardRef( (props: ScopedProps, forwardedRef) => { const { __scopePopover, ...anchorProps } = props; const context = usePopoverContext(ANCHOR_NAME, __scopePopover); const popperScope = usePopperScope(__scopePopover); const { onCustomAnchorAdd, onCustomAnchorRemove } = context; useEffect(() => { onCustomAnchorAdd(); return () => onCustomAnchorRemove(); }, [onCustomAnchorAdd, onCustomAnchorRemove]); return ; }, ); PopoverAnchor.displayName = ANCHOR_NAME; /* ------------------------------------------------------------------------------------------------- * PopoverTrigger * ----------------------------------------------------------------------------------------------- */ const TRIGGER_NAME = 'PopoverTrigger'; type PopoverTriggerElement = ElementRef; type PrimitiveButtonProps = ComponentPropsWithoutRef; interface PopoverTriggerProps extends PrimitiveButtonProps {} const PopoverTrigger = forwardRef( (props: ScopedProps, forwardedRef) => { const { __scopePopover, ...triggerProps } = props; const context = usePopoverContext(TRIGGER_NAME, __scopePopover); const popperScope = usePopperScope(__scopePopover); const composedTriggerRef = useComposedRefs(forwardedRef, context.triggerRef); const trigger = ( ); return context.hasCustomAnchor ? ( trigger ) : ( {trigger} ); }, ); PopoverTrigger.displayName = TRIGGER_NAME; /* ------------------------------------------------------------------------------------------------- * PopoverVirtualTrigger * ----------------------------------------------------------------------------------------------- */ const VIRTUAL_TRIGGER_NAME = 'PopoverVirtualTrigger'; interface PopoverVirtualTriggerProps { virtualRef: RefObject; } const PopoverVirtualTrigger = (props: ScopedProps) => { const { __scopePopover, virtualRef } = props; const context = usePopoverContext(VIRTUAL_TRIGGER_NAME, __scopePopover); const popperScope = usePopperScope(__scopePopover); useEffect(() => { if (virtualRef.current) { context.triggerRef.current = virtualRef.current; } }); return ; }; PopoverVirtualTrigger.displayName = VIRTUAL_TRIGGER_NAME; /* ------------------------------------------------------------------------------------------------- * PopoverPortal * ----------------------------------------------------------------------------------------------- */ const PORTAL_NAME = 'PopoverPortal'; type PortalContextValue = { forceMount?: true }; const [PortalProvider, usePortalContext] = createPopoverContext(PORTAL_NAME, { forceMount: undefined, }); type PortalProps = ComponentPropsWithoutRef; interface PopoverPortalProps { children?: ReactNode; /** * Specify a container element to portal the content into. */ container?: PortalProps['container']; /** * Used to force mounting when more control is needed. Useful when * controlling animation with React animation libraries. */ forceMount?: true; } const PopoverPortal: FC = (props: ScopedProps) => { const { __scopePopover, forceMount, children, container } = props; const context = usePopoverContext(PORTAL_NAME, __scopePopover); return ( {children} ); }; PopoverPortal.displayName = PORTAL_NAME; /* ------------------------------------------------------------------------------------------------- * PopoverContent * ----------------------------------------------------------------------------------------------- */ const CONTENT_NAME = 'PopoverContent'; type PopoverContentProps = ThemedClassName & { /** * Used to force mounting when more control is needed. Useful when * controlling animation with React animation libraries. */ forceMount?: boolean; }; const PopoverContent = forwardRef( (props: ScopedProps, forwardedRef) => { const portalContext = usePortalContext(CONTENT_NAME, props.__scopePopover); const { forceMount = portalContext.forceMount, ...contentProps } = props; const context = usePopoverContext(CONTENT_NAME, props.__scopePopover); return ( {context.modal ? ( ) : ( )} ); }, ); PopoverContent.displayName = CONTENT_NAME; /* ----------------------------------------------------------------------------------------------- */ type PopoverContentTypeElement = PopoverContentImplElement; export interface PopoverContentTypeProps extends Omit {} const PopoverContentModal = forwardRef( (props: ScopedProps, forwardedRef) => { const context = usePopoverContext(CONTENT_NAME, props.__scopePopover); const contentRef = useRef(null); const composedRefs = useComposedRefs(forwardedRef, contentRef); const isRightClickOutsideRef = useRef(false); // aria-hide everything except the content (better supported equivalent to setting aria-modal) useEffect(() => { const content = contentRef.current; if (content) { return hideOthers(content); } }, []); return ( { event.preventDefault(); if (!isRightClickOutsideRef.current) { context.triggerRef.current?.focus(); } })} onPointerDownOutside={composeEventHandlers( props.onPointerDownOutside, (event) => { const originalEvent = event.detail.originalEvent; const ctrlLeftClick = originalEvent.button === 0 && originalEvent.ctrlKey === true; const isRightClick = originalEvent.button === 2 || ctrlLeftClick; isRightClickOutsideRef.current = isRightClick; }, { checkForDefaultPrevented: false }, )} // When focus is trapped, a `focusout` event may still happen. // We make sure we don't trigger our `onDismiss` in such case. onFocusOutside={composeEventHandlers(props.onFocusOutside, (event) => event.preventDefault(), { checkForDefaultPrevented: false, })} /> ); }, ); const PopoverContentNonModal = forwardRef( (props: ScopedProps, forwardedRef) => { const context = usePopoverContext(CONTENT_NAME, props.__scopePopover); const hasInteractedOutsideRef = useRef(false); const hasPointerDownOutsideRef = useRef(false); return ( { props.onCloseAutoFocus?.(event); if (!event.defaultPrevented) { if (!hasInteractedOutsideRef.current) { context.triggerRef.current?.focus(); } // Always prevent auto focus because we either focus manually or want user agent focus event.preventDefault(); } hasInteractedOutsideRef.current = false; hasPointerDownOutsideRef.current = false; }} onInteractOutside={(event) => { props.onInteractOutside?.(event); if (!event.defaultPrevented) { hasInteractedOutsideRef.current = true; if (event.detail.originalEvent.type === 'pointerdown') { hasPointerDownOutsideRef.current = true; } } // Prevent dismissing when clicking the trigger. // As the trigger is already setup to close, without doing so would // cause it to close and immediately open. const target = event.target as HTMLElement; const targetIsTrigger = context.triggerRef.current?.contains(target); if (targetIsTrigger) { event.preventDefault(); } // On Safari if the trigger is inside a container with tabIndex={0}, when clicked // we will get the pointer down outside event on the trigger, but then a subsequent // focus outside event on the container, we ignore any focus outside event when we've // already had a pointer down outside event. if (event.detail.originalEvent.type === 'focusin' && hasPointerDownOutsideRef.current) { event.preventDefault(); } }} /> ); }, ); /* ----------------------------------------------------------------------------------------------- */ type PopoverContentImplElement = ElementRef; type FocusScopeProps = ComponentPropsWithoutRef; type DismissableLayerProps = ComponentPropsWithoutRef; type PopperContentProps = ThemedClassName>; interface PopoverContentImplProps extends Omit, Omit { /** * Whether focus should be trapped within the `Popover` * (default: false) */ trapFocus?: FocusScopeProps['trapped']; /** * Event handler called when auto-focusing on open. * Can be prevented. */ onOpenAutoFocus?: FocusScopeProps['onMountAutoFocus']; /** * Event handler called when auto-focusing on close. * Can be prevented. */ onCloseAutoFocus?: FocusScopeProps['onUnmountAutoFocus']; } const PopoverContentImpl = forwardRef( (props: ScopedProps, forwardedRef) => { const { __scopePopover, trapFocus, onOpenAutoFocus, onCloseAutoFocus, disableOutsidePointerEvents, onEscapeKeyDown, onPointerDownOutside, onFocusOutside, onInteractOutside, collisionPadding = 8, classNames, ...contentProps } = props; const context = usePopoverContext(CONTENT_NAME, __scopePopover); const popperScope = usePopperScope(__scopePopover); const { tx } = useThemeContext(); const elevation = useElevationContext(); const safeCollisionPadding = useSafeCollisionPadding(collisionPadding); // Make sure the whole tree has focus guards as our `Popover` may be // the last element in the DOM (because of the `Portal`) useFocusGuards(); return ( context.onOpenChange(false)} > ); }, ); /* ------------------------------------------------------------------------------------------------- * PopoverClose * ----------------------------------------------------------------------------------------------- */ const CLOSE_NAME = 'PopoverClose'; type PopoverCloseElement = ElementRef; interface PopoverCloseProps extends PrimitiveButtonProps {} const PopoverClose = forwardRef( (props: ScopedProps, forwardedRef) => { const { __scopePopover, ...closeProps } = props; const context = usePopoverContext(CLOSE_NAME, __scopePopover); return ( context.onOpenChange(false))} /> ); }, ); PopoverClose.displayName = CLOSE_NAME; /* ------------------------------------------------------------------------------------------------- * PopoverArrow * ----------------------------------------------------------------------------------------------- */ const ARROW_NAME = 'PopoverArrow'; type PopoverArrowElement = ElementRef; type PopperArrowProps = ThemedClassName>; interface PopoverArrowProps extends PopperArrowProps {} const PopoverArrow = forwardRef( (props: ScopedProps, forwardedRef) => { const { __scopePopover, classNames, ...arrowProps } = props; const popperScope = usePopperScope(__scopePopover); const { tx } = useThemeContext(); return ( ); }, ); PopoverArrow.displayName = ARROW_NAME; /* ------------------------------------------------------------------------------------------------- * PopoverViewport * ----------------------------------------------------------------------------------------------- */ type PopoverViewportProps = ThemedClassName> & { asChild?: boolean; constrainInline?: boolean; constrainBlock?: boolean; }; const PopoverViewport = forwardRef( ({ classNames, asChild, constrainInline = true, constrainBlock = true, children, ...props }, forwardedRef) => { const { tx } = useThemeContext(); const Root = asChild ? Slot : Primitive.div; return ( {children} ); }, ); /* ----------------------------------------------------------------------------------------------- */ const getState = (open: boolean) => (open ? 'open' : 'closed'); type PopoverContentInteractOutsideEvent = Parameters>[0]; export const Popover = { Root: PopoverRoot, Anchor: PopoverAnchor, Trigger: PopoverTrigger, VirtualTrigger: PopoverVirtualTrigger, Portal: PopoverPortal, Content: PopoverContent, Close: PopoverClose, Arrow: PopoverArrow, Viewport: PopoverViewport, }; export { createPopoverScope }; export type { PopoverRootProps, PopoverAnchorProps, PopoverTriggerProps, PopoverVirtualTriggerProps, PopoverPortalProps, PopoverContentProps, PopoverCloseProps, PopoverArrowProps, PopoverViewportProps, PopoverContentInteractOutsideEvent, };