//
// 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,
};