Files
boocode/apps/web/src/components/SwipeablePaneTab.tsx
indifferentketchup cd897d6893 feat(mobile): single-pane stack + long-press tab menu + swipe-to-close
- Workspace: on mobile renders only panes[activePaneIdx] (rest skipped).
  When panes.length > 1, adds a horizontal pane-navigator strip built
  from SwipeablePaneTab above the active pane. URL state ?pane=<paneId>
  written by switchActivePane (user-initiated only) and read on URL
  change (back-button + deep-link). Bare URL resets activePaneIdx to 0.
- useLongPress: 500ms touchstart timer; on fire, dispatches a synthetic
  contextmenu event on target.closest('[data-tab-id]') so the existing
  Radix ContextMenuTrigger opens at the touch coordinates. Suppresses
  the synthetic click that follows touchend. Cancels on touchmove /
  touchend / touchcancel.
- ChatTabBar: each tab gets data-tab-id, touch handlers wired to
  useLongPress, and WebkitTouchCallout: 'none' to disable iOS Safari's
  text-selection callout.
- SwipeablePaneTab: tracks horizontal drag; bails if vertical delta
  exceeds 30px (so vertical scroll still works); past 60px on release
  fires onClose (removePane), else snaps back. Opacity fades 1->0.4
  approaching the threshold. Hand-rolled per spec.
- Pane drag-and-drop disabled on mobile (HTML5 drag is broken on touch
  anyway; mobile uses the navigator + swipe-to-close instead).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 05:55:05 +00:00

104 lines
3.0 KiB
TypeScript

import { useRef, useState } from 'react';
import type { TouchEvent } from 'react';
import { cn } from '@/lib/utils';
interface Props {
label: string;
isActive: boolean;
onTap: () => void;
onClose: () => void;
canClose: boolean;
}
const CLOSE_THRESHOLD = 60;
const MAX_TRAVEL = 120;
const VERTICAL_BAIL = 30;
// Pane tab with horizontal swipe-to-close (mobile only). Tracks horizontal
// finger movement; if vertical exceeds VERTICAL_BAIL the gesture is cancelled
// (so vertical scroll still works). On release past CLOSE_THRESHOLD, the
// onClose callback fires. Otherwise the tab snaps back. Hand-rolled per spec.
export function SwipeablePaneTab({ label, isActive, onTap, onClose, canClose }: Props) {
const [translateX, setTranslateX] = useState(0);
const [dragging, setDragging] = useState(false);
const startRef = useRef<{ x: number; y: number; bailed: boolean } | null>(null);
const onTouchStart = (e: TouchEvent) => {
if (!canClose) return;
const t = e.touches[0];
if (!t) return;
startRef.current = { x: t.clientX, y: t.clientY, bailed: false };
setDragging(true);
};
const onTouchMove = (e: TouchEvent) => {
const start = startRef.current;
if (!start || start.bailed) return;
const t = e.touches[0];
if (!t) return;
const dx = t.clientX - start.x;
const dy = t.clientY - start.y;
if (Math.abs(dy) > VERTICAL_BAIL) {
start.bailed = true;
setTranslateX(0);
setDragging(false);
return;
}
if (dx < 0) {
setTranslateX(Math.max(dx, -MAX_TRAVEL));
} else {
setTranslateX(0);
}
};
const onTouchEnd = () => {
const start = startRef.current;
startRef.current = null;
setDragging(false);
if (!start || start.bailed) {
setTranslateX(0);
return;
}
const tx = translateX;
if (tx <= -CLOSE_THRESHOLD) {
onClose();
// Don't reset translateX; the parent will unmount this tab.
} else {
setTranslateX(0);
}
};
// Opacity fades from 1 -> 0.4 as the tab approaches the close threshold.
const opacity =
translateX < 0
? Math.max(0.4, 1 - (Math.abs(translateX) / CLOSE_THRESHOLD) * 0.6)
: 1;
return (
<button
type="button"
onClick={onTap}
onTouchStart={onTouchStart}
onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd}
onTouchCancel={onTouchEnd}
style={{
transform: `translateX(${translateX}px)`,
opacity,
// Only animate when releasing (snap-back); during drag the transform
// tracks the finger 1:1 for a tight feel.
transition: dragging ? undefined : 'transform 0.15s ease, opacity 0.15s ease',
}}
className={cn(
'shrink-0 px-3 py-2 text-xs rounded min-h-[44px] min-w-[44px]',
isActive
? 'bg-background text-foreground border'
: 'text-muted-foreground hover:bg-muted/40',
)}
aria-current={isActive ? 'true' : undefined}
>
<span className="truncate max-w-[140px] inline-block">{label}</span>
</button>
);
}