feat(mobile): pull sheets from anywhere with a mouse too, glide the active day

A pull on a sheet's content only answered touch, so with a mouse the sheet
could only be dragged by its top edge, and a pull that started on a button
read as a press. Mouse and pen now pull the content like a finger, a pull
that starts on a button swallows its click, and text fields keep their own
gesture.

The active day chip and the segmented controls now glide one shared pill to
the new item on the compositor, instead of repainting two buttons.
This commit is contained in:
Maurice
2026-11-06 12:16:54 +01:00
parent 7293ee1174
commit 83f0bd2ead
5 changed files with 192 additions and 69 deletions
+5 -19
View File
@@ -1,4 +1,5 @@
import { ReactNode, useLayoutEffect, useRef, useState } from 'react'
import { ReactNode, useRef } from 'react'
import { useActivePill } from './useActivePill'
export interface MSegmentedOption<T extends string = string> {
value: T
@@ -26,19 +27,9 @@ export default function MSegmented<T extends string = string>({
className = '',
}: MSegmentedProps<T>) {
const trackRef = useRef<HTMLDivElement>(null)
const [indicator, setIndicator] = useState<{ left: number; width: number } | null>(null)
const pill = useActivePill(trackRef, '[data-active="true"]', [value, options.length])
useLayoutEffect(() => {
const track = trackRef.current
if (!track) return
const measure = () => {
const active = track.querySelector<HTMLElement>('[data-active="true"]')
if (active) setIndicator({ left: active.offsetLeft, width: active.offsetWidth })
}
measure()
const ro = new ResizeObserver(measure)
ro.observe(track)
return () => ro.disconnect()
return () => ro.disconnect()
}, [value, options.length])
return (
@@ -47,12 +38,7 @@ export default function MSegmented<T extends string = string>({
role="tablist"
className={`relative flex rounded-full bg-[color:var(--m-ic)] p-[3px] ${className}`}
>
{indicator && (
<div
className="absolute top-[3px] bottom-[3px] rounded-full bg-m-act transition-[left,width] duration-[280ms] ease-[var(--ease-drawer)]"
style={{ left: indicator.left, width: indicator.width }}
/>
)}
{pill && <div aria-hidden="true" className="absolute bottom-[3px] left-0 top-[3px] rounded-full bg-m-act" style={pill} />}
{options.map((opt) => {
const active = opt.value === value
return (
@@ -0,0 +1,50 @@
import type { CSSProperties, DependencyList, RefObject } from 'react';
import { useLayoutEffect, useState } from 'react';
/**
* The pill that glides under the active item of a segmented control or a chip
* rail, the way a native segmented control moves its thumb instead of
* repainting two buttons.
*
* Measures the active item inside `trackRef` (the first match of `selector`)
* and hands back the pill's style: positioned by transform, so the glide runs
* on the compositor. The first placement does not animate, so a screen opens
* with the pill already in place rather than sliding in from the edge. The
* pill sits inside the track's scroll content, so a rail that scrolls carries
* it along.
*/
export function useActivePill(
/** The track, as a ref or as the element itself (a callback-ref'd rail). */
trackRef: RefObject<HTMLElement | null> | HTMLElement | null,
selector: string,
deps: DependencyList
): CSSProperties | null {
const [box, setBox] = useState<{ left: number; width: number; animate: boolean } | null>(null);
useLayoutEffect(() => {
const track = trackRef && 'current' in trackRef ? trackRef.current : trackRef;
if (!track) return;
const measure = () => {
const active = track.querySelector<HTMLElement>(selector);
if (!active) {
setBox(null);
return;
}
setBox((prev) => ({ left: active.offsetLeft, width: active.offsetWidth, animate: prev !== null }));
};
measure();
if (typeof ResizeObserver === 'undefined') return;
const observer = new ResizeObserver(measure);
observer.observe(track);
return () => observer.disconnect();
// The caller names what moves the active item; the ref and selector are stable.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps);
if (!box) return null;
return {
width: box.width,
transform: `translateX(${box.left}px)`,
transition: box.animate ? 'transform 320ms var(--ease-drawer), width 320ms var(--ease-drawer)' : 'none',
};
}
+96 -47
View File
@@ -6,7 +6,8 @@ import { useCallback, useEffect, useRef } from 'react';
*
* Two ways in. The grab strip along the top edge answers pointers, so a mouse
* or a pen on a narrow window can pull it too. And the sheet's content answers
* a finger pulling down once whatever it scrolls is at its top: the gesture
* a finger, a mouse or a pen pulling down once whatever it scrolls is at its top
* (a pull that starts on a button is a pull, and its click is swallowed): the gesture
* that would otherwise only rubber-band the list carries the whole sheet, as
* on iOS. A content drag is claimed on the first move or not at all, so the
* list's own scroll and any horizontal scroller inside keep their gestures.
@@ -34,7 +35,8 @@ const STRETCH_MAX = 20;
export const SPRING_BACK = 'transform 280ms var(--ease-drawer)';
/** Controls that own a downward finger themselves. */
const OWNS_GESTURE = 'input[type="range"], textarea, select, [data-sheet-nodrag]';
const OWNS_GESTURE =
'input:not([type="checkbox"]):not([type="radio"]):not([type="button"]):not([type="submit"]), textarea, select, [contenteditable="true"], [data-sheet-nodrag]';
interface Drag {
y0: number;
@@ -156,68 +158,115 @@ export function useSheetDrag({ enabled, panelRef, onDismiss }: SheetDragOptions)
[release]
);
// ── The content: touch events, claimed on the first move ──────────────────
// ── The content: a finger (touch events) or a mouse or pen (pointer events),
// claimed on the first move or let go ─────────────────────────────────────
useEffect(() => {
const panel = panelRef.current;
if (!enabled || !panel) return;
/** Set by a claimed drag: the click the browser sends after it is not a tap. */
let swallowClick = false;
const onStart = (e: TouchEvent) => {
const begin = (x: number, y: number, t: number, target: EventTarget | null): boolean => {
const el = target as Element | null;
if (el?.closest?.('[data-sheet-grab]') || el?.closest?.(OWNS_GESTURE)) return false;
swallowClick = false;
drag.current = { y0: y, x0: x, samples: [{ y, t }], claimed: false, scroller: nearestScroller(target, panel) };
return true;
};
/** False once the gesture is not ours, so the caller lets it go. */
const advance = (x: number, y: number, t: number): boolean => {
const d = drag.current;
if (!d) return false;
if (!d.claimed) {
const dy = y - d.y0;
const dx = x - d.x0;
if (Math.abs(dy) <= CLASSIFY_PX && Math.abs(dx) <= CLASSIFY_PX) return true;
const atTop = !d.scroller || d.scroller.scrollTop <= 0;
if (!(dy > 0 && Math.abs(dy) > Math.abs(dx) * AXIS_BIAS && atTop)) {
drag.current = null;
return false;
}
d.claimed = true;
// Measured from here, so the sheet does not jump by the classify slop.
d.y0 = y;
d.samples = [{ y, t }];
}
sample(d, y, t);
follow(y - d.y0);
return true;
};
const finish = (y: number | undefined) => {
const d = drag.current;
drag.current = null;
if (!d?.claimed) return;
swallowClick = true;
const last = d.samples[d.samples.length - 1];
release(d, y ?? last.y);
};
const onTouchStart = (e: TouchEvent) => {
if (e.touches.length !== 1) {
drag.current = null;
return;
}
const target = e.target as Element | null;
if (target?.closest?.('[data-sheet-grab]') || target?.closest?.(OWNS_GESTURE)) return;
const t = e.touches[0];
drag.current = {
y0: t.clientY,
x0: t.clientX,
samples: [{ y: t.clientY, t: e.timeStamp }],
claimed: false,
scroller: nearestScroller(e.target, panel),
};
begin(t.clientX, t.clientY, e.timeStamp, e.target);
};
const onMove = (e: TouchEvent) => {
const d = drag.current;
if (!d || e.touches.length !== 1) return;
const onTouchMove = (e: TouchEvent) => {
if (!drag.current || e.touches.length !== 1) return;
const t = e.touches[0];
const dy = t.clientY - d.y0;
const dx = t.clientX - d.x0;
if (!d.claimed) {
if (Math.abs(dy) <= CLASSIFY_PX && Math.abs(dx) <= CLASSIFY_PX) return;
const atTop = !d.scroller || d.scroller.scrollTop <= 0;
if (dy > 0 && Math.abs(dy) > Math.abs(dx) * AXIS_BIAS && atTop) {
d.claimed = true;
// Measured from here, so the sheet does not jump by the classify slop.
d.y0 = t.clientY;
d.samples = [{ y: t.clientY, t: e.timeStamp }];
} else {
drag.current = null;
return;
}
if (advance(t.clientX, t.clientY, e.timeStamp) && drag.current?.claimed && e.cancelable) e.preventDefault();
};
const onTouchEnd = (e: TouchEvent) => finish(e.changedTouches[0]?.clientY);
// Touch has its own path above: a touch pointer is cancelled the moment the
// browser starts scrolling, so only mouse and pen come through here.
const onPointerDown = (e: PointerEvent) => {
if (e.pointerType === 'touch' || e.button !== 0) return;
if (!begin(e.clientX, e.clientY, e.timeStamp, e.target)) return;
window.addEventListener('pointermove', onPointerMove);
window.addEventListener('pointerup', onPointerUp);
window.addEventListener('pointercancel', onPointerUp);
};
const onPointerMove = (e: PointerEvent) => {
if (!advance(e.clientX, e.clientY, e.timeStamp)) {
stopPointer();
return;
}
if (e.cancelable) e.preventDefault();
sample(d, t.clientY, e.timeStamp);
follow(t.clientY - d.y0);
// No text selection while the sheet is being pulled.
if (drag.current?.claimed) e.preventDefault();
};
const onEnd = (e: TouchEvent) => {
const d = drag.current;
drag.current = null;
if (!d?.claimed) return;
const last = d.samples[d.samples.length - 1];
release(d, e.changedTouches[0]?.clientY ?? last.y);
const onPointerUp = (e: PointerEvent) => {
stopPointer();
finish(e.type === 'pointercancel' ? undefined : e.clientY);
};
const stopPointer = () => {
window.removeEventListener('pointermove', onPointerMove);
window.removeEventListener('pointerup', onPointerUp);
window.removeEventListener('pointercancel', onPointerUp);
};
const onClickCapture = (e: MouseEvent) => {
if (!swallowClick) return;
swallowClick = false;
e.preventDefault();
e.stopPropagation();
};
panel.addEventListener('touchstart', onStart, { passive: true });
panel.addEventListener('touchmove', onMove, { passive: false });
panel.addEventListener('touchend', onEnd, { passive: true });
panel.addEventListener('touchcancel', onEnd, { passive: true });
panel.addEventListener('touchstart', onTouchStart, { passive: true });
panel.addEventListener('touchmove', onTouchMove, { passive: false });
panel.addEventListener('touchend', onTouchEnd, { passive: true });
panel.addEventListener('touchcancel', onTouchEnd, { passive: true });
panel.addEventListener('pointerdown', onPointerDown);
panel.addEventListener('click', onClickCapture, true);
return () => {
cancelAnimationFrame(frame.current);
panel.removeEventListener('touchstart', onStart);
panel.removeEventListener('touchmove', onMove);
panel.removeEventListener('touchend', onEnd);
panel.removeEventListener('touchcancel', onEnd);
stopPointer();
panel.removeEventListener('touchstart', onTouchStart);
panel.removeEventListener('touchmove', onTouchMove);
panel.removeEventListener('touchend', onTouchEnd);
panel.removeEventListener('touchcancel', onTouchEnd);
panel.removeEventListener('pointerdown', onPointerDown);
panel.removeEventListener('click', onClickCapture, true);
};
}, [enabled, panelRef, follow, release]);
+13 -3
View File
@@ -8,6 +8,7 @@ import { useTripPlanner } from '../../../pages/tripPlanner/useTripPlanner'
import { pickDockTabs } from './dockTabs'
import MBadge from '../../components/MBadge'
import MIconBtn from '../../components/MIconBtn'
import { useActivePill } from '../../components/useActivePill'
import MPlanTimeline from './plan/MPlanTimeline'
import MMapArea from './map/MMapArea'
import MPlacesBrowser from './places/MPlacesBrowser'
@@ -289,6 +290,8 @@ export default function MTripShell({
block: 'nearest',
})
}, [planner.selectedDayId, days, dayRail])
// The active day's pill glides to the next chip rather than jumping there.
const dayPill = useActivePill(dayRail, '[aria-current="true"]', [planner.selectedDayId, days, dayRail, language])
const trTab = planner.activeTab
@@ -498,7 +501,14 @@ export default function MTripShell({
{/* ── Day chips (z-25 — covered by non-plan tab overlays, stays mounted) ── */}
{days.length > 0 && (
<div className="absolute inset-x-4 z-[25] flex gap-[6px] top-[calc(var(--m-safe-top,12px)+50px)]">
<div ref={setDayRail} className="flex flex-1 items-center gap-[2px] overflow-x-auto rounded-full border border-[color:var(--m-gbr)] bg-[color:var(--m-glass)] p-[3px] backdrop-blur-[24px] backdrop-saturate-[1.7]">
<div ref={setDayRail} className="relative flex flex-1 items-center gap-[2px] overflow-x-auto rounded-full border border-[color:var(--m-gbr)] bg-[color:var(--m-glass)] p-[3px] backdrop-blur-[24px] backdrop-saturate-[1.7]">
{dayPill && (
<span
aria-hidden="true"
className="pointer-events-none absolute bottom-[3px] left-0 top-[3px] rounded-full bg-m-act shadow-[0_6px_16px_-6px_rgba(0,0,0,.4)]"
style={dayPill}
/>
)}
{days.map((day, idx) => {
const active = day.id === planner.selectedDayId
const isToday = day.id === todayDayId
@@ -516,8 +526,8 @@ export default function MTripShell({
// the active chip's bg-m-act class.
style={active ? undefined : { background: dayTintBackground(tint, 'badge', '--day-tint-chip') }}
data-today={isToday || undefined}
className={`flex flex-1 items-center justify-center gap-[3px] whitespace-nowrap rounded-full px-3 py-[5px] text-center text-[0.75rem] font-semibold ${
active ? 'bg-m-act text-m-actfg shadow-[0_6px_16px_-6px_rgba(0,0,0,.4)]' : 'text-m-ink'
className={`relative flex flex-1 items-center justify-center gap-[3px] whitespace-nowrap rounded-full px-3 py-[5px] text-center text-[0.75rem] font-semibold transition-colors duration-200 ${
active ? `text-m-actfg ${dayPill ? '' : 'bg-m-act'}` : 'text-m-ink'
} ${isToday && !active ? 'ring-[1.5px] ring-inset ring-[color:var(--m-st-info)]' : ''}`}
>
{isToday && (
+28
View File
@@ -385,6 +385,34 @@ describe('MSheet', () => {
expect(dialog.style.transform).toBe('translateY(0px)');
});
it('FE-MOB-SHEET-027: a mouse pull that starts on a button carries the sheet and does not press it', () => {
const pressed = vi.fn();
const { dialog, onClose } = renderBottom(vi.fn(), <button type="button" onClick={pressed}>Go</button>);
const button = screen.getByRole('button', { name: 'Go' });
fireEvent.pointerDown(button, { pointerType: 'mouse', button: 0, clientX: 100, clientY: 100 });
fireEvent.pointerMove(window, { pointerType: 'mouse', clientX: 100, clientY: 140 });
fireEvent.pointerMove(window, { pointerType: 'mouse', clientX: 100, clientY: 300 });
expect(dialog.style.transform).toBe('translateY(160px)');
fireEvent.pointerUp(window, { pointerType: 'mouse', clientX: 100, clientY: 300 });
fireEvent.click(button);
expect(onClose).toHaveBeenCalledTimes(1);
expect(pressed).not.toHaveBeenCalled();
});
it('FE-MOB-SHEET-028: a plain click on a button in the sheet still presses it', () => {
const pressed = vi.fn();
renderBottom(vi.fn(), <button type="button" onClick={pressed}>Go</button>);
const button = screen.getByRole('button', { name: 'Go' });
fireEvent.pointerDown(button, { pointerType: 'mouse', button: 0, clientX: 100, clientY: 100 });
fireEvent.pointerUp(window, { pointerType: 'mouse', clientX: 100, clientY: 101 });
fireEvent.click(button);
expect(pressed).toHaveBeenCalledTimes(1);
});
it('FE-MOB-SHEET-026: the docked sheet rises from below, the card pops in place', () => {
const { dialog } = renderBottom();
expect(dialog.className).toContain('m-sheet-up');