import { LitElement, html, css } from 'lit'; import '../atoms/grain-spinner.js'; const THRESHOLD = 60; const MAX_PULL = 100; export class GrainPullToRefresh extends LitElement { static properties = { refreshing: { type: Boolean }, _pulling: { state: true }, _pullDistance: { state: true } }; static styles = css` :host { display: flex; flex-direction: column; flex: 1; overflow: hidden; min-height: 100%; } .container { position: relative; flex: 1; display: flex; flex-direction: column; } .indicator { position: absolute; top: 0; left: 0; right: 0; display: flex; justify-content: center; align-items: center; height: 0; overflow: visible; pointer-events: none; } .content { transition: transform 0.2s; } .content.pulling { transition: none; } `; #startY = 0; #currentY = 0; #scrollContainer = null; constructor() { super(); this.refreshing = false; this._pulling = false; this._pullDistance = 0; } connectedCallback() { super.connectedCallback(); this.addEventListener('touchstart', this.#onTouchStart, { passive: true }); this.addEventListener('touchmove', this.#onTouchMove, { passive: false }); this.addEventListener('touchend', this.#onTouchEnd, { passive: true }); } #findScrollContainer() { let el = this; while (el) { // Get next parent, crossing shadow DOM boundaries const parent = el.parentElement || el.getRootNode()?.host; if (!parent || parent === document.documentElement) break; const style = getComputedStyle(parent); if (style.overflowY === 'auto' || style.overflowY === 'scroll') { return parent; } el = parent; } return null; } #getScrollTop() { // Find scroll container lazily (content may not be loaded at connectedCallback) if (!this.#scrollContainer) { this.#scrollContainer = this.#findScrollContainer(); } if (this.#scrollContainer) { return this.#scrollContainer.scrollTop; } return window.scrollY; } disconnectedCallback() { this.removeEventListener('touchstart', this.#onTouchStart); this.removeEventListener('touchmove', this.#onTouchMove); this.removeEventListener('touchend', this.#onTouchEnd); // Reset state to avoid stale values on reconnect this._pulling = false; this._pullDistance = 0; super.disconnectedCallback(); } #onTouchStart = (e) => { if (this.refreshing) return; if (this.#getScrollTop() > 0) return; this.#startY = e.touches[0].clientY; this._pulling = true; }; #onTouchMove = (e) => { if (!this._pulling || this.refreshing) return; this.#currentY = e.touches[0].clientY; const diff = this.#currentY - this.#startY; if (diff > 0 && this.#getScrollTop() === 0) { e.preventDefault(); // Apply resistance this._pullDistance = Math.min(diff * 0.5, MAX_PULL); } else { this._pullDistance = 0; } }; #onTouchEnd = () => { if (!this._pulling) return; if (this._pullDistance >= THRESHOLD) { this.dispatchEvent(new CustomEvent('refresh', { bubbles: true, composed: true })); } this._pulling = false; this._pullDistance = 0; }; render() { const indicatorY = this._pullDistance - 30; const showSpinner = this._pullDistance > 10 || this.refreshing; const opacity = this.refreshing ? 1 : Math.min(this._pullDistance / THRESHOLD, 1); return html`