refactor: Integrate PortalDropdown component into PageHeaderActions for improved dropdown management and layout consistency
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 17m4s

This commit is contained in:
2025-08-13 12:04:36 +07:00
parent bdfbdcfa1f
commit ae8141bee8
2 changed files with 116 additions and 16 deletions
+21 -16
View File
@@ -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({
<IconPlayerPlay className="me-1" /> Фоновое обновление
</button>
{(onBackgroundUpdate && onOnlineUpdate) && (
<>
<button className="btn btn-outline-success dropdown-toggle dropdown-toggle-split" type="button" data-bs-toggle="dropdown" data-bs-container="body" aria-expanded="false" aria-label="Другие варианты обновления"></button>
<div className="dropdown-menu">
<button className="dropdown-item" type="button" onClick={onBackgroundUpdate}>
<IconPlayerPlay className="me-1" /> Фоновое обновление
</button>
<button className="dropdown-item" type="button" onClick={onOnlineUpdate}>
<IconBolt className="me-1" /> Online-обновление
</button>
</div>
</>
<PortalDropdown
buttonClassName="btn btn-outline-success dropdown-toggle dropdown-toggle-split"
buttonProps={{ 'aria-label': 'Другие варианты обновления' }}
align="right"
buttonContent={null}
>
<button className="dropdown-item" type="button" onClick={onBackgroundUpdate}>
<IconPlayerPlay className="me-1" /> Фоновое обновление
</button>
<button className="dropdown-item" type="button" onClick={onOnlineUpdate}>
<IconBolt className="me-1" /> Online-обновление
</button>
</PortalDropdown>
)}
</div>
)}
@@ -63,10 +66,12 @@ function PageHeaderActions({
)}
{(onImport || onExport) && (
<div className="dropdown">
<button className="btn btn-outline-primary dropdown-toggle" type="button" data-bs-toggle="dropdown" data-bs-container="body" aria-expanded="false" title="Импорт/Экспорт">
<IconDownload className="me-1" /> Импорт/Экспорт
</button>
<div className="dropdown-menu">
<PortalDropdown
buttonClassName="btn btn-outline-primary dropdown-toggle"
buttonProps={{ title: 'Импорт/Экспорт' }}
align="left"
buttonContent={<><IconDownload className="me-1" /> Импорт/Экспорт</>}
>
{onImport && (
<button className="dropdown-item" type="button" onClick={onImport}>
<IconUpload className="me-1" /> Импорт
@@ -77,7 +82,7 @@ function PageHeaderActions({
<IconDownload className="me-1" /> Экспорт
</button>
)}
</div>
</PortalDropdown>
</div>
)}
{onClear && (
@@ -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 (
<>
<button
{...buttonProps}
ref={buttonRef}
type="button"
className={buttonClassName}
onClick={(e) => { e.preventDefault(); setOpen((v) => !v); }}
aria-expanded={open ? 'true' : 'false'}
>
{buttonContent}
</button>
{open && body && createPortal(
<div
ref={menuRef}
style={{ position: 'absolute', top: `${coords.top}px`, left: `${coords.left}px`, zIndex: 10000, minWidth: `${coords.minWidth}px` }}
>
<div className={menuClassName} style={{ display: 'block' }}>
{children}
</div>
</div>,
body
)}
</>
);
}
export default PortalDropdown;