├── .editorconfig ├── .env ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ ├── codeql-analysis.yml │ ├── pr.yml │ └── release.yml ├── .gitignore ├── .idea ├── .gitignore ├── codeStyles │ ├── Project.xml │ └── codeStyleConfig.xml ├── inspectionProfiles │ └── Project_Default.xml ├── jsLibraryMappings.xml ├── jsonSchemas.xml └── prettier.xml ├── .npmignore ├── .releaserc.json ├── LICENSE ├── README.md ├── index.html ├── jest.config.js ├── netlify.toml ├── package-lock.json ├── package.json ├── renovate.json5 ├── src ├── Grid.vue ├── demo │ ├── App.vue │ ├── Bem.js │ ├── Control.vue │ ├── Header.vue │ ├── Logo.vue │ ├── Money.vue │ ├── Price.vue │ ├── ProductItem.vue │ ├── SmartImage.vue │ ├── main.ts │ ├── reset.css │ └── store.ts ├── index.ts ├── pipeline.test.ts ├── pipeline.ts ├── shims-vue.d.ts └── utilites.ts ├── tsconfig.json └── vite.config.ts /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: http://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | [*] 7 | indent_style = space 8 | indent_size = 2 9 | tab_width = 2 10 | end_of_line = lf 11 | charset = utf-8 12 | trim_trailing_whitespace = true 13 | insert_final_newline = true 14 | max_line_length = 80 15 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | VITE_APP_ID= 2 | VITE_SEARCH_ONLY_API_KEY= 3 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [rocwang] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "[BUG]" 5 | labels: bug 6 | assignees: rocwang 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "[FEATURE REQUEST]" 5 | labels: enhancement 6 | assignees: rocwang 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ main ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ main ] 20 | schedule: 21 | - cron: '23 13 * * 5' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'javascript' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] 37 | # Learn more: 38 | # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed 39 | 40 | steps: 41 | - name: Checkout repository 42 | uses: actions/checkout@v4 43 | 44 | # Initializes the CodeQL tools for scanning. 45 | - name: Initialize CodeQL 46 | uses: github/codeql-action/init@v2 47 | with: 48 | languages: ${{ matrix.language }} 49 | # If you wish to specify custom queries, you can do so here or in a config file. 50 | # By default, queries listed here will override any specified in a config file. 51 | # Prefix the list here with "+" to use these queries and those in the config file. 52 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 53 | 54 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 55 | # If this step fails, then you should remove it and run the build manually (see below) 56 | - name: Autobuild 57 | uses: github/codeql-action/autobuild@v2 58 | 59 | # ℹ️ Command-line programs to run using the OS shell. 60 | # 📚 https://git.io/JvXDl 61 | 62 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 63 | # and modify them (or add more) to build your code if your project 64 | # uses a compiled language 65 | 66 | #- run: | 67 | # make bootstrap 68 | # make release 69 | 70 | - name: Perform CodeQL Analysis 71 | uses: github/codeql-action/analyze@v2 72 | -------------------------------------------------------------------------------- /.github/workflows/pr.yml: -------------------------------------------------------------------------------- 1 | name: Verify PRs 2 | 3 | on: 4 | pull_request: 5 | branches: [ main ] 6 | 7 | # Allows you to run this workflow manually from the Actions tab 8 | workflow_dispatch: 9 | 10 | jobs: 11 | verification: 12 | name: Test & Build 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@v4 18 | 19 | - name: Set up Node.js 20 | uses: actions/setup-node@v4 21 | with: 22 | node-version-file: "package.json" 23 | cache: "npm" 24 | 25 | - name: Install dependencies 26 | run: npm --no-update-notifier --no-fund --no-audit ci 27 | 28 | - name: Lint 29 | run: npm run lint 30 | 31 | - name: Unit Test 32 | run: npm test 33 | 34 | - name: Build 35 | run: npm run build 36 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | 7 | # Allows you to run this workflow manually from the Actions tab 8 | workflow_dispatch: 9 | 10 | jobs: 11 | release: 12 | name: Test, Build & Release 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v4 17 | 18 | - name: Set up Node.js 19 | uses: actions/setup-node@v4 20 | with: 21 | node-version-file: "package.json" 22 | cache: "npm" 23 | 24 | - name: Install dependencies 25 | run: npm --no-update-notifier --no-fund --no-audit ci 26 | 27 | - name: Lint 28 | run: npm run lint 29 | 30 | - name: Unit Test 31 | run: npm test 32 | 33 | - name: Build 34 | run: npm run build 35 | 36 | - name: Semantic Release 37 | env: 38 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 39 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 40 | run: npm run semantic-release 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | dist 4 | dist-ssr 5 | *.local 6 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | # Datasource local storage ignored files 5 | /dataSources/ 6 | /dataSources.local.xml 7 | # Editor-based HTTP Client requests 8 | /httpRequests/ 9 | 10 | /aws.xml 11 | /misc.xml 12 | /modules.xml 13 | /vcs.xml 14 | /vue-virtual-scroll-grid.iml 15 | /git_toolbox_prj.xml 16 | 17 | -------------------------------------------------------------------------------- /.idea/codeStyles/Project.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | -------------------------------------------------------------------------------- /.idea/codeStyles/codeStyleConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | -------------------------------------------------------------------------------- /.idea/jsLibraryMappings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/jsonSchemas.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /.idea/prettier.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | * 2 | !/dist/ 3 | -------------------------------------------------------------------------------- /.releaserc.json: -------------------------------------------------------------------------------- 1 | { 2 | "branches": ["main"], 3 | "dryRun": false 4 | } 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 roc 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Virtual Scroll Grid for Vue 3 2 | 3 | This is a reusable component for Vue 3 that renders a list with a huge number of 4 | items (e.g. 1000+ items) as a grid in a performant way. 5 | 6 | - [Demo][demo] 7 | - [NPM Package][npm] 8 | 9 | ## Features 10 | 11 | - Use virtual-scrolling / windowing to render the items, so the number of DOM 12 | nodes is kept low. 13 | - Just use CSS grid to style your grid. Minimum styling opinions form the 14 | library. 15 | - Support using a paginated API to load the items in the background. 16 | - Support rendering placeholders for unloaded items. 17 | - Support both vertical and horizontal scroll. 18 | - Loaded items are cached for better performance. 19 | 20 | ## Code Examples 21 | 22 | - [As an ES module (with a bundler)][esm] 23 | - [As a Universal Module Definition (no bundler)][umd] 24 | 25 | ## Install 26 | 27 | ```shell 28 | npm install vue-virtual-scroll-grid 29 | ``` 30 | 31 | ## Available Props 32 | 33 | | Name | Description | Type | Validation | 34 | | -------------------------- | --------------------------------------------------------------------------------- | -------------------------------------------------------------- | ------------------------------------------------------------------- | 35 | | `length` | The number of items in the list | `number` | Required, an integer greater than or equal to 0 | 36 | | `pageProvider` | The callback that returns a page of items as a promise. `pageNumber` start with 0 | `(pageNumber: number, pageSize: number) => Promise` | Required | 37 | | `pageSize` | The number of items in a page from the item provider (e.g. a backend API) | `number` | Required, an integer greater than or equal to 1 | 38 | | `pageProviderDebounceTime` | Debounce window in milliseconds on the calls to `pageProvider` | `number` | Optional, an integer greater than or equal to 0, defaults to `0` | 39 | | `probeTag` | The HTML tag used as probe element. Default value is `div` | `string` | Optional, any valid HTML tag, defaults to `div` | 40 | | `respectScrollToOnResize` | Snap to the position set by `scrollTo` when the grid container is resized | `boolean` | Optional, defaults to `false` | 41 | | `scrollBehavior` | The behavior of `scrollTo`. Default value is `smooth` | `smooth` | `auto` | Optional, a string to be `smooth` or `auto`, defaults to `smooth` | 42 | | `scrollTo` | Scroll to a specific item by index | `number` | Optional, an integer from 0 to the `length` prop - 1, defaults to 0 | 43 | | `tag` | The HTML tag used as container element. Default value is `div` | `string` | Optional, any valid HTML tag, defaults to `div` | 44 | | `getKey` | The `:key` used on each grid item. Auto-generated, but overwritable via function | `(internalItem: InternalItem) => number \| string` 1| Optional, any valid Function that returns a `string` or `number` | 45 | 46 | Example: 47 | 48 | ```vue 49 | 55 | 56 | 57 | ``` 58 | 59 | ## Available Slots 60 | 61 | There are 3 scoped slots: `default`, `placeholder` and `probe`. 62 | 63 | ### The `default` slot 64 | 65 | The `default` slot is used to render a loaded item. 66 | 67 | Props: 68 | 69 | - `item`: the loaded item that is used for rendering your item 70 | element/component. 71 | - `index`: the index of current item within the list. 72 | - `style`: the style object provided by the library that need to be set on the 73 | item element/component. 74 | 75 | Example: 76 | 77 | ```vue 78 | 81 | ``` 82 | 83 | ### The`placeholder` slot 84 | 85 | When an item is not loaded, the component/element in the `placeholder` slot will 86 | be used for rendering. The `placeholder` slot is optional. If missing, the space 87 | of unloaded items will be blank until they are loaded. 88 | 89 | Props: 90 | 91 | - `index`: the index of current item within the list. 92 | - `style`: the style object provided by the library that need to be set on the 93 | item element/component. 94 | 95 | Example: 96 | 97 | ```vue 98 | 101 | ``` 102 | 103 | ### The `probe` slot 104 | 105 | The `probe` slot is used to measure the visual size of grid item. It has no 106 | prop. You can pass the same element/component for the 107 | `placeholder` slot. **If not provided, you must set a fixed height 108 | to `grid-template-rows` on your CSS grid, e.g. `200px`. If provided, make sure 109 | it is styled with the same dimensions as rendered items in the `default` 110 | or `placeholder` slot. Otherwise, the view wouldn't be rendered properly, or the 111 | rendering could be very slow.** 112 | 113 | Example: 114 | 115 | ```vue 116 | 119 | ``` 120 | 121 | ## Exposed Public Properties 122 | 123 | * `allItems`: All items memoized by the grid 124 | 125 | ## Scroll Mode 126 | 127 | The library uses `grid-auto-flow` CSS property to infer scroll mode. Set it to 128 | `column` value if you want to enable horizontal scroll. 129 | 130 | ## Caveats 131 | 132 | The library does not require items have foreknown width and height, but do 133 | require them to be styled with the same width and height under a view. E.g. the 134 | items can be 200px x 200px when the view is under 768px and 300px x 500px above 135 | 768px. 136 | 137 | ## Development 138 | 139 | Required environment variables: 140 | 141 | - `VITE_APP_ID`: An Algolia app ID 142 | - `VITE_SEARCH_ONLY_API_KEY`: The search API key for the Algolia app above 143 | 144 | * Setup: `npm install` 145 | * Run dev server: `npm run dev ` 146 | * Lint (type check): `npm run lint ` 147 | * Build the library: `npm run build ` 148 | * Build the demo: `npm run build -- --mode=demo ` 149 | * Preview the locally built demo: `npm run serve ` 150 | 151 | ### How to Release a New Version 152 | 153 | We use [semantic-release][semantic-release] to release the library on npm 154 | automatically. 155 | 156 | [demo]: https://grid.kiwiberry.nz/ 157 | [npm]: https://www.npmjs.com/package/vue-virtual-scroll-grid 158 | [esm]: https://codesandbox.io/s/vue-virtual-scroll-grid-esm-vt27c?file=/App.vue 159 | [umd]: https://codesandbox.io/s/vue-virtual-scroll-grid-umd-k14w5?file=/index.html 160 | [semantic-release]: https://semantic-release.gitbook.io/semantic-release/#how-does-it-work 161 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Vue Virtual Scroll Grid Demo 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | roots: ["/src"], 3 | testMatch: [ 4 | "**/__tests__/**/*.+(ts|tsx|js)", 5 | "**/?(*.)+(spec|test).+(ts|tsx|js)", 6 | ], 7 | transform: { 8 | "^.+\\.(ts|tsx)$": "ts-jest", 9 | }, 10 | testEnvironment: "jsdom", 11 | }; 12 | -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | [Settings] 2 | # Added automatically by the Netlify CLI. It has no effect during normal 3 | # Git-backed deploys. 4 | # ID = "Your_Site_ID" 5 | 6 | # Redirects and headers are GLOBAL for all builds – they do not get scoped to 7 | # contexts no matter where you define them in the file. 8 | # For context-specific rules, use _headers or _redirects files, which are 9 | # PER-DEPLOY. 10 | 11 | [[redirects]] 12 | from = "/index.html" 13 | to = "/" 14 | 15 | # The default HTTP status code is 301, but you can define a different one. 16 | status = 301 17 | 18 | # By default, redirects won't be applied if there's a file with the same 19 | # path as the one defined in the `from` property. Setting `force` to `true` 20 | # will make the redirect rule take precedence over any existing files. 21 | force = true 22 | 23 | # The following redirect is intended for use with most SPAs that handle 24 | # routing internally. 25 | [[redirects]] 26 | from = "/*" 27 | to = "/index.html" 28 | status = 200 29 | force = false 30 | 31 | # Redirect default Netlify subdomain to primary domain 32 | [[redirects]] 33 | from = "https://vue-virtual-scroll-grid.netlify.app" 34 | to = "https://grid.kiwiberry.nz" 35 | status = 301 36 | force = true 37 | 38 | # Long-term cache by default. 39 | [[headers]] 40 | for = "/*" 41 | [headers.values] 42 | Cache-Control = "max-age=31536000" 43 | 44 | # And here are the exceptions: 45 | [[headers]] 46 | for = "/" 47 | [headers.values] 48 | Cache-Control = "no-cache" 49 | 50 | [[headers]] 51 | for = "/*.html" 52 | [headers.values] 53 | Cache-Control = "no-cache" 54 | 55 | [[headers]] 56 | for = "/service-worker.js" 57 | [headers.values] 58 | Cache-Control = "no-cache" 59 | 60 | [[headers]] 61 | for = "/robots.txt" 62 | [headers.values] 63 | Cache-Control = "max-age=86400" 64 | 65 | [[headers]] 66 | for = "/sitemap.txt" 67 | [headers.values] 68 | Cache-Control = "max-age=86400" 69 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vue-virtual-scroll-grid", 3 | "author": "Roc Wong (https://kiwiberry.nz/)", 4 | "repository": { 5 | "type": "git", 6 | "url": "https://github.com/rocwang/vue-virtual-scroll-grid.git" 7 | }, 8 | "keywords": [ 9 | "vue", 10 | "windowing", 11 | "virtual scroll", 12 | "grid" 13 | ], 14 | "bugs": { 15 | "url": "https://github.com/rocwang/vue-virtual-scroll-grid/issues" 16 | }, 17 | "homepage": "https://grid.kiwiberry.nz/", 18 | "files": [ 19 | "dist" 20 | ], 21 | "module": "./dist/index.es.js", 22 | "main": "./dist/index.umd.js", 23 | "types": "./dist/index.d.ts", 24 | "exports": { 25 | ".": { 26 | "import": "./dist/index.es.js", 27 | "require": "./dist/index.umd.js", 28 | "types": "./dist/index.d.ts" 29 | } 30 | }, 31 | "license": "MIT", 32 | "scripts": { 33 | "gen:types": "vue-tsc --declaration --emitDeclarationOnly --skipLibCheck", 34 | "serve": "vite preview", 35 | "build": "vite build && npm run gen:types", 36 | "build:demo": "vite build --mode demo", 37 | "dev": "vite", 38 | "lint": "vue-tsc --noEmit --skipLibCheck", 39 | "test": "jest", 40 | "preversion": "npm run lint && npm test", 41 | "version": "npm run build", 42 | "postversion": "npm publish --dry-run", 43 | "semantic-release": "semantic-release" 44 | }, 45 | "peerDependencies": { 46 | "vue": "^3.2.33" 47 | }, 48 | "dependencies": { 49 | "@vueuse/core": "^10.0.0", 50 | "ramda": ">=0.28.0", 51 | "rxjs": "^7.8.0" 52 | }, 53 | "devDependencies": { 54 | "@types/jest": "^29.5.0", 55 | "@types/node": "^20.0.0", 56 | "@types/ramda": ">=0.28.23", 57 | "@vitejs/plugin-vue": "^4.1.0", 58 | "algoliasearch": "^4.16.0", 59 | "jest": "^29.5.0", 60 | "jest-environment-jsdom": "^29.5.0", 61 | "lodash-es": "^4.17.21", 62 | "prettier": "^3.0.0", 63 | "semantic-release": "^22.0.5", 64 | "ts-jest": "^29.0.5", 65 | "typescript": "^5.0.3", 66 | "vite": "^4.2.3", 67 | "vue": "^3.2.47", 68 | "vue-tsc": "^1.2.0" 69 | }, 70 | "engines": { 71 | "node": ">=14" 72 | }, 73 | "volta": { 74 | "node": "20.9.0", 75 | "npm": "10.2.2" 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /renovate.json5: -------------------------------------------------------------------------------- 1 | { 2 | $schema: "https://docs.renovatebot.com/renovate-schema.json", 3 | extends: ["group:all", "schedule:monthly"], 4 | assignees: ["rocwang"], 5 | timezone: "Pacific/Auckland", 6 | dependencyDashboard: true, 7 | } 8 | -------------------------------------------------------------------------------- /src/Grid.vue: -------------------------------------------------------------------------------- 1 | 40 | 41 | 191 | -------------------------------------------------------------------------------- /src/demo/App.vue: -------------------------------------------------------------------------------- 1 | 47 | 48 | 83 | 84 | 210 | -------------------------------------------------------------------------------- /src/demo/Bem.js: -------------------------------------------------------------------------------- 1 | import kebabCase from "lodash-es/kebabCase"; 2 | 3 | export default { 4 | methods: { 5 | bem(element, modifier, outputElement = false) { 6 | const blockClass = kebabCase( 7 | this.$options.className || this.$options.name, 8 | ); 9 | const elementClass = `${blockClass}__${element}`; 10 | const elementModifierClass = `${elementClass}--${modifier}`; 11 | 12 | if (element && modifier && outputElement) { 13 | return `${elementClass} ${elementModifierClass}`; 14 | } else if (element && modifier && !outputElement) { 15 | return elementModifierClass; 16 | } else if (element && !modifier) { 17 | return elementClass; 18 | } else if (modifier) { 19 | return `${blockClass}--${modifier}`; 20 | } else { 21 | return blockClass; 22 | } 23 | }, 24 | }, 25 | }; 26 | -------------------------------------------------------------------------------- /src/demo/Control.vue: -------------------------------------------------------------------------------- 1 | 152 | 153 | 180 | 181 | 310 | -------------------------------------------------------------------------------- /src/demo/Header.vue: -------------------------------------------------------------------------------- 1 | 30 | 31 | 38 | 39 | 85 | -------------------------------------------------------------------------------- /src/demo/Logo.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 15 | -------------------------------------------------------------------------------- /src/demo/Money.vue: -------------------------------------------------------------------------------- 1 | 6 | 60 | -------------------------------------------------------------------------------- /src/demo/Price.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /src/demo/ProductItem.vue: -------------------------------------------------------------------------------- 1 | 46 | 47 | 173 | 174 | 251 | -------------------------------------------------------------------------------- /src/demo/SmartImage.vue: -------------------------------------------------------------------------------- 1 | 39 | 40 | 239 | 240 | 299 | -------------------------------------------------------------------------------- /src/demo/main.ts: -------------------------------------------------------------------------------- 1 | import { createApp } from "vue"; 2 | import App from "./App.vue"; 3 | import Bem from "./Bem"; 4 | 5 | const app = createApp(App); 6 | app.mixin(Bem); 7 | app.mount(document.body); 8 | -------------------------------------------------------------------------------- /src/demo/reset.css: -------------------------------------------------------------------------------- 1 | /* CSS reset */ 2 | *, 3 | *::before, 4 | *::after { 5 | box-sizing: inherit; 6 | } 7 | 8 | html { 9 | -ms-text-size-adjust: 100%; 10 | -webkit-text-size-adjust: 100%; 11 | -webkit-font-smoothing: antialiased; 12 | -moz-osx-font-smoothing: grayscale; 13 | -webkit-tap-highlight-color: transparent; 14 | font-size: 10px; 15 | box-sizing: border-box; 16 | } 17 | 18 | body { 19 | margin: 0; 20 | line-height: 1; 21 | font-family: system-ui, sans-serif; 22 | font-size: 1.4rem; 23 | font-weight: 400; 24 | color: #000; 25 | background-color: #fff; 26 | } 27 | 28 | main { 29 | display: block; 30 | } 31 | 32 | iframe { 33 | border: 0; 34 | } 35 | 36 | ul, 37 | ol { 38 | margin-top: 0; 39 | margin-bottom: 0; 40 | padding-left: 0; 41 | } 42 | 43 | li { 44 | display: block; 45 | } 46 | 47 | h1, 48 | h2, 49 | h3, 50 | h4, 51 | h5, 52 | h6 { 53 | margin-top: 0; 54 | margin-bottom: 0; 55 | font-size: inherit; 56 | font-weight: inherit; 57 | } 58 | 59 | p { 60 | margin-top: 0; 61 | margin-bottom: 0; 62 | } 63 | 64 | strong { 65 | font-weight: bold; 66 | } 67 | 68 | figure { 69 | margin: 0; 70 | } 71 | 72 | img { 73 | border: 0; 74 | max-width: 100%; 75 | height: auto; 76 | vertical-align: middle; 77 | display: block; 78 | } 79 | 80 | svg { 81 | vertical-align: middle; 82 | display: block; 83 | } 84 | 85 | a { 86 | text-decoration: none; 87 | color: inherit; 88 | touch-action: manipulation; 89 | } 90 | 91 | fieldset { 92 | margin: 0; 93 | padding: 0; 94 | border-style: none; 95 | } 96 | 97 | button { 98 | user-select: none; 99 | -webkit-appearance: none; 100 | -moz-appearance: none; 101 | appearance: none; 102 | border: 0; 103 | margin: 0; 104 | padding: 0; 105 | text-align: center; 106 | text-transform: inherit; 107 | line-height: 1; 108 | -webkit-font-smoothing: inherit; 109 | letter-spacing: inherit; 110 | background: none; 111 | cursor: pointer; 112 | overflow: visible; 113 | touch-action: manipulation; 114 | display: block; 115 | text-decoration: none; 116 | vertical-align: middle; 117 | white-space: nowrap; 118 | font-family: inherit; 119 | color: inherit; 120 | } 121 | 122 | button:focus { 123 | outline: none; 124 | } 125 | 126 | label { 127 | display: block; 128 | } 129 | 130 | label[for] { 131 | touch-action: manipulation; 132 | cursor: pointer; 133 | } 134 | 135 | input { 136 | display: block; 137 | line-height: 1; 138 | border-style: initial; 139 | touch-action: manipulation; 140 | border-radius: 0; 141 | max-width: 100%; 142 | padding: 0; 143 | background-color: transparent; 144 | font-family: inherit; 145 | margin: 0; 146 | } 147 | 148 | input[type="submit"] { 149 | cursor: pointer; 150 | } 151 | 152 | input:focus { 153 | outline: none; 154 | } 155 | 156 | input[disabled] { 157 | cursor: default; 158 | } 159 | 160 | select { 161 | -webkit-appearance: none; 162 | -moz-appearance: none; 163 | appearance: none; 164 | touch-action: manipulation; 165 | border-radius: 0; 166 | max-width: 100%; 167 | background-color: transparent; 168 | font-family: inherit; 169 | line-height: 1; 170 | } 171 | 172 | select:focus { 173 | outline: none; 174 | } 175 | 176 | select[disabled] { 177 | cursor: default; 178 | } 179 | 180 | textarea { 181 | -webkit-appearance: none; 182 | -moz-appearance: none; 183 | appearance: none; 184 | touch-action: manipulation; 185 | border-radius: 0; 186 | max-width: 100%; 187 | font-family: inherit; 188 | } 189 | 190 | textarea:focus { 191 | outline: none; 192 | } 193 | 194 | textarea[disabled] { 195 | cursor: default; 196 | } 197 | 198 | table { 199 | border-collapse: collapse; 200 | } 201 | 202 | th { 203 | font-weight: 400; 204 | padding: 0; 205 | text-align: left; 206 | } 207 | 208 | td { 209 | padding: 0; 210 | } 211 | 212 | address { 213 | font-style: normal; 214 | } 215 | -------------------------------------------------------------------------------- /src/demo/store.ts: -------------------------------------------------------------------------------- 1 | import { computed, ref } from "vue"; 2 | import algoliasearch from "algoliasearch"; 3 | import { curry, prop } from "ramda"; 4 | 5 | export const length = ref(1000); 6 | export const pageSize = ref(40); 7 | export const scrollTo = ref(undefined); 8 | export const respectScrollToOnResize = ref(false); 9 | 10 | export type ScrollBehavior = "smooth" | "auto"; 11 | export const scrollBehavior = ref("smooth"); 12 | 13 | export type Collection = "" | "all-mens" | "womens-view-all"; 14 | export const collection = ref(""); 15 | 16 | export type ScrollMode = "vertical" | "horizontal"; 17 | export const scrollMode = ref("vertical"); 18 | 19 | export const generalPageProvider = curry( 20 | ( 21 | collection: Collection, 22 | pageNumber: number, 23 | pageSize: number, 24 | ): Promise => 25 | algoliasearch( 26 | import.meta.env.VITE_APP_ID, 27 | import.meta.env.VITE_SEARCH_ONLY_API_KEY, 28 | ) 29 | .initIndex("shopify_products") 30 | .search("", { 31 | distinct: true, 32 | hitsPerPage: pageSize, 33 | page: pageNumber, 34 | facetFilters: 35 | collection === "" ? undefined : [`collections:${collection}`], 36 | }) 37 | .then(prop("hits")), 38 | ); 39 | 40 | export const pageProvider = computed(() => 41 | generalPageProvider(collection.value), 42 | ); 43 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import Grid from "./Grid.vue"; 2 | 3 | export default Grid; 4 | -------------------------------------------------------------------------------- /src/pipeline.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | accumulateAllItems, 3 | accumulateBuffer, 4 | callPageProvider, 5 | computeSpaceBehindWindowOf, 6 | getBufferMeta, 7 | getContentSize, 8 | getGridMeasurement, 9 | getObservableOfVisiblePageNumbers, 10 | getResizeMeasurement, 11 | getVisibleItems, 12 | } from "./pipeline"; 13 | import { TestScheduler } from "rxjs/testing"; 14 | 15 | describe("computeSpaceBehindWindowOf", () => { 16 | // Mock getBoundingClientRect() for jsdom as it always returns: 17 | // { bottom: 0, height: 0, left: 0, right: 0, top: 0, width: 0 } 18 | function createMockDiv(left: number = 0, top: number = 0): HTMLElement { 19 | const div = document.createElement("div"); 20 | div.getBoundingClientRect = () => ({ 21 | width: 0, 22 | height: 0, 23 | top, 24 | left, 25 | right: 0, 26 | bottom: 0, 27 | x: 0, 28 | y: 0, 29 | toJSON: jest.fn(), 30 | }); 31 | 32 | return div; 33 | } 34 | 35 | it("returns 0 space when the element is after window", () => { 36 | const el = createMockDiv(100, 200); 37 | const space = computeSpaceBehindWindowOf(el); 38 | 39 | expect(space).toEqual({ width: 0, height: 0 }); 40 | }); 41 | 42 | it("returns the space behind window when the element is before window", () => { 43 | const el = createMockDiv(-100, -200); 44 | const space = computeSpaceBehindWindowOf(el); 45 | 46 | expect(space).toEqual({ width: 100, height: 200 }); 47 | }); 48 | }); 49 | 50 | function createGridRoot( 51 | rowGap: string = "10px", 52 | columnGap: string = "20px", 53 | gridAutoFlow: string = "row", 54 | gridTemplateColumns: string = "30px 30px 30px", 55 | gridTemplateRows: string = "30px 30px 30px", 56 | ): HTMLElement { 57 | const el = document.createElement("div"); 58 | Object.assign(el.style, { 59 | rowGap, 60 | columnGap, 61 | gridAutoFlow, 62 | gridTemplateColumns, 63 | gridTemplateRows, 64 | }); 65 | 66 | return el; 67 | } 68 | 69 | describe("getGridMeasurement", () => { 70 | it("returns correct grid measurement in numbers", () => { 71 | const el = createGridRoot("10px", "20px", "row", "30px 30px 30px", "30px"); 72 | const measurement = getGridMeasurement(el); 73 | 74 | expect(measurement).toEqual({ 75 | rowGap: 10, 76 | colGap: 20, 77 | flow: "row", 78 | columns: 3, 79 | rows: 1, 80 | }); 81 | }); 82 | 83 | it("returns correct grid flow when flow is dense", () => { 84 | const el = createGridRoot("10px", "20px", "dense"); 85 | const { flow } = getGridMeasurement(el); 86 | 87 | expect(flow).toBe("row"); 88 | }); 89 | 90 | it("returns correct grid flow when flow contains two words", () => { 91 | const el = createGridRoot("10px", "20px", "column dense"); 92 | const { flow } = getGridMeasurement(el); 93 | 94 | expect(flow).toBe("column"); 95 | }); 96 | }); 97 | 98 | describe("getResizeMeasurement", () => { 99 | it("returns correct grid measurement in numbers", () => { 100 | const el = createGridRoot("10px", "20px", "column", "30px", "20px 20px"); 101 | 102 | const measurement = getResizeMeasurement(el, { 103 | width: 10, 104 | height: 20, 105 | top: 0, 106 | left: 0, 107 | right: 0, 108 | bottom: 0, 109 | x: 0, 110 | y: 0, 111 | toJSON: jest.fn(), 112 | }); 113 | 114 | expect(measurement).toEqual({ 115 | rowGap: 10, 116 | colGap: 20, 117 | flow: "column", 118 | columns: 1, 119 | rows: 2, 120 | itemHeightWithGap: 30, 121 | itemWidthWithGap: 30, 122 | }); 123 | }); 124 | }); 125 | 126 | describe("getBufferMeta", () => { 127 | function createMockResizeMeasurement(flow: "row" | "column") { 128 | return { 129 | colGap: 20, 130 | rowGap: 10, 131 | flow, 132 | columns: 3, 133 | rows: 2, 134 | itemHeightWithGap: 50, 135 | itemWidthWithGap: 50, 136 | }; 137 | } 138 | 139 | it("returns correct buffer meta data when flow is row and space is 0", () => { 140 | const space = { width: 0, height: 0 }; 141 | const meta = getBufferMeta(1000, 1000)( 142 | space, 143 | createMockResizeMeasurement("row"), 144 | ); 145 | 146 | expect(meta).toEqual({ bufferedOffset: 0, bufferedLength: 132 }); 147 | }); 148 | 149 | it("returns correct buffer meta data when flow is column and space is 0", () => { 150 | const space = { width: 0, height: 0 }; 151 | const meta = getBufferMeta(1000, 1000)( 152 | space, 153 | createMockResizeMeasurement("column"), 154 | ); 155 | 156 | expect(meta).toEqual({ bufferedOffset: 0, bufferedLength: 88 }); 157 | }); 158 | 159 | it("returns correct buffer meta data when flow is row and space is greater than 0", () => { 160 | const space = { width: 5000, height: 5000 }; 161 | const meta = getBufferMeta(1000, 1000)( 162 | space, 163 | createMockResizeMeasurement("row"), 164 | ); 165 | 166 | expect(meta).toEqual({ bufferedOffset: 267, bufferedLength: 132 }); 167 | }); 168 | 169 | it("returns correct buffer meta data when flow is column and space is greater than 0", () => { 170 | const space = { width: 5000, height: 5000 }; 171 | const meta = getBufferMeta(1000, 1000)( 172 | space, 173 | createMockResizeMeasurement("column"), 174 | ); 175 | 176 | expect(meta).toEqual({ bufferedOffset: 178, bufferedLength: 88 }); 177 | }); 178 | }); 179 | 180 | describe("getObservableOfVisiblePageNumbers", () => { 181 | const scheduler = new TestScheduler((actual, expected) => { 182 | expect(actual).toEqual(expected); 183 | }); 184 | 185 | it("returns 1 page when the buffer length is smaller than the page size", () => { 186 | scheduler.run(({ expectObservable }) => { 187 | const expectedMarble = "(a|)"; 188 | const expectedPageNumbers = { a: 0 }; 189 | const pageNumber$ = getObservableOfVisiblePageNumbers( 190 | { bufferedOffset: 0, bufferedLength: 10 }, 191 | 100, 192 | 20, 193 | ); 194 | expectObservable(pageNumber$).toBe(expectedMarble, expectedPageNumbers); 195 | }); 196 | }); 197 | 198 | it("returns multiple pages when the buffer length is greater than the page size", () => { 199 | scheduler.run(({ expectObservable }) => { 200 | const expectedMarble = "(abcde|)"; 201 | const expectedPageNumbers = { a: 2, b: 3, c: 4, d: 5, e: 6 }; 202 | const pageNumber$ = getObservableOfVisiblePageNumbers( 203 | { bufferedOffset: 50, bufferedLength: 80 }, 204 | 200, 205 | 20, 206 | ); 207 | expectObservable(pageNumber$).toBe(expectedMarble, expectedPageNumbers); 208 | }); 209 | }); 210 | 211 | it("returns multiple pages up to the list length", () => { 212 | scheduler.run(({ expectObservable }) => { 213 | const expectedMarble = "(abc|)"; 214 | const expectedPageNumbers = { a: 2, b: 3, c: 4 }; 215 | const pageNumber$ = getObservableOfVisiblePageNumbers( 216 | { bufferedOffset: 50, bufferedLength: 80 }, 217 | 100, 218 | 20, 219 | ); 220 | expectObservable(pageNumber$).toBe(expectedMarble, expectedPageNumbers); 221 | }); 222 | }); 223 | }); 224 | 225 | describe("callPageProvider", () => { 226 | it("calls pageProvider and returns pageNumber and items", async () => { 227 | const pageProvider = jest.fn(async () => Array(10).fill("item")); 228 | const { pageNumber, items } = await callPageProvider(0, 10, pageProvider); 229 | 230 | expect(pageNumber).toBe(0); 231 | expect(items).toEqual(Array(10).fill("item")); 232 | }); 233 | }); 234 | 235 | describe("accumulateAllItems", () => { 236 | it("can extend allItems", () => { 237 | const allItems = accumulateAllItems( 238 | [0, 1, 2, 3, 4, 5], 239 | [{ pageNumber: 1, items: ["a", "b", "c"] }, 10, 3], 240 | ); 241 | 242 | expect(allItems).toEqual([ 243 | 0, 244 | 1, 245 | 2, 246 | "a", 247 | "b", 248 | "c", 249 | undefined, 250 | undefined, 251 | undefined, 252 | undefined, 253 | ]); 254 | }); 255 | 256 | it("can shrink allItems", () => { 257 | const allItems = accumulateAllItems( 258 | [0, 1, 2, 3, 4, 5, 6], 259 | [{ pageNumber: 0, items: ["a", "b", "c"] }, 5, 3], 260 | ); 261 | 262 | expect(allItems).toEqual(["a", "b", "c", 3, 4]); 263 | }); 264 | 265 | it("behave properly when pageProvider returns fewer items than pageSize", () => { 266 | const allItems = accumulateAllItems( 267 | [0, 1, 2, 3, 4, 5], 268 | [{ pageNumber: 0, items: ["a", "b"] }, 6, 3], 269 | ); 270 | 271 | expect(allItems).toEqual(["a", "b", undefined, 3, 4, 5]); 272 | }); 273 | 274 | it("behave properly when pageProvider returns more items than pageSize", () => { 275 | const allItems = accumulateAllItems( 276 | [0, 1, 2, 3, 4, 5], 277 | [{ pageNumber: 0, items: ["a", "b", "c", "d"] }, 6, 3], 278 | ); 279 | 280 | expect(allItems).toEqual(["a", "b", "c", 3, 4, 5]); 281 | }); 282 | }); 283 | 284 | describe("getVisibleItems", () => { 285 | it("returns correct visible items when flow is row", () => { 286 | const visibleItems = getVisibleItems( 287 | { bufferedOffset: 2, bufferedLength: 2 }, 288 | { 289 | colGap: 10, 290 | rowGap: 10, 291 | flow: "row", 292 | columns: 2, 293 | rows: 2, 294 | itemHeightWithGap: 50, 295 | itemWidthWithGap: 60, 296 | }, 297 | ["a", "b", "c", "d", "e", "f", "g"], 298 | ); 299 | 300 | expect(visibleItems).toEqual([ 301 | { 302 | index: 2, 303 | value: "c", 304 | style: { 305 | gridArea: "1/1", 306 | transform: `translate(0px, 50px)`, 307 | }, 308 | }, 309 | { 310 | index: 3, 311 | value: "d", 312 | style: { 313 | gridArea: "1/1", 314 | transform: `translate(60px, 50px)`, 315 | }, 316 | }, 317 | ]); 318 | }); 319 | 320 | // TODO: test getVisibleItems() when flow is column 321 | }); 322 | 323 | describe("accumulateBuffer", () => { 324 | it("merge visible items into buffer in a stable way", () => { 325 | const buffer = [ 326 | { 327 | index: 0, 328 | value: "a", 329 | style: { 330 | gridArea: "1/1", 331 | transform: `translate(0px, 50px)`, 332 | }, 333 | }, 334 | { 335 | index: 1, 336 | value: "b", 337 | style: { 338 | gridArea: "1/1", 339 | transform: `translate(0px, 100px)`, 340 | }, 341 | }, 342 | ]; 343 | const visibleItems = [ 344 | { 345 | index: 1, 346 | value: "b", 347 | style: { 348 | gridArea: "1/1", 349 | transform: `translate(0px, 100px)`, 350 | }, 351 | }, 352 | { 353 | index: 2, 354 | value: "c", 355 | style: { 356 | gridArea: "1/1", 357 | transform: `translate(0px, 150px)`, 358 | }, 359 | }, 360 | { 361 | index: 3, 362 | value: "d", 363 | style: { 364 | gridArea: "1/1", 365 | transform: `translate(0px, 200px)`, 366 | }, 367 | }, 368 | ]; 369 | const newBuffer = [ 370 | { 371 | index: 2, 372 | value: "c", 373 | style: { 374 | gridArea: "1/1", 375 | transform: `translate(0px, 150px)`, 376 | }, 377 | }, 378 | { 379 | index: 1, 380 | value: "b", 381 | style: { 382 | gridArea: "1/1", 383 | transform: `translate(0px, 100px)`, 384 | }, 385 | }, 386 | { 387 | index: 3, 388 | value: "d", 389 | style: { 390 | gridArea: "1/1", 391 | transform: `translate(0px, 200px)`, 392 | }, 393 | }, 394 | ]; 395 | 396 | expect(accumulateBuffer(buffer, visibleItems)).toEqual(newBuffer); 397 | }); 398 | }); 399 | 400 | describe("getContentSize", () => { 401 | function createMockResizeMeasurement(flow: "row" | "column") { 402 | return { 403 | colGap: 10, 404 | rowGap: 10, 405 | flow: flow, 406 | columns: 5, 407 | rows: 5, 408 | itemHeightWithGap: 100, 409 | itemWidthWithGap: 100, 410 | }; 411 | } 412 | 413 | it("returns correct content width", () => { 414 | const measurement = createMockResizeMeasurement("column"); 415 | const contentSize = getContentSize(measurement, 1000); 416 | 417 | expect(contentSize).toEqual({ width: 19_990 }); 418 | }); 419 | 420 | it("returns correct content height", () => { 421 | const measurement = createMockResizeMeasurement("row"); 422 | const contentSize = getContentSize(measurement, 1000); 423 | 424 | expect(contentSize).toEqual({ height: 19_990 }); 425 | }); 426 | }); 427 | 428 | // TODO: test accumulateAllItems() 429 | // TODO: test getVisibleItems() 430 | // TODO: test accumulateBuffer() 431 | // TODO: test pipeline() 432 | -------------------------------------------------------------------------------- /src/pipeline.ts: -------------------------------------------------------------------------------- 1 | import { 2 | combineLatest, 3 | debounceTime, 4 | distinctUntilChanged, 5 | filter, 6 | map, 7 | merge, 8 | mergeMap, 9 | Observable, 10 | range, 11 | scan, 12 | shareReplay, 13 | switchMap, 14 | withLatestFrom, 15 | } from "rxjs"; 16 | import { 17 | __, 18 | addIndex, 19 | apply, 20 | complement, 21 | concat, 22 | difference, 23 | equals, 24 | identity, 25 | insertAll, 26 | isNil, 27 | map as ramdaMap, 28 | memoizeWith, 29 | pipe, 30 | remove, 31 | slice, 32 | without, 33 | zip, 34 | } from "ramda"; 35 | import { getScrollParents } from "./utilites"; 36 | 37 | interface SpaceBehindWindow { 38 | width: number; 39 | height: number; 40 | } 41 | 42 | export function computeSpaceBehindWindowOf(el: Element): SpaceBehindWindow { 43 | const { left, top } = el.getBoundingClientRect(); 44 | 45 | return { 46 | width: Math.abs(Math.min(left, 0)), 47 | height: Math.abs(Math.min(top, 0)), 48 | }; 49 | } 50 | 51 | interface GridMeasurement { 52 | colGap: number; 53 | rowGap: number; 54 | flow: "row" | "column"; 55 | columns: number; 56 | rows: number; 57 | } 58 | 59 | export function getGridMeasurement(rootEl: Element): GridMeasurement { 60 | const computedStyle = window.getComputedStyle(rootEl); 61 | 62 | return { 63 | rowGap: parseInt(computedStyle.getPropertyValue("row-gap")) || 0, 64 | colGap: parseInt(computedStyle.getPropertyValue("column-gap")) || 0, 65 | flow: computedStyle.getPropertyValue("grid-auto-flow").startsWith("column") 66 | ? "column" 67 | : "row", 68 | columns: computedStyle.getPropertyValue("grid-template-columns").split(" ") 69 | .length, 70 | rows: computedStyle.getPropertyValue("grid-template-rows").split(" ") 71 | .length, 72 | }; 73 | } 74 | 75 | interface ResizeMeasurement extends GridMeasurement { 76 | itemHeightWithGap: number; 77 | itemWidthWithGap: number; 78 | } 79 | 80 | export function getResizeMeasurement( 81 | rootEl: Element, 82 | { height, width }: DOMRectReadOnly, 83 | ): ResizeMeasurement { 84 | const { colGap, rowGap, flow, columns, rows } = getGridMeasurement(rootEl); 85 | 86 | return { 87 | colGap, 88 | rowGap, 89 | flow, 90 | columns, 91 | rows, 92 | itemHeightWithGap: height + rowGap, 93 | itemWidthWithGap: width + colGap, 94 | }; 95 | } 96 | 97 | interface BufferMeta { 98 | bufferedOffset: number; 99 | bufferedLength: number; 100 | } 101 | 102 | export const getBufferMeta = 103 | ( 104 | windowInnerWidth: number = window.innerWidth, 105 | windowInnerHeight: number = window.innerHeight, 106 | ) => 107 | ( 108 | { width: widthBehindWindow, height: heightBehindWindow }: SpaceBehindWindow, 109 | { 110 | colGap, 111 | rowGap, 112 | flow, 113 | columns, 114 | rows, 115 | itemHeightWithGap, 116 | itemWidthWithGap, 117 | }: ResizeMeasurement, 118 | ): BufferMeta => { 119 | let crosswiseLines; 120 | let gap; 121 | let itemSizeWithGap; 122 | let windowInnerSize; 123 | let spaceBehindWindow; 124 | if (flow === "row") { 125 | crosswiseLines = columns; 126 | gap = rowGap; 127 | itemSizeWithGap = itemHeightWithGap; 128 | windowInnerSize = windowInnerHeight; 129 | spaceBehindWindow = heightBehindWindow; 130 | } else { 131 | crosswiseLines = rows; 132 | gap = colGap; 133 | itemSizeWithGap = itemWidthWithGap; 134 | windowInnerSize = windowInnerWidth; 135 | spaceBehindWindow = widthBehindWindow; 136 | } 137 | 138 | const linesInView = 139 | itemSizeWithGap && 140 | Math.ceil((windowInnerSize + gap) / itemSizeWithGap) + 1; 141 | const length = linesInView * crosswiseLines; 142 | 143 | const linesBeforeView = 144 | itemSizeWithGap && 145 | Math.floor((spaceBehindWindow + gap) / itemSizeWithGap); 146 | const offset = linesBeforeView * crosswiseLines; 147 | const bufferedOffset = Math.max(offset - Math.floor(length / 2), 0); 148 | const bufferedLength = length * 2; 149 | 150 | return { 151 | bufferedOffset, 152 | bufferedLength, 153 | }; 154 | }; 155 | 156 | export function getObservableOfVisiblePageNumbers( 157 | { bufferedOffset, bufferedLength }: BufferMeta, 158 | length: number, 159 | pageSize: number, 160 | ): Observable { 161 | const startPage = Math.floor(bufferedOffset / pageSize); 162 | const endPage = Math.ceil( 163 | Math.min(bufferedOffset + bufferedLength, length) / pageSize, 164 | ); 165 | const numberOfPages = endPage - startPage; 166 | 167 | return range(startPage, numberOfPages); 168 | } 169 | 170 | interface ItemsByPage { 171 | pageNumber: number; 172 | items: unknown[]; 173 | } 174 | 175 | export type PageProvider = ( 176 | pageNumber: number, 177 | pageSize: number, 178 | ) => Promise; 179 | 180 | export function callPageProvider( 181 | pageNumber: number, 182 | pageSize: number, 183 | pageProvider: PageProvider, 184 | ): Promise { 185 | return pageProvider(pageNumber, pageSize).then((items) => ({ 186 | pageNumber, 187 | items, 188 | })); 189 | } 190 | 191 | export function accumulateAllItems( 192 | allItems: unknown[], 193 | [{ pageNumber, items }, length, pageSize]: [ItemsByPage, number, number], 194 | ): unknown[] { 195 | const allItemsFill = new Array(Math.max(length - allItems.length, 0)).fill( 196 | undefined, 197 | ); 198 | 199 | const pageFill = new Array(Math.max(pageSize - items.length, 0)).fill( 200 | undefined, 201 | ); 202 | 203 | const normalizedItems = concat(slice(0, pageSize, items), pageFill); 204 | 205 | return pipe( 206 | concat(__, allItemsFill), 207 | remove(pageNumber * pageSize, pageSize), 208 | insertAll(pageNumber * pageSize, normalizedItems), 209 | slice(0, length), 210 | )(allItems); 211 | } 212 | 213 | interface ItemOffset { 214 | x: number; 215 | y: number; 216 | } 217 | 218 | export function getItemOffsetByIndex( 219 | index: number, 220 | { 221 | flow, 222 | columns, 223 | rows, 224 | itemWidthWithGap, 225 | itemHeightWithGap, 226 | }: ResizeMeasurement, 227 | ): ItemOffset { 228 | let x; 229 | let y; 230 | if (flow === "row") { 231 | x = (index % columns) * itemWidthWithGap; 232 | y = Math.floor(index / columns) * itemHeightWithGap; 233 | } else { 234 | x = Math.floor(index / rows) * itemWidthWithGap; 235 | y = (index % rows) * itemHeightWithGap; 236 | } 237 | 238 | return { x, y }; 239 | } 240 | 241 | export interface InternalItem { 242 | index: number; 243 | value: unknown | undefined; 244 | style?: { transform: string; gridArea: string }; 245 | } 246 | 247 | export function getVisibleItems( 248 | { bufferedOffset, bufferedLength }: BufferMeta, 249 | resizeMeasurement: ResizeMeasurement, 250 | allItems: unknown[], 251 | ): InternalItem[] { 252 | return pipe( 253 | slice(bufferedOffset, bufferedOffset + bufferedLength), 254 | addIndex(ramdaMap)((value, localIndex) => { 255 | const index = bufferedOffset + localIndex; 256 | const { x, y } = getItemOffsetByIndex(index, resizeMeasurement); 257 | 258 | return { 259 | index, 260 | value, 261 | style: { 262 | gridArea: "1/1", 263 | transform: `translate(${x}px, ${y}px)`, 264 | }, 265 | }; 266 | }) as (a: unknown[]) => InternalItem[], 267 | )(allItems); 268 | } 269 | 270 | export function accumulateBuffer( 271 | buffer: InternalItem[], 272 | visibleItems: InternalItem[], 273 | ): InternalItem[] { 274 | const itemsToAdd = difference(visibleItems, buffer); 275 | const itemsFreeToUse = difference(buffer, visibleItems); 276 | 277 | const replaceMap = new Map(zip(itemsFreeToUse, itemsToAdd)); 278 | const itemsToBeReplaced = [...replaceMap.keys()]; 279 | const itemsToReplaceWith = [...replaceMap.values()]; 280 | 281 | const itemsToDelete = difference(itemsFreeToUse, itemsToBeReplaced); 282 | const itemsToAppend = difference(itemsToAdd, itemsToReplaceWith); 283 | 284 | return pipe( 285 | without(itemsToDelete), 286 | ramdaMap((item) => replaceMap.get(item) ?? item), 287 | concat(__, itemsToAppend), 288 | )(buffer); 289 | } 290 | 291 | interface ContentSize { 292 | width?: number; 293 | height?: number; 294 | } 295 | 296 | export function getContentSize( 297 | { 298 | colGap, 299 | rowGap, 300 | flow, 301 | columns, 302 | rows, 303 | itemWidthWithGap, 304 | itemHeightWithGap, 305 | }: ResizeMeasurement, 306 | length: number, 307 | ): ContentSize { 308 | return flow === "row" 309 | ? { height: itemHeightWithGap * Math.ceil(length / columns) - rowGap } 310 | : { width: itemWidthWithGap * Math.ceil(length / rows) - colGap }; 311 | } 312 | 313 | interface PipelineInput { 314 | length$: Observable; 315 | pageProvider$: Observable; 316 | pageProviderDebounceTime$: Observable; 317 | pageSize$: Observable; 318 | itemRect$: Observable; 319 | rootResize$: Observable; 320 | scroll$: Observable; 321 | respectScrollToOnResize$: Observable; 322 | scrollTo$: Observable; 323 | } 324 | 325 | export type ScrollAction = { 326 | target: Element; 327 | top: number; 328 | left: number; 329 | }; 330 | 331 | interface PipelineOutput { 332 | buffer$: Observable; 333 | contentSize$: Observable; 334 | scrollAction$: Observable; 335 | allItems$: Observable; 336 | } 337 | 338 | export function pipeline({ 339 | length$, 340 | pageProvider$, 341 | pageProviderDebounceTime$, 342 | pageSize$, 343 | itemRect$, 344 | rootResize$, 345 | scroll$, 346 | respectScrollToOnResize$, 347 | scrollTo$, 348 | }: PipelineInput): PipelineOutput { 349 | // region: measurements of the visual grid 350 | const spaceBehindWindow$: Observable = merge( 351 | rootResize$, 352 | scroll$, 353 | ).pipe(map(computeSpaceBehindWindowOf), distinctUntilChanged()); 354 | 355 | const resizeMeasurement$: Observable = combineLatest( 356 | [rootResize$, itemRect$], 357 | getResizeMeasurement, 358 | ).pipe(distinctUntilChanged(equals)); 359 | 360 | const contentSize$: Observable = combineLatest( 361 | [resizeMeasurement$, length$], 362 | getContentSize, 363 | ); 364 | // endregion 365 | 366 | // region: scroll to a given item by index 367 | const scrollToNotNil$: Observable = scrollTo$.pipe( 368 | filter(complement(isNil)), 369 | ); 370 | const scrollAction$: Observable = respectScrollToOnResize$.pipe( 371 | switchMap((respectScrollToOnResize) => 372 | respectScrollToOnResize 373 | ? // Emit when any input stream emits 374 | combineLatest<[number, ResizeMeasurement, Element]>([ 375 | scrollToNotNil$, 376 | resizeMeasurement$, 377 | rootResize$, 378 | ]) 379 | : // Emit only when the source stream emmits 380 | scrollToNotNil$.pipe( 381 | withLatestFrom( 382 | resizeMeasurement$, 383 | rootResize$, 384 | ), 385 | ), 386 | ), 387 | map<[number, ResizeMeasurement, Element], ScrollAction>( 388 | ([scrollTo, resizeMeasurement, rootEl]) => { 389 | const { vertical: verticalScrollEl, horizontal: horizontalScrollEl } = 390 | getScrollParents(rootEl); 391 | const computedStyle = getComputedStyle(rootEl); 392 | 393 | const gridPaddingTop = parseInt( 394 | computedStyle.getPropertyValue("padding-top"), 395 | ); 396 | const gridBoarderTop = parseInt( 397 | computedStyle.getPropertyValue("border-top"), 398 | ); 399 | 400 | const gridPaddingLeft = parseInt( 401 | computedStyle.getPropertyValue("padding-left"), 402 | ); 403 | const gridBoarderLeft = parseInt( 404 | computedStyle.getPropertyValue("border-left"), 405 | ); 406 | 407 | const leftToGridContainer = 408 | rootEl instanceof HTMLElement && 409 | horizontalScrollEl instanceof HTMLElement 410 | ? rootEl.offsetLeft - horizontalScrollEl.offsetLeft 411 | : 0; 412 | 413 | const topToGridContainer = 414 | rootEl instanceof HTMLElement && 415 | verticalScrollEl instanceof HTMLElement 416 | ? rootEl.offsetTop - verticalScrollEl.offsetTop 417 | : 0; 418 | 419 | const { x, y } = getItemOffsetByIndex(scrollTo, resizeMeasurement); 420 | 421 | return { 422 | target: verticalScrollEl, 423 | top: y + topToGridContainer + gridPaddingTop + gridBoarderTop, 424 | left: x + leftToGridContainer + gridPaddingLeft + gridBoarderLeft, 425 | }; 426 | }, 427 | ), 428 | ); 429 | // endregion 430 | 431 | // region: rendering buffer 432 | const bufferMeta$: Observable = combineLatest( 433 | [spaceBehindWindow$, resizeMeasurement$], 434 | getBufferMeta(), 435 | ).pipe(distinctUntilChanged(equals)); 436 | 437 | const visiblePageNumbers$: Observable> = combineLatest([ 438 | bufferMeta$, 439 | length$, 440 | pageSize$, 441 | ]).pipe(map(apply(getObservableOfVisiblePageNumbers))); 442 | 443 | const debouncedVisiblePageNumbers$: Observable> = 444 | pageProviderDebounceTime$.pipe( 445 | switchMap((time) => 446 | visiblePageNumbers$.pipe(time === 0 ? identity : debounceTime(time)), 447 | ), 448 | ); 449 | 450 | const memorizedPageProvider$: Observable = pageProvider$.pipe( 451 | map((f) => 452 | memoizeWith( 453 | (pageNumber: number, pageSize: number) => `${pageNumber},${pageSize}`, 454 | f, 455 | ), 456 | ), 457 | shareReplay(1), 458 | ); 459 | 460 | const itemsByPage$: Observable = combineLatest([ 461 | debouncedVisiblePageNumbers$, 462 | pageSize$, 463 | memorizedPageProvider$, 464 | ]).pipe( 465 | mergeMap< 466 | [Observable, number, PageProvider], 467 | Observable 468 | >(([pageNumber$, pageSize, memorizedPageProvider]) => 469 | pageNumber$.pipe( 470 | mergeMap>((pageNumber) => 471 | callPageProvider(pageNumber, pageSize, memorizedPageProvider), 472 | ), 473 | ), 474 | ), 475 | shareReplay(1), 476 | ); 477 | 478 | const replayLength$: Observable = length$.pipe(shareReplay(1)); 479 | const replayPageSize$: Observable = pageSize$.pipe(shareReplay(1)); 480 | 481 | const allItems$: Observable = memorizedPageProvider$.pipe( 482 | switchMap(() => 483 | combineLatest([itemsByPage$, replayLength$, replayPageSize$]), 484 | ), 485 | scan(accumulateAllItems, []), 486 | shareReplay(1), 487 | ); 488 | 489 | const buffer$: Observable = combineLatest( 490 | [bufferMeta$, resizeMeasurement$, allItems$], 491 | getVisibleItems, 492 | ).pipe(scan(accumulateBuffer, [])); 493 | // endregion 494 | 495 | return { buffer$, contentSize$, scrollAction$, allItems$ }; 496 | } 497 | -------------------------------------------------------------------------------- /src/shims-vue.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.vue" { 2 | import type { DefineComponent } from "vue"; 3 | const component: DefineComponent<{}, {}, any>; 4 | export default component; 5 | } 6 | -------------------------------------------------------------------------------- /src/utilites.ts: -------------------------------------------------------------------------------- 1 | import { 2 | animationFrameScheduler, 3 | filter, 4 | fromEventPattern, 5 | map, 6 | mergeAll, 7 | Observable, 8 | scheduled, 9 | Subject, 10 | } from "rxjs"; 11 | import { onMounted, Ref, ref, watchEffect } from "vue"; 12 | import { partial, pipe, unary } from "ramda"; 13 | import { 14 | MaybeElementRef, 15 | ResizeObserverEntry, 16 | tryOnUnmounted, 17 | unrefElement, 18 | useResizeObserver, 19 | } from "@vueuse/core"; 20 | 21 | export function fromProp( 22 | props: T, 23 | propName: U, 24 | ): Observable { 25 | return new Observable((subscriber) => 26 | watchEffect(() => subscriber.next(props[propName])), 27 | ); 28 | } 29 | 30 | export function fromResizeObserver( 31 | elRef: MaybeElementRef, 32 | pluckTarget: T, 33 | ): Observable { 34 | return scheduled( 35 | fromEventPattern( 36 | pipe(unary, partial(useResizeObserver, [elRef])), 37 | ), 38 | animationFrameScheduler, 39 | ).pipe( 40 | mergeAll(), 41 | map( 42 | (entry) => entry[pluckTarget], 43 | ), 44 | ); 45 | } 46 | 47 | export function fromScrollParent(elRef: MaybeElementRef): Observable { 48 | const scrollSubject = new Subject(); 49 | 50 | onMounted(() => { 51 | const el = unrefElement(elRef); 52 | 53 | if (el) { 54 | const { vertical, horizontal } = getScrollParents(el); 55 | 56 | const scrollParents = ( 57 | vertical === horizontal ? [vertical] : [vertical, horizontal] 58 | ).map((parent) => 59 | // If the scrolling parent is the doc root, use window instead. 60 | // As using doc root might not work 61 | parent === document.documentElement ? window : parent, 62 | ); 63 | 64 | const pushEl = () => scrollSubject.next(el); 65 | 66 | scrollParents.forEach((parent) => 67 | parent.addEventListener("scroll", pushEl, { 68 | passive: true, 69 | capture: true, 70 | }), 71 | ); 72 | 73 | tryOnUnmounted(() => 74 | scrollParents.forEach((parent) => 75 | parent.removeEventListener("scroll", pushEl), 76 | ), 77 | ); 78 | } 79 | }); 80 | 81 | return scrollSubject; 82 | } 83 | 84 | export function useObservable(observable: Observable): Readonly> { 85 | const valueRef = ref(); 86 | const subscription = observable.subscribe((val) => (valueRef.value = val)); 87 | 88 | tryOnUnmounted(() => subscription.unsubscribe()); 89 | 90 | return valueRef as Readonly>; 91 | } 92 | 93 | interface ScrollParents { 94 | vertical: Element; 95 | horizontal: Element; 96 | } 97 | 98 | export function getScrollParents( 99 | element: Element, 100 | includeHidden: boolean = false, 101 | ): ScrollParents { 102 | const style = getComputedStyle(element); 103 | 104 | if (style.position === "fixed") { 105 | return { 106 | vertical: document.body, 107 | horizontal: document.body, 108 | }; 109 | } 110 | 111 | const excludeStaticParent = style.position === "absolute"; 112 | const overflowRegex = includeHidden 113 | ? /(auto|scroll|hidden)/ 114 | : /(auto|scroll)/; 115 | 116 | let vertical; 117 | let horizontal; 118 | 119 | for ( 120 | let parent: Element | null = element; 121 | // parent.assignedSlot.parentElement find the correct parent if the grid is inside a native web component 122 | (parent = parent.assignedSlot?.parentElement ?? parent.parentElement); 123 | 124 | ) { 125 | const parentStyle = getComputedStyle(parent); 126 | 127 | if (excludeStaticParent && parentStyle.position === "static") continue; 128 | 129 | if (!horizontal && overflowRegex.test(parentStyle.overflowX)) { 130 | horizontal = parent; 131 | if (vertical) return { vertical, horizontal }; 132 | } 133 | 134 | if (!vertical && overflowRegex.test(parentStyle.overflowY)) { 135 | vertical = parent; 136 | if (horizontal) return { vertical, horizontal }; 137 | } 138 | } 139 | 140 | const fallback = document.scrollingElement || document.documentElement; 141 | return { 142 | vertical: vertical ?? fallback, 143 | horizontal: horizontal ?? fallback, 144 | }; 145 | } 146 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": false, 4 | "baseUrl": ".", 5 | "outDir": "dist", 6 | "target": "esnext", 7 | "module": "esnext", 8 | "moduleResolution": "node", 9 | "strict": true, 10 | "jsx": "preserve", 11 | "sourceMap": true, 12 | "resolveJsonModule": true, 13 | "esModuleInterop": true, 14 | "lib": ["esnext", "dom"], 15 | "types": ["vite/client", "jest"], 16 | }, 17 | "include": ["src/*.ts", "src/*.tsx", "src/*.vue"] 18 | } 19 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { ConfigEnv, UserConfig } from "vite"; 2 | import vue from "@vitejs/plugin-vue"; 3 | import { homedir } from "os"; 4 | import { existsSync, readFileSync } from "fs"; 5 | import { resolve } from "path"; 6 | 7 | export default ({ mode }: ConfigEnv): UserConfig => { 8 | return { 9 | plugins: [vue()], 10 | server: { 11 | open: true, 12 | https: existsSync(`${homedir()}/.localhost_ssl/server.key`) 13 | ? { 14 | key: readFileSync(`${homedir()}/.localhost_ssl/server.key`), 15 | cert: readFileSync(`${homedir()}/.localhost_ssl/server.crt`), 16 | } 17 | : false, 18 | }, 19 | optimizeDeps: { exclude: ["prettier"] }, 20 | build: 21 | mode === "demo" 22 | ? {} 23 | : { 24 | lib: { 25 | entry: resolve(__dirname, "src/index.ts"), 26 | name: "VirtualScrollGrid", 27 | fileName: (format) => `index.${format}.js`, 28 | }, 29 | rollupOptions: { 30 | // Make sure to externalize deps that shouldn't be bundled 31 | // into your library 32 | external: ["vue"], 33 | output: { 34 | // Provide global variables to use in the UMD build 35 | // for externalized deps 36 | globals: { 37 | vue: "Vue", 38 | }, 39 | // Since we publish our ./src folder, there's no point 40 | // in bloating sourcemaps with another copy of it. 41 | sourcemapExcludeSources: true, 42 | }, 43 | }, 44 | sourcemap: true, 45 | // Reduce bloat from legacy polyfills. 46 | target: "esnext", 47 | // Leave minification up to applications. 48 | minify: false, 49 | }, 50 | }; 51 | }; 52 | --------------------------------------------------------------------------------