@chayns-components/core
Version:
A set of beautiful React components for developing your own applications with chayns.
194 lines (189 loc) • 7.92 kB
JavaScript
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { DropdownDirection } from '../types/dropdown';
const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect;
export const useDropdownListener = ({
onClick,
onClose,
onTouchEnd,
onTouchStart,
shouldCaptureEvents
}) => {
useEffect(() => {
document.addEventListener('click', onClick, shouldCaptureEvents);
document.addEventListener('touchend', onTouchEnd, shouldCaptureEvents);
document.addEventListener('touchstart', onTouchStart, shouldCaptureEvents);
window.addEventListener('blur', onClose);
return () => {
document.removeEventListener('click', onClick, shouldCaptureEvents);
document.removeEventListener('touchend', onTouchEnd, shouldCaptureEvents);
document.removeEventListener('touchstart', onTouchStart, shouldCaptureEvents);
window.removeEventListener('blur', onClose);
};
}, [onClick, onClose, onTouchEnd, onTouchStart]);
};
export const useDropdownAlignment = ({
anchorElement,
contentWidth,
direction,
shouldUseTopAlignment
}) => {
const [translateX, setTranslateX] = useState('0px');
const [translateY, setTranslateY] = useState('0px');
useEffect(() => {
if ([DropdownDirection.BOTTOM_LEFT, DropdownDirection.TOP_LEFT, DropdownDirection.LEFT].includes(direction)) {
const difference = anchorElement.clientWidth - contentWidth;
setTranslateX(`${difference}px`);
} else {
setTranslateX('0px');
}
}, [anchorElement.clientWidth, contentWidth, direction]);
useEffect(() => {
const useTopAlignment = shouldUseTopAlignment || [DropdownDirection.TOP, DropdownDirection.TOP_LEFT, DropdownDirection.TOP_RIGHT].includes(direction);
if (useTopAlignment) {
setTranslateY('-100%');
} else {
setTranslateY('0px');
}
}, [direction, shouldUseTopAlignment]);
return useMemo(() => ({
x: translateX,
y: translateY
}), [translateX, translateY]);
};
/**
* The space (in pixels) that should be kept between the dropdown content and the edge of the
* container when calculating the available maximum height.
*/
const AVAILABLE_HEIGHT_SPACING = 16;
export const useDropdownPosition = ({
anchorElement,
container,
contentHeight = 0,
direction,
shouldShowDropdown
}) => {
const [coordinates, setCoordinates] = useState({
x: 0,
y: 0
});
const [shouldUseTopAlignment, setShouldUseTopAlignment] = useState(false);
const [availableMaxHeight, setAvailableMaxHeight] = useState(0);
// Stores the alignment decision (top/bottom) that was made when the dropdown opened. The
// decision depends on the content height, but the content height in turn is limited by the
// available height (which depends on the alignment). Recalculating the alignment on every
// change would create a feedback loop that makes the alignment flip and the height oscillate.
// We therefore lock the alignment while the dropdown stays open and reset it on close.
const lockedTopAlignmentRef = useRef(null);
const calculateCoordinates = useCallback(() => {
// While the dropdown is closing (or closed) we must not recalculate the position and the
// available height. Otherwise layout changes underneath the dropdown (e.g. content that
// appears after a selection) would move or resize the dropdown body while it is still fading
// out, causing it to visibly jump. Freezing the last calculated values keeps the closing
// animation stable.
if (!shouldShowDropdown) {
return;
}
if (container) {
const {
left: anchorLeft,
top: anchorTop,
height: anchorHeight
} = anchorElement.getBoundingClientRect();
const {
left,
top,
height
} = container.getBoundingClientRect();
const x = anchorLeft - left + container.scrollLeft;
const y = anchorTop - top + container.scrollTop;
let useTopAlignment = [DropdownDirection.TOP, DropdownDirection.TOP_LEFT, DropdownDirection.TOP_RIGHT].includes(direction);
const hasBottomAlignment = [DropdownDirection.BOTTOM, DropdownDirection.BOTTOM_LEFT, DropdownDirection.BOTTOM_RIGHT].includes(direction);
// The available space above and below the anchor within the container.
const spaceToTop = y;
const spaceToBottom = height - (y + anchorHeight);
if (lockedTopAlignmentRef.current !== null) {
// Keep the alignment that was decided when the dropdown opened.
useTopAlignment = lockedTopAlignmentRef.current;
} else if (!hasBottomAlignment && y + anchorHeight + contentHeight > height) {
// The content does not fit below the anchor. Only flip to the top when there is
// actually more space above than below. Otherwise the content would be cut off less
// when opened downwards (the content is limited to the available height anyway), so
// we keep the downward alignment. This avoids opening upwards for a content that,
// once limited to the available space, would fit below just fine.
useTopAlignment = spaceToTop > spaceToBottom;
}
lockedTopAlignmentRef.current = useTopAlignment;
setShouldUseTopAlignment(useTopAlignment);
// Calculate the space that is available for the dropdown content. When the dropdown is
// opened to the top, the available space reaches from the anchor to the top edge of the
// container. When it is opened to the bottom, it reaches from the bottom of the anchor
// to the bottom edge of the container. A small spacing is subtracted so the content does
// not touch the container edge (e.g. to leave room for shadows).
const nextAvailableMaxHeight = Math.max(Math.round((useTopAlignment ? spaceToTop : spaceToBottom) - AVAILABLE_HEIGHT_SPACING), 0);
// Ignore sub-pixel fluctuations so tiny changes from getBoundingClientRect do not
// repeatedly trigger re-renders that could keep the height oscillating.
setAvailableMaxHeight(currentAvailableMaxHeight => Math.abs(currentAvailableMaxHeight - nextAvailableMaxHeight) <= 1 ? currentAvailableMaxHeight : nextAvailableMaxHeight);
setCoordinates({
x,
y: useTopAlignment ? y : y + anchorHeight
});
}
}, [anchorElement, container, contentHeight, direction, shouldShowDropdown]);
useIsomorphicLayoutEffect(() => {
// Reset the locked alignment whenever the dropdown is closed, so the next time it opens the
// alignment (top/bottom) is decided freshly based on the then-available space.
if (!shouldShowDropdown) {
lockedTopAlignmentRef.current = null;
}
const handleResize = () => {
calculateCoordinates();
setTimeout(calculateCoordinates, 300);
};
handleResize();
if (shouldShowDropdown) {
window.addEventListener('resize', handleResize);
}
return () => {
window.removeEventListener('resize', handleResize);
};
}, [calculateCoordinates, shouldShowDropdown]);
return useMemo(() => ({
shouldUseTopAlignment,
coordinates,
availableMaxHeight
}), [availableMaxHeight, coordinates, shouldUseTopAlignment]);
};
export const useDropdown = ({
anchorElement,
container,
contentHeight,
contentWidth,
direction,
shouldShowDropdown
}) => {
const {
shouldUseTopAlignment,
coordinates,
availableMaxHeight
} = useDropdownPosition({
anchorElement,
container,
contentHeight,
direction,
shouldShowDropdown
});
const transform = useDropdownAlignment({
anchorElement,
contentWidth,
direction,
shouldUseTopAlignment
});
const width = anchorElement.clientWidth;
return useMemo(() => ({
coordinates,
transform,
width,
availableMaxHeight
}), [availableMaxHeight, coordinates, transform, width]);
};
//# sourceMappingURL=dropdown.js.map