//
// Copyright 2023 DXOS.org
//
// This is based upon `@radix-ui/react-popover` fetched Oct 25, 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 ComponentPropsWithoutRef,
type ComponentPropsWithRef,
type ComponentRef,
type FC,
type ReactNode,
type RefObject,
forwardRef,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { RemoveScroll } from 'react-remove-scroll';
import { useElevationContext, useSafeCollisionPadding, useThemeContext } from '../../hooks';
import { type ThemedClassName } from '../../util';
//
// Context
//
type ScopedProps
= P & { __scopePopover?: Scope };
const POPOVER_NAME = 'Popover';
const [createPopoverContext, createPopoverScope] = createContextScope(POPOVER_NAME, [createPopperScope]);
const usePopperScope = createPopperScope();
type PopoverContextValue = {
triggerRef: RefObject;
contentId: string;
hasCustomAnchor: boolean;
modal: boolean;
open: boolean;
onOpenChange(open: boolean): void;
onOpenToggle(): void;
onCustomAnchorAdd(): void;
onCustomAnchorRemove(): void;
};
const [PopoverProvider, usePopoverContext] = createPopoverContext(POPOVER_NAME);
//
// PopoverRoot
//
type 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 = 'Popover.Anchor';
type PopoverAnchorElement = ComponentRef;
type PopperAnchorProps = ComponentPropsWithoutRef;
type PopoverAnchorProps = 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 = 'Popover.Trigger';
type PopoverTriggerElement = ComponentRef;
type PrimitiveButtonProps = ComponentPropsWithoutRef;
type PopoverTriggerProps = PrimitiveButtonProps & {
asChild?: boolean;
};
const PopoverTrigger = forwardRef(
(props: ScopedProps, forwardedRef) => {
const { __scopePopover, asChild, ...triggerProps } = props;
const context = usePopoverContext(TRIGGER_NAME, __scopePopover);
const popperScope = usePopperScope(__scopePopover);
const composedTriggerRef = useComposedRefs(forwardedRef, context.triggerRef);
const Comp = asChild ? Slot : Primitive.button;
const trigger = (
);
return context.hasCustomAnchor ? (
trigger
) : (
{trigger}
);
},
);
PopoverTrigger.displayName = TRIGGER_NAME;
//
// PopoverVirtualTrigger
//
const VIRTUAL_TRIGGER_NAME = 'Popover.VirtualTrigger';
type 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 = 'Popover.Portal';
type PortalContextValue = { forceMount?: true };
const [PortalProvider, usePortalContext] = createPopoverContext(PORTAL_NAME, {
forceMount: undefined,
});
type PortalProps = ComponentPropsWithoutRef;
type 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 = (props: ScopedProps) => {
const { __scopePopover, forceMount, children, container } = props;
const context = usePopoverContext(PORTAL_NAME, __scopePopover);
return (
{children}
);
};
PopoverPortal.displayName = PORTAL_NAME;
//
// PopoverContent
//
const CONTENT_NAME = 'Popover.Content';
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<
PopoverContentImplProps,
'trapFocus' | 'disableOutsidePointerEvents'
> {}
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 = ComponentRef;
type FocusScopeProps = ComponentPropsWithoutRef;
type DismissableLayerProps = ComponentPropsWithoutRef;
type PopperContentProps = ThemedClassName>;
type PopoverContentImplProps = 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,
collisionBoundary,
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();
// Check for the closest annotated collision boundary in the DOM tree.
const computedCollisionBoundary = useMemo(() => {
const closestBoundary = context.triggerRef.current?.closest(
'[data-popover-collision-boundary]',
) as HTMLElement | null;
return closestBoundary
? Array.isArray(collisionBoundary)
? [closestBoundary, ...collisionBoundary]
: collisionBoundary
? [closestBoundary, collisionBoundary]
: [closestBoundary]
: collisionBoundary;
}, [context.open, collisionBoundary, context.triggerRef.current]);
return (
context.onOpenChange(false)}
>
);
},
);
//
// PopoverClose
//
const CLOSE_NAME = 'Popover.Close';
type PopoverCloseElement = ComponentRef;
type PopoverCloseProps = 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 = 'Popover.Arrow';
type PopoverArrowElement = ComponentRef;
type PopperArrowProps = ThemedClassName>;
type PopoverArrowProps = 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 Comp = asChild ? Slot : Primitive.div;
return (
{children}
);
},
);
const getState = (open: boolean) => (open ? 'open' : 'closed');
type PopoverContentInteractOutsideEvent = Parameters>[0];
//
// Popver
//
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 {
PopoverAnchorProps,
PopoverArrowProps,
PopoverCloseProps,
PopoverContentInteractOutsideEvent,
PopoverContentProps,
PopoverPortalProps,
PopoverRootProps,
PopoverTriggerProps,
PopoverViewportProps,
PopoverVirtualTriggerProps,
};