shadimage

Popover

Popover overlay for Tile UI.

packages/react/src/components/popover/popover.tsx

import React, { createContext, useCallback, useContext, useEffect, useId, useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { Slot } from '@radix-ui/react-slot';
import { getPopoverPosition, getPopoverState, popoverStyleKeys } from '../lib/core';
import type { PopoverBaseProps, PopoverContentBaseProps, PopoverPosition, PopoverTriggerBaseProps } from '../lib/core';
import { usePortalContainer, type PortalContainer } from '../portal';
import styles from './popover.module.scss';

interface PopoverContextValue {
	open: boolean;
	triggerRef: React.RefObject<HTMLElement | null>;
	contentId: string;
	setOpen: (open: boolean) => void;
}

const PopoverContext = createContext<PopoverContextValue | null>(null);

function usePopoverContext(): PopoverContextValue {
	const context = useContext(PopoverContext);
	if (!context) {
		throw new Error('Popover sub-components must be used within <Popover>.');
	}
	return context;
}

export interface PopoverProps extends React.HTMLAttributes<HTMLDivElement>, PopoverBaseProps {}

const Popover = React.forwardRef<HTMLDivElement, PopoverProps>(({ className = '', open, defaultOpen = false, onOpenChange, children, ...props }, ref) => {
	const [internalOpen, setInternalOpen] = useState(defaultOpen);
	const isOpen = open !== undefined ? open : internalOpen;
	const triggerRef = useRef<HTMLElement | null>(null);
	const contentId = useId();

	const setOpen = useCallback(
		(next: boolean) => {
			if (open === undefined) {
				setInternalOpen(next);
			}
			onOpenChange?.(next);
		},
		[open, onOpenChange],
	);

	return (
		<PopoverContext.Provider value={{ open: isOpen, triggerRef, contentId, setOpen }}>
			<div ref={ref} className={`${styles[popoverStyleKeys.root]} ${className}`} {...props}>
				{children}
			</div>
		</PopoverContext.Provider>
	);
});
Popover.displayName = 'Popover';

export interface PopoverTriggerProps extends React.ButtonHTMLAttributes<HTMLButtonElement>, PopoverTriggerBaseProps {}

const PopoverTrigger = React.forwardRef<HTMLButtonElement, PopoverTriggerProps>(({ className = '', asChild = false, children, onClick, ...props }, ref) => {
	const context = usePopoverContext();

	function setRef(element: HTMLElement | null) {
		context.triggerRef.current = element;
		if (typeof ref === 'function') {
			ref(element as HTMLButtonElement | null);
		} else if (ref) {
			ref.current = element as HTMLButtonElement | null;
		}
	}

	function handleClick(event: React.MouseEvent<HTMLButtonElement>) {
		context.setOpen(!context.open);
		onClick?.(event);
	}

	const Comp = asChild ? Slot : 'button';
	const state = getPopoverState(context.open);

	return (
		<Comp
			ref={setRef}
			aria-haspopup="dialog"
			aria-expanded={context.open}
			aria-controls={context.contentId}
			data-state={state}
			className={asChild ? undefined : `${styles[popoverStyleKeys.trigger]} ${className}`}
			onClick={handleClick}
			{...props}>
			{children}
		</Comp>
	);
});
PopoverTrigger.displayName = 'PopoverTrigger';

export interface PopoverContentProps extends React.HTMLAttributes<HTMLDivElement>, PopoverContentBaseProps {
	container?: PortalContainer;
}

const PopoverContent = React.forwardRef<HTMLDivElement, PopoverContentProps>(
	({ className = '', side = 'bottom', align = 'center', sideOffset = 4, container, children, ...props }, ref) => {
		const { open, triggerRef, contentId, setOpen } = usePopoverContext();
		const portalContainer = usePortalContainer(container);
		const [position, setPosition] = useState<PopoverPosition | null>(null);
		const contentRef = useRef<HTMLDivElement | null>(null);

		function setRef(element: HTMLDivElement | null) {
			contentRef.current = element;
			if (typeof ref === 'function') {
				ref(element);
			} else if (ref) {
				ref.current = element;
			}
		}

		useLayoutEffect(() => {
			if (!open) {
				return;
			}

			function updatePosition() {
				const trigger = triggerRef.current;
				const content = contentRef.current;
				if (!trigger || !content) {
					return;
				}

				const triggerRect = trigger.getBoundingClientRect();
				const contentSize = { width: content.offsetWidth, height: content.offsetHeight };
				const viewport = { width: window.innerWidth, height: window.innerHeight };
				setPosition(getPopoverPosition({ triggerRect, contentSize, side, align, sideOffset, viewport }));
			}

			updatePosition();

			const content = contentRef.current;
			if (content && !content.contains(document.activeElement)) {
				content.focus();
			}

			window.addEventListener('resize', updatePosition);
			document.addEventListener('scroll', updatePosition, true);

			return () => {
				window.removeEventListener('resize', updatePosition);
				document.removeEventListener('scroll', updatePosition, true);
			};
		}, [open, side, align, sideOffset, triggerRef]);

		useEffect(() => {
			if (!open) {
				return;
			}

			function handlePointerDown(event: PointerEvent) {
				const target = event.target as Node | null;
				const content = contentRef.current;
				const trigger = triggerRef.current;
				if (!target) {
					return;
				}
				if (content && content.contains(target)) {
					return;
				}
				if (trigger && trigger.contains(target)) {
					return;
				}
				setOpen(false);
			}

			function handleKeyDown(event: KeyboardEvent) {
				if (event.key === 'Escape') {
					setOpen(false);
					triggerRef.current?.focus();
				}
			}

			document.addEventListener('pointerdown', handlePointerDown);
			document.addEventListener('keydown', handleKeyDown);

			return () => {
				document.removeEventListener('pointerdown', handlePointerDown);
				document.removeEventListener('keydown', handleKeyDown);
			};
		}, [open, triggerRef, setOpen]);

		const state = getPopoverState(open);
		const classes = [styles[popoverStyleKeys.content], className].filter(Boolean).join(' ');

		const content = (
			<div
				ref={setRef}
				id={contentId}
				role="dialog"
				aria-modal="false"
				tabIndex={-1}
				data-state={state}
				data-side={side}
				data-align={align}
				className={classes}
				style={position ? { top: `${position.top}px`, left: `${position.left}px` } : undefined}
				{...props}>
				{children}
			</div>
		);

		if (!open || !portalContainer) {
			return null;
		}

		return createPortal(content, portalContainer);
	},
);
PopoverContent.displayName = 'PopoverContent';

export { Popover, PopoverTrigger, PopoverContent };
export default Popover;
Interaction
tooltip

DependenciesBundle weight · light

ui-primitives
@radix-ui/react-slot