mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 07:40:59 +00:00
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:
@@ -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',
|
||||
};
|
||||
}
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user