mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 15:36:33 +00:00
96ab748f34
The fill level was one figure for the whole trip, and a trip does not have one. A motorway rapid charger is worth about 80 % because the last fifth costs as long again; the charger at the hotel is worth all of it because the car stands there all night. Between those two the difference is a whole leg, and a single setting has to be wrong about one of them. So the badge on a refuelling stop is now the way in: it shows what that stop will actually fill to, its own figure or the traveller's, and opens a panel of the six answers people actually give. Under them it says what the choice buys in kilometres, because 80 % is an abstraction and "about 290 km from here" is the thing being decided. Picking the figure a stop already has hands it back to the setting, the same gesture the kind picker uses to un-pick a kind. The value lives in places.fill_percent, beside stop_type, because it is a property of the stop rather than of the person. Null means "follow the setting", which is every stop that predates the column and every stop nobody has an opinion about, so nothing changes for a trip that never touches it. Two pieces of shared machinery came out of it rather than being copied. The popover chrome — measure before showing, flip when the window is short, close on an outside click, Escape or a scroll — is one component now, used by the kind picker and this one. So is the assembly of the range from seven settings, which the routes hook and the rail both need and which would have drifted the first time a field was added.
85 lines
3.5 KiB
TypeScript
85 lines
3.5 KiB
TypeScript
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
|
import { createPortal } from 'react-dom'
|
|
|
|
/**
|
|
* A small panel hung under something in the rail.
|
|
*
|
|
* The rail has more than one badge worth opening — what kind of stop this is, how full it
|
|
* fills — and every one of them needs the same four things: measure before showing,
|
|
* flip above when the window is short, close on an outside click or Escape, and close on
|
|
* a scroll rather than chase the anchor. Each is one line to get wrong and none of them
|
|
* is about what the panel contains, which is why they live here once instead of beside
|
|
* every panel that needs them.
|
|
*
|
|
* Deliberately not the `CustomSelect` machinery: that one is a select, with a value, a
|
|
* search and a keyboard model. This is a surface.
|
|
*/
|
|
export default function AnchoredPopover({ anchor, label, children, onClose }: {
|
|
/** The element the panel hangs under. Null renders nothing, which is how it stays closed. */
|
|
anchor: HTMLElement | null
|
|
/** What the dialog is called, for anyone who cannot see where it is hanging. */
|
|
label: string
|
|
children: React.ReactNode
|
|
onClose: () => void
|
|
}): React.ReactElement | null {
|
|
const ref = useRef<HTMLDivElement>(null)
|
|
const [pos, setPos] = useState<{ top: number; left: number } | null>(null)
|
|
|
|
// Measured after paint, before the browser shows it: reading the size first and then
|
|
// placing it is what stops the panel appearing at 0,0 for one frame.
|
|
useLayoutEffect(() => {
|
|
if (!anchor || !ref.current) return
|
|
const a = anchor.getBoundingClientRect()
|
|
const p = ref.current.getBoundingClientRect()
|
|
const gap = 8
|
|
let top = a.bottom + gap
|
|
// Flips above when there is no room below, which is most of the rail on a short
|
|
// window: a panel clipped by the viewport is one that cannot be used at all.
|
|
if (top + p.height > window.innerHeight - 8) top = Math.max(8, a.top - p.height - gap)
|
|
// Centred on the column, not on the anchor. A badge sits hard against one edge of the
|
|
// rail, so a panel centred on it hangs half off the sidebar and points at the map.
|
|
// Vertically it still follows the row it belongs to.
|
|
const column = anchor.closest('section')?.getBoundingClientRect() ?? a
|
|
const left = Math.min(
|
|
Math.max(8, column.left + column.width / 2 - p.width / 2),
|
|
window.innerWidth - p.width - 8,
|
|
)
|
|
setPos({ top, left })
|
|
}, [anchor])
|
|
|
|
useEffect(() => {
|
|
if (!anchor) return
|
|
const onDown = (e: MouseEvent) => {
|
|
if (ref.current?.contains(e.target as Node)) return
|
|
if (anchor.contains(e.target as Node)) return
|
|
onClose()
|
|
}
|
|
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
|
|
document.addEventListener('mousedown', onDown)
|
|
document.addEventListener('keydown', onKey)
|
|
// Any scroll moves the anchor out from under the panel, and following it would mean
|
|
// measuring on every frame for a menu that is open for two seconds.
|
|
window.addEventListener('scroll', onClose, true)
|
|
return () => {
|
|
document.removeEventListener('mousedown', onDown)
|
|
document.removeEventListener('keydown', onKey)
|
|
window.removeEventListener('scroll', onClose, true)
|
|
}
|
|
}, [anchor, onClose])
|
|
|
|
if (!anchor) return null
|
|
|
|
return createPortal(
|
|
<div
|
|
ref={ref}
|
|
role="dialog"
|
|
aria-label={label}
|
|
className="fixed z-[70] rounded-2xl border border-edge bg-surface-card p-2 shadow-xl"
|
|
style={{ top: pos?.top ?? -9999, left: pos?.left ?? -9999, visibility: pos ? 'visible' : 'hidden' }}
|
|
>
|
|
{children}
|
|
</div>,
|
|
document.body,
|
|
)
|
|
}
|