diff --git a/packages/ui/src/components/DateInput/components/Popup.tsx b/packages/ui/src/components/DateInput/components/Popup.tsx index 9e346f2bd9..4750ba6224 100644 --- a/packages/ui/src/components/DateInput/components/Popup.tsx +++ b/packages/ui/src/components/DateInput/components/Popup.tsx @@ -1,7 +1,7 @@ 'use client' import type { Dispatch, ReactNode, RefObject, SetStateAction } from 'react' -import { useEffect, useRef } from 'react' +import { useLayoutEffect, useRef } from 'react' import { Popup } from '../../Popup' import { POPUP_WIDTH } from '../constants' import { dateinputPopup } from './styles.css' @@ -38,7 +38,7 @@ export const CalendarPopup = ({ }: PopupProps) => { const ref = useRef(null) - useEffect(() => { + useLayoutEffect(() => { document.addEventListener('mousedown', event => handleClickOutside(event, ref, setVisible, refInput), ) diff --git a/packages/ui/src/components/Popup/index.tsx b/packages/ui/src/components/Popup/index.tsx index 6ac6ae50d1..8a2439782d 100644 --- a/packages/ui/src/components/Popup/index.tsx +++ b/packages/ui/src/components/Popup/index.tsx @@ -17,6 +17,7 @@ import { useEffect, useId, useImperativeHandle, + useLayoutEffect, useMemo, useRef, useState, @@ -204,8 +205,7 @@ export const Popup = forwardRef( } return null - // oxlint-disable react/exhaustive-deps - }, [portalTarget, role, childrenRef.current]) + }, [portalTarget, role]) // There are some issue when mixing animation and maxHeight on some browsers, so we disable animation if maxHeight is set. const animationDuration = @@ -227,19 +227,19 @@ export const Popup = forwardRef( ) const generatePopupPositions = useCallback(() => { - if (childrenRef.current && innerPopupRef.current) { + if (childrenRef.current && innerPopupRef.current && popupPortalTarget) { setPositions( computePositions({ align, childrenRef, hasArrow, placement, - popupPortalTarget: popupPortalTarget as HTMLElement, + popupPortalTarget, popupRef: innerPopupRef, }), ) } - }, [hasArrow, placement, popupPortalTarget, align, children]) + }, [hasArrow, placement, popupPortalTarget, align]) /** * This function is called when we need to recompute positions of popup due to window scroll or resize. @@ -346,11 +346,28 @@ export const Popup = forwardRef( [closePopup, debounceDelay, visible], ) + useLayoutEffect(() => { + const currentRef = childrenRef.current + const resizeObserver = new ResizeObserver(() => { + generatePopupPositions() + }) + + if (currentRef) { + resizeObserver.observe(currentRef) + } + + return () => { + if (currentRef) { + resizeObserver.unobserve(currentRef) + } + } + }, [visibleInDom, generatePopupPositions]) + /** * Once popup is visible in the dom we can compute positions, then set it visible on screen and add event to * recompute positions on scroll or screen resize. */ - useEffect(() => { + useLayoutEffect(() => { if (visibleInDom) { generatePopupPositions() @@ -379,12 +396,11 @@ export const Popup = forwardRef( ]) // This will be triggered when positions are computed and popup is visible in the dom. - useEffect(() => { + useLayoutEffect(() => { if (visibleInDom && innerPopupRef.current) { innerPopupRef.current.style.opacity = '1' } - // oxlint-disable react/exhaustive-deps - }, [positions]) + }, [visibleInDom, positions]) /** * If popup has `visible` prop it means the popup is manually controlled through this prop. diff --git a/packages/ui/src/components/SelectInput/components/Dropdown.tsx b/packages/ui/src/components/SelectInput/components/Dropdown.tsx index 76c2a58cc9..458e9ed7a1 100644 --- a/packages/ui/src/components/SelectInput/components/Dropdown.tsx +++ b/packages/ui/src/components/SelectInput/components/Dropdown.tsx @@ -2,17 +2,20 @@ import { useTheme } from '@ultraviolet/themes' import type { + ChangeEvent, ComponentProps, Dispatch, KeyboardEvent, + MouseEvent, ReactNode, RefObject, SetStateAction, } from 'react' import { + use, useCallback, - useContext, useEffect, + useLayoutEffect, useMemo, useRef, useState, @@ -219,13 +222,26 @@ const CreateDropdown = ({ ) } - const handleClick = (clickedOption: OptionType, group?: string) => { + const handleClick = ({ + clickedOption, + group, + event, + }: { + clickedOption: OptionType + group?: string + event: + | MouseEvent + | KeyboardEvent + | ChangeEvent + }) => { + event.stopPropagation() + setSelectedData({ clickedOption, group, type: 'selectOption' }) if (multiselect) { if (selectedData.selectedValues.includes(clickedOption.value)) { onChange?.( selectedData.selectedValues.filter( - val => val !== clickedOption.value, + value => value !== clickedOption.value, ), ) } else { @@ -420,16 +436,25 @@ const CreateDropdown = ({ data-testid={`option-${option.value}`} id={`option-${indexOption}`} key={option.value} - onClick={() => { + onClick={event => { if (!option.disabled) { - handleClick(option, group) + handleClick({ + clickedOption: option, + event, + group, + }) + } + }} + onKeyDown={event => { + const shouldClick = [' ', 'Enter'].includes(event.key) + if (shouldClick) { + handleClick({ + clickedOption: option, + event, + group, + }) } }} - onKeyDown={event => - [' ', 'Enter'].includes(event.key) - ? handleClick(option, group) - : null - } ref={ option.value === defaultSearchValue || option.searchText === defaultSearchValue @@ -447,9 +472,13 @@ const CreateDropdown = ({ } className={dropdownCheckbox} disabled={option.disabled} - onChange={() => { + onChange={event => { if (!option.disabled) { - handleClick(option, group) + handleClick({ + clickedOption: option, + event, + group, + }) } }} tabIndex={-1} @@ -548,14 +577,23 @@ const CreateDropdown = ({ data-testid={`option-${option.value}`} id={`option-${index}`} key={option.value} - onClick={() => { + onClick={event => { if (!option.disabled) { - handleClick(option) + handleClick({ + clickedOption: option, + event, + }) + } + }} + onKeyDown={event => { + const shouldClick = [' ', 'Enter'].includes(event.key) + if (shouldClick) { + handleClick({ + clickedOption: option, + event, + }) } }} - onKeyDown={event => - [' ', 'Enter'].includes(event.key) ? handleClick(option) : null - } ref={ option.value === defaultSearchValue || option.searchText === defaultSearchValue @@ -573,9 +611,12 @@ const CreateDropdown = ({ } className={dropdownCheckbox} disabled={option.disabled} - onChange={() => { + onChange={event => { if (!option.disabled) { - handleClick(option) + handleClick({ + clickedOption: option, + event, + }) } }} tabIndex={-1} @@ -637,9 +678,9 @@ export const Dropdown = ({ const [maxWidth, setWidth] = useState( refSelect.current?.offsetWidth ?? '100%', ) - const modalContext = useContext(ModalContext) + const modalContext = use(ModalContext) - useEffect(() => { + useLayoutEffect(() => { if (refSelect.current && isDropdownVisible) { const position = refSelect.current.getBoundingClientRect().bottom + @@ -647,25 +688,31 @@ export const Dropdown = ({ Number(theme.sizing[INPUT_SIZE_HEIGHT[size]].replace('rem', '')) * 16 + Number.parseInt(theme.space['5'], 10) const overflow = position - window.innerHeight + 32 + if (overflow > 0 && modalContext) { const currentModal = modalContext.openedModals[0] const modalElement = currentModal?.ref.current if (modalElement) { - const parentElement = modalElement.parentNode as HTMLElement - if (parentElement) { + const parentElement = modalElement.parentNode + + if (parentElement instanceof HTMLElement) { parentElement.scrollBy({ behavior: 'smooth', top: overflow, }) + } else { + modalElement.scrollBy({ + behavior: 'smooth', + top: overflow, + }) } } else { window.scrollBy({ behavior: 'smooth', top: overflow }) } } } - // oxlint-disable react/exhaustive-deps - }, [isDropdownVisible, refSelect, size, ref.current]) + }, [isDropdownVisible, refSelect, size, modalContext, theme]) const resizeDropdown = useCallback(() => { if ( @@ -696,33 +743,24 @@ export const Dropdown = ({ setSearch('') } - if (!searchable) { - document.addEventListener('keydown', event => - handleKeyDown( - event, - ref, - options, - searchBarActive, - setSearch, - setDefaultSearch, - search, - ), + const eventKeydown = (event: globalThis.KeyboardEvent) => + handleKeyDown( + event, + ref, + options, + searchBarActive, + setSearch, + setDefaultSearch, + search, ) + + if (!searchable) { + document.addEventListener('keydown', eventKeydown) } return () => { if (!searchable) { - document.removeEventListener('keydown', event => - handleKeyDown( - event, - ref, - options, - searchBarActive, - setSearch, - setDefaultSearch, - search, - ), - ) + document.removeEventListener('keydown', eventKeydown) } } }, [