diff --git a/ui/src/components/OperationPanel.jsx b/ui/src/components/OperationPanel.jsx index 15e02ca..87c0729 100644 --- a/ui/src/components/OperationPanel.jsx +++ b/ui/src/components/OperationPanel.jsx @@ -98,39 +98,72 @@ const LABEL_W = 'w-24' // toggle is skipped, while still costing the dock a row. Hovering gives it // (native title, which nothing can clip), clicking pins it. // -// It opens upward, because the settings it explains sit low in a dock that -// scrolls -- a popover hanging below them is cut off by the dock's own edge. -// `dir="down"` is for the few that sit at the top of the panel, where up is the -// clipped direction instead. -function Help({ text, dir = 'up' }) { - const [open, setOpen] = useState(false) +// Positioned `fixed` against the icon's own rect rather than absolutely inside +// it. Absolute meant the dock clipped it: the panel is an overflow-auto box, so +// a popover under a setting at the bottom was cut off by the dock's edge and +// one above a control at the top was cut off by its top. Direction was a prop +// for a while, which only moved the clipping around -- fixed leaves the box +// altogether, so it can prefer up everywhere and flip down only when the window +// itself has no room. +const HELP_W = 256 // px; matches the rendered width +function Help({ text }) { + const [at, setAt] = useState(null) + const btnRef = useRef(null) + + const place = () => { + const r = btnRef.current?.getBoundingClientRect() + if (!r) return + const left = Math.max(8, Math.min(r.left, window.innerWidth - HELP_W - 8)) + // Up unless the top of the window is nearer than the box is tall. + const up = r.top > 160 + setAt(up + ? { left, bottom: window.innerHeight - r.top + 6 } + : { left, top: r.bottom + 6 }) + } + + useEffect(() => { + if (!at) return + const close = () => setAt(null) + // Any scroll moves the anchor out from under a fixed box, so it closes + // rather than floating somewhere it no longer points at. + window.addEventListener('scroll', close, true) + window.addEventListener('resize', close) + return () => { + window.removeEventListener('scroll', close, true) + window.removeEventListener('resize', close) + } + }, [at]) + if (!text) return null return ( - - - {open && ( - + {at && ( + {text} )} - + ) } // `stack` puts the label above its control instead of beside it. The aligned // label column costs 6rem, which is most of a narrow zone -- the tag field in // the bottom dock had 12rem of box left to type an initiative name into. -function Field({ label, children, hint, hintDir, stack }) { +function Field({ label, children, hint, stack }) { if (stack) { return (
- {label} + {label}
{children}
) @@ -140,7 +173,7 @@ function Field({ label, children, hint, hintDir, stack }) { {label}
{children} - +
) @@ -1540,7 +1573,7 @@ export default function OperationPanel({ with no heading over it, so the panel never said what it was about to do until you read the button. */}
- +
{['scale', 'recode', 'clone'].map(op => (