302 lines
6.9 KiB
Svelte
302 lines
6.9 KiB
Svelte
<script lang="ts">
|
|
import { onDestroy, onMount } from 'svelte';
|
|
|
|
const UNSAFE_EDGE = 16;
|
|
const SWIPE_SLOP = 8;
|
|
const AXIS_RATIO = 1.2;
|
|
const FLICK_VELOCITY = 0.55;
|
|
const SETTLE_MS = 210;
|
|
|
|
export let open = false;
|
|
export let enabled = false;
|
|
export let width = 245;
|
|
export let onOpenChange: (open: boolean) => void = () => {};
|
|
|
|
let mounted = false;
|
|
let active = false;
|
|
let locked = false;
|
|
let direction: 'open' | 'close' | null = null;
|
|
let startX = 0;
|
|
let startY = 0;
|
|
let lastX = 0;
|
|
let lastTime = 0;
|
|
let velocity = 0;
|
|
let startProgress = 0;
|
|
let swipeProgress = 0;
|
|
let settling = false;
|
|
let settleTimer: ReturnType<typeof setTimeout> | null = null;
|
|
let settleFrame: ReturnType<typeof requestAnimationFrame> | null = null;
|
|
let scrollLocked = false;
|
|
let lastOpen: boolean | null = null;
|
|
let lastEnabled: boolean | null = null;
|
|
|
|
$: panelWidth = width || 245;
|
|
$: progress = enabled ? Math.max(0, Math.min(1, swipeProgress)) : 1;
|
|
$: visible = open || (enabled && mounted);
|
|
$: panelStyle = enabled
|
|
? `transform: translateX(${(progress - 1) * 100}%); transition: ${
|
|
settling ? `transform ${SETTLE_MS}ms cubic-bezier(0.22, 1, 0.36, 1)` : 'none'
|
|
};`
|
|
: '';
|
|
$: backdropStyle = enabled
|
|
? `opacity: ${progress * 0.6}; transition: ${
|
|
settling ? `opacity ${SETTLE_MS}ms cubic-bezier(0.22, 1, 0.36, 1)` : 'none'
|
|
};`
|
|
: '';
|
|
|
|
$: if (open !== lastOpen || enabled !== lastEnabled) {
|
|
lastOpen = open;
|
|
lastEnabled = enabled;
|
|
|
|
if (enabled) {
|
|
animate(open);
|
|
} else {
|
|
mounted = false;
|
|
swipeProgress = open ? 1 : 0;
|
|
settling = false;
|
|
setScrollLock(false);
|
|
}
|
|
}
|
|
|
|
const shouldSkipSwipe = (target: EventTarget | null) => {
|
|
return (
|
|
target instanceof Element &&
|
|
!!target.closest(
|
|
'input, textarea, select, [role="menu"], [contenteditable="true"], [data-sidebar-no-gesture]'
|
|
)
|
|
);
|
|
};
|
|
|
|
const canScrollHorizontally = (target: EventTarget | null, dx: number) => {
|
|
if (!(target instanceof Element) || dx === 0 || typeof window === 'undefined') {
|
|
return false;
|
|
}
|
|
|
|
let element: Element | null = target;
|
|
while (element && element !== document.documentElement && element !== document.body) {
|
|
const node = element as HTMLElement;
|
|
const overflowX = window.getComputedStyle(node).overflowX;
|
|
const isScrollable =
|
|
/(auto|scroll|overlay)/.test(overflowX) && node.scrollWidth > node.clientWidth + 1;
|
|
|
|
if (isScrollable) {
|
|
const maxScrollLeft = node.scrollWidth - node.clientWidth;
|
|
return dx > 0 ? node.scrollLeft > 0 : node.scrollLeft < maxScrollLeft - 1;
|
|
}
|
|
|
|
element = element.parentElement;
|
|
}
|
|
|
|
return false;
|
|
};
|
|
|
|
const setScrollLock = (locked: boolean) => {
|
|
if (scrollLocked === locked || typeof document === 'undefined') {
|
|
return;
|
|
}
|
|
|
|
scrollLocked = locked;
|
|
document.documentElement.classList.toggle('mobile-swipe-panel-active', locked);
|
|
document.body.classList.toggle('mobile-swipe-panel-active', locked);
|
|
};
|
|
|
|
const clearTimer = () => {
|
|
if (settleTimer) {
|
|
clearTimeout(settleTimer);
|
|
settleTimer = null;
|
|
}
|
|
};
|
|
|
|
const clearFrame = () => {
|
|
if (settleFrame) {
|
|
cancelAnimationFrame(settleFrame);
|
|
settleFrame = null;
|
|
}
|
|
};
|
|
|
|
const prefersReducedMotion = () =>
|
|
typeof window !== 'undefined' &&
|
|
window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
|
|
|
|
const animate = (nextOpen: boolean) => {
|
|
clearTimer();
|
|
clearFrame();
|
|
mounted = nextOpen || mounted;
|
|
|
|
const finish = () => {
|
|
settling = false;
|
|
settleTimer = null;
|
|
};
|
|
|
|
const settle = () => {
|
|
settling = !prefersReducedMotion();
|
|
swipeProgress = nextOpen ? 1 : 0;
|
|
settleTimer = setTimeout(finish, settling ? SETTLE_MS : 0);
|
|
};
|
|
|
|
if (nextOpen && swipeProgress <= 0.01) {
|
|
swipeProgress = 0;
|
|
settling = false;
|
|
settleFrame = requestAnimationFrame(() => {
|
|
settleFrame = null;
|
|
settle();
|
|
});
|
|
return;
|
|
}
|
|
|
|
settle();
|
|
};
|
|
|
|
const resetSwipe = () => {
|
|
active = false;
|
|
locked = false;
|
|
direction = null;
|
|
velocity = 0;
|
|
setScrollLock(false);
|
|
};
|
|
|
|
const cancelSwipe = () => {
|
|
resetSwipe();
|
|
animate(open);
|
|
};
|
|
|
|
const onTouchStart = (e: TouchEvent) => {
|
|
if (!enabled || e.touches.length !== 1 || shouldSkipSwipe(e.target)) {
|
|
return;
|
|
}
|
|
|
|
const touch = e.touches[0];
|
|
const x = touch.clientX;
|
|
|
|
if (!open) {
|
|
if (x < UNSAFE_EDGE) {
|
|
return;
|
|
}
|
|
|
|
direction = 'open';
|
|
startProgress = 0;
|
|
mounted = true;
|
|
swipeProgress = 0;
|
|
} else {
|
|
direction = 'close';
|
|
startProgress = swipeProgress || 1;
|
|
}
|
|
|
|
clearTimer();
|
|
settling = false;
|
|
active = true;
|
|
locked = false;
|
|
startX = x;
|
|
startY = touch.clientY;
|
|
lastX = x;
|
|
lastTime = e.timeStamp;
|
|
velocity = 0;
|
|
};
|
|
|
|
const onTouchMove = (e: TouchEvent) => {
|
|
if (!active || !direction || e.touches.length !== 1) {
|
|
return;
|
|
}
|
|
|
|
const touch = e.touches[0];
|
|
const dx = touch.clientX - startX;
|
|
const dy = touch.clientY - startY;
|
|
const absX = Math.abs(dx);
|
|
const absY = Math.abs(dy);
|
|
|
|
if (!locked) {
|
|
if ((direction === 'open' && dx > 0) || (direction === 'close' && dx < 0)) {
|
|
swipeProgress = Math.max(0, Math.min(1, startProgress + dx / panelWidth));
|
|
}
|
|
|
|
if (absX < SWIPE_SLOP && absY < SWIPE_SLOP) {
|
|
return;
|
|
}
|
|
|
|
if (absX > absY && canScrollHorizontally(e.target, dx)) {
|
|
cancelSwipe();
|
|
return;
|
|
}
|
|
|
|
if (absY > absX || absX < absY * AXIS_RATIO) {
|
|
cancelSwipe();
|
|
return;
|
|
}
|
|
|
|
locked = true;
|
|
setScrollLock(true);
|
|
}
|
|
|
|
e.preventDefault();
|
|
|
|
const now = e.timeStamp;
|
|
const elapsed = Math.max(1, now - lastTime);
|
|
velocity = (touch.clientX - lastX) / elapsed;
|
|
lastX = touch.clientX;
|
|
lastTime = now;
|
|
|
|
swipeProgress = Math.max(0, Math.min(1, startProgress + dx / panelWidth));
|
|
};
|
|
|
|
const onTouchEnd = () => {
|
|
if (!active) {
|
|
return;
|
|
}
|
|
|
|
if (!locked) {
|
|
cancelSwipe();
|
|
return;
|
|
}
|
|
|
|
let shouldOpen = direction === 'open' ? swipeProgress > 0.35 : swipeProgress > 0.65;
|
|
|
|
if (velocity > FLICK_VELOCITY) {
|
|
shouldOpen = true;
|
|
} else if (velocity < -FLICK_VELOCITY) {
|
|
shouldOpen = false;
|
|
}
|
|
|
|
resetSwipe();
|
|
animate(shouldOpen);
|
|
onOpenChange(shouldOpen);
|
|
};
|
|
|
|
const onTouchCancel = () => {
|
|
if (active) {
|
|
cancelSwipe();
|
|
}
|
|
};
|
|
|
|
onMount(() => {
|
|
window.addEventListener('touchstart', onTouchStart, { passive: true });
|
|
window.addEventListener('touchmove', onTouchMove, { passive: false });
|
|
window.addEventListener('touchend', onTouchEnd);
|
|
window.addEventListener('touchcancel', onTouchCancel);
|
|
});
|
|
|
|
onDestroy(() => {
|
|
window.removeEventListener('touchstart', onTouchStart);
|
|
window.removeEventListener('touchmove', onTouchMove);
|
|
window.removeEventListener('touchend', onTouchEnd);
|
|
window.removeEventListener('touchcancel', onTouchCancel);
|
|
clearTimer();
|
|
clearFrame();
|
|
setScrollLock(false);
|
|
});
|
|
</script>
|
|
|
|
<slot {visible} {progress} {panelStyle} {backdropStyle} />
|
|
|
|
<style>
|
|
:global(html),
|
|
:global(body) {
|
|
overflow-x: hidden;
|
|
overscroll-behavior-x: contain;
|
|
}
|
|
|
|
:global(html.mobile-swipe-panel-active),
|
|
:global(body.mobile-swipe-panel-active) {
|
|
touch-action: pan-y;
|
|
}
|
|
</style>
|