src/js/components/Drop/DropContainer.js
import React, { forwardRef, useContext, useEffect, useMemo } from 'react';
import { ThemeContext } from 'styled-components';
import { ContainerTargetContext } from '../../contexts/ContainerTargetContext';
import { FocusedContainer } from '../FocusedContainer';
import {
backgroundIsDark,
findScrollParents,
parseMetricToNum,
PortalContext,
useForwardedRef,
} from '../../utils';
import { defaultProps } from '../../default-props';
import { Box } from '../Box';
import { Keyboard } from '../Keyboard';
import { StyledDrop } from './StyledDrop';
import { OptionsContext } from '../../contexts/OptionsContext';
// using react synthetic event to be able to stop propagation that
// would otherwise close the layer on ESC.
const preventLayerClose = (event) => {
const key = event.keyCode ? event.keyCode : event.which;
if (key === 27) {
event.stopPropagation();
}
};
// Gets the closest ancestor positioned element
const getParentNode = (element) => element.offsetParent ?? element.parentNode;
// return the containing block
const getContainingBlock = (element) => {
let currentNode = getParentNode(element);
while (
currentNode instanceof window.HTMLElement &&
!['html', 'body'].includes(currentNode.nodeName.toLowerCase())
) {
const css = window.getComputedStyle(currentNode);
// This is non-exhaustive but covers the most common CSS properties that
// create a containing block.
// https://developer.mozilla.org/en-US/docs/Web/CSS/Containing_block#identifying_the_containing_block
if (
(css.transform ? css.transform !== 'none' : false) ||
(css.perspective ? css.perspective !== 'none' : false) ||
(css.backdropFilter ? css.backdropFilter !== 'none' : false) ||
css.contain === 'paint' ||
['transform', 'perspective'].includes(css.willChange) ||
css.willChange === 'filter' ||
(css.filter ? css.filter !== 'none' : false)
) {
return currentNode;
}
currentNode = currentNode?.parentNode;
}
return null;
};
const defaultAlign = { top: 'top', left: 'left' };
const DropContainer = forwardRef(
(
{
a11yTitle,
'aria-label': ariaLabel,
align = defaultAlign,
background,
onAlign,
children,
dropTarget,
elevation,
onClickOutside,
onEsc,
onKeyDown,
overflow = 'auto',
plain,
responsive = true,
restrictFocus,
stretch = 'width',
trapFocus,
...rest
},
ref,
) => {
const containerTarget = useContext(ContainerTargetContext);
const theme = useContext(ThemeContext) || defaultProps.theme;
// dropOptions was created to preserve backwards compatibility
const { drop: dropOptions } = useContext(OptionsContext);
const portalContext = useContext(PortalContext);
const portalId = useMemo(() => portalContext.length, [portalContext]);
const nextPortalContext = useMemo(
() => [...portalContext, portalId],
[portalContext, portalId],
);
const dropRef = useForwardedRef(ref);
useEffect(() => {
const onClickDocument = (event) => {
// determine which portal id the target is in, if any
let clickedPortalId = null;
let node = (event.composed && event.composedPath()[0]) || event.target;
while (
clickedPortalId === null &&
node &&
node !== document &&
!(node instanceof ShadowRoot)
) {
const attr = node.getAttribute('data-g-portal-id');
if (attr !== null) clickedPortalId = parseInt(attr, 10);
node = node.parentNode;
}
if (
clickedPortalId === null ||
portalContext.indexOf(clickedPortalId) !== -1
) {
onClickOutside(event);
}
};
if (onClickOutside) {
document.addEventListener('mousedown', onClickDocument);
}
return () => {
if (onClickOutside) {
document.removeEventListener('mousedown', onClickDocument);
}
};
}, [onClickOutside, containerTarget, portalContext]);
useEffect(() => {
const target = dropTarget?.current || dropTarget;
const notifyAlign = () => {
const styleCurrent = dropRef?.current?.style;
const alignControl = styleCurrent?.top !== '' ? 'top' : 'bottom';
onAlign(alignControl);
};
// We try to preserve the maxHeight as changing it causes any scroll
// position to be lost. We set the maxHeight on mount and if the window
// is resized.
const place = (preserveHeight) => {
const windowWidth = window.innerWidth;
const windowHeight = window.innerHeight;
const container = dropRef.current;
if (container && target) {
// clear prior styling
container.style.left = '';
container.style.top = '';
container.style.bottom = '';
container.style.width = '';
if (!preserveHeight) {
container.style.maxHeight = '';
}
// get bounds
const targetRect = target.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
// determine width
let width;
if (stretch) {
width = Math.min(
stretch === 'align'
? Math.min(targetRect.width, containerRect.width)
: Math.max(targetRect.width, containerRect.width),
windowWidth,
);
} else {
width = Math.min(containerRect.width, windowWidth);
}
// set left position
let left;
if (align.left) {
if (align.left === 'left') {
({ left } = targetRect);
} else if (align.left === 'right') {
left = targetRect.left + targetRect.width;
}
} else if (align.right) {
if (align.right === 'left') {
left = targetRect.left - width;
} else if (align.right === 'right') {
left = targetRect.left + targetRect.width - width;
}
} else {
left = targetRect.left + targetRect.width / 2 - width / 2;
}
if (left + width > windowWidth) {
left -= left + width - windowWidth;
} else if (left < 0) {
left = 0;
}
// set top or bottom position
let top;
let bottom;
let maxHeight = containerRect.height;
/* If responsive is true and the Drop doesn't have enough room
to be fully visible and there is more room in the other
direction, change the Drop to display above/below. If there is
less room in the other direction leave the Drop in its current
position. */
if (
responsive &&
((align.top === 'top' && targetRect.top < 0) ||
(align.bottom === 'top' &&
targetRect.top - containerRect.height <= 0 &&
targetRect.bottom + containerRect.height < windowHeight))
) {
top = targetRect.bottom;
maxHeight = top;
} else if (
responsive &&
((align.bottom === 'bottom' && targetRect.bottom > windowHeight) ||
(align.top === 'bottom' &&
targetRect.bottom + containerRect.height >= windowHeight &&
targetRect.top - containerRect.height > 0))
) {
bottom = targetRect.top;
maxHeight = bottom;
} else if (align.top === 'top') {
top = targetRect.top;
maxHeight = windowHeight - top;
} else if (align.top === 'bottom') {
top = targetRect.bottom;
maxHeight = windowHeight - top;
} else if (align.bottom === 'top') {
bottom = targetRect.top;
maxHeight = bottom;
} else if (align.bottom === 'bottom') {
bottom = targetRect.bottom;
maxHeight = bottom;
} else {
top =
targetRect.top + targetRect.height / 2 - containerRect.height / 2;
}
let containingBlock;
let containingBlockRect;
// dropOptions was created to preserve backwards compatibility
if (dropOptions?.checkContainingBlock) {
// return the containing block for absolute elements or `null`
// for fixed elements
containingBlock = getContainingBlock(container);
containingBlockRect = containingBlock?.getBoundingClientRect();
}
// compute viewport offsets
const viewportOffsetLeft = containingBlockRect?.left ?? 0;
const viewportOffsetTop = containingBlockRect?.top ?? 0;
const viewportOffsetBottom =
containingBlockRect?.bottom ?? windowHeight;
const containerOffsetLeft = containingBlock?.scrollLeft ?? 0;
const containerOffsetTop = containingBlock?.scrollTop ?? 0;
container.style.left = `${left - viewportOffsetLeft +
containerOffsetLeft}px`;
if (stretch) {
// offset width by 0.1 to avoid a bug in ie11 that
// unnecessarily wraps the text if width is the same
// NOTE: turned off for now
container.style.width = `${width + 0.1}px`;
}
// the (position:absolute + scrollTop)
// is presenting issues with desktop scroll flickering
if (top !== '') {
container.style.top = `${top - viewportOffsetTop +
containerOffsetTop}px`;
}
if (bottom !== '') {
container.style.bottom = `${viewportOffsetBottom - bottom -
containerOffsetTop}px`;
}
if (!preserveHeight) {
if (theme.drop && theme.drop.maxHeight) {
maxHeight = Math.min(
maxHeight,
parseMetricToNum(theme.drop.maxHeight),
);
}
container.style.maxHeight = `${maxHeight}px`;
}
}
if (onAlign) notifyAlign();
};
let scrollParents;
const addScrollListeners = () => {
scrollParents = findScrollParents(target);
scrollParents.forEach((scrollParent) =>
scrollParent.addEventListener('scroll', place),
);
};
const removeScrollListeners = () => {
scrollParents.forEach((scrollParent) =>
scrollParent.removeEventListener('scroll', place),
);
scrollParents = [];
};
const onResize = () => {
removeScrollListeners();
addScrollListeners();
place(false);
};
addScrollListeners();
window.addEventListener('resize', onResize);
place(false);
return () => {
removeScrollListeners();
window.removeEventListener('resize', onResize);
};
}, [
align,
containerTarget,
onAlign,
dropTarget,
portalContext,
portalId,
responsive,
restrictFocus,
stretch,
theme.drop,
dropRef,
dropOptions,
]);
useEffect(() => {
if (restrictFocus) {
dropRef.current.focus();
}
}, [dropRef, restrictFocus]);
let content = (
<StyledDrop
aria-label={a11yTitle || ariaLabel}
ref={dropRef}
as={Box}
background={background}
plain={plain}
elevation={
!plain
? elevation ||
theme.global.drop.elevation ||
theme.global.drop.shadowSize || // backward compatibility
'small'
: undefined
}
tabIndex="-1"
alignProp={align}
overflow={overflow}
data-g-portal-id={portalId}
{...rest}
>
{children}
</StyledDrop>
);
const themeContextValue = useMemo(() => {
let dark;
if (background || theme.global.drop.background) {
dark = backgroundIsDark(
background || theme.global.drop.background,
theme,
);
}
return { ...theme, dark };
}, [background, theme]);
const { dark } = themeContextValue;
if (dark !== undefined && dark !== theme.dark) {
content = (
<ThemeContext.Provider value={themeContextValue}>
{content}
</ThemeContext.Provider>
);
}
return (
<PortalContext.Provider value={nextPortalContext}>
<FocusedContainer
onKeyDown={onEsc && preventLayerClose}
trapFocus={trapFocus}
>
<Keyboard
// should capture keyboard event before other elements,
// such as Layer
// https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener
capture
onEsc={
onEsc
? (event) => {
event.stopPropagation();
onEsc(event);
}
: undefined
}
onKeyDown={onKeyDown}
target="document"
>
{content}
</Keyboard>
</FocusedContainer>
</PortalContext.Provider>
);
},
);
export { DropContainer };