├── .browserslistrc ├── .npmignore ├── docs ├── public │ └── logo.png ├── index.md ├── guide │ ├── installation.md │ ├── retry-loading.md │ ├── preloaded-data.md │ ├── reset-state.md │ ├── loader-margin.md │ ├── manually-change-states.md │ ├── styling-with-slots.md │ ├── loader-positions.md │ ├── simple-usage.md │ └── loading-states.md ├── api │ ├── types.md │ ├── slots.md │ └── props.md └── .vitepress │ └── config.ts ├── src ├── main-dev.ts ├── shims-vue.d.ts ├── main.ts ├── components │ └── VueEternalLoading │ │ ├── helpers │ │ ├── type │ │ │ └── type.ts │ │ └── scroll │ │ │ └── scroll.ts │ │ ├── VueEternalLoading.vue │ │ └── __tests__ │ │ └── VueEternalLoading.test.ts └── App.vue ├── .release-it.js ├── .travis.yml ├── .gitignore ├── jest.config.js ├── index.html ├── vite.config.js ├── tsconfig.json ├── .eslintrc.js ├── LICENSE ├── README.md ├── package.json ├── dist ├── vue-eternal-loading.js ├── vue-eternal-loading.umd.js ├── index.html └── vue-eternal-loading.mjs └── public └── index.html /.browserslistrc: -------------------------------------------------------------------------------- 1 | > 1% 2 | last 2 versions 3 | not dead 4 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | * 2 | !src/**/* 3 | !dist/**/* 4 | !LICENSE 5 | !/README.md 6 | !package.json 7 | -------------------------------------------------------------------------------- /docs/public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ts-pro/vue-eternal-loading/HEAD/docs/public/logo.png -------------------------------------------------------------------------------- /src/main-dev.ts: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue'; 2 | import App from './App.vue'; 3 | 4 | createApp(App).mount('#app'); 5 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # Introduction 2 | 3 | **Vue-eternal-loading** is a simple component for creating infinite scroll pagination. It is written in TypeScript for Vue 3. 4 | -------------------------------------------------------------------------------- /src/shims-vue.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | declare module '*.vue' { 3 | import type { DefineComponent } from 'vue' 4 | const component: DefineComponent<{}, {}, any> 5 | export default component 6 | } 7 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | export { default as VueEternalLoading } from './components/VueEternalLoading/VueEternalLoading.vue'; 2 | 3 | export type { 4 | LoadAction, 5 | LoadPayload, 6 | Position, 7 | State, 8 | } from './components/VueEternalLoading/helpers/type/type'; 9 | -------------------------------------------------------------------------------- /.release-it.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | git: { 3 | commitMessage: 'Release: v${version}', 4 | tagName: 'v${version}', 5 | }, 6 | npm: { 7 | publish: true, 8 | skipChecks: true, 9 | }, 10 | github: { 11 | release: true, 12 | }, 13 | }; 14 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 14.18.0 4 | install: 5 | - yarn install 6 | script: 7 | - yarn docs:build 8 | deploy: 9 | provider: pages 10 | skip_cleanup: true 11 | local_dir: docs/.vitepress/dist 12 | github_token: $GITHUB_TOKEN 13 | keep_history: true 14 | on: 15 | branch: main 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | 4 | # local env files 5 | .env.local 6 | .env.*.local 7 | 8 | # Log files 9 | npm-debug.log* 10 | yarn-debug.log* 11 | yarn-error.log* 12 | pnpm-debug.log* 13 | 14 | # Editor directories and files 15 | .idea 16 | .vscode 17 | *.suo 18 | *.ntvs* 19 | *.njsproj 20 | *.sln 21 | *.sw? 22 | 23 | # Vite press build files 24 | docs/.vitepress/dist 25 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | // preset: '@vue/cli-plugin-unit-jest/presets/typescript-and-babel', 3 | testEnvironment: 'jsdom', 4 | preset: 'ts-jest', 5 | transform: { 6 | '^.+\\.vue$': '@vue/vue3-jest', 7 | // '^.+\\js$': 'babel-jest', 8 | '^.+\\.ts$': 'ts-jest', 9 | }, 10 | testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.(js|ts)$', 11 | moduleFileExtensions: ['vue', 'js', 'ts'], 12 | }; 13 | -------------------------------------------------------------------------------- /src/components/VueEternalLoading/helpers/type/type.ts: -------------------------------------------------------------------------------- 1 | export type LoadAction = { 2 | loaded(count?: number, pageSize?: number): State; 3 | noMore(): void; 4 | noResults(): void; 5 | error(): void; 6 | }; 7 | 8 | export type LoadPayload = { 9 | isFirstLoad: boolean; 10 | }; 11 | 12 | export type State = 'loading' | 'error' | 'no-more' | 'no-results'; 13 | 14 | export type Position = 'top' | 'left' | 'default'; 15 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | VueEternalLoading Serve 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /docs/guide/installation.md: -------------------------------------------------------------------------------- 1 | # Installation 2 | 3 | ## Yarn 4 | ``` 5 | yarn add @ts-pro/vue-eternal-loading 6 | ``` 7 | 8 | ## Npm 9 | ``` 10 | npm install @ts-pro/vue-eternal-loading 11 | ``` 12 | 13 | ## Browser 14 | ```html 15 | 16 | 17 | 18 | 19 | ``` 20 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import vue from '@vitejs/plugin-vue'; 3 | import { resolve } from 'path'; 4 | 5 | export default defineConfig({ 6 | plugins: [vue()], 7 | build: { 8 | lib: { 9 | entry: resolve(__dirname, 'src/main.ts'), 10 | name: 'TSPro', 11 | formats: ['cjs', 'umd', 'es'], 12 | }, 13 | rollupOptions: { 14 | external: ['vue'], 15 | output: { 16 | globals: { 17 | vue: 'Vue', 18 | }, 19 | }, 20 | }, 21 | }, 22 | }); 23 | -------------------------------------------------------------------------------- /src/components/VueEternalLoading/helpers/scroll/scroll.ts: -------------------------------------------------------------------------------- 1 | export function getScrollHeightFromEl($el: HTMLElement): number { 2 | return $el.scrollHeight; 3 | } 4 | 5 | export function getScrollWidthFromEl($el: HTMLElement): number { 6 | return $el.scrollWidth; 7 | } 8 | 9 | export function restoreScrollVerticalPosition( 10 | $el: HTMLElement, 11 | scrollHeight: number 12 | ): void { 13 | $el.scrollTop = $el.scrollHeight - scrollHeight + $el.scrollTop; 14 | } 15 | 16 | export function restoreScrollHorizontalPosition( 17 | $el: HTMLElement, 18 | scrollWidth: number 19 | ): void { 20 | $el.scrollLeft = $el.scrollWidth - scrollWidth + $el.scrollLeft; 21 | } 22 | -------------------------------------------------------------------------------- /docs/api/types.md: -------------------------------------------------------------------------------- 1 | # Types 2 | 3 | ## LoadAction 4 | ```ts 5 | type LoadAction = { 6 | loaded(count?: number, pageSize?: number): State; 7 | noMore(): void; 8 | noResults(): void; 9 | error(): void; 10 | }; 11 | ``` 12 | Describes possible actions in `loaded` prop callback. 13 | 14 | ## LoadPayload 15 | ```ts 16 | type LoadPayload = { 17 | isFirstLoad: boolean; 18 | }; 19 | ``` 20 | Describes payload what we get in `loaded` prop callback. 21 | 22 | ## Position 23 | ```ts 24 | type Position = 'top' | 'left' | 'default'; 25 | ``` 26 | Describes possible loader positions. 27 | 28 | ## State 29 | Added in `v1.2.0` 30 | ```ts 31 | type State = 'loading' | 'error' | 'no-more' | 'no-results'; 32 | ``` 33 | Describes possible state for loader. 34 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "module": "esnext", 5 | "strict": true, 6 | "jsx": "preserve", 7 | "importHelpers": true, 8 | "moduleResolution": "node", 9 | "skipLibCheck": true, 10 | "esModuleInterop": true, 11 | "allowSyntheticDefaultImports": true, 12 | "sourceMap": true, 13 | "baseUrl": ".", 14 | "types": [ 15 | "webpack-env", 16 | "jest", 17 | "node", 18 | ], 19 | "paths": { 20 | "@/*": [ 21 | "src/*" 22 | ] 23 | }, 24 | "lib": [ 25 | "esnext", 26 | "dom", 27 | "dom.iterable", 28 | "scripthost" 29 | ] 30 | }, 31 | "include": [ 32 | "src/**/*.ts", 33 | "src/**/*.tsx", 34 | "src/**/*.vue", 35 | ], 36 | "exclude": [ 37 | "node_modules", 38 | ] 39 | } 40 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | node: true, 5 | browser: true, 6 | }, 7 | extends: [ 8 | 'plugin:vue/vue3-essential', 9 | 'eslint:recommended', 10 | '@vue/typescript/recommended', 11 | '@vue/prettier', 12 | '@vue/prettier/@typescript-eslint', 13 | ], 14 | parserOptions: { 15 | ecmaVersion: 2020, 16 | }, 17 | rules: { 18 | 'prettier/prettier': [ 19 | 'error', 20 | { 21 | singleQuote: true, 22 | }, 23 | ], 24 | 'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off', 25 | 'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off', 26 | }, 27 | overrides: [ 28 | { 29 | files: [ 30 | '**/__tests__/*.{j,t}s?(x)', 31 | '**/tests/unit/**/*.spec.{j,t}s?(x)', 32 | ], 33 | env: { 34 | jest: true, 35 | }, 36 | }, 37 | ], 38 | globals: { 39 | defineProps: "readonly", 40 | defineEmits: "readonly", 41 | defineExpose: "readonly", 42 | withDefaults: "readonly" 43 | } 44 | }; 45 | -------------------------------------------------------------------------------- /docs/guide/retry-loading.md: -------------------------------------------------------------------------------- 1 | # Retry loading 2 | 3 | Typically, when you encounter the **no-more**, **no-results**, or **error** states in **vue-eternal-loading**, your only option to restart the component is by using the `isInitial` prop. However, it's important to note that using the `isInitial` prop will also reset the `isFirstLoad` state, which may not always be desired. In scenarios where you have caught an error or reached the end and want to retry the loading process, you can utilize the `retry` method available within the `#no-more`, `#no-results`, and `#error` slots. 4 | 5 | Here's an example demonstrating how you can implement a retry button when an error is caught: 6 | 7 | ```html 8 | 9 | 13 | 14 | ``` 15 | 16 | 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Oleksandr Havrashenko 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 | -------------------------------------------------------------------------------- /docs/guide/preloaded-data.md: -------------------------------------------------------------------------------- 1 | # Preloaded data 2 | 3 | Normally, **vue-eternal-loading** calls the `load` prop when you need to fetch data from the server. However, there may be cases where you already have preloaded data from cache or other requests. In such cases, if you render your preloaded data and place **vue-eternal-loading** next to it as usual, the component is unaware of the preloaded data and behaves as if it's the first loading. This can lead to undesired results, such as getting the **no-results** state even when you have data, or having an incorrect value for the `isFirstLoad` prop in the `#loading` slot. 4 | 5 | To prevent this behavior, you can pass a `false` to the `isInitial` prop. This informs **vue-eternal-loading** that it is not the initial loading, and it will adjust its behavior accordingly. 6 | 7 | ```html 8 | 9 | ``` 10 | 11 | Or you can pass it using v-model. 12 | 13 | ```html 14 | 15 | 16 | ``` 17 | 18 | 19 | -------------------------------------------------------------------------------- /docs/api/slots.md: -------------------------------------------------------------------------------- 1 | # Slots 2 | 3 | ## #loading 4 | - Default: `
Loading...
` 5 | 6 | Renders normal loading state when try to load more content. 7 | 8 | - Available scoped slot data: 9 | - `isFirstLoad` - indicates whether it was first load `` 10 | 11 | ## #noMore 12 | - Default: `
No more.
` 13 | 14 | Renders state when we don't have more new content and there is no need to make further loading. 15 | 16 | - Available scoped slot data (added in `v1.1.0`): 17 | - `retry` - activates `loading` state again 18 | `` 19 | 20 | ## #noResults 21 | - Default: `
No results.
` 22 | 23 | Renders case when we don't have even 1 item loaded. 24 | 25 | - Available scoped slot data (added in `v1.1.0`): 26 | - `retry` - activates `loading` state again 27 | `` 28 | 29 | ## #error 30 | - Default: `
Error.
` 31 | 32 | Renders case when we caught an error. 33 | 34 | - Available scoped slot data (added in `v1.1.0`): 35 | - `retry` - activates `loading` state again 36 | `` 37 | 38 | -------------------------------------------------------------------------------- /docs/guide/reset-state.md: -------------------------------------------------------------------------------- 1 | # Reset state 2 | 3 | Sometimes it is important to reset the state of **vue-eternal-loading** and the `isFirstLoad` flag to their defaults. For instance, when implementing filters on your website, you may want to reset the pagination if any of the filters have been changed. You have the flexibility to reset the component whenever needed, and we can implement a special reset button to demonstrate how it can be done. 4 | 5 | To enable the reset functionality, you need to pass an `isInitial` prop via `v-model` and initialize it with a value of `true`. When the `load` prop is called, the `isInitial` prop will be automatically changed to `false`. If you set the isInitial prop back to true, it will reset the component to its initial state. 6 | 7 | By managing the value of the `isInitial` prop, you can control the reset behavior of the component and trigger a reset whenever necessary. 8 | 9 | ```html 10 | 11 | 12 | 13 | 14 | ``` 15 | 16 | ```js 17 | setup() { 18 | // Other props 19 | // ... 20 | const isInitial = ref(true); 21 | 22 | // Other methods 23 | // ... 24 | async function load({ loaded }) { 25 | // Process response logic 26 | } 27 | 28 | function reset() { 29 | // Reset all data 30 | // ... 31 | // This will reset `vue-eternal-loading` 32 | isInitial.value = true; 33 | } 34 | 35 | return { 36 | load, 37 | reset, 38 | isInitial, 39 | // Other data... 40 | }; 41 | } 42 | ``` 43 | 44 | 45 | -------------------------------------------------------------------------------- /docs/guide/loader-margin.md: -------------------------------------------------------------------------------- 1 | # Loader margin 2 | 3 | In the previous examples, the `load` prop was triggered when the loader became visible on the screen or within the specified container element (depending on the `container` prop). However, there may be situations where you want to start the loading process a bit earlier to provide a more seamless experience for the user. This can be achieved using the `margin` prop, which accepts parameters in pixels or percentages as a string. 4 | 5 | The `margin` prop is passed as the `rootMargin` parameter to the underlying `IntersectionObserver`. You can refer to the [Mozilla Developer Network (MDN) documentation](https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserver/rootMargin) for more information on the different formats and options for `rootMargin`. 6 | 7 | In most cases, specifying the `margin` as a single value in pixels (e.g., `200px`) will cover the most common scenarios, so there is no need to delve into the details of other format options unless required for specific use cases. 8 | 9 | ```html 10 | 11 | 12 | ``` 13 | 14 | 15 | 16 | In the example mentioned above, we created an invisible bounding box with a margin of `200px` around our `VueEternalLoading` component markup. This means that the load prop will be triggered `200px` earlier, as if the bounding box were `200px` larger in all directions. 17 | 18 | The significant aspect to note is that the `margin` prop does not affect your layout in any way, unlike the **css margin** property, which pushes content if specified. The `margin` prop purely influences the triggering of the `load` prop and the timing of when the loading process starts, ensuring a smoother user experience. 19 | -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 77 | 78 | 95 | -------------------------------------------------------------------------------- /docs/api/props.md: -------------------------------------------------------------------------------- 1 | # Props 2 | 3 | ## load 4 | - Type: `(action: LoadAction, payload: LoadPayload) => void` 5 | - **Required** 6 | 7 | Callback prop which is called when it's time to load new items ( loader is visible to user ). 8 | Accepts 2 arguments: 9 | ```js 10 | load( 11 | { 12 | // Call when you finished loading data 13 | // Optional params: 14 | // - count - how many items has been loaded 15 | // - pageSize - items per page count 16 | // Returns: 17 | // - State ( 'loading', 'no-more', 'no-results' ) 18 | loaded, 19 | 20 | // Call when you have no more item 21 | noMore, 22 | 23 | // Call if you have no items at all 24 | noResults, 25 | 26 | // Call if you caught an error 27 | error, 28 | }, 29 | { 30 | // Indicates is it was first load 31 | isFirstLoad, 32 | } 33 | ) 34 | ``` 35 | 36 | ## isInitial 37 | - Type: `boolean` 38 | - Default: `true` 39 | 40 | Tells component is it first loading or not. Can be used with v-model to reset component if set it to true after component creation. 41 | 42 | Using prop: 43 | ```html 44 | 45 | 46 | ``` 47 | 48 | Using v-model ( can be reset ): 49 | ```html 50 | 51 | 52 | ``` 53 | 54 | ## position 55 | - Type: `Position` 56 | - Default: `default` 57 | - Values: `'top'` | `'left'` | `'default'` 58 | 59 | Tells where **vue-eternal-loader** is. It is required for `top` / `left` positions for correct scroll calculations. 60 | 61 | ## container 62 | - Type: `HTMLElement` 63 | - Default: `document.documentElement` 64 | 65 | Required if your scroll area is not the whole document ( `document.documentElement` ) and your position `top` or `left`. This prop tells where the scroll is, to scroll right container. 66 | 67 | ## margin 68 | Added in `v1.1.0` 69 | - Type: `string` 70 | - Default: `undefined` 71 | 72 | Creates invisible bounding box around `vue-eternal-loading` which trigger `load` prop. Normally it may be specified in pixels ( e.g. `200px` ). All formats available [here](https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserver/rootMargin). 73 | -------------------------------------------------------------------------------- /docs/.vitepress/config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitepress' 2 | 3 | export default defineConfig({ 4 | lang: 'en-US', 5 | title: 'vue-eternal-loading', 6 | description: 'Infinity loading component vue3 projects.', 7 | base: '/vue-eternal-loading/', 8 | head: [ 9 | ['link', { rel: "icon", type: "image/png", sizes: "16x16", href: "/vue-eternal-loading/logo.png"}], 10 | ], 11 | themeConfig: { 12 | logo: '/logo.png', 13 | 14 | nav: [ 15 | { text: 'Guide', link: '/', activeMatch: '^/$|^/guide/' }, 16 | { 17 | text: 'API Reference', 18 | link: '/api/props', 19 | activeMatch: '^/api/' 20 | }, 21 | { 22 | text: 'Release Notes', 23 | link: 'https://github.com/ts-pro/vue-eternal-loading/releases' 24 | } 25 | ], 26 | 27 | sidebar: { 28 | '/guide/': getGuideSidebar(), 29 | '/api/': getApiSidebar(), 30 | '/': getGuideSidebar(), 31 | } 32 | }, 33 | }) 34 | 35 | function getGuideSidebar() { 36 | return [ 37 | { 38 | text: 'Getting started', 39 | items: [ 40 | { text: 'Introduction', link: '/' }, 41 | { text: 'Installation', link: '/guide/installation' }, 42 | { text: 'Simple usage', link: '/guide/simple-usage' }, 43 | ] 44 | }, 45 | { 46 | text: 'Examples', 47 | items: [ 48 | { text: 'Loading states', link: '/guide/loading-states' }, 49 | { text: 'Styling with slots', link: '/guide/styling-with-slots' }, 50 | { text: 'Manually change states', link: '/guide/manually-change-states' }, 51 | { text: 'Reset state', link: '/guide/reset-state' }, 52 | { text: 'Preloaded data', link: '/guide/preloaded-data' }, 53 | { text: 'Loader positions', link: '/guide/loader-positions' }, 54 | { text: 'Loader margin', link: '/guide/loader-margin' }, 55 | { text: 'Retry loading', link: '/guide/retry-loading' }, 56 | ] 57 | } 58 | ] 59 | } 60 | 61 | function getApiSidebar() { 62 | return [ 63 | { 64 | text: 'API', 65 | items: [ 66 | { text: 'Props', link: '/api/props' }, 67 | { text: 'Slots', link: '/api/slots' }, 68 | { text: 'Types', link: '/api/types' }, 69 | ] 70 | }, 71 | ] 72 | } 73 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🇺🇦 vue-eternal-loading [![Build Status](https://app.travis-ci.com/ts-pro/vue-eternal-loading.svg?branch=main)](https://app.travis-ci.com/ts-pro/vue-eternal-loading) 2 | 3 | The Infinity loading component is written in [TypeScript](https://www.typescriptlang.org/) for [Vue 3](https://v3.vuejs.org/). No dependencies. 4 | 5 | ### Features: 6 | - 4 directional ( top / left / right / bottom) 7 | - 4 loading states ( loading / no-more / no-results / error ) 8 | - Custom markup & styles 9 | - Works in browsers & bundlers 10 | - SSR friendly 11 | 12 | ### Installation: 13 | **Yarn** 14 | ``` 15 | yarn add @ts-pro/vue-eternal-loading 16 | ``` 17 | 18 | **Npm** 19 | ``` 20 | npm install @ts-pro/vue-eternal-loading 21 | ``` 22 | 23 | **Browser** 24 | ```html 25 | 26 | 27 | 28 | 29 | ``` 30 | 31 | ### Simple usage: 32 | ```html 33 | 34 | ``` 35 | ```ts 36 | const PAGE_SIZE = 5; 37 | 38 | // We need to pass this method to the component, 39 | // and it will be called automatically when needed. 40 | async function load({ loaded }) { 41 | // Load your data from the server or any other source. 42 | const loadedItems = await loadItems(page); 43 | items.value.push(...loadedItems); 44 | page += 1; 45 | // Call the `loaded` callback with 2 arguments: 46 | // 1. The number of items we have loaded 47 | // 2. Our page size to know when we have reached the end 48 | loaded(loadedItems.length, PAGE_SIZE); 49 | } 50 | ``` 51 | 52 | ### Guide & demo: 53 | Check out our [vue-eternal-loading docs](https://ts-pro.github.io/vue-eternal-loading/) 54 | 55 | ### Releases 56 | List releases [vue-eternal-loading releases](https://github.com/ts-pro/vue-eternal-loading/releases) 57 | 58 | ### Vue2 support 59 | Our component is specifically designed for Vue 3. If you are looking for a solution for Vue 2, you can check out this library [vue-infinite-loading](https://github.com//PeachScript/vue-infinite-loading). 60 | 61 | ### Issue 62 | Please feel free to create an issue or submit a feature request [vue-eternal-loading issues](https://github.com/ts-pro/vue-eternal-loading/issues) 63 | 64 | ### License 65 | MIT License 66 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@ts-pro/vue-eternal-loading", 3 | "description": "Infinity loading component vue3 projects. Slava Ukraini!", 4 | "version": "1.3.1", 5 | "main": "./dist/vue-eternal-loading.js", 6 | "types": "./src/main.ts", 7 | "exports": { 8 | "import": "./dist/vue-eternal-loading.mjs", 9 | "default": "./dist/vue-eternal-loading.umd.js", 10 | "require": "./dist/vue-eternal-loading.js", 11 | "node": "./dist/vue-eternal-loading.js", 12 | "types": "./src/main.ts" 13 | }, 14 | "sideEffects": false, 15 | "author": "Oleksandr Havrashenko", 16 | "license": "MIT", 17 | "repository": { 18 | "type": "git", 19 | "url": "git+https://github.com/ts-pro/vue-eternal-loading.git" 20 | }, 21 | "bugs": { 22 | "url": "https://github.com/ts-pro/vue-eternal-loading/issues" 23 | }, 24 | "homepage": "https://github.com/ts-pro/vue-eternal-loading", 25 | "scripts": { 26 | "dev": "vite", 27 | "build": "vue-tsc --noEmit && vite build", 28 | "preview": "vite preview", 29 | "test:unit": "jest", 30 | "lint": "eslint --fix --ext .ts,.vue --ignore-path .gitignore src", 31 | "docs:dev": "vitepress dev docs", 32 | "docs:build": "vitepress build docs", 33 | "docs:serve": "vitepress serve docs" 34 | }, 35 | "devDependencies": { 36 | "@types/jest": "^24.0.19", 37 | "@types/node": "^18.7.14", 38 | "@types/webpack-env": "^1.18.0", 39 | "@typescript-eslint/eslint-plugin": "^4.18.0", 40 | "@typescript-eslint/parser": "^4.18.0", 41 | "@vitejs/plugin-vue": "^3.0.0", 42 | "@vue/compiler-sfc": "^3.0.0", 43 | "@vue/eslint-config-prettier": "^6.0.0", 44 | "@vue/eslint-config-typescript": "^7.0.0", 45 | "@vue/test-utils": "^2.0.2", 46 | "@vue/vue3-jest": "^27.0.0", 47 | "core-js": "^3.6.5", 48 | "eslint": "^6.7.2", 49 | "eslint-plugin-prettier": "^3.3.1", 50 | "eslint-plugin-vue": "^7.0.0", 51 | "jest": "^27.5.1", 52 | "prettier": "^2.2.1", 53 | "ts-jest": "^27.1.5", 54 | "typescript": "~4.4.4", 55 | "vite": "3.0.9", 56 | "vitepress": "^1.0.0-alpha.13", 57 | "vue": "3.2.37", 58 | "vue-tsc": "^0.38.4" 59 | }, 60 | "peerDependencies": { 61 | "vue": "^3.2.25" 62 | }, 63 | "keywords": [ 64 | "vue", 65 | "vue3", 66 | "vue 3", 67 | "infinity loader", 68 | "infinity loading", 69 | "eternal loading", 70 | "infinite loading", 71 | "infinite scroll", 72 | "vue infinite" 73 | ], 74 | "publishConfig": { 75 | "access": "public" 76 | }, 77 | "dependencies": {} 78 | } 79 | -------------------------------------------------------------------------------- /dist/vue-eternal-loading.js: -------------------------------------------------------------------------------- 1 | "use strict";Object.defineProperties(exports,{__esModule:{value:!0},[Symbol.toStringTag]:{value:"Module"}});const e=require("vue");function P(r){return r.scrollHeight}function k(r){return r.scrollWidth}function I(r,l){r.scrollTop=r.scrollHeight-l+r.scrollTop}function L(r,l){r.scrollLeft=r.scrollWidth-l+r.scrollLeft}const V=e.createElementVNode("div",{class:"loading"},"Loading...",-1),N=e.createElementVNode("div",{class:"no-more"},"No more.",-1),q=e.createElementVNode("div",{class:"no-results"},"No results.",-1),F=e.createElementVNode("div",{class:"error"},"Error.",-1),z=e.defineComponent({__name:"VueEternalLoading",props:{load:{required:!0,type:Function},isInitial:{required:!1,type:Boolean,default:!0},position:{required:!1,type:String,default:"default"},container:{required:!1,type:Object,default:null},margin:{required:!1,type:String,default:void 0}},emits:["update:isInitial"],setup(r,{emit:l}){const t=r,s=e.ref();let a=e.ref("loading"),n=e.ref(t.isInitial),c=0;function d(){e.nextTick(()=>{var o,i;t.position==="top"?I((o=t.container)!=null?o:document.documentElement,c):t.position==="left"&&L((i=t.container)!=null?i:document.documentElement,c)})}function S(o,i){return o===0?n.value?(g(),"no-results"):(p(),"no-more"):o!==void 0&&i!==void 0&&o{var i,E;o.isIntersecting&&(t.position==="top"?c=P((i=t.container)!=null?i:document.documentElement):t.position==="left"&&(c=k((E=t.container)!=null?E:document.documentElement)),h(),t.load({loaded:S,noMore:p,noResults:g,error:y},{isFirstLoad:n.value}))},{root:t.container,threshold:0,rootMargin:t.margin})}let m;return typeof IntersectionObserver<"u"&&e.watchEffect(()=>{m&&h(),m=b(),f()},{flush:"post"}),e.watch(()=>t.isInitial,o=>{o&&_()}),e.watch(n,o=>{o||l("update:isInitial",!1)}),(o,i)=>(e.openBlock(),e.createElementBlock("div",{class:"vue-eternal-loading",ref_key:"rootRef",ref:s},[e.unref(a)==="loading"?e.renderSlot(o.$slots,"loading",e.normalizeProps(e.mergeProps({key:0},{isFirstLoad:e.unref(n)})),()=>[V]):e.unref(a)==="no-more"?e.renderSlot(o.$slots,"no-more",e.normalizeProps(e.mergeProps({key:1},{retry:v})),()=>[N]):e.unref(a)==="no-results"?e.renderSlot(o.$slots,"no-results",e.normalizeProps(e.mergeProps({key:2},{retry:v})),()=>[q]):e.unref(a)==="error"?e.renderSlot(o.$slots,"error",e.normalizeProps(e.mergeProps({key:3},{retry:v})),()=>[F]):e.createCommentVNode("",!0)],512))}});exports.VueEternalLoading=z; 2 | -------------------------------------------------------------------------------- /dist/vue-eternal-loading.umd.js: -------------------------------------------------------------------------------- 1 | (function(l,e){typeof exports=="object"&&typeof module<"u"?e(exports,require("vue")):typeof define=="function"&&define.amd?define(["exports","vue"],e):(l=typeof globalThis<"u"?globalThis:l||self,e(l.TSPro={},l.Vue))})(this,function(l,e){"use strict";function _(t){return t.scrollHeight}function P(t){return t.scrollWidth}function b(t,s){t.scrollTop=t.scrollHeight-s+t.scrollTop}function V(t,s){t.scrollLeft=t.scrollWidth-s+t.scrollLeft}const k=e.createElementVNode("div",{class:"loading"},"Loading...",-1),I=e.createElementVNode("div",{class:"no-more"},"No more.",-1),L=e.createElementVNode("div",{class:"no-results"},"No results.",-1),N=e.createElementVNode("div",{class:"error"},"Error.",-1),T=e.defineComponent({__name:"VueEternalLoading",props:{load:{required:!0,type:Function},isInitial:{required:!1,type:Boolean,default:!0},position:{required:!1,type:String,default:"default"},container:{required:!1,type:Object,default:null},margin:{required:!1,type:String,default:void 0}},emits:["update:isInitial"],setup(t,{emit:s}){const r=t,a=e.ref();let d=e.ref("loading"),n=e.ref(r.isInitial),c=0;function u(){e.nextTick(()=>{var o,i;r.position==="top"?b((o=r.container)!=null?o:document.documentElement,c):r.position==="left"&&V((i=r.container)!=null?i:document.documentElement,c)})}function q(o,i){return o===0?n.value?(y(),"no-results"):(g(),"no-more"):o!==void 0&&i!==void 0&&o{var i,S;o.isIntersecting&&(r.position==="top"?c=_((i=r.container)!=null?i:document.documentElement):r.position==="left"&&(c=P((S=r.container)!=null?S:document.documentElement)),E(),r.load({loaded:q,noMore:g,noResults:y,error:F},{isFirstLoad:n.value}))},{root:r.container,threshold:0,rootMargin:r.margin})}let p;return typeof IntersectionObserver<"u"&&e.watchEffect(()=>{p&&E(),p=O(),m()},{flush:"post"}),e.watch(()=>r.isInitial,o=>{o&&z()}),e.watch(n,o=>{o||s("update:isInitial",!1)}),(o,i)=>(e.openBlock(),e.createElementBlock("div",{class:"vue-eternal-loading",ref_key:"rootRef",ref:a},[e.unref(d)==="loading"?e.renderSlot(o.$slots,"loading",e.normalizeProps(e.mergeProps({key:0},{isFirstLoad:e.unref(n)})),()=>[k]):e.unref(d)==="no-more"?e.renderSlot(o.$slots,"no-more",e.normalizeProps(e.mergeProps({key:1},{retry:h})),()=>[I]):e.unref(d)==="no-results"?e.renderSlot(o.$slots,"no-results",e.normalizeProps(e.mergeProps({key:2},{retry:h})),()=>[L]):e.unref(d)==="error"?e.renderSlot(o.$slots,"error",e.normalizeProps(e.mergeProps({key:3},{retry:h})),()=>[N]):e.createCommentVNode("",!0)],512))}});l.VueEternalLoading=T,Object.defineProperties(l,{__esModule:{value:!0},[Symbol.toStringTag]:{value:"Module"}})}); 2 | -------------------------------------------------------------------------------- /dist/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | VueEternalLoading Demo 6 | 7 | 8 | 9 | 21 | 22 | 23 |
24 |
25 |
30 | 36 | 41 | 42 | 43 | ... 51 |
52 |
53 |
54 | 55 | 94 | 95 | 96 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | VueEternalLoading Demo 6 | 7 | 8 | 9 | 21 | 22 | 23 |
24 |
25 |
30 | 36 | 41 | 42 | 43 | ... 51 |
52 |
53 |
54 | 55 | 94 | 95 | 96 | -------------------------------------------------------------------------------- /docs/guide/manually-change-states.md: -------------------------------------------------------------------------------- 1 | # Manually Change States 2 | 3 | 4 | In **vue-eternal-loading**, you can manually change the state, which can be useful when you have more complex or custom logic that goes beyond what the `loaded` callback offers. 5 | 6 | Let's write a logic where we don't rely on the current page size, but instead, use another field in the response (e.g., **total_pages**). With this field, we can determine that if the current page number equals the total pages count, the state should be changed to **no-more**. 7 | 8 | ```js 9 | function load({ loaded, noMore }) { 10 | // Load data from server 11 | // ... 12 | if (page < response.total_pages) { 13 | loaded(); 14 | } else { 15 | noMore(); 16 | } 17 | } 18 | ``` 19 | 20 | 21 | --- 22 | 23 | To set the **no-results** state if we receive an empty response for the first loading, we can utilize the second argument in the `load` prop method. This argument is a payload that contains the `isFirstLoad` flag. By checking the value of the `isFirstLoad` flag, we can determine whether it is the first load or subsequent loads. In the case of the first load and an empty response, we can manually set the state to **no-results**. 24 | 25 | ```js 26 | function load({ loaded, noMore, noResults }, { isFirstLoad }) { 27 | // Load data from server 28 | // ... 29 | if (items.length === 0) { 30 | if (isFirstLoad) { 31 | noResults(); 32 | } else { 33 | noMore(); 34 | } 35 | } else { 36 | loaded(); 37 | } 38 | } 39 | ``` 40 | 41 | 42 | 43 | --- 44 | 45 | It is indeed a good practice to handle errors in an application and provide clear feedback to the user. That's why the **error** state is offered in **vue-eternal-loading**. If something unfavorable occurs and you need to display an error message to the user, you can manually set the state to **error**. This can be done if you receive an error response from the server. Additionally, you can utilize the `#error` slot to show a custom error message to the user. This allows you to have more control over the error handling and display relevant information to the user when an error occurs. 46 | 47 | ```html 48 | 49 | 54 | 55 | ``` 56 | ```js 57 | load({ loaded, error }) { 58 | // Load users from server 59 | this.loadUsers(this.page).then((users) => { 60 | // Add users to an array 61 | // ... 62 | loaded(); 63 | }).catch(() => { 64 | error(); 65 | }) 66 | } 67 | ``` 68 | 69 | -------------------------------------------------------------------------------- /docs/guide/styling-with-slots.md: -------------------------------------------------------------------------------- 1 | # Styling with slots 2 | 3 | **vue-eternal-loading** provides 4 slots for each state, allowing you to set custom templates for each of them. The default templates for each state were described in the previous section. You can style the templates using regular CSS. If you require different markup, text, or more complex customization, you can pass your own template to the appropriate slot. 4 | 5 | ```html 6 | 40 | ``` 41 | 42 | 43 | 44 | --- 45 | 46 | Alternatively, you can use a fancy Bootstrap spinner or any other spinner component by providing your own custom template in the loading slot: 47 | 48 | 49 | 50 | --- 51 | 52 | In the loading slot, you have access to the `isFirstLoad` data through the scoped slot. This data can be utilized when you want to display a different loader for the first time, such as a skeleton loading animation or any other specific design or content. 53 | 54 | ```html 55 | 80 | ``` 81 | 82 | 83 | 84 | 85 | 86 | 87 | -------------------------------------------------------------------------------- /docs/guide/loader-positions.md: -------------------------------------------------------------------------------- 1 | # Loader positions 2 | 3 | **vue-eternal-loading** offers four loader positions: `top`, `left`, `right`, and `bottom`. In the examples provided earlier, we used the `bottom` position, which means that the **vue-eternal-loading** component was positioned below the loaded items. In such cases, we do not need to pass any specific props to indicate the component's position. 4 | 5 | However, for the `top` and `left` positions, special props need to be passed. This is because when loading new content that is appended to the top or left of a container, it does not affect the scroll position towards our content. In other words, if you have scrolled down by 100 pixels and then load 200 pixels of new content at the top, it will be fine because the top content and scroll position remain in the same position. The same principle applies to the `right` loader position. 6 | 7 | By providing the necessary props for the `top` and `left` positions, you can ensure that the loading behavior and positioning work correctly, maintaining the desired scroll and content alignment. 8 | 9 | 10 | 11 | As you can see, we simply position the loader to the right using CSS without requiring any additional steps, and the functionality works seamlessly. 12 | 13 | If we simply position the loader at the top or left, it won't function in the same manner. This is because when new content is added to the top or left, the browser retains the scroll position. However, the content displayed at that scroll position will be different. As a result, we need to adjust the scroll position by the difference between the sizes of the old and new content. 14 | 15 | Fortunately, **vue-eternal-loading** takes care of these calculations for us. It automatically handles the necessary adjustments to the scroll position, accounting for the changes in content size. With **vue-eternal-loading**, we don't have to manually manage these complex calculations; the component handles them seamlessly behind the scenes. 16 | 17 | To specify the loader's position, we need to provide the `position` prop with one of the following values: 18 | - **default**: This is the default value for the bottom or right positions, and it does not need to be specified manually. 19 | - **top**: Use this value if the loader is positioned above the items. 20 | - **left**: Use this value if the loader is positioned to the left of the items. 21 | 22 | If your scroll area encompasses the entire document, specifying the `container` prop is not necessary. However, if your scroll area is a custom element, you must pass this element as a `ref` value to the `container` prop. It's required because we need to change scroll position and it's important to know where the scroll is: 23 | 24 | ```html 25 | 26 |
27 | 28 | 29 | 30 | 31 | 32 |
33 | ``` 34 | 35 | ```js 36 | setup() { 37 | // Other props 38 | // ... 39 | const containerRef = ref(); 40 | 41 | // Other methods 42 | // ... 43 | function load({ loaded }) { 44 | // Load logic 45 | } 46 | 47 | return { 48 | load, 49 | containerRef, 50 | // Other data 51 | }; 52 | } 53 | ``` 54 | 55 | Top scroll example: 56 | 57 | 58 | 59 | Left scroll example: 60 | 61 | 62 | -------------------------------------------------------------------------------- /dist/vue-eternal-loading.mjs: -------------------------------------------------------------------------------- 1 | import { defineComponent as H, ref as _, watchEffect as N, watch as I, openBlock as O, createElementBlock as P, unref as u, renderSlot as v, normalizeProps as p, mergeProps as g, createCommentVNode as w, createElementVNode as h, nextTick as B } from "vue"; 2 | function R(o) { 3 | return o.scrollHeight; 4 | } 5 | function T(o) { 6 | return o.scrollWidth; 7 | } 8 | function W(o, i) { 9 | o.scrollTop = o.scrollHeight - i + o.scrollTop; 10 | } 11 | function z(o, i) { 12 | o.scrollLeft = o.scrollWidth - i + o.scrollLeft; 13 | } 14 | const C = /* @__PURE__ */ h("div", { class: "loading" }, "Loading...", -1), M = /* @__PURE__ */ h("div", { class: "no-more" }, "No more.", -1), j = /* @__PURE__ */ h("div", { class: "no-results" }, "No results.", -1), A = /* @__PURE__ */ h("div", { class: "error" }, "Error.", -1), G = /* @__PURE__ */ H({ 15 | __name: "VueEternalLoading", 16 | props: { 17 | load: { 18 | required: !0, 19 | type: Function 20 | }, 21 | isInitial: { 22 | required: !1, 23 | type: Boolean, 24 | default: !0 25 | }, 26 | position: { 27 | required: !1, 28 | type: String, 29 | default: "default" 30 | }, 31 | container: { 32 | required: !1, 33 | type: Object, 34 | default: null 35 | }, 36 | margin: { 37 | required: !1, 38 | type: String, 39 | default: void 0 40 | } 41 | }, 42 | emits: ["update:isInitial"], 43 | setup(o, { emit: i }) { 44 | const t = o, l = _(); 45 | let s = _("loading"), r = _(t.isInitial), c = 0; 46 | function f() { 47 | B(() => { 48 | var e, n; 49 | t.position === "top" ? W( 50 | (e = t.container) != null ? e : document.documentElement, 51 | c 52 | ) : t.position === "left" && z( 53 | (n = t.container) != null ? n : document.documentElement, 54 | c 55 | ); 56 | }); 57 | } 58 | function L(e, n) { 59 | return e === 0 ? r.value ? (b(), "no-results") : (y(), "no-more") : e !== void 0 && n !== void 0 && e < n ? (y(), "no-more") : (r.value = !1, f(), d(), "loading"); 60 | } 61 | function y() { 62 | r.value = !1, a("no-more"), f(); 63 | } 64 | function b() { 65 | r.value = !1, a("no-results"), f(); 66 | } 67 | function F() { 68 | r.value = !1, a("error"), f(); 69 | } 70 | function q() { 71 | r.value = !0, a("loading"), d(); 72 | } 73 | function E() { 74 | a("loading"), d(); 75 | } 76 | function a(e) { 77 | s.value = e; 78 | } 79 | function S() { 80 | l.value && m.unobserve(l.value); 81 | } 82 | function d() { 83 | l.value && m.observe(l.value); 84 | } 85 | function V() { 86 | return new IntersectionObserver( 87 | ([e]) => { 88 | var n, k; 89 | e.isIntersecting && (t.position === "top" ? c = R( 90 | (n = t.container) != null ? n : document.documentElement 91 | ) : t.position === "left" && (c = T( 92 | (k = t.container) != null ? k : document.documentElement 93 | )), S(), t.load( 94 | { 95 | loaded: L, 96 | noMore: y, 97 | noResults: b, 98 | error: F 99 | }, 100 | { 101 | isFirstLoad: r.value 102 | } 103 | )); 104 | }, 105 | { 106 | root: t.container, 107 | threshold: 0, 108 | rootMargin: t.margin 109 | } 110 | ); 111 | } 112 | let m; 113 | return typeof IntersectionObserver < "u" && N( 114 | () => { 115 | m && S(), m = V(), d(); 116 | }, 117 | { 118 | flush: "post" 119 | } 120 | ), I( 121 | () => t.isInitial, 122 | (e) => { 123 | e && q(); 124 | } 125 | ), I(r, (e) => { 126 | e || i("update:isInitial", !1); 127 | }), (e, n) => (O(), P("div", { 128 | class: "vue-eternal-loading", 129 | ref_key: "rootRef", 130 | ref: l 131 | }, [ 132 | u(s) === "loading" ? v(e.$slots, "loading", p(g({ key: 0 }, { isFirstLoad: u(r) })), () => [ 133 | C 134 | ]) : u(s) === "no-more" ? v(e.$slots, "no-more", p(g({ key: 1 }, { retry: E })), () => [ 135 | M 136 | ]) : u(s) === "no-results" ? v(e.$slots, "no-results", p(g({ key: 2 }, { retry: E })), () => [ 137 | j 138 | ]) : u(s) === "error" ? v(e.$slots, "error", p(g({ key: 3 }, { retry: E })), () => [ 139 | A 140 | ]) : w("", !0) 141 | ], 512)); 142 | } 143 | }); 144 | export { 145 | G as VueEternalLoading 146 | }; 147 | -------------------------------------------------------------------------------- /docs/guide/simple-usage.md: -------------------------------------------------------------------------------- 1 | # Simple usage 2 | 3 | Here is a basic example of how to use **vue-eternal-loading**. 4 | 5 | When using **vue-eternal-loading**, the only required prop you need to implement is `load`. This callback method will be automatically called when it's time to load more data. `load` takes two arguments, which will be described later. For now, we only need the first one. 6 | 7 | ```html 8 | 9 | ``` 10 | ```ts 11 | const PAGE_SIZE = 5; 12 | 13 | // We must pass this method to the component, 14 | // and it will be called automatically when needed. 15 | async function load({ loaded }) { 16 | // Load your data from the server or any other source. 17 | const loadedItems = await loadItems(page); 18 | items.value.push(...loadedItems); 19 | page += 1; 20 | // Call the `loaded` callback with 2 arguments: 21 | // 1. The number of items we have loaded 22 | // 2. Our page size to know when we have reached the end 23 | loaded(loadedItems.length, PAGE_SIZE); 24 | } 25 | ``` 26 | 27 | > **_NOTE:_** You can find a detailed explanation and explore other possibilities of the component in the following sections. 28 | 29 | ## Example 30 | 31 | Here, you can scroll down to view more content. When you reach the end, you will see the message "No more." All the texts in this example are set to their default values. 32 | 33 | 34 | 35 | ## TypeScript 36 | ```vue 37 | 46 | 47 | 74 | ``` 75 | 76 | ## JavaScript ( ES ) 77 | ```vue 78 | 87 | 88 | 111 | ``` 112 | 113 | ## Browser 114 | ```html 115 | 116 | 117 | 118 |
119 |
120 | 121 |
122 | 123 | 124 |
125 | 126 | 159 | ``` 160 | 161 | -------------------------------------------------------------------------------- /docs/guide/loading-states.md: -------------------------------------------------------------------------------- 1 | # Loading states 2 | 3 | The **vue-eternal-loading** component has four different states that can render different templates and influence the behavior of the component: 4 | 5 | - **loading**: This is the default state when we are trying to load new content. In this state, the `load` prop triggers automatically when needed. Default template: `
Loading...
` 6 | 7 | 8 | - **no-more**: This state indicates that we have reached the end of the available content. It occurs when the server responds with empty content or content that is less than a full page. In this state, the `load` prop is not called anymore. Default template: `
No more.
` 9 | 10 | 11 | - **no-results**: This state means that we have no content at all. It can occur when we attempt to load content from the server, but the initial request returns zero items. In such cases, we may want to display a "No results" message. In this state, the `load` prop is not called anymore. Default template: `
No results.
` 12 | 13 | 14 | - **error** - This state indicates that we encountered an error from the server or any other source. In this state, the `load` prop is not called anymore. Default template: `
Error.
` 15 | 16 | We can automatically switch between states by using the `loaded` callback within the `load` prop method, which will be described below. Alternatively, we can manually set any state, and we will explain this in a later section. 17 | 18 | --- 19 | 20 | In some cases, we may not want to have a state other than **loading**. For example, when implementing a loading feature that should never stop, such as loading logs, real-time news, or continuously attempting to load something. To achieve this behavior, we can call the `loaded` callback without any parameters. 21 | 22 | ```js 23 | function load({ loaded }) { 24 | // Load data from server 25 | // ... 26 | loaded(); 27 | } 28 | ``` 29 | 30 | 31 | --- 32 | 33 | If we use the `loaded` callback with one parameter (items count), we can reach two states: **no-more** and **no-results**. We might want to have these states in order to render the corresponding templates. If we call `loaded(0)` during our first load, we will enter the **no-results** state. 34 | ```js 35 | function load({ loaded }) { 36 | // Load data from server 37 | // ... 38 | // And items.length === 0 39 | loaded(items.length); 40 | } 41 | ``` 42 | 43 | 44 | If we call `loaded(0)` during our second or subsequent load, we will enter the **no-more** state. This indicates that we have previously loaded content, but we have now reached the end and there is no more content available to load. 45 | ```js 46 | function load({ loaded }) { 47 | // Load data from server 48 | // ... 49 | // items.length === 0 and this is 2+ try 50 | loaded(items.length); 51 | } 52 | ``` 53 | 54 | 55 | In the example mentioned above, we encountered an extra request before reaching the **no-more** state. This occurs because we don't know the exact page size, and we can only set the **no-more** state if we receive an empty response. This situation is acceptable when the page size is unknown or when the number of items per request may vary. However, if you expect a specific item count per page, it is good practice to pass a second parameter to the `loaded` callback, where you can specify your page size. This helps prevent unnecessary extra requests to the server and allows us to set the **no-more** state when we receive an items count less than the page size. 56 | ```js 57 | const PAGE_SIZE = 5; 58 | 59 | function load({ loaded }) { 60 | // Load data from server 61 | // ... 62 | loaded(items.length, PAGE_SIZE); 63 | } 64 | ``` 65 | 66 | 67 | --- 68 | 69 | We have one more state called **error**, but it cannot be reached automatically just by using the `loaded` callback. This is because **vue-eternal-loading** is not aware of loading errors, and it switches states based on the information you pass to the `loaded` callback. The information provided is not sufficient to set the **error** state. In the upcoming sections, we will learn how to manually set the **error** state. 70 | 71 | --- 72 | 73 | If you want to know the current state inside the `load` function, the `loaded()` callback can return it for you. 74 | ```js 75 | function load({ loaded }) { 76 | // Load data from server 77 | // ... 78 | const state = loaded(); 79 | if (state === 'no-more') { 80 | alert('Boom! You have reached the end.') 81 | } 82 | } 83 | ``` 84 | -------------------------------------------------------------------------------- /src/components/VueEternalLoading/VueEternalLoading.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 229 | -------------------------------------------------------------------------------- /src/components/VueEternalLoading/__tests__/VueEternalLoading.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/ban-ts-comment */ 2 | import { shallowMount } from '@vue/test-utils'; 3 | import { nextTick } from 'vue'; 4 | import { 5 | getScrollHeightFromEl, 6 | getScrollWidthFromEl, 7 | restoreScrollHorizontalPosition, 8 | restoreScrollVerticalPosition, 9 | } from '../helpers/scroll/scroll'; 10 | import type { LoadAction, LoadPayload } from '../helpers/type/type'; 11 | import VueEternalLoading from '../VueEternalLoading.vue'; 12 | 13 | jest.mock('../helpers/scroll/scroll'); 14 | 15 | describe('VueEternalLoading', () => { 16 | let callback: IntersectionObserverCallback; 17 | let observe: (target: Element) => void; 18 | let unobserve: (target: Element) => void; 19 | let action: LoadAction; 20 | let payload: LoadPayload; 21 | 22 | function getComponent(isInitial = true, container?: HTMLElement | null) { 23 | return shallowMount(VueEternalLoading, { 24 | props: { 25 | load(a: LoadAction, p: LoadPayload) { 26 | action = a; 27 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 28 | payload = p; 29 | }, 30 | isInitial, 31 | container, 32 | }, 33 | slots: { 34 | loading: 'STATE_LOADING', 35 | 'no-more': 'STATE_NO_MORE', 36 | 'no-results': 'STATE_NO_RESULTS', 37 | error: 'STATE_ERROR', 38 | }, 39 | }); 40 | } 41 | 42 | // Trigger IntersectionObserver hit. 43 | function runCallback(isIntersecting: boolean) { 44 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 45 | // @ts-ignore 46 | callback([ 47 | { 48 | isIntersecting, 49 | }, 50 | ]); 51 | } 52 | 53 | beforeEach(() => { 54 | jest.resetAllMocks(); 55 | 56 | observe = jest.fn(); 57 | unobserve = jest.fn(); 58 | // Mocked version of IntersectionObserver. 59 | class IntersectionObserver { 60 | readonly root = null; 61 | readonly rootMargin = ''; 62 | readonly thresholds = [0]; 63 | 64 | constructor(cb: IntersectionObserverCallback) { 65 | callback = cb; 66 | } 67 | 68 | observe = observe; 69 | unobserve = unobserve; 70 | disconnect = jest.fn(); 71 | takeRecords = jest.fn(); 72 | } 73 | window.IntersectionObserver = IntersectionObserver; 74 | }); 75 | 76 | test('common flow', async () => { 77 | const wrapper = getComponent(); 78 | 79 | expect(observe).toHaveBeenCalledTimes(1); 80 | expect(unobserve).not.toHaveBeenCalled(); 81 | expect(wrapper.html()).toContain('STATE_LOADING'); 82 | 83 | runCallback(false); 84 | expect(observe).toHaveBeenCalledTimes(1); 85 | expect(unobserve).not.toHaveBeenCalled(); 86 | // @ts-ignore 87 | expect(action).toBeUndefined(); 88 | 89 | runCallback(true); 90 | expect(observe).toHaveBeenCalledTimes(1); 91 | expect(unobserve).toHaveBeenCalledTimes(1); 92 | // @ts-ignore 93 | expect(action.loaded).toBeDefined(); 94 | // @ts-ignore 95 | action.loaded(); 96 | expect(observe).toHaveBeenCalledTimes(2); 97 | expect(unobserve).toHaveBeenCalledTimes(1); 98 | 99 | runCallback(true); 100 | // @ts-ignore 101 | action.loaded(); 102 | expect(observe).toHaveBeenCalledTimes(3); 103 | expect(unobserve).toHaveBeenCalledTimes(2); 104 | 105 | runCallback(true); 106 | // @ts-ignore 107 | action.loaded(0); 108 | expect(observe).toHaveBeenCalledTimes(3); 109 | expect(unobserve).toHaveBeenCalledTimes(3); 110 | await nextTick(); 111 | expect(wrapper.html()).toContain('STATE_NO_MORE'); 112 | }); 113 | 114 | test('loaded with pageSize param', async () => { 115 | const wrapper = getComponent(); 116 | runCallback(true); 117 | expect(observe).toHaveBeenCalledTimes(1); 118 | expect(unobserve).toHaveBeenCalledTimes(1); 119 | // @ts-ignore 120 | let state = action.loaded(5, 5); 121 | expect(state).toBe('loading'); 122 | expect(observe).toHaveBeenCalledTimes(2); 123 | expect(unobserve).toHaveBeenCalledTimes(1); 124 | await nextTick(); 125 | expect(wrapper.html()).toContain('STATE_LOADING'); 126 | // @ts-ignore 127 | state = action.loaded(4, 5); 128 | expect(state).toBe('no-more'); 129 | await nextTick(); 130 | expect(wrapper.html()).toContain('STATE_NO_MORE'); 131 | }); 132 | 133 | test('loaded sets no-results', async () => { 134 | const wrapper = getComponent(); 135 | runCallback(true); 136 | // @ts-ignore 137 | const state = action.loaded(0, 5); 138 | expect(state).toBe('no-results'); 139 | expect(observe).toHaveBeenCalledTimes(1); 140 | expect(unobserve).toHaveBeenCalledTimes(1); 141 | await nextTick(); 142 | expect(wrapper.html()).toContain('STATE_NO_RESULTS'); 143 | }); 144 | 145 | test('noMore / noResults / error', async () => { 146 | const wrapper = getComponent(); 147 | runCallback(true); 148 | // @ts-ignore 149 | action.noMore(); 150 | expect(observe).toHaveBeenCalledTimes(1); 151 | expect(unobserve).toHaveBeenCalledTimes(1); 152 | await nextTick(); 153 | expect(wrapper.html()).toContain('STATE_NO_MORE'); 154 | // @ts-ignore 155 | action.noResults(); 156 | expect(observe).toHaveBeenCalledTimes(1); 157 | expect(unobserve).toHaveBeenCalledTimes(1); 158 | await nextTick(); 159 | expect(wrapper.html()).toContain('STATE_NO_RESULTS'); 160 | // @ts-ignore 161 | action.error(); 162 | expect(observe).toHaveBeenCalledTimes(1); 163 | expect(unobserve).toHaveBeenCalledTimes(1); 164 | await nextTick(); 165 | expect(wrapper.html()).toContain('STATE_ERROR'); 166 | }); 167 | 168 | test('v-model:is-initial', async () => { 169 | const wrapper = getComponent(); 170 | runCallback(true); 171 | // @ts-ignore 172 | action.noResults(); 173 | await nextTick(); 174 | expect(wrapper.html()).toContain('STATE_NO_RESULTS'); 175 | expect(wrapper.emitted()).toEqual({ 'update:isInitial': [[false]] }); 176 | await wrapper.setProps({ isInitial: false }); 177 | await wrapper.setProps({ isInitial: true }); 178 | await nextTick(); 179 | expect(wrapper.html()).toContain('STATE_LOADING'); 180 | }); 181 | 182 | test('initialization with falsy isInitial', async () => { 183 | const wrapper = getComponent(false); 184 | runCallback(true); 185 | // @ts-ignore 186 | action.loaded(0); 187 | await nextTick(); 188 | expect(wrapper.html()).toContain('STATE_NO_MORE'); 189 | }); 190 | 191 | test('top / left positions without container', async () => { 192 | const wrapper = getComponent(false); 193 | await wrapper.setProps({ position: 'top' }); 194 | runCallback(true); 195 | expect(getScrollHeightFromEl).toHaveBeenCalledWith( 196 | window.document.documentElement 197 | ); 198 | // @ts-ignore 199 | action.loaded(); 200 | await nextTick(); 201 | expect(restoreScrollVerticalPosition).toHaveBeenCalledWith( 202 | window.document.documentElement, 203 | undefined 204 | ); 205 | 206 | await wrapper.setProps({ position: 'left' }); 207 | runCallback(true); 208 | expect(getScrollWidthFromEl).toHaveBeenCalledWith( 209 | window.document.documentElement 210 | ); 211 | // @ts-ignore 212 | action.loaded(); 213 | await nextTick(); 214 | expect(restoreScrollHorizontalPosition).toHaveBeenCalledWith( 215 | window.document.documentElement, 216 | undefined 217 | ); 218 | }); 219 | 220 | test('top / left positions with container', async () => { 221 | const container = document.createElement('div'); 222 | const wrapper = getComponent(false); 223 | await wrapper.setProps({ position: 'top' }); 224 | await wrapper.setProps({ container }); 225 | runCallback(true); 226 | expect(getScrollHeightFromEl).toHaveBeenCalledWith(container); 227 | // @ts-ignore 228 | action.loaded(); 229 | await nextTick(); 230 | expect(restoreScrollVerticalPosition).toHaveBeenCalledWith( 231 | container, 232 | undefined 233 | ); 234 | 235 | await wrapper.setProps({ position: 'left' }); 236 | runCallback(true); 237 | expect(getScrollWidthFromEl).toHaveBeenCalledWith(container); 238 | // @ts-ignore 239 | action.loaded(); 240 | await nextTick(); 241 | expect(restoreScrollHorizontalPosition).toHaveBeenCalledWith( 242 | container, 243 | undefined 244 | ); 245 | }); 246 | 247 | test('container is null', async () => { 248 | const wrapper = getComponent(true, null); 249 | expect(observe).toHaveBeenCalled(); 250 | expect(unobserve).not.toHaveBeenCalled(); 251 | 252 | await wrapper.setProps({ container: document.documentElement }); 253 | expect(observe).toHaveBeenCalledTimes(2); 254 | expect(unobserve).toHaveBeenCalledTimes(1); 255 | 256 | await wrapper.setProps({ container: document.body }); 257 | expect(observe).toHaveBeenCalledTimes(3); 258 | expect(unobserve).toHaveBeenCalledTimes(2); 259 | }); 260 | }); 261 | --------------------------------------------------------------------------------