diff --git a/frontend/src/components/PageHeaderActions.jsx b/frontend/src/components/PageHeaderActions.jsx index 54765a3..cb59bbc 100644 --- a/frontend/src/components/PageHeaderActions.jsx +++ b/frontend/src/components/PageHeaderActions.jsx @@ -6,6 +6,7 @@ import { IconPlayerPlay, IconBolt } from '@tabler/icons-react'; +import PortalDropdown from './PortalDropdown.jsx'; function PageHeaderActions({ loading = false, @@ -39,17 +40,19 @@ function PageHeaderActions({ Фоновое обновление {(onBackgroundUpdate && onOnlineUpdate) && ( - <> - -
- - -
- + + + + )} )} @@ -63,10 +66,12 @@ function PageHeaderActions({ )} {(onImport || onExport) && (
- -
+ Импорт/Экспорт} + > {onImport && ( )} -
+
)} {onClear && ( diff --git a/frontend/src/components/PortalDropdown.jsx b/frontend/src/components/PortalDropdown.jsx new file mode 100644 index 0000000..1b98581 --- /dev/null +++ b/frontend/src/components/PortalDropdown.jsx @@ -0,0 +1,95 @@ +import { useEffect, useLayoutEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; + +function getBody() { + if (typeof document === 'undefined') return null; + return document.body; +} + +function PortalDropdown({ + buttonClassName = 'btn btn-outline-secondary dropdown-toggle', + buttonProps = {}, + align = 'left', + children, + buttonContent, + menuClassName = 'dropdown-menu show', +}) { + const buttonRef = useRef(null); + const menuRef = useRef(null); + const [open, setOpen] = useState(false); + const [coords, setCoords] = useState({ top: 0, left: 0, minWidth: 0 }); + + const updatePosition = () => { + const btn = buttonRef.current; + if (!btn) return; + const rect = btn.getBoundingClientRect(); + const minWidth = rect.width; // make menu at least as wide as button + let left = rect.left; + if (align === 'right') { + const menu = menuRef.current; + const menuWidth = menu ? menu.offsetWidth : 0; + left = Math.max(0, rect.right - (menuWidth || rect.width)); + } + setCoords({ top: rect.bottom + window.scrollY, left: left + window.scrollX, minWidth }); + }; + + useLayoutEffect(() => { + if (open) updatePosition(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]); + + useEffect(() => { + if (!open) return; + const onScroll = () => updatePosition(); + const onResize = () => updatePosition(); + const onKey = (e) => { if (e.key === 'Escape') setOpen(false); }; + const onClickOutside = (e) => { + if (!menuRef.current || !buttonRef.current) return; + if (!menuRef.current.contains(e.target) && !buttonRef.current.contains(e.target)) { + setOpen(false); + } + }; + window.addEventListener('scroll', onScroll, true); + window.addEventListener('resize', onResize); + document.addEventListener('keydown', onKey); + document.addEventListener('mousedown', onClickOutside); + return () => { + window.removeEventListener('scroll', onScroll, true); + window.removeEventListener('resize', onResize); + document.removeEventListener('keydown', onKey); + document.removeEventListener('mousedown', onClickOutside); + }; + }, [open]); + + const body = getBody(); + + return ( + <> + + {open && body && createPortal( +
+
+ {children} +
+
, + body + )} + + ); +} + +export default PortalDropdown; + +