WIP PWA for Grain
at main 161 lines 4.2 kB view raw
1import { LitElement, html, css } from 'lit'; 2import '../atoms/grain-spinner.js'; 3 4const THRESHOLD = 60; 5const MAX_PULL = 100; 6 7export class GrainPullToRefresh extends LitElement { 8 static properties = { 9 refreshing: { type: Boolean }, 10 _pulling: { state: true }, 11 _pullDistance: { state: true } 12 }; 13 14 static styles = css` 15 :host { 16 display: flex; 17 flex-direction: column; 18 flex: 1; 19 overflow: hidden; 20 min-height: 100%; 21 } 22 .container { 23 position: relative; 24 flex: 1; 25 display: flex; 26 flex-direction: column; 27 } 28 .indicator { 29 position: absolute; 30 top: 0; 31 left: 0; 32 right: 0; 33 display: flex; 34 justify-content: center; 35 align-items: center; 36 height: 0; 37 overflow: visible; 38 pointer-events: none; 39 } 40 .content { 41 transition: transform 0.2s; 42 } 43 .content.pulling { 44 transition: none; 45 } 46 `; 47 48 #startY = 0; 49 #currentY = 0; 50 #scrollContainer = null; 51 52 constructor() { 53 super(); 54 this.refreshing = false; 55 this._pulling = false; 56 this._pullDistance = 0; 57 } 58 59 connectedCallback() { 60 super.connectedCallback(); 61 this.addEventListener('touchstart', this.#onTouchStart, { passive: true }); 62 this.addEventListener('touchmove', this.#onTouchMove, { passive: false }); 63 this.addEventListener('touchend', this.#onTouchEnd, { passive: true }); 64 } 65 66 #findScrollContainer() { 67 let el = this; 68 while (el) { 69 // Get next parent, crossing shadow DOM boundaries 70 const parent = el.parentElement || el.getRootNode()?.host; 71 if (!parent || parent === document.documentElement) break; 72 73 const style = getComputedStyle(parent); 74 if (style.overflowY === 'auto' || style.overflowY === 'scroll') { 75 return parent; 76 } 77 el = parent; 78 } 79 return null; 80 } 81 82 #getScrollTop() { 83 // Find scroll container lazily (content may not be loaded at connectedCallback) 84 if (!this.#scrollContainer) { 85 this.#scrollContainer = this.#findScrollContainer(); 86 } 87 if (this.#scrollContainer) { 88 return this.#scrollContainer.scrollTop; 89 } 90 return window.scrollY; 91 } 92 93 disconnectedCallback() { 94 this.removeEventListener('touchstart', this.#onTouchStart); 95 this.removeEventListener('touchmove', this.#onTouchMove); 96 this.removeEventListener('touchend', this.#onTouchEnd); 97 // Reset state to avoid stale values on reconnect 98 this._pulling = false; 99 this._pullDistance = 0; 100 super.disconnectedCallback(); 101 } 102 103 #onTouchStart = (e) => { 104 if (this.refreshing) return; 105 if (this.#getScrollTop() > 0) return; 106 107 this.#startY = e.touches[0].clientY; 108 this._pulling = true; 109 }; 110 111 #onTouchMove = (e) => { 112 if (!this._pulling || this.refreshing) return; 113 114 this.#currentY = e.touches[0].clientY; 115 const diff = this.#currentY - this.#startY; 116 117 if (diff > 0 && this.#getScrollTop() === 0) { 118 e.preventDefault(); 119 // Apply resistance 120 this._pullDistance = Math.min(diff * 0.5, MAX_PULL); 121 } else { 122 this._pullDistance = 0; 123 } 124 }; 125 126 #onTouchEnd = () => { 127 if (!this._pulling) return; 128 129 if (this._pullDistance >= THRESHOLD) { 130 this.dispatchEvent(new CustomEvent('refresh', { bubbles: true, composed: true })); 131 } 132 133 this._pulling = false; 134 this._pullDistance = 0; 135 }; 136 137 render() { 138 const indicatorY = this._pullDistance - 30; 139 const showSpinner = this._pullDistance > 10 || this.refreshing; 140 const opacity = this.refreshing ? 1 : Math.min(this._pullDistance / THRESHOLD, 1); 141 142 return html` 143 <div class="container"> 144 <div 145 class="indicator" 146 style="transform: translateY(${this.refreshing ? 25 : indicatorY}px); opacity: ${opacity}" 147 > 148 ${showSpinner ? html`<grain-spinner size="small"></grain-spinner>` : ''} 149 </div> 150 <div 151 class="content ${this._pulling ? 'pulling' : ''}" 152 style="transform: translateY(${this.refreshing ? 50 : this._pullDistance}px)" 153 > 154 <slot></slot> 155 </div> 156 </div> 157 `; 158 } 159} 160 161customElements.define('grain-pull-to-refresh', GrainPullToRefresh);