diff --git a/src/components/Dropdown/Dropdown.tsx b/src/components/Dropdown/Dropdown.tsx index 127cdece5..822f1eac0 100644 --- a/src/components/Dropdown/Dropdown.tsx +++ b/src/components/Dropdown/Dropdown.tsx @@ -26,7 +26,8 @@ type UsefulDropdownState = { multi?: boolean; selected?: boolean; variant: variants; - isDropDown?: boolean; + isOpenedBelow?: boolean; + isHidden?: boolean; }; const Container = styled(Div)` @@ -43,16 +44,15 @@ const Container = styled(Div)` `; // TODO - Add constants for width export const ValueContainer = styled(Button.Container)` - ${({ isOpen, isDropDown }) => ` + ${({ isOpen, isOpenedBelow, isHidden }) => ` user-select: none; display: flex; justify-content: space-between; flex-direction: row; align-items: center; - ${ - isOpen - ? isDropDown + isOpen && !isHidden + ? isOpenedBelow ? ` border-bottom: 0px solid transparent; border-bottom-right-radius: 0rem; @@ -65,7 +65,6 @@ export const ValueContainer = styled(Button.Container)` ` : '' } - width: 15rem; padding: .5rem 1rem; `} @@ -88,7 +87,7 @@ const ValueItem = styled(Div)` `; const OptionsContainer = styled(Div)` - ${({ color, variant, isDropDown }: UsefulDropdownState) => ` + ${({ color, variant, isOpenedBelow, isHidden }: UsefulDropdownState) => ` background: white; position: absolute; left: 0px; @@ -104,7 +103,29 @@ const OptionsContainer = styled(Div)` } z-index: 1000; ${ - isDropDown + isOpenedBelow + ? ` + top: 100%; + border-top: 0px solid transparent; + border-radius: 0rem 0rem 0.25rem 0.25rem; + ` + : ` + top: auto; + bottom: 100%; + border-bottom: 0px solid transparent; + border-radius: 0.25rem 0.25rem 0rem 0rem; + ` + } + ${isHidden ? `visibility: hidden;` : ''} + `} +`; + +const HiddenOptionsContainer = styled(OptionsContainer)` + ${({ isOpenedBelow }) => ` + visibility: hidden; + height: 10rem; + ${ + isOpenedBelow ? ` top: 100%; border-top: 0px solid transparent; @@ -312,14 +333,17 @@ const Dropdown = ({ const [isOpen, setIsOpen] = useState(false); const containerInternalRef = useRef(null); const optionsContainerInternalRef = useRef(null); + const hiddenOptionsContainerInternalRef = useRef(null); const [focusWithin, setFocusWithin] = useState(false); const [focusTimeoutId, setFocusTimeoutId] = useState(); const scrollPos = useRef(0); - const [isDropDown, setIsDropDown] = useState(true); - const [prevIntersectRatio, setPrevIntersectRatio] = useState(0.0); + const [isOpenedBelow, setIsOpenedBelow] = useState(true); + const [isHidden, setIsHidden] = useState(true); + const [isScrollingDown, setIsScrollingDown] = useState(false); + const [prevIntersectionRatio, setPrevIntersectionRatio] = useState(0.5); // Merge the default styled container prop and the placeholderProps object to get user styles const placeholderMergedProps = { @@ -329,27 +353,71 @@ const Dropdown = ({ const tagContainerItemProps = valueItemTagProps.containerProps || {}; - const intersectionCallback = useCallback( - (entries: IntersectionObserverEntry[]) => { - const [entry] = entries; - console.log(`New intersection ${entry.intersectionRatio}`); - console.log(`Old intersection ${prevIntersectRatio}`); - if (!entry.isIntersecting) { - setIsDropDown(val => !val); + useEffect(() => { + const threshold = 0; + let lastScrollY = window.pageYOffset; + let ticking = false; + + const updateScrollDir = () => { + const scrollY = window.pageYOffset; + + if (Math.abs(scrollY - lastScrollY) < threshold) { + ticking = false; + return; } - setPrevIntersectRatio(entry.intersectionRatio); - }, - [prevIntersectRatio], - ); + setIsScrollingDown(scrollY > lastScrollY); + lastScrollY = scrollY > 0 ? scrollY : 0; + ticking = false; + }; + const onScroll = () => { + if (!ticking) { + window.requestAnimationFrame(updateScrollDir); + ticking = true; + } + }; + window.addEventListener('scroll', onScroll); + + return () => window.removeEventListener('scroll', onScroll); + }, [isScrollingDown]); + + const buildThresholdArray = () => Array.from(Array(100).keys(), i => i / 100); const intersectOptions = useMemo(() => { return { root: null, - rootMargin: '0%', - threshold: 0.9, + rootMargin: '0px', + threshold: buildThresholdArray(), }; }, []); + const intersectionCallback = useCallback( + (entries: IntersectionObserverEntry[]) => { + if (entries.length === 1) { + const [entry] = entries; + // swap the dropdown to open downward if its hitting the top + if ( + entry.intersectionRatio < 0.95 && + entry.target === optionsContainerInternalRef.current + ) { + if (isScrollingDown) { + if (!isOpenedBelow && entry.intersectionRatio < prevIntersectionRatio) { + setIsOpenedBelow(true); + } + } + } + setPrevIntersectionRatio(entry.intersectionRatio); + } else if (entries.length === 2) { + const [dropdown, invisibleDrop] = entries; + // flip the view if the other direction is more visible in viewport + if (invisibleDrop.intersectionRatio > dropdown.intersectionRatio) { + setIsOpenedBelow(drop => !drop); + } + } + setIsHidden(false); + }, + [isOpenedBelow, isScrollingDown, prevIntersectionRatio], + ); + const intersectObserver = useMemo(() => { const observer = new IntersectionObserver(intersectionCallback, intersectOptions); return observer; @@ -360,8 +428,15 @@ const Dropdown = ({ if (optionsContainerInternalRef.current) { observer.observe(optionsContainerInternalRef.current); } + if (hiddenOptionsContainerInternalRef.current) { + observer.observe(hiddenOptionsContainerInternalRef.current); + } + if (optionsContainerInternalRef.current && hiddenOptionsContainerInternalRef.current) { + hiddenOptionsContainerInternalRef.current.style.height = + optionsContainerInternalRef.current?.style.height; + } return () => observer.disconnect(); - }, [optionsContainerInternalRef, intersectObserver, isOpen]); + }, [optionsContainerInternalRef, hiddenOptionsContainerInternalRef, intersectObserver, isOpen]); const optionsHash: { [key: string]: OptionProps } = useMemo(() => { const hash: { [key: string]: OptionProps } = {}; @@ -404,6 +479,8 @@ const Dropdown = ({ setFocusWithin(true); } + setIsHidden(true); + setIsOpenedBelow(true); setIsOpen(true); if (onFocus) { onFocus(); @@ -558,7 +635,8 @@ const Dropdown = ({ {...valueContainerProps} containerProps={{ isOpen, - isDropDown, + isOpenedBelow, + isHidden, // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-expect-error ...(valueContainerProps ? valueContainerProps.containerProps : {}), @@ -599,6 +677,12 @@ const Dropdown = ({ {closeIcons} + {isOpen && ( + + )} {isOpen && ( {options.map(option => ( diff --git a/src/components/Dropdown/__tests__/Dropdown.test.tsx b/src/components/Dropdown/__tests__/Dropdown.test.tsx index e4cd65edb..ec4db9784 100644 --- a/src/components/Dropdown/__tests__/Dropdown.test.tsx +++ b/src/components/Dropdown/__tests__/Dropdown.test.tsx @@ -15,6 +15,27 @@ const pokeOptions = [ const mockedSelectHandler = jest.fn(); +// Need to mock the IntersectionObserver class as it is not native to node +class MockIntersectionObserver { + readonly root: Element | null; + readonly rootMargin: string; + readonly thresholds: ReadonlyArray; + + constructor() { + this.root = null; + this.rootMargin = ''; + this.thresholds = []; + } + + disconnect() {} + observe() {} + takeRecords(): IntersectionObserverEntry[] { + return []; + } + unobserve() {} +} +window.IntersectionObserver = MockIntersectionObserver; + describe('Dropdown', () => { it('does not display options on initial render', () => { const { container } = render();