3c5c6964e2
- Mail reader: .miu-mail-msg gets flex:0 0 auto so cards keep their natural height in the scrolling column instead of being compressed + clipped by overflow:hidden. - Reaction/emoji picker renders through a PopoverPortal to <body> (positioned + themed) so it floats above the message list instead of being cut by the scroll container's overflow. 72 tests green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
68 lines
2.3 KiB
TypeScript
68 lines
2.3 KiB
TypeScript
import { useEffect, useLayoutEffect, useState, type CSSProperties, type ReactNode, type RefObject } from 'react';
|
|
import { createPortal } from 'react-dom';
|
|
import { copyThemeVars } from './modal-portal';
|
|
|
|
/**
|
|
* A small popover (e.g. the reaction picker) rendered into document.body so it is never clipped by
|
|
* a scroll container's `overflow: hidden` — the reported "emoji picker goes beneath the container"
|
|
* bug. Positioned fixed just below the anchor, right-aligned to it, and re-placed on scroll/resize.
|
|
* Closes on outside pointer-down, scroll of a different element, or Escape. Carries the SDK theme
|
|
* tokens (copied from the live surface) since a body-portaled node is outside the themed subtree.
|
|
*/
|
|
export function PopoverPortal({
|
|
anchorRef,
|
|
onClose,
|
|
children,
|
|
}: {
|
|
anchorRef: RefObject<HTMLElement | null>;
|
|
onClose: () => void;
|
|
children: ReactNode;
|
|
}) {
|
|
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
|
|
const [vars] = useState(copyThemeVars);
|
|
|
|
useLayoutEffect(() => {
|
|
const el = anchorRef.current;
|
|
if (!el) return;
|
|
const place = (): void => {
|
|
const r = el.getBoundingClientRect();
|
|
setPos({ top: r.bottom + 4, left: r.right });
|
|
};
|
|
place();
|
|
window.addEventListener('resize', place);
|
|
window.addEventListener('scroll', place, true);
|
|
return () => {
|
|
window.removeEventListener('resize', place);
|
|
window.removeEventListener('scroll', place, true);
|
|
};
|
|
}, [anchorRef]);
|
|
|
|
useEffect(() => {
|
|
const onDown = (e: PointerEvent): void => {
|
|
const target = e.target as Node;
|
|
if (anchorRef.current?.contains(target)) return;
|
|
if ((target as Element).closest?.('.miu-popover')) return;
|
|
onClose();
|
|
};
|
|
const onKey = (e: KeyboardEvent): void => {
|
|
if (e.key === 'Escape') onClose();
|
|
};
|
|
document.addEventListener('pointerdown', onDown, true);
|
|
document.addEventListener('keydown', onKey);
|
|
return () => {
|
|
document.removeEventListener('pointerdown', onDown, true);
|
|
document.removeEventListener('keydown', onKey);
|
|
};
|
|
}, [anchorRef, onClose]);
|
|
|
|
if (typeof document === 'undefined' || !pos) return null;
|
|
return createPortal(
|
|
<div className="miu-portal">
|
|
<div className="miu-popover" style={{ top: pos.top, left: pos.left, ...vars } as CSSProperties}>
|
|
{children}
|
|
</div>
|
|
</div>,
|
|
document.body,
|
|
);
|
|
}
|