Files
Genarrative/packages/shared/src/components/OverflowActions.tsx
T
suzmii c329c438d9
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Has been cancelled
修复资源画布PR的CI回归(#410)
修复活动回合空快照引用及停用后晚到请求覆盖问题
对齐共享卡片角标与窗口发布次数回归断言
同步原生HTTP权限检查与图集显式切片测试契约
整理菜单组件导入顺序并记录本地验证范围
2026-09-17 20:17:26 +08:00

238 lines
7.5 KiB
TypeScript

import {
Children,
cloneElement,
Fragment,
isValidElement,
type ReactNode,
useEffect,
useId,
useLayoutEffect,
useRef,
useState,
} from 'react';
import { createPortal } from 'react-dom';
type ActionProps = { children?: ReactNode; 'aria-hidden'?: boolean | 'true' };
function flatten(nodes: ReactNode, prefix = ''): ReactNode[] {
return Children.toArray(nodes).flatMap((node, index) =>
isValidElement<ActionProps>(node) && node.type === Fragment
? flatten(node.props.children, `${prefix}${index}.`)
: [
isValidElement(node)
? cloneElement(node, { key: `${prefix}${index}` })
: node,
],
);
}
function isDivider(node: ReactNode) {
return (
isValidElement<ActionProps>(node) &&
(node.props['aria-hidden'] === true || node.props['aria-hidden'] === 'true')
);
}
/** 只负责展示收纳;动作权限、禁用与执行仍由调用方提供。 */
export function OverflowActions({
children,
maxVisible = Infinity,
label = '更多',
}: {
children: ReactNode;
maxVisible?: number;
label?: string;
}) {
const [open, setOpen] = useState(false);
const [position, setPosition] = useState({ left: 8, top: 8, maxHeight: 320 });
const trigger = useRef<HTMLButtonElement>(null);
const panel = useRef<HTMLDivElement>(null);
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
const id = useId();
const limit = Number.isFinite(maxVisible)
? Math.max(0, Math.floor(maxVisible))
: Infinity;
const nodes = flatten(children);
let count = 0;
const split = nodes.findIndex((node) => !isDivider(node) && ++count > limit);
const primary = split < 0 ? nodes : nodes.slice(0, split);
while (primary.length && isDivider(primary[primary.length - 1]))
primary.pop();
const overflow =
split < 0 ? [] : nodes.slice(split).filter((node) => !isDivider(node));
useEffect(() => {
if (overflow.length === 0) setOpen(false);
}, [overflow.length]);
const cancelClose = () => {
if (timer.current !== null) clearTimeout(timer.current);
timer.current = null;
};
const show = () => {
cancelClose();
setOpen(true);
};
const scheduleClose = () => {
cancelClose();
timer.current = setTimeout(() => {
if (!panel.current?.contains(document.activeElement)) setOpen(false);
}, 150);
};
useEffect(
() => () => {
if (timer.current !== null) clearTimeout(timer.current);
},
[],
);
useLayoutEffect(() => {
if (!open || !overflow.length) return;
const update = () => {
const anchor = trigger.current?.getBoundingClientRect();
if (!anchor) return;
const width = panel.current?.getBoundingClientRect().width ?? 200;
const height = panel.current?.scrollHeight ?? 320;
const below = window.innerHeight - anchor.bottom - 12;
const above = anchor.top - 12;
// 优先上展,留出卡片预览;窗口顶边空间不足时才向下避让。
const down = above < 80 && below > above;
const maxHeight = Math.max(40, Math.min(360, down ? below : above));
setPosition({
left: Math.max(
8,
Math.min(anchor.right - width, window.innerWidth - width - 8),
),
top: down
? anchor.bottom + 4
: Math.max(8, anchor.top - Math.min(height, maxHeight) - 4),
maxHeight,
});
};
update();
window.addEventListener('resize', update);
window.addEventListener('scroll', update, true);
return () => {
window.removeEventListener('resize', update);
window.removeEventListener('scroll', update, true);
};
}, [open, overflow.length, children]);
useEffect(() => {
if (!open) return;
const outside = (event: PointerEvent) => {
if (
event.target instanceof Node &&
!trigger.current?.contains(event.target) &&
!panel.current?.contains(event.target)
) {
setOpen(false);
}
};
const escape = (event: KeyboardEvent) => {
if (event.key !== 'Escape' || event.defaultPrevented) return;
event.preventDefault();
event.stopPropagation();
setOpen(false);
trigger.current?.focus();
};
document.addEventListener('pointerdown', outside);
document.addEventListener('keydown', escape);
return () => {
document.removeEventListener('pointerdown', outside);
document.removeEventListener('keydown', escape);
};
}, [open]);
if (!overflow.length) return <>{children}</>;
return (
<>
{primary}
<button
ref={trigger}
type="button"
className="shared-overflow-trigger image-canvas-editor__floating-toolbar-text-button"
aria-label={label}
aria-expanded={open}
aria-controls={open ? id : undefined}
onMouseEnter={show}
onMouseLeave={scheduleClose}
onClick={show}
onKeyDown={(event) => {
if (event.key === 'ArrowDown') {
event.preventDefault();
show();
requestAnimationFrame(() =>
panel.current
?.querySelector<HTMLButtonElement>('button:not(:disabled)')
?.focus(),
);
}
if (event.key === 'Escape') {
event.stopPropagation();
setOpen(false);
}
}}
>
{label} <span aria-hidden="true"></span>
</button>
{open
? createPortal(
<div
ref={panel}
id={id}
role="group"
aria-label={`${label}操作`}
className="shared-overflow-panel image-canvas-editor__portal-menu"
style={position}
onMouseEnter={cancelClose}
onMouseLeave={scheduleClose}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
if ((event.target as Element).closest('button:not(:disabled)'))
setOpen(false);
}}
onBlur={(event) => {
if (
!event.currentTarget.contains(event.relatedTarget) &&
event.relatedTarget !== trigger.current
)
setOpen(false);
}}
onKeyDown={(event) => {
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
setOpen(false);
trigger.current?.focus();
}
if (
['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)
) {
event.preventDefault();
const buttons = Array.from(
event.currentTarget.querySelectorAll<HTMLButtonElement>(
'button:not(:disabled)',
),
);
const index = buttons.indexOf(
document.activeElement as HTMLButtonElement,
);
const next =
event.key === 'Home'
? 0
: event.key === 'End'
? buttons.length - 1
: (index +
(event.key === 'ArrowDown' ? 1 : -1) +
buttons.length) %
buttons.length;
buttons[next]?.focus();
}
}}
>
{overflow}
</div>,
document.body,
)
: null}
</>
);
}