diff --git a/mini-app/src/features/JobDetails.tsx b/mini-app/src/features/JobDetails.tsx index 3ab8eb0..317523e 100644 --- a/mini-app/src/features/JobDetails.tsx +++ b/mini-app/src/features/JobDetails.tsx @@ -15,10 +15,43 @@ import { ScoreRail } from '../components/ScoreRail'; import { OpenVacancy } from './Review'; import { Points } from './JobCard'; +/** Where the sheet rests. The heights themselves belong to the stylesheet, not to this file. */ +type Snap = 'collapsed' | 'expanded'; + +/** + * How far a drag must travel before it changes snap state. + * + * Deliberately measured from where the drag started rather than from the midpoint between + * the two snaps: that midpoint is a quarter of the screen away, so every state change would + * need a long deliberate haul instead of the flick a bottom sheet is expected to answer. + */ +const COMMIT_PX = 64; + +/** The snap heights in pixels, read back from the stylesheet that defines them. */ +function snapMetrics(sheet: HTMLDialogElement) { + const styles = getComputedStyle(sheet); + const visible = + parseFloat( + getComputedStyle(document.documentElement).getPropertyValue('--tg-viewport-stable-height'), + ) || window.innerHeight; + const max = parseFloat(styles.maxHeight) || visible; + const ratio = (name: string) => parseFloat(styles.getPropertyValue(name)) || 0; + return { + collapsed: Math.min(visible * ratio('--sheet-collapsed'), max), + expanded: Math.min(visible * ratio('--sheet-expanded'), max), + }; +} + /** * A native : focus trapping, Esc, inert background and the backdrop all come * from the platform. Enter and exit are CSS transitions with allow-discrete, so no * animation library is involved in showing it. + * + * It is a real bottom sheet on top of that: two snap heights, dragged by its handle. The + * dialog is retained because nothing about that behaviour fights it — the failure it was + * blamed for is shrink-wrap sizing (see app.css), which an explicit height settles, whereas + * replacing it would mean hand-rolling the focus trap, inert background, Esc, focus restore + * and top-layer stacking it provides for nothing. */ export function JobDetails({ job, @@ -32,7 +65,12 @@ export function JobDetails({ const dialog = useRef(null); /** Held through the closing transition so the sheet does not empty mid-fade. */ const [shown, setShown] = useState(job); + const [snap, setSnap] = useState('collapsed'); const open = job !== null; + /** Live drag, never rendered: a height per pointermove through React would be a frame behind. */ + const drag = useRef<{ pointer: number; from: number; startHeight: number; height: number } | null>( + null, + ); /** * Telegram's own vertical gesture competes with scrolling this sheet, so the host is asked @@ -52,6 +90,8 @@ export function JobDetails({ if (job) { setShown(job); if (!element.open) { + // Every open starts at the near snap, whatever the last one was left at. + setSnap('collapsed'); element.showModal(); // The body outlives the dialog and keeps its offset, so a reopen would resume // wherever the last vacancy was left. Only a shown dialog has a scroll box to @@ -63,10 +103,77 @@ export function JobDetails({ } }, [job]); + /** + * Local drag, on the handle only. Pointer events cover mouse, touch and pen through one + * path, and pointer capture keeps the gesture attached to the handle once the finger has + * left it — so there is no document-level listener and nothing global to unwind. + */ + const startDrag = (event: React.PointerEvent) => { + const element = dialog.current; + // The close button lives in this region; a press on a control belongs to the control. + if (!element || (event.target as HTMLElement).closest('button, a')) return; + const height = element.getBoundingClientRect().height; + drag.current = { pointer: event.pointerId, from: event.clientY, startHeight: height, height }; + event.currentTarget.setPointerCapture(event.pointerId); + element.dataset.dragging = 'true'; + }; + + const moveDrag = (event: React.PointerEvent) => { + const element = dialog.current; + const state = drag.current; + if (!element || !state || state.pointer !== event.pointerId) return; + const { collapsed, expanded } = snapMetrics(element); + // Below the collapsed height is allowed so a dismissing drag tracks the finger rather + // than sticking; above the expanded height is not, since that is the ceiling it snaps to. + state.height = Math.min( + expanded, + Math.max(collapsed * 0.4, state.startHeight - (event.clientY - state.from)), + ); + element.style.setProperty('--sheet-drag', `${state.height}px`); + }; + + /** + * Ends the gesture and hands the height back to the snap ratio, which the transition then + * animates to. Returns how far the drag travelled, or null if this pointer was not the one + * dragging. + */ + const releaseDrag = (event: React.PointerEvent) => { + const element = dialog.current; + const state = drag.current; + if (!element || !state || state.pointer !== event.pointerId) return null; + drag.current = null; + delete element.dataset.dragging; + element.style.removeProperty('--sheet-drag'); + return state.height - state.startHeight; + }; + + /** A completed gesture: the distance the user chose is what decides where the sheet lands. */ + const endDrag = (event: React.PointerEvent) => { + const travelled = releaseDrag(event); + if (travelled === null) return; + if (travelled > COMMIT_PX) setSnap('expanded'); + else if (travelled < -COMMIT_PX) { + if (snap === 'expanded') setSnap('collapsed'); + // Already at the near snap and still pulling down: that is a dismissal. + else onClose(); + } + }; + + /** + * The browser or the OS took the pointer away — an interrupted gesture, not a decision. It + * expresses no intent, so committing on the distance it happened to reach would expand, + * collapse or even dismiss the sheet on something the user never finished. Tear down and + * let it spring back to the snap it started from. + */ + const cancelDrag = (event: React.PointerEvent) => { + releaseDrag(event); + }; + return ( {shown && ( <> -