swipeable interface
This commit is contained in:
@@ -0,0 +1,271 @@
|
||||
<script lang="ts">
|
||||
import WeatherCard from "./WeatherCard.svelte";
|
||||
import type { WeatherPeriod } from "$lib/types/weather.js";
|
||||
import { onMount } from "svelte";
|
||||
|
||||
interface Props {
|
||||
periods: WeatherPeriod[];
|
||||
}
|
||||
|
||||
let { periods }: Props = $props();
|
||||
|
||||
let cardStack = $state([...periods]);
|
||||
let currentCardIndex = $state(0);
|
||||
let isDragging = false;
|
||||
let startX = 0;
|
||||
let startY = 0;
|
||||
let currentX = 0;
|
||||
let currentY = 0;
|
||||
let topCardRef: HTMLDivElement | undefined = $state();
|
||||
|
||||
const SWIPE_THRESHOLD = 100;
|
||||
const ROTATION_FACTOR = 0.1;
|
||||
const MAX_ROTATION = 15;
|
||||
|
||||
function handleTouchStart(e: TouchEvent) {
|
||||
if (!topCardRef) return;
|
||||
|
||||
startX = e.touches[0].clientX;
|
||||
startY = e.touches[0].clientY;
|
||||
isDragging = true;
|
||||
|
||||
// Enable hardware acceleration and disable transitions smoothly
|
||||
topCardRef.style.willChange = "transform, opacity";
|
||||
topCardRef.style.transition = "none";
|
||||
}
|
||||
|
||||
function handleTouchMove(e: TouchEvent) {
|
||||
if (!isDragging || !topCardRef) return;
|
||||
|
||||
currentX = e.touches[0].clientX;
|
||||
currentY = e.touches[0].clientY;
|
||||
|
||||
const deltaX = currentX - startX;
|
||||
const deltaY = currentY - startY;
|
||||
const rotation = Math.min(
|
||||
Math.max(deltaX * ROTATION_FACTOR, -MAX_ROTATION),
|
||||
MAX_ROTATION,
|
||||
);
|
||||
|
||||
// Use translate3d for hardware acceleration
|
||||
topCardRef.style.transform = `translate3d(${deltaX}px, ${deltaY}px, 0) rotate(${rotation}deg)`;
|
||||
topCardRef.style.opacity = `${1 - Math.abs(deltaX) / 300}`;
|
||||
}
|
||||
|
||||
function handleTouchEnd() {
|
||||
if (!isDragging || !topCardRef) return;
|
||||
|
||||
isDragging = false;
|
||||
const deltaX = currentX - startX;
|
||||
|
||||
if (Math.abs(deltaX) > SWIPE_THRESHOLD) {
|
||||
// Card swiped away - animate it off screen
|
||||
const direction = deltaX > 0 ? 1 : -1;
|
||||
topCardRef.style.transition =
|
||||
"transform 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94), opacity 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94)";
|
||||
topCardRef.style.transform = `translate3d(${direction * window.innerWidth}px, ${currentY - startY}px, 0) rotate(${direction * MAX_ROTATION * 2}deg)`;
|
||||
topCardRef.style.opacity = "0";
|
||||
|
||||
// Remove the card after animation
|
||||
setTimeout(() => {
|
||||
removeTopCard();
|
||||
}, 400);
|
||||
} else {
|
||||
// Snap back to center with smooth spring-like animation
|
||||
topCardRef.style.transition =
|
||||
"transform 0.5s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.3s ease-out";
|
||||
topCardRef.style.transform = "translate3d(0px, 0px, 0) rotate(0deg)";
|
||||
topCardRef.style.opacity = "1";
|
||||
|
||||
// Clean up will-change after animation
|
||||
setTimeout(() => {
|
||||
if (topCardRef) {
|
||||
topCardRef.style.willChange = "auto";
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
||||
function removeTopCard() {
|
||||
if (cardStack.length === 0) return;
|
||||
|
||||
// Remove the top card and add it to the bottom of the stack
|
||||
const removedCard = cardStack[currentCardIndex];
|
||||
cardStack = [
|
||||
...cardStack.slice(0, currentCardIndex),
|
||||
...cardStack.slice(currentCardIndex + 1),
|
||||
removedCard,
|
||||
];
|
||||
|
||||
// Reset the top card reference
|
||||
if (topCardRef) {
|
||||
topCardRef.style.willChange = "auto";
|
||||
topCardRef.style.transition = "none";
|
||||
topCardRef.style.transform = "translate3d(0px, 0px, 0) rotate(0deg)";
|
||||
topCardRef.style.opacity = "1";
|
||||
}
|
||||
}
|
||||
|
||||
function jumpToNow() {
|
||||
if (isDragging) return; // Don't jump while dragging
|
||||
|
||||
const now = new Date();
|
||||
const currentHour = now.getHours();
|
||||
|
||||
// Find the period that represents the current time or closest future time
|
||||
const nowIndex = periods.findIndex(period => {
|
||||
const periodStart = new Date(period.startTime);
|
||||
return periodStart.getHours() >= currentHour;
|
||||
});
|
||||
|
||||
if (nowIndex !== -1) {
|
||||
// Reorder the card stack to put the "now" period at the front
|
||||
const beforeNow = periods.slice(0, nowIndex);
|
||||
const fromNow = periods.slice(nowIndex);
|
||||
cardStack = [...fromNow, ...beforeNow];
|
||||
currentCardIndex = 0;
|
||||
|
||||
// Wait a frame to ensure DOM updates, then reset the top card
|
||||
setTimeout(() => {
|
||||
if (topCardRef) {
|
||||
topCardRef.style.willChange = "auto";
|
||||
topCardRef.style.transition = "none";
|
||||
topCardRef.style.transform = "translate3d(0px, 0px, 0) rotate(0deg)";
|
||||
topCardRef.style.opacity = "1";
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle mouse events for desktop testing
|
||||
function handleMouseDown(e: MouseEvent) {
|
||||
if (!topCardRef) return;
|
||||
|
||||
startX = e.clientX;
|
||||
startY = e.clientY;
|
||||
isDragging = true;
|
||||
|
||||
// Enable hardware acceleration and disable transitions smoothly
|
||||
topCardRef.style.willChange = "transform, opacity";
|
||||
topCardRef.style.transition = "none";
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
function handleMouseMove(e: MouseEvent) {
|
||||
if (!isDragging || !topCardRef) return;
|
||||
|
||||
currentX = e.clientX;
|
||||
currentY = e.clientY;
|
||||
|
||||
const deltaX = currentX - startX;
|
||||
const deltaY = currentY - startY;
|
||||
const rotation = Math.min(
|
||||
Math.max(deltaX * ROTATION_FACTOR, -MAX_ROTATION),
|
||||
MAX_ROTATION,
|
||||
);
|
||||
|
||||
// Use translate3d for hardware acceleration
|
||||
topCardRef.style.transform = `translate3d(${deltaX}px, ${deltaY}px, 0) rotate(${rotation}deg)`;
|
||||
topCardRef.style.opacity = `${1 - Math.abs(deltaX) / 300}`;
|
||||
}
|
||||
|
||||
function handleMouseUp() {
|
||||
if (!isDragging || !topCardRef) return;
|
||||
|
||||
isDragging = false;
|
||||
const deltaX = currentX - startX;
|
||||
|
||||
if (Math.abs(deltaX) > SWIPE_THRESHOLD) {
|
||||
// Card swiped away
|
||||
const direction = deltaX > 0 ? 1 : -1;
|
||||
topCardRef.style.transition =
|
||||
"transform 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94), opacity 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94)";
|
||||
topCardRef.style.transform = `translate3d(${direction * window.innerWidth}px, ${currentY - startY}px, 0) rotate(${direction * MAX_ROTATION * 2}deg)`;
|
||||
topCardRef.style.opacity = "0";
|
||||
|
||||
setTimeout(() => {
|
||||
removeTopCard();
|
||||
}, 400);
|
||||
} else {
|
||||
// Snap back with smooth spring-like animation
|
||||
topCardRef.style.transition =
|
||||
"transform 0.5s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.3s ease-out";
|
||||
topCardRef.style.transform = "translate3d(0px, 0px, 0) rotate(0deg)";
|
||||
topCardRef.style.opacity = "1";
|
||||
|
||||
// Clean up will-change after animation
|
||||
setTimeout(() => {
|
||||
if (topCardRef) {
|
||||
topCardRef.style.willChange = "auto";
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Desktop view - keep original grid layout -->
|
||||
<div class="hidden md:grid md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{#each periods as period (period.number)}
|
||||
<WeatherCard {period} />
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Mobile view - card stack -->
|
||||
<div class="md:hidden">
|
||||
<div class="relative h-[360px] flex items-center justify-center">
|
||||
{#each cardStack.slice(0, 3) as period, index (period.number)}
|
||||
<div
|
||||
class="absolute w-full max-w-sm"
|
||||
style="
|
||||
z-index: {3 - index};
|
||||
transform: translate3d({index * (index % 2 === 0 ? 6 : -6)}px, {index * 8}px, 0) rotate({index * (index % 2 === 0 ? 2 : -2)}deg) scale({1 - index * 0.03});
|
||||
will-change: {index === 0 ? 'transform, opacity' : 'auto'};
|
||||
opacity: {1 - index * 0.15};
|
||||
"
|
||||
>
|
||||
{#if index === 0}
|
||||
<div
|
||||
bind:this={topCardRef}
|
||||
class="cursor-grab active:cursor-grabbing"
|
||||
ontouchstart={handleTouchStart}
|
||||
ontouchmove={handleTouchMove}
|
||||
ontouchend={handleTouchEnd}
|
||||
onmousedown={handleMouseDown}
|
||||
role="button"
|
||||
aria-label="Swipe to dismiss card"
|
||||
tabindex="0"
|
||||
>
|
||||
<WeatherCard {period} />
|
||||
</div>
|
||||
{:else}
|
||||
<WeatherCard {period} />
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Jump to now button -->
|
||||
<div class="text-center mt-4">
|
||||
<button
|
||||
class="text-sm text-blue-500 hover:text-blue-700 underline cursor-pointer"
|
||||
onclick={jumpToNow}
|
||||
>
|
||||
jump to now
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Instructions -->
|
||||
<div class="text-center mt-2 text-xs text-muted-foreground">
|
||||
Swipe left or right to dismiss
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user