forked from
grain.social/grain-pwa
WIP PWA for Grain
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);