From 73c5b02b4b0cc85f14c796eba0ad56d928efcd7a Mon Sep 17 00:00:00 2001 From: Pavlo Sushkov Date: Sun, 9 Aug 2026 22:14:56 +0300 Subject: [PATCH 1/3] fix(mini-app): make the details sheet a real two-snap bottom sheet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Web Inspector on a real iPhone: the sheet was 193px tall with a 60px scroll port, so almost every vacancy action sat behind a strip of content. The host gesture was not the cause — PR #10 works, and Telegram receives allow_vertical_swipe:false — and neither was the viewport, which reports 800 everywhere. The cause is sizing. The sheet is a flex column whose only growing child is `flex: 1; min-height: 0`, and it had no height of its own. With `height: auto` that child has a base size of 0 and its content-based minimum switched off, so it contributes nothing to the column's intrinsic height and there is no free space to hand back: the sheet shrink-wraps to grip + header and the body is left at the size of its padding. WebKit does that; Chromium stretches the dialog instead, which is why a Chromium-only suite never saw it — playwright.config.ts documents that the WebKit build segfaults on this machine. So the sheet gets an explicit height, and with a height it may as well be the bottom sheet the grip has always implied: two snap points at 42% and 90% of the *visible* Telegram viewport, the expanded one clamped to clear the host header. The ratios live in CSS, so a viewportChanged event recomputes both through ordinary custom-property substitution with no resize listener. Dragging is local to a `.sheet__handle` region — grip plus header, `touch-action: none` — using pointer events with pointer capture. That is the whole gesture arbitration: a finger that lands on the body is scrolling the body, because nothing else is listening there. No global touchmove interception, no scrollTop-watching handover, no dependency added. The is retained. What it was blamed for was the sizing above, not anything about being a dialog, and it still supplies the focus trap, inert background, Esc, focus restore and top-layer stacking that a hand-rolled overlay would have to reimplement. Co-Authored-By: Claude Opus 5 --- mini-app/src/features/JobDetails.tsx | 131 +++++++++-- mini-app/src/styles/app.css | 57 ++++- mini-app/tests/sheet-snap.spec.ts | 337 +++++++++++++++++++++++++++ mini-app/tests/telegram.ts | 108 ++++++++- 4 files changed, 611 insertions(+), 22 deletions(-) create mode 100644 mini-app/tests/sheet-snap.spec.ts diff --git a/mini-app/src/features/JobDetails.tsx b/mini-app/src/features/JobDetails.tsx index 3ab8eb0..d813297 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,58 @@ 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`); + }; + + const endDrag = (event: React.PointerEvent) => { + const element = dialog.current; + const state = drag.current; + if (!element || !state || state.pointer !== event.pointerId) return; + drag.current = null; + delete element.dataset.dragging; + // Hands the height back to the snap ratio, which the transition then animates to. + element.style.removeProperty('--sheet-drag'); + + const travelled = state.height - state.startHeight; + 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(); + } + }; + return ( {shown && ( <> -