Position the ? popover against the viewport, not inside the dock

Direction was a prop, which only moved the clipping around: the panel is
an overflow-auto box, so a popover below a setting near the bottom is
cut by the dock's edge and one above a control near the top is cut by
its top. The box is `fixed` now, placed from the icon's own rect, so it
leaves the dock altogether -- it prefers up everywhere and flips down
only when the window itself has no room above. Scrolling closes it,
since a fixed box cannot follow its anchor.

Drops the dir prop and the `Change` icon's downward special case.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul Trowbridge 2026-09-23 22:40:58 -04:00
parent 6e09e90bdf
commit c87b380918

View File

@ -98,39 +98,72 @@ const LABEL_W = 'w-24'
// toggle is skipped, while still costing the dock a row. Hovering gives it // toggle is skipped, while still costing the dock a row. Hovering gives it
// (native title, which nothing can clip), clicking pins it. // (native title, which nothing can clip), clicking pins it.
// //
// It opens upward, because the settings it explains sit low in a dock that // Positioned `fixed` against the icon's own rect rather than absolutely inside
// scrolls -- a popover hanging below them is cut off by the dock's own edge. // it. Absolute meant the dock clipped it: the panel is an overflow-auto box, so
// `dir="down"` is for the few that sit at the top of the panel, where up is the // a popover under a setting at the bottom was cut off by the dock's edge and
// clipped direction instead. // one above a control at the top was cut off by its top. Direction was a prop
function Help({ text, dir = 'up' }) { // for a while, which only moved the clipping around -- fixed leaves the box
const [open, setOpen] = useState(false) // 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 if (!text) return null
return ( return (
<span className="relative inline-flex shrink-0"> <>
<button type="button" title={text} onClick={() => setOpen(o => !o)} onBlur={() => setOpen(false)} <button ref={btnRef} type="button" title={text}
onClick={() => (at ? setAt(null) : place())}
onBlur={() => setAt(null)}
aria-label={text} aria-label={text}
className={`w-4 h-4 rounded-full border leading-none flex items-center justify-center transition-colors ${ className={`w-4 h-4 rounded-full border leading-none flex items-center justify-center shrink-0 transition-colors ${
open ? 'border-blue-400 text-blue-600 bg-blue-50' : 'border-gray-300 text-gray-500 hover:text-blue-600 hover:border-blue-400'}`} at ? 'border-blue-400 text-blue-600 bg-blue-50' : 'border-gray-300 text-gray-500 hover:text-blue-600 hover:border-blue-400'}`}
style={{ fontSize: '10px' }}>?</button> style={{ fontSize: '10px' }}>?</button>
{open && ( {at && (
<span className={`absolute z-20 left-0 w-64 rounded border border-gray-200 bg-white shadow-lg px-2 py-1.5 <span
text-gray-600 leading-snug normal-case tracking-normal className="fixed z-50 rounded border border-gray-200 bg-white shadow-lg px-2 py-1.5
${dir === 'down' ? 'top-5' : 'bottom-5'}`}> text-gray-600 leading-snug normal-case tracking-normal"
style={{ ...at, width: HELP_W }}>
{text} {text}
</span> </span>
)} )}
</span> </>
) )
} }
// `stack` puts the label above its control instead of beside it. The aligned // `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 // 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. // 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) { if (stack) {
return ( return (
<div className="flex flex-col gap-1 min-w-0"> <div className="flex flex-col gap-1 min-w-0">
<span className="text-gray-600 whitespace-nowrap flex items-center gap-1.5">{label}<Help text={hint} dir={hintDir} /></span> <span className="text-gray-600 whitespace-nowrap flex items-center gap-1.5">{label}<Help text={hint} /></span>
<div className="flex items-center gap-2 min-w-0">{children}</div> <div className="flex items-center gap-2 min-w-0">{children}</div>
</div> </div>
) )
@ -140,7 +173,7 @@ function Field({ label, children, hint, hintDir, stack }) {
<span className={`text-gray-600 whitespace-nowrap shrink-0 pt-1 ${LABEL_W}`}>{label}</span> <span className={`text-gray-600 whitespace-nowrap shrink-0 pt-1 ${LABEL_W}`}>{label}</span>
<div className="flex items-center gap-2 flex-wrap min-w-0"> <div className="flex items-center gap-2 flex-wrap min-w-0">
{children} {children}
<Help text={hint} dir={hintDir} /> <Help text={hint} />
</div> </div>
</div> </div>
) )
@ -1540,7 +1573,7 @@ export default function OperationPanel({
with no heading over it, so the panel never said what it was about with no heading over it, so the panel never said what it was about
to do until you read the button. */} to do until you read the button. */}
<div className="flex items-start gap-4 flex-wrap px-3 py-2 border-b border-gray-200 bg-gray-50/60"> <div className="flex items-start gap-4 flex-wrap px-3 py-2 border-b border-gray-200 bg-gray-50/60">
<Field label="Change" hintDir="down" hint={OP_HELP[activeOp]}> <Field label="Change" hint={OP_HELP[activeOp]}>
<div className="inline-flex rounded border border-gray-300 divide-x divide-gray-300 shadow-sm overflow-hidden"> <div className="inline-flex rounded border border-gray-300 divide-x divide-gray-300 shadow-sm overflow-hidden">
{['scale', 'recode', 'clone'].map(op => ( {['scale', 'recode', 'clone'].map(op => (
<Button key={op} onClick={() => setActiveOp(op)} active={activeOp === op}> <Button key={op} onClick={() => setActiveOp(op)} active={activeOp === op}>