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

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.
@@ -1,2 +1,2 @@
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)}();
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;const s=e.target;if(!s||!this.contains(s))return;let t;t=this._scrollContainer?this._scrollContainer.scrollTop:document.documentElement.scrollTop||document.body.scrollTop||0,t<=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 * 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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"}
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 const touchTarget = e.target as Node;\n if (!touchTarget || !this.contains(touchTarget)) 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","touchTarget","contains","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.3.0",
3
+ "version": "1.3.1",
4
4
  "description": "Web Component for infinite scroll loading",
5
5
  "type": "module",
6
6
  "main": "dist/infinite-scroll-list.min.js",
@@ -10,12 +10,6 @@
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
- },
19
13
  "keywords": [
20
14
  "web-component",
21
15
  "infinite-scroll",
@@ -23,6 +17,10 @@
23
17
  ],
24
18
  "author": "",
25
19
  "license": "MIT",
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "https://github.com/luokaibin/infinite-scroll-list"
23
+ },
26
24
  "devDependencies": {
27
25
  "@rollup/plugin-node-resolve": "^15.0.0",
28
26
  "@rollup/plugin-typescript": "^11.0.0",
@@ -34,5 +32,9 @@
34
32
  "publishConfig": {
35
33
  "access": "public"
36
34
  },
37
- "packageManager": "pnpm@10.20.0+sha512.cf9998222162dd85864d0a8102e7892e7ba4ceadebbf5a31f9c2fce48dfce317a9c53b9f6464d1ef9042cba2e02ae02a9f7c143a2b438cd93c91840f0192b9dd"
38
- }
35
+ "scripts": {
36
+ "build": "rollup -c",
37
+ "dev": "rollup -c -w",
38
+ "serve": "pnpm run dev & http-server -p 8080"
39
+ }
40
+ }