stackchain-dashboard/frontend/mobile-pull-refresh.js
timmy 386acda1dc
All checks were successful
CI / lint (pull_request) Successful in 2m57s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 2m1s
CI / release-candidate (pull_request) Has been skipped
feat: add mobile pull-to-refresh for My Work (Closes #1002)
2026-08-17 05:43:39 +00:00

89 lines
3.5 KiB
JavaScript

(function (root, factory) {
if (typeof module !== 'undefined' && module.exports) module.exports = factory;
else root.createMobilePullRefresh = factory;
})(typeof self !== 'undefined' ? self : this, function createMobilePullRefresh(options) {
const surface = options.surface || document.querySelector('#my-work');
const indicator = options.indicator || document.querySelector('#mobile-pull-refresh');
const threshold = options.threshold || 64;
const isMobile = options.isMobile || (() => window.matchMedia('(max-width: 600px)').matches);
const getScrollY = options.getScrollY || (() => window.scrollY);
const isBlocked = options.isBlocked || (() => Array.from(options.overlays || document.querySelectorAll('[role="dialog"], #whiteboard-modal, #markdown-modal')).some(overlay =>
(overlay.checkVisibility ? overlay.checkVisibility() : !overlay.closest('[hidden]')) &&
(overlay.classList.contains('open') || overlay.getAttribute('aria-modal') === 'true')));
let gesture = null;
let inFlight = null;
function show(state, text) {
indicator.hidden = false;
indicator.dataset.state = state;
indicator.textContent = text;
}
function resetGesture() {
gesture = null;
}
function startsOnExcludedTarget(target) {
if (!target) return false;
if (target.closest?.('button, a, input, textarea, select, summary, [contenteditable="true"], [role="button"]')) return true;
for (let node = target; node && node !== surface; node = node.parentElement) {
if ((node.scrollWidth || 0) > (node.clientWidth || 0)) return true;
}
return false;
}
function onPointerDown(event) {
if (inFlight || event.pointerType !== 'touch' || !isMobile() || getScrollY() > 0 || isBlocked() || startsOnExcludedTarget(event.target)) return;
gesture = {id:event.pointerId, x:event.clientX, y:event.clientY, ready:false};
surface.setPointerCapture?.(event.pointerId);
}
function onPointerMove(event) {
if (!gesture || gesture.id !== event.pointerId) return;
const dx = event.clientX - gesture.x;
const dy = event.clientY - gesture.y;
if (dy <= 0 || Math.abs(dx) > dy) return resetGesture();
event.preventDefault();
gesture.ready = dy >= threshold;
show(gesture.ready ? 'ready' : 'pulling', gesture.ready ? 'Release to refresh' : 'Pull to refresh');
}
async function onPointerUp(event) {
if (!gesture || gesture.id !== event.pointerId) return;
const shouldRefresh = gesture.ready;
resetGesture();
surface.releasePointerCapture?.(event.pointerId);
if (!shouldRefresh || inFlight) return;
show('refreshing', 'Refreshing My Work…');
inFlight = Promise.resolve().then(options.refresh);
try {
await inFlight;
show('success', 'My Work is up to date');
} catch (_) {
show('error', 'Could not refresh · pull to retry');
} finally {
inFlight = null;
}
}
function onPointerCancel() {
resetGesture();
}
return {
start() {
surface.addEventListener('pointerdown', onPointerDown);
surface.addEventListener('pointermove', onPointerMove);
surface.addEventListener('pointerup', onPointerUp);
surface.addEventListener('pointercancel', onPointerCancel);
},
stop() {
surface.removeEventListener('pointerdown', onPointerDown);
surface.removeEventListener('pointermove', onPointerMove);
surface.removeEventListener('pointerup', onPointerUp);
surface.removeEventListener('pointercancel', onPointerCancel);
resetGesture();
},
};
});