@dnb/eufemia
Version:
DNB Eufemia Design System UI Library
522 lines (521 loc) • 18.6 kB
JavaScript
"use client";
import _extends from "@babel/runtime/helpers/esm/extends";
import React, { cloneElement, isValidElement, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import classnames from 'classnames';
import PopoverCloseButton from "./internal/PopoverCloseButton.js";
import useId from "../../shared/helpers/useId.js";
import useTranslation from "../../shared/useTranslation.js";
import { combineDescribedBy, warn } from "../../shared/component-helper.js";
import getRefElement from "../../shared/internal/getRefElement.js";
import PopoverPortal from "./PopoverPortal.js";
import PopoverContainer from "./PopoverContainer.js";
export default function Popover(props) {
var _closeButtonProps$var, _closeButtonProps$ico;
const {
children,
content,
trigger,
triggerAttributes,
triggerClassName,
title,
placement = 'bottom',
open: controlledOpenProp,
openInitially: openInitiallyProp = false,
onOpenChange,
focusOnOpen = true,
focusOnOpenElement,
restoreFocus = true,
preventClose = false,
hideCloseButton = false,
closeButtonProps,
contentClassName,
className,
baseClassName,
theme = 'light',
disableFocusTrap = false,
hideOutline = false,
noInnerSpace = false,
noMaxWidth = false,
keepInDOM = false,
triggerOffset,
targetElement: externalTargetElement,
targetSelector,
targetRefreshKey,
portalRootClass,
showDelay: showDelayProp,
hideDelay: hideDelayProp,
contentRef: externalContentRef,
alignOnTarget = 'center',
horizontalOffset,
autoAlignMode = 'initial',
arrowPosition = 'center',
arrowPositionSelector,
hideArrow = false,
skipPortal = false,
noAnimation = false,
fixedPosition = false,
arrowEdgeOffset,
id: idProp,
omitDescribedBy,
...restAttributes
} = props;
const baseClassNames = useMemo(() => {
const names = ['dnb-popover'];
if (baseClassName && baseClassName !== 'dnb-popover') {
names.push(baseClassName);
}
return names;
}, [baseClassName]);
const tr = useTranslation().Popover;
const {
isOpen,
setOpenState
} = usePopoverOpenState({
open: controlledOpenProp,
openInitially: openInitiallyProp,
onOpenChange
});
const triggerRef = useRef(null);
const tooltipRef = useRef(null);
const contentWrapperRef = useRef(null);
const previousTargetElementRef = useRef(null);
const focusRestoreAnimationRef = useRef(null);
const touchStartTargetRef = useRef(null);
const touchMovedRef = useRef(false);
const tooltipId = useId(idProp);
const descriptionId = `${tooltipId}-description`;
const showDelay = showDelayProp !== null && showDelayProp !== void 0 ? showDelayProp : 0;
const hideDelay = hideDelayProp !== null && hideDelayProp !== void 0 ? hideDelayProp : 0;
const hasExplicitTargetElement = Object.hasOwn(props, 'targetElement');
const shouldRenderTrigger = !hasExplicitTargetElement && typeof targetSelector !== 'string';
const getCurrentTriggerElement = useCallback(() => {
if (shouldRenderTrigger) {
return triggerRef.current;
}
if (isPopoverTargetElementObject(externalTargetElement)) {
return resolveTargetNode(externalTargetElement.verticalRef) || resolveTargetNode(externalTargetElement.horizontalRef);
}
if (externalTargetElement) {
return resolveTargetNode(externalTargetElement);
}
if (typeof targetSelector === 'string' && typeof document !== 'undefined') {
return document.querySelector(targetSelector);
}
return null;
}, [externalTargetElement, shouldRenderTrigger, targetSelector]);
const [targetElement, setTargetElement] = useState(null);
const resolveTargetElementForContainer = useCallback(() => {
if (isPopoverTargetElementObject(externalTargetElement)) {
const horizontalRef = resolveTargetNode(externalTargetElement.horizontalRef);
const verticalRef = resolveTargetNode(externalTargetElement.verticalRef);
return {
horizontalRef,
verticalRef
};
}
if (externalTargetElement) {
return resolveTargetNode(externalTargetElement);
}
if (typeof targetSelector === 'string' && typeof document !== 'undefined') {
return document.querySelector(targetSelector);
}
if (shouldRenderTrigger) {
return triggerRef.current;
}
return null;
}, [externalTargetElement, shouldRenderTrigger, targetSelector]);
useEffect(() => {
const resolved = resolveTargetElementForContainer();
const hadPreviousTarget = previousTargetElementRef.current !== null;
const hadValidPreviousTarget = hadPreviousTarget && (previousTargetElementRef.current instanceof HTMLElement || typeof previousTargetElementRef.current === 'object' && previousTargetElementRef.current !== null && ('horizontalRef' in previousTargetElementRef.current || 'verticalRef' in previousTargetElementRef.current));
previousTargetElementRef.current = resolved;
setTargetElement(resolved);
if (!resolved && isOpen && hadValidPreviousTarget) {
setOpenState(false);
}
}, [resolveTargetElementForContainer, isOpen, setOpenState]);
const resolveFocusTarget = useCallback(() => {
if (!focusOnOpenElement) {
return null;
}
return typeof focusOnOpenElement === 'function' ? focusOnOpenElement() : focusOnOpenElement;
}, [focusOnOpenElement]);
const focusTrigger = useCallback(() => {
if (!restoreFocus) {
return;
}
const element = getCurrentTriggerElement();
if (!element) {
return;
}
if (focusRestoreAnimationRef.current !== null) {
cancelAnimationFrame(focusRestoreAnimationRef.current);
}
focusRestoreAnimationRef.current = requestAnimationFrame(() => {
element.focus({
preventScroll: true
});
focusRestoreAnimationRef.current = null;
});
}, [getCurrentTriggerElement, restoreFocus]);
useEffect(() => {
return () => {
if (focusRestoreAnimationRef.current !== null) {
cancelAnimationFrame(focusRestoreAnimationRef.current);
}
};
}, []);
const close = useCallback(() => {
if (preventClose) {
return;
}
setOpenState(false);
focusTrigger();
}, [focusTrigger, setOpenState, preventClose]);
const openPopover = useCallback(() => {
setOpenState(true);
}, [setOpenState]);
const toggle = useCallback(next => {
const value = typeof next === 'boolean' ? next : !isOpen;
if (value) {
openPopover();
} else {
close();
}
}, [close, isOpen, openPopover]);
const runTriggerClick = useCallback(event => {
if (event && 'preventDefault' in event) {
event.preventDefault();
}
toggle();
}, [toggle]);
useEffect(() => {
if (!focusOnOpen || !isOpen) {
return;
}
const timers = [];
const focusContent = () => {
const focusTarget = resolveFocusTarget() || contentWrapperRef.current || tooltipRef.current?.querySelector('.dnb-popover__content');
if (!(focusTarget instanceof HTMLElement)) {
return false;
}
focusTarget.focus({
preventScroll: true
});
setTimeout(() => {
focusTarget?.focus({
preventScroll: true
});
}, 10);
return true;
};
const scheduleFocusAttempt = (delay, retries) => {
timers.push(setTimeout(() => {
if (!focusContent() && retries > 0) {
scheduleFocusAttempt(delay, retries - 1);
}
}, delay));
};
if (!focusContent()) {
scheduleFocusAttempt(10, 3);
}
return () => timers.forEach(clearTimeout);
}, [focusOnOpen, isOpen, resolveFocusTarget]);
const handleDocumentInteraction = useCallback((event, overrideTarget) => {
if (preventClose) {
return;
}
const target = overrideTarget !== null && overrideTarget !== void 0 ? overrideTarget : event.target;
if (!(target instanceof Node)) {
return;
}
const insideContent = !!tooltipRef.current && tooltipRef.current.contains(target);
const triggerElement = getCurrentTriggerElement();
const insideTrigger = !!triggerElement && triggerElement.contains(target);
if (!insideContent && !insideTrigger) {
toggle(false);
}
}, [preventClose, getCurrentTriggerElement, toggle]);
const handleDocumentTouchStart = useCallback(event => {
touchMovedRef.current = false;
touchStartTargetRef.current = event.target;
}, []);
const handleDocumentTouchMove = useCallback(() => {
touchMovedRef.current = true;
}, []);
const handleDocumentTouchEnd = useCallback(event => {
const target = touchStartTargetRef.current;
const moved = touchMovedRef.current;
touchMovedRef.current = false;
touchStartTargetRef.current = null;
if (moved) {
return;
}
handleDocumentInteraction(event, target);
}, [handleDocumentInteraction]);
const handleDocumentKeyDown = useCallback(event => {
if (event.defaultPrevented || preventClose) {
return;
}
if (event.key === 'Escape') {
const target = event.target || document.activeElement;
const triggerElement = getCurrentTriggerElement();
const insideTrigger = triggerElement?.contains(target);
const insideContent = tooltipRef.current?.contains(target);
if (insideContent || insideTrigger) {
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation?.();
toggle(false);
}
}
}, [preventClose, getCurrentTriggerElement, toggle]);
useEffect(() => {
if (!isOpen) {
return;
}
document.documentElement.addEventListener('mousedown', handleDocumentInteraction);
document.documentElement.addEventListener('touchstart', handleDocumentTouchStart, {
passive: true
});
document.documentElement.addEventListener('touchmove', handleDocumentTouchMove, {
passive: true
});
document.documentElement.addEventListener('touchend', handleDocumentTouchEnd, {
passive: true
});
document.documentElement.addEventListener('keyup', handleDocumentInteraction);
document.addEventListener('keydown', handleDocumentKeyDown, true);
return () => {
document.documentElement.removeEventListener('mousedown', handleDocumentInteraction);
document.documentElement.removeEventListener('touchstart', handleDocumentTouchStart);
document.documentElement.removeEventListener('touchmove', handleDocumentTouchMove);
document.documentElement.removeEventListener('touchend', handleDocumentTouchEnd);
document.documentElement.removeEventListener('keyup', handleDocumentInteraction);
document.removeEventListener('keydown', handleDocumentKeyDown, true);
};
}, [handleDocumentInteraction, handleDocumentKeyDown, handleDocumentTouchEnd, handleDocumentTouchMove, handleDocumentTouchStart, isOpen]);
const handleTriggerKeyDown = useCallback((event, userHandler) => {
userHandler?.(event);
if (event.defaultPrevented) {
return;
}
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
toggle();
}
}, [toggle]);
const mergedTriggerAttributes = triggerAttributes || {};
const {
onClick: triggerOnClick,
onKeyDown: triggerOnKeyDown,
className: triggerAttrClassName,
ref: triggerAttrRef,
role: triggerAttrRole,
tabIndex: triggerAttrTabIndex,
title: triggerAttrTitle,
['aria-describedby']: triggerAttrDescribedBy,
...restTriggerAttrs
} = mergedTriggerAttributes;
const assignTriggerRef = useCallback(node => {
triggerRef.current = node;
if (!triggerAttrRef) {
return;
}
if (typeof triggerAttrRef === 'function') {
triggerAttrRef(node);
} else if ('current' in triggerAttrRef) {
triggerAttrRef.current = node;
}
}, [triggerAttrRef]);
const statefulTitle = isOpen ? tr.closeTriggerTitle : tr.openTriggerTitle;
const triggerDomProps = {
...restTriggerAttrs,
ref: assignTriggerRef,
className: classnames('dnb-popover__trigger', triggerAttrClassName, triggerClassName),
title: triggerAttrTitle || statefulTitle,
role: triggerAttrRole || 'button',
tabIndex: typeof triggerAttrTabIndex === 'number' ? triggerAttrTabIndex : 0,
'aria-controls': tooltipId,
'aria-expanded': isOpen,
'aria-describedby': mergeDescribedBy(triggerAttrDescribedBy, descriptionId),
onClick: event => {
triggerOnClick?.(event);
if (event.defaultPrevented) {
return;
}
runTriggerClick(event);
},
onKeyDown: event => handleTriggerKeyDown(event, triggerOnKeyDown)
};
const triggerRenderProps = {
...triggerDomProps
};
Object.defineProperties(triggerRenderProps, {
active: {
value: isOpen,
enumerable: false
},
open: {
value: openPopover,
enumerable: false
},
close: {
value: close,
enumerable: false
},
toggle: {
value: toggle,
enumerable: false
}
});
let triggerMarkup = null;
if (shouldRenderTrigger) {
if (isRenderer(trigger)) {
triggerMarkup = trigger(triggerRenderProps);
} else if (isValidElement(trigger)) {
triggerMarkup = cloneElement(trigger, triggerDomProps);
} else if (trigger) {
warn('Popover: `trigger` must be a valid React element or render function when not using targetElement/targetSelector.');
} else {
warn('Popover: please provide a `trigger` prop or point to an existing element using `targetElement` / `targetSelector`.');
}
}
const hasInternalTrigger = shouldRenderTrigger && Boolean(triggerMarkup);
const contentContext = useMemo(() => ({
active: isOpen,
open: openPopover,
close,
toggle,
id: tooltipId
}), [close, isOpen, openPopover, toggle, tooltipId]);
const userContent = useMemo(() => {
const source = typeof content !== 'undefined' ? content : children;
if (isRenderer(source)) {
return source(contentContext);
}
return source;
}, [children, content, contentContext]);
const closeButton = !hideCloseButton && React.createElement(PopoverCloseButton, _extends({
variant: (_closeButtonProps$var = closeButtonProps?.variant) !== null && _closeButtonProps$var !== void 0 ? _closeButtonProps$var : 'tertiary',
icon: (_closeButtonProps$ico = closeButtonProps?.icon) !== null && _closeButtonProps$ico !== void 0 ? _closeButtonProps$ico : 'close'
}, closeButtonProps, {
className: classnames('dnb-popover__close', closeButtonProps?.className),
title: closeButtonProps?.title || tr.closeButtonTitle,
onClick: event => {
closeButtonProps?.onClick?.(event);
if (event?.defaultPrevented) {
return;
}
toggle(false);
}
}));
const popoverClassName = classnames(baseClassNames, className, theme && baseClassNames.map(name => `${name}--theme-${theme}`), !hideOutline && baseClassNames.map(name => `${name}--show-outline`), noInnerSpace && baseClassNames.map(name => `${name}--no-inner-space`), noMaxWidth && baseClassNames.map(name => `${name}--no-max-width`));
const popoverAttributes = {
...restAttributes,
className: popoverClassName
};
const contentRef = externalContentRef || tooltipRef;
const overlayContent = React.createElement(React.Fragment, null, !disableFocusTrap && React.createElement("button", {
className: "dnb-sr-only",
onFocus: close
}, tr.focusTrapTitle), React.createElement("span", {
className: classnames("dnb-popover__content dnb-no-focus", contentClassName),
id: tooltipId,
tabIndex: -1,
ref: contentWrapperRef
}, title && React.createElement("span", {
className: "dnb-popover__title"
}, React.createElement("strong", {
className: "dnb-h--basis"
}, title)), React.createElement("span", {
className: "dnb-popover__body"
}, userContent)), closeButton, !disableFocusTrap && React.createElement("button", {
className: "dnb-sr-only",
onFocus: close
}, tr.focusTrapTitle));
if (targetElement === null) {
return triggerMarkup;
}
const PopoverElement = skipPortal ? PopoverContainer : PopoverPortal;
return React.createElement(React.Fragment, null, triggerMarkup, hasInternalTrigger && React.createElement("span", {
className: "dnb-sr-only",
"aria-hidden": "true",
id: descriptionId
}, statefulTitle), React.createElement(PopoverElement, _extends({
baseClassNames: baseClassNames,
active: isOpen,
showDelay: showDelay,
attributes: popoverAttributes,
targetElement: targetElement,
hideDelay: hideDelay,
keepInDOM: keepInDOM,
autoAlignMode: autoAlignMode,
noAnimation: noAnimation,
arrowPosition: arrowPosition,
placement: placement,
alignOnTarget: alignOnTarget,
horizontalOffset: horizontalOffset,
arrowPositionSelector: arrowPositionSelector,
fixedPosition: fixedPosition,
skipPortal: skipPortal
}, !skipPortal && {
portalRootClass
}, {
contentRef: contentRef,
triggerOffset: triggerOffset,
hideArrow: hideArrow,
arrowEdgeOffset: arrowEdgeOffset,
targetRefreshKey: targetRefreshKey
}), overlayContent));
}
function usePopoverOpenState({
open,
openInitially,
onOpenChange
}) {
const isControlled = typeof open === 'boolean';
const [internalOpen, setInternalOpen] = useState(() => {
if (typeof openInitially === 'boolean') {
return openInitially;
}
return false;
});
const isOpen = isControlled ? open : internalOpen;
const setOpenState = useCallback(next => {
if (!isControlled) {
setInternalOpen(next);
}
onOpenChange?.(next);
}, [isControlled, onOpenChange]);
return {
isOpen,
setOpenState
};
}
function isRenderer(value) {
return typeof value === 'function';
}
function mergeDescribedBy(existing, next) {
return combineDescribedBy({
'aria-describedby': existing
}, next);
}
function isPopoverTargetElementObject(target) {
return Boolean(target) && typeof target === 'object' && !('getBoundingClientRect' in target) && ('verticalRef' in target || 'horizontalRef' in target);
}
function resolveTargetNode(target) {
if (!target || isPopoverTargetElementObject(target)) {
return null;
}
if (target instanceof HTMLElement) {
return target;
}
if (typeof target === 'object' && 'current' in target) {
return getRefElement(target);
}
return null;
}
export { default as getRefElement } from "../../shared/internal/getRefElement.js";
Popover._supportsSpacingProps = true;
//# sourceMappingURL=Popover.js.map