- 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>
104 lines
3.0 KiB
TypeScript
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>
|
|
);
|
|
}
|