474 lines
13 KiB
TypeScript
474 lines
13 KiB
TypeScript
import React from "react";
|
|
import { useTheme } from "styled-components";
|
|
import { isIOS, isMobileOnly } from "react-device-detect";
|
|
import { Portal } from "../portal";
|
|
import { DomHelpers, isTablet } from "../../utils";
|
|
|
|
import StyledDropdown from "./DropDown.styled";
|
|
|
|
import { VirtualList } from "./sub-components/VirtualList";
|
|
import { Row } from "./sub-components/Row";
|
|
|
|
import { DropDownProps } from "./DropDown.types";
|
|
import { DEFAULT_PARENT_HEIGHT } from "./DropDown.constants";
|
|
|
|
const DropDown = ({
|
|
directionY,
|
|
directionX,
|
|
manualY,
|
|
open,
|
|
enableOnClickOutside,
|
|
isDefaultMode,
|
|
fixedDirection,
|
|
smallSectionWidth,
|
|
forwardedRef,
|
|
disableOnClickOutside,
|
|
right,
|
|
offsetLeft = 0,
|
|
top,
|
|
children,
|
|
maxHeight,
|
|
showDisabledItems,
|
|
isMobileView,
|
|
isNoFixedHeightOptions,
|
|
enableKeyboardEvents,
|
|
appendTo,
|
|
eventTypes,
|
|
zIndex,
|
|
clickOutsideAction,
|
|
manualWidth,
|
|
}: DropDownProps) => {
|
|
const theme = useTheme();
|
|
|
|
const documentResizeListener = React.useRef<null | (() => void)>(null);
|
|
const dropDownRef = React.useRef<null | HTMLDivElement>(null);
|
|
|
|
const [state, setState] = React.useState({
|
|
directionX,
|
|
directionY,
|
|
manualY,
|
|
width: 0,
|
|
borderOffset: theme?.isBase ? 0 : 2, // need to remove the difference in width with the parent in a dark theme
|
|
isDropdownReady: false, // need to avoid scrollbar appearing during dropdown position calculation
|
|
});
|
|
|
|
const checkPositionPortal = React.useCallback(() => {
|
|
const parent = forwardedRef;
|
|
|
|
if (!parent?.current || fixedDirection) {
|
|
setState((s) => ({ ...s, isDropdownReady: true }));
|
|
return;
|
|
}
|
|
|
|
const dropDown = dropDownRef.current;
|
|
|
|
const parentRects = parent.current.getBoundingClientRect();
|
|
|
|
const dropDownHeight = dropDownRef.current?.offsetParent
|
|
? dropDownRef.current.offsetHeight
|
|
: DomHelpers.getHiddenElementOuterHeight(dropDownRef.current);
|
|
|
|
let bottom = parentRects.bottom;
|
|
|
|
const viewport = DomHelpers.getViewport();
|
|
const scrollBarWidth =
|
|
viewport.width - document.documentElement.clientWidth;
|
|
|
|
const dropDownRects = dropDownRef.current?.getBoundingClientRect();
|
|
|
|
if (
|
|
directionY === "top" ||
|
|
(directionY === "both" &&
|
|
parentRects.bottom + dropDownHeight > viewport.height)
|
|
) {
|
|
bottom -= parent.current.clientHeight + dropDownHeight;
|
|
}
|
|
|
|
if (dropDown) {
|
|
if (theme?.interfaceDirection === "ltr") {
|
|
if (right) {
|
|
dropDown.style.right = right;
|
|
} else if (directionX === "right") {
|
|
dropDown.style.left = `${parentRects.right - dropDown.clientWidth}px`;
|
|
} else if (
|
|
dropDownRects &&
|
|
parentRects.left + dropDownRects.width > viewport.width
|
|
) {
|
|
if (parentRects.right - dropDownRects.width < 0) {
|
|
dropDown.style.left = "0px";
|
|
} else {
|
|
dropDown.style.left = `${
|
|
parentRects.right - dropDown.clientWidth
|
|
}px`;
|
|
}
|
|
} else {
|
|
dropDown.style.left = `${parentRects.left + offsetLeft}px`;
|
|
}
|
|
} else if (right) {
|
|
dropDown.style.left = right;
|
|
} else if (directionX === "right") {
|
|
dropDown.style.left = `${parentRects.left - scrollBarWidth}px`;
|
|
} else if (dropDownRects && parentRects.right - dropDownRects.width < 0) {
|
|
if (parentRects.left + dropDownRects.width > viewport.width) {
|
|
dropDown.style.left = `${
|
|
viewport.width - dropDown.clientWidth - scrollBarWidth
|
|
}px`;
|
|
} else {
|
|
dropDown.style.left = `${parentRects.left - scrollBarWidth}px`;
|
|
}
|
|
} else {
|
|
dropDown.style.left = `${
|
|
parentRects.right - dropDown.clientWidth - offsetLeft - scrollBarWidth
|
|
}px`;
|
|
}
|
|
}
|
|
if (dropDownRef.current)
|
|
dropDownRef.current.style.top = top || `${bottom}px`;
|
|
|
|
setState((s) => ({
|
|
...s,
|
|
directionX,
|
|
directionY,
|
|
width: dropDownRef.current
|
|
? dropDownRef.current.offsetWidth - state.borderOffset
|
|
: 240,
|
|
isDropdownReady: true,
|
|
}));
|
|
}, [
|
|
directionX,
|
|
directionY,
|
|
fixedDirection,
|
|
forwardedRef,
|
|
offsetLeft,
|
|
right,
|
|
state.borderOffset,
|
|
theme?.interfaceDirection,
|
|
top,
|
|
]);
|
|
|
|
const checkPosition = React.useCallback(() => {
|
|
if (!dropDownRef.current || fixedDirection) {
|
|
setState((s) => ({ ...s, isDropdownReady: true }));
|
|
return;
|
|
}
|
|
|
|
if (dropDownRef.current) {
|
|
const isRtl = theme?.interfaceDirection === "rtl";
|
|
const rects = dropDownRef.current.getBoundingClientRect();
|
|
const parentRects = forwardedRef?.current?.getBoundingClientRect();
|
|
|
|
const container = DomHelpers.getViewport();
|
|
|
|
const dimensions = parentRects
|
|
? {
|
|
toTopCorner: parentRects.top,
|
|
parentHeight: parentRects.height,
|
|
containerHeight: parentRects.top,
|
|
}
|
|
: {
|
|
toTopCorner: rects.top,
|
|
parentHeight: DEFAULT_PARENT_HEIGHT,
|
|
containerHeight: container.height,
|
|
};
|
|
|
|
let left;
|
|
let rightVar;
|
|
|
|
if (isRtl) {
|
|
rightVar =
|
|
rects.right > container.width && rects.width < container.width;
|
|
left =
|
|
rects.width &&
|
|
rects.right > (container.width - rects.width || 250) &&
|
|
rects.right < container.width - rects.width &&
|
|
rects.width < container.width;
|
|
} else {
|
|
left = rects.left < 0 && rects.width < container.width;
|
|
rightVar =
|
|
rects.width &&
|
|
rects.left < (rects.width || 250) &&
|
|
rects.left > rects.width &&
|
|
rects.width < container.width;
|
|
}
|
|
|
|
const topVar =
|
|
rects.bottom > dimensions.containerHeight &&
|
|
dimensions.toTopCorner > rects.height;
|
|
const bottom = rects.top < 0;
|
|
|
|
const x = left
|
|
? "left"
|
|
: rightVar || smallSectionWidth
|
|
? "right"
|
|
: state.directionX;
|
|
|
|
const y = bottom ? "bottom" : topVar ? "top" : state.directionY;
|
|
|
|
const mY = topVar ? `${dimensions.parentHeight}px` : state.manualY;
|
|
|
|
setState((s) => ({
|
|
...s,
|
|
directionX: x,
|
|
directionY: y,
|
|
manualY: mY,
|
|
width: dropDownRef.current
|
|
? dropDownRef.current.offsetWidth - state.borderOffset
|
|
: 240,
|
|
isDropdownReady: true,
|
|
}));
|
|
}
|
|
}, [
|
|
fixedDirection,
|
|
forwardedRef,
|
|
smallSectionWidth,
|
|
state.directionX,
|
|
state.borderOffset,
|
|
state.directionY,
|
|
state.manualY,
|
|
theme?.interfaceDirection,
|
|
]);
|
|
|
|
// const handleClickOutside = (e: any) => {
|
|
// if (e.type !== "touchstart") {
|
|
// e.preventDefault();
|
|
// }
|
|
|
|
// toggleDropDown(e);
|
|
// };
|
|
|
|
// const toggleDropDown = (e: any) => {
|
|
// clickOutsideAction?.(e, open);
|
|
// };
|
|
|
|
const bindDocumentResizeListener = React.useCallback(() => {
|
|
if (!documentResizeListener.current) {
|
|
documentResizeListener.current = () => {
|
|
if (open) {
|
|
if (isDefaultMode) {
|
|
checkPositionPortal();
|
|
} else {
|
|
checkPosition();
|
|
}
|
|
}
|
|
};
|
|
|
|
window.addEventListener("resize", documentResizeListener.current);
|
|
}
|
|
}, [checkPosition, checkPositionPortal, isDefaultMode, open]);
|
|
|
|
const unbindDocumentResizeListener = React.useCallback(() => {
|
|
if (documentResizeListener.current) {
|
|
window.removeEventListener("resize", documentResizeListener.current);
|
|
documentResizeListener.current = null;
|
|
}
|
|
}, []);
|
|
|
|
const getItemHeight = (item: React.ReactElement) => {
|
|
const isTabletDevice = isTablet();
|
|
|
|
const height = item?.props.height;
|
|
const heightTablet = item?.props.heightTablet;
|
|
|
|
if (item && item.props.isSeparator) {
|
|
return isTabletDevice ? 16 : 12;
|
|
}
|
|
|
|
return isTabletDevice ? heightTablet : height;
|
|
};
|
|
|
|
const hideDisabledItems = () => {
|
|
if (React.Children.count(children) > 0) {
|
|
const enabledChildren = React.Children.map(children, (child) => {
|
|
const props =
|
|
child &&
|
|
React.isValidElement(child) &&
|
|
(child.props as { disabled?: boolean });
|
|
if (props && !props?.disabled) return child;
|
|
});
|
|
|
|
const sizeEnabledChildren = enabledChildren?.length;
|
|
|
|
const cleanChildren = React.Children.map(
|
|
enabledChildren,
|
|
(child, index) => {
|
|
const props =
|
|
child &&
|
|
React.isValidElement(child) &&
|
|
(child.props as { isSeparator?: boolean });
|
|
if (props && !props?.isSeparator) return child;
|
|
if (
|
|
index !== 0 &&
|
|
sizeEnabledChildren &&
|
|
index !== sizeEnabledChildren - 1
|
|
)
|
|
return child;
|
|
},
|
|
);
|
|
|
|
return cleanChildren;
|
|
}
|
|
};
|
|
|
|
const renderDropDown = () => {
|
|
// Need to avoid conflict between inline styles from checkPositionPortal and styled-component styles
|
|
const directionXStylesDisabled =
|
|
isDefaultMode && forwardedRef?.current && !fixedDirection;
|
|
|
|
let cleanChildren = children;
|
|
let itemCount = children ? React.Children.toArray(children).length : 0;
|
|
|
|
if (!showDisabledItems) {
|
|
cleanChildren = hideDisabledItems();
|
|
if (cleanChildren)
|
|
itemCount = React.Children.toArray(cleanChildren).length;
|
|
}
|
|
|
|
const rowHeights =
|
|
cleanChildren &&
|
|
Array.isArray(cleanChildren) &&
|
|
React.Children.map(cleanChildren, (child: React.ReactElement) => {
|
|
return getItemHeight(child);
|
|
});
|
|
|
|
const getItemSize = (index: number) => rowHeights && rowHeights[index];
|
|
const fullHeight =
|
|
cleanChildren &&
|
|
rowHeights &&
|
|
rowHeights.reduce((a: number, b: number) => a + b, 0);
|
|
let calculatedHeight =
|
|
fullHeight > 0 && maxHeight && fullHeight < maxHeight
|
|
? fullHeight
|
|
: maxHeight;
|
|
|
|
const container = DomHelpers.getViewport();
|
|
|
|
if (
|
|
isIOS &&
|
|
isMobileOnly &&
|
|
container?.height !== window.visualViewport?.height
|
|
) {
|
|
const rects = dropDownRef.current?.getBoundingClientRect();
|
|
const parentRects = forwardedRef?.current?.getBoundingClientRect();
|
|
|
|
const parentHeight = parentRects?.height || DEFAULT_PARENT_HEIGHT;
|
|
|
|
if (window.visualViewport) {
|
|
const rectsTop = rects?.top || 0;
|
|
|
|
const height = window.visualViewport.height - rectsTop - parentHeight;
|
|
|
|
if (rects && calculatedHeight > height) calculatedHeight = height;
|
|
}
|
|
}
|
|
|
|
const dropDownMaxHeightProp = maxHeight
|
|
? { height: `${calculatedHeight}px` }
|
|
: {};
|
|
|
|
return (
|
|
<StyledDropdown
|
|
ref={dropDownRef}
|
|
// {...this.props}
|
|
directionX={state.directionX}
|
|
directionY={state.directionY}
|
|
manualY={state.manualY}
|
|
isMobileView={isMobileView}
|
|
itemCount={itemCount}
|
|
{...dropDownMaxHeightProp}
|
|
directionXStylesDisabled={directionXStylesDisabled}
|
|
isDropdownReady={state.isDropdownReady}
|
|
open={open}
|
|
maxHeight={maxHeight}
|
|
zIndex={zIndex}
|
|
manualWidth={manualWidth}
|
|
>
|
|
<VirtualList
|
|
Row={Row}
|
|
theme={theme}
|
|
width={state.width}
|
|
itemCount={itemCount}
|
|
maxHeight={maxHeight}
|
|
cleanChildren={cleanChildren}
|
|
calculatedHeight={calculatedHeight}
|
|
isNoFixedHeightOptions={isNoFixedHeightOptions || false}
|
|
getItemSize={getItemSize}
|
|
isOpen={open || false}
|
|
enableKeyboardEvents={enableKeyboardEvents || false}
|
|
>
|
|
{children}
|
|
</VirtualList>
|
|
</StyledDropdown>
|
|
);
|
|
};
|
|
|
|
React.useEffect(() => {
|
|
if (open) {
|
|
enableOnClickOutside?.();
|
|
bindDocumentResizeListener();
|
|
if (isDefaultMode) {
|
|
setTimeout(() => checkPositionPortal?.(), 0);
|
|
return;
|
|
}
|
|
checkPosition?.();
|
|
} else {
|
|
// disableOnClickOutside;
|
|
}
|
|
}, [
|
|
checkPosition,
|
|
checkPositionPortal,
|
|
enableOnClickOutside,
|
|
// disableOnClickOutside,
|
|
isDefaultMode,
|
|
open,
|
|
bindDocumentResizeListener,
|
|
]);
|
|
|
|
React.useEffect(() => {
|
|
if (!dropDownRef.current) return;
|
|
|
|
const listener = (evt: Event) => {
|
|
const target = evt.target as HTMLElement;
|
|
|
|
if (dropDownRef.current && dropDownRef.current.contains(target)) return;
|
|
clickOutsideAction?.(evt, !open);
|
|
};
|
|
|
|
if (!open) {
|
|
eventTypes?.forEach((type) => {
|
|
window.removeEventListener(type, listener);
|
|
});
|
|
|
|
return;
|
|
}
|
|
|
|
eventTypes?.forEach((type) => {
|
|
window.addEventListener(type, listener);
|
|
});
|
|
|
|
return () => {
|
|
eventTypes?.forEach((type) => {
|
|
window.removeEventListener(type, listener);
|
|
});
|
|
};
|
|
}, [clickOutsideAction, eventTypes, open]);
|
|
|
|
React.useEffect(() => {
|
|
return () => {
|
|
// disableOnClickOutside?.();
|
|
unbindDocumentResizeListener();
|
|
};
|
|
}, [disableOnClickOutside, unbindDocumentResizeListener]);
|
|
|
|
const element = renderDropDown();
|
|
|
|
if (isDefaultMode) {
|
|
return <Portal element={element} appendTo={appendTo} />;
|
|
}
|
|
|
|
return element;
|
|
};
|
|
|
|
const EnhancedComponent = DropDown;
|
|
|
|
export { EnhancedComponent };
|