Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
150 changes: 135 additions & 15 deletions mini-app/src/features/JobDetails.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <dialog>: 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,
Expand All @@ -32,7 +65,12 @@ export function JobDetails({
const dialog = useRef<HTMLDialogElement>(null);
/** Held through the closing transition so the sheet does not empty mid-fade. */
const [shown, setShown] = useState<Job | null>(job);
const [snap, setSnap] = useState<Snap>('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
Expand All @@ -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
Expand All @@ -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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
releaseDrag(event);
};

return (
<dialog
ref={dialog}
className="sheet"
data-snap={snap}
aria-label={shown ? `${shown.title} at ${shown.company}` : 'Vacancy details'}
onCancel={onClose}
onClose={onClose}
Expand All @@ -76,21 +183,34 @@ export function JobDetails({
>
{shown && (
<>
<span className="sheet__grip" aria-hidden="true" />
<header className="sheet__head">
<div style={{ flex: 1, minWidth: 0 }}>
<p className="eyebrow">
{sourceLabel(shown.source)} · {age(shown.publishedAt)}
</p>
<h2 className="title" style={{ fontSize: 19, marginTop: 4 }}>
{shown.title}
</h2>
<p className="job__company">{shown.company}</p>
</div>
<button type="button" className="sheet__close" onClick={onClose} aria-label="Close details">
<IconClose />
</button>
</header>
<div
className="sheet__handle"
onPointerDown={startDrag}
onPointerMove={moveDrag}
onPointerUp={endDrag}
onPointerCancel={cancelDrag}
>
<span className="sheet__grip" aria-hidden="true" />
<header className="sheet__head">
<div style={{ flex: 1, minWidth: 0 }}>
<p className="eyebrow">
{sourceLabel(shown.source)} · {age(shown.publishedAt)}
</p>
<h2 className="title" style={{ fontSize: 19, marginTop: 4 }}>
{shown.title}
</h2>
<p className="job__company">{shown.company}</p>
</div>
<button
type="button"
className="sheet__close"
onClick={onClose}
aria-label="Close details"
>
<IconClose />
</button>
</header>
</div>

<div className="sheet__body">
<section>
Expand Down
57 changes: 54 additions & 3 deletions mini-app/src/styles/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -782,14 +782,38 @@ p {
height and slides, leaving its lower part outside the screen area, so `dvh`
is not what the user can see — --tg-viewport-stable-height is. Sizing and
pinning against that keeps the whole sheet on screen; outside Telegram the
fallback makes both expressions collapse back to plain dvh. */
fallback makes both expressions collapse back to plain dvh.

Two snap heights, expressed as fractions of the visible viewport. Keeping the
arithmetic here rather than in JS means a host viewport change recomputes both
snap points through ordinary CSS, with no resize listener to keep in step. */

.sheet {
--sheet-vh: var(--tg-viewport-stable-height, 100dvh);
--sheet-collapsed: 0.42;
--sheet-expanded: 0.9;
--sheet-snap: var(--sheet-collapsed);
width: min(460px, 100%);
max-width: none;
/* Same 8% peek as before, now off the visible height, less the host header. */
max-height: calc(var(--sheet-vh) * 0.92 - var(--safe-top));
/*
* An explicit height, never shrink-wrap.
*
* This sheet is a flex column whose only growing child is `flex: 1; min-height: 0`.
* With `height: auto` that child's base size is 0 and its content-based minimum is
* switched off, so it contributes nothing to the column's intrinsic height and there
* is no free space to hand back — the sheet collapses to grip + header and leaves a
* scroll port the size of the body's own padding. WebKit does exactly that on
* Telegram iOS (a 193px sheet, a 60px body); Chromium stretches the dialog instead,
* which is why it was invisible to a Chromium-only suite. A definite height removes
* the dependency on intrinsic sizing altogether, so both engines agree.
*
* --sheet-drag is set only while a finger is on the handle, and removed on release so
* the height falls back to whichever snap point React settled on.
*/
height: var(--sheet-drag, calc(var(--sheet-vh) * var(--sheet-snap)));
/* The expanded snap is clamped by this: on a device with a Telegram header, clearing
that header wins over reaching the nominal 90%. */
max-height: calc(var(--sheet-vh) - var(--safe-top) - 8px);
/* Lifts the sheet by whatever Telegram is holding off screen. Clamped at 0 so a
host reporting a stable height above the WebView can never push it down. */
margin: auto auto max(0px, calc(100dvh - var(--sheet-vh)));
Expand All @@ -807,6 +831,7 @@ p {
transition:
opacity 200ms var(--ease),
translate 220ms var(--ease),
height 260ms var(--ease),
overlay 220ms allow-discrete,
display 220ms allow-discrete;
}
Expand All @@ -818,6 +843,15 @@ p {
flex-direction: column;
}

.sheet[data-snap='expanded'] {
--sheet-snap: var(--sheet-expanded);
}

/* A finger is already carrying the height; a transition would lag behind it. */
.sheet[data-dragging] {
transition: none;
}

@starting-style {
.sheet[open] {
opacity: 0;
Expand All @@ -844,6 +878,23 @@ p {
}
}

/*
* The drag region, and the only one.
*
* "Am I dragging the sheet or scrolling its content?" is answered by where the finger
* landed, not by a gesture arbiter watching scrollTop — so the body below stays a plain
* native scroll container and needs no touchmove interception to defend it.
*/
.sheet__handle {
flex: none;
touch-action: none;
cursor: grab;
}

.sheet__handle:active {
cursor: grabbing;
}

.sheet__grip {
flex: none;
width: 34px;
Expand Down
Loading
Loading