Files
smart-go/web/lib/hooks/use-flyout-state.ts
T
2026-04-23 18:58:13 +08:00

130 lines
3.7 KiB
TypeScript

'use client';
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import type { MenuNode } from '@/lib/api/types/menu';
const HOVER_LEAVE_MS = 280;
type FlyoutState = {
flyoutRoot: MenuNode | null;
l1AnchorRect: DOMRect | null;
openFlyout: (node: MenuNode, anchorEl: HTMLDivElement) => void;
scheduleCloseFlyout: () => void;
clearCloseTimer: () => void;
closeFlyoutNow: () => void;
toggleFlyoutClick: (node: MenuNode, wrapper: HTMLDivElement | null) => void;
l1AnchorElRef: React.RefObject<HTMLDivElement | null>;
railScrollRef: React.RefObject<HTMLDivElement | null>;
};
/** 从 ClassicCollapsedSidebar 提取的 flyout 状态管理逻辑 */
export function useFlyoutState(pathname: string): FlyoutState {
const [flyoutRoot, setFlyoutRoot] = useState<MenuNode | null>(null);
const [l1AnchorRect, setL1AnchorRect] = useState<DOMRect | null>(null);
const l1AnchorElRef = useRef<HTMLDivElement | null>(null);
const railScrollRef = useRef<HTMLDivElement | null>(null);
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const clearCloseTimer = useCallback(() => {
if (closeTimerRef.current) {
clearTimeout(closeTimerRef.current);
closeTimerRef.current = null;
}
}, []);
const closeFlyout = useCallback(() => {
setFlyoutRoot(null);
setL1AnchorRect(null);
l1AnchorElRef.current = null;
}, []);
const scheduleCloseFlyout = useCallback(() => {
clearCloseTimer();
closeTimerRef.current = setTimeout(() => {
closeFlyout();
closeTimerRef.current = null;
}, HOVER_LEAVE_MS);
}, [clearCloseTimer, closeFlyout]);
const closeFlyoutNow = useCallback(() => {
clearCloseTimer();
closeFlyout();
}, [clearCloseTimer, closeFlyout]);
const syncL1Anchor = useCallback(() => {
const el = l1AnchorElRef.current;
if (el) setL1AnchorRect(el.getBoundingClientRect());
}, []);
useLayoutEffect(() => {
if (flyoutRoot) syncL1Anchor();
}, [flyoutRoot, syncL1Anchor]);
useEffect(() => {
if (!flyoutRoot) return;
const onResize = () => syncL1Anchor();
window.addEventListener('resize', onResize);
const rs = railScrollRef.current;
rs?.addEventListener('scroll', onResize, { passive: true });
return () => {
window.removeEventListener('resize', onResize);
rs?.removeEventListener('scroll', onResize);
};
}, [flyoutRoot, syncL1Anchor]);
useEffect(() => {
closeFlyout();
}, [pathname, closeFlyout]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') closeFlyout();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [closeFlyout]);
useEffect(() => clearCloseTimer, [clearCloseTimer]);
const openFlyout = useCallback(
(node: MenuNode, anchorEl: HTMLDivElement) => {
clearCloseTimer();
l1AnchorElRef.current = anchorEl;
setL1AnchorRect(anchorEl.getBoundingClientRect());
setFlyoutRoot(node);
},
[clearCloseTimer],
);
const toggleFlyoutClick = useCallback(
(node: MenuNode, wrapper: HTMLDivElement | null) => {
setFlyoutRoot((prev) => {
if (prev?.id === node.id) {
setL1AnchorRect(null);
l1AnchorElRef.current = null;
return null;
}
if (wrapper) {
l1AnchorElRef.current = wrapper;
setL1AnchorRect(wrapper.getBoundingClientRect());
return node;
}
return prev;
});
},
[],
);
return {
flyoutRoot,
l1AnchorRect,
openFlyout,
scheduleCloseFlyout,
clearCloseTimer,
closeFlyoutNow,
toggleFlyoutClick,
l1AnchorElRef,
railScrollRef,
};
}