Add swipe-to-open sidebar on all pages + fix bottom bar styling

- Create SwipeableContent component for sidebar swipe on non-tab pages
- Add swipe-to-close sidebar from overlay
- Make swipe work from entire page (ignoring interactive elements)
- Show title and divider on desktop when tab bar is at bottom
- Hide title/divider only on mobile for bottom position

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-23 21:48:19 +01:00
parent 9e3556322f
commit 500d038ed0
22 changed files with 493 additions and 63 deletions

View File

@@ -23,6 +23,11 @@ type SwipeTabsProps<T extends string | number> = {
renderWindow?: number;
scrollToTopOnChange?: boolean;
onSwipePastStart?: () => void;
// For 1:1 sidebar gesture tracking
onSwipePastStartProgress?: (progress: number) => void;
onSwipePastStartDragStart?: () => void;
onSwipePastStartDragEnd?: (shouldOpen: boolean) => void;
sidebarWidth?: number; // Width of sidebar for progress calculation
};
const DRAG_START_DISTANCE = 3;
@@ -59,7 +64,11 @@ export function SwipeTabs<T extends string | number>({
threshold = DEFAULT_THRESHOLD,
renderWindow = DEFAULT_RENDER_WINDOW,
scrollToTopOnChange = true,
onSwipePastStart
onSwipePastStart,
onSwipePastStartProgress,
onSwipePastStartDragStart,
onSwipePastStartDragEnd,
sidebarWidth = 280
}: SwipeTabsProps<T>) {
const containerRef = useRef<HTMLDivElement>(null);
const trackRef = useRef<HTMLDivElement>(null);
@@ -271,6 +280,9 @@ export function SwipeTabs<T extends string | number>({
[cancelAnimationForDrag, measureWidth, swipeDisabled, tabs.length]
);
const sidebarDragStartedRef = useRef(false);
const sidebarDragProgressRef = useRef(0);
const updateDrag = useCallback(
(x: number, y: number, preventDefault: () => void, pointerType?: string) => {
const state = dragRef.current;
@@ -305,10 +317,27 @@ export function SwipeTabs<T extends string | number>({
const atStart = state.startIndex <= 0;
const atEnd = state.startIndex >= tabs.length - 1;
// Don't apply resistance at start if onSwipePastStart is defined (sidebar can open)
const hasVirtualTabAtStart = !!onSwipePastStart;
if ((atStart && dx > 0 && !hasVirtualTabAtStart) || (atEnd && dx < 0)) {
const hasVirtualTabAtStart = !!onSwipePastStart || !!onSwipePastStartProgress;
// Calculate sidebar drag progress when swiping right on first tab
const isSidebarSwipe = atStart && dx > 0 && hasVirtualTabAtStart;
if (isSidebarSwipe) {
// Notify drag start
if (!sidebarDragStartedRef.current) {
sidebarDragStartedRef.current = true;
onSwipePastStartDragStart?.();
}
// Calculate progress (0 to 1) based on sidebar width
const progress = Math.min(1, dx / sidebarWidth);
sidebarDragProgressRef.current = progress;
onSwipePastStartProgress?.(progress);
// Don't move the page - only the sidebar moves
offset = 0;
} else if ((atStart && dx > 0) || (atEnd && dx < 0)) {
// Apply resistance at edges when not doing sidebar swipe
offset = dx * EDGE_RESISTANCE;
}
offset = Math.max(Math.min(offset, width), -width);
dragOffsetRef.current = offset;
scheduleDragUpdate();
@@ -317,7 +346,7 @@ export function SwipeTabs<T extends string | number>({
preventDefault();
}
},
[onSwipePastStart, scheduleDragUpdate, tabs.length]
[onSwipePastStart, onSwipePastStartProgress, onSwipePastStartDragStart, scheduleDragUpdate, sidebarWidth, tabs.length]
);
const finishDrag = useCallback(
@@ -334,6 +363,12 @@ export function SwipeTabs<T extends string | number>({
isDraggingRef.current = false;
setDraggingClass(false);
// Handle sidebar drag end
const wasSidebarDragging = sidebarDragStartedRef.current;
const sidebarProgress = sidebarDragProgressRef.current;
sidebarDragStartedRef.current = false;
sidebarDragProgressRef.current = 0;
if (!wasDragging) return;
if (rafRef.current) {
@@ -346,12 +381,23 @@ export function SwipeTabs<T extends string | number>({
const thresholdPx = width * threshold;
let targetIndex = state.startIndex;
if (!cancelled && Math.abs(dx) > thresholdPx) {
// Check if this was a sidebar gesture (using the flag, not dx which is 0 for sidebar swipes)
const shouldOpenSidebar = !cancelled && sidebarProgress > 0.3; // 30% threshold
if (wasSidebarDragging) {
// Call the new drag end callback with whether to open
onSwipePastStartDragEnd?.(shouldOpenSidebar);
// Also call legacy callback if sidebar should open
if (shouldOpenSidebar && onSwipePastStart) {
onSwipePastStart();
}
} else if (!cancelled && Math.abs(dx) > thresholdPx) {
const direction = dx < 0 ? 1 : -1;
targetIndex = Math.min(Math.max(state.startIndex + direction, 0), tabs.length - 1);
// If swiping right on first tab, call onSwipePastStart
if (state.startIndex === 0 && dx > 0 && onSwipePastStart) {
// If swiping right on first tab without new callbacks, call onSwipePastStart
if (state.startIndex === 0 && dx > 0 && onSwipePastStart && !onSwipePastStartDragEnd) {
onSwipePastStart();
}
}
@@ -372,7 +418,7 @@ export function SwipeTabs<T extends string | number>({
}
}
},
[activeTab, applyTransform, onTabChange, onSwipePastStart, setDraggingClass, tabs, threshold]
[activeTab, applyTransform, onTabChange, onSwipePastStart, onSwipePastStartDragEnd, setDraggingClass, sidebarWidth, tabs, threshold]
);
const handlePointerDown = (event: PointerEvent<HTMLDivElement>) => {