Live swipe feedback: content tracks finger with destination peek
Replace the instant-jump swipe with a live drag on touch: the current tab content translates with the finger in real time (translateX driven by touchmove), a peek chip at the edge shows the destination tab, and on release the sheet animates to the next tab or snaps back. Horizontal vs vertical travel is decided after 12px so normal page scrolling is untouched; touch-action:pan-y keeps horizontal gestures JS-owned. Radar still pans its map instead of switching tabs.
This commit is contained in:
parent
09cea275b7
commit
f5488eaa98
251
dist/index.html
vendored
251
dist/index.html
vendored
@ -7188,7 +7188,7 @@ var root_4$3 = /* @__PURE__ */ from_html(`<span class="lightning-strike svelte-t
|
||||
var root_5$3 = /* @__PURE__ */ from_html(`<div class="lightning-layer svelte-tjvfz7" aria-label="Lightning strikes"></div>`);
|
||||
var root_6$2 = /* @__PURE__ */ from_html(`<div class="radar-layer svelte-tjvfz7"><!> <!></div> <div class="location-marker svelte-tjvfz7"><span class="marker-dot svelte-tjvfz7"></span></div>`, 1);
|
||||
var root_7$1 = /* @__PURE__ */ from_html(`<div class="radar-overlay svelte-tjvfz7"><span class="loading-spinner svelte-tjvfz7">⏳</span> <span class="text-xs text-muted svelte-tjvfz7">Loading radar...</span></div>`);
|
||||
var root_8 = /* @__PURE__ */ from_html(`<div class="radar-error-banner svelte-tjvfz7"><span class="text-xs svelte-tjvfz7"> </span> <button class="btn btn-ghost btn-sm svelte-tjvfz7">Retry</button></div>`);
|
||||
var root_8$1 = /* @__PURE__ */ from_html(`<div class="radar-error-banner svelte-tjvfz7"><span class="text-xs svelte-tjvfz7"> </span> <button class="btn btn-ghost btn-sm svelte-tjvfz7">Retry</button></div>`);
|
||||
var root_9 = /* @__PURE__ */ from_html(`<div class="lightning-status svelte-tjvfz7"><span class="text-xs svelte-tjvfz7">⏳ Loading lightning...</span></div>`);
|
||||
var root_10 = /* @__PURE__ */ from_html(`<div class="lightning-status lightning-status-err svelte-tjvfz7"><span class="text-xs svelte-tjvfz7"> </span></div>`);
|
||||
var root_11 = /* @__PURE__ */ from_html(`<div class="lightning-status svelte-tjvfz7"><span class="text-xs svelte-tjvfz7">⚡ No recent strikes nearby</span></div>`);
|
||||
@ -7536,7 +7536,7 @@ function PrecipitationRadar($$anchor, $$props) {
|
||||
});
|
||||
var node_7 = sibling(node_6, 2);
|
||||
var consequent_6 = ($$anchor) => {
|
||||
var div_8 = root_8();
|
||||
var div_8 = root_8$1();
|
||||
var span_2 = child(div_8);
|
||||
var text_1 = child(span_2);
|
||||
reset(span_2);
|
||||
@ -8250,9 +8250,10 @@ var root_1 = /* @__PURE__ */ from_html(`<span class="current-badge svelte-1n46o8
|
||||
var root_2 = /* @__PURE__ */ from_html(`<div class="error-banner animate-slide-down svelte-1n46o8q"><span> </span> <button class="btn-icon" aria-label="Dismiss">✕</button></div>`);
|
||||
var root_3 = /* @__PURE__ */ from_html(`<div class="empty-state svelte-1n46o8q"><div class="empty-icon svelte-1n46o8q">🌍</div> <h2 class="svelte-1n46o8q">Welcome to WeatherLens</h2> <p class="text-muted"> </p> <button class="btn btn-primary mt-3">+ Add a Location</button></div>`);
|
||||
var root_4 = /* @__PURE__ */ from_html(`<div class="loading-state svelte-1n46o8q"><div class="loading-spinner svelte-1n46o8q">⏳</div> <p class="text-muted mt-2">Loading weather data...</p></div>`);
|
||||
var root_5 = /* @__PURE__ */ from_html(`<div class="animate-fade-in"><!></div>`);
|
||||
var root_6 = /* @__PURE__ */ from_html(`<div class="mobile-nav svelte-1n46o8q"><button>Now</button> <button>Hourly</button> <button>Daily</button> <button>More</button> <button>Radar</button></div> <div class="desktop-tabs svelte-1n46o8q"><button>Current</button> <button>Hourly</button> <button>7-Day Forecast</button> <button>Details</button> <button>Radar</button></div> <div class="content-area svelte-1n46o8q"><!></div>`, 1);
|
||||
var root_7 = /* @__PURE__ */ from_html(`<div class="app-shell svelte-1n46o8q"><div class="mobile-header svelte-1n46o8q"><button class="btn-icon" aria-label="Toggle menu"> </button> <span class="mobile-title svelte-1n46o8q"> </span> <button class="btn-icon" aria-label="Settings" title="Settings">⚙️</button> <!></div> <!> <aside><!></aside> <main class="main-content svelte-1n46o8q"><div class="top-bar svelte-1n46o8q"><div class="top-bar-title svelte-1n46o8q"><h1 class="svelte-1n46o8q"> </h1> <!></div> <div class="top-bar-actions svelte-1n46o8q"><!></div></div> <!> <!> <!> <!></main> <!> <!></div>`);
|
||||
var root_5 = /* @__PURE__ */ from_html(`<span aria-hidden="true"> </span>`);
|
||||
var root_6 = /* @__PURE__ */ from_html(`<div class="animate-fade-in"><!></div>`);
|
||||
var root_7 = /* @__PURE__ */ from_html(`<div class="mobile-nav svelte-1n46o8q"><button>Now</button> <button>Hourly</button> <button>Daily</button> <button>More</button> <button>Radar</button></div> <div class="desktop-tabs svelte-1n46o8q"><button>Current</button> <button>Hourly</button> <button>7-Day Forecast</button> <button>Details</button> <button>Radar</button></div> <div><div><!> <!></div></div>`, 1);
|
||||
var root_8 = /* @__PURE__ */ from_html(`<div class="app-shell svelte-1n46o8q"><div class="mobile-header svelte-1n46o8q"><button class="btn-icon" aria-label="Toggle menu"> </button> <span class="mobile-title svelte-1n46o8q"> </span> <button class="btn-icon" aria-label="Settings" title="Settings">⚙️</button> <!></div> <!> <aside><!></aside> <main class="main-content svelte-1n46o8q"><div class="top-bar svelte-1n46o8q"><div class="top-bar-title svelte-1n46o8q"><h1 class="svelte-1n46o8q"> </h1> <!></div> <div class="top-bar-actions svelte-1n46o8q"><!></div></div> <!> <!> <!> <!></main> <!> <!></div>`);
|
||||
function App($$anchor, $$props) {
|
||||
push($$props, true);
|
||||
let viewMode = /* @__PURE__ */ state(proxy(parseHash(typeof window !== "undefined" ? window.location.hash : "")));
|
||||
@ -8270,26 +8271,94 @@ function App($$anchor, $$props) {
|
||||
window.addEventListener("hashchange", onHash);
|
||||
return () => window.removeEventListener("hashchange", onHash);
|
||||
});
|
||||
const SWIPE_THRESHOLD = 60;
|
||||
let touchAnchor = /* @__PURE__ */ state(null);
|
||||
const SWIPE_THRESHOLD = 70;
|
||||
const AXIS_THRESHOLD = 12;
|
||||
const SWIPE_TIMEOUT = 160;
|
||||
const TAB_LABELS = {
|
||||
current: "Now",
|
||||
hourly: "Hourly",
|
||||
daily: "Daily",
|
||||
detail: "More",
|
||||
radar: "Radar"
|
||||
};
|
||||
let contentEl = /* @__PURE__ */ state(null);
|
||||
let swiping = /* @__PURE__ */ state(false);
|
||||
let swipeAnchor = /* @__PURE__ */ state(null);
|
||||
let swipeDX = /* @__PURE__ */ state(0);
|
||||
let swipeLock = /* @__PURE__ */ state(null);
|
||||
let settling = /* @__PURE__ */ state(false);
|
||||
const clampIdx = (i) => Math.min(Math.max(i, 0), VALID_VIEWS.length - 1);
|
||||
const peekView = /* @__PURE__ */ user_derived(() => get(swiping) && get(swipeLock) === "x" && VALID_VIEWS.includes(get(viewMode)) ? VALID_VIEWS[clampIdx(VALID_VIEWS.indexOf(get(viewMode)) + (get(swipeDX) < 0 ? 1 : -1))] : null);
|
||||
function onSwipeStart(e) {
|
||||
if (get(viewMode) === "radar" || !e.touches || !e.touches[0]) return;
|
||||
set(touchAnchor, {
|
||||
x: e.touches[0].clientX,
|
||||
y: e.touches[0].clientY
|
||||
const t = e.touches[0];
|
||||
set(swipeAnchor, {
|
||||
x: t.clientX,
|
||||
y: t.clientY
|
||||
}, true);
|
||||
set(swipeDX, 0);
|
||||
set(swipeLock, null);
|
||||
set(settling, false);
|
||||
set(swiping, true);
|
||||
}
|
||||
function onSwipeEnd(e) {
|
||||
if (get(viewMode) === "radar" || !get(touchAnchor) || !e.changedTouches || !e.changedTouches[0]) return;
|
||||
const t = e.changedTouches[0];
|
||||
const dx = t.clientX - get(touchAnchor).x;
|
||||
const dy = t.clientY - get(touchAnchor).y;
|
||||
set(touchAnchor, null);
|
||||
if (Math.abs(dx) < SWIPE_THRESHOLD || Math.abs(dx) < Math.abs(dy)) return;
|
||||
const idx = VALID_VIEWS.indexOf(get(viewMode));
|
||||
const next = VALID_VIEWS[Math.min(Math.max(idx + (dx < 0 ? 1 : -1), 0), VALID_VIEWS.length - 1)];
|
||||
if (next !== get(viewMode)) setView(next);
|
||||
function onSwipeMove(e) {
|
||||
if (!get(swiping) || !get(swipeAnchor)) return;
|
||||
const t = e.touches[0];
|
||||
const dx = t.clientX - get(swipeAnchor).x;
|
||||
const dy = t.clientY - get(swipeAnchor).y;
|
||||
if (!get(swipeLock)) {
|
||||
if (Math.abs(dx) > AXIS_THRESHOLD && Math.abs(dx) > Math.abs(dy)) set(swipeLock, "x");
|
||||
else if (Math.abs(dy) > AXIS_THRESHOLD) set(swipeLock, "y");
|
||||
}
|
||||
if (get(swipeLock) === "x") {
|
||||
e.preventDefault();
|
||||
set(swipeDX, dx);
|
||||
} else if (get(swipeLock) === "y") set(swipeDX, 0);
|
||||
}
|
||||
function finishSwipe(e) {
|
||||
if (!get(swiping)) return;
|
||||
const t = e.changedTouches && e.changedTouches[0];
|
||||
const dx = get(swipeLock) === "x" ? t ? t.clientX - get(swipeAnchor).x : get(swipeDX) : get(swipeDX);
|
||||
const dy = t ? t.clientY - get(swipeAnchor).y : 0;
|
||||
if (get(swipeLock) === "x" && Math.abs(dx) >= SWIPE_THRESHOLD && Math.abs(dx) >= Math.abs(dy) && get(viewMode) !== "radar") {
|
||||
const idx = VALID_VIEWS.indexOf(get(viewMode));
|
||||
const step = dx < 0 ? 1 : -1;
|
||||
const next = VALID_VIEWS[clampIdx(idx + step)];
|
||||
if (next !== get(viewMode)) {
|
||||
set(settling, true);
|
||||
set(swipeDX, (get(contentEl) ? get(contentEl).clientWidth : 420) * step * -1);
|
||||
setTimeout(() => {
|
||||
setView(next);
|
||||
set(swiping, false);
|
||||
set(swipeDX, 0);
|
||||
set(swipeLock, null);
|
||||
set(swipeAnchor, null);
|
||||
set(settling, false);
|
||||
}, SWIPE_TIMEOUT);
|
||||
return;
|
||||
}
|
||||
}
|
||||
set(settling, true);
|
||||
set(swipeDX, 0);
|
||||
setTimeout(() => {
|
||||
set(swiping, false);
|
||||
set(swipeLock, null);
|
||||
set(swipeAnchor, null);
|
||||
set(settling, false);
|
||||
}, SWIPE_TIMEOUT);
|
||||
}
|
||||
function cancelSwipe(e) {
|
||||
if (!get(swiping)) return;
|
||||
set(settling, true);
|
||||
set(swipeDX, 0);
|
||||
setTimeout(() => {
|
||||
set(swiping, false);
|
||||
set(swipeLock, null);
|
||||
set(swipeAnchor, null);
|
||||
set(settling, false);
|
||||
}, SWIPE_TIMEOUT);
|
||||
}
|
||||
const slideStyle = /* @__PURE__ */ user_derived(() => get(swipeDX) ? `transform: translateX(${get(swipeDX)}px)` : "");
|
||||
let initDone = /* @__PURE__ */ state(false);
|
||||
let geoError = /* @__PURE__ */ state("");
|
||||
user_effect(() => {
|
||||
@ -8361,7 +8430,7 @@ function App($$anchor, $$props) {
|
||||
const newest = updated[updated.length - 1];
|
||||
if (newest) app$1.selectLocation(newest.id);
|
||||
}
|
||||
var div = root_7();
|
||||
var div = root_8();
|
||||
var div_1 = child(div);
|
||||
var button = child(div_1);
|
||||
var text = child(button, true);
|
||||
@ -8441,8 +8510,8 @@ function App($$anchor, $$props) {
|
||||
if (app$1.loading && !app$1.forecastData) $$render(consequent_4);
|
||||
});
|
||||
var node_8 = sibling(node_7, 2);
|
||||
var consequent_10 = ($$anchor) => {
|
||||
var fragment = root_6();
|
||||
var consequent_11 = ($$anchor) => {
|
||||
var fragment = root_7();
|
||||
var div_8 = first_child(fragment);
|
||||
var button_5 = child(div_8);
|
||||
let classes_1;
|
||||
@ -8468,45 +8537,68 @@ function App($$anchor, $$props) {
|
||||
let classes_10;
|
||||
reset(div_9);
|
||||
var div_10 = sibling(div_9, 2);
|
||||
var node_9 = child(div_10);
|
||||
let classes_11;
|
||||
var div_11 = child(div_10);
|
||||
let classes_12;
|
||||
var node_9 = child(div_11);
|
||||
var consequent_5 = ($$anchor) => {
|
||||
var div_11 = root_5();
|
||||
CurrentWeather(child(div_11), {});
|
||||
reset(div_11);
|
||||
append($$anchor, div_11);
|
||||
var span_3 = root_5();
|
||||
let classes_13;
|
||||
var text_5 = child(span_3, true);
|
||||
reset(span_3);
|
||||
template_effect(() => {
|
||||
classes_13 = set_class(span_3, 1, "swipe-peek svelte-1n46o8q", null, classes_13, {
|
||||
"from-left": get(swipeDX) >= 0,
|
||||
"from-right": get(swipeDX) < 0
|
||||
});
|
||||
set_text(text_5, TAB_LABELS[get(peekView)]);
|
||||
});
|
||||
append($$anchor, span_3);
|
||||
};
|
||||
if_block(node_9, ($$render) => {
|
||||
if (get(peekView) && TAB_LABELS[get(peekView)]) $$render(consequent_5);
|
||||
});
|
||||
var node_10 = sibling(node_9, 2);
|
||||
var consequent_6 = ($$anchor) => {
|
||||
var div_12 = root_5();
|
||||
HourlyForecast(child(div_12), {});
|
||||
var div_12 = root_6();
|
||||
CurrentWeather(child(div_12), {});
|
||||
reset(div_12);
|
||||
append($$anchor, div_12);
|
||||
};
|
||||
var consequent_7 = ($$anchor) => {
|
||||
var div_13 = root_5();
|
||||
DailyForecast(child(div_13), {});
|
||||
var div_13 = root_6();
|
||||
HourlyForecast(child(div_13), {});
|
||||
reset(div_13);
|
||||
append($$anchor, div_13);
|
||||
};
|
||||
var consequent_8 = ($$anchor) => {
|
||||
var div_14 = root_5();
|
||||
WeatherDetail(child(div_14), {});
|
||||
var div_14 = root_6();
|
||||
DailyForecast(child(div_14), {});
|
||||
reset(div_14);
|
||||
append($$anchor, div_14);
|
||||
};
|
||||
var consequent_9 = ($$anchor) => {
|
||||
var div_15 = root_5();
|
||||
PrecipitationRadar(child(div_15), {});
|
||||
var div_15 = root_6();
|
||||
WeatherDetail(child(div_15), {});
|
||||
reset(div_15);
|
||||
append($$anchor, div_15);
|
||||
};
|
||||
if_block(node_9, ($$render) => {
|
||||
if (get(viewMode) === "current") $$render(consequent_5);
|
||||
else if (get(viewMode) === "hourly") $$render(consequent_6, 1);
|
||||
else if (get(viewMode) === "daily") $$render(consequent_7, 2);
|
||||
else if (get(viewMode) === "detail") $$render(consequent_8, 3);
|
||||
else if (get(viewMode) === "radar") $$render(consequent_9, 4);
|
||||
var consequent_10 = ($$anchor) => {
|
||||
var div_16 = root_6();
|
||||
PrecipitationRadar(child(div_16), {});
|
||||
reset(div_16);
|
||||
append($$anchor, div_16);
|
||||
};
|
||||
if_block(node_10, ($$render) => {
|
||||
if (get(viewMode) === "current") $$render(consequent_6);
|
||||
else if (get(viewMode) === "hourly") $$render(consequent_7, 1);
|
||||
else if (get(viewMode) === "daily") $$render(consequent_8, 2);
|
||||
else if (get(viewMode) === "detail") $$render(consequent_9, 3);
|
||||
else if (get(viewMode) === "radar") $$render(consequent_10, 4);
|
||||
});
|
||||
reset(div_11);
|
||||
reset(div_10);
|
||||
bind_this(div_10, ($$value) => set(contentEl, $$value), () => get(contentEl));
|
||||
template_effect(() => {
|
||||
classes_1 = set_class(button_5, 1, "nav-btn svelte-1n46o8q", null, classes_1, { active: get(viewMode) === "current" });
|
||||
classes_2 = set_class(button_6, 1, "nav-btn svelte-1n46o8q", null, classes_2, { active: get(viewMode) === "hourly" });
|
||||
@ -8518,6 +8610,9 @@ function App($$anchor, $$props) {
|
||||
classes_8 = set_class(button_12, 1, "tab-btn svelte-1n46o8q", null, classes_8, { active: get(viewMode) === "daily" });
|
||||
classes_9 = set_class(button_13, 1, "tab-btn svelte-1n46o8q", null, classes_9, { active: get(viewMode) === "detail" });
|
||||
classes_10 = set_class(button_14, 1, "tab-btn svelte-1n46o8q", null, classes_10, { active: get(viewMode) === "radar" });
|
||||
classes_11 = set_class(div_10, 1, "content-area svelte-1n46o8q", null, classes_11, { swiping: get(swiping) });
|
||||
classes_12 = set_class(div_11, 1, "swipe-layer svelte-1n46o8q", null, classes_12, { settle: get(settling) });
|
||||
set_style(div_11, get(slideStyle));
|
||||
});
|
||||
delegated("click", button_5, () => setView("current"));
|
||||
delegated("click", button_6, () => setView("hourly"));
|
||||
@ -8530,22 +8625,24 @@ function App($$anchor, $$props) {
|
||||
delegated("click", button_13, () => setView("detail"));
|
||||
delegated("click", button_14, () => setView("radar"));
|
||||
delegated("touchstart", div_10, onSwipeStart, void 0, true);
|
||||
delegated("touchend", div_10, onSwipeEnd);
|
||||
delegated("touchmove", div_10, onSwipeMove, void 0, true);
|
||||
delegated("touchend", div_10, finishSwipe);
|
||||
event("touchcancel", div_10, cancelSwipe);
|
||||
append($$anchor, fragment);
|
||||
};
|
||||
if_block(node_8, ($$render) => {
|
||||
if (app$1.forecastData && app$1.selectedLocation) $$render(consequent_10);
|
||||
if (app$1.forecastData && app$1.selectedLocation) $$render(consequent_11);
|
||||
});
|
||||
reset(main);
|
||||
var node_15 = sibling(main, 2);
|
||||
var consequent_11 = ($$anchor) => {
|
||||
var node_16 = sibling(main, 2);
|
||||
var consequent_12 = ($$anchor) => {
|
||||
SettingsDialog($$anchor, { onClose: () => app$1.showSettings = false });
|
||||
};
|
||||
if_block(node_15, ($$render) => {
|
||||
if (app$1.showSettings) $$render(consequent_11);
|
||||
if_block(node_16, ($$render) => {
|
||||
if (app$1.showSettings) $$render(consequent_12);
|
||||
});
|
||||
var node_16 = sibling(node_15, 2);
|
||||
var consequent_12 = ($$anchor) => {
|
||||
var node_17 = sibling(node_16, 2);
|
||||
var consequent_13 = ($$anchor) => {
|
||||
AddLocationDialog($$anchor, {
|
||||
onClose: () => {
|
||||
app$1.showAddLocation = false;
|
||||
@ -8554,8 +8651,8 @@ function App($$anchor, $$props) {
|
||||
onAdd: handleAddLocation
|
||||
});
|
||||
};
|
||||
if_block(node_16, ($$render) => {
|
||||
if (app$1.showAddLocation) $$render(consequent_12);
|
||||
if_block(node_17, ($$render) => {
|
||||
if (app$1.showAddLocation) $$render(consequent_13);
|
||||
});
|
||||
reset(div);
|
||||
template_effect(() => {
|
||||
@ -8572,11 +8669,12 @@ function App($$anchor, $$props) {
|
||||
delegate([
|
||||
"click",
|
||||
"touchstart",
|
||||
"touchmove",
|
||||
"touchend"
|
||||
]);
|
||||
//#endregion
|
||||
//#region src/main.js
|
||||
console.info({ commit_hash: "93e5660a5c76fd41088193f5ca3e3a35cd7478b1" });
|
||||
console.info({ commit_hash: "09cea275b7ac2fd46d2184d84524ee1c3ddcbe60" });
|
||||
mount(App, { target: document.getElementById("app") });
|
||||
//#endregion</script>
|
||||
<style rel="stylesheet" crossorigin>/* ===== CSS Reset & Base ===== */
|
||||
@ -10914,6 +11012,53 @@ input::placeholder {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.swipe-layer.svelte-1n46o8q {
|
||||
position: relative;
|
||||
min-height: 100%;
|
||||
will-change: transform;
|
||||
touch-action: pan-y;
|
||||
}
|
||||
|
||||
.swipe-layer.settle.svelte-1n46o8q {
|
||||
transition: transform 160ms ease-out;
|
||||
}
|
||||
|
||||
.content-area.swiping.svelte-1n46o8q {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.swipe-peek.svelte-1n46o8q {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
z-index: 5;
|
||||
padding: 5px 10px;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-primary);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.18);
|
||||
opacity: 0;
|
||||
animation: svelte-1n46o8q-peek-in 160ms ease-out forwards;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.swipe-peek.from-left.svelte-1n46o8q {
|
||||
left: 8px;
|
||||
}
|
||||
|
||||
.swipe-peek.from-right.svelte-1n46o8q {
|
||||
right: 8px;
|
||||
}
|
||||
|
||||
@keyframes svelte-1n46o8q-peek-in {
|
||||
from { opacity: 0; transform: translateX(10px); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 1023px) {
|
||||
.app-shell.svelte-1n46o8q {
|
||||
|
||||
230
src/App.svelte
230
src/App.svelte
@ -36,33 +36,114 @@
|
||||
return () => window.removeEventListener('hashchange', onHash)
|
||||
})
|
||||
|
||||
// --- Mobile swipe-to-switch tabs ---
|
||||
// Horizontal swipe on the content area moves between tabs (touch only).
|
||||
// --- Mobile swipe-to-switch tabs (live drag + visual feedback) ---
|
||||
// Horizontal swipe on the content area moves between tabs (touch only). The
|
||||
// content translates with the finger in real time, showing a "peek" chip of
|
||||
// the destination, then animates to the next tab (or snaps back) on release.
|
||||
// Gated to non-radar views: the radar map handles its own pan gestures via
|
||||
// pointer events, so a swipe there should pan the map, not switch tabs.
|
||||
const SWIPE_THRESHOLD = 60 // px of horizontal travel before it counts as a swipe
|
||||
let touchAnchor = $state(null)
|
||||
const SWIPE_THRESHOLD = 70 // px of horizontal travel before it counts as a swipe
|
||||
const AXIS_THRESHOLD = 12 // travel before we decide horizontal vs vertical
|
||||
const SWIPE_TIMEOUT = 160 // ms for the snap-back / snap-forward animation
|
||||
const TAB_LABELS = { current: 'Now', hourly: 'Hourly', daily: 'Daily', detail: 'More', radar: 'Radar' }
|
||||
|
||||
let contentEl = $state(null) // bound to .content-area, gives travel distance
|
||||
let swiping = $state(false) // a touch drag is in progress
|
||||
let swipeAnchor = $state(null) // { x, y } of the starting touch
|
||||
let swipeDX = $state(0) // live horizontal travel in px (drives the transform)
|
||||
let swipeLock = $state(null) // 'x' | 'y' | null once the axis is decided
|
||||
let settling = $state(false) // true while animating on release, enables CSS transition
|
||||
|
||||
const clampIdx = (i) => Math.min(Math.max(i, 0), VALID_VIEWS.length - 1)
|
||||
|
||||
// The destination view to hint at as the finger drags (only after we've
|
||||
// committed to a horizontal swipe, so vertical scrolling shows nothing).
|
||||
const peekView = $derived(
|
||||
swiping && swipeLock === 'x' && VALID_VIEWS.includes(viewMode)
|
||||
? VALID_VIEWS[clampIdx(VALID_VIEWS.indexOf(viewMode) + (swipeDX < 0 ? 1 : -1))]
|
||||
: null
|
||||
)
|
||||
|
||||
function onSwipeStart(e) {
|
||||
if (viewMode === 'radar' || !e.touches || !e.touches[0]) return
|
||||
touchAnchor = { x: e.touches[0].clientX, y: e.touches[0].clientY }
|
||||
const t = e.touches[0]
|
||||
swipeAnchor = { x: t.clientX, y: t.clientY }
|
||||
swipeDX = 0
|
||||
swipeLock = null
|
||||
settling = false
|
||||
swiping = true
|
||||
}
|
||||
|
||||
function onSwipeEnd(e) {
|
||||
if (viewMode === 'radar' || !touchAnchor || !e.changedTouches || !e.changedTouches[0]) return
|
||||
const t = e.changedTouches[0]
|
||||
const dx = t.clientX - touchAnchor.x
|
||||
const dy = t.clientY - touchAnchor.y
|
||||
touchAnchor = null
|
||||
// Require a mostly-horizontal swipe past the threshold so vertical
|
||||
// scrolling stays untouched.
|
||||
if (Math.abs(dx) < SWIPE_THRESHOLD || Math.abs(dx) < Math.abs(dy)) return
|
||||
const idx = VALID_VIEWS.indexOf(viewMode)
|
||||
const step = dx < 0 ? 1 : -1 // left swipe = next tab, right = previous
|
||||
const next = VALID_VIEWS[Math.min(Math.max(idx + step, 0), VALID_VIEWS.length - 1)]
|
||||
if (next !== viewMode) setView(next)
|
||||
function onSwipeMove(e) {
|
||||
if (!swiping || !swipeAnchor) return
|
||||
const t = e.touches[0]
|
||||
const dx = t.clientX - swipeAnchor.x
|
||||
const dy = t.clientY - swipeAnchor.y
|
||||
// Decide the axis the first time we travel far enough; once locked to Y we
|
||||
// leave default scrolling alone and never pop the peek nipple.
|
||||
if (!swipeLock) {
|
||||
if (Math.abs(dx) > AXIS_THRESHOLD && Math.abs(dx) > Math.abs(dy)) swipeLock = 'x'
|
||||
else if (Math.abs(dy) > AXIS_THRESHOLD) swipeLock = 'y'
|
||||
}
|
||||
if (swipeLock === 'x') {
|
||||
e.preventDefault() // stop the page from scrolling while we drag sideways
|
||||
swipeDX = dx
|
||||
} else if (swipeLock === 'y') {
|
||||
swipeDX = 0 // let vertical scroll pass through
|
||||
}
|
||||
}
|
||||
|
||||
function finishSwipe(e) {
|
||||
if (!swiping) return
|
||||
const t = e.changedTouches && e.changedTouches[0]
|
||||
const dx = swipeLock === 'x' ? (t ? t.clientX - swipeAnchor.x : swipeDX) : swipeDX
|
||||
const dy = t ? t.clientY - swipeAnchor.y : 0
|
||||
const horizontal = swipeLock === 'x' && Math.abs(dx) >= SWIPE_THRESHOLD && Math.abs(dx) >= Math.abs(dy)
|
||||
|
||||
if (horizontal && viewMode !== 'radar') {
|
||||
const idx = VALID_VIEWS.indexOf(viewMode)
|
||||
const step = dx < 0 ? 1 : -1 // left swipe = next, right = previous
|
||||
const next = VALID_VIEWS[clampIdx(idx + step)]
|
||||
if (next !== viewMode) {
|
||||
// Animate the current sheet the rest of the way off-screen, then swap.
|
||||
settling = true
|
||||
swipeDX = (contentEl ? contentEl.clientWidth : 420) * step * -1
|
||||
setTimeout(() => {
|
||||
setView(next)
|
||||
swiping = false
|
||||
swipeDX = 0
|
||||
swipeLock = null
|
||||
swipeAnchor = null
|
||||
settling = false
|
||||
}, SWIPE_TIMEOUT)
|
||||
return
|
||||
}
|
||||
}
|
||||
// Not a switch: ease the sheet back to rest.
|
||||
settling = true
|
||||
swipeDX = 0
|
||||
setTimeout(() => {
|
||||
swiping = false
|
||||
swipeLock = null
|
||||
swipeAnchor = null
|
||||
settling = false
|
||||
}, SWIPE_TIMEOUT)
|
||||
}
|
||||
|
||||
function cancelSwipe(e) {
|
||||
if (!swiping) return
|
||||
settling = true
|
||||
swipeDX = 0
|
||||
setTimeout(() => {
|
||||
swiping = false
|
||||
swipeLock = null
|
||||
swipeAnchor = null
|
||||
settling = false
|
||||
}, SWIPE_TIMEOUT)
|
||||
}
|
||||
|
||||
const slideStyle = $derived(swipeDX ? `transform: translateX(${swipeDX}px)` : '')
|
||||
|
||||
let initDone = $state(false)
|
||||
let geoError = $state('')
|
||||
|
||||
@ -271,28 +352,50 @@
|
||||
</div>
|
||||
|
||||
<!-- Content area -->
|
||||
<div class="content-area" ontouchstart={onSwipeStart} ontouchend={onSwipeEnd}>
|
||||
{#if viewMode === 'current'}
|
||||
<div class="animate-fade-in">
|
||||
<CurrentWeather />
|
||||
</div>
|
||||
{:else if viewMode === 'hourly'}
|
||||
<div class="animate-fade-in">
|
||||
<HourlyForecast />
|
||||
</div>
|
||||
{:else if viewMode === 'daily'}
|
||||
<div class="animate-fade-in">
|
||||
<DailyForecast />
|
||||
</div>
|
||||
{:else if viewMode === 'detail'}
|
||||
<div class="animate-fade-in">
|
||||
<WeatherDetail />
|
||||
</div>
|
||||
{:else if viewMode === 'radar'}
|
||||
<div class="animate-fade-in">
|
||||
<PrecipitationRadar />
|
||||
</div>
|
||||
{/if}
|
||||
<div
|
||||
class="content-area"
|
||||
class:swiping={swiping}
|
||||
bind:this={contentEl}
|
||||
ontouchstart={onSwipeStart}
|
||||
ontouchmove={onSwipeMove}
|
||||
ontouchend={finishSwipe}
|
||||
ontouchcancel={cancelSwipe}
|
||||
>
|
||||
<div
|
||||
class="swipe-layer"
|
||||
class:settle={settling}
|
||||
style={slideStyle}
|
||||
>
|
||||
{#if peekView && TAB_LABELS[peekView]}
|
||||
<span
|
||||
class="swipe-peek"
|
||||
class:from-left={swipeDX >= 0}
|
||||
class:from-right={swipeDX < 0}
|
||||
aria-hidden="true"
|
||||
>{TAB_LABELS[peekView]}</span>
|
||||
{/if}
|
||||
{#if viewMode === 'current'}
|
||||
<div class="animate-fade-in">
|
||||
<CurrentWeather />
|
||||
</div>
|
||||
{:else if viewMode === 'hourly'}
|
||||
<div class="animate-fade-in">
|
||||
<HourlyForecast />
|
||||
</div>
|
||||
{:else if viewMode === 'daily'}
|
||||
<div class="animate-fade-in">
|
||||
<DailyForecast />
|
||||
</div>
|
||||
{:else if viewMode === 'detail'}
|
||||
<div class="animate-fade-in">
|
||||
<WeatherDetail />
|
||||
</div>
|
||||
{:else if viewMode === 'radar'}
|
||||
<div class="animate-fade-in">
|
||||
<PrecipitationRadar />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
@ -552,6 +655,53 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.swipe-layer {
|
||||
position: relative;
|
||||
min-height: 100%;
|
||||
will-change: transform;
|
||||
touch-action: pan-y;
|
||||
}
|
||||
|
||||
.swipe-layer.settle {
|
||||
transition: transform 160ms ease-out;
|
||||
}
|
||||
|
||||
.content-area.swiping {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.swipe-peek {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
z-index: 5;
|
||||
padding: 5px 10px;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-primary);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.18);
|
||||
opacity: 0;
|
||||
animation: peek-in 160ms ease-out forwards;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.swipe-peek.from-left {
|
||||
left: 8px;
|
||||
}
|
||||
|
||||
.swipe-peek.from-right {
|
||||
right: 8px;
|
||||
}
|
||||
|
||||
@keyframes peek-in {
|
||||
from { opacity: 0; transform: translateX(10px); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 1023px) {
|
||||
.app-shell {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user