├── .prettierrc ├── .husky ├── pre-commit └── commit-msg ├── lib ├── main.ts └── LoopScroll │ ├── index.ts │ ├── hooks │ ├── index.ts │ ├── useForceUpdate.ts │ ├── useResizeObserver.ts │ └── useCancellableTask.ts │ ├── utils │ ├── generateUniqueId.ts │ ├── index.ts │ ├── isPlainObject.ts │ ├── withResolvers.ts │ ├── traverseArray.ts │ ├── getPaddingBoxSize.ts │ └── delayTask.ts │ ├── types │ ├── index.ts │ ├── viewport.ts │ ├── data.ts │ ├── layout.ts │ └── props.ts │ ├── __tests__ │ └── index.test.ts │ └── index.vue ├── .prettierignore ├── .vscode ├── extensions.json └── settings.json ├── commitlint.config.js ├── tsconfig.json ├── stylelint.config.js ├── .gitignore ├── .changeset ├── config.json └── README.md ├── vitest.config.ts ├── tsconfig.node.json ├── tsconfig.app.json ├── CONTRIBUTING-CN.md ├── LICENSE ├── .github └── workflows │ ├── version.yml │ └── release.yml ├── vite.config.ts ├── eslint.config.js ├── CHANGELOG.md ├── CONTRIBUTING.md ├── package.json ├── README.zh-CN.md └── README.md /.prettierrc: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | pnpm lint-staged 2 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | pnpm commitlint --edit $1 2 | -------------------------------------------------------------------------------- /lib/main.ts: -------------------------------------------------------------------------------- 1 | export * from "./LoopScroll"; 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Ignore artifacts: 2 | build 3 | coverage 4 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["Vue.volar"] 3 | } 4 | -------------------------------------------------------------------------------- /lib/LoopScroll/index.ts: -------------------------------------------------------------------------------- 1 | export { default as LoopScroll } from "./index.vue"; 2 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | export default { extends: ['@commitlint/config-conventional'] }; 2 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "editor.defaultFormatter": "esbenp.prettier-vscode" 4 | } 5 | -------------------------------------------------------------------------------- /lib/LoopScroll/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./useCancellableTask"; 2 | export * from "./useForceUpdate"; 3 | export * from "./useResizeObserver"; 4 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { "path": "./tsconfig.app.json" }, 5 | { "path": "./tsconfig.node.json" } 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /lib/LoopScroll/utils/generateUniqueId.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 生成自增的唯一ID字符串(从0开始计数) 3 | * @returns {string} 格式如 "0", "1", "2"... 4 | */ 5 | 6 | let uid = 0; 7 | export const generateUniqueId = () => String(uid++); 8 | -------------------------------------------------------------------------------- /lib/LoopScroll/types/index.ts: -------------------------------------------------------------------------------- 1 | // 组件属性 2 | export * from "./props"; 3 | 4 | // 数据结构 5 | export * from "./data"; 6 | 7 | // 布局计算 8 | export * from "./layout"; 9 | 10 | // 视口分析 11 | export * from "./viewport"; 12 | -------------------------------------------------------------------------------- /lib/LoopScroll/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./getPaddingBoxSize"; 2 | export * from "./generateUniqueId"; 3 | export * from "./traverseArray"; 4 | export * from "./isPlainObject"; 5 | export * from "./delayTask"; 6 | export * from "./withResolvers"; 7 | -------------------------------------------------------------------------------- /stylelint.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('stylelint').Config} */ 2 | export default { 3 | extends: [ 4 | "stylelint-config-standard", 5 | "stylelint-config-recommended-scss", 6 | "stylelint-config-recommended-vue/scss", 7 | "stylelint-config-recess-order", 8 | ], 9 | }; 10 | -------------------------------------------------------------------------------- /lib/LoopScroll/utils/isPlainObject.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 判断是否是对象 3 | * @param {any} value - 要检查的值 4 | * @returns {boolean} - 返回是否是对象 5 | */ 6 | export function isPlainObject(value: any): value is Record { 7 | const type = typeof value; 8 | return value != null && type === "object"; 9 | } 10 | -------------------------------------------------------------------------------- /lib/LoopScroll/hooks/useForceUpdate.ts: -------------------------------------------------------------------------------- 1 | import { ref } from "vue"; 2 | 3 | export const useForceUpdate = () => { 4 | const updateCounter = ref(0); 5 | const triggerUpdate = () => { 6 | updateCounter.value++; 7 | }; 8 | 9 | return { 10 | updateCounter, 11 | triggerUpdate, 12 | }; 13 | }; 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .idea 17 | .DS_Store 18 | *.suo 19 | *.ntvs* 20 | *.njsproj 21 | *.sln 22 | *.sw? 23 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@3.1.1/schema.json", 3 | "changelog": "@changesets/cli/changelog", 4 | "commit": false, 5 | "fixed": [], 6 | "linked": [], 7 | "access": "restricted", 8 | "baseBranch": "main", 9 | "updateInternalDependencies": "patch", 10 | "ignore": [] 11 | } 12 | -------------------------------------------------------------------------------- /lib/LoopScroll/types/viewport.ts: -------------------------------------------------------------------------------- 1 | import type { MarginInfo } from "."; 2 | 3 | /** 4 | * 视口锚点分析结果 5 | * @template T - 原始数据项类型 6 | */ 7 | export type ViewportAnchorResult = 8 | | { 9 | status: "found"; 10 | item: T; 11 | index: number; 12 | margins: MarginInfo; 13 | } 14 | | { 15 | status: "not-found"; 16 | }; 17 | -------------------------------------------------------------------------------- /lib/LoopScroll/types/data.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 滚动列表渲染项数据格式 3 | * @template T - 原始数据项类型 4 | */ 5 | interface ScrollItem { 6 | /**​ 原始数据值 */ 7 | value: T; 8 | /**​ 渲染项唯一键(基于数据项生成) */ 9 | key: string; 10 | /**​ DOM 层唯一标识(用于虚拟滚动) */ 11 | uid: string; 12 | } 13 | 14 | /** 15 | * 滚动列表渲染数据集 16 | * @template T - 原始数据项类型 17 | */ 18 | export type ScrollItems = ScrollItem[]; 19 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vitest/config"; 2 | import vue from "@vitejs/plugin-vue"; 3 | import vueJsx from "@vitejs/plugin-vue-jsx"; 4 | import path from "path"; 5 | 6 | export default defineConfig({ 7 | plugins: [vue(), vueJsx()], 8 | resolve: { 9 | alias: { 10 | "@": path.resolve(__dirname, "lib"), 11 | }, 12 | }, 13 | test: { 14 | globals: true, 15 | environment: "happy-dom", 16 | }, 17 | }); 18 | -------------------------------------------------------------------------------- /lib/LoopScroll/utils/withResolvers.ts: -------------------------------------------------------------------------------- 1 | interface PromiseResolvers { 2 | promise: Promise; 3 | resolve: (value: T | PromiseLike) => void; 4 | reject: (reason?: any) => void; 5 | } 6 | 7 | export function withResolvers(): PromiseResolvers { 8 | let resolve!: (value: T | PromiseLike) => void; 9 | let reject!: (reason?: any) => void; 10 | 11 | const promise = new Promise((res, rej) => { 12 | resolve = res; 13 | reject = rej; 14 | }); 15 | 16 | return { promise, resolve, reject }; 17 | } 18 | -------------------------------------------------------------------------------- /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /lib/LoopScroll/types/layout.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 列表项布局信息 3 | */ 4 | export type ItemPositions = { 5 | /**​ 数据项唯一键 */ 6 | key: string; 7 | /**​ DOM 唯一标识 */ 8 | uid: string; 9 | /**​ 项宽度(像素) */ 10 | width: number; 11 | /**​ 项高度(像素) */ 12 | height: number; 13 | /**​ 顶部位置(像素) */ 14 | top: number; 15 | /**​ 底部位置(像素) */ 16 | bottom: number; 17 | /**​ 左侧位置(像素) */ 18 | left: number; 19 | /**​ 右侧位置(像素) */ 20 | right: number; 21 | }[]; 22 | 23 | /** 24 | * 边距计算结果 25 | */ 26 | export interface MarginInfo { 27 | /**​ 项前剩余空间(像素) */ 28 | marginBefore: number; 29 | /**​ 项后剩余空间(像素) */ 30 | marginAfter: number; 31 | } 32 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", 4 | "target": "ES2022", 5 | "lib": ["ES2023"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "isolatedModules": true, 13 | "moduleDetection": "force", 14 | "noEmit": true, 15 | 16 | /* Linting */ 17 | "strict": true, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "noFallthroughCasesInSwitch": true, 21 | "noUncheckedSideEffectImports": true 22 | }, 23 | "include": ["vite.config.ts"] 24 | } 25 | -------------------------------------------------------------------------------- /lib/LoopScroll/utils/traverseArray.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 按指定方向遍历数组并执行回调 3 | * @param items - 待遍历的数组 4 | * @param callback - 遍历回调函数(参数:当前元素,元素索引) 5 | * @param direction - 遍历方向(forward: 正序,reverse: 逆序) 6 | * @template K - 数组元素类型 7 | */ 8 | export const traverseArray = ( 9 | items: K[], // 原参数名 arr → items 10 | callback: (item: K, index: number) => void, 11 | direction: "forward" | "reverse" = "forward", 12 | ) => { 13 | const maxIndex = items.length - 1; 14 | let currentIndex = 0; 15 | 16 | if (direction === "forward") { 17 | while (currentIndex <= maxIndex) { 18 | callback(items[currentIndex], currentIndex); 19 | currentIndex++; 20 | } 21 | } else { 22 | let reverseIndex = maxIndex; 23 | while (reverseIndex >= 0) { 24 | callback(items[reverseIndex], reverseIndex); 25 | reverseIndex--; 26 | } 27 | } 28 | }; 29 | -------------------------------------------------------------------------------- /lib/LoopScroll/utils/getPaddingBoxSize.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 获取元素内容区域尺寸(包含内边距,排除边框) 3 | * @param {HTMLElement} element 4 | * @returns { {width: number, height: number} } 5 | */ 6 | export function getPaddingBoxSize(element: HTMLElement | null) { 7 | if (!element?.offsetParent) return { width: 0, height: 0 }; 8 | 9 | // 获取元素完整尺寸(包含边框) 10 | const rect = element.getBoundingClientRect(); 11 | 12 | // 获取计算样式(用于提取边框宽度) 13 | const computedStyle = window.getComputedStyle(element); 14 | 15 | // 解析边框宽度(处理 NaN 情况) 16 | const parseBorder = (width: string) => parseFloat(width) || 0; 17 | const borderX = 18 | parseBorder(computedStyle.borderLeftWidth) + 19 | parseBorder(computedStyle.borderRightWidth); 20 | const borderY = 21 | parseBorder(computedStyle.borderTopWidth) + 22 | parseBorder(computedStyle.borderBottomWidth); 23 | 24 | return { 25 | width: rect.width - borderX, // 宽度 = 总宽度 - 左右边框 26 | height: rect.height - borderY, // 高度 = 总高度 - 上下边框 27 | }; 28 | } 29 | -------------------------------------------------------------------------------- /tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@vue/tsconfig/tsconfig.dom.json", 3 | "compilerOptions": { 4 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", 5 | 6 | "target": "ESNext", 7 | "module": "ESNext", 8 | "lib": ["ESNext", "DOM", "DOM.Iterable"], 9 | "skipLibCheck": true, 10 | "useDefineForClassFields": true, 11 | 12 | /* Bundler mode */ 13 | "moduleResolution": "Bundler", 14 | "allowImportingTsExtensions": true, 15 | "isolatedModules": true, 16 | "moduleDetection": "force", 17 | "noEmit": true, 18 | "jsx": "preserve", 19 | "jsxImportSource": "vue", 20 | "jsxFactory": "h", 21 | "jsxFragmentFactory": "Fragment", 22 | "baseUrl": ".", 23 | "paths": { 24 | "@/*": ["lib/*"] 25 | }, 26 | 27 | /* Linting */ 28 | "strict": true, 29 | "noUnusedLocals": true, 30 | "noUnusedParameters": true, 31 | "noFallthroughCasesInSwitch": true, 32 | "noUncheckedSideEffectImports": true 33 | }, 34 | "include": ["lib/**/*.ts", "lib/**/*.tsx", "lib/**/*.vue", "lib/**/*.d.ts"] 35 | } 36 | -------------------------------------------------------------------------------- /lib/LoopScroll/hooks/useResizeObserver.ts: -------------------------------------------------------------------------------- 1 | import { 2 | computed, 3 | type MaybeRefOrGetter, 4 | onBeforeUnmount, 5 | toValue, 6 | watch, 7 | } from "vue"; 8 | 9 | export function useResizeObserver( 10 | target: MaybeRefOrGetter, 11 | callback: (entries: ResizeObserverEntry[]) => void, 12 | ) { 13 | let observer: ResizeObserver | null = null; 14 | const isSupported = computed(() => "ResizeObserver" in window); 15 | 16 | const cleanup = () => { 17 | if (observer) { 18 | observer.disconnect(); 19 | observer = null; 20 | } 21 | }; 22 | 23 | const stopWatch = watch( 24 | () => toValue(target), 25 | (el) => { 26 | cleanup(); 27 | if (el && isSupported.value) { 28 | observer = new window.ResizeObserver(callback); 29 | observer.observe(el); 30 | } 31 | }, 32 | { immediate: true, flush: "post" }, 33 | ); 34 | 35 | onBeforeUnmount(() => { 36 | stopWatch(); 37 | cleanup(); 38 | }); 39 | 40 | const stop = () => { 41 | stopWatch(); 42 | }; 43 | 44 | return { isSupported, stop }; 45 | } 46 | -------------------------------------------------------------------------------- /CONTRIBUTING-CN.md: -------------------------------------------------------------------------------- 1 | # 贡献指南 2 | 3 | **中文** | [English](./CONTRIBUTING.md) 4 | 5 | 感谢您有兴趣为 Feutopia 做出贡献!在提交您的贡献之前,请阅读以下指南。 6 | 7 | ## 🔧 开发环境设置 8 | 9 | ### 1. 克隆仓库 10 | 11 | ```bash 12 | git clone https://github.com/joydayX/vue-loop-scroll.git 13 | ``` 14 | 15 | ### 2. 安装依赖 16 | 17 | ```bash 18 | pnpm install 19 | ``` 20 | 21 | ### 3. 启动开发服务器 22 | 23 | ```bash 24 | pnpm dev 25 | ``` 26 | 27 | ## 📝 Pull Request 规范 28 | 29 | - 从 `main` 分支检出一个主题分支,并在该分支上进行开发 30 | - 在 `lib` 文件夹中工作,不要在提交中包含 `dist` 目录 31 | - 如果添加新功能: 32 | - 添加相应的测试用例 33 | - 提供添加此功能的充分理由 34 | - 如果修复 bug: 35 | - 在 PR 中提供 bug 的详细描述 36 | - 添加适当的测试覆盖(如果适用) 37 | 38 | ## 🔍 代码风格 39 | 40 | - 遵循 [Vue 风格指南](https://vuejs.org/style-guide/) 41 | - 使用 TypeScript 42 | - 运行 `pnpm lint` 并修复任何代码格式问题 43 | - 运行 `pnpm test` 并确保所有测试通过 44 | 45 | ## 📦 提交规范 46 | 47 | 提交信息应该遵循 [Conventional Commits](https://www.conventionalcommits.org/) 规范: 48 | 49 | - `feat`: 新功能 50 | - `fix`: Bug 修复 51 | - `docs`: 仅文档更改 52 | - `style`: 不影响代码含义的更改 53 | - `refactor`: 既不修复 bug 也不添加新功能的代码更改 54 | - `perf`: 提高性能的代码更改 55 | - `test`: 添加缺失的测试 56 | - `chore`: 构建过程或辅助工具的变动 57 | 58 | 示例:`feat: 添加新的 Button 组件` 59 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | # MIT License 2 | 3 | Copyright (c) 2025 joydayX 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /lib/LoopScroll/types/props.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 尺寸对象 3 | * @remarks 用于描述组件的高度和宽度 4 | */ 5 | export type Size = { 6 | height: number; 7 | width: number; 8 | }; 9 | 10 | /** 11 | * 滚动列表组件属性配置 12 | * @template T - 数据项类型 13 | * @template K - 数据项唯一键字段类型(可选) 14 | */ 15 | export interface ScrollProps { 16 | /**​ 17 | * 原始数据源 18 | * @example 19 | * [{ id: 1, text: "Item1" }, { id: 2, text: "Item2" }] 20 | */ 21 | dataSource: T[]; 22 | 23 | /**​ 24 | * 数据项唯一标识字段名 25 | * @defaultValue "id" 26 | */ 27 | itemKey?: K; 28 | 29 | /**​ 30 | * 滚动方向 31 | * @defaultValue "up" 32 | */ 33 | direction?: "up" | "down" | "left" | "right"; 34 | 35 | /**​ 36 | * 滚动速度系数(像素/帧) 37 | * @remarks 值越大滚动越快 38 | * @defaultValue 1 39 | */ 40 | speed?: number; 41 | 42 | /** 43 | * 配合 waitTime 使用,控制滚动后是否暂停的模式: 44 | * - 'item':每滚动一条等待(默认) 45 | * - 'page':每滚动一页(等于可视条数)等待 46 | */ 47 | waitMode?: "item" | "page"; 48 | 49 | /**​ 50 | * 滚动暂停时间(毫秒) 51 | * @defaultValue 0(不暂停) 52 | */ 53 | waitTime?: number; 54 | 55 | /**​ 56 | * 是否启用悬停暂停 57 | * @defaultValue true 58 | */ 59 | pausedOnHover?: boolean; 60 | 61 | /**​ 62 | * 批量加载数据量 63 | * @remarks 影响滚动平滑度和性能 64 | * @defaultValue 10 65 | */ 66 | loadCount?: number; 67 | } 68 | -------------------------------------------------------------------------------- /.github/workflows/version.yml: -------------------------------------------------------------------------------- 1 | # 工作流名称 2 | name: Version 3 | 4 | # 当推送到 release/ 开头的分支时触发 5 | on: 6 | push: 7 | branches: 8 | - release/** 9 | workflow_dispatch: # 允许手动触发 10 | 11 | # 授予工作流权限 12 | permissions: write-all 13 | 14 | # 工作流任务 15 | jobs: 16 | version: # 任务ID 17 | name: Version 18 | runs-on: ubuntu-latest # 运行环境 19 | 20 | strategy: 21 | matrix: 22 | node-version: [18] # Node 环境版本 23 | 24 | # 执行步骤 25 | steps: 26 | # 1. 检出代码 27 | - name: Checkout Branch 28 | uses: actions/checkout@v3 29 | 30 | # 2. 安装 pnpm 31 | - name: Install pnpm 32 | uses: pnpm/action-setup@v4 33 | 34 | # 3. 设置 Node.js 环境 35 | - name: Use Node.js ${{ matrix.node-version }} 36 | uses: actions/setup-node@v3 37 | with: 38 | node-version: ${{ matrix.node-version }} 39 | cache: 'pnpm' 40 | 41 | # 4. 安装项目依赖 42 | - name: Install Dependencies 43 | run: pnpm install 44 | 45 | # 5. 创建版本更新 PR 46 | - name: Create Release Pull Request 47 | uses: changesets/action@v1 48 | with: 49 | version: pnpm run pub:version # 自定义版本命令 50 | commit: 'chore: update versions' # 提交信息 51 | title: 'chore: update versions' # PR 标题 52 | env: # 环境变量 53 | GITHUB_TOKEN: ${{ secrets.PERSONAL_GITHUB_TOKEN }} 54 | -------------------------------------------------------------------------------- /lib/LoopScroll/utils/delayTask.ts: -------------------------------------------------------------------------------- 1 | import { withResolvers } from "."; 2 | 3 | type DelayResult = boolean; 4 | 5 | export type DelayTaskPromise = Promise & { 6 | isRunning: boolean; 7 | }; 8 | 9 | const createDelay = (ms: number, callback?: () => void) => { 10 | const timeoutId = setTimeout(() => callback?.(), ms); 11 | return () => clearTimeout(timeoutId); 12 | }; 13 | 14 | const cancelStore = new WeakMap void>(); 15 | 16 | function createDelayTask(ms: number, callback?: () => void): DelayTaskPromise { 17 | let isRunning = true; 18 | const { resolve, promise } = withResolvers(); 19 | 20 | const done = () => { 21 | resolve(false); 22 | callback?.(); 23 | }; 24 | 25 | const stop = createDelay(ms, done); 26 | 27 | const delayTaskPromise = Object.defineProperty(promise, "isRunning", { 28 | get: () => isRunning, 29 | }) as DelayTaskPromise; 30 | 31 | cancelStore.set(delayTaskPromise, () => { 32 | if (isRunning) { 33 | isRunning = false; 34 | stop(); 35 | resolve(true); 36 | } 37 | }); 38 | 39 | promise.finally(() => { 40 | isRunning = false; 41 | cancelStore.delete(delayTaskPromise); 42 | }); 43 | 44 | return delayTaskPromise; 45 | } 46 | 47 | function cancelDelayTask(promise: DelayTaskPromise) { 48 | cancelStore.get(promise)?.(); 49 | } 50 | 51 | export { createDelayTask, cancelDelayTask }; 52 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import vue from "@vitejs/plugin-vue"; 3 | import dts from "vite-plugin-dts"; 4 | import vueJsx from "@vitejs/plugin-vue-jsx"; 5 | import { libInjectCss } from "vite-plugin-lib-inject-css"; 6 | import { resolve } from "path"; 7 | 8 | export default defineConfig({ 9 | plugins: [ 10 | vue(), 11 | vueJsx(), 12 | libInjectCss(), 13 | dts({ 14 | tsconfigPath: "./tsconfig.app.json", 15 | exclude: ["lib/**/__tests__/"], 16 | rollupTypes: true, // 将所有的类型声明打包到一个文件中 17 | }), 18 | ], 19 | esbuild: { 20 | drop: ["console", "debugger"], // 删除console和debugger 21 | }, 22 | build: { 23 | lib: { 24 | entry: resolve(__dirname, "lib/main.ts"), 25 | name: "VueLoopScroll", 26 | formats: ["es", "cjs", "umd"], 27 | fileName: (format) => { 28 | const formatMap = { 29 | es: "esm", 30 | cjs: "cjs", 31 | umd: "umd", 32 | }; 33 | const directory = formatMap[format as keyof typeof formatMap]; 34 | return `${directory}/index.js`; 35 | }, 36 | }, 37 | rollupOptions: { 38 | external: ["vue", "vue/jsx-runtime"], 39 | output: { 40 | globals: { 41 | vue: "Vue", 42 | }, 43 | assetFileNames: "assets/[name][extname]", 44 | }, 45 | }, 46 | // 禁止复制 public 目录 47 | copyPublicDir: false, 48 | }, 49 | }); 50 | -------------------------------------------------------------------------------- /lib/LoopScroll/hooks/useCancellableTask.ts: -------------------------------------------------------------------------------- 1 | type AnyFunction = (...args: any[]) => Promise; 2 | 3 | class AsyncTaskCancelledError extends Error { 4 | readonly cancelled: boolean; 5 | 6 | constructor(message: string) { 7 | super(message); 8 | this.cancelled = true; 9 | Object.setPrototypeOf(this, AsyncTaskCancelledError.prototype); 10 | } 11 | } 12 | 13 | export const useCancellableTask = (asyncTask: T) => { 14 | let cancelCounter = 0; 15 | let isExecuting = false; 16 | 17 | const cancel = () => { 18 | if (!isExecuting) return; 19 | cancelCounter++; 20 | isExecuting = false; 21 | }; 22 | 23 | const isCancellable = () => isExecuting; 24 | 25 | const isCancelledError = (error: any) => 26 | error instanceof AsyncTaskCancelledError; 27 | 28 | const execute = async function ( 29 | this: ThisParameterType, 30 | ...args: Parameters 31 | ) { 32 | const currentCounter = cancelCounter; 33 | isExecuting = true; 34 | 35 | try { 36 | const result = await asyncTask.apply(this, args); 37 | 38 | if (currentCounter !== cancelCounter) { 39 | throw new AsyncTaskCancelledError("Async operation cancelled"); 40 | } 41 | 42 | return result; 43 | } finally { 44 | isExecuting = false; 45 | } 46 | }; 47 | 48 | return { 49 | execute, 50 | cancel, 51 | isCancellable, 52 | isCancelledError, 53 | }; 54 | }; 55 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import globals from "globals"; 2 | import pluginJs from "@eslint/js"; 3 | import tseslint from "typescript-eslint"; 4 | import pluginVue from "eslint-plugin-vue"; 5 | import eslintConfigPrettier from "eslint-config-prettier/flat"; 6 | import { defineConfig, globalIgnores } from "eslint/config"; 7 | 8 | /** @type {import('eslint').Linter.Config[]} */ 9 | export default defineConfig([ 10 | globalIgnores(["lib/**/__tests__/**"]), 11 | { files: ["**/*.{js,mjs,cjs,ts,vue}"] }, 12 | { languageOptions: { globals: globals.browser } }, 13 | pluginJs.configs.recommended, 14 | ...tseslint.configs.recommended, 15 | ...pluginVue.configs["flat/essential"], 16 | { 17 | files: ["**/*.vue"], 18 | languageOptions: { parserOptions: { parser: tseslint.parser } }, 19 | }, 20 | eslintConfigPrettier, 21 | // 自定义规则 22 | { 23 | rules: { 24 | "@typescript-eslint/no-explicit-any": "off", 25 | "no-irregular-whitespace": "off", 26 | "vue/multi-word-component-names": "off", 27 | "@typescript-eslint/no-unused-expressions": "off", 28 | "@typescript-eslint/no-unused-vars": [ 29 | "error", 30 | { 31 | args: "all", 32 | argsIgnorePattern: "^_", 33 | caughtErrors: "all", 34 | caughtErrorsIgnorePattern: "^_", 35 | destructuredArrayIgnorePattern: "^_", 36 | varsIgnorePattern: "^_", 37 | ignoreRestSiblings: true, 38 | }, 39 | ], 40 | }, 41 | }, 42 | ]); 43 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | # 工作流名称 2 | name: Release 3 | 4 | # 当推送到 main 分支时触发 5 | on: 6 | push: 7 | branches: 8 | - main 9 | workflow_dispatch: # 允许手动触发 10 | 11 | permissions: write-all 12 | 13 | jobs: 14 | release: 15 | name: Release 16 | runs-on: ubuntu-latest 17 | 18 | strategy: 19 | matrix: 20 | node-version: [18] 21 | 22 | steps: 23 | # 1. 检出代码 24 | - name: Checkout Branch 25 | uses: actions/checkout@v3 26 | 27 | # 2. 安装 pnpm 28 | - name: Install pnpm 29 | uses: pnpm/action-setup@v4 30 | 31 | # 3. 设置 Node.js 环境 32 | - name: Use Node.js ${{ matrix.node-version }} 33 | uses: actions/setup-node@v3 34 | with: 35 | node-version: ${{ matrix.node-version }} 36 | cache: 'pnpm' 37 | 38 | # 4. 安装项目依赖 39 | - name: Install Dependencies 40 | run: pnpm install 41 | 42 | # 5. 对库进行打包 43 | - name: Setup 44 | run: pnpm run pub:build 45 | 46 | # 6. 发版 47 | - name: Publish to npm 48 | id: changesets 49 | uses: changesets/action@v1 50 | with: 51 | version: pnpm run pub:version # 执行版本升级命令(生成 CHANGELOG 和更新版本号) 52 | commit: 'chore: update versions' # 版本更新后的提交信息 53 | title: 'chore: update versions' # 创建版本提交的标题 54 | publish: pnpm run pub:release # 执行实际发布命令 55 | createGithubReleases: false # 不自动创建 GitHub Release 56 | env: 57 | GITHUB_TOKEN: ${{ secrets.PERSONAL_GITHUB_TOKEN }} 58 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 59 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # @joyday/vue-loop-scroll 2 | 3 | ## 1.1.4 4 | 5 | ### Patch Changes 6 | 7 | - 687b268: fix: resolve compatibility issue with Promise.withResolvers 8 | - resolve compatibility issue with Promise.withResolvers​ 9 | 10 | ## 1.1.4-rc.0 11 | 12 | ### Patch Changes 13 | 14 | - fix: resolve compatibility issue with Promise.withResolvers 15 | 16 | ## 1.1.3 17 | 18 | ### Patch Changes 19 | 20 | - fallback to JSON.stringify(item) as itemKey when not provided 21 | 22 | ## 1.1.2 23 | 24 | ### Patch Changes 25 | 26 | - fix: prevent setting scroll speed to a value less than 1 27 | 28 | ## 1.1.1 29 | 30 | ### Patch Changes 31 | 32 | - fix: list does not clear when data-source is emptied. 33 | - fix: list does not clear when data-source is emptied 34 | 35 | ## 1.1.1-rc.0 36 | 37 | ### Patch Changes 38 | 39 | - fix: list does not clear when data-source is emptied. 40 | - fix: list does not clear when data-source is emptied 41 | 42 | ## 1.1.0 43 | 44 | ### Minor Changes 45 | 46 | - 899a5ab: add scroll pause control by page mode 47 | 48 | ### Patch Changes 49 | 50 | - 899a5ab: add scroll pause control by page mode 51 | 52 | ## 1.1.0-rc.1 53 | 54 | ### Patch Changes 55 | 56 | - add scroll pause control by page mode 57 | 58 | ## 1.1.0-rc.0 59 | 60 | ### Minor Changes 61 | 62 | - add scroll pause control by page mode 63 | 64 | ## 1.0.2 65 | 66 | ### Patch Changes 67 | 68 | - fde1c8d: Update README.md 69 | 70 | ## 1.0.1 71 | 72 | ### Patch Changes 73 | 74 | - 3abecda: docs: update README.md 75 | 76 | ## 1.0.0 77 | 78 | ### Major Changes 79 | 80 | - 5aa4f19: initial implementation 81 | - ff9247c: stable release 82 | - af0541b: init rc publish 83 | 84 | ## 1.0.0-rc.0 85 | 86 | ### Major Changes 87 | 88 | - af0541b: init rc publish 89 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guide 2 | 3 | **English** | [中文](./CONTRIBUTING-CN.md) 4 | 5 | Thank you for being interested in contributing to `vue-loop-scroll`!Before submitting your contribution,please read through the following guide. 6 | 7 | ## 🔧 Development Setup 8 | 9 | ### 1. Clone the repository 10 | 11 | ```bash 12 | git clone https://github.com/joydayX/vue-loop-scroll.git 13 | ``` 14 | 15 | ### 2. Install dependencies 16 | 17 | ```bash 18 | pnpm install 19 | ``` 20 | 21 | ### 3. Start development server 22 | 23 | ```bash 24 | pnpm dev 25 | ``` 26 | 27 | ## 📝 Pull Request Guidelines 28 | 29 | - Checkout a topic branch from `main` branch and merge back against that branch. 30 | - Work in the `lib` folder and DO NOT check in `dist` in the commits. 31 | - If adding a new feature: 32 | - Add accompanying test case 33 | - Provide a convincing reason to add this feature 34 | - If fixing a bug: 35 | - Provide a detailed description of the bug in the PR 36 | - Add appropriate test coverage if applicable 37 | 38 | ## 🔍 Code Style 39 | 40 | - Follow the [Vue Style Guide](https://vuejs.org/style-guide/) 41 | - Use TypeScript 42 | - Run `pnpm lint` and fix any linting errors 43 | - Run `pnpm test` and ensure all tests pass 44 | 45 | ## 📦 Commit Guidelines 46 | 47 | Commit messages should follow the [Conventional Commits](https://www.conventionalcommits.org/) specification: 48 | 49 | - `feat`: New feature 50 | - `fix`: Bug fix 51 | - `docs`: Documentation only changes 52 | - `style`: Changes that do not affect the meaning of the code 53 | - `refactor`: Code change that neither fixes a bug nor adds a feature 54 | - `perf`: Code change that improves performance 55 | - `test`: Adding missing tests 56 | - `chore`: Changes to the build process or auxiliary tools 57 | 58 | Example: `feat: add new Button component` 59 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@joyday/vue-loop-scroll", 3 | "private": false, 4 | "version": "1.1.4", 5 | "description": "A high-performance Vue component for loop scrolling, supporting large data sets, adaptive resizing, real-time data updates, and flexible scrolling controls.", 6 | "keywords": [ 7 | "vue", 8 | "vuejs", 9 | "ui", 10 | "seamless", 11 | "sroll", 12 | "loop scroll", 13 | "seamless scroll", 14 | "vue component" 15 | ], 16 | "files": [ 17 | "dist" 18 | ], 19 | "publishConfig": { 20 | "access": "public" 21 | }, 22 | "sideEffects": [ 23 | "**/*.css" 24 | ], 25 | "scripts": { 26 | "pub:changeset": "pnpm changeset", 27 | "pub:build": "pnpm run typecheck && pnpm run test --run && pnpm run build:vite", 28 | "pub:alpha": "pnpm changeset pre enter alpha", 29 | "pub:beta": "pnpm changeset pre enter beta", 30 | "pub:rc": "pnpm changeset pre enter rc", 31 | "pub:exit-pre": "pnpm changeset pre exit", 32 | "pub:version": "pnpm changeset version", 33 | "pub:release": "pnpm changeset publish", 34 | "typecheck": "vue-tsc --noEmit", 35 | "lint": "eslint --fix --ext .ts,.vue ./lib", 36 | "test": "vitest", 37 | "build:vite": "vue-tsc -p ./tsconfig.json && vite build", 38 | "prepare": "husky" 39 | }, 40 | "type": "module", 41 | "main": "./dist/cjs/index.js", 42 | "module": "./dist/esm/index.js", 43 | "types": "./dist/index.d.ts", 44 | "exports": { 45 | ".": { 46 | "types": "./dist/index.d.ts", 47 | "umd": "./dist/umd/index.js", 48 | "import": "./dist/esm/index.js", 49 | "require": "./dist/cjs/index.js" 50 | } 51 | }, 52 | "author": "David Zheng", 53 | "homepage": "https://joydayX.github.io/website-vue-loop-scroll/", 54 | "repository": { 55 | "type": "git", 56 | "url": "git+https://github.com/joydayX/vue-loop-scroll.git" 57 | }, 58 | "bugs": { 59 | "url": "https://github.com/joydayX/vue-loop-scroll/issues" 60 | }, 61 | "lint-staged": { 62 | "*.{js,jsx,ts,tsx,vue}": [ 63 | "eslint --fix" 64 | ], 65 | "*.css": [ 66 | "stylelint --fix" 67 | ], 68 | "*.md": [ 69 | "prettier --write" 70 | ] 71 | }, 72 | "husky": { 73 | "hooks": { 74 | "prepare-commit-msg": "exec < /dev/tty && pnpm cz --hook || true" 75 | } 76 | }, 77 | "packageManager": "pnpm@7.15.0", 78 | "license": "MIT", 79 | "peerDependencies": { 80 | "vue": ">=3.0.0" 81 | }, 82 | "devDependencies": { 83 | "@changesets/cli": "^2.28.1", 84 | "@commitlint/cli": "^19.8.0", 85 | "@commitlint/config-conventional": "^19.8.0", 86 | "@eslint/js": "^9.22.0", 87 | "@types/node": "^22.13.10", 88 | "@vitejs/plugin-vue": "^5.2.1", 89 | "@vitejs/plugin-vue-jsx": "^4.1.2", 90 | "@vue/test-utils": "^2.4.6", 91 | "@vue/tsconfig": "^0.7.0", 92 | "commitizen": "^4.3.1", 93 | "commitlint": "^19.8.0", 94 | "cz-conventional-changelog": "^3.3.0", 95 | "eslint": "^9.22.0", 96 | "eslint-config-prettier": "^10.1.1", 97 | "eslint-plugin-vue": "^10.0.0", 98 | "globals": "^16.0.0", 99 | "happy-dom": "^17.4.4", 100 | "husky": "^9.1.7", 101 | "lint-staged": "^15.5.0", 102 | "postcss": "^8.5.3", 103 | "postcss-html": "^1.8.0", 104 | "postcss-lit": "^1.2.0", 105 | "prettier": "3.5.3", 106 | "stylelint": "^16.16.0", 107 | "stylelint-config-recess-order": "^6.0.0", 108 | "stylelint-config-recommended-scss": "^14.1.0", 109 | "stylelint-config-recommended-vue": "^1.6.0", 110 | "stylelint-config-standard": "^37.0.0", 111 | "stylelint-config-standard-scss": "^14.0.0", 112 | "typescript": "~5.7.2", 113 | "typescript-eslint": "^8.26.1", 114 | "vite": "^6.2.0", 115 | "vite-plugin-dts": "^4.5.3", 116 | "vite-plugin-lib-inject-css": "^2.2.1", 117 | "vitest": "^3.0.9", 118 | "vue": "^3.5.13", 119 | "vue-eslint-parser": "^10.1.1", 120 | "vue-tsc": "^2.2.4" 121 | }, 122 | "config": { 123 | "commitizen": { 124 | "path": "./node_modules/cz-conventional-changelog" 125 | } 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /README.zh-CN.md: -------------------------------------------------------------------------------- 1 | # Vue Loop Scroll 2 | 3 | [![Commitizen friendly](https://img.shields.io/badge/commitizen-friendly-brightgreen.svg)](http://commitizen.github.io/cz-cli/) 4 | 5 | **中文** | [English](./README.md) 6 | 7 | ## 🚀 特性 8 | 9 | - 🔥 超大数据流畅滚动 10 | - 即使 10 万条数据,也能丝滑滚动不卡顿!仅渲染可视区域的 2 倍数据,大幅减少 DOM 负担,让滚动更流畅。 11 | - 🌟 适应变化,始终顺滑 12 | - 支持容器大小动态调整,即使数据实时更新,依然能保持平滑滚动,提供最佳用户体验。 13 | - 🔧 灵活滚动控制 14 | - 支持四向滚动、单步停顿、滚动速度调节、鼠标悬停控制等多种配置,让滚动更符合需求。 15 | 16 | ## 文档 17 | 18 | 19 | 20 | ## 📦 安装 21 | 22 | ```bash 23 | # npm 24 | npm i @joyday/vue-loop-scroll 25 | # pnpm 26 | pnpm i @joyday/vue-loop-scroll 27 | # yarn 28 | yarn add @joyday/vue-loop-scroll 29 | ``` 30 | 31 | ## 示例 32 | 33 | ### 1. 四个方向滚动 34 | 35 | ![Scroll in Direction Up](https://ik.imagekit.io/uxo1w62ii/npm/vue-loop-scroll/%E5%90%91%E4%B8%8A%E6%BB%9A%E5%8A%A8.gif?updatedAt=1744700804839) 36 | 37 | ![Scroll in Direction Down](https://ik.imagekit.io/uxo1w62ii/npm/vue-loop-scroll/%E5%90%91%E4%B8%8B%E6%BB%9A%E5%8A%A8.gif?updatedAt=1744700865824) 38 | 39 | ![Scroll in Direction Left](https://ik.imagekit.io/uxo1w62ii/npm/vue-loop-scroll/%E5%90%91%E5%B7%A6%E6%BB%9A%E5%8A%A8.gif?updatedAt=1744700825999) 40 | 41 | ![Scroll in Direction Right](https://ik.imagekit.io/uxo1w62ii/npm/vue-loop-scroll/%E5%90%91%E5%8F%B3%E6%BB%9A%E5%8A%A8.gif?updatedAt=1744700866570) 42 | 43 | ### 2. 滚动停顿 44 | 45 | #### 单步滚动暂停 46 | 47 | ![Step-by-Step Pause-1](https://ik.imagekit.io/uxo1w62ii/npm/vue-loop-scroll/%E5%8D%95%E6%AD%A5%E6%BB%9A%E5%8A%A8%E5%81%9C%E9%A1%BF-1.gif?updatedAt=1744700886956) 48 | 49 | ![Step-by-Step Pause-2](https://ik.imagekit.io/uxo1w62ii/npm/vue-loop-scroll/%E5%8D%95%E6%AD%A5%E6%BB%9A%E5%8A%A8%E5%81%9C%E9%A1%BF-2.gif?updatedAt=1744700886365) 50 | 51 | #### 翻页滚动暂停 52 | 53 | ![Step-by-Step Pause-3](https://ik.imagekit.io/uxo1w62ii/npm/vue-loop-scroll/%E6%BB%9A%E5%8A%A8%E5%81%9C%E9%A1%BF-%E6%8C%89%E9%A1%B5%E6%BB%9A%E5%8A%A8.gif?updatedAt=1744700948853) 54 | 55 | ### 3. 自适应视口大小 & 动态数据更新 56 | 57 | ![Responsive Viewport & Dynamic Data Update](https://ik.imagekit.io/uxo1w62ii/npm/vue-loop-scroll/%E8%87%AA%E9%80%82%E5%BA%94%E8%A7%86%E5%8F%A3%E5%A4%A7%E5%B0%8F%E5%92%8C%E5%8A%A8%E6%80%81%E6%95%B0%E6%8D%AE%E6%9B%B4%E6%96%B0.gif?updatedAt=1744700971941) 58 | 59 | ## 🦄 使用 60 | 61 | ### 1. 基础用法 62 | 63 | 组件的基本使用方法。 64 | 65 | ```html 66 | 77 | 78 | 83 | 84 | 94 | ``` 95 | 96 | ### 2. 自定义用法 97 | 98 | 可以使用插槽自定义渲染内容。 99 | 100 | ```html 101 | 112 | 113 | 122 | 123 | 133 | ``` 134 | 135 | ### 3. 高级用法 136 | 137 | 可以配置滚动方向、单步暂停时间,并为数据项指定唯一标识键。 138 | 139 | ```html 140 | 151 | 152 | 161 | 162 | 171 | ``` 172 | 173 | ## 贡献指南 174 | 175 | 欢迎参与贡献!请阅读[贡献指南](./CONTRIBUTING.md)了解详情。 176 | 177 | ## 开源协议 178 | 179 | 本项目基于 [MIT 协议](./LICENSE) 授权。 180 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Vue Loop Scroll 2 | 3 | [![Commitizen friendly](https://img.shields.io/badge/commitizen-friendly-brightgreen.svg)](http://commitizen.github.io/cz-cli/) 4 | 5 | **English** | [中文](./README.zh-CN.md) 6 | 7 | ## 🚀 Features 8 | 9 | - 🔥 Ultra-Smooth Scrolling for Large Data Sets 10 | - Efficiently handles large data sets, rendering only visible content for smooth performance. 11 | - 🌟 Adaptive & Seamless with Dynamic Changes 12 | - Seamlessly adapts to container size changes and real-time data updates, ensuring a consistently smooth scrolling experience. 13 | - 🔧 Flexible Scrolling Controls 14 | - Offers flexible controls including four-direction scrolling, step pauses, speed adjustments, and hover interactions. 15 | 16 | ## Documentation 17 | 18 | 19 | 20 | ## 📦 Install 21 | 22 | ```bash 23 | # npm 24 | npm i @joyday/vue-loop-scroll 25 | # pnpm 26 | pnpm i @joyday/vue-loop-scroll 27 | # yarn 28 | yarn add @joyday/vue-loop-scroll 29 | ``` 30 | 31 | ## Demo 32 | 33 | ### 1. Scroll in All Directions 34 | 35 | ![Scroll in Direction Up](https://ik.imagekit.io/uxo1w62ii/npm/vue-loop-scroll/%E5%90%91%E4%B8%8A%E6%BB%9A%E5%8A%A8.gif?updatedAt=1744700804839) 36 | 37 | ![Scroll in Direction Down](https://ik.imagekit.io/uxo1w62ii/npm/vue-loop-scroll/%E5%90%91%E4%B8%8B%E6%BB%9A%E5%8A%A8.gif?updatedAt=1744700865824) 38 | 39 | ![Scroll in Direction Left](https://ik.imagekit.io/uxo1w62ii/npm/vue-loop-scroll/%E5%90%91%E5%B7%A6%E6%BB%9A%E5%8A%A8.gif?updatedAt=1744700825999) 40 | 41 | ![Scroll in Direction Right](https://ik.imagekit.io/uxo1w62ii/npm/vue-loop-scroll/%E5%90%91%E5%8F%B3%E6%BB%9A%E5%8A%A8.gif?updatedAt=1744700866570) 42 | 43 | ### 2. Step-by-Step Pause 44 | 45 | #### Item-based Pause 46 | 47 | ![Step-by-Step Pause-1](https://ik.imagekit.io/uxo1w62ii/npm/vue-loop-scroll/%E5%8D%95%E6%AD%A5%E6%BB%9A%E5%8A%A8%E5%81%9C%E9%A1%BF-1.gif?updatedAt=1744700886956) 48 | 49 | ![Step-by-Step Pause-2](https://ik.imagekit.io/uxo1w62ii/npm/vue-loop-scroll/%E5%8D%95%E6%AD%A5%E6%BB%9A%E5%8A%A8%E5%81%9C%E9%A1%BF-2.gif?updatedAt=1744700886365) 50 | 51 | #### Page-based Pause 52 | 53 | ![Step-by-Step Pause-3](https://ik.imagekit.io/uxo1w62ii/npm/vue-loop-scroll/%E6%BB%9A%E5%8A%A8%E5%81%9C%E9%A1%BF-%E6%8C%89%E9%A1%B5%E6%BB%9A%E5%8A%A8.gif?updatedAt=1744700948853) 54 | 55 | ### 3. Responsive Viewport & Dynamic Data Update 56 | 57 | ![Responsive Viewport & Dynamic Data Update](https://ik.imagekit.io/uxo1w62ii/npm/vue-loop-scroll/%E8%87%AA%E9%80%82%E5%BA%94%E8%A7%86%E5%8F%A3%E5%A4%A7%E5%B0%8F%E5%92%8C%E5%8A%A8%E6%80%81%E6%95%B0%E6%8D%AE%E6%9B%B4%E6%96%B0.gif?updatedAt=1744700971941) 58 | 59 | ## 🦄 Usage 60 | 61 | ### 1. Basic Usage 62 | 63 | The basic usage of the component. 64 | 65 | ```html 66 | 77 | 78 | 83 | 84 | 94 | ``` 95 | 96 | ### 2. Customize Usage 97 | 98 | You can customize the rendering content using `slot`. 99 | 100 | ```html 101 | 112 | 113 | 122 | 123 | 133 | ``` 134 | 135 | ### 3. Advanced Usage 136 | 137 | You can pass scrolling direction, pause time per step, and specify a unique key for each data item. 138 | 139 | ```html 140 | 151 | 152 | 161 | 162 | 171 | ``` 172 | 173 | ## Contributing 174 | 175 | Contributions are welcome! Please read our [Contributing Guide](./CONTRIBUTING.md) for details. 176 | 177 | ## License 178 | 179 | This project is licensed under the MIT License. 180 | -------------------------------------------------------------------------------- /lib/LoopScroll/__tests__/index.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; 2 | import { flushPromises, mount, VueWrapper } from "@vue/test-utils"; 3 | import { h, nextTick } from "vue"; 4 | import LoopScroll from "../index.vue"; 5 | 6 | let wrapper: VueWrapper | null = null; 7 | 8 | // Mock ResizeObserver 9 | beforeEach(() => {}); 10 | 11 | afterEach(() => { 12 | vi.clearAllMocks(); 13 | if (wrapper) { 14 | wrapper.unmount(); 15 | } 16 | }); 17 | 18 | describe("LoopScroll", () => { 19 | // Sample data for testing 20 | const dataSource = [ 21 | { id: 1, text: "Item 1" }, 22 | { id: 2, text: "Item 2" }, 23 | { id: 3, text: "Item 3" }, 24 | { id: 4, text: "Item 4" }, 25 | { id: 5, text: "Item 5" }, 26 | ]; 27 | 28 | // Test component mount and basic rendering 29 | it("should mount without errors", async () => { 30 | wrapper = mount(LoopScroll, { 31 | props: { 32 | dataSource, 33 | }, 34 | slots: { 35 | default: ({ item }: any) => h("div", { class: "test-item" }, item.text), 36 | }, 37 | }); 38 | 39 | expect(wrapper.exists()).toBe(true); 40 | 41 | await nextTick(); 42 | 43 | const items = wrapper.findAll(".test-item"); 44 | expect(items.length).toBeGreaterThan(0); 45 | expect(items[0].text()).toBe("Item 1"); 46 | }); 47 | 48 | // Test prop: dataSource 49 | it("should update when dataSource changes", async () => { 50 | const dataSourceSlice = dataSource.slice(0, 2); 51 | const wrapper = mount(LoopScroll, { 52 | props: { 53 | dataSource: dataSourceSlice, 54 | }, 55 | slots: { 56 | default: ({ item }: any) => h("div", { class: "test-item" }, item.text), 57 | }, 58 | }); 59 | 60 | await nextTick(); 61 | 62 | expect(wrapper.findAll(".scroll-loop-item").length).toBe( 63 | dataSourceSlice.length, 64 | ); 65 | 66 | // Update dataSource 67 | wrapper.setProps({ dataSource }); 68 | 69 | await flushPromises(); 70 | 71 | // Wait for component to update 72 | expect(wrapper.findAll(".scroll-loop-item").length).toBe(dataSource.length); 73 | 74 | // clear dataSource 75 | 76 | wrapper.setProps({ dataSource: [] }); 77 | 78 | await flushPromises(); 79 | 80 | expect(wrapper.findAll(".scroll-loop-item").length).toBe(0); 81 | }); 82 | 83 | // Test prop: direction 84 | it("should apply vertical/horizontal class for up/down directions", async () => { 85 | const directions = ["up", "down", "left", "right"] as const; 86 | 87 | for (const direction of directions) { 88 | const wrapper = mount(LoopScroll, { 89 | props: { 90 | dataSource, 91 | direction, 92 | }, 93 | slots: { 94 | default: ({ item }: any) => 95 | h("div", { class: "test-item" }, item.text), 96 | }, 97 | }); 98 | 99 | await nextTick(); 100 | 101 | const dirClass = ["up", "down"].includes(direction) 102 | ? ".direction-vertical" 103 | : ".direction-horizontal"; 104 | expect(wrapper.find(dirClass).exists()).toBe(true); 105 | } 106 | }); 107 | 108 | // Test prop: pausedOnHover 109 | it("should pause on hover when pausedOnHover is true", async () => { 110 | const wrapper = mount(LoopScroll, { 111 | props: { 112 | dataSource, 113 | pausedOnHover: true, 114 | }, 115 | slots: { 116 | default: ({ item }: any) => h("div", { class: "test-item" }, item.text), 117 | }, 118 | }); 119 | 120 | await nextTick(); 121 | 122 | // Trigger mouseenter event 123 | await wrapper.find(".scroll-loop-viewport").trigger("mouseenter"); 124 | 125 | // We can't directly test the internal state, but we can confirm event handlers exist 126 | expect(wrapper.props("pausedOnHover")).toBe(true); 127 | 128 | // Trigger mouseleave event 129 | await wrapper.find(".scroll-loop-viewport").trigger("mouseleave"); 130 | }); 131 | 132 | // Test component with all props specified 133 | it("should work with all props specified", async () => { 134 | const wrapper = mount(LoopScroll, { 135 | props: { 136 | dataSource, 137 | itemKey: "id" as any, 138 | direction: "up", 139 | speed: 1.5, 140 | waitMode: "page", 141 | waitTime: 500, 142 | pausedOnHover: true, 143 | loadCount: 3, 144 | }, 145 | slots: { 146 | default: ({ item }: any) => h("div", { class: "test-item" }, item.text), 147 | }, 148 | }); 149 | 150 | expect(wrapper.props()).toEqual({ 151 | dataSource, 152 | itemKey: "id" as any, 153 | direction: "up", 154 | speed: 1.5, 155 | waitMode: "page", 156 | waitTime: 500, 157 | pausedOnHover: true, 158 | loadCount: 3, 159 | }); 160 | }); 161 | 162 | // Test component behavior when dataSource is empty 163 | it("should not render when dataSource is empty", async () => { 164 | const wrapper = mount(LoopScroll, { 165 | props: { 166 | dataSource: [], 167 | itemKey: "id" as any, 168 | }, 169 | slots: { 170 | default: ({ item }: any) => `
${item?.text}
`, 171 | }, 172 | }); 173 | 174 | await nextTick(); 175 | 176 | expect(wrapper.find(".scroll-loop-viewport").exists()).toBe(false); 177 | }); 178 | 179 | // Test component unmount 180 | it("should clean up on unmount", async () => { 181 | const wrapper = mount(LoopScroll, { 182 | props: { 183 | dataSource, 184 | itemKey: "id" as any, 185 | }, 186 | slots: { 187 | default: ({ item }: any) => h("div", { class: "test-item" }, item.text), 188 | }, 189 | }); 190 | 191 | wrapper.unmount(); 192 | expect(wrapper.exists()).toBe(false); 193 | }); 194 | }); 195 | -------------------------------------------------------------------------------- /lib/LoopScroll/index.vue: -------------------------------------------------------------------------------- 1 | 941 | 942 | 969 | 970 | 997 | --------------------------------------------------------------------------------