@wc-lib/infinite-scroll-list 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -11,6 +11,8 @@
11
11
  - 支持在body或任何overflow-y: auto/scroll的容器中使用
12
12
  - 自适应父元素大小变化
13
13
  - 只在有下一页时触发加载事件,避免重复加载
14
+ - 支持下拉刷新功能(移动端专用)
15
+ - 支持自定义下拉刷新样式和动画
14
16
 
15
17
  ## 安装
16
18
 
@@ -61,26 +63,112 @@ pnpm build
61
63
  </script>
62
64
  ```
63
65
 
66
+ ### 下拉刷新用法
67
+
68
+ ```html
69
+ <infinite-scroll-list
70
+ id="scrollView"
71
+ on-end-reached-threshold="100"
72
+ has-next-page="true"
73
+ enable-refresh="true"
74
+ refresh-threshold="80"
75
+ is-refreshing="false"
76
+ >
77
+ <!-- 下拉刷新插槽 -->
78
+ <div slot="refresh" class="my-refresh-spinner">
79
+ <svg class="refresh-icon" viewBox="0 0 24 24">
80
+ <!-- 刷新图标 -->
81
+ </svg>
82
+ <span>下拉刷新</span>
83
+ </div>
84
+
85
+ <div id="list-container">
86
+ <!-- 列表内容 -->
87
+ </div>
88
+
89
+ <div slot="loading">加载中...</div>
90
+ <div slot="no-data">没有更多了</div>
91
+ </infinite-scroll-list>
92
+
93
+ <script>
94
+ const scrollView = document.getElementById('scrollView');
95
+
96
+ // 监听下拉进度(可选,用于动画效果)
97
+ scrollView.addEventListener('refresh-pulling', (e) => {
98
+ const { progress } = e.detail;
99
+ const icon = scrollView.querySelector('.refresh-icon');
100
+ if (icon) {
101
+ icon.style.transform = `rotate(${progress * 360}deg)`;
102
+ }
103
+ });
104
+
105
+ // 监听下拉刷新事件
106
+ scrollView.addEventListener('refresh', async () => {
107
+ scrollView.setAttribute('is-refreshing', 'true');
108
+
109
+ try {
110
+ await reloadData(); // 调用接口刷新数据
111
+ } finally {
112
+ scrollView.setAttribute('is-refreshing', 'false'); // 刷新完成后收回
113
+ }
114
+ });
115
+
116
+ // 监听触底加载
117
+ scrollView.addEventListener('end-reached', () => {
118
+ loadMoreData();
119
+ });
120
+
121
+ // 程序化触发刷新(例如点击顶部 Logo)
122
+ const logo = document.getElementById('logo');
123
+ logo.addEventListener('click', () => {
124
+ // 自动平滑滚动到顶部并同步展开刷新容器
125
+ scrollView.scrollToTopAndRefresh();
126
+ });
127
+ </script>
128
+ ```
129
+
64
130
  ### 属性
65
131
 
66
132
  | 属性名 | 类型 | 默认值 | 说明 |
67
133
  | --- | --- | --- | --- |
68
134
  | on-end-reached-threshold | Number | 0 | 距离底部多少像素时触发加载事件 |
69
135
  | has-next-page | Boolean | false | 是否还有下一页数据 |
136
+ | enable-refresh | Boolean | false | 是否启用下拉刷新功能(移动端专用) |
137
+ | refresh-threshold | Number | 60 | 下拉多少像素时触发刷新事件 |
138
+ | is-refreshing | Boolean | false | 是否正在刷新中,刷新完成后应设置为 false |
70
139
 
71
140
  ### 事件
72
141
 
73
142
  | 事件名 | 说明 |
74
143
  | --- | --- |
75
144
  | end-reached | 滚动到底部时触发,只有当has-next-page为true时才会触发 |
145
+ | refresh | 下拉刷新时触发,只有当enable-refresh为true且达到refresh-threshold时才会触发 |
146
+ | refresh-pulling | 下拉过程中触发,用于显示下拉进度(detail包含distance、threshold、progress) |
147
+
148
+ ### 方法
149
+
150
+ | 方法名 | 参数 | 返回值 | 说明 |
151
+ | --- | --- | --- | --- |
152
+ | scrollToTop | 无 | `Promise<void>` | 平滑滚动到顶部,并等待滚动结束。 |
153
+ | scrollToTopAndRefresh | 无 | `Promise<void>` | **原子化刷新**:平滑滚动到顶部的同时并行展开刷新容器,并触发 `refresh` 事件。 |
76
154
 
77
155
  ### 插槽
78
156
 
79
157
  | 插槽名 | 说明 |
80
158
  | --- | --- |
81
159
  | default | 默认插槽,用于放置列表内容 |
82
- | loading | 加载中状态的显示内容 |
83
- | no-data | 没有更多数据时的显示内容 |
160
+ | loading | 加载中状态的显示内容(当 `has-next-page="true"` 且滚动到底部时显示) |
161
+ | no-data | 没有更多数据时的显示内容(当 `has-next-page="false"` 时显示) |
162
+ | refresh | 下拉刷新时显示的内容(仅在移动端且 `enable-refresh="true"` 时显示) |
163
+
164
+ ## 核心优化点说明
165
+
166
+ 本项目近期通过以下深度优化提升了性能和体验:
167
+
168
+ 1. **状态驱动 UI**:完全移除手动 DOM 修改逻辑,利用 CSS 选择器响应属性变化,渲染效率更高。
169
+ 2. **原子化刷新**:`scrollToTopAndRefresh` 采用并行化设计,滚动与容器展开动画同步进行,视觉反馈更灵敏。
170
+ 3. **智能容器查找**:利用 `ResizeObserver` 实时监听布局变化并缓存滚动容器引用,大幅减少高频事件中的 DOM 遍历开销。
171
+ 4. **事件绑定简化**:采用类字段箭头函数,消除内存泄露隐患并精简样板代码。
84
172
 
85
173
  ## 示例
86
174
 
@@ -9,22 +9,123 @@
9
9
  * 5. 支持在body中使用,也支持在父或祖先元素overflow-y: auto的元素中使用
10
10
  * 6. 自适应父元素大小变化
11
11
  * 7. 组件只在has-next-page为true时触发end-reached事件
12
+ * 8. 支持下拉刷新功能(移动端专用)
12
13
  */
13
- declare const style = "\n:host {\n display: block;\n width: 100%;\n height: 100%;\n}\n\n.bottom-ref {\n width: 0;\n height: 0;\n pointer-events: none;\n}\n\n.w-full {\n width: 100%;\n}\n\n.hidden {\n display: none;\n}\n\n.contents {\n display: contents;\n}\n";
14
+ declare const template = "\n<style>\n :host {\n display: block;\n width: 100%;\n height: 100%;\n overscroll-behavior-y: contain;\n }\n\n .bottom-ref {\n width: 0;\n height: 0;\n pointer-events: none;\n }\n\n /* \u72B6\u6001\u9A71\u52A8 UI\uFF1A\u5229\u7528 CSS \u9009\u62E9\u5668\u54CD\u5E94\u5C5E\u6027\u53D8\u5316 */\n .slot-wrapper {\n display: none;\n }\n :host([has-next-page=\"true\"]) .loading-slot {\n display: contents;\n }\n :host([has-next-page=\"false\"]) .no-data-slot {\n display: contents;\n }\n\n .refresh-container {\n overflow: hidden;\n height: 0;\n display: flex;\n align-items: center;\n justify-content: center;\n transition: height 0.3s ease;\n flex-shrink: 0;\n background-color: transparent;\n }\n</style>\n\n<div class=\"refresh-container\">\n <slot name=\"refresh\"></slot>\n</div>\n<slot></slot>\n<div class=\"bottom-ref\"></div>\n<div class=\"slot-wrapper loading-slot\">\n <slot name=\"loading\"></slot>\n</div>\n<div class=\"slot-wrapper no-data-slot\">\n <slot name=\"no-data\"></slot>\n</div>\n";
14
15
  declare class InfiniteScrollList extends HTMLElement {
15
16
  private _observerRef;
16
17
  private _resizeObserverRef;
17
18
  private _bottomRef;
19
+ private _scrollContainer;
18
20
  private _onEndReachedThreshold;
19
21
  private _hasNextPage;
22
+ private _refreshContainer;
23
+ private _enableRefresh;
24
+ private _refreshThreshold;
25
+ private _isRefreshing;
26
+ private _startY;
27
+ private _isPulling;
28
+ private _isScrollingToTop;
20
29
  constructor();
30
+ /**
31
+ * 定义需要观察的属性列表
32
+ * 当这些属性发生变化时,会触发 attributeChangedCallback
33
+ * @returns 需要观察的属性名称数组
34
+ */
21
35
  static get observedAttributes(): string[];
36
+ /**
37
+ * 属性变化时的回调函数
38
+ * 当 observedAttributes 中定义的属性发生变化时,浏览器会自动调用此方法
39
+ * @param name - 发生变化的属性名称
40
+ * @param oldValue - 属性的旧值
41
+ * @param newValue - 属性的新值
42
+ */
22
43
  attributeChangedCallback(name: string, oldValue: string, newValue: string): void;
44
+ /**
45
+ * 组件连接到 DOM 时的生命周期回调
46
+ * 当组件被插入到 DOM 树中时,浏览器会自动调用此方法
47
+ * 在此方法中初始化观察器、事件监听器等资源
48
+ */
23
49
  connectedCallback(): void;
50
+ /**
51
+ * 组件从 DOM 断开连接时的生命周期回调
52
+ * 当组件从 DOM 树中移除时,浏览器会自动调用此方法
53
+ * 在此方法中清理观察器、事件监听器等资源,防止内存泄漏
54
+ */
24
55
  disconnectedCallback(): void;
56
+ /**
57
+ * 查找滚动容器
58
+ * 从组件自身开始,向上遍历元素,查找第一个设置了 overflow-y: auto 或 overflow-y: scroll 的元素
59
+ * 如果找不到,返回 null,表示滚动发生在 body 上
60
+ * @returns 滚动容器元素,如果未找到则返回 null
61
+ */
25
62
  private _findScrollContainer;
63
+ /**
64
+ * IntersectionObserver 的回调函数
65
+ * 当底部观察元素进入视口时触发,用于检测是否滚动到底部
66
+ * @param entries - IntersectionObserver 的观察条目数组
67
+ */
26
68
  private _intersectionCallback;
69
+ /**
70
+ * 设置 IntersectionObserver
71
+ * 创建或重新创建 IntersectionObserver,用于观察底部元素是否进入视口
72
+ * @param container - 滚动容器元素,如果为 null 则使用 viewport 作为根
73
+ */
74
+ private _setupObserver;
75
+ /**
76
+ * ResizeObserver 的回调函数
77
+ * 当父元素大小发生变化时触发
78
+ * 只有当滚动容器发生变化时,才重新设置 IntersectionObserver,避免不必要的性能开销
79
+ * @param entries - ResizeObserver 的观察条目数组
80
+ */
27
81
  private _resizeCallback;
28
- private _updateSlotVisibility;
29
- private _render;
82
+ /**
83
+ * 检测当前环境是否为移动端
84
+ */
85
+ private get _isMobile();
86
+ /**
87
+ * 设置下拉刷新的事件监听器
88
+ */
89
+ private _setupRefreshListeners;
90
+ /**
91
+ * 清理下拉刷新的事件监听器
92
+ */
93
+ private _cleanupRefreshListeners;
94
+ /**
95
+ * 处理 touchstart 事件
96
+ * 检测是否在滚动容器顶部,如果是则开始下拉刷新流程
97
+ * @param e - TouchEvent 对象
98
+ */
99
+ private _handleTouchStart;
100
+ /**
101
+ * 处理 touchmove 事件
102
+ * 计算下拉距离,应用阻尼效果,更新刷新容器高度
103
+ * 派发 refresh-pulling 事件,让外部感知下拉进度
104
+ * @param e - TouchEvent 对象
105
+ */
106
+ private _handleTouchMove;
107
+ /**
108
+ * 处理 touchend 事件
109
+ * 判断下拉距离是否达到阈值,如果达到则触发刷新事件,否则回弹
110
+ * @param e - TouchEvent 对象
111
+ */
112
+ private _handleTouchEnd;
113
+ /**
114
+ * 滚动到顶部并触发刷新
115
+ * 用于程序化触发刷新,例如点击导航菜单时
116
+ * 使用平滑滚动动画
117
+ * @returns Promise,滚动完成后 resolve
118
+ */
119
+ scrollToTopAndRefresh(): Promise<void>;
120
+ /**
121
+ * 滚动到顶部
122
+ * 使用平滑滚动动画,并等待滚动结束
123
+ * @returns Promise,滚动完成后 resolve
124
+ */
125
+ scrollToTop(): Promise<void>;
126
+ /**
127
+ * 触发刷新(内部方法,供下拉刷新和程序化刷新共用)
128
+ * 显示刷新容器并触发刷新事件
129
+ */
130
+ private _triggerRefresh;
30
131
  }
@@ -1,2 +1,2 @@
1
- !function(){"use strict";class e extends HTMLElement{constructor(){super(),this._observerRef=null,this._resizeObserverRef=null,this._bottomRef=null,this._onEndReachedThreshold=0,this._hasNextPage=!1,this.attachShadow({mode:"open"}),this._intersectionCallback=this._intersectionCallback.bind(this),this._resizeCallback=this._resizeCallback.bind(this)}static get observedAttributes(){return["on-end-reached-threshold","has-next-page"]}attributeChangedCallback(e,t,s){t!==s&&("on-end-reached-threshold"===e?(this._onEndReachedThreshold=Number(s)||0,this._bottomRef&&(this._bottomRef.style.transform=`translateY(${-this._onEndReachedThreshold}px)`)):"has-next-page"===e&&(this._hasNextPage=null!==s&&"false"!==s,this._updateSlotVisibility()))}connectedCallback(){this._onEndReachedThreshold=Number(this.getAttribute("on-end-reached-threshold"))||0,this._hasNextPage="false"!==this.getAttribute("has-next-page"),this._render(),this._observerRef=new IntersectionObserver(this._intersectionCallback,{threshold:0,root:this._findScrollContainer()}),this._bottomRef&&this._observerRef.observe(this._bottomRef),this._resizeObserverRef=new ResizeObserver(this._resizeCallback),this.parentElement&&this._resizeObserverRef.observe(this.parentElement)}disconnectedCallback(){this._observerRef&&(this._observerRef.disconnect(),this._observerRef=null),this._resizeObserverRef&&(this._resizeObserverRef.disconnect(),this._resizeObserverRef=null)}_findScrollContainer(){let e=this.parentElement;for(;e;){const t=window.getComputedStyle(e).overflowY;if("auto"===t||"scroll"===t)return e;e=e.parentElement}return null}_intersectionCallback(e){const[t]=e;t.isIntersecting&&this._hasNextPage&&this.dispatchEvent(new CustomEvent("end-reached",{bubbles:!0,composed:!0}))}_resizeCallback(e){this._observerRef&&(this._observerRef.disconnect(),this._observerRef=new IntersectionObserver(this._intersectionCallback,{threshold:0,root:this._findScrollContainer()}),this._bottomRef&&this._observerRef.observe(this._bottomRef))}_updateSlotVisibility(){const e=this.shadowRoot?.querySelector(".loading-slot"),t=this.shadowRoot?.querySelector(".no-data-slot");e&&t&&(e.className=this._hasNextPage?"loading-slot contents":"loading-slot hidden",t.className=this._hasNextPage?"no-data-slot hidden":"no-data-slot contents")}_render(){if(!this.shadowRoot)return;const e=document.createElement("style");e.textContent="\n:host {\n display: block;\n width: 100%;\n height: 100%;\n}\n\n.bottom-ref {\n width: 0;\n height: 0;\n pointer-events: none;\n}\n\n.w-full {\n width: 100%;\n}\n\n.hidden {\n display: none;\n}\n\n.contents {\n display: contents;\n}\n",this.shadowRoot.appendChild(e);const t=document.createElement("slot");this._bottomRef=document.createElement("div"),this._bottomRef.className="bottom-ref",this._bottomRef.style.transform=`translateY(${-this._onEndReachedThreshold}px)`;const s=document.createElement("div");s.className=this._hasNextPage?"loading-slot contents":"loading-slot hidden";const n=document.createElement("slot");n.setAttribute("name","loading"),s.appendChild(n);const o=document.createElement("div");o.className=this._hasNextPage?"no-data-slot hidden":"no-data-slot contents";const i=document.createElement("slot");i.setAttribute("name","no-data"),o.appendChild(i),this.shadowRoot.appendChild(t),this.shadowRoot.appendChild(this._bottomRef),this.shadowRoot.appendChild(s),this.shadowRoot.appendChild(o)}}customElements.define("infinite-scroll-list",e)}();
1
+ !function(){"use strict";class e extends HTMLElement{constructor(){super(),this._observerRef=null,this._resizeObserverRef=null,this._bottomRef=null,this._scrollContainer=null,this._onEndReachedThreshold=0,this._hasNextPage=!1,this._refreshContainer=null,this._enableRefresh=!1,this._refreshThreshold=60,this._isRefreshing=!1,this._startY=0,this._isPulling=!1,this._isScrollingToTop=!1,this._intersectionCallback=e=>{const[s]=e;s.isIntersecting&&(this._isScrollingToTop||this._hasNextPage&&this.dispatchEvent(new CustomEvent("end-reached",{bubbles:!0,composed:!0})))},this._resizeCallback=e=>{const s=this._findScrollContainer();s!==this._scrollContainer&&(this._cleanupRefreshListeners(),this._setupObserver(s),this._enableRefresh&&this._isMobile&&this._setupRefreshListeners())},this._handleTouchStart=e=>{if(!this._enableRefresh)return;if(this._isRefreshing)return;let s;s=this._scrollContainer?this._scrollContainer.scrollTop:document.documentElement.scrollTop||document.body.scrollTop||0,s<=0&&(this._startY=e.touches[0].pageY,this._isPulling=!0,this._refreshContainer&&(this._refreshContainer.style.transition="none"))},this._handleTouchMove=e=>{if(!this._isPulling||!this._refreshContainer)return;const s=e.touches[0].pageY-this._startY;if(s>0){e.cancelable&&e.preventDefault();const t=2*this._refreshThreshold,n=Math.min(Math.pow(s,.85),t);this._refreshContainer.style.height=`${n}px`,this.dispatchEvent(new CustomEvent("refresh-pulling",{bubbles:!0,composed:!0,detail:{distance:n,threshold:this._refreshThreshold,progress:Math.min(n/this._refreshThreshold,1)}}))}},this._handleTouchEnd=e=>{if(!this._isPulling||!this._refreshContainer)return;this._isPulling=!1,this._refreshContainer.style.transition="height 0.3s ease";(parseFloat(this._refreshContainer.style.height)||0)>=this._refreshThreshold?(this._refreshContainer.style.height=`${this._refreshThreshold}px`,this._triggerRefresh()):this._refreshContainer.style.height="0"},this.attachShadow({mode:"open"}).innerHTML='\n<style>\n :host {\n display: block;\n width: 100%;\n height: 100%;\n overscroll-behavior-y: contain;\n }\n\n .bottom-ref {\n width: 0;\n height: 0;\n pointer-events: none;\n }\n\n /* 状态驱动 UI:利用 CSS 选择器响应属性变化 */\n .slot-wrapper {\n display: none;\n }\n :host([has-next-page="true"]) .loading-slot {\n display: contents;\n }\n :host([has-next-page="false"]) .no-data-slot {\n display: contents;\n }\n\n .refresh-container {\n overflow: hidden;\n height: 0;\n display: flex;\n align-items: center;\n justify-content: center;\n transition: height 0.3s ease;\n flex-shrink: 0;\n background-color: transparent;\n }\n</style>\n\n<div class="refresh-container">\n <slot name="refresh"></slot>\n</div>\n<slot></slot>\n<div class="bottom-ref"></div>\n<div class="slot-wrapper loading-slot">\n <slot name="loading"></slot>\n</div>\n<div class="slot-wrapper no-data-slot">\n <slot name="no-data"></slot>\n</div>\n',this._bottomRef=this.shadowRoot.querySelector(".bottom-ref"),this._refreshContainer=this.shadowRoot.querySelector(".refresh-container")}static get observedAttributes(){return["on-end-reached-threshold","has-next-page","enable-refresh","refresh-threshold","is-refreshing"]}attributeChangedCallback(e,s,t){if(s!==t)if("on-end-reached-threshold"===e)this._onEndReachedThreshold=Number(t)||0,this._bottomRef&&(this._bottomRef.style.transform=`translateY(${-this._onEndReachedThreshold}px)`);else if("has-next-page"===e)this._hasNextPage=null!==t&&"false"!==t;else if("enable-refresh"===e)this._enableRefresh=null!==t&&"false"!==t,this._enableRefresh&&this._isMobile?this._setupRefreshListeners():this._cleanupRefreshListeners();else if("refresh-threshold"===e)this._refreshThreshold=Number(t)||60;else if("is-refreshing"===e){const e=this._isRefreshing;this._isRefreshing=null!==t&&"false"!==t,e&&!this._isRefreshing&&this._refreshContainer&&(this._refreshContainer.style.height="0")}}connectedCallback(){const e=this._findScrollContainer();this._setupObserver(e),this._resizeObserverRef=new ResizeObserver(this._resizeCallback),this.parentElement&&this._resizeObserverRef.observe(this.parentElement),this._enableRefresh&&this._isMobile&&this._setupRefreshListeners()}disconnectedCallback(){this._observerRef&&(this._observerRef.disconnect(),this._observerRef=null),this._resizeObserverRef&&(this._resizeObserverRef.disconnect(),this._resizeObserverRef=null),this._cleanupRefreshListeners()}_findScrollContainer(){let e=this;for(;e;){const{overflowY:s}=window.getComputedStyle(e);if(["auto","scroll"].includes(s))return e;e=e.parentElement}return null}_setupObserver(e){this._scrollContainer=e,this._observerRef&&this._observerRef.disconnect(),this._observerRef=new IntersectionObserver(this._intersectionCallback,{threshold:0,root:e}),this._bottomRef&&this._observerRef.observe(this._bottomRef)}get _isMobile(){return"ontouchstart"in window||navigator.maxTouchPoints>0}_setupRefreshListeners(){if(!this._isMobile)return;const e=this._scrollContainer||document.body;e.addEventListener("touchstart",this._handleTouchStart,{passive:!0}),e.addEventListener("touchmove",this._handleTouchMove,{passive:!1}),e.addEventListener("touchend",this._handleTouchEnd)}_cleanupRefreshListeners(){const e=this._scrollContainer||document.body;e.removeEventListener("touchstart",this._handleTouchStart),e.removeEventListener("touchmove",this._handleTouchMove),e.removeEventListener("touchend",this._handleTouchEnd)}async scrollToTopAndRefresh(){if(this._enableRefresh&&!this._isRefreshing&&!this._isScrollingToTop){this._isScrollingToTop=!0;try{const e=this.scrollToTop();this._triggerRefresh(),await e}finally{setTimeout((()=>{this._isScrollingToTop=!1}),300)}}}scrollToTop(){return new Promise((e=>{(this._scrollContainer||window).scrollTo({top:0,behavior:"smooth"});const s=setTimeout(e,1e3),t=()=>{(this._scrollContainer?this._scrollContainer.scrollTop:document.documentElement.scrollTop||document.body.scrollTop||0)<=1?(clearTimeout(s),e()):requestAnimationFrame(t)};requestAnimationFrame(t)}))}_triggerRefresh(){this._refreshContainer&&(this._refreshContainer.style.height=`${this._refreshThreshold}px`,this._isRefreshing=!0,this.setAttribute("is-refreshing","true"),this.dispatchEvent(new CustomEvent("refresh",{bubbles:!0,composed:!0})))}}customElements.define("infinite-scroll-list",e)}();
2
2
  //# sourceMappingURL=infinite-scroll-list.min.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"infinite-scroll-list.min.js","sources":["../src/infinite-scroll-list.ts"],"sourcesContent":["/**\n * 无限滚动加载组件\n * \n * 功能:\n * 1. 支持传入距离底部的距离参数(on-end-reached-threshold)\n * 2. 支持外部传入是否还有下一页(has-next-page)\n * 3. 支持两个slot:loading和no-data\n * 4. 根据是否有下一页显示对应的slot内容\n * 5. 支持在body中使用,也支持在父或祖先元素overflow-y: auto的元素中使用\n * 6. 自适应父元素大小变化\n * 7. 组件只在has-next-page为true时触发end-reached事件\n */\n\n// 定义组件的样式\nconst style = `\n:host {\n display: block;\n width: 100%;\n height: 100%;\n}\n\n.bottom-ref {\n width: 0;\n height: 0;\n pointer-events: none;\n}\n\n.w-full {\n width: 100%;\n}\n\n.hidden {\n display: none;\n}\n\n.contents {\n display: contents;\n}\n`;\n\nclass InfiniteScrollList extends HTMLElement {\n // 私有属性\n private _observerRef: IntersectionObserver | null = null;\n private _resizeObserverRef: ResizeObserver | null = null;\n private _bottomRef: HTMLDivElement | null = null;\n private _onEndReachedThreshold: number = 0;\n private _hasNextPage: boolean = false;\n\n constructor() {\n super();\n \n // 创建 Shadow DOM\n this.attachShadow({ mode: 'open' });\n \n // 绑定回调函数,避免this指向问题\n this._intersectionCallback = this._intersectionCallback.bind(this);\n this._resizeCallback = this._resizeCallback.bind(this);\n }\n\n // 定义观察的属性\n static get observedAttributes(): string[] {\n return ['on-end-reached-threshold', 'has-next-page'];\n }\n\n // 属性变化时的回调\n attributeChangedCallback(name: string, oldValue: string, newValue: string): void {\n if (oldValue === newValue) return;\n \n if (name === 'on-end-reached-threshold') {\n this._onEndReachedThreshold = Number(newValue) || 0;\n if (this._bottomRef) {\n // 使用 transform 来实现距离阈值,不影响文档流\n // 当滚动到距离底部还有 threshold 像素时,_bottomRef 会进入视口\n this._bottomRef.style.transform = `translateY(${-this._onEndReachedThreshold}px)`;\n }\n } else if (name === 'has-next-page') {\n this._hasNextPage = newValue !== null && newValue !== 'false';\n this._updateSlotVisibility();\n }\n }\n\n // 组件连接到 DOM 时\n connectedCallback(): void {\n // 初始化属性默认值\n this._onEndReachedThreshold = Number(this.getAttribute('on-end-reached-threshold')) || 0;\n this._hasNextPage = this.getAttribute('has-next-page') !== 'false';\n \n // 渲染组件\n this._render();\n \n // 初始化 IntersectionObserver\n this._observerRef = new IntersectionObserver(this._intersectionCallback, {\n threshold: 0.0,\n root: this._findScrollContainer()\n });\n \n if (this._bottomRef) {\n this._observerRef.observe(this._bottomRef);\n }\n\n // 初始化 ResizeObserver 监听父元素大小变化\n this._resizeObserverRef = new ResizeObserver(this._resizeCallback);\n if (this.parentElement) {\n this._resizeObserverRef.observe(this.parentElement);\n }\n }\n\n // 组件从 DOM 断开连接时\n disconnectedCallback(): void {\n // 清理 IntersectionObserver\n if (this._observerRef) {\n this._observerRef.disconnect();\n this._observerRef = null;\n }\n\n // 清理 ResizeObserver\n if (this._resizeObserverRef) {\n this._resizeObserverRef.disconnect();\n this._resizeObserverRef = null;\n }\n }\n\n // 查找滚动容器\n private _findScrollContainer(): Element | null {\n let parent = this.parentElement;\n \n while (parent) {\n const overflowY = window.getComputedStyle(parent).overflowY;\n if (overflowY === 'auto' || overflowY === 'scroll') {\n return parent;\n }\n parent = parent.parentElement;\n }\n \n return null; // 如果没有找到滚动容器,则返回null,使用默认的viewport\n }\n\n // IntersectionObserver 回调\n private _intersectionCallback(entries: IntersectionObserverEntry[]): void {\n const [target] = entries;\n if (!(target.isIntersecting)) return;\n \n // 只在有下一页时触发事件\n if (this._hasNextPage) {\n // 触发自定义事件\n this.dispatchEvent(new CustomEvent('end-reached', {\n bubbles: true,\n composed: true\n }));\n }\n }\n\n // ResizeObserver 回调\n private _resizeCallback(entries: ResizeObserverEntry[]): void {\n // 当父元素大小变化时,重新初始化观察器\n if (this._observerRef) {\n this._observerRef.disconnect();\n \n this._observerRef = new IntersectionObserver(this._intersectionCallback, {\n threshold: 0.0,\n root: this._findScrollContainer()\n });\n \n if (this._bottomRef) {\n this._observerRef.observe(this._bottomRef);\n }\n }\n }\n\n // 更新插槽可见性\n private _updateSlotVisibility(): void {\n const loadingSlot = this.shadowRoot?.querySelector('.loading-slot');\n const noDataSlot = this.shadowRoot?.querySelector('.no-data-slot');\n \n if (loadingSlot && noDataSlot) {\n loadingSlot.className = this._hasNextPage ? 'loading-slot contents' : 'loading-slot hidden';\n noDataSlot.className = this._hasNextPage ? 'no-data-slot hidden' : 'no-data-slot contents';\n }\n }\n\n // 渲染组件\n private _render(): void {\n if (!this.shadowRoot) return;\n \n // 添加样式\n const styleElement = document.createElement('style');\n styleElement.textContent = style;\n this.shadowRoot.appendChild(styleElement);\n \n // 创建内容插槽\n const defaultSlot = document.createElement('slot');\n \n // 创建底部观察元素(宽高为0,跟在内容之后)\n this._bottomRef = document.createElement('div');\n this._bottomRef.className = 'bottom-ref';\n this._bottomRef.style.transform = `translateY(${-this._onEndReachedThreshold}px)`;\n \n // 创建加载中插槽\n const loadingSlot = document.createElement('div');\n loadingSlot.className = this._hasNextPage ? 'loading-slot contents' : 'loading-slot hidden';\n \n const loadingNamedSlot = document.createElement('slot');\n loadingNamedSlot.setAttribute('name', 'loading');\n \n loadingSlot.appendChild(loadingNamedSlot);\n \n // 创建无数据插槽\n const noDataSlot = document.createElement('div');\n noDataSlot.className = this._hasNextPage ? 'no-data-slot hidden' : 'no-data-slot contents';\n \n const noDataNamedSlot = document.createElement('slot');\n noDataNamedSlot.setAttribute('name', 'no-data');\n \n noDataSlot.appendChild(noDataNamedSlot);\n \n // 组装组件(调整顺序:defaultSlot -> _bottomRef -> loadingSlot -> noDataSlot)\n this.shadowRoot.appendChild(defaultSlot);\n this.shadowRoot.appendChild(this._bottomRef);\n this.shadowRoot.appendChild(loadingSlot);\n this.shadowRoot.appendChild(noDataSlot);\n }\n}\n\n// 注册自定义元素\ncustomElements.define('infinite-scroll-list', InfiniteScrollList);\n"],"names":["InfiniteScrollList","HTMLElement","constructor","super","this","_observerRef","_resizeObserverRef","_bottomRef","_onEndReachedThreshold","_hasNextPage","attachShadow","mode","_intersectionCallback","bind","_resizeCallback","observedAttributes","attributeChangedCallback","name","oldValue","newValue","Number","style","transform","_updateSlotVisibility","connectedCallback","getAttribute","_render","IntersectionObserver","threshold","root","_findScrollContainer","observe","ResizeObserver","parentElement","disconnectedCallback","disconnect","parent","overflowY","window","getComputedStyle","entries","target","dispatchEvent","CustomEvent","bubbles","composed","loadingSlot","shadowRoot","querySelector","noDataSlot","className","styleElement","document","createElement","textContent","appendChild","defaultSlot","loadingNamedSlot","setAttribute","noDataNamedSlot","customElements","define"],"mappings":"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"}
1
+ {"version":3,"file":"infinite-scroll-list.min.js","sources":["../src/infinite-scroll-list.ts"],"sourcesContent":["/**\n * 无限滚动加载组件\n * \n * 功能:\n * 1. 支持传入距离底部的距离参数(on-end-reached-threshold)\n * 2. 支持外部传入是否还有下一页(has-next-page)\n * 3. 支持两个slot:loading和no-data\n * 4. 根据是否有下一页显示对应的slot内容\n * 5. 支持在body中使用,也支持在父或祖先元素overflow-y: auto的元素中使用\n * 6. 自适应父元素大小变化\n * 7. 组件只在has-next-page为true时触发end-reached事件\n * 8. 支持下拉刷新功能(移动端专用)\n */\n\n// 定义组件的模板(包含样式和结构)\nconst template = `\n<style>\n :host {\n display: block;\n width: 100%;\n height: 100%;\n overscroll-behavior-y: contain;\n }\n\n .bottom-ref {\n width: 0;\n height: 0;\n pointer-events: none;\n }\n\n /* 状态驱动 UI:利用 CSS 选择器响应属性变化 */\n .slot-wrapper {\n display: none;\n }\n :host([has-next-page=\"true\"]) .loading-slot {\n display: contents;\n }\n :host([has-next-page=\"false\"]) .no-data-slot {\n display: contents;\n }\n\n .refresh-container {\n overflow: hidden;\n height: 0;\n display: flex;\n align-items: center;\n justify-content: center;\n transition: height 0.3s ease;\n flex-shrink: 0;\n background-color: transparent;\n }\n</style>\n\n<div class=\"refresh-container\">\n <slot name=\"refresh\"></slot>\n</div>\n<slot></slot>\n<div class=\"bottom-ref\"></div>\n<div class=\"slot-wrapper loading-slot\">\n <slot name=\"loading\"></slot>\n</div>\n<div class=\"slot-wrapper no-data-slot\">\n <slot name=\"no-data\"></slot>\n</div>\n`;\n\nclass InfiniteScrollList extends HTMLElement {\n // 私有属性\n private _observerRef: IntersectionObserver | null = null;\n private _resizeObserverRef: ResizeObserver | null = null;\n private _bottomRef: HTMLDivElement | null = null;\n private _scrollContainer: Element | null = null; // 缓存当前的滚动容器\n private _onEndReachedThreshold: number = 0;\n private _hasNextPage: boolean = false;\n \n // 下拉刷新相关属性\n private _refreshContainer: HTMLDivElement | null = null;\n private _enableRefresh: boolean = false;\n private _refreshThreshold: number = 60;\n private _isRefreshing: boolean = false;\n private _startY: number = 0;\n private _isPulling: boolean = false;\n private _isScrollingToTop: boolean = false; // 是否正在滚动到顶部\n\n constructor() {\n super();\n \n // 创建 Shadow DOM 并注入模板,取代手动 DOM 构建\n this.attachShadow({ mode: 'open' }).innerHTML = template;\n \n // 获取模板中的关键元素引用\n this._bottomRef = this.shadowRoot!.querySelector('.bottom-ref');\n this._refreshContainer = this.shadowRoot!.querySelector('.refresh-container');\n }\n\n /**\n * 定义需要观察的属性列表\n * 当这些属性发生变化时,会触发 attributeChangedCallback\n * @returns 需要观察的属性名称数组\n */\n static get observedAttributes(): string[] {\n return [\n 'on-end-reached-threshold', \n 'has-next-page',\n 'enable-refresh',\n 'refresh-threshold',\n 'is-refreshing'\n ];\n }\n\n /**\n * 属性变化时的回调函数\n * 当 observedAttributes 中定义的属性发生变化时,浏览器会自动调用此方法\n * @param name - 发生变化的属性名称\n * @param oldValue - 属性的旧值\n * @param newValue - 属性的新值\n */\n attributeChangedCallback(name: string, oldValue: string, newValue: string): void {\n if (oldValue === newValue) return;\n \n if (name === 'on-end-reached-threshold') {\n this._onEndReachedThreshold = Number(newValue) || 0;\n if (this._bottomRef) {\n // 使用 transform 来实现距离阈值,不影响文档流\n // 当滚动到距离底部还有 threshold 像素时,_bottomRef 会进入视口\n this._bottomRef.style.transform = `translateY(${-this._onEndReachedThreshold}px)`;\n }\n } else if (name === 'has-next-page') {\n this._hasNextPage = newValue !== null && newValue !== 'false';\n } else if (name === 'enable-refresh') {\n this._enableRefresh = newValue !== null && newValue !== 'false';\n if (this._enableRefresh && this._isMobile) {\n // 只在移动端且启用刷新时才设置监听器\n this._setupRefreshListeners();\n } else {\n this._cleanupRefreshListeners();\n }\n } else if (name === 'refresh-threshold') {\n this._refreshThreshold = Number(newValue) || 60;\n } else if (name === 'is-refreshing') {\n const wasRefreshing = this._isRefreshing;\n this._isRefreshing = newValue !== null && newValue !== 'false';\n \n // 如果从 true 变为 false,收起刷新头\n if (wasRefreshing && !this._isRefreshing && this._refreshContainer) {\n this._refreshContainer.style.height = '0';\n }\n }\n }\n\n /**\n * 组件连接到 DOM 时的生命周期回调\n * 当组件被插入到 DOM 树中时,浏览器会自动调用此方法\n * 在此方法中初始化观察器、事件监听器等资源\n */\n connectedCallback(): void {\n // 注意:如果属性在 HTML 中存在,浏览器会在 connectedCallback 之前自动触发 attributeChangedCallback\n // 如果属性不存在,使用属性声明处的默认值即可(已在类属性声明处设置)\n \n // CSS 会根据 has-next-page 属性自动处理插槽可见性\n \n // 初始化 IntersectionObserver(使用提取的方法)\n const scrollContainer = this._findScrollContainer();\n this._setupObserver(scrollContainer);\n\n // 初始化 ResizeObserver 监听父元素大小变化\n this._resizeObserverRef = new ResizeObserver(this._resizeCallback);\n if (this.parentElement) {\n this._resizeObserverRef.observe(this.parentElement);\n }\n \n // 如果启用刷新且为移动端,设置事件监听\n if (this._enableRefresh && this._isMobile) {\n this._setupRefreshListeners();\n }\n }\n\n /**\n * 组件从 DOM 断开连接时的生命周期回调\n * 当组件从 DOM 树中移除时,浏览器会自动调用此方法\n * 在此方法中清理观察器、事件监听器等资源,防止内存泄漏\n */\n disconnectedCallback(): void {\n // 清理 IntersectionObserver\n if (this._observerRef) {\n this._observerRef.disconnect();\n this._observerRef = null;\n }\n\n // 清理 ResizeObserver\n if (this._resizeObserverRef) {\n this._resizeObserverRef.disconnect();\n this._resizeObserverRef = null;\n }\n \n // 清理刷新相关的事件监听\n this._cleanupRefreshListeners();\n }\n\n /**\n * 查找滚动容器\n * 从组件自身开始,向上遍历元素,查找第一个设置了 overflow-y: auto 或 overflow-y: scroll 的元素\n * 如果找不到,返回 null,表示滚动发生在 body 上\n * @returns 滚动容器元素,如果未找到则返回 null\n */\n private _findScrollContainer(): Element | null {\n // 从组件自身开始检查(组件自身可能是滚动容器)\n let parent: Element | null = this as Element;\n \n while (parent) {\n const { overflowY } = window.getComputedStyle(parent);\n if (['auto', 'scroll'].includes(overflowY)) return parent;\n parent = parent.parentElement;\n }\n \n return null; // 如果没有找到滚动容器,则返回null,使用默认的viewport\n }\n\n /**\n * IntersectionObserver 的回调函数\n * 当底部观察元素进入视口时触发,用于检测是否滚动到底部\n * @param entries - IntersectionObserver 的观察条目数组\n */\n private _intersectionCallback = (entries: IntersectionObserverEntry[]): void => {\n const [target] = entries;\n if (!(target.isIntersecting)) return;\n \n // 如果正在滚动到顶部,忽略触发(防止滚动过程中误触发)\n if (this._isScrollingToTop) return;\n \n // 只在有下一页时触发事件\n if (this._hasNextPage) {\n // 触发自定义事件\n this.dispatchEvent(new CustomEvent('end-reached', {\n bubbles: true,\n composed: true\n }));\n }\n }\n\n /**\n * 设置 IntersectionObserver\n * 创建或重新创建 IntersectionObserver,用于观察底部元素是否进入视口\n * @param container - 滚动容器元素,如果为 null 则使用 viewport 作为根\n */\n private _setupObserver(container: Element | null): void {\n this._scrollContainer = container;\n \n // 如果已存在观察器,先断开连接\n if (this._observerRef) {\n this._observerRef.disconnect();\n }\n \n // 创建新的观察器\n this._observerRef = new IntersectionObserver(this._intersectionCallback, {\n threshold: 0.0,\n root: container\n });\n \n if (this._bottomRef) {\n this._observerRef.observe(this._bottomRef);\n }\n }\n\n /**\n * ResizeObserver 的回调函数\n * 当父元素大小发生变化时触发\n * 只有当滚动容器发生变化时,才重新设置 IntersectionObserver,避免不必要的性能开销\n * @param entries - ResizeObserver 的观察条目数组\n */\n private _resizeCallback = (entries: ResizeObserverEntry[]): void => {\n // 只有当滚动容器发生变化时,才重新设置观察器\n const newContainer = this._findScrollContainer();\n if (newContainer !== this._scrollContainer) {\n // 1. 先清理旧容器的监听器(此时 this._scrollContainer 还指向旧容器)\n this._cleanupRefreshListeners();\n \n // 2. 更新容器引用并重新设置无限滚动观察器\n this._setupObserver(newContainer);\n \n // 3. 在新容器上重新设置监听器(如果启用了刷新且在移动端)\n if (this._enableRefresh && this._isMobile) {\n this._setupRefreshListeners();\n }\n }\n };\n\n /**\n * 检测当前环境是否为移动端\n */\n private get _isMobile(): boolean {\n return 'ontouchstart' in window || navigator.maxTouchPoints > 0;\n }\n\n /**\n * 设置下拉刷新的事件监听器\n */\n private _setupRefreshListeners(): void {\n if (!this._isMobile) return;\n \n const target = (this._scrollContainer || document.body) as EventTarget;\n \n // 直接绑定方法名,箭头函数会自动处理 this\n target.addEventListener('touchstart', this._handleTouchStart as EventListener, { passive: true });\n target.addEventListener('touchmove', this._handleTouchMove as EventListener, { passive: false });\n target.addEventListener('touchend', this._handleTouchEnd as EventListener);\n }\n\n /**\n * 清理下拉刷新的事件监听器\n */\n private _cleanupRefreshListeners(): void {\n const target = (this._scrollContainer || document.body) as EventTarget;\n \n target.removeEventListener('touchstart', this._handleTouchStart as EventListener);\n target.removeEventListener('touchmove', this._handleTouchMove as EventListener);\n target.removeEventListener('touchend', this._handleTouchEnd as EventListener);\n }\n\n /**\n * 处理 touchstart 事件\n * 检测是否在滚动容器顶部,如果是则开始下拉刷新流程\n * @param e - TouchEvent 对象\n */\n private _handleTouchStart = (e: TouchEvent): void => {\n // 检查是否启用刷新\n if (!this._enableRefresh) return;\n \n // 检查是否正在刷新中\n if (this._isRefreshing) return;\n \n // 使用缓存的滚动容器获取滚动位置\n let scrollTop: number;\n \n if (this._scrollContainer) {\n // 在滚动容器中\n scrollTop = this._scrollContainer.scrollTop;\n } else {\n // 在 body 中滚动,需要兼容处理\n scrollTop = document.documentElement.scrollTop || document.body.scrollTop || 0;\n }\n \n // 只有在滚动条位于顶部时才开启下拉逻辑\n if (scrollTop <= 0) {\n this._startY = e.touches[0].pageY;\n this._isPulling = true;\n if (this._refreshContainer) {\n this._refreshContainer.style.transition = 'none';\n }\n }\n };\n\n /**\n * 处理 touchmove 事件\n * 计算下拉距离,应用阻尼效果,更新刷新容器高度\n * 派发 refresh-pulling 事件,让外部感知下拉进度\n * @param e - TouchEvent 对象\n */\n private _handleTouchMove = (e: TouchEvent): void => {\n if (!this._isPulling || !this._refreshContainer) return;\n\n const currentY = e.touches[0].pageY;\n const diff = currentY - this._startY;\n\n if (diff > 0) {\n // 阻止浏览器默认下拉刷新\n if (e.cancelable) e.preventDefault();\n \n // 阻尼计算 + 最大距离限制\n const maxPull = this._refreshThreshold * 2; // 最大下拉距离为阈值的2倍\n const dampingHeight = Math.min(\n Math.pow(diff, 0.85), \n maxPull\n );\n \n this._refreshContainer.style.height = `${dampingHeight}px`;\n \n // 派发 pulling 事件,让外部感知进度(例如旋转图标)\n this.dispatchEvent(new CustomEvent('refresh-pulling', {\n bubbles: true,\n composed: true,\n detail: { \n distance: dampingHeight,\n threshold: this._refreshThreshold,\n progress: Math.min(dampingHeight / this._refreshThreshold, 1)\n }\n }));\n }\n };\n\n /**\n * 处理 touchend 事件\n * 判断下拉距离是否达到阈值,如果达到则触发刷新事件,否则回弹\n * @param e - TouchEvent 对象\n */\n private _handleTouchEnd = (e: TouchEvent): void => {\n if (!this._isPulling || !this._refreshContainer) return;\n \n this._isPulling = false;\n this._refreshContainer.style.transition = 'height 0.3s ease';\n\n const finalHeight = parseFloat(this._refreshContainer.style.height) || 0;\n \n if (finalHeight >= this._refreshThreshold) {\n // 保持在 threshold 高度并触发刷新事件\n this._refreshContainer.style.height = `${this._refreshThreshold}px`;\n this._triggerRefresh();\n } else {\n // 回弹\n this._refreshContainer.style.height = '0';\n }\n };\n\n /**\n * 滚动到顶部并触发刷新\n * 用于程序化触发刷新,例如点击导航菜单时\n * 使用平滑滚动动画\n * @returns Promise,滚动完成后 resolve\n */\n public async scrollToTopAndRefresh(): Promise<void> {\n // 1. 检查是否启用刷新\n if (!this._enableRefresh) {\n console.warn('下拉刷新功能未启用');\n return;\n }\n \n // 2. 检查是否正在刷新中\n if (this._isRefreshing) {\n console.warn('正在刷新中,请勿重复触发');\n return;\n }\n \n // 3. 检查是否正在滚动到顶部\n if (this._isScrollingToTop) {\n console.warn('正在滚动到顶部,请勿重复触发');\n return;\n }\n \n this._isScrollingToTop = true;\n \n try {\n const scrollPromise =this.scrollToTop();\n this._triggerRefresh();\n await scrollPromise;\n } finally {\n // 给予一小段缓冲时间,确保 UI 状态更新完成\n setTimeout(() => {\n this._isScrollingToTop = false;\n }, 300);\n }\n }\n\n /**\n * 滚动到顶部\n * 使用平滑滚动动画,并等待滚动结束\n * @returns Promise,滚动完成后 resolve\n */\n public scrollToTop(): Promise<void> {\n return new Promise((resolve) => {\n const target = this._scrollContainer || window;\n \n // 执行平滑滚动\n target.scrollTo({ top: 0, behavior: 'smooth' });\n\n // 兜底超时:如果 1 秒内没检测到触顶,也强制结束\n const timeout = setTimeout(resolve, 1000);\n \n // 循环检测是否触顶\n const checkScroll = () => {\n const currentTop = this._scrollContainer \n ? this._scrollContainer.scrollTop \n : (document.documentElement.scrollTop || document.body.scrollTop || 0);\n \n if (currentTop <= 1) {\n clearTimeout(timeout);\n resolve();\n } else {\n requestAnimationFrame(checkScroll);\n }\n };\n \n requestAnimationFrame(checkScroll);\n });\n }\n\n /**\n * 触发刷新(内部方法,供下拉刷新和程序化刷新共用)\n * 显示刷新容器并触发刷新事件\n */\n private _triggerRefresh(): void {\n if (!this._refreshContainer) return;\n \n // 显示刷新容器\n this._refreshContainer.style.height = `${this._refreshThreshold}px`;\n \n // 设置刷新状态\n this._isRefreshing = true;\n this.setAttribute('is-refreshing', 'true');\n \n // 触发刷新事件\n this.dispatchEvent(new CustomEvent('refresh', {\n bubbles: true,\n composed: true\n }));\n }\n}\n\n// 注册自定义元素\ncustomElements.define('infinite-scroll-list', InfiniteScrollList);\n"],"names":["InfiniteScrollList","HTMLElement","constructor","super","this","_observerRef","_resizeObserverRef","_bottomRef","_scrollContainer","_onEndReachedThreshold","_hasNextPage","_refreshContainer","_enableRefresh","_refreshThreshold","_isRefreshing","_startY","_isPulling","_isScrollingToTop","_intersectionCallback","entries","target","dispatchEvent","CustomEvent","bubbles","composed","_resizeCallback","newContainer","_findScrollContainer","_cleanupRefreshListeners","_setupObserver","_isMobile","_setupRefreshListeners","_handleTouchStart","e","scrollTop","document","documentElement","body","touches","pageY","style","transition","_handleTouchMove","diff","cancelable","preventDefault","maxPull","dampingHeight","Math","min","pow","height","detail","distance","threshold","progress","_handleTouchEnd","parseFloat","_triggerRefresh","attachShadow","mode","innerHTML","shadowRoot","querySelector","observedAttributes","attributeChangedCallback","name","oldValue","newValue","Number","transform","wasRefreshing","connectedCallback","scrollContainer","ResizeObserver","parentElement","observe","disconnectedCallback","disconnect","parent","overflowY","window","getComputedStyle","includes","container","IntersectionObserver","root","navigator","maxTouchPoints","addEventListener","passive","removeEventListener","scrollToTopAndRefresh","scrollPromise","scrollToTop","setTimeout","Promise","resolve","scrollTo","top","behavior","timeout","checkScroll","clearTimeout","requestAnimationFrame","setAttribute","customElements","define"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wc-lib/infinite-scroll-list",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "Web Component for infinite scroll loading",
5
5
  "type": "module",
6
6
  "main": "dist/infinite-scroll-list.min.js",
@@ -10,6 +10,12 @@
10
10
  "LICENSE",
11
11
  "README.md"
12
12
  ],
13
+ "scripts": {
14
+ "build": "rollup -c",
15
+ "dev": "rollup -c -w",
16
+ "serve": "pnpm run dev & http-server -p 8080",
17
+ "prepublishOnly": "npm run build"
18
+ },
13
19
  "keywords": [
14
20
  "web-component",
15
21
  "infinite-scroll",
@@ -28,9 +34,5 @@
28
34
  "publishConfig": {
29
35
  "access": "public"
30
36
  },
31
- "scripts": {
32
- "build": "rollup -c",
33
- "dev": "rollup -c -w",
34
- "serve": "http-server -p 8080"
35
- }
36
- }
37
+ "packageManager": "pnpm@10.20.0+sha512.cf9998222162dd85864d0a8102e7892e7ba4ceadebbf5a31f9c2fce48dfce317a9c53b9f6464d1ef9042cba2e02ae02a9f7c143a2b438cd93c91840f0192b9dd"
38
+ }