├── .all-contributorsrc ├── .commitlintrc.json ├── .editorconfig ├── .eslintignore ├── .eslintrc.json ├── .gitattributes ├── .github ├── actions │ └── install-npm-deps │ │ └── action.yml ├── copilot-instructions.md ├── dependabot.yml ├── funding.yml └── workflows │ ├── main.yml │ ├── release-please.yml │ └── release.yml ├── .gitignore ├── .husky ├── commit-msg └── pre-commit ├── .lintstagedrc.json ├── .mcprc.json ├── .npmrc ├── .nvmrc ├── .prettierignore ├── .prettierrc.json ├── .vscode ├── extensions.json ├── launch.json ├── settings.json └── tasks.json ├── AGENTS.md ├── CHANGELOG.md ├── CODEOWNERS ├── CODE_OF_CONDUCT.md ├── LICENSE ├── README.md ├── angular.json ├── jest-global-mocks.ts ├── jest.config.ts ├── mcp.json ├── package-lock.json ├── package.json ├── projects ├── demo-e2e │ ├── artifacts │ │ └── .gitignore │ ├── cypress.config.ts │ ├── src │ │ ├── fixtures │ │ │ └── .gitkeep │ │ ├── index.d.ts │ │ ├── support │ │ │ ├── commands.ts │ │ │ ├── e2e.ts │ │ │ └── utils.ts │ │ └── tests │ │ │ └── demo.cy.ts │ └── tsconfig.json ├── demo │ ├── .eslintrc.json │ ├── jest-setup.ts │ ├── jest.config.ts │ ├── src │ │ ├── app │ │ │ ├── __snapshots__ │ │ │ │ └── app.component.spec.ts.snap │ │ │ ├── app.component.html │ │ │ ├── app.component.scss │ │ │ ├── app.component.spec.ts │ │ │ ├── app.component.ts │ │ │ ├── app.config.ts │ │ │ └── example │ │ │ │ ├── __snapshots__ │ │ │ │ └── example.component.spec.ts.snap │ │ │ │ ├── example.component.html │ │ │ │ ├── example.component.scss │ │ │ │ ├── example.component.spec.ts │ │ │ │ ├── example.component.ts │ │ │ │ └── section-options.pipe.ts │ │ ├── assets │ │ │ └── .gitkeep │ │ ├── favicon.ico │ │ ├── index.html │ │ ├── main.ts │ │ └── styles.scss │ ├── tsconfig.app.json │ └── tsconfig.spec.json ├── example-e2e │ ├── artifacts │ │ └── .gitignore │ ├── cypress.config.ts │ ├── src │ │ ├── fixtures │ │ │ └── .gitkeep │ │ ├── index.d.ts │ │ ├── support │ │ │ ├── commands.ts │ │ │ └── e2e.ts │ │ └── tests │ │ │ └── page-highlighting.cy.ts │ └── tsconfig.json ├── example │ ├── .eslintrc.json │ ├── jest-setup.ts │ ├── jest.config.ts │ ├── src │ │ ├── app │ │ │ ├── app.component.html │ │ │ ├── app.component.scss │ │ │ ├── app.component.ts │ │ │ ├── app.config.ts │ │ │ ├── app.routes.ts │ │ │ ├── page-highlighting │ │ │ │ ├── __snapshots__ │ │ │ │ │ └── page-highlighting.component.spec.ts.snap │ │ │ │ ├── page-highlighting.component.html │ │ │ │ ├── page-highlighting.component.scss │ │ │ │ ├── page-highlighting.component.spec.ts │ │ │ │ └── page-highlighting.component.ts │ │ │ ├── page-infinite-scroll │ │ │ │ ├── page-infinite-scroll.component.html │ │ │ │ ├── page-infinite-scroll.component.scss │ │ │ │ └── page-infinite-scroll.component.ts │ │ │ └── page-lazy-images │ │ │ │ ├── lazy-image-skeleton │ │ │ │ ├── lazy-image-skeleton.component.html │ │ │ │ ├── lazy-image-skeleton.component.scss │ │ │ │ └── lazy-image-skeleton.component.ts │ │ │ │ ├── lazy-image.directive.ts │ │ │ │ ├── page-lazy-images.component.html │ │ │ │ ├── page-lazy-images.component.scss │ │ │ │ └── page-lazy-images.component.ts │ │ ├── assets │ │ │ └── .gitkeep │ │ ├── favicon.ico │ │ ├── index.html │ │ ├── main.ts │ │ └── styles.scss │ ├── tsconfig.app.json │ └── tsconfig.spec.json └── ng-in-viewport │ ├── .eslintrc.json │ ├── jest-setup.ts │ ├── jest.config.ts │ ├── ng-package.json │ ├── package.json │ ├── src │ ├── lib │ │ ├── directives │ │ │ ├── destroyable.directive.spec.ts │ │ │ ├── destroyable.directive.ts │ │ │ ├── in-viewport.directive.spec.ts │ │ │ ├── in-viewport.directive.ts │ │ │ └── index.ts │ │ ├── enums │ │ │ ├── in-viewport-direction.ts │ │ │ └── index.ts │ │ ├── exceptions │ │ │ ├── index.ts │ │ │ ├── invalid-direction.exception.spec.ts │ │ │ ├── invalid-direction.exception.ts │ │ │ ├── invalid-root-margin.exception.spec.ts │ │ │ ├── invalid-root-margin.exception.ts │ │ │ ├── invalid-root-node.exception.spec.ts │ │ │ ├── invalid-root-node.exception.ts │ │ │ ├── invalid-threshold.exception.spec.ts │ │ │ └── invalid-threshold.exception.ts │ │ ├── in-viewport.module.ts │ │ ├── services │ │ │ ├── in-viewport.service.spec.ts │ │ │ ├── in-viewport.service.ts │ │ │ └── index.ts │ │ ├── utils │ │ │ ├── index.ts │ │ │ ├── is-object.spec.ts │ │ │ ├── is-object.ts │ │ │ ├── observer-cache-item.spec.ts │ │ │ ├── observer-cache-item.ts │ │ │ ├── observer-cache.spec.ts │ │ │ ├── observer-cache.ts │ │ │ ├── stringify.spec.ts │ │ │ ├── stringify.ts │ │ │ ├── to-base64.spec.ts │ │ │ └── to-base64.ts │ │ └── values │ │ │ ├── check-fn.spec.ts │ │ │ ├── check-fn.ts │ │ │ ├── config.spec.ts │ │ │ ├── config.ts │ │ │ ├── direction.spec.ts │ │ │ ├── direction.ts │ │ │ ├── index.ts │ │ │ ├── partial.spec.ts │ │ │ ├── partial.ts │ │ │ ├── root-margin.spec.ts │ │ │ ├── root-margin.ts │ │ │ ├── root-node.spec.ts │ │ │ ├── root-node.ts │ │ │ ├── threshold.spec.ts │ │ │ └── threshold.ts │ └── public-api.ts │ ├── tsconfig.lib.json │ ├── tsconfig.lib.prod.json │ └── tsconfig.spec.json └── tsconfig.json /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "projectName": "ng-in-viewport", 3 | "projectOwner": "k3nsei", 4 | "repoType": "github", 5 | "repoHost": "https://github.com", 6 | "files": [ 7 | "README.md" 8 | ], 9 | "imageSize": 100, 10 | "commit": false, 11 | "commitConvention": "angular", 12 | "contributors": [ 13 | { 14 | "login": "k3nsei", 15 | "name": "Piotr Stępniewski", 16 | "avatar_url": "https://avatars2.githubusercontent.com/u/190422?v=4", 17 | "profile": "https://github.com/k3nsei", 18 | "contributions": [ 19 | "code", 20 | "doc", 21 | "review", 22 | "test" 23 | ] 24 | }, 25 | { 26 | "login": "Bengejd", 27 | "name": "Jordan Benge", 28 | "avatar_url": "https://avatars3.githubusercontent.com/u/11723093?v=4", 29 | "profile": "https://github.com/Bengejd", 30 | "contributions": [ 31 | "blog" 32 | ] 33 | }, 34 | { 35 | "login": "numerized", 36 | "name": "Kévin Perrée", 37 | "avatar_url": "https://avatars1.githubusercontent.com/u/166829?v=4", 38 | "profile": "https://github.com/numerized", 39 | "contributions": [ 40 | "bug" 41 | ] 42 | }, 43 | { 44 | "login": "OzoTek", 45 | "name": "Alexandre Couret", 46 | "avatar_url": "https://avatars3.githubusercontent.com/u/6436053?v=4", 47 | "profile": "https://github.com/OzoTek", 48 | "contributions": [ 49 | "bug" 50 | ] 51 | }, 52 | { 53 | "login": "anwar-elmawardy", 54 | "name": "anwar-elmawardy", 55 | "avatar_url": "https://avatars0.githubusercontent.com/u/23740710?v=4", 56 | "profile": "https://github.com/anwar-elmawardy", 57 | "contributions": [ 58 | "bug" 59 | ] 60 | }, 61 | { 62 | "login": "jwillebrands", 63 | "name": "Jan-Willem Willebrands", 64 | "avatar_url": "https://avatars0.githubusercontent.com/u/8925?v=4", 65 | "profile": "https://github.com/jwillebrands", 66 | "contributions": [ 67 | "bug" 68 | ] 69 | }, 70 | { 71 | "login": "CSchulz", 72 | "name": "CSchulz ", 73 | "avatar_url": "https://avatars2.githubusercontent.com/u/1520593?v=4", 74 | "profile": "https://github.com/CSchulz", 75 | "contributions": [ 76 | "bug" 77 | ] 78 | }, 79 | { 80 | "login": "Silvest89", 81 | "name": "Johnnie Ho", 82 | "avatar_url": "https://avatars2.githubusercontent.com/u/2388338?v=4", 83 | "profile": "https://github.com/Silvest89", 84 | "contributions": [ 85 | "bug" 86 | ] 87 | }, 88 | { 89 | "login": "pasevin", 90 | "name": "Aleksandr Pasevin", 91 | "avatar_url": "https://avatars2.githubusercontent.com/u/1058469?v=4", 92 | "profile": "https://github.com/pasevin", 93 | "contributions": [ 94 | "code", 95 | "bug" 96 | ] 97 | }, 98 | { 99 | "login": "wkurniawan07", 100 | "name": "Wilson Kurniawan", 101 | "avatar_url": "https://avatars2.githubusercontent.com/u/7261051?v=4", 102 | "profile": "https://github.com/wkurniawan07", 103 | "contributions": [ 104 | "bug" 105 | ] 106 | }, 107 | { 108 | "login": "basters", 109 | "name": "Eugene", 110 | "avatar_url": "https://avatars0.githubusercontent.com/u/17099950?v=4", 111 | "profile": "https://github.com/basters", 112 | "contributions": [ 113 | "code" 114 | ] 115 | }, 116 | { 117 | "login": "samizarraa", 118 | "name": "Sami Zarraa", 119 | "avatar_url": "https://avatars3.githubusercontent.com/u/20872538?v=4", 120 | "profile": "https://github.com/samizarraa", 121 | "contributions": [ 122 | "bug" 123 | ] 124 | }, 125 | { 126 | "login": "Jonnyprof", 127 | "name": "JordiJS", 128 | "avatar_url": "https://avatars.githubusercontent.com/u/9952131?v=4", 129 | "profile": "https://github.com/Jonnyprof", 130 | "contributions": [ 131 | "bug" 132 | ] 133 | }, 134 | { 135 | "login": "mpschaeuble", 136 | "name": "mpschaeuble", 137 | "avatar_url": "https://avatars.githubusercontent.com/u/18322360?v=4", 138 | "profile": "https://github.com/mpschaeuble", 139 | "contributions": [ 140 | "bug" 141 | ] 142 | }, 143 | { 144 | "login": "karptonite", 145 | "name": "Daniel Karp", 146 | "avatar_url": "https://avatars.githubusercontent.com/u/132278?v=4", 147 | "profile": "https://github.com/karptonite", 148 | "contributions": [ 149 | "bug" 150 | ] 151 | }, 152 | { 153 | "login": "tinesoft", 154 | "name": "Tine Kondo", 155 | "avatar_url": "https://avatars.githubusercontent.com/u/4053092?v=4", 156 | "profile": "https://github.com/tinesoft", 157 | "contributions": [ 158 | "ideas", "review" 159 | ] 160 | }, 161 | { 162 | "login": "BojanKogoj", 163 | "name": "Bojan Kogoj", 164 | "avatar_url": "https://avatars.githubusercontent.com/u/634075?v=4", 165 | "profile": "https://github.com/BojanKogoj", 166 | "contributions": [ 167 | "doc" 168 | ] 169 | } 170 | ], 171 | "contributorsPerLine": 7, 172 | "linkToUsage": false, 173 | "skipCi": true, 174 | "commitType": "docs" 175 | } 176 | -------------------------------------------------------------------------------- /.commitlintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["@commitlint/config-conventional"] 3 | } 4 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see https://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | indent_style = space 8 | indent_size = 2 9 | insert_final_newline = true 10 | trim_trailing_whitespace = true 11 | 12 | [*.ts] 13 | quote_type = single 14 | 15 | [*.md] 16 | max_line_length = off 17 | trim_trailing_whitespace = false 18 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | /dist 2 | /out-tsc 3 | /tmp 4 | /coverage 5 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "ignorePatterns": ["projects/**/*"], 4 | "settings": { 5 | "import/resolver": { 6 | "typescript": true, 7 | "node": true 8 | } 9 | }, 10 | "overrides": [ 11 | { 12 | "files": ["*.ts"], 13 | "extends": [ 14 | "eslint:recommended", 15 | "plugin:@typescript-eslint/recommended", 16 | "plugin:@angular-eslint/recommended", 17 | "plugin:@angular-eslint/template/process-inline-templates", 18 | "plugin:import/recommended", 19 | "plugin:import/typescript", 20 | "plugin:prettier/recommended" 21 | ], 22 | "rules": { 23 | "sort-imports": [ 24 | "error", 25 | { 26 | "ignoreDeclarationSort": true, 27 | "allowSeparatedGroups": true 28 | } 29 | ], 30 | "import/first": "error", 31 | "import/order": [ 32 | "error", 33 | { 34 | "alphabetize": { "order": "asc", "caseInsensitive": true }, 35 | "newlines-between": "always", 36 | "groups": [ 37 | ["builtin", "external"], 38 | "internal", 39 | "parent", 40 | ["sibling", "index"], 41 | "object", 42 | "type" 43 | ] 44 | } 45 | ], 46 | "@typescript-eslint/no-unused-vars": [ 47 | "error", 48 | { 49 | "argsIgnorePattern": "^_", 50 | "caughtErrors": "all", 51 | "caughtErrorsIgnorePattern": "^(_|ignore)", 52 | "destructuredArrayIgnorePattern": "^_", 53 | "ignoreRestSiblings": true 54 | } 55 | ], 56 | "@typescript-eslint/explicit-member-accessibility": [ 57 | "error", 58 | { 59 | "accessibility": "explicit", 60 | "overrides": { 61 | "constructors": "no-public" 62 | } 63 | } 64 | ] 65 | } 66 | }, 67 | { 68 | "files": ["*.html"], 69 | "extends": [ 70 | "plugin:@angular-eslint/template/recommended", 71 | "plugin:prettier/recommended" 72 | ], 73 | "rules": {} 74 | } 75 | ] 76 | } 77 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | .gitattributes !filter !diff 2 | 3 | # Source code 4 | *.coffee text 5 | *.css text 6 | *.htm text diff=html 7 | *.html text diff=html 8 | *.inc text 9 | *.js text 10 | *.json text 11 | *.jsx text 12 | *.less text 13 | *.ls text 14 | *.map text -diff 15 | *.od text 16 | *.onlydata text 17 | *.phtml text diff=php 18 | *.php text diff=php 19 | *.pl text 20 | *.py text diff=python 21 | *.rb text diff=ruby 22 | *.sass text 23 | *.scm text 24 | *.scss text diff=css 25 | *.sql text 26 | *.styl text 27 | *.tag text 28 | *.ts text 29 | *.tsx text 30 | *.xml text 31 | *.xhtml text diff=html 32 | 33 | # Templates 34 | *.dot text 35 | *.ejs text 36 | *.haml text 37 | *.handlebars text 38 | *.hbs text 39 | *.hbt text 40 | *.jade text 41 | *.latte text 42 | *.mustache text 43 | *.njk text 44 | *.phtml text 45 | *.tmpl text 46 | *.tpl text 47 | *.twig text 48 | *.vue text 49 | 50 | # Configs 51 | *.cnf text 52 | *.conf text 53 | *.config text 54 | *.ini text 55 | *.lock text -diff 56 | *.toml text 57 | *.xml text 58 | *.yml text 59 | *.yaml text 60 | .editorconfig text 61 | .env text 62 | .gitconfig text 63 | .htaccess text 64 | browserslist text 65 | Makefile text 66 | makefile text 67 | package-lock.json text -diff 68 | 69 | # Documentation 70 | *.ipynb text 71 | *.markdown text 72 | *.md text 73 | *.mdwn text 74 | *.mdown text 75 | *.mdx text 76 | *.mkd text 77 | *.mkdn text 78 | *.mdtxt text 79 | *.mdtext text 80 | AUTHORS text 81 | CHANGELOG text 82 | CHANGES text 83 | CONTRIBUTING text 84 | COPYING text 85 | copyright text 86 | *COPYRIGHT* text 87 | INSTALL text 88 | license text 89 | LICENSE text 90 | NEWS text 91 | readme text 92 | *README* text 93 | TODO text 94 | 95 | # Scripts 96 | *.bash text eol=lf 97 | *.bat text eol=crlf 98 | *.cmd ext eol=crlf 99 | *.fish text eol=lf 100 | *.ps1 text eol=crlf 101 | *.sh text eol=lf 102 | 103 | # Docker 104 | Dockerfile text 105 | 106 | # Heroku 107 | Procfile text 108 | 109 | # Text 110 | *.textile text eol=lf 111 | *.txt text eol=lf 112 | 113 | # Data 114 | *.csv text eol=lf 115 | 116 | # Fonts 117 | *.eot binary 118 | *.otf binary 119 | *.ttf binary 120 | *.woff binary 121 | *.woff2 binary 122 | 123 | # Images 124 | *.ai binary 125 | *.apng binary 126 | *.avif binary 127 | *.bmp binary 128 | *.cur binary 129 | *.eps binary 130 | *.gif binary 131 | *.gifv binary 132 | *.ico binary 133 | *.jfif binary 134 | *.jng binary 135 | *.jp2 binary 136 | *.jpg binary 137 | *.jpeg binary 138 | *.jpx binary 139 | *.jxr binary 140 | *.pdf binary 141 | *.pjp binary 142 | *.pjpeg binary 143 | *.png binary 144 | *.psb binary 145 | *.psd binary 146 | *.svg text 147 | *.svgz binary 148 | *.tif binary 149 | *.tiff binary 150 | *.wbmp binary 151 | *.webp binary 152 | 153 | # Audio 154 | *.kar binary 155 | *.m4a binary 156 | *.mid binary 157 | *.midi binary 158 | *.mp3 binary 159 | *.ogg binary 160 | *.ra binary 161 | 162 | # Videos 163 | *.3gp binary 164 | *.3gpp binary 165 | *.as binary 166 | *.asf binary 167 | *.asx binary 168 | *.fla binary 169 | *.flv binary 170 | *.m4v binary 171 | *.mkv binary 172 | *.mng binary 173 | *.mov binary 174 | *.mp4 binary 175 | *.mpg binary 176 | *.mpeg binary 177 | *.ogv binary 178 | *.swc binary 179 | *.swf binary 180 | *.webm binary 181 | 182 | # Archives 183 | *.7z binary 184 | *.gz binary 185 | *.jar binary 186 | *.rar binary 187 | *.tar binary 188 | *.tgz binary 189 | *.zip binary 190 | 191 | # Executables 192 | *.bin binary 193 | *.deb binary 194 | *.dll binary 195 | *.elf binary 196 | *.exe binary 197 | *.msi binary 198 | *.pyc binary 199 | *.so binary 200 | 201 | # Ignore files 202 | *.*ignore text 203 | 204 | # RC files 205 | *.*rc text 206 | 207 | # Auto detect text files and perform LF normalization 208 | * text=auto 209 | -------------------------------------------------------------------------------- /.github/actions/install-npm-deps/action.yml: -------------------------------------------------------------------------------- 1 | name: Setup Node.js and install NPM dependencies 2 | description: Install Node.js and NPM, then install NPM dependencies 3 | 4 | inputs: 5 | node-version: 6 | description: 'Which version of Node.js to install' 7 | required: false 8 | default: 'lts/*' 9 | registry-url: 10 | description: 'Which NPM registry url to use' 11 | required: false 12 | default: 'https://registry.npmjs.org' 13 | 14 | runs: 15 | using: composite 16 | steps: 17 | - name: Setup Node.js and NPM 18 | uses: actions/setup-node@v3 19 | with: 20 | node-version: ${{ inputs.node-version }} 21 | registry-url: ${{ inputs.registry-url }} 22 | cache: 'npm' 23 | cache-dependency-path: '**/package-lock.json' 24 | 25 | - name: Install NPM dependencies 26 | shell: bash 27 | run: npm ci 28 | -------------------------------------------------------------------------------- /.github/copilot-instructions.md: -------------------------------------------------------------------------------- 1 | # GitHub Copilot Instructions for ng-in-viewport 2 | 3 | **Angular Development Guidelines** 4 | _Ignore current project patterns - use only latest Angular v20+ standards and best practices_ 5 | 6 | ## Project Overview 7 | 8 | This is an Angular library for viewport detection using modern Angular v20+ patterns. The repository contains: 9 | 10 | - **Core library**: `projects/ng-in-viewport` - Signal-based viewport detection with Intersection Observer 11 | - **Demo application**: `projects/demo` - Documentation and interactive examples 12 | - **Example application**: `projects/example` - Real-world usage scenarios and performance testing 13 | - **Test suites**: Comprehensive unit and E2E testing with modern Angular testing patterns 14 | 15 | ## Environment Setup 16 | 17 | ### Node.js Requirements 18 | 19 | - **Required**: Node.js >=22.0.0 (LTS) 20 | - **Required**: npm >=10.0.0 21 | - **Package Manager**: Prefer `npm` with exact versions for consistency 22 | 23 | ### Dependency Installation 24 | 25 | For restricted environments, use network workarounds: 26 | 27 | ```bash 28 | CYPRESS_INSTALL_BINARY=0 npm ci 29 | ``` 30 | 31 | **Execution time**: ~15 seconds 32 | **Timeout**: Use 60+ seconds minimum, NEVER CANCEL 33 | 34 | ## Build Pipeline 35 | 36 | ### Complete Build Validation 37 | 38 | ```bash 39 | npm run format && npm run lint && npm run build:lib && npm run test:lib 40 | ``` 41 | 42 | **Execution time**: ~45 seconds 43 | **Timeout**: Use 120+ seconds minimum, NEVER CANCEL 44 | 45 | ### Individual Commands 46 | 47 | ```bash 48 | # Code Quality 49 | npm run format # Check formatting (5s) 50 | npm run format:write # Fix formatting (5s) 51 | npm run lint # Lint all projects (15s) 52 | 53 | # Library Build 54 | npm run build:lib # Production build (20s) 55 | npm run watch:lib # Development watch mode 56 | 57 | # Testing 58 | npm run test:lib # Unit tests with coverage (15s) 59 | npm run e2e:run # E2E tests headless (60s+) 60 | 61 | # Development Servers 62 | npm run serve:demo # Demo app - localhost:4200 63 | npm run serve:example # Example app - localhost:4300 64 | ``` 65 | 66 | ## Angular v20+ Development Standards 67 | 68 | ### Core Principles 69 | 70 | **Signal-First Architecture**: Use signals as the primary state management pattern. Observables are reserved for streams and HTTP operations only. 71 | 72 | **Component Design**: All components MUST be standalone with OnPush change detection and signal-based APIs. 73 | 74 | **Template Syntax**: Exclusive use of modern control flow (`@if`, `@for`, `@switch`) and direct binding patterns. 75 | 76 | ### TypeScript Configuration 77 | 78 | ```typescript 79 | // Use strict TypeScript with latest features 80 | { 81 | "compilerOptions": { 82 | "strict": true, 83 | "exactOptionalPropertyTypes": true, 84 | "noUncheckedIndexedAccess": true, 85 | "noImplicitReturns": true, 86 | "noFallthroughCasesInSwitch": true 87 | } 88 | } 89 | ``` 90 | 91 | **Type Standards**: 92 | 93 | - NEVER use `any` - use `unknown` for uncertain types 94 | - Use `satisfies` operator for type checking with inference 95 | - Prefer `readonly` for all data that shouldn't be mutated 96 | - Use template literal types for string constants 97 | 98 | ### Component Architecture 99 | 100 | ```typescript 101 | import { 102 | ChangeDetectionStrategy, 103 | Component, 104 | computed, 105 | effect, 106 | inject, 107 | input, 108 | output, 109 | signal, 110 | ElementRef, 111 | } from '@angular/core'; 112 | 113 | @Component({ 114 | selector: 'viewport-element', 115 | standalone: true, 116 | changeDetection: ChangeDetectionStrategy.OnPush, 117 | template: ` 118 | @if (isInViewport()) { 119 |
120 | Content is visible ({{ visibilityRatio() }}%) 121 |
122 | } @else { 123 | 124 | } 125 | `, 126 | host: { 127 | '[attr.data-viewport-state]': 'viewportState()', 128 | '[class.in-viewport]': 'isInViewport()', 129 | }, 130 | }) 131 | export class ViewportElementComponent { 132 | // Signal inputs - primary pattern for v20+ 133 | readonly threshold = input(0.5); 134 | readonly rootMargin = input('0px'); 135 | readonly trackVisibility = input(true); 136 | 137 | // Signal outputs - modern event handling 138 | readonly visibilityChange = output<{ 139 | isVisible: boolean; 140 | entry: IntersectionObserverEntry; 141 | }>(); 142 | 143 | // Dependency injection with inject() 144 | private readonly elementRef = inject(ElementRef); 145 | private readonly viewportService = inject(ViewportService); 146 | 147 | // Internal signals 148 | protected readonly isInViewport = signal(false); 149 | protected readonly visibilityRatio = signal(0); 150 | private readonly lastEntry = signal(null); 151 | 152 | // Computed values - derived state 153 | protected readonly opacity = computed( 154 | () => this.visibilityRatio() * 0.8 + 0.2 155 | ); 156 | 157 | protected readonly viewportState = computed(() => 158 | this.isInViewport() ? 'visible' : 'hidden' 159 | ); 160 | 161 | constructor() { 162 | // Effects for side effects and reactions 163 | effect(() => { 164 | if (this.trackVisibility()) { 165 | this.setupViewportObserver(); 166 | } 167 | }); 168 | 169 | effect(() => { 170 | // Emit events when visibility changes 171 | const entry = this.lastEntry(); 172 | if (entry) { 173 | this.visibilityChange.emit({ 174 | isVisible: this.isInViewport(), 175 | entry, 176 | }); 177 | } 178 | }); 179 | } 180 | 181 | private setupViewportObserver(): void { 182 | // Implementation with Intersection Observer 183 | this.viewportService.observe(this.elementRef.nativeElement, { 184 | threshold: this.threshold(), 185 | rootMargin: this.rootMargin(), 186 | callback: (entry) => { 187 | this.isInViewport.set(entry.isIntersecting); 188 | this.visibilityRatio.set(entry.intersectionRatio * 100); 189 | this.lastEntry.set(entry); 190 | }, 191 | }); 192 | } 193 | } 194 | ``` 195 | 196 | ### Service Design Patterns 197 | 198 | ```typescript 199 | import { Injectable, inject, signal, computed } from '@angular/core'; 200 | import { DOCUMENT } from '@angular/common'; 201 | import { isPlatformBrowser } from '@angular/common'; 202 | import { PLATFORM_ID } from '@angular/core'; 203 | 204 | interface ViewportConfig { 205 | threshold: number | number[]; 206 | rootMargin: string; 207 | callback: (entry: IntersectionObserverEntry) => void; 208 | } 209 | 210 | interface ViewportGlobalConfig { 211 | rootMargin: string; 212 | threshold: number[]; 213 | } 214 | 215 | @Injectable({ providedIn: 'root' }) 216 | export class ViewportService { 217 | private readonly document = inject(DOCUMENT); 218 | private readonly platformId = inject(PLATFORM_ID); 219 | 220 | // Signal-based state management 221 | private readonly _elements = signal>(new Map()); 222 | private readonly _globalConfig = signal({ 223 | rootMargin: '0px', 224 | threshold: [0, 0.25, 0.5, 0.75, 1], 225 | }); 226 | 227 | // Public computed properties 228 | readonly trackedElementsCount = computed(() => this._elements().size); 229 | readonly isActive = computed(() => this.trackedElementsCount() > 0); 230 | 231 | // Modern intersection observer setup 232 | private observer?: IntersectionObserver; 233 | 234 | constructor() { 235 | if (isPlatformBrowser(this.platformId)) { 236 | this.initializeObserver(); 237 | } 238 | } 239 | 240 | // Public API methods 241 | observe(element: Element, config?: Partial): () => void { 242 | if (!isPlatformBrowser(this.platformId)) { 243 | return () => {}; // No-op for SSR 244 | } 245 | 246 | const fullConfig = { ...this._globalConfig(), ...config } as ViewportConfig; 247 | 248 | this._elements.update((elements) => { 249 | const newElements = new Map(elements); 250 | newElements.set(element, fullConfig); 251 | return newElements; 252 | }); 253 | 254 | this.observer?.observe(element); 255 | 256 | // Return cleanup function 257 | return () => this.unobserve(element); 258 | } 259 | 260 | unobserve(element: Element): void { 261 | this._elements.update((elements) => { 262 | const newElements = new Map(elements); 263 | newElements.delete(element); 264 | return newElements; 265 | }); 266 | 267 | this.observer?.unobserve(element); 268 | } 269 | 270 | private initializeObserver(): void { 271 | if (typeof IntersectionObserver === 'undefined') { 272 | console.warn('IntersectionObserver not supported'); 273 | return; 274 | } 275 | 276 | this.observer = new IntersectionObserver( 277 | (entries) => this.handleIntersection(entries), 278 | this._globalConfig() 279 | ); 280 | } 281 | 282 | private handleIntersection(entries: IntersectionObserverEntry[]): void { 283 | const elements = this._elements(); 284 | 285 | entries.forEach((entry) => { 286 | const config = elements.get(entry.target); 287 | if (config?.callback) { 288 | config.callback(entry); 289 | } 290 | }); 291 | } 292 | } 293 | ``` 294 | 295 | ### Template Best Practices 296 | 297 | **Control Flow**: Exclusive use of Angular v20+ control flow syntax: 298 | 299 | ```html 300 | 301 | @if (isLoading()) { 302 | 303 | } @else if (hasError()) { 304 | 305 | } @else { 306 | 307 | } 308 | 309 | 310 | @for (item of items(); track item.id) { 311 | 316 | } @empty { 317 | 318 | } 319 | 320 | 321 | @switch (status()) { @case ('loading') { } @case ('error') { 322 | } @case ('success') { 323 | } @default { } } 324 | ``` 325 | 326 | **Binding Patterns**: Use direct property and class bindings: 327 | 328 | ```html 329 | 330 |
336 | 337 | 343 | 344 | 345 |
347 | ``` 348 | 349 | ### Directive Patterns 350 | 351 | ```typescript 352 | import { 353 | Directive, 354 | effect, 355 | inject, 356 | input, 357 | output, 358 | signal, 359 | ElementRef, 360 | OnDestroy, 361 | } from '@angular/core'; 362 | 363 | @Directive({ 364 | selector: '[viewportObserver]', 365 | standalone: true, 366 | host: { 367 | '[attr.data-in-viewport]': 'isInViewport()', 368 | }, 369 | }) 370 | export class ViewportObserverDirective implements OnDestroy { 371 | // Signal inputs 372 | readonly threshold = input(0.5); 373 | readonly rootMargin = input('0px'); 374 | 375 | // Signal outputs 376 | readonly viewportChange = output<{ 377 | isVisible: boolean; 378 | entry: IntersectionObserverEntry; 379 | }>(); 380 | 381 | // Injected dependencies 382 | private readonly elementRef = inject(ElementRef); 383 | private readonly viewportService = inject(ViewportService); 384 | 385 | // Internal state 386 | private readonly isInViewport = signal(false); 387 | private cleanup?: () => void; 388 | 389 | constructor() { 390 | effect(() => { 391 | // Setup observer when inputs change 392 | this.setupObserver(); 393 | }); 394 | 395 | effect(() => { 396 | // Emit changes when viewport state changes 397 | this.viewportChange.emit({ 398 | isVisible: this.isInViewport(), 399 | entry: this.lastEntry(), 400 | }); 401 | }); 402 | } 403 | 404 | ngOnDestroy(): void { 405 | this.cleanup?.(); 406 | } 407 | 408 | private setupObserver(): void { 409 | this.cleanup?.(); 410 | 411 | this.cleanup = this.viewportService.observe(this.elementRef.nativeElement, { 412 | threshold: this.threshold(), 413 | rootMargin: this.rootMargin(), 414 | callback: (entry) => { 415 | this.isInViewport.set(entry.isIntersecting); 416 | this.lastEntry.set(entry); 417 | }, 418 | }); 419 | } 420 | 421 | private readonly lastEntry = signal(null); 422 | } 423 | ``` 424 | 425 | ### Testing Patterns 426 | 427 | **Component Testing**: Use Angular v20+ testing utilities with signals: 428 | 429 | ```typescript 430 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 431 | import { signal } from '@angular/core'; 432 | import { ViewportElementComponent } from './viewport-element.component'; 433 | 434 | describe('ViewportElementComponent', () => { 435 | let component: ViewportElementComponent; 436 | let fixture: ComponentFixture; 437 | 438 | beforeEach(async () => { 439 | await TestBed.configureTestingModule({ 440 | imports: [ViewportElementComponent], // Standalone component 441 | }).compileComponents(); 442 | 443 | fixture = TestBed.createComponent(ViewportElementComponent); 444 | component = fixture.componentInstance; 445 | }); 446 | 447 | it('should emit visibility changes when signal updates', () => { 448 | const emitSpy = jest.spyOn(component.visibilityChange, 'emit'); 449 | 450 | // Update signal inputs directly 451 | fixture.componentRef.setInput('threshold', 0.8); 452 | fixture.detectChanges(); 453 | 454 | // Test signal-based state changes 455 | component['isInViewport'].set(true); 456 | component['visibilityRatio'].set(75); 457 | 458 | expect(component['opacity']()).toBe(0.8); // 0.75 * 0.8 + 0.2 459 | }); 460 | 461 | it('should compute opacity based on visibility ratio', () => { 462 | // Test computed signals 463 | component['visibilityRatio'].set(50); 464 | expect(component['opacity']()).toBe(0.6); // 0.5 * 0.8 + 0.2 465 | 466 | component['visibilityRatio'].set(100); 467 | expect(component['opacity']()).toBe(1.0); // 1.0 * 0.8 + 0.2 468 | }); 469 | 470 | it('should update viewport state based on visibility', () => { 471 | component['isInViewport'].set(true); 472 | expect(component['viewportState']()).toBe('visible'); 473 | 474 | component['isInViewport'].set(false); 475 | expect(component['viewportState']()).toBe('hidden'); 476 | }); 477 | }); 478 | ``` 479 | 480 | **Service Testing**: Test signal-based services: 481 | 482 | ```typescript 483 | import { TestBed } from '@angular/core/testing'; 484 | import { ViewportService } from './viewport.service'; 485 | import { PLATFORM_ID } from '@angular/core'; 486 | 487 | describe('ViewportService', () => { 488 | let service: ViewportService; 489 | let mockElement: HTMLElement; 490 | 491 | beforeEach(() => { 492 | TestBed.configureTestingModule({ 493 | providers: [{ provide: PLATFORM_ID, useValue: 'browser' }], 494 | }); 495 | service = TestBed.inject(ViewportService); 496 | mockElement = document.createElement('div'); 497 | }); 498 | 499 | it('should track elements correctly', () => { 500 | expect(service.trackedElementsCount()).toBe(0); 501 | expect(service.isActive()).toBe(false); 502 | 503 | const cleanup = service.observe(mockElement); 504 | expect(service.trackedElementsCount()).toBe(1); 505 | expect(service.isActive()).toBe(true); 506 | 507 | cleanup(); 508 | expect(service.trackedElementsCount()).toBe(0); 509 | expect(service.isActive()).toBe(false); 510 | }); 511 | 512 | it('should handle SSR gracefully', () => { 513 | TestBed.resetTestingModule(); 514 | TestBed.configureTestingModule({ 515 | providers: [{ provide: PLATFORM_ID, useValue: 'server' }], 516 | }); 517 | 518 | const ssrService = TestBed.inject(ViewportService); 519 | const cleanup = ssrService.observe(mockElement); 520 | 521 | // Should return no-op cleanup function 522 | expect(typeof cleanup).toBe('function'); 523 | expect(ssrService.trackedElementsCount()).toBe(0); 524 | }); 525 | }); 526 | ``` 527 | 528 | ### Performance Optimization 529 | 530 | **Signal Optimization**: 531 | 532 | - Use `computed()` for derived state - automatically optimized 533 | - Prefer `effect()` over manual subscriptions 534 | - Use `untracked()` to break signal dependencies when needed 535 | 536 | **Intersection Observer Optimization**: 537 | 538 | ```typescript 539 | // Efficient observer configuration 540 | private readonly observerConfig = computed(() => ({ 541 | root: this.root(), 542 | rootMargin: this.rootMargin(), 543 | threshold: this.threshold(), 544 | })); 545 | 546 | // Single observer instance with signal-based config updates 547 | effect(() => { 548 | this.updateObserverConfig(this.observerConfig()); 549 | }); 550 | ``` 551 | 552 | **Memory Management**: 553 | 554 | ```typescript 555 | // Automatic cleanup with effect cleanup 556 | effect((onCleanup) => { 557 | const cleanup = this.setupObserver(); 558 | 559 | onCleanup(() => { 560 | cleanup(); 561 | }); 562 | }); 563 | ``` 564 | 565 | ### Error Handling 566 | 567 | ```typescript 568 | // Signal-based error handling 569 | private readonly error = signal(null); 570 | private readonly isLoading = signal(false); 571 | 572 | protected readonly hasError = computed(() => this.error() !== null); 573 | protected readonly canRetry = computed(() => 574 | this.hasError() && !this.isLoading() 575 | ); 576 | 577 | async performOperation(): Promise { 578 | this.isLoading.set(true); 579 | this.error.set(null); 580 | 581 | try { 582 | await this.operation(); 583 | } catch (error) { 584 | this.error.set(error instanceof Error ? error : new Error(String(error))); 585 | } finally { 586 | this.isLoading.set(false); 587 | } 588 | } 589 | ``` 590 | 591 | ### Library-Specific Guidelines 592 | 593 | **Public API Design**: Expose signal-based APIs for consumers: 594 | 595 | ```typescript 596 | // Public API should use signals 597 | export interface ViewportDetectionApi { 598 | readonly isInViewport: Signal; 599 | readonly visibilityRatio: Signal; 600 | readonly observe: (element: Element) => () => void; 601 | } 602 | ``` 603 | 604 | **SSR Compatibility**: Always check platform and handle SSR: 605 | 606 | ```typescript 607 | import { isPlatformBrowser } from '@angular/common'; 608 | import { PLATFORM_ID, inject } from '@angular/core'; 609 | 610 | constructor() { 611 | const platformId = inject(PLATFORM_ID); 612 | 613 | if (isPlatformBrowser(platformId)) { 614 | this.initializeObserver(); 615 | } 616 | } 617 | ``` 618 | 619 | ### Migration Strategy 620 | 621 | **From Angular 17 to v20+**: 622 | 623 | 1. Replace all `@Input()` with `input()` 624 | 2. Replace all `@Output()` with `output()` 625 | 3. Convert component state to signals 626 | 4. Update templates to use `@if`, `@for`, `@switch` 627 | 5. Replace observables with signals where appropriate 628 | 6. Use `computed()` for derived state 629 | 7. Replace manual subscriptions with `effect()` 630 | 631 | **Breaking Changes to Expect**: 632 | 633 | - Remove all structural directives (`*ngIf`, `*ngFor`) 634 | - Remove `ngClass` and `ngStyle` - use direct bindings 635 | - Remove `async` pipe for signals (not needed) 636 | - Update event handling to use signal outputs 637 | 638 | ## Validation Requirements 639 | 640 | ### Manual Testing Protocol 641 | 642 | 1. **Demo App**: Verify all examples work with signal-based updates 643 | 2. **Example App**: Test performance with rapid viewport changes 644 | 3. **Responsive Testing**: Validate across viewport sizes 645 | 4. **Memory Testing**: Check for leaks during rapid scroll/resize 646 | 647 | ### Automated Testing 648 | 649 | ```bash 650 | npm run test:lib # Unit tests with signal coverage 651 | npm run e2e:run # E2E tests with viewport scenarios 652 | npm run test:performance # Performance regression tests 653 | ``` 654 | 655 | ### Expected Behavior 656 | 657 | - **Signal reactivity**: Immediate updates on viewport changes 658 | - **Performance**: 60fps during scroll with hundreds of elements 659 | - **Memory**: No leaks after component destruction 660 | - **SSR**: Graceful degradation without browser APIs 661 | 662 | ## Troubleshooting 663 | 664 | ### Common Migration Issues 665 | 666 | **Signal Conversion**: When updating to signals, ensure all dependencies are also signals or wrapped with signal access. 667 | 668 | **Effect Dependencies**: Effects automatically track signal dependencies - use `untracked()` to break unwanted dependencies. 669 | 670 | **Testing**: Signal-based tests require `fixture.detectChanges()` after signal updates. 671 | 672 | ### Performance Issues 673 | 674 | **Too Many Effects**: Combine related effects or use `computed()` for derived state. 675 | 676 | **Observer Thrashing**: Use debouncing for rapid viewport changes: 677 | 678 | ```typescript 679 | // Debounced viewport updates 680 | private readonly debouncedUpdate = computed(() => { 681 | const update = this.immediateUpdate(); 682 | return debounce(update, 16); // ~60fps 683 | }); 684 | ``` 685 | 686 | This guide ensures all Angular v20+ development follows the latest standards and patterns, ignoring outdated practices from earlier versions. 687 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: 'github-actions' 4 | directory: '/' 5 | schedule: 6 | interval: 'weekly' 7 | timezone: 'Europe/Warsaw' 8 | day: 'saturday' 9 | time: '06:00' 10 | target-branch: 'develop' 11 | open-pull-requests-limit: 10 12 | commit-message: 13 | prefix: 'ci' 14 | include: 'scope' 15 | assignees: ['k3nsei'] 16 | reviewers: ['k3nsei'] 17 | 18 | - package-ecosystem: 'npm' 19 | directory: '/' 20 | schedule: 21 | interval: 'monthly' 22 | timezone: 'Europe/Warsaw' 23 | day: 'saturday' 24 | time: '06:00' 25 | target-branch: 'develop' 26 | groups: 27 | angular: 28 | applies-to: 'version-updates' 29 | patterns: 30 | - '@angular/*' 31 | - '@angular-devkit/*' 32 | - 'ng-packagr' 33 | - 'zone.js' 34 | exclude-patterns: 35 | - '@angular/cdk' 36 | - '@angular/material' 37 | - '@angular/material-*' 38 | angular-material: 39 | applies-to: 'version-updates' 40 | patterns: 41 | - '@angular/cdk' 42 | - '@angular/material' 43 | - '@angular/material-*' 44 | angular-eslint: 45 | applies-to: 'version-updates' 46 | patterns: 47 | - '@angular-eslint/*' 48 | eslint: 49 | applies-to: 'version-updates' 50 | patterns: 51 | - '@typescript-eslint/*' 52 | - 'eslint' 53 | - 'eslint-*' 54 | jest: 55 | applies-to: 'version-updates' 56 | patterns: 57 | - '@types/jest' 58 | - 'jest' 59 | - 'jest-*' 60 | - 'ts-jest' 61 | open-pull-requests-limit: 20 62 | commit-message: 63 | prefix: 'chore' 64 | prefix-development: 'chore' 65 | include: 'scope' 66 | assignees: ['k3nsei'] 67 | reviewers: ['k3nsei'] 68 | -------------------------------------------------------------------------------- /.github/funding.yml: -------------------------------------------------------------------------------- 1 | github: k3nsei 2 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: main 2 | 3 | concurrency: 4 | group: ${{ github.workflow }}-${{ github.ref }} 5 | cancel-in-progress: true 6 | 7 | on: 8 | pull_request: 9 | branches: [develop] 10 | push: 11 | branches: [stable, develop] 12 | workflow_dispatch: 13 | inputs: 14 | reason: 15 | description: Why was the workflow triggered manually? 16 | required: true 17 | type: string 18 | 19 | permissions: 20 | contents: read 21 | 22 | jobs: 23 | build: 24 | name: Run build and unit tests 25 | runs-on: ubuntu-latest 26 | steps: 27 | - name: Checkout repository 28 | uses: actions/checkout@v3 29 | 30 | - name: Setup Node.js and install NPM dependencies 31 | uses: ./.github/actions/install-npm-deps 32 | 33 | - name: Check code format 34 | run: npm run format 35 | 36 | - name: Lint 37 | run: | 38 | npm run lint 39 | 40 | - name: Build 41 | run: | 42 | npm run build:lib 43 | npm run build:demo -- --progress=false 44 | npm run build:example -- --progress=false 45 | 46 | - name: Test 47 | run: | 48 | npm run test:lib 49 | npm run test:demo 50 | npm run test:example 51 | 52 | - name: Upload library build artifacts 53 | uses: actions/upload-artifact@v4 54 | with: 55 | name: artifacts-ng-in-viewport 56 | path: dist/ng-in-viewport 57 | 58 | - name: Upload demo application build artifacts 59 | uses: actions/upload-artifact@v4 60 | with: 61 | name: artifacts-demo 62 | path: dist/demo 63 | 64 | - name: Upload example application build artifacts 65 | uses: actions/upload-artifact@v4 66 | with: 67 | name: artifacts-example 68 | path: dist/example 69 | 70 | - name: Upload code coverage report to Codecov 71 | if: ${{ github.event_name != 'pull_request' }} 72 | uses: codecov/codecov-action@v4 73 | with: 74 | token: ${{ secrets.CODECOV_TOKEN }} 75 | files: coverage/ng-in-viewport/lcov.info 76 | flags: unittests 77 | 78 | e2e: 79 | name: Run E2E tests 80 | needs: build 81 | strategy: 82 | matrix: 83 | project: ['demo', 'example'] 84 | runs-on: ubuntu-latest 85 | steps: 86 | - name: Checkout repository 87 | uses: actions/checkout@v3 88 | 89 | - name: Setup Node.js and install NPM dependencies 90 | uses: ./.github/actions/install-npm-deps 91 | 92 | - name: Download build artifacts 93 | uses: actions/download-artifact@v4 94 | with: 95 | name: artifacts-${{ matrix.project }} 96 | path: dist/${{ matrix.project }} 97 | 98 | - name: Run cypress 99 | uses: cypress-io/github-action@v5 100 | with: 101 | project: projects/${{ matrix.project }}-e2e 102 | browser: chrome 103 | headless: true 104 | 105 | - name: Upload failed screenshots 106 | uses: actions/upload-artifact@v4 107 | if: failure() 108 | with: 109 | name: failed-e2e-${{ matrix.project }}-screenshots 110 | path: projects/${{ matrix.project }}-e2e/artifacts/screenshots 111 | 112 | - name: Upload failed videos 113 | uses: actions/upload-artifact@v4 114 | if: failure() 115 | with: 116 | name: failed-e2e-${{ matrix.project }}-videos 117 | path: projects/${{ matrix.project }}-e2e/artifacts/videos 118 | -------------------------------------------------------------------------------- /.github/workflows/release-please.yml: -------------------------------------------------------------------------------- 1 | name: release-please 2 | 3 | on: 4 | push: 5 | branches: [develop] 6 | workflow_dispatch: 7 | inputs: 8 | reason: 9 | description: Why was the workflow triggered manually? 10 | required: true 11 | type: string 12 | 13 | permissions: 14 | contents: write 15 | pull-requests: write 16 | id-token: write 17 | 18 | jobs: 19 | release-please: 20 | runs-on: ubuntu-latest 21 | steps: 22 | - name: Prepare release PR or move on with release when PR was accepted 23 | id: release 24 | uses: google-github-actions/release-please-action@v4 25 | with: 26 | release-type: node 27 | package-name: ng-in-viewport 28 | extra-files: | 29 | README.md 30 | projects/ng-in-viewport/package.json 31 | 32 | - name: Checkout repository 33 | if: ${{ steps.release.outputs.release_created }} 34 | uses: actions/checkout@v3 35 | 36 | - name: Setup Node.js and install NPM dependencies 37 | if: ${{ steps.release.outputs.release_created }} 38 | uses: ./.github/actions/install-npm-deps 39 | with: 40 | node-version: 'lts/*' 41 | registry-url: 'https://registry.npmjs.org' 42 | 43 | - name: Build library 44 | if: ${{ steps.release.outputs.release_created }} 45 | run: npm run build:lib 46 | 47 | - name: Copy license and readme files 48 | if: ${{ steps.release.outputs.release_created }} 49 | run: | 50 | cp LICENSE dist/ng-in-viewport/LICENSE 51 | cp README.md dist/ng-in-viewport/README.md 52 | 53 | - name: Publish library package 54 | if: ${{ steps.release.outputs.release_created }} 55 | env: 56 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 57 | run: | 58 | cd dist/ng-in-viewport 59 | npm publish --provenance --access public 60 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v[0-9]+.[0-9]+.[0-9]+*' 7 | workflow_dispatch: 8 | inputs: 9 | reason: 10 | description: Why was the workflow triggered manually? 11 | required: true 12 | type: string 13 | 14 | permissions: 15 | contents: read 16 | packages: write 17 | id-token: write 18 | 19 | jobs: 20 | publish-to-github: 21 | runs-on: ubuntu-latest 22 | steps: 23 | - name: Checkout repository 24 | uses: actions/checkout@v3 25 | 26 | - name: Setup Node.js and install NPM dependencies 27 | uses: ./.github/actions/install-npm-deps 28 | with: 29 | node-version: 'lts/*' 30 | registry-url: 'https://npm.pkg.github.com' 31 | 32 | - name: Build library 33 | run: npm run build:lib 34 | 35 | - name: Copy license and readme files 36 | run: | 37 | cp LICENSE dist/ng-in-viewport/LICENSE 38 | cp README.md dist/ng-in-viewport/README.md 39 | 40 | - name: Publish library package 41 | env: 42 | NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 43 | run: | 44 | cd dist/ng-in-viewport 45 | jq '.name="@k3nsei/ng-in-viewport"' ./package.json > /tmp/package.json 46 | mv /tmp/package.json ./package.json 47 | jq 'del(.publishConfig)' ./package.json > /tmp/package.json 48 | mv /tmp/package.json ./package.json 49 | cat ./package.json 50 | npm publish --registry="https://npm.pkg.github.com" --scope="@k3nsei" 51 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # Compiled output 4 | /dist 5 | /tmp 6 | /out-tsc 7 | /bazel-out 8 | 9 | # Node 10 | /node_modules 11 | npm-debug.log 12 | yarn-error.log 13 | 14 | # IDEs and editors 15 | .idea/ 16 | .project 17 | .classpath 18 | .c9/ 19 | *.launch 20 | .settings/ 21 | *.sublime-workspace 22 | 23 | # Visual Studio Code 24 | .vscode/* 25 | !.vscode/settings.json 26 | !.vscode/tasks.json 27 | !.vscode/launch.json 28 | !.vscode/extensions.json 29 | .history/* 30 | 31 | # Miscellaneous 32 | /.angular/cache 33 | .sass-cache/ 34 | /connect.lock 35 | /coverage 36 | /libpeerconnection.log 37 | testem.log 38 | /typings 39 | 40 | # System files 41 | .DS_Store 42 | Thumbs.db 43 | 44 | # GitHub 45 | /.github/* 46 | /.github/actions/* 47 | /.github/workflows/* 48 | !/.github/dependabot.yml 49 | !/.github/funding.yml 50 | !/.github/copilot-instructions.md 51 | !/.github/actions/install-npm-deps/action.yml 52 | !/.github/workflows/main.yml 53 | !/.github/workflows/pr.yml 54 | !/.github/workflows/release-please.yml 55 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | [ -n "$CI" ] && exit 0 4 | 5 | npx --no -- commitlint --edit "$1" 6 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | [ -n "$CI" ] && exit 0 4 | 5 | npx --no -- lint-staged 6 | -------------------------------------------------------------------------------- /.lintstagedrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "**/*": "prettier --write --ignore-unknown", 3 | "projects/**/*.{js,ts,html}": "eslint", 4 | ".all-contributorsrc": "prettier --write --parser=json" 5 | } 6 | -------------------------------------------------------------------------------- /.mcprc.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://schemas.modelcontextprotocol.org/v1/mcp-config.schema.json", 3 | "description": "MCP configuration for ng-in-viewport Angular library project", 4 | "servers": { 5 | "angular-cli": { 6 | "command": "npx", 7 | "args": ["-y", "@angular/cli", "mcp"], 8 | "description": "Angular CLI MCP for Angular development assistance, project management, and code generation" 9 | }, 10 | "github-mcp-server": { 11 | "command": "npx", 12 | "args": ["-y", "@modelcontextprotocol/server-github"], 13 | "description": "GitHub MCP for repository management, issues, PRs, and code review", 14 | "env": { 15 | "GITHUB_PERSONAL_ACCESS_TOKEN": "" 16 | } 17 | }, 18 | "playwright-mcp": { 19 | "command": "npx", 20 | "args": ["-y", "@modelcontextprotocol/server-playwright"], 21 | "description": "Playwright MCP for browser automation, E2E testing, and web page interaction" 22 | }, 23 | "filesystem": { 24 | "command": "npx", 25 | "args": [ 26 | "-y", 27 | "@modelcontextprotocol/server-filesystem", 28 | "/home/runner/work/ng-in-viewport/ng-in-viewport" 29 | ], 30 | "description": "File system access for reading and writing project files" 31 | }, 32 | "brave-search": { 33 | "command": "npx", 34 | "args": ["-y", "@modelcontextprotocol/server-brave-search"], 35 | "description": "Web search capabilities for documentation and troubleshooting", 36 | "env": { 37 | "BRAVE_API_KEY": "" 38 | } 39 | }, 40 | "git": { 41 | "command": "npx", 42 | "args": [ 43 | "-y", 44 | "@modelcontextprotocol/server-git", 45 | "/home/runner/work/ng-in-viewport/ng-in-viewport" 46 | ], 47 | "description": "Git operations and repository management" 48 | } 49 | }, 50 | "globalSettings": { 51 | "logLevel": "info", 52 | "timeout": 30000, 53 | "retries": 3 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict = false 2 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 18.13.0 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | /.angular 2 | /.husky/_ 3 | /coverage 4 | /dist 5 | /docs 6 | /out-tsc 7 | /tmp 8 | 9 | .all-contributorsrc 10 | CHANGELOG.md 11 | *.snap 12 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 80, 3 | "tabWidth": 2, 4 | "useTabs": false, 5 | "semi": true, 6 | "singleQuote": true, 7 | "quoteProps": "as-needed", 8 | "trailingComma": "es5", 9 | "bracketSpacing": true, 10 | "bracketSameLine": true, 11 | "arrowParens": "always", 12 | "proseWrap": "preserve", 13 | "htmlWhitespaceSensitivity": "strict", 14 | "endOfLine": "lf", 15 | "overrides": [ 16 | { 17 | "files": "*.{js,ts,html}", 18 | "options": { 19 | "printWidth": 120 20 | } 21 | }, 22 | { 23 | "files": "*.html", 24 | "options": { 25 | "parser": "angular" 26 | } 27 | } 28 | ] 29 | } 30 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846 3 | "recommendations": [ 4 | "angular.ng-template", 5 | "ms-playwright.playwright", 6 | "github.vscode-github-actions", 7 | "esbenp.prettier-vscode", 8 | "dbaeumer.vscode-eslint", 9 | "ms-vscode.vscode-typescript-next" 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 3 | "version": "0.2.0", 4 | "configurations": [ 5 | { 6 | "name": "ng serve", 7 | "type": "pwa-chrome", 8 | "request": "launch", 9 | "preLaunchTask": "npm: start", 10 | "url": "http://localhost:4200/" 11 | }, 12 | { 13 | "name": "ng test", 14 | "type": "chrome", 15 | "request": "launch", 16 | "preLaunchTask": "npm: test", 17 | "url": "http://localhost:9876/debug.html" 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "mcp.configFile": ".mcprc.json", 3 | "typescript.preferences.includePackageJsonAutoImports": "on", 4 | "typescript.suggest.autoImports": true, 5 | "angular.experimental-ivy": true, 6 | "files.associations": { 7 | "*.json": "jsonc" 8 | }, 9 | "search.exclude": { 10 | "**/node_modules": true, 11 | "**/dist": true, 12 | "**/coverage": true, 13 | "**/.angular": true 14 | }, 15 | "files.exclude": { 16 | "**/.git": true, 17 | "**/.DS_Store": true, 18 | "**/node_modules": true, 19 | "**/dist": true, 20 | "**/coverage": true, 21 | "**/.angular": true 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | // For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558 3 | "version": "2.0.0", 4 | "tasks": [ 5 | { 6 | "type": "npm", 7 | "script": "start", 8 | "isBackground": true, 9 | "problemMatcher": { 10 | "owner": "typescript", 11 | "pattern": "$tsc", 12 | "background": { 13 | "activeOnStart": true, 14 | "beginsPattern": { 15 | "regexp": "(.*?)" 16 | }, 17 | "endsPattern": { 18 | "regexp": "bundle generation complete" 19 | } 20 | } 21 | } 22 | }, 23 | { 24 | "type": "npm", 25 | "script": "test", 26 | "isBackground": true, 27 | "problemMatcher": { 28 | "owner": "typescript", 29 | "pattern": "$tsc", 30 | "background": { 31 | "activeOnStart": true, 32 | "beginsPattern": { 33 | "regexp": "(.*?)" 34 | }, 35 | "endsPattern": { 36 | "regexp": "bundle generation complete" 37 | } 38 | } 39 | } 40 | } 41 | ] 42 | } 43 | -------------------------------------------------------------------------------- /AGENTS.md: -------------------------------------------------------------------------------- 1 | # AI Agent Instructions for ng-in-viewport 2 | 3 | ## Project Overview 4 | 5 | This is an Angular library for viewport detection using modern Angular v20+ patterns and signals. The library provides efficient viewport intersection detection using the Intersection Observer API. 6 | 7 | ### Repository Structure 8 | 9 | - `projects/ng-in-viewport/` - Core library source code 10 | - `projects/demo/` - Demo application for documentation 11 | - `projects/example/` - Example application showing real-world usage 12 | - `e2e/` - End-to-end tests 13 | - `docs/` - Documentation files 14 | 15 | ## Development Guidelines 16 | 17 | ### Angular Standards 18 | 19 | - Use Angular v20+ features exclusively (signals, modern control flow) 20 | - All components must be standalone with OnPush change detection 21 | - Use signal-based APIs and computed values 22 | - Prefer `inject()` over constructor dependency injection 23 | - Use modern template syntax (`@if`, `@for`, `@switch`) 24 | 25 | ### Code Quality 26 | 27 | - TypeScript strict mode enabled 28 | - ESLint and Prettier configured 29 | - Jest for unit testing 30 | - Playwright for E2E testing 31 | - 100% code coverage expected for library code 32 | 33 | ### Key Commands 34 | 35 | ```bash 36 | # Development 37 | npm run build:lib # Build library 38 | npm run test:lib # Test library 39 | npm run lint # Lint all projects 40 | npm run format # Check formatting 41 | npm run serve:demo # Run demo app (port 4200) 42 | npm run serve:example # Run example app (port 4300) 43 | 44 | # Testing 45 | npm run test # All tests with coverage 46 | npm run e2e:run # E2E tests headless 47 | npm run e2e:open # E2E tests with UI 48 | ``` 49 | 50 | ## Library Architecture 51 | 52 | ### Core Components 53 | 54 | - `InViewportDirective` - Main directive for viewport detection 55 | - `InViewportService` - Service managing intersection observers 56 | - Signal-based state management throughout 57 | 58 | ### Key Features 59 | 60 | - Intersection Observer API integration 61 | - Signal-based reactivity 62 | - SSR-compatible (graceful degradation) 63 | - TypeScript first with strict typing 64 | - Angular standalone components 65 | 66 | ## Testing Strategy 67 | 68 | ### Unit Tests 69 | 70 | - Test all public APIs 71 | - Mock Intersection Observer for browser compatibility 72 | - Test signal reactivity and computed values 73 | - Verify SSR compatibility 74 | 75 | ### E2E Tests 76 | 77 | - Test real viewport interactions 78 | - Verify performance with many elements 79 | - Test responsive behavior 80 | - Validate accessibility 81 | 82 | ## Performance Requirements 83 | 84 | - Library bundle size < 10KB gzipped 85 | - Zero runtime dependencies 86 | - Efficient memory usage with proper cleanup 87 | - 60fps performance during scroll operations 88 | 89 | ## Compatibility 90 | 91 | - Angular 16+ (with standalone component support) 92 | - Modern browsers with Intersection Observer 93 | - Server-side rendering support 94 | - TypeScript 5.0+ 95 | 96 | ## Contributing Guidelines 97 | 98 | - Follow conventional commits 99 | - Ensure all tests pass 100 | - Maintain code coverage 101 | - Update documentation for API changes 102 | - Use Angular best practices from latest version 103 | 104 | ## AI Assistant Context 105 | 106 | When helping with this project: 107 | 108 | 1. Always use modern Angular patterns (v20+ features) 109 | 2. Prioritize performance and bundle size 110 | 3. Maintain TypeScript strict compliance 111 | 4. Ensure cross-browser compatibility 112 | 5. Test thoroughly with provided test suites 113 | 6. Follow the existing code patterns and architecture 114 | 115 | ## Common Tasks 116 | 117 | - Adding new directive features 118 | - Optimizing intersection observer performance 119 | - Updating for new Angular versions 120 | - Enhancing TypeScript definitions 121 | - Improving test coverage 122 | - Updating documentation examples 123 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [16.1.0](https://github.com/k3nsei/ng-in-viewport/compare/v16.0.0...v16.1.0) (2023-11-14) 4 | 5 | 6 | ### Features 7 | 8 | * emit action before directive destroy ([ec25af9](https://github.com/k3nsei/ng-in-viewport/commit/ec25af968fffa2e081a5c0558056734642a91c2b)), closes [#1418](https://github.com/k3nsei/ng-in-viewport/issues/1418) 9 | 10 | ## [16.0.0](https://github.com/k3nsei/ng-in-viewport/compare/v16.0.0-rc.0...v16.0.0) (2023-06-30) 11 | 12 | As no issues were reported during the release candidate period. We are releasing it as stable. 13 | 14 | ## [16.0.0-rc.0](https://github.com/k3nsei/ng-in-viewport/compare/v15.0.2...v16.0.0-rc.0) (2023-05-06) 15 | 16 | ### Features 17 | 18 | * add support for angular v16 ([#1346](https://github.com/k3nsei/ng-in-viewport/issues/1346)) ([79039bd](https://github.com/k3nsei/ng-in-viewport/commit/79039bdce9c3dfa2a43d7f08ea57c7e9491882b3)) 19 | 20 | ### Miscellaneous Chores 21 | 22 | * **qodana:** update baseline file ([7a210dd](https://github.com/k3nsei/ng-in-viewport/commit/7a210dd2dcdbe3fe7918e5ccc16b3a73e682b42d)) 23 | 24 | ## [15.0.2](https://github.com/k3nsei/ng-in-viewport/compare/v15.0.1...v15.0.2) (2023-04-27) 25 | 26 | ### Features 27 | 28 | * enable npm package provenance ([ce13c14](https://github.com/k3nsei/ng-in-viewport/commit/ce13c146996baa26cc621fa4cda2d2b5854c1064)) 29 | 30 | ## [15.0.1](https://github.com/k3nsei/ng-in-viewport/compare/v15.0.0...v15.0.1) (2023-02-22) 31 | 32 | ### Bug Fixes 33 | 34 | - emit event inside NgZone ([72ff720](https://github.com/k3nsei/ng-in-viewport/commit/72ff720ee4b7dd9c705e5ce6fe3d9538e610aef9)), closes [#1284](https://github.com/k3nsei/ng-in-viewport/issues/1284) 35 | 36 | ## [15.0.0](https://github.com/k3nsei/ng-in-viewport/compare/v13.0.1...v15.0.0) (2023-01-30) 37 | 38 | ### Enhancements 39 | 40 | - Library was rewritten from scratch with usage of new standalone APIs that comes in `Angular v14.0.0` 41 | - Unit test coverage is now 100% 42 | 43 | ### Bug Fixes 44 | 45 | - Use `globalThis.btoa` for platform browser and server as `node.js` implemented it (#1274)(8b1fc918ac6e463afdd736324e19dd4a5c3a3e34) 46 | - Use `WeakMap` and generated unique id to reference `checkFn` (#1055)(8b1fc918ac6e463afdd736324e19dd4a5c3a3e34) 47 | 48 | ### Breaking Changes 49 | 50 | With this release support for Angular lower than `v14.0.0` is dropped for those please use `ng-in-viewport` `v6.1.5` or `v13.0.1` 51 | 52 | ## [13.0.1](https://github.com/k3nsei/ng-in-viewport/compare/v13.0.0...v13.0.1) (2022-02-14) 53 | 54 | **Note: This version is re-release of v13.0.0 with a corrected peer dependencies list. NX workspace added dependencies from unit tests, which was source of an issue.** 55 | 56 | There were no changes in code, library was compiled with ivy in mind because Angular 13 is no longer supports view engine. 57 | 58 | ### Breaking Changes 59 | 60 | With this version support for versions of angular lower than 12 is dropped for those please use ng-in-viewport v6.1.5 61 | 62 | ## [13.0.0](https://github.com/k3nsei/ng-in-viewport/compare/v6.1.5...v13.0.0) (2022-02-07) 63 | 64 | There were no changes in code, library was compiled with ivy in mind because Angular 13 is no longer supports view engine. 65 | 66 | ### Breaking Changes 67 | 68 | - With this version support for versions of angular lower than 12 is dropped for those please use **ng-in-viewport v6.1.5** 69 | 70 | ## [6.1.5](https://github.com/k3nsei/ng-in-viewport/compare/v6.1.4...v6.1.5) (2021-01-22) 71 | 72 | ### Enhancement 73 | 74 | - Update readme 75 | 76 | ## [6.1.4](https://github.com/k3nsei/ng-in-viewport/compare/v6.1.3...v6.1.4) (2020-11-03) 77 | 78 | ### Bug Fixes 79 | 80 | - Make options input public (#360)(42d460029dc88bceb1ff4d01e98691ccdb2706b0) 81 | 82 | ## [6.1.3](https://github.com/k3nsei/ng-in-viewport/compare/v6.1.1...v6.1.3) (2020-08-09) 83 | 84 | ### Bug Fixes 85 | 86 | - Drop usage of Function constructor (#333)(2db638c0a0c767345984b21bf94da2e91cb8e3a8) 87 | 88 | ## [6.1.1](https://github.com/k3nsei/ng-in-viewport/compare/v6.1.0...v6.1.1) (2020-07-02) 89 | 90 | ### Bug Fixes 91 | 92 | - Broken support of server platform in config (#316)(53ed86b6cb30fa60336f8560efc182e536f83ede) 93 | 94 | ## [6.1.0](https://github.com/k3nsei/ng-in-viewport/compare/v6.0.3...v6.1.0) (2020-04-14) 95 | 96 | ### Enhancement 97 | 98 | - Support root with many options (#147)(22672d6c01322f190706093c24a184a39049256d) 99 | 100 | ## [6.0.3](https://github.com/k3nsei/ng-in-viewport/compare/v6.0.2...v6.0.3) (2018-11-05) 101 | 102 | ### Enhancement 103 | 104 | - Use better predicate for completelyVisible (#101)(e5c6363ead40638619304d1ce6fdcf8caeda8cfa) 105 | 106 | ## [6.0.2](https://github.com/k3nsei/ng-in-viewport/compare/v6.0.1...v6.0.2) (2018-07-17) 107 | 108 | ### Enhancement 109 | 110 | - Fix "similar-code" issue in projects/ng-in-viewport/src/lib/in-viewport.directive.ts (#53)(eeb4e27a8b77f027572e4161b6f06c400b729ca1) 111 | 112 | ## [6.0.1](https://github.com/k3nsei/ng-in-viewport/compare/v6.0.0...v6.0.1) (2018-06-30) 113 | 114 | ### Bug Fixes 115 | 116 | - Changed dependency semver ranges 117 | 118 | ## [6.0.0](https://github.com/k3nsei/ng-in-viewport/compare/v1.2.8...v6.0.0) (2018-06-28) 119 | 120 | ### Upgrade to Angular 6 121 | 122 | - Optimization and refactoring 123 | - Change dependencies to require Angular 6 or newer 124 | - Change dependencies to require RxJS 6 or newer 125 | 126 | ### Breaking Changes 127 | 128 | - (inViewportAction) output has changed. Now there is `visible` property instead of `value`. 129 | 130 | ## [1.2.8](https://github.com/k3nsei/ng-in-viewport/compare/v1.2.7...v1.2.8) (2018-06-08) 131 | 132 | ### Enhancement: 133 | 134 | - Build with typescript v2.6.2 (#42)(d0008a2084029da59e4383fa50dcba70b8b31966) 135 | 136 | ### Bug Fixes: 137 | 138 | - Fix platform-server support (#12)(d0008a2084029da59e4383fa50dcba70b8b31966) 139 | 140 | ## [1.2.7](https://github.com/k3nsei/ng-in-viewport/compare/v1.2.5...v1.2.7) (2018-05-25) 141 | 142 | ### Enhancement: 143 | 144 | - Add platform-server support (#12)(5241efb2304af0b64eca6d610b3c1813bffb865d 69fb3c25815ee7991f32f7967325ac4d9bf4879e) 145 | 146 | ## [1.2.5](https://github.com/k3nsei/ng-in-viewport/compare/v1.2.0...v1.2.5) (2018-05-23) 147 | 148 | ### Bug Fixes: 149 | 150 | - Run IntersectionObserver outside angular zone (#39)(a340fa4e862f374be5a10558f458279ab0f2aea5) 151 | 152 | ## [1.2.0](https://github.com/k3nsei/ng-in-viewport/compare/v1.1.3...v1.2.0) (2017-04-10) 153 | 154 | - Use ES5 and ES2015 modules with AOT support 155 | - Use `intersection-observer` polyfill and drop strategies 156 | - Support for rootElement 157 | 158 | ## [1.1.3](https://github.com/k3nsei/ng-in-viewport/compare/v1.1.2...v1.1.3) (2017-03-28) 159 | 160 | - Fire init check after view init 161 | 162 | ## [1.1.2](https://github.com/k3nsei/ng-in-viewport/compare/v1.1.1...v1.1.2) (2017-03-25) 163 | 164 | - Do not modifies Observable prototype 165 | 166 | ## [1.1.1](https://github.com/k3nsei/ng-in-viewport/compare/v1.1.0...v1.1.1) (2017-03-25) 167 | 168 | - Add docs and github-page 169 | 170 | ## [1.1.0](https://github.com/k3nsei/ng-in-viewport/compare/v1.0.0...v1.1.0) (2017-03-25) 171 | 172 | ## [1.0.0](https://github.com/k3nsei/ng-in-viewport/tree/v1.0.0) (2017-02-10) 173 | 174 | - Optimize listening scroll and resize events 175 | - Create Module 176 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @k3nsei 2 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | - Using welcoming and inclusive language 18 | - Being respectful of differing viewpoints and experiences 19 | - Gracefully accepting constructive criticism 20 | - Focusing on what is best for the community 21 | - Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | - The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | - Trolling, insulting/derogatory comments, and personal or political attacks 28 | - Public or private harassment 29 | - Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | - Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at k3nsei.pl@gmail.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Piotr Stępniewski 4 | (https://www.linkedin.com/in/piotrstepniewski/) 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ng-in-viewport 2 | 3 | [![npm license][npm-badge-license]][npm-badge-url] 4 | [![npm version][npm-badge-version]][npm-badge-url] 5 | [![npm monthly downloads][npm-badge-downloads]][npm-badge-url] 6 | [![code coverage][codecov-badge]][codecov-badge-url] 7 | 8 | > Allows us to check if an element is within the browsers visual viewport 9 | 10 | - 🤓 Learn about it on the [Docs Site][lib-docs] 11 | - 🚀 See it in action on the [Examples Site][example-app] 12 | - 🎮 Play with it on [Stackblitz][example-app-embed] 13 | 14 | ## Compatibility matrix 🔢 15 | 16 | | **ng-in-viewport** | **Angular** | 17 | | ------------------ | ----------------------- | 18 | | `16.1.x` | `>= 17.x.y \|\| 16.x.y` | 19 | | `16.0.x` | `16.x.y \|\| 15.x.y` | 20 | | `15.0.x` | `15.x.y \|\| 14.x.y` | 21 | 22 | ## Support the Project 💖 23 | 24 | Maintaining `ng-in-viewport` is a labor of love, developed mostly during spare time by its maintainers. If you find this library helpful, consider sponsoring—whether it's the cost of a coffee, a beer, or more. Your support helps keep the project alive and evolving. 25 | 26 | By sponsoring, you'll help to: 27 | 28 | - Add new features 29 | - Ensure long-term support 30 | - Improve compatibility with future Angular versions 31 | 32 | Every contribution makes a difference, and even a small gesture goes a long way in keeping `ng-in-viewport` up to date for the community. 33 | 34 | ## License 📝 35 | 36 | [MIT](https://github.com/k3nsei/ng-in-viewport/blob/stable/LICENSE) 37 | 38 | ## Contributors ✨ 39 | 40 | Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)): 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 |
Piotr Stępniewski
Piotr Stępniewski

💻 📖 👀 ⚠️
Jordan Benge
Jordan Benge

📝
Kévin Perrée
Kévin Perrée

🐛
Alexandre Couret
Alexandre Couret

🐛
anwar-elmawardy
anwar-elmawardy

🐛
Jan-Willem Willebrands
Jan-Willem Willebrands

🐛
CSchulz
CSchulz

🐛
Johnnie Ho
Johnnie Ho

🐛
Aleksandr Pasevin
Aleksandr Pasevin

💻 🐛
Wilson Kurniawan
Wilson Kurniawan

🐛
Eugene
Eugene

💻
Sami Zarraa
Sami Zarraa

🐛
JordiJS
JordiJS

🐛
mpschaeuble
mpschaeuble

🐛
Daniel Karp
Daniel Karp

🐛
Tine Kondo
Tine Kondo

🤔 👀
Bojan Kogoj
Bojan Kogoj

📖
72 | 73 | 74 | 75 | 76 | 77 | 78 | This project follows the [all-contributors][all-contributors-url] specification. Contributions of any kind welcome! 79 | 80 | 81 | 82 | 83 | 84 | [npm-badge-version]: https://img.shields.io/npm/v/ng-in-viewport?style=flat-square 85 | [npm-badge-license]: https://img.shields.io/npm/l/ng-in-viewport?style=flat-square 86 | [npm-badge-downloads]: https://img.shields.io/npm/dm/ng-in-viewport?style=flat-square 87 | [npm-badge-url]: https://www.npmjs.com/package/ng-in-viewport 88 | [codecov-badge]: https://img.shields.io/codecov/c/github/k3nsei/ng-in-viewport/develop?logo=codecov&style=flat-square 89 | [codecov-badge-url]: https://codecov.io/gh/k3nsei/ng-in-viewport 90 | [lib-docs]: https://k3nsei.gitbook.io/ng-in-viewport/ 91 | [example-app]: https://ng-in-viewport.vercel.app/ 92 | [example-app-embed]: https://stackblitz.com/edit/ng-in-viewport-example?embed=1&file=src/main.ts 93 | [all-contributors-url]: https://github.com/all-contributors/all-contributors 94 | 95 | 96 | 97 | 98 | 99 | -------------------------------------------------------------------------------- /angular.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@angular/cli/lib/config/schema.json", 3 | "version": 1, 4 | "newProjectRoot": "projects", 5 | "projects": { 6 | "ng-in-viewport": { 7 | "projectType": "library", 8 | "root": "projects/ng-in-viewport", 9 | "sourceRoot": "projects/ng-in-viewport/src", 10 | "prefix": "in-viewport", 11 | "architect": { 12 | "build": { 13 | "builder": "@angular-devkit/build-angular:ng-packagr", 14 | "options": { 15 | "project": "projects/ng-in-viewport/ng-package.json" 16 | }, 17 | "configurations": { 18 | "production": { 19 | "tsConfig": "projects/ng-in-viewport/tsconfig.lib.prod.json" 20 | }, 21 | "development": { 22 | "tsConfig": "projects/ng-in-viewport/tsconfig.lib.json" 23 | } 24 | }, 25 | "defaultConfiguration": "production" 26 | }, 27 | "lint": { 28 | "builder": "@angular-eslint/builder:lint", 29 | "options": { 30 | "lintFilePatterns": [ 31 | "projects/ng-in-viewport/**/*.ts", 32 | "projects/ng-in-viewport/**/*.html" 33 | ] 34 | } 35 | } 36 | } 37 | }, 38 | "demo": { 39 | "projectType": "application", 40 | "root": "projects/demo", 41 | "sourceRoot": "projects/demo/src", 42 | "prefix": "invp", 43 | "architect": { 44 | "build": { 45 | "builder": "@angular-devkit/build-angular:application", 46 | "options": { 47 | "outputPath": "dist/demo", 48 | "index": "projects/demo/src/index.html", 49 | "browser": "projects/demo/src/main.ts", 50 | "polyfills": ["zone.js"], 51 | "tsConfig": "projects/demo/tsconfig.app.json", 52 | "inlineStyleLanguage": "scss", 53 | "assets": [ 54 | "projects/demo/src/favicon.ico", 55 | "projects/demo/src/assets" 56 | ], 57 | "styles": ["projects/demo/src/styles.scss"], 58 | "scripts": [] 59 | }, 60 | "configurations": { 61 | "production": { 62 | "budgets": [ 63 | { 64 | "type": "initial", 65 | "maximumWarning": "500kb", 66 | "maximumError": "1mb" 67 | }, 68 | { 69 | "type": "anyComponentStyle", 70 | "maximumWarning": "2kb", 71 | "maximumError": "4kb" 72 | } 73 | ], 74 | "outputHashing": "all" 75 | }, 76 | "development": { 77 | "optimization": false, 78 | "extractLicenses": false, 79 | "sourceMap": true 80 | } 81 | }, 82 | "defaultConfiguration": "production" 83 | }, 84 | "serve": { 85 | "builder": "@angular-devkit/build-angular:dev-server", 86 | "configurations": { 87 | "production": { 88 | "buildTarget": "demo:build:production" 89 | }, 90 | "development": { 91 | "buildTarget": "demo:build:development" 92 | } 93 | }, 94 | "defaultConfiguration": "development" 95 | }, 96 | "extract-i18n": { 97 | "builder": "@angular-devkit/build-angular:extract-i18n", 98 | "options": { 99 | "buildTarget": "demo:build" 100 | } 101 | }, 102 | "lint": { 103 | "builder": "@angular-eslint/builder:lint", 104 | "options": { 105 | "lintFilePatterns": [ 106 | "projects/demo/**/*.ts", 107 | "projects/demo/**/*.html" 108 | ] 109 | } 110 | } 111 | } 112 | }, 113 | "example": { 114 | "projectType": "application", 115 | "root": "projects/example", 116 | "sourceRoot": "projects/example/src", 117 | "prefix": "invp-ex", 118 | "architect": { 119 | "build": { 120 | "builder": "@angular-devkit/build-angular:application", 121 | "options": { 122 | "outputPath": "dist/example", 123 | "index": "projects/example/src/index.html", 124 | "browser": "projects/example/src/main.ts", 125 | "polyfills": ["zone.js"], 126 | "tsConfig": "projects/example/tsconfig.app.json", 127 | "inlineStyleLanguage": "scss", 128 | "assets": [ 129 | "projects/example/src/favicon.ico", 130 | "projects/example/src/assets" 131 | ], 132 | "styles": [ 133 | "@angular/material/prebuilt-themes/indigo-pink.css", 134 | "projects/example/src/styles.scss" 135 | ], 136 | "scripts": [] 137 | }, 138 | "configurations": { 139 | "production": { 140 | "budgets": [ 141 | { 142 | "type": "initial", 143 | "maximumWarning": "500kb", 144 | "maximumError": "1mb" 145 | }, 146 | { 147 | "type": "anyComponentStyle", 148 | "maximumWarning": "2kb", 149 | "maximumError": "4kb" 150 | } 151 | ], 152 | "outputHashing": "all" 153 | }, 154 | "development": { 155 | "optimization": false, 156 | "extractLicenses": false, 157 | "sourceMap": true 158 | } 159 | }, 160 | "defaultConfiguration": "production" 161 | }, 162 | "serve": { 163 | "builder": "@angular-devkit/build-angular:dev-server", 164 | "configurations": { 165 | "production": { 166 | "buildTarget": "example:build:production" 167 | }, 168 | "development": { 169 | "buildTarget": "example:build:development" 170 | } 171 | }, 172 | "defaultConfiguration": "development", 173 | "options": { 174 | "port": 4300 175 | } 176 | }, 177 | "extract-i18n": { 178 | "builder": "@angular-devkit/build-angular:extract-i18n", 179 | "options": { 180 | "buildTarget": "example:build" 181 | } 182 | }, 183 | "lint": { 184 | "builder": "@angular-eslint/builder:lint", 185 | "options": { 186 | "lintFilePatterns": [ 187 | "projects/example/**/*.ts", 188 | "projects/example/**/*.html" 189 | ] 190 | } 191 | } 192 | } 193 | } 194 | }, 195 | "cli": { 196 | "packageManager": "npm", 197 | "schematicCollections": ["@ngneat/spectator", "@angular-eslint/schematics"] 198 | }, 199 | "schematics": { 200 | "@schematics/angular:component": { 201 | "style": "scss" 202 | }, 203 | "@ngneat/spectator:spectator-component": { 204 | "style": "scss" 205 | } 206 | } 207 | } 208 | -------------------------------------------------------------------------------- /jest-global-mocks.ts: -------------------------------------------------------------------------------- 1 | Object.defineProperty(window, 'crypto', { 2 | value: { 3 | randomUUID: () => { 4 | let dt = new Date().getTime(); 5 | return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { 6 | const r = (dt + Math.random() * 16) % 16 | 0; 7 | dt = Math.floor(dt / 16); 8 | return (c == 'x' ? r : (r & 0x3) | 0x8).toString(16); 9 | }); 10 | }, 11 | }, 12 | }); 13 | 14 | Object.defineProperty(window, 'CSS', { value: null }); 15 | 16 | Object.defineProperty(document, 'doctype', { 17 | value: '', 18 | }); 19 | 20 | Object.defineProperty(window, 'getComputedStyle', { 21 | value: () => { 22 | return { 23 | display: 'none', 24 | appearance: ['-webkit-appearance'], 25 | }; 26 | }, 27 | }); 28 | 29 | /** 30 | * ISSUE: https://github.com/angular/material2/issues/7101 31 | * Workaround for JSDOM missing transform property 32 | */ 33 | Object.defineProperty(document.body.style, 'transform', { 34 | value: () => { 35 | return { 36 | enumerable: true, 37 | configurable: true, 38 | }; 39 | }, 40 | }); 41 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'jest'; 2 | 3 | const jestConfig = { 4 | projects: ['/projects/ng-in-viewport', '/projects/demo', '/projects/example'], 5 | coverageDirectory: '/coverage/all', 6 | coverageProvider: 'v8', 7 | coverageReporters: ['lcovonly', 'text', 'html-spa'], 8 | } satisfies Config; 9 | 10 | export default jestConfig; 11 | -------------------------------------------------------------------------------- /mcp.json: -------------------------------------------------------------------------------- 1 | { 2 | "mcpServers": { 3 | "angular-cli": { 4 | "command": "npx", 5 | "args": ["-y", "@angular/cli", "mcp"], 6 | "description": "Angular CLI MCP for Angular development assistance" 7 | }, 8 | "github": { 9 | "command": "npx", 10 | "args": ["-y", "@modelcontextprotocol/server-github"], 11 | "description": "GitHub repository management and operations", 12 | "env": { 13 | "GITHUB_PERSONAL_ACCESS_TOKEN": "" 14 | } 15 | }, 16 | "playwright": { 17 | "command": "npx", 18 | "args": ["-y", "@modelcontextprotocol/server-playwright"], 19 | "description": "Browser automation and E2E testing with Playwright" 20 | }, 21 | "filesystem": { 22 | "command": "npx", 23 | "args": ["-y", "@modelcontextprotocol/server-filesystem", "."], 24 | "description": "File system operations for project files" 25 | }, 26 | "git": { 27 | "command": "npx", 28 | "args": ["-y", "@modelcontextprotocol/server-git", "."], 29 | "description": "Git version control operations" 30 | }, 31 | "typescript": { 32 | "command": "npx", 33 | "args": ["-y", "@modelcontextprotocol/server-typescript"], 34 | "description": "TypeScript language server for code analysis" 35 | }, 36 | "brave-search": { 37 | "command": "npx", 38 | "args": ["-y", "@modelcontextprotocol/server-brave-search"], 39 | "description": "Web search for documentation and troubleshooting", 40 | "env": { 41 | "BRAVE_API_KEY": "" 42 | } 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ng-in-viewport", 3 | "version": "16.1.0", 4 | "private": true, 5 | "license": "MIT", 6 | "scripts": { 7 | "all-contributors:add": "all-contributors add", 8 | "all-contributors:generate": "all-contributors generate", 9 | "build:demo": "ng build demo --configuration=production", 10 | "build:example": "ng build example --configuration=production", 11 | "build:lib": "ng build ng-in-viewport --configuration=production", 12 | "e2e:open:demo": "npx cypress open --project=projects/demo-e2e --e2e", 13 | "e2e:open:example": "npx cypress open --project=projects/example-e2e --e2e", 14 | "e2e:run:demo": "npx cypress run --project=projects/demo-e2e --e2e", 15 | "e2e:run:example": "npx cypress run --project=projects/example-e2e --e2e", 16 | "format": "prettier --check --ignore-unknown .", 17 | "format:write": "prettier --write --ignore-unknown .", 18 | "lint": "eslint projects/ng-in-viewport projects/demo projects/example", 19 | "lint:demo": "ng lint demo", 20 | "lint:example": "ng lint example", 21 | "lint:lib": "ng lint ng-in-viewport", 22 | "prepare": "npx --no -- is-ci || npx --no -- husky || true", 23 | "serve:demo": "ng serve demo", 24 | "serve:example": "ng serve example", 25 | "test": "jest --coverage", 26 | "test:demo": "jest --projects=\"projects/demo\" --coverage", 27 | "test:example": "jest --projects=\"projects/example\" --coverage", 28 | "test:lib": "jest --projects=\"projects/ng-in-viewport\" --coverage", 29 | "watch:demo": "ng build demo --watch --configuration=development", 30 | "watch:example": "ng build example --watch --configuration=development", 31 | "watch:lib": "ng build ng-in-viewport --watch --configuration=development" 32 | }, 33 | "dependencies": { 34 | "@angular/animations": "~17.3.7", 35 | "@angular/cdk": "~17.3.7", 36 | "@angular/common": "~17.3.7", 37 | "@angular/compiler": "~17.3.7", 38 | "@angular/core": "~17.3.7", 39 | "@angular/forms": "~17.3.7", 40 | "@angular/material": "~17.3.7", 41 | "@angular/platform-browser": "~17.3.7", 42 | "@angular/platform-browser-dynamic": "~17.3.7", 43 | "@angular/router": "~17.3.7", 44 | "lodash-es": "^4.17.21", 45 | "rxjs": "^7.8.1", 46 | "tslib": "^2.6.2", 47 | "zone.js": "~0.14.5" 48 | }, 49 | "devDependencies": { 50 | "@angular-devkit/build-angular": "~17.3.6", 51 | "@angular-eslint/builder": "~17.3.0", 52 | "@angular-eslint/eslint-plugin": "~17.3.0", 53 | "@angular-eslint/eslint-plugin-template": "~17.3.0", 54 | "@angular-eslint/schematics": "~17.3.0", 55 | "@angular-eslint/template-parser": "~17.3.0", 56 | "@angular/cli": "~17.3.6", 57 | "@angular/compiler-cli": "~17.3.7", 58 | "@commitlint/cli": "^18.6.1", 59 | "@commitlint/config-conventional": "^18.6.2", 60 | "@ngneat/spectator": "^16.0.0", 61 | "@types/jest": "^29.5.12", 62 | "@types/lodash-es": "^4.17.12", 63 | "@typescript-eslint/eslint-plugin": "^7.0.1", 64 | "@typescript-eslint/parser": "^7.0.1", 65 | "all-contributors-cli": "^6.26.1", 66 | "cypress": "^13.6.4", 67 | "eslint": "^8.56.0", 68 | "eslint-config-prettier": "^9.1.0", 69 | "eslint-import-resolver-typescript": "^3.6.1", 70 | "eslint-plugin-import": "^2.29.1", 71 | "eslint-plugin-prettier": "^5.1.3", 72 | "husky": "^9.0.11", 73 | "is-ci": "^3.0.1", 74 | "jest": "^29.7.0", 75 | "jest-environment-jsdom": "^29.7.0", 76 | "jest-preset-angular": "^14.0.3", 77 | "lint-staged": "^15.2.2", 78 | "ng-mocks": "^14.12.1", 79 | "ng-packagr": "^17.3.0", 80 | "prettier": "3.2.5", 81 | "prettier-plugin-packagejson": "^2.4.11", 82 | "ts-jest": "^29.1.2", 83 | "ts-node": "^10.9.2", 84 | "typescript": "~5.3.3" 85 | }, 86 | "engines": { 87 | "node": "18.x || >=20.10.0", 88 | "npm": ">=10.0.0" 89 | }, 90 | "volta": { 91 | "node": "20.11.1", 92 | "npm": "10.4.0" 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /projects/demo-e2e/artifacts/.gitignore: -------------------------------------------------------------------------------- 1 | screenshots/* 2 | videos/* 3 | -------------------------------------------------------------------------------- /projects/demo-e2e/cypress.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'cypress'; 2 | 3 | export default defineConfig({ 4 | e2e: { 5 | fileServerFolder: '../../dist/demo/browser', 6 | fixturesFolder: 'src/fixtures', 7 | supportFolder: 'src/support', 8 | supportFile: 'src/support/e2e.ts', 9 | specPattern: 'src/tests/**/*.cy.ts', 10 | screenshotOnRunFailure: true, 11 | video: true, 12 | screenshotsFolder: 'artifacts/screenshots', 13 | videosFolder: 'artifacts/videos', 14 | viewportWidth: 1280, 15 | viewportHeight: 720, 16 | setupNodeEvents(_on, _config) { 17 | // implement node event listeners here 18 | }, 19 | }, 20 | }); 21 | -------------------------------------------------------------------------------- /projects/demo-e2e/src/fixtures/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/k3nsei/ng-in-viewport/07f29fecd4816c2b7993c3eb8859cd5ddc132de5/projects/demo-e2e/src/fixtures/.gitkeep -------------------------------------------------------------------------------- /projects/demo-e2e/src/index.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | declare namespace Cypress { 4 | interface Chainable { 5 | assertColumnItems(column: 'first' | 'second', start: number, end?: number): void; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /projects/demo-e2e/src/support/commands.ts: -------------------------------------------------------------------------------- 1 | import { inRange } from './utils'; 2 | 3 | // *********************************************** 4 | // This example commands.js shows you how to 5 | // create various custom commands and overwrite 6 | // existing commands. 7 | // 8 | // For more comprehensive examples of custom 9 | // commands please read more here: 10 | // https://on.cypress.io/custom-commands 11 | // *********************************************** 12 | // 13 | // 14 | // -- This is a parent command -- 15 | // Cypress.Commands.add('login', (email, password) => { ... }) 16 | // 17 | // 18 | // -- This is a child command -- 19 | // Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... }) 20 | // 21 | // 22 | // -- This is a dual command -- 23 | // Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... }) 24 | // 25 | // 26 | // -- This will overwrite an existing command -- 27 | // Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... }) 28 | 29 | Cypress.Commands.add('assertColumnItems', (column: 'first' | 'second', start: number, end?: number): void => { 30 | cy.get(`.example--${column} .item`).each(($el, index) => { 31 | const number = index + 1; 32 | 33 | inRange(number, start, end) 34 | ? cy.wrap($el).should('have.class', 'item--active') 35 | : cy.wrap($el).should('not.have.class', 'item--active'); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /projects/demo-e2e/src/support/e2e.ts: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/e2e.ts is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.ts using ES2015 syntax: 17 | import './commands'; 18 | 19 | // Alternatively you can use CommonJS syntax: 20 | // require('./commands') 21 | -------------------------------------------------------------------------------- /projects/demo-e2e/src/support/utils.ts: -------------------------------------------------------------------------------- 1 | export const inRange = (number: number, start: number, end?: number): boolean => { 2 | if (end == null) { 3 | end = start; 4 | start = 0; 5 | } 6 | return number >= start && number <= end; 7 | }; 8 | -------------------------------------------------------------------------------- /projects/demo-e2e/src/tests/demo.cy.ts: -------------------------------------------------------------------------------- 1 | describe('GIVEN: Demo Application', () => { 2 | describe('WHEN page was loaded', () => { 3 | beforeEach(() => { 4 | cy.visit('/'); 5 | }); 6 | 7 | it('THEN should render title', () => { 8 | cy.get('.app-header__title').contains('ng-in-viewport demo').should('be.visible'); 9 | }); 10 | 11 | it('THEN should render 1st column', () => { 12 | cy.get('.example--first').should('be.visible').should('not.be.empty'); 13 | }); 14 | 15 | it('THEN 1st column items from 1st to 9th should be active', () => { 16 | cy.assertColumnItems('first', 1, 9); 17 | }); 18 | 19 | it('THEN should render 2nd column', () => { 20 | cy.get('.example--second').should('be.visible').should('not.be.empty'); 21 | }); 22 | 23 | it('THEN 2nd column items from 1st to 7th should be active', () => { 24 | cy.assertColumnItems('second', 1, 7); 25 | }); 26 | 27 | describe('AND scrolled into view 10th item of 1st column', () => { 28 | beforeEach(() => cy.get('.example--first .item:nth-child(10)').scrollIntoView()); 29 | 30 | it('THEN 1st column items from 10th to 18th should be active', () => { 31 | cy.assertColumnItems('first', 10, 18); 32 | }); 33 | 34 | it('THEN 2nd column items from 1st to 7th should be active', () => { 35 | cy.assertColumnItems('second', 1, 7); 36 | }); 37 | }); 38 | 39 | describe('AND scrolled 2st column vertically by 779px', () => { 40 | beforeEach(() => cy.get('.example--second').scrollTo(0, 779)); 41 | 42 | it('THEN 1st column items from 1st to 9th should be active', () => { 43 | cy.assertColumnItems('first', 1, 9); 44 | }); 45 | 46 | it('THEN 2nd column items from 11th to 17th should be active', () => { 47 | cy.assertColumnItems('second', 11, 17); 48 | }); 49 | }); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /projects/demo-e2e/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "./", 4 | "target": "ES5", 5 | "lib": ["ES2022", "DOM"], 6 | "types": ["cypress", "node"] 7 | }, 8 | "include": ["**/*.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /projects/demo/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../.eslintrc.json", 3 | "ignorePatterns": ["!**/*"], 4 | "overrides": [ 5 | { 6 | "files": ["*.ts"], 7 | "rules": { 8 | "@angular-eslint/directive-selector": [ 9 | "error", 10 | { 11 | "type": "attribute", 12 | "prefix": "invp", 13 | "style": "camelCase" 14 | } 15 | ], 16 | "@angular-eslint/component-selector": [ 17 | "error", 18 | { 19 | "type": "element", 20 | "prefix": "invp", 21 | "style": "kebab-case" 22 | } 23 | ] 24 | } 25 | }, 26 | { 27 | "files": ["*.html"], 28 | "rules": {} 29 | } 30 | ] 31 | } 32 | -------------------------------------------------------------------------------- /projects/demo/jest-setup.ts: -------------------------------------------------------------------------------- 1 | import 'jest-preset-angular/setup-jest'; 2 | import '../../jest-global-mocks'; 3 | -------------------------------------------------------------------------------- /projects/demo/jest.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'jest'; 2 | 3 | const jestConfig = { 4 | displayName: 'demo', 5 | preset: 'jest-preset-angular', 6 | // globalSetup: 'jest-preset-angular/global-setup', 7 | moduleNameMapper: { 8 | 'ng-in-viewport': '/../ng-in-viewport/src/public-api.ts', 9 | '^lodash-es$': 'lodash', 10 | }, 11 | setupFilesAfterEnv: ['/jest-setup.ts'], 12 | coverageDirectory: '/../../coverage/demo', 13 | coverageProvider: 'v8', 14 | coverageReporters: ['lcovonly', 'text', 'html-spa'], 15 | } satisfies Config; 16 | 17 | export default jestConfig; 18 | -------------------------------------------------------------------------------- /projects/demo/src/app/app.component.html: -------------------------------------------------------------------------------- 1 |
2 |

{{ title }}

3 |
4 | 5 |
6 | 7 |
8 | -------------------------------------------------------------------------------- /projects/demo/src/app/app.component.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | display: contents; 3 | } 4 | 5 | :is(.app-header, .app-content) { 6 | padding: 1rem; 7 | } 8 | 9 | .app-header { 10 | block-size: 4rem; 11 | padding-block-end: 0; 12 | } 13 | 14 | .app-header__title { 15 | margin: 0; 16 | font-size: 2rem; 17 | text-transform: uppercase; 18 | } 19 | -------------------------------------------------------------------------------- /projects/demo/src/app/app.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { Spectator, createComponentFactory } from '@ngneat/spectator/jest'; 2 | import { MockProvider } from 'ng-mocks'; 3 | import { EMPTY } from 'rxjs'; 4 | 5 | import { InViewportService } from 'ng-in-viewport'; 6 | 7 | import { AppComponent } from './app.component'; 8 | 9 | describe('AppComponent', () => { 10 | const createComponent = createComponentFactory({ 11 | component: AppComponent, 12 | providers: [MockProvider(InViewportService, { trigger$: EMPTY })], 13 | }); 14 | let spectator: Spectator; 15 | let component: AppComponent; 16 | 17 | beforeEach(() => { 18 | spectator = createComponent(); 19 | component = spectator.component; 20 | }); 21 | 22 | it('should create component', () => { 23 | const actual: boolean = component instanceof AppComponent; 24 | const expected = true; 25 | 26 | expect(actual).toBe(expected); 27 | expect(spectator.debugElement.nativeElement).toMatchSnapshot(); 28 | }); 29 | 30 | it(`should have title`, () => { 31 | const actual: string = component.title; 32 | const expected = 'ng-in-viewport demo'; 33 | 34 | expect(actual).toBe(expected); 35 | }); 36 | 37 | it('should render title', () => { 38 | const actual = spectator.query('header h1'); 39 | const expected = 'ng-in-viewport demo'; 40 | 41 | expect(actual).toHaveText(expected); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /projects/demo/src/app/app.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component } from '@angular/core'; 2 | 3 | import { ExampleComponent } from './example/example.component'; 4 | 5 | @Component({ 6 | standalone: true, 7 | selector: 'invp-app', 8 | templateUrl: './app.component.html', 9 | styleUrl: './app.component.scss', 10 | changeDetection: ChangeDetectionStrategy.OnPush, 11 | imports: [ExampleComponent], 12 | }) 13 | export class AppComponent { 14 | public readonly title = 'ng-in-viewport demo'; 15 | } 16 | -------------------------------------------------------------------------------- /projects/demo/src/app/app.config.ts: -------------------------------------------------------------------------------- 1 | import { ApplicationConfig } from '@angular/core'; 2 | import { provideAnimations } from '@angular/platform-browser/animations'; 3 | 4 | export const appConfig: ApplicationConfig = { 5 | providers: [provideAnimations()], 6 | }; 7 | -------------------------------------------------------------------------------- /projects/demo/src/app/example/example.component.html: -------------------------------------------------------------------------------- 1 |
2 | @for (item of items; track item.id; let isLast = $last; let isEven = $even) { 3 |
10 | {{ item.value }} 11 |
12 | } 13 |
14 | 15 |
16 | @for (item of items; track item.id; let isLast = $last; let isEven = $even) { 17 |
24 | {{ item.value }} 25 |
26 | } 27 |
28 | -------------------------------------------------------------------------------- /projects/demo/src/app/example/example.component.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | display: grid; 3 | grid-template-columns: 1fr 1fr; 4 | gap: 1rem; 5 | } 6 | 7 | .example { 8 | padding: 1rem; 9 | display: block; 10 | border: 0.4rem dashed var(--br-c); 11 | 12 | &--second { 13 | block-size: calc(100vh - 6rem); 14 | block-size: calc(100dvh - 6rem); 15 | overflow: auto; 16 | } 17 | } 18 | 19 | .item { 20 | $parent: &; 21 | 22 | --bg-c: var(--item-bg-c); 23 | --st-c: var(--item-sh-c); 24 | --st-c2: var(--item-sh-c2); 25 | 26 | block-size: 10vh; 27 | block-size: 10dvh; 28 | margin-block-end: 1rem; 29 | display: flex; 30 | justify-content: center; 31 | align-items: center; 32 | background-color: var(--bg-c); 33 | font-size: 2.4rem; 34 | font-weight: bold; 35 | text-shadow: 36 | 0.1rem 0.1rem 0 var(--st-c), 37 | 0 0.1rem 0 var(--st-c), 38 | -0.1rem -0.1rem 0 var(--st-c), 39 | -0.1rem -0.1rem 0 var(--st-c), 40 | -0.1rem 0.1rem 0 var(--st-c), 41 | 0.1rem -0.1rem 0 var(--st-c), 42 | 0.03535533906rem 0.03535533906rem 0 var(--st-c2), 43 | 0.07071067812rem 0.07071067812rem 0 var(--st-c2), 44 | 0.10606601718rem 0.10606601718rem 0 var(--st-c2), 45 | 0.14142135624rem 0.14142135624rem 0 var(--st-c2), 46 | 0.1767766953rem 0.1767766953rem 0 var(--st-c2), 47 | 0.21213203436rem 0.21213203436rem 0 var(--st-c2), 48 | 0.24748737342rem 0.24748737342rem 0 var(--st-c2), 49 | 0.28284271247rem 0.28284271247rem 0 var(--st-c2); 50 | transition: background-color 250ms linear; 51 | 52 | &--last { 53 | margin: 0; 54 | } 55 | 56 | &--active { 57 | --st-c: var(--active-item-sh-c); 58 | --st-c2: var(--active-item-sh-c2); 59 | --bg-c: var(--active-item-bg-c); 60 | } 61 | 62 | &--alt { 63 | --bg-c: var(--item-alt-bg-c); 64 | 65 | &#{$parent}--active { 66 | --bg-c: var(--active-item-alt-bg-c); 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /projects/demo/src/app/example/example.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { Spectator, createComponentFactory } from '@ngneat/spectator/jest'; 2 | import { MockProvider } from 'ng-mocks'; 3 | import { EMPTY } from 'rxjs'; 4 | 5 | import { InViewportAction, InViewportService } from 'ng-in-viewport'; 6 | 7 | import { ExampleComponent } from './example.component'; 8 | 9 | describe('ExampleComponent', () => { 10 | const activeClassName = 'item--active'; 11 | const createComponent = createComponentFactory({ 12 | component: ExampleComponent, 13 | providers: [MockProvider(InViewportService, { trigger$: EMPTY })], 14 | }); 15 | let spectator: Spectator; 16 | let component: ExampleComponent; 17 | 18 | beforeEach(() => { 19 | spectator = createComponent(); 20 | component = spectator.component; 21 | }); 22 | 23 | it('should create component', () => { 24 | const actual: boolean = component instanceof ExampleComponent; 25 | const expected = true; 26 | 27 | expect(actual).toBe(expected); 28 | expect(spectator.debugElement.nativeElement).toMatchSnapshot(); 29 | }); 30 | 31 | it(`should have correct items count`, () => { 32 | const actual: number = component.items.length; 33 | const expected = 100; 34 | 35 | expect(actual).toBe(expected); 36 | }); 37 | 38 | describe('first section', () => { 39 | it('should rendered correct elements count', () => { 40 | const actual: number = spectator.queryAll('.example.example--first .item').length; 41 | const expected = 100; 42 | 43 | expect(actual).toBe(expected); 44 | }); 45 | 46 | it('should be inactive item', () => { 47 | const el = spectator.query('.example.example--first .item'); 48 | 49 | component.handleAction({ target: el, visible: false } as InViewportAction); 50 | spectator.detectChanges(); 51 | 52 | expect(el).not.toHaveClass(activeClassName); 53 | }); 54 | 55 | it('should be active item', () => { 56 | const el = spectator.query('.example.example--first .item'); 57 | 58 | component.handleAction({ target: el, visible: true } as InViewportAction); 59 | spectator.detectChanges(); 60 | 61 | expect(el).toHaveClass(activeClassName); 62 | }); 63 | }); 64 | 65 | describe('second section', () => { 66 | it('should rendered correct elements count', () => { 67 | const actual: number = spectator.queryAll('.example.example--second .item').length; 68 | const expected = 100; 69 | 70 | expect(actual).toBe(expected); 71 | }); 72 | 73 | it('should be inactive item', () => { 74 | const el = spectator.query('.example.example--second .item'); 75 | 76 | component.handleAction({ target: el, visible: false } as InViewportAction); 77 | spectator.detectChanges(); 78 | 79 | expect(el).not.toHaveClass(activeClassName); 80 | }); 81 | 82 | it('should be active item', () => { 83 | const el = spectator.query('.example.example--second .item'); 84 | 85 | component.handleAction({ target: el, visible: true } as InViewportAction); 86 | spectator.detectChanges(); 87 | 88 | expect(el).toHaveClass(activeClassName); 89 | }); 90 | }); 91 | }); 92 | -------------------------------------------------------------------------------- /projects/demo/src/app/example/example.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component, Renderer2, inject } from '@angular/core'; 2 | 3 | import { InViewportAction, InViewportDirective } from 'ng-in-viewport'; 4 | 5 | import { SectionOptionsPipe } from './section-options.pipe'; 6 | 7 | interface Item { 8 | id: string; 9 | value: number; 10 | } 11 | @Component({ 12 | standalone: true, 13 | selector: 'invp-example', 14 | templateUrl: './example.component.html', 15 | styleUrl: './example.component.scss', 16 | changeDetection: ChangeDetectionStrategy.OnPush, 17 | imports: [InViewportDirective, SectionOptionsPipe], 18 | }) 19 | export class ExampleComponent { 20 | public readonly items: Item[] = Array.from({ length: 100 }, (_, idx: number) => ({ 21 | id: globalThis.crypto.randomUUID(), 22 | value: idx + 1, 23 | })); 24 | 25 | private readonly renderer = inject(Renderer2); 26 | 27 | public handleAction({ target, visible }: InViewportAction): void { 28 | const activeClassname = 'item--active'; 29 | 30 | if (visible) { 31 | this.renderer.addClass(target, activeClassname); 32 | } else { 33 | this.renderer.removeClass(target, activeClassname); 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /projects/demo/src/app/example/section-options.pipe.ts: -------------------------------------------------------------------------------- 1 | import { Pipe, PipeTransform } from '@angular/core'; 2 | 3 | import { InViewportOptions } from 'ng-in-viewport'; 4 | 5 | @Pipe({ 6 | standalone: true, 7 | name: 'options', 8 | pure: true, 9 | }) 10 | export class SectionOptionsPipe implements PipeTransform { 11 | public transform(element: HTMLElement, section: 'first' | 'second', isEven: boolean): InViewportOptions { 12 | const isSecond: boolean = section === 'second'; 13 | 14 | return { 15 | root: isSecond ? element : undefined, 16 | threshold: isEven ? [0, 0.5, 1] : [0, 1], 17 | partial: !isSecond, 18 | }; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /projects/demo/src/assets/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/k3nsei/ng-in-viewport/07f29fecd4816c2b7993c3eb8859cd5ddc132de5/projects/demo/src/assets/.gitkeep -------------------------------------------------------------------------------- /projects/demo/src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/k3nsei/ng-in-viewport/07f29fecd4816c2b7993c3eb8859cd5ddc132de5/projects/demo/src/favicon.ico -------------------------------------------------------------------------------- /projects/demo/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Demo 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /projects/demo/src/main.ts: -------------------------------------------------------------------------------- 1 | import { bootstrapApplication } from '@angular/platform-browser'; 2 | 3 | import { AppComponent } from './app/app.component'; 4 | import { appConfig } from './app/app.config'; 5 | 6 | bootstrapApplication(AppComponent, appConfig).catch((err) => console.error(err)); 7 | -------------------------------------------------------------------------------- /projects/demo/src/styles.scss: -------------------------------------------------------------------------------- 1 | :root { 2 | font-size: 62.5%; 3 | --bg-c: #3c4556; 4 | --fg-c: #f8f8f2; 5 | --br-c: #f1fa8c; 6 | --sb-bg-c: #333340; 7 | --sb-fg-c: #515166; 8 | --item-bg-c: #6272a4; 9 | --item-alt-bg-c: #5a6ca3; 10 | --item-sh-c: #3c4556; 11 | --item-sh-c2: #{rgba(#3c4556, 0.5)}; 12 | --active-item-bg-c: #69fb8e; 13 | --active-item-alt-bg-c: #50fa7b; 14 | --active-item-sh-c: #6272a4; 15 | --active-item-sh-c2: #{rgba(#6272a4, 0.5)}; 16 | } 17 | 18 | *, 19 | *::before, 20 | *::after { 21 | box-sizing: border-box; 22 | } 23 | 24 | *::-webkit-scrollbar { 25 | width: 1.8rem; 26 | 27 | &-track { 28 | background: var(--sb-bg-c); 29 | } 30 | 31 | &-thumb { 32 | background: var(--sb-fg-c); 33 | background-clip: padding-box; 34 | border: 0.4rem solid transparent; 35 | border-radius: 1rem; 36 | } 37 | } 38 | 39 | :root, 40 | body { 41 | height: 100%; 42 | margin: 0; 43 | padding: 0; 44 | background: var(--bg-c); 45 | color: var(--fg-c); 46 | } 47 | 48 | body { 49 | font-size: 1.6rem; 50 | } 51 | -------------------------------------------------------------------------------- /projects/demo/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../out-tsc/app", 5 | "types": [] 6 | }, 7 | "files": ["src/main.ts"], 8 | "include": ["src/**/*.d.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /projects/demo/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../out-tsc/spec", 5 | "module": "CommonJS", 6 | "target": "ES2016", 7 | "types": ["jest"] 8 | }, 9 | "include": ["src/**/*.spec.ts", "src/**/*.d.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /projects/example-e2e/artifacts/.gitignore: -------------------------------------------------------------------------------- 1 | screenshots/* 2 | videos/* 3 | -------------------------------------------------------------------------------- /projects/example-e2e/cypress.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'cypress'; 2 | 3 | export default defineConfig({ 4 | e2e: { 5 | fileServerFolder: '../../dist/example/browser', 6 | fixturesFolder: 'src/fixtures', 7 | supportFolder: 'src/support', 8 | supportFile: 'src/support/e2e.ts', 9 | specPattern: 'src/tests/**/*.cy.ts', 10 | screenshotOnRunFailure: true, 11 | video: true, 12 | screenshotsFolder: 'artifacts/screenshots', 13 | videosFolder: 'artifacts/videos', 14 | viewportWidth: 800, 15 | viewportHeight: 600, 16 | setupNodeEvents(_on, _config) { 17 | // implement node event listeners here 18 | }, 19 | }, 20 | }); 21 | -------------------------------------------------------------------------------- /projects/example-e2e/src/fixtures/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/k3nsei/ng-in-viewport/07f29fecd4816c2b7993c3eb8859cd5ddc132de5/projects/example-e2e/src/fixtures/.gitkeep -------------------------------------------------------------------------------- /projects/example-e2e/src/index.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /projects/example-e2e/src/support/commands.ts: -------------------------------------------------------------------------------- 1 | // *********************************************** 2 | // This example commands.js shows you how to 3 | // create various custom commands and overwrite 4 | // existing commands. 5 | // 6 | // For more comprehensive examples of custom 7 | // commands please read more here: 8 | // https://on.cypress.io/custom-commands 9 | // *********************************************** 10 | // 11 | // 12 | // -- This is a parent command -- 13 | // Cypress.Commands.add('login', (email, password) => { ... }) 14 | // 15 | // 16 | // -- This is a child command -- 17 | // Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... }) 18 | // 19 | // 20 | // -- This is a dual command -- 21 | // Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... }) 22 | // 23 | // 24 | // -- This will overwrite an existing command -- 25 | // Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... }) 26 | -------------------------------------------------------------------------------- /projects/example-e2e/src/support/e2e.ts: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/e2e.ts is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.ts using ES2015 syntax: 17 | import './commands'; 18 | 19 | // Alternatively you can use CommonJS syntax: 20 | // require('./commands') 21 | -------------------------------------------------------------------------------- /projects/example-e2e/src/tests/page-highlighting.cy.ts: -------------------------------------------------------------------------------- 1 | describe('GIVEN: Example Application', () => { 2 | describe('WHEN page was loaded', () => { 3 | beforeEach(() => { 4 | cy.visit('/'); 5 | }); 6 | 7 | it('THEN title should be rendered', () => { 8 | cy.get('.toolbar-label').contains('Example of ng-in-viewport').should('be.visible'); 9 | }); 10 | 11 | it('THEN `highlighting` navigation tab should be active', () => { 12 | cy.get('nav a.is-active').contains('Highlighting').should('be.visible'); 13 | }); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /projects/example-e2e/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "./", 4 | "target": "ES5", 5 | "lib": ["ES2022", "DOM"], 6 | "types": ["cypress", "node"] 7 | }, 8 | "include": ["**/*.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /projects/example/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../.eslintrc.json", 3 | "ignorePatterns": ["!**/*"], 4 | "overrides": [ 5 | { 6 | "files": ["*.ts"], 7 | "rules": { 8 | "@angular-eslint/directive-selector": [ 9 | "error", 10 | { 11 | "type": "attribute", 12 | "prefix": "invpEx", 13 | "style": "camelCase" 14 | } 15 | ], 16 | "@angular-eslint/component-selector": [ 17 | "error", 18 | { 19 | "type": "element", 20 | "prefix": "invp-ex", 21 | "style": "kebab-case" 22 | } 23 | ] 24 | } 25 | }, 26 | { 27 | "files": ["*.html"], 28 | "rules": {} 29 | } 30 | ] 31 | } 32 | -------------------------------------------------------------------------------- /projects/example/jest-setup.ts: -------------------------------------------------------------------------------- 1 | import 'jest-preset-angular/setup-jest'; 2 | import '../../jest-global-mocks'; 3 | -------------------------------------------------------------------------------- /projects/example/jest.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'jest'; 2 | 3 | const jestConfig = { 4 | displayName: 'example', 5 | preset: 'jest-preset-angular', 6 | // globalSetup: 'jest-preset-angular/global-setup', 7 | moduleNameMapper: { 8 | 'ng-in-viewport': '/../ng-in-viewport/src/public-api.ts', 9 | '^lodash-es$': 'lodash', 10 | }, 11 | setupFilesAfterEnv: ['/jest-setup.ts'], 12 | coverageDirectory: '/../../coverage/demo', 13 | coverageProvider: 'v8', 14 | coverageReporters: ['lcovonly', 'text', 'html-spa'], 15 | } satisfies Config; 16 | 17 | export default jestConfig; 18 | -------------------------------------------------------------------------------- /projects/example/src/app/app.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | GitHub 6 | 7 | 8 | NPM 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 19 | 20 | {{ labels.toolbar }} 21 | 22 | 23 | 24 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /projects/example/src/app/app.component.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | inline-size: 100%; 3 | min-block-size: 100dvh; 4 | margin: 0; 5 | padding: 0; 6 | display: block; 7 | isolation: isolate; 8 | } 9 | 10 | .mat-toolbar { 11 | z-index: 9999999; 12 | 13 | &-row:nth-child(1) { 14 | block-size: 40px; 15 | } 16 | 17 | &-row:nth-child(2) { 18 | block-size: 49px; 19 | padding: 0; 20 | } 21 | } 22 | 23 | .toolbar-label { 24 | margin-inline-start: 14px; 25 | display: inline-block; 26 | text-transform: lowercase; 27 | } 28 | 29 | .mat-tab-link { 30 | text-transform: uppercase; 31 | } 32 | 33 | button[mat-mini-fab] { 34 | inset-inline-end: 0; 35 | inset-block-end: 0; 36 | position: fixed; 37 | transform: translate3d(-100%, -50%, 0); 38 | z-index: 9999999; 39 | } 40 | -------------------------------------------------------------------------------- /projects/example/src/app/app.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component, ElementRef, viewChild } from '@angular/core'; 2 | import { MatButtonModule } from '@angular/material/button'; 3 | import { MatIconModule } from '@angular/material/icon'; 4 | import { MatListModule } from '@angular/material/list'; 5 | import { MatDrawerContent, MatSidenavModule } from '@angular/material/sidenav'; 6 | import { MatTabsModule } from '@angular/material/tabs'; 7 | import { MatToolbarModule } from '@angular/material/toolbar'; 8 | import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router'; 9 | 10 | @Component({ 11 | standalone: true, 12 | selector: 'invp-ex-app', 13 | templateUrl: './app.component.html', 14 | styleUrl: './app.component.scss', 15 | changeDetection: ChangeDetectionStrategy.OnPush, 16 | imports: [ 17 | RouterLink, 18 | RouterLinkActive, 19 | RouterOutlet, 20 | MatButtonModule, 21 | MatIconModule, 22 | MatListModule, 23 | MatSidenavModule, 24 | MatTabsModule, 25 | MatToolbarModule, 26 | ], 27 | }) 28 | export class AppComponent { 29 | public readonly drawerContent = viewChild(MatDrawerContent, { read: ElementRef }); 30 | 31 | public readonly labels = { 32 | toolbar: 'Example of ng-in-viewport', 33 | highlighting: 'Highlighting', 34 | lazyImages: 'Lazy images', 35 | infiniteScroll: 'Infinite scroll', 36 | } as const; 37 | 38 | public readonly navLinks = [ 39 | { 40 | path: '/highlighting', 41 | label: this.labels.highlighting, 42 | }, 43 | { 44 | path: '/lazy-images', 45 | label: this.labels.lazyImages, 46 | }, 47 | { 48 | path: '/infinite-scroll', 49 | label: this.labels.infiniteScroll, 50 | }, 51 | ] as const; 52 | 53 | public scrollTop(): void { 54 | const ref = this.drawerContent(); 55 | 56 | if (ref) { 57 | ref.nativeElement.scrollTop = 0; 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /projects/example/src/app/app.config.ts: -------------------------------------------------------------------------------- 1 | import { APP_BASE_HREF } from '@angular/common'; 2 | import { provideHttpClient } from '@angular/common/http'; 3 | import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core'; 4 | import { provideAnimations } from '@angular/platform-browser/animations'; 5 | import { provideRouter, withInMemoryScrolling } from '@angular/router'; 6 | 7 | import { APP_ROUTES } from './app.routes'; 8 | 9 | export const appConfig: ApplicationConfig = { 10 | providers: [ 11 | provideZoneChangeDetection({ eventCoalescing: true, runCoalescing: true }), 12 | provideAnimations(), 13 | provideHttpClient(), 14 | provideRouter( 15 | APP_ROUTES, 16 | withInMemoryScrolling({ anchorScrolling: 'enabled', scrollPositionRestoration: 'enabled' }) 17 | ), 18 | { provide: APP_BASE_HREF, useValue: '/' }, 19 | ], 20 | }; 21 | -------------------------------------------------------------------------------- /projects/example/src/app/app.routes.ts: -------------------------------------------------------------------------------- 1 | import { Route } from '@angular/router'; 2 | 3 | export const APP_ROUTES: Route[] = [ 4 | { 5 | path: 'highlighting', 6 | loadComponent: () => import('./page-highlighting/page-highlighting.component'), 7 | }, 8 | { 9 | path: 'infinite-scroll', 10 | loadComponent: () => import('./page-infinite-scroll/page-infinite-scroll.component'), 11 | }, 12 | { 13 | path: 'lazy-images', 14 | loadComponent: () => import('./page-lazy-images/page-lazy-images.component'), 15 | }, 16 | { 17 | path: '**', 18 | pathMatch: 'full', 19 | redirectTo: 'highlighting', 20 | }, 21 | ]; 22 | -------------------------------------------------------------------------------- /projects/example/src/app/page-highlighting/page-highlighting.component.html: -------------------------------------------------------------------------------- 1 | 2 | @for (item of items; track item.id) { 3 | 4 | {{ item.value }} 5 | 6 | } 7 | 8 | -------------------------------------------------------------------------------- /projects/example/src/app/page-highlighting/page-highlighting.component.scss: -------------------------------------------------------------------------------- 1 | .grid-tile { 2 | background-color: rgb(158, 158, 158); 3 | font-size: 2em; 4 | font-weight: bold; 5 | color: rgba(255, 255, 255, 0.87); 6 | transition: 7 | background-color 0.25s ease-in, 8 | color 0.25s ease-in; 9 | 10 | &.active { 11 | background-color: #00c853; 12 | color: rgba(255, 255, 255, 0.87); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /projects/example/src/app/page-highlighting/page-highlighting.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { Spectator, createComponentFactory } from '@ngneat/spectator/jest'; 2 | import { MockDirective } from 'ng-mocks'; 3 | 4 | import { InViewportAction, InViewportDirective } from 'ng-in-viewport'; 5 | 6 | import { PageHighlightingComponent } from './page-highlighting.component'; 7 | 8 | describe('GIVEN PageHighlightingComponent', () => { 9 | let spectator: Spectator; 10 | let component: PageHighlightingComponent; 11 | const createComponent = createComponentFactory({ 12 | component: PageHighlightingComponent, 13 | overrideComponents: [ 14 | [ 15 | PageHighlightingComponent, 16 | { 17 | remove: { imports: [InViewportDirective] }, 18 | add: { imports: [MockDirective(InViewportDirective)] }, 19 | }, 20 | ], 21 | ], 22 | }); 23 | 24 | describe('WHEN component was created', () => { 25 | beforeEach(() => { 26 | spectator = createComponent(); 27 | component = spectator.component; 28 | }); 29 | 30 | it('THEN instance should exists', () => { 31 | expect(component).toBeTruthy(); 32 | }); 33 | 34 | it('THEN host should match snapshot', () => { 35 | const hostElement = spectator.element; 36 | 37 | expect(hostElement).toMatchSnapshot(); 38 | }); 39 | 40 | it('THEN host should render 100 inactive tiles', () => { 41 | const tiles = spectator.queryAll('.grid-tile.inactive'); 42 | 43 | expect(tiles.length).toBe(100); 44 | }); 45 | 46 | describe('AND `highlight` method was called', () => { 47 | beforeEach(() => { 48 | const tile = spectator.query('.grid-tile.inactive'); 49 | 50 | component.highlight({ target: tile, visible: true } as InViewportAction); 51 | }); 52 | 53 | it('THEN one tile should be active', () => { 54 | const tile = spectator.query('.grid-tile.active'); 55 | 56 | expect(tile).toBeTruthy(); 57 | }); 58 | }); 59 | }); 60 | }); 61 | -------------------------------------------------------------------------------- /projects/example/src/app/page-highlighting/page-highlighting.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component, Renderer2, inject } from '@angular/core'; 2 | import { MatGridListModule } from '@angular/material/grid-list'; 3 | 4 | import { InViewportAction, InViewportDirective } from 'ng-in-viewport'; 5 | 6 | interface HighlightingItem { 7 | id: string; 8 | value: number; 9 | } 10 | 11 | @Component({ 12 | standalone: true, 13 | selector: 'invp-ex-page-highlighting', 14 | templateUrl: './page-highlighting.component.html', 15 | styleUrl: './page-highlighting.component.scss', 16 | changeDetection: ChangeDetectionStrategy.OnPush, 17 | imports: [MatGridListModule, InViewportDirective], 18 | }) 19 | export class PageHighlightingComponent { 20 | public readonly items: HighlightingItem[] = Array.from({ length: 100 }, (_, i) => ({ 21 | id: crypto.randomUUID(), 22 | value: i + 1, 23 | })); 24 | 25 | private readonly renderer = inject(Renderer2); 26 | 27 | public highlight(event: InViewportAction): void { 28 | const { target, visible } = event; 29 | const classnames = ['active', 'inactive']; 30 | const [toAdd, toRemove] = visible ? classnames : classnames.reverse(); 31 | 32 | this.renderer.addClass(target, toAdd); 33 | this.renderer.removeClass(target, toRemove); 34 | } 35 | } 36 | 37 | export default PageHighlightingComponent; 38 | -------------------------------------------------------------------------------- /projects/example/src/app/page-infinite-scroll/page-infinite-scroll.component.html: -------------------------------------------------------------------------------- 1 | @for (card of cards(); track card; let isOdd = $odd; let isEven = $even) { 2 | 3 | 4 |
5 | {{ isOdd ? 'Lego Builder' : 'Lego Doctor' }} 6 | {{ isOdd ? 'Engineer' : 'Surgeon' }} 7 |
8 | 9 |

10 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque porta lorem id nulla varius dictum. 11 | Suspendisse ut ante ac odio finibus fringilla id quis nulla. Etiam sapien elit, facilisis ut mattis non, euismod 12 | ac magna. Donec vehicula, mi ac bibendum finibus, mauris turpis malesuada tellus, accumsan feugiat felis mi in 13 | velit. Donec vitae sollicitudin eros. Vestibulum hendrerit magna urna, ut malesuada sem fringilla sit amet. Cras 14 | non tellus posuere nibh tincidunt pellentesque. Nunc mattis finibus accumsan. Aenean fringilla sapien ut quam 15 | posuere tempus. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. 16 | Pellentesque interdum sit amet dui id auctor. Donec in gravida ex. Duis convallis tellus at lacus euismod 17 | suscipit sed quis risus. In varius urna ipsum, eget varius eros viverra in. 18 |

19 |
20 |
21 | } 22 | 23 | @if (loading()) { 24 | 25 | } @else if (page() < pages()) { 26 | 27 |
 
34 | } 35 | -------------------------------------------------------------------------------- /projects/example/src/app/page-infinite-scroll/page-infinite-scroll.component.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | display: flex; 3 | flex-flow: column nowrap; 4 | align-items: center; 5 | } 6 | 7 | .card { 8 | inline-size: clamp(240px, calc(100% - 24px), 1200px); 9 | margin-inline: 24px; 10 | margin-block: 0 12px; 11 | 12 | &:first-of-type { 13 | margin-block-start: 24px; 14 | } 15 | } 16 | 17 | .header-image { 18 | background-image: url('https://randomuser.me/api/portraits/lego/7.jpg'); 19 | background-size: cover; 20 | } 21 | 22 | .card.even .header-image { 23 | background-image: url('https://randomuser.me/api/portraits/lego/3.jpg'); 24 | } 25 | 26 | .mat-spinner { 27 | margin: 0; 28 | margin-block-end: 12px; 29 | } 30 | 31 | [mat-raised-button] { 32 | inline-size: clamp(240px, calc(100% - 24px), 1200px); 33 | margin-block: 0 8px; 34 | } 35 | 36 | .infinite-scroll-zone { 37 | block-size: 2px; 38 | margin-block-end: 2px; 39 | overflow: hidden; 40 | } 41 | -------------------------------------------------------------------------------- /projects/example/src/app/page-infinite-scroll/page-infinite-scroll.component.ts: -------------------------------------------------------------------------------- 1 | import { NgClass } from '@angular/common'; 2 | import { ChangeDetectionStrategy, Component, DestroyRef, effect, inject, signal } from '@angular/core'; 3 | import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; 4 | import { MatButtonModule } from '@angular/material/button'; 5 | import { MatCardModule } from '@angular/material/card'; 6 | import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; 7 | import { ActivatedRoute, Router } from '@angular/router'; 8 | import { of } from 'rxjs'; 9 | import { delay } from 'rxjs/operators'; 10 | 11 | import { InViewportAction, InViewportDirective } from 'ng-in-viewport'; 12 | 13 | @Component({ 14 | standalone: true, 15 | selector: 'invp-ex-page-infinite-scroll', 16 | templateUrl: './page-infinite-scroll.component.html', 17 | styleUrl: './page-infinite-scroll.component.scss', 18 | changeDetection: ChangeDetectionStrategy.OnPush, 19 | imports: [NgClass, MatButtonModule, MatCardModule, MatProgressSpinnerModule, InViewportDirective], 20 | }) 21 | export class PageInfiniteScrollComponent { 22 | public page = signal(1); 23 | 24 | public pages = signal(20).asReadonly(); 25 | 26 | public loading = signal(false); 27 | 28 | public cards = signal(PageInfiniteScrollComponent.generateCards()); 29 | 30 | private readonly destroyRef = inject(DestroyRef); 31 | 32 | private readonly activatedRoute = inject(ActivatedRoute); 33 | 34 | private readonly router = inject(Router); 35 | 36 | constructor() { 37 | effect(async () => { 38 | await this.router.navigate([], { 39 | relativeTo: this.activatedRoute, 40 | queryParamsHandling: 'merge', 41 | queryParams: { 42 | page: this.page(), 43 | }, 44 | }); 45 | }); 46 | } 47 | 48 | private static generateCards(): string[] { 49 | return Array.from({ length: 5 }, () => crypto.randomUUID()); 50 | } 51 | 52 | public loadMore(event: InViewportAction | Event): void { 53 | if (this.page() >= this.pages() || ('visible' in event && !event.visible)) { 54 | return; 55 | } 56 | 57 | this.loading.set(true); 58 | 59 | of(PageInfiniteScrollComponent.generateCards()) 60 | .pipe(delay(1000), takeUntilDestroyed(this.destroyRef)) 61 | .subscribe((cards) => { 62 | this.loading.set(false); 63 | this.page.update((page) => page + 1); 64 | this.cards.update((prevCards) => [...prevCards, ...cards]); 65 | }); 66 | } 67 | } 68 | 69 | export default PageInfiniteScrollComponent; 70 | -------------------------------------------------------------------------------- /projects/example/src/app/page-lazy-images/lazy-image-skeleton/lazy-image-skeleton.component.html: -------------------------------------------------------------------------------- 1 |
2 | -------------------------------------------------------------------------------- /projects/example/src/app/page-lazy-images/lazy-image-skeleton/lazy-image-skeleton.component.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | --line-fg: rgba(182, 182, 182, 0.87); 3 | --line-bg: rgba(158, 158, 158, 0.87); 4 | --duration: 1.25s; 5 | 6 | inline-size: 100%; 7 | block-size: 100%; 8 | margin: 0; 9 | padding: 0; 10 | inset: 0; 11 | position: absolute; 12 | display: flex; 13 | align-items: center; 14 | justify-content: center; 15 | } 16 | 17 | .lazy-img-skeleton { 18 | inline-size: 100%; 19 | block-size: 100%; 20 | display: block; 21 | overflow: hidden; 22 | background-image: linear-gradient( 23 | 90deg, 24 | var(--line-bg) 0%, 25 | var(--line-fg) 40%, 26 | var(--line-bg) 80% 27 | ); 28 | background-size: 80vw; 29 | animation: lines var(--duration) infinite linear; 30 | } 31 | 32 | @keyframes lines { 33 | 0% { 34 | background-position: -20dvw; 35 | } 36 | 40% { 37 | background-position: 40dvw; 38 | } 39 | 100% { 40 | background-position: 60dvw; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /projects/example/src/app/page-lazy-images/lazy-image-skeleton/lazy-image-skeleton.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component } from '@angular/core'; 2 | 3 | @Component({ 4 | standalone: true, 5 | selector: 'invp-ex-lazy-image-skeleton', 6 | templateUrl: './lazy-image-skeleton.component.html', 7 | styleUrl: './lazy-image-skeleton.component.scss', 8 | changeDetection: ChangeDetectionStrategy.OnPush, 9 | }) 10 | export class LazyImageSkeletonComponent {} 11 | -------------------------------------------------------------------------------- /projects/example/src/app/page-lazy-images/lazy-image.directive.ts: -------------------------------------------------------------------------------- 1 | import { HttpClient } from '@angular/common/http'; 2 | import { DestroyRef, Directive, ElementRef, OnInit, Renderer2, effect, inject, input, signal } from '@angular/core'; 3 | import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; 4 | import { MatSnackBar } from '@angular/material/snack-bar'; 5 | import { animationFrameScheduler, throwError } from 'rxjs'; 6 | import { catchError, delay, filter, take, tap } from 'rxjs/operators'; 7 | 8 | import { InViewportDirective } from 'ng-in-viewport'; 9 | 10 | export const enum LazyImageClassname { 11 | Loading = 'loading', 12 | Loaded = 'loaded', 13 | } 14 | 15 | @Directive({ 16 | standalone: true, 17 | selector: '[invpExLazyImage]', 18 | hostDirectives: [InViewportDirective], 19 | exportAs: 'invp-ex-lazy-image', 20 | }) 21 | export class LazyImageDirective implements OnInit { 22 | public readonly src = input.required({ alias: 'invpExLazyImage' }); 23 | 24 | public readonly loading = signal(false); 25 | 26 | public readonly loaded = signal(false); 27 | 28 | private readonly destroyRef = inject(DestroyRef); 29 | 30 | private readonly elementRef = inject(ElementRef); 31 | 32 | private readonly renderer = inject(Renderer2); 33 | 34 | private readonly httpClient = inject(HttpClient); 35 | 36 | private readonly snackBar = inject(MatSnackBar); 37 | 38 | private readonly inViewport = inject(InViewportDirective, { self: true }); 39 | 40 | constructor() { 41 | effect(() => { 42 | this.loading() 43 | ? this.renderer.addClass(this.elementRef.nativeElement, LazyImageClassname.Loading) 44 | : this.renderer.removeClass(this.elementRef.nativeElement, LazyImageClassname.Loading); 45 | }); 46 | 47 | effect(() => { 48 | this.loaded() 49 | ? this.renderer.addClass(this.elementRef.nativeElement, LazyImageClassname.Loaded) 50 | : this.renderer.removeClass(this.elementRef.nativeElement, LazyImageClassname.Loaded); 51 | }); 52 | } 53 | 54 | public ngOnInit(): void { 55 | this.inViewport.options = { threshold: 0.0001 }; 56 | 57 | this.inViewport.inViewportAction 58 | .pipe( 59 | filter(({ visible }) => visible), 60 | take(1), 61 | takeUntilDestroyed(this.destroyRef) 62 | ) 63 | .subscribe(() => this.load()); 64 | } 65 | 66 | private load(): void { 67 | if (this.src() == null) { 68 | return; 69 | } 70 | 71 | this.loading.set(true); 72 | 73 | this.httpClient 74 | .get(this.src(), { responseType: 'blob' }) 75 | .pipe( 76 | catchError((_error) => throwError(() => new Error(`Error during fetching image from: ${this.src}`))), 77 | tap((data: Blob) => 78 | this.renderer.setAttribute(this.elementRef.nativeElement, 'src', URL.createObjectURL(data)) 79 | ), 80 | delay(0, animationFrameScheduler), 81 | takeUntilDestroyed(this.destroyRef) 82 | ) 83 | .subscribe({ 84 | next: () => { 85 | this.loaded.set(true); 86 | }, 87 | error: (error) => { 88 | this.loading.set(false); 89 | this.snackBar.open(error, undefined, { 90 | duration: 3000, 91 | panelClass: 'error-snackbar', 92 | }); 93 | }, 94 | complete: () => this.loading.set(false), 95 | }); 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /projects/example/src/app/page-lazy-images/page-lazy-images.component.html: -------------------------------------------------------------------------------- 1 | 2 | @for (item of items; track item.id; let i = $index) { 3 | 4 | 10 | @if (image.loading()) { 11 | 12 | } 13 | 14 | } 15 | 16 | -------------------------------------------------------------------------------- /projects/example/src/app/page-lazy-images/page-lazy-images.component.scss: -------------------------------------------------------------------------------- 1 | .grid-tile { 2 | background-color: #9e9e9e; 3 | font-size: 2em; 4 | font-weight: bold; 5 | color: rgba(255, 255, 255, 0.87); 6 | transition: 7 | background-color 0.25s ease-in, 8 | color 0.25s ease-in; 9 | 10 | mat-spinner { 11 | inline-size: 50px; 12 | block-size: 50px; 13 | inset-inline: calc(50% - 25px); 14 | position: absolute; 15 | } 16 | 17 | img { 18 | inline-size: 100%; 19 | block-size: 100%; 20 | object-fit: cover; 21 | opacity: 0; 22 | transition: opacity 1s ease-in; 23 | 24 | &.loaded { 25 | opacity: 1; 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /projects/example/src/app/page-lazy-images/page-lazy-images.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component } from '@angular/core'; 2 | import { MatGridListModule } from '@angular/material/grid-list'; 3 | import { MatSnackBarModule } from '@angular/material/snack-bar'; 4 | 5 | import { LazyImageSkeletonComponent } from './lazy-image-skeleton/lazy-image-skeleton.component'; 6 | import { LazyImageDirective } from './lazy-image.directive'; 7 | 8 | interface ImageItem { 9 | id: string; 10 | imageUrl: string; 11 | } 12 | 13 | @Component({ 14 | standalone: true, 15 | selector: 'invp-ex-page-lazy-images', 16 | templateUrl: './page-lazy-images.component.html', 17 | styleUrl: './page-lazy-images.component.scss', 18 | changeDetection: ChangeDetectionStrategy.OnPush, 19 | imports: [MatGridListModule, MatSnackBarModule, LazyImageSkeletonComponent, LazyImageDirective], 20 | }) 21 | export class PageLazyImagesComponent { 22 | public readonly dimensions: [number, number] = [640, 360]; 23 | 24 | public readonly items: ReadonlyArray = Array.from( 25 | { length: 100 }, 26 | (_, i): ImageItem => ({ 27 | id: crypto.randomUUID(), 28 | imageUrl: this.getImageUrl(i + 1), 29 | }) 30 | ); 31 | 32 | private getImageUrl(number = 1): string { 33 | const [width, height]: [number, number] = this.dimensions; 34 | const searchParams = new URLSearchParams({ 35 | random: '', 36 | gravity: 'center', 37 | number: `${number}`, 38 | }); 39 | 40 | return `https://picsum.photos/${width}/${height}?${searchParams}`; 41 | } 42 | } 43 | 44 | export default PageLazyImagesComponent; 45 | -------------------------------------------------------------------------------- /projects/example/src/assets/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/k3nsei/ng-in-viewport/07f29fecd4816c2b7993c3eb8859cd5ddc132de5/projects/example/src/assets/.gitkeep -------------------------------------------------------------------------------- /projects/example/src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/k3nsei/ng-in-viewport/07f29fecd4816c2b7993c3eb8859cd5ddc132de5/projects/example/src/favicon.ico -------------------------------------------------------------------------------- /projects/example/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Example 6 | 7 | 8 | 9 | 10 | 11 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /projects/example/src/main.ts: -------------------------------------------------------------------------------- 1 | import { bootstrapApplication } from '@angular/platform-browser'; 2 | 3 | import { AppComponent } from './app/app.component'; 4 | import { appConfig } from './app/app.config'; 5 | 6 | bootstrapApplication(AppComponent, appConfig).catch((err) => console.error(err)); 7 | -------------------------------------------------------------------------------- /projects/example/src/styles.scss: -------------------------------------------------------------------------------- 1 | *, 2 | *::before, 3 | *::after { 4 | box-sizing: border-box; 5 | } 6 | 7 | :root, 8 | html, 9 | body, 10 | #blitz-app { 11 | min-height: 100%; 12 | min-height: 100dvh; 13 | } 14 | 15 | body { 16 | margin: 0; 17 | padding: 0; 18 | font-family: 'Roboto Condensed', sans-serif; 19 | } 20 | 21 | .error-snackbar { 22 | background-color: red; 23 | } 24 | -------------------------------------------------------------------------------- /projects/example/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "../../tsconfig.json", 4 | "compilerOptions": { 5 | "outDir": "../../out-tsc/app", 6 | "types": [] 7 | }, 8 | "files": ["src/main.ts"], 9 | "include": ["src/**/*.d.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /projects/example/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../out-tsc/spec", 5 | "module": "CommonJS", 6 | "target": "ES2016", 7 | "types": ["jest"] 8 | }, 9 | "include": ["src/**/*.spec.ts", "src/**/*.d.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /projects/ng-in-viewport/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../.eslintrc.json", 3 | "ignorePatterns": ["!**/*"], 4 | "overrides": [ 5 | { 6 | "files": ["*.ts"], 7 | "rules": { 8 | "@typescript-eslint/no-explicit-any": "off", 9 | "@typescript-eslint/no-non-null-assertion": "off", 10 | "@angular-eslint/directive-selector": [ 11 | "error", 12 | { 13 | "type": "attribute", 14 | "prefix": "inViewport", 15 | "style": "camelCase" 16 | } 17 | ], 18 | "@angular-eslint/component-selector": [ 19 | "error", 20 | { 21 | "type": "element", 22 | "prefix": "in-viewport", 23 | "style": "kebab-case" 24 | } 25 | ] 26 | } 27 | }, 28 | { 29 | "files": ["*.html"], 30 | "rules": {} 31 | } 32 | ] 33 | } 34 | -------------------------------------------------------------------------------- /projects/ng-in-viewport/jest-setup.ts: -------------------------------------------------------------------------------- 1 | import 'jest-preset-angular/setup-jest'; 2 | 3 | import '../../jest-global-mocks'; 4 | -------------------------------------------------------------------------------- /projects/ng-in-viewport/jest.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'jest'; 2 | 3 | const jestConfig: Config = { 4 | displayName: 'ng-in-viewport', 5 | preset: 'jest-preset-angular', 6 | // globalSetup: 'jest-preset-angular/global-setup', 7 | moduleNameMapper: { '^lodash-es$': 'lodash' }, 8 | setupFilesAfterEnv: ['/jest-setup.ts'], 9 | coverageDirectory: '/../../coverage/ng-in-viewport', 10 | coverageProvider: 'v8', 11 | coverageReporters: ['lcovonly', 'text', 'html-spa'], 12 | coverageThreshold: { 13 | global: { 14 | branches: 95, 15 | functions: 95, 16 | lines: 90, 17 | statements: 90, 18 | }, 19 | }, 20 | coveragePathIgnorePatterns: ['node_modules/', 'enums/', 'index.ts', 'public-api.ts', 'in-viewport.module.ts'], 21 | }; 22 | 23 | export default jestConfig; 24 | -------------------------------------------------------------------------------- /projects/ng-in-viewport/ng-package.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../../node_modules/ng-packagr/ng-package.schema.json", 3 | "dest": "../../dist/ng-in-viewport", 4 | "lib": { 5 | "entryFile": "src/public-api.ts" 6 | }, 7 | "allowedNonPeerDependencies": ["lodash-es"] 8 | } 9 | -------------------------------------------------------------------------------- /projects/ng-in-viewport/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ng-in-viewport", 3 | "version": "16.1.0", 4 | "description": "Allows us to check if an element is within the browsers visual viewport", 5 | "keywords": [ 6 | "angular", 7 | "ng", 8 | "IntersectionObserver", 9 | "intersection-observer", 10 | "viewport", 11 | "visibility", 12 | "infinite-scroll", 13 | "lazy-loading", 14 | "lazyload", 15 | "lazy-loading-images", 16 | "lazyload-images", 17 | "angular-14", 18 | "angular-15", 19 | "angular-16" 20 | ], 21 | "homepage": "https://k3nsei.github.io/ng-in-viewport/", 22 | "bugs": { 23 | "url": "https://github.com/k3nsei/ng-in-viewport/issues" 24 | }, 25 | "repository": { 26 | "type": "git", 27 | "url": "https://github.com/k3nsei/ng-in-viewport.git" 28 | }, 29 | "license": "MIT", 30 | "author": { 31 | "name": "Piotr Stępniewski", 32 | "email": "k3nsei.pl@gmail.com", 33 | "url": "https://github.com/k3nsei" 34 | }, 35 | "dependencies": { 36 | "lodash-es": "^4.17.21", 37 | "tslib": "^2.3.0" 38 | }, 39 | "peerDependencies": { 40 | "@angular/common": "^14.0.0 || ^15.0.0 || ^16.0.0 || >=17.0.0", 41 | "@angular/core": "^14.0.0 || ^15.0.0 || ^16.0.0 || >=17.0.0" 42 | }, 43 | "publishConfig": { 44 | "provenance": true 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /projects/ng-in-viewport/src/lib/directives/destroyable.directive.spec.ts: -------------------------------------------------------------------------------- 1 | import { SpectatorDirective, createDirectiveFactory } from '@ngneat/spectator/jest'; 2 | import { interval } from 'rxjs'; 3 | import { takeUntil } from 'rxjs/operators'; 4 | 5 | import { DestroyableDirective } from './destroyable.directive'; 6 | 7 | describe('GIVEN DestroyableDirective', () => { 8 | const createDirective = createDirectiveFactory(DestroyableDirective); 9 | let spectator: SpectatorDirective; 10 | let directive: DestroyableDirective; 11 | 12 | beforeEach(async () => { 13 | spectator = createDirective(`
Testing DestroyableDirective
`); 14 | directive = spectator.directive; 15 | }); 16 | 17 | describe('WHEN directive was created', () => { 18 | let complete: () => void; 19 | 20 | beforeEach(() => { 21 | complete = jest.fn(); 22 | interval(1).pipe(takeUntil(directive.destroyed$)).subscribe({ complete }); 23 | }); 24 | 25 | it('THEN instance should exists', () => { 26 | expect(directive).toBeTruthy(); 27 | }); 28 | 29 | it("THEN `complete` function wasn't called", () => { 30 | expect(complete).not.toHaveBeenCalled(); 31 | }); 32 | 33 | describe('AND directive was destroyed', () => { 34 | beforeEach(() => { 35 | spectator.fixture.destroy(); 36 | }); 37 | 38 | it('THEN `complete` function was called', () => { 39 | expect(complete).toHaveBeenCalledTimes(1); 40 | }); 41 | }); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /projects/ng-in-viewport/src/lib/directives/destroyable.directive.ts: -------------------------------------------------------------------------------- 1 | import { Directive, OnDestroy } from '@angular/core'; 2 | import { Observable, ReplaySubject } from 'rxjs'; 3 | 4 | @Directive({ 5 | standalone: true, 6 | selector: '[inViewportDestroyable]', 7 | }) 8 | export class DestroyableDirective implements OnDestroy { 9 | public readonly destroyed$: Observable; 10 | 11 | private readonly destroyed$$ = new ReplaySubject(1); 12 | 13 | constructor() { 14 | this.destroyed$ = this.destroyed$$.asObservable(); 15 | } 16 | 17 | public ngOnDestroy(): void { 18 | if (this.destroyed$$ && !this.destroyed$$.closed) { 19 | this.destroyed$$.next(); 20 | this.destroyed$$.complete(); 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /projects/ng-in-viewport/src/lib/directives/in-viewport.directive.spec.ts: -------------------------------------------------------------------------------- 1 | import { PLATFORM_ID } from '@angular/core'; 2 | import { HostComponent } from '@ngneat/spectator'; 3 | import { SpectatorDirective, createDirectiveFactory } from '@ngneat/spectator/jest'; 4 | import { MockProvider } from 'ng-mocks'; 5 | import { ReplaySubject } from 'rxjs'; 6 | 7 | import { InViewportDirection } from '../enums'; 8 | import { InViewportService } from '../services'; 9 | import { Config } from '../values'; 10 | 11 | import { InViewportDirective, InViewportMetadata } from './in-viewport.directive'; 12 | 13 | describe('GIVEN InViewportDirective', () => { 14 | const createDirective = createDirectiveFactory(InViewportDirective); 15 | let spectator: SpectatorDirective; 16 | let directive: InViewportDirective; 17 | let host: HostComponent & { action: () => void }; 18 | let service: InViewportService; 19 | let node: Element; 20 | let config: Config; 21 | let trigger$: ReplaySubject; 22 | 23 | describe('WHEN directive was created', () => { 24 | beforeEach(async () => { 25 | trigger$ = new ReplaySubject(1); 26 | 27 | spectator = createDirective( 28 | `
Testing InViewportDirective
`, 29 | { 30 | hostProps: { options: { threshold: [0, 0.5, 1], partial: false }, action: jest.fn() }, 31 | providers: [ 32 | MockProvider(InViewportService, { 33 | trigger$: trigger$.asObservable(), 34 | register: jest.fn(), 35 | unregister: jest.fn(), 36 | }), 37 | ], 38 | } 39 | ); 40 | 41 | directive = spectator.directive; 42 | host = spectator.hostComponent as HostComponent & { action: () => void }; 43 | service = spectator.inject(InViewportService); 44 | node = spectator.query('div') as HTMLDivElement; 45 | config = new Config({ threshold: [0, 0.5, 1] }); 46 | }); 47 | 48 | it('THEN instance should exists', () => { 49 | expect(directive).toBeTruthy(); 50 | }); 51 | 52 | it('THEN `register` methods from service should be called', () => { 53 | expect(service.register).toHaveBeenCalledWith(node, config); 54 | }); 55 | 56 | describe('AND `trigger$` observable from service emitted partially visible value', () => { 57 | beforeEach(() => 58 | trigger$.next({ 59 | target: node, 60 | intersectionRatio: 0.5, 61 | isIntersecting: true, 62 | } as IntersectionObserverEntry) 63 | ); 64 | 65 | it('THEN `action` method from host component should be called by action output', () => { 66 | expect(host.action).toHaveBeenCalledTimes(1); 67 | }); 68 | }); 69 | 70 | describe('AND `trigger$` observable from service emitted visible value', () => { 71 | beforeEach(() => 72 | trigger$.next({ 73 | target: node, 74 | intersectionRatio: 1, 75 | isIntersecting: true, 76 | } as IntersectionObserverEntry) 77 | ); 78 | 79 | it('THEN `action` method from host component should be called by action output', () => { 80 | expect(host.action).toHaveBeenCalledTimes(1); 81 | }); 82 | }); 83 | 84 | describe('AND config has checkFn and `trigger$` observable from service emitted value', () => { 85 | let mockCheckFn: () => void; 86 | 87 | beforeEach(() => { 88 | mockCheckFn = jest.fn(); 89 | 90 | spectator.setInput('options', { partial: true, checkFn: mockCheckFn }); 91 | spectator.detectChanges(); 92 | 93 | trigger$.next({ 94 | target: node, 95 | intersectionRatio: 1, 96 | isIntersecting: false, 97 | } as IntersectionObserverEntry); 98 | }); 99 | 100 | it('THEN `action` method from host component should be called by action output', () => { 101 | expect(host.action).toHaveBeenCalledTimes(1); 102 | }); 103 | 104 | it('THEN provided `checkFn` function should be called', () => { 105 | expect(mockCheckFn).toHaveBeenCalledTimes(1); 106 | }); 107 | }); 108 | 109 | describe('AND directive was destroyed', () => { 110 | beforeEach(() => spectator.fixture.destroy()); 111 | 112 | it('THEN `unregister` methods from service should be called', () => { 113 | expect(service.unregister).toHaveBeenCalledWith(node, config); 114 | }); 115 | 116 | it('THEN `action` method from host component should be called by action output', () => { 117 | expect(host.action).toHaveBeenCalledWith({ 118 | [InViewportMetadata]: { entry: undefined }, 119 | target: node, 120 | visible: false, 121 | }); 122 | }); 123 | }); 124 | }); 125 | 126 | describe('WHEN directive was created on server platform', () => { 127 | beforeEach(async () => { 128 | trigger$ = new ReplaySubject(1); 129 | 130 | spectator = createDirective( 131 | `
Testing InViewportDirective
`, 132 | { 133 | hostProps: { options: { threshold: [0, 0.5, 1] }, action: jest.fn() }, 134 | providers: [ 135 | { provide: PLATFORM_ID, useValue: 'server' }, 136 | MockProvider(InViewportService, { 137 | trigger$: trigger$.asObservable(), 138 | register: jest.fn(), 139 | unregister: jest.fn(), 140 | }), 141 | ], 142 | } 143 | ); 144 | 145 | directive = spectator.directive; 146 | host = spectator.hostComponent as HostComponent & { action: () => void }; 147 | service = spectator.inject(InViewportService); 148 | node = spectator.query('div') as HTMLDivElement; 149 | config = new Config({ 150 | root: spectator.element, 151 | rootMargin: '1px 2px 3px 4px', 152 | threshold: 1, 153 | partial: false, 154 | direction: InViewportDirection.VERTICAL, 155 | }); 156 | }); 157 | 158 | it('THEN instance should exists', () => { 159 | expect(directive).toBeTruthy(); 160 | }); 161 | 162 | it('THEN `register` methods from service should be called', () => { 163 | expect(service.register).not.toHaveBeenCalled(); 164 | }); 165 | 166 | it('THEN `action` method from host component should be called by action output', () => { 167 | expect(host.action).toHaveBeenCalledWith({ 168 | [InViewportMetadata]: { entry: undefined }, 169 | target: node, 170 | visible: true, 171 | }); 172 | }); 173 | 174 | describe('AND directive was destroyed', () => { 175 | beforeEach(() => spectator.fixture.destroy()); 176 | 177 | it('THEN `unregister` methods from service should be called', () => { 178 | expect(service.unregister).not.toHaveBeenCalled(); 179 | }); 180 | 181 | it('THEN `action` method from host component should be called by action output', () => { 182 | expect(host.action).not.toHaveBeenCalledWith({ 183 | [InViewportMetadata]: { entry: undefined }, 184 | target: node, 185 | visible: false, 186 | }); 187 | }); 188 | }); 189 | }); 190 | }); 191 | -------------------------------------------------------------------------------- /projects/ng-in-viewport/src/lib/directives/in-viewport.directive.ts: -------------------------------------------------------------------------------- 1 | import { isPlatformBrowser } from '@angular/common'; 2 | import { 3 | AfterViewInit, 4 | ChangeDetectorRef, 5 | Directive, 6 | ElementRef, 7 | EventEmitter, 8 | Input, 9 | OnDestroy, 10 | Output, 11 | PLATFORM_ID, 12 | inject, 13 | } from '@angular/core'; 14 | import { filter, takeUntil } from 'rxjs/operators'; 15 | 16 | import { InViewportService } from '../services'; 17 | import { Config } from '../values'; 18 | 19 | import { DestroyableDirective } from './destroyable.directive'; 20 | 21 | export const InViewportMetadata = Symbol('InViewportMetadata'); 22 | 23 | export interface InViewportAction { 24 | [InViewportMetadata]: { entry?: IntersectionObserverEntry }; 25 | target: HTMLElement | SVGElement | Element; 26 | visible: boolean; 27 | } 28 | 29 | export type InViewportOptions = Partial[0]>; 30 | 31 | @Directive({ 32 | standalone: true, 33 | selector: '[inViewport]', 34 | hostDirectives: [DestroyableDirective], 35 | }) 36 | export class InViewportDirective implements AfterViewInit, OnDestroy { 37 | @Input('inViewportOptions') 38 | public set options(options: InViewportOptions) { 39 | this.config = new Config(options); 40 | } 41 | 42 | @Output() public readonly inViewportAction = new EventEmitter(); 43 | 44 | @Output() public readonly inViewportCustomCheck = new EventEmitter(); 45 | 46 | private config = new Config({}); 47 | 48 | protected readonly platformId = inject(PLATFORM_ID); 49 | 50 | protected readonly changeDetectorRef = inject(ChangeDetectorRef); 51 | 52 | protected readonly elementRef = inject>(ElementRef); 53 | 54 | protected readonly destroyable = inject(DestroyableDirective, { self: true }); 55 | 56 | protected readonly inViewportService = inject(InViewportService); 57 | 58 | private get nativeElement(): Element { 59 | return this.elementRef.nativeElement; 60 | } 61 | 62 | public ngAfterViewInit(): void { 63 | if (!isPlatformBrowser(this.platformId)) { 64 | this.emit(undefined, true, true); 65 | return; 66 | } 67 | 68 | this.inViewportService.trigger$ 69 | .pipe( 70 | filter((entry) => entry.target === this.nativeElement), 71 | takeUntil(this.destroyable.destroyed$) 72 | ) 73 | .subscribe((entry) => { 74 | this.emit(entry, false); 75 | this.changeDetectorRef.markForCheck(); 76 | }); 77 | 78 | this.inViewportService.register(this.nativeElement, this.config); 79 | } 80 | 81 | public ngOnDestroy(): void { 82 | if (isPlatformBrowser(this.platformId)) { 83 | this.inViewportService.unregister(this.nativeElement, this.config); 84 | 85 | this.emit(undefined, true, false); 86 | } 87 | } 88 | 89 | private isVisible(entry: IntersectionObserverEntry): boolean { 90 | return this.config.partial ? entry.isIntersecting || entry.intersectionRatio > 0 : entry.intersectionRatio >= 1; 91 | } 92 | 93 | private emit(entry: IntersectionObserverEntry, force: false): void; 94 | private emit(entry: undefined, force: true, forcedValue: boolean): void; 95 | private emit(entry: IntersectionObserverEntry | undefined, force: boolean, forcedValue?: boolean): void { 96 | this.inViewportAction.emit({ 97 | [InViewportMetadata]: { entry }, 98 | target: this.nativeElement, 99 | visible: force ? !!forcedValue : !entry || this.isVisible(entry), 100 | }); 101 | 102 | if (this.config.checkFn) { 103 | this.inViewportCustomCheck.emit( 104 | this.config.checkFn(entry, { 105 | force, 106 | forcedValue: force ? !!forcedValue : undefined, 107 | config: this.config, 108 | }) 109 | ); 110 | } 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /projects/ng-in-viewport/src/lib/directives/index.ts: -------------------------------------------------------------------------------- 1 | export * from './destroyable.directive'; 2 | export * from './in-viewport.directive'; 3 | -------------------------------------------------------------------------------- /projects/ng-in-viewport/src/lib/enums/in-viewport-direction.ts: -------------------------------------------------------------------------------- 1 | export enum InViewportDirection { 2 | BOTH = 'both', 3 | VERTICAL = 'vertical', 4 | HORIZONTAL = 'horizontal', 5 | } 6 | -------------------------------------------------------------------------------- /projects/ng-in-viewport/src/lib/enums/index.ts: -------------------------------------------------------------------------------- 1 | export * from './in-viewport-direction'; 2 | -------------------------------------------------------------------------------- /projects/ng-in-viewport/src/lib/exceptions/index.ts: -------------------------------------------------------------------------------- 1 | export * from './invalid-direction.exception'; 2 | export * from './invalid-root-margin.exception'; 3 | export * from './invalid-root-node.exception'; 4 | export * from './invalid-threshold.exception'; 5 | -------------------------------------------------------------------------------- /projects/ng-in-viewport/src/lib/exceptions/invalid-direction.exception.spec.ts: -------------------------------------------------------------------------------- 1 | import { InViewportDirection } from '../enums'; 2 | 3 | import { InvalidDirectionException } from './invalid-direction.exception'; 4 | 5 | describe('GIVEN InvalidDirectionException', () => { 6 | describe('WHEN exception was thrown', () => { 7 | const throwException = () => { 8 | throw new InvalidDirectionException(); 9 | }; 10 | 11 | it('THEN error should be instance of TypeError', () => { 12 | expect(throwException).toThrow(TypeError); 13 | }); 14 | 15 | it('THEN error message should match', () => { 16 | const values = Object.values(InViewportDirection).join('|'); 17 | const message = [ 18 | 'The provided value for the direction is incorrect.', 19 | `The value must be any of \`${values}\`.`, 20 | ].join(' '); 21 | 22 | expect(throwException).toThrow(message); 23 | }); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /projects/ng-in-viewport/src/lib/exceptions/invalid-direction.exception.ts: -------------------------------------------------------------------------------- 1 | import { InViewportDirection } from '../enums'; 2 | 3 | export class InvalidDirectionException extends TypeError { 4 | constructor() { 5 | const values = Object.values(InViewportDirection).join('|'); 6 | 7 | super(`The provided value for the direction is incorrect. The value must be any of \`${values}\`.`); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /projects/ng-in-viewport/src/lib/exceptions/invalid-root-margin.exception.spec.ts: -------------------------------------------------------------------------------- 1 | import { InvalidRootMarginException } from './invalid-root-margin.exception'; 2 | 3 | describe('GIVEN InvalidRootMarginException', () => { 4 | describe('WHEN exception was thrown', () => { 5 | const throwException = () => { 6 | throw new InvalidRootMarginException(); 7 | }; 8 | 9 | it('THEN error should be instance of TypeError', () => { 10 | expect(throwException).toThrow(TypeError); 11 | }); 12 | 13 | it('THEN error message should match', () => { 14 | const message = [ 15 | 'The provided value for the rootMargin is incorrect.', 16 | 'The value must be specified in pixels or percent.', 17 | ].join(' '); 18 | 19 | expect(throwException).toThrow(message); 20 | }); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /projects/ng-in-viewport/src/lib/exceptions/invalid-root-margin.exception.ts: -------------------------------------------------------------------------------- 1 | export class InvalidRootMarginException extends TypeError { 2 | constructor() { 3 | super('The provided value for the rootMargin is incorrect. The value must be specified in pixels or percent.'); 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /projects/ng-in-viewport/src/lib/exceptions/invalid-root-node.exception.spec.ts: -------------------------------------------------------------------------------- 1 | import { InvalidRootNodeException } from './invalid-root-node.exception'; 2 | 3 | describe('GIVEN InvalidRootNodeException', () => { 4 | describe('WHEN exception was thrown', () => { 5 | const throwException = () => { 6 | throw new InvalidRootNodeException(); 7 | }; 8 | 9 | it('THEN error should be instance of TypeError', () => { 10 | expect(throwException).toThrow(TypeError); 11 | }); 12 | 13 | it('THEN error message should match', () => { 14 | const message = [ 15 | 'The provided value for the root is incorrect.', 16 | `The value must be of type '(Document or Element)'.`, 17 | ].join(' '); 18 | 19 | expect(throwException).toThrow(message); 20 | }); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /projects/ng-in-viewport/src/lib/exceptions/invalid-root-node.exception.ts: -------------------------------------------------------------------------------- 1 | export class InvalidRootNodeException extends TypeError { 2 | constructor() { 3 | super(`The provided value for the root is incorrect. The value must be of type '(Document or Element)'.`); 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /projects/ng-in-viewport/src/lib/exceptions/invalid-threshold.exception.spec.ts: -------------------------------------------------------------------------------- 1 | import { InvalidThresholdException } from './invalid-threshold.exception'; 2 | 3 | describe('GIVEN InvalidThresholdException', () => { 4 | describe('WHEN exception was thrown', () => { 5 | const throwException = () => { 6 | throw new InvalidThresholdException(); 7 | }; 8 | 9 | it('THEN error should be instance of TypeError', () => { 10 | expect(throwException).toThrow(TypeError); 11 | }); 12 | 13 | it('THEN error message should match', () => { 14 | const message = [ 15 | 'The provided values for the threshold are incorrect.', 16 | 'The values must be numbers between 0 and 1.', 17 | ].join(' '); 18 | 19 | expect(throwException).toThrow(message); 20 | }); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /projects/ng-in-viewport/src/lib/exceptions/invalid-threshold.exception.ts: -------------------------------------------------------------------------------- 1 | export class InvalidThresholdException extends TypeError { 2 | constructor() { 3 | super('The provided values for the threshold are incorrect. The values must be numbers between 0 and 1.'); 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /projects/ng-in-viewport/src/lib/in-viewport.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | 3 | import { DestroyableDirective, InViewportDirective } from './directives/'; 4 | 5 | @NgModule({ 6 | imports: [InViewportDirective, DestroyableDirective], 7 | exports: [InViewportDirective, DestroyableDirective], 8 | }) 9 | export class InViewportModule {} 10 | -------------------------------------------------------------------------------- /projects/ng-in-viewport/src/lib/services/in-viewport.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { NgZone } from '@angular/core'; 2 | import { SpectatorService, createServiceFactory } from '@ngneat/spectator/jest'; 3 | import { uniqueId } from 'lodash'; 4 | import { Subscription } from 'rxjs'; 5 | 6 | import { ObserverCache } from '../utils'; 7 | import { Config } from '../values'; 8 | 9 | import { InViewportService } from './in-viewport.service'; 10 | 11 | const createNode = (): HTMLDivElement => { 12 | return Object.assign(document.createElement('div'), { 13 | className: uniqueId('c-'), 14 | }); 15 | }; 16 | 17 | let mockAddNode: (node: Element, config: Config) => void; 18 | let mockDeleteNode: (node: Element, config: Config) => void; 19 | 20 | jest.mock('../utils/observer-cache', () => ({ 21 | ObserverCache: jest.fn().mockImplementation((...args: ConstructorParameters) => { 22 | const callback = args[0]; 23 | 24 | mockAddNode = jest.fn().mockImplementation((node) => { 25 | callback([{ target: node } as IntersectionObserverEntry], {} as IntersectionObserver); 26 | }); 27 | mockDeleteNode = jest.fn(); 28 | 29 | return { 30 | addNode: mockAddNode, 31 | deleteNode: mockDeleteNode, 32 | }; 33 | }), 34 | })); 35 | 36 | describe('GIVEN InViewportService', () => { 37 | let spectator: SpectatorService; 38 | let service: InViewportService; 39 | 40 | const createService = createServiceFactory(InViewportService); 41 | 42 | beforeEach(() => { 43 | spectator = createService(); 44 | service = spectator.service; 45 | }); 46 | 47 | describe('WHEN service was created', () => { 48 | it('THEN instance should exists', () => { 49 | expect(service).toBeTruthy(); 50 | }); 51 | 52 | describe('AND `register` method was called', () => { 53 | const node = createNode(); 54 | const config = new Config(); 55 | 56 | let triggerCallback: (...args: any[]) => boolean; 57 | let triggerSubscription$: Subscription; 58 | 59 | beforeEach(() => { 60 | triggerCallback = jest.fn().mockImplementation(() => NgZone.isInAngularZone()); 61 | 62 | triggerSubscription$ = service.trigger$.subscribe((...args) => triggerCallback(...args)); 63 | 64 | service.register(node, config); 65 | }); 66 | 67 | afterEach(() => triggerSubscription$.unsubscribe()); 68 | 69 | it('THEN `addNode` from cache should by called by service', () => { 70 | expect(mockAddNode).toHaveBeenCalledWith(node, config); 71 | }); 72 | 73 | it('THEN intersection event should be handled in NgZone', () => { 74 | expect(triggerCallback).toHaveReturnedWith(true); 75 | }); 76 | 77 | it('THEN `trigger$` should emit initial event', () => { 78 | expect(triggerCallback).toHaveBeenCalledWith({ target: node }); 79 | }); 80 | }); 81 | 82 | describe('AND `unregister` method was called', () => { 83 | const node = createNode(); 84 | const config = new Config(); 85 | 86 | beforeEach(() => { 87 | service.unregister(node, config); 88 | }); 89 | 90 | it('THEN `deleteNode` from cache should by called by service', () => { 91 | expect(mockDeleteNode).toHaveBeenCalledWith(node, config); 92 | }); 93 | }); 94 | }); 95 | }); 96 | -------------------------------------------------------------------------------- /projects/ng-in-viewport/src/lib/services/in-viewport.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, NgZone, inject } from '@angular/core'; 2 | import { Subject } from 'rxjs'; 3 | 4 | import { ObserverCache } from '../utils'; 5 | import { Config } from '../values'; 6 | 7 | @Injectable({ providedIn: 'root' }) 8 | export class InViewportService { 9 | readonly #trigger$ = new Subject(); 10 | 11 | #cache!: ObserverCache; 12 | 13 | public readonly trigger$ = this.#trigger$.asObservable(); 14 | 15 | private readonly zone = inject(NgZone); 16 | 17 | constructor() { 18 | this.zone.runOutsideAngular(() => { 19 | this.#cache = new ObserverCache((entries) => this.onIntersectionEvent(entries)); 20 | }); 21 | } 22 | 23 | public register(node: Element, config: Config): void { 24 | this.zone.runOutsideAngular(() => { 25 | this.#cache.addNode(node, config); 26 | }); 27 | } 28 | 29 | public unregister(node: Element, config: Config): void { 30 | this.zone.runOutsideAngular(() => { 31 | this.#cache.deleteNode(node, config); 32 | }); 33 | } 34 | 35 | private onIntersectionEvent(entries: IntersectionObserverEntry[] = []): void { 36 | this.zone.run(() => entries.forEach((entry) => this.#trigger$.next(entry))); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /projects/ng-in-viewport/src/lib/services/index.ts: -------------------------------------------------------------------------------- 1 | export * from './in-viewport.service'; 2 | -------------------------------------------------------------------------------- /projects/ng-in-viewport/src/lib/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './is-object'; 2 | export * from './observer-cache'; 3 | export * from './observer-cache-item'; 4 | export * from './stringify'; 5 | export * from './to-base64'; 6 | -------------------------------------------------------------------------------- /projects/ng-in-viewport/src/lib/utils/is-object.spec.ts: -------------------------------------------------------------------------------- 1 | import { isObject } from './is-object'; 2 | 3 | describe('GIVEN isObject', () => { 4 | describe('WHEN called with plain object', () => { 5 | it('THEN return true', () => { 6 | expect(isObject({})).toBe(true); 7 | }); 8 | }); 9 | 10 | describe('WHEN called with class instance', () => { 11 | it('THEN return true', () => { 12 | const instance = new (class Foo {})(); 13 | 14 | expect(isObject(instance)).toBe(true); 15 | }); 16 | }); 17 | 18 | describe('WHEN called invalid values', () => { 19 | it('THEN return false', () => { 20 | [ 21 | undefined, 22 | null, 23 | 'foo bar', 24 | 1, 25 | Number.NaN, 26 | Symbol(), 27 | [], 28 | new Set(), 29 | new Map(), 30 | () => { 31 | return; 32 | }, 33 | ].forEach((val) => { 34 | expect(isObject(val)).toBe(false); 35 | }); 36 | }); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /projects/ng-in-viewport/src/lib/utils/is-object.ts: -------------------------------------------------------------------------------- 1 | export const isObject = (value: unknown): value is Record => 2 | Object.prototype.toString.call(value) === '[object Object]'; 3 | -------------------------------------------------------------------------------- /projects/ng-in-viewport/src/lib/utils/observer-cache-item.spec.ts: -------------------------------------------------------------------------------- 1 | import { Config } from '../values'; 2 | 3 | import { ObserverCacheItem } from './observer-cache-item'; 4 | 5 | const intersectionObserver = globalThis.IntersectionObserver; 6 | 7 | describe('GIVEN ObserverCacheItem', () => { 8 | let mockObserve: typeof IntersectionObserver.prototype.observe; 9 | let mockUnobserve: typeof IntersectionObserver.prototype.unobserve; 10 | let mockDisconnect: typeof IntersectionObserver.prototype.disconnect; 11 | 12 | beforeEach(() => { 13 | globalThis.IntersectionObserver = jest.fn().mockImplementation((callback: IntersectionObserverCallback) => { 14 | mockObserve = jest.fn().mockImplementation((node: Element) => { 15 | callback([{ target: node } as IntersectionObserverEntry], {} as IntersectionObserver); 16 | }); 17 | mockUnobserve = jest.fn(); 18 | mockDisconnect = jest.fn(); 19 | 20 | return { 21 | observe: mockObserve, 22 | unobserve: mockUnobserve, 23 | disconnect: mockDisconnect, 24 | }; 25 | }); 26 | }); 27 | 28 | afterEach(() => { 29 | globalThis.IntersectionObserver = intersectionObserver; 30 | }); 31 | 32 | describe('WHEN instance was created', () => { 33 | let instance: ObserverCacheItem; 34 | let mockNext: IntersectionObserverCallback; 35 | let mockComplete: () => void; 36 | 37 | beforeEach(() => { 38 | mockNext = jest.fn(); 39 | mockComplete = jest.fn(); 40 | 41 | instance = new ObserverCacheItem(new Config(), { next: mockNext, complete: mockComplete }); 42 | }); 43 | 44 | it('THEN instance should exists', () => { 45 | expect(instance).toBeTruthy(); 46 | }); 47 | 48 | describe('AND `addNode` method was called', () => { 49 | const node = createNode('div'); 50 | 51 | beforeEach(() => { 52 | instance.addNode(node); 53 | }); 54 | 55 | it('THEN `observe` from IntersectionObserver should be called', () => { 56 | expect(mockObserve).toHaveBeenCalledWith(node); 57 | }); 58 | 59 | describe('AND `addNode` method was called with another nextNode', () => { 60 | const nextNode = createNode('div'); 61 | 62 | beforeEach(() => { 63 | instance.addNode(nextNode); 64 | }); 65 | 66 | it('THEN `observe` from IntersectionObserver should be called', () => { 67 | expect(mockObserve).toHaveBeenCalledWith(nextNode); 68 | }); 69 | 70 | describe('AND `deleteNode` method was called with nextNode', () => { 71 | it('THEN `unobserve` from IntersectionObserver should be called', () => { 72 | instance.deleteNode(nextNode); 73 | 74 | expect(mockUnobserve).toHaveBeenCalledWith(nextNode); 75 | }); 76 | }); 77 | 78 | describe('AND `deleteNode` method was called with all nodes', () => { 79 | it('THEN `disconnect` from IntersectionObserver should be called', () => { 80 | instance.deleteNode(node); 81 | instance.deleteNode(nextNode); 82 | 83 | expect(mockDisconnect).toHaveBeenCalledTimes(1); 84 | }); 85 | }); 86 | }); 87 | }); 88 | }); 89 | }); 90 | 91 | function createNode(tagName: string): HTMLElement { 92 | return Object.assign(document.createElement(tagName), { 93 | classname: `c-${Date.now()}`, 94 | }); 95 | } 96 | -------------------------------------------------------------------------------- /projects/ng-in-viewport/src/lib/utils/observer-cache-item.ts: -------------------------------------------------------------------------------- 1 | import { Config } from '../values'; 2 | 3 | export class ObserverCacheItem { 4 | readonly #nodes = new Set(); 5 | 6 | readonly #observer: IntersectionObserver; 7 | 8 | readonly #destroy: () => void; 9 | 10 | constructor(config: Config, callback: { next: IntersectionObserverCallback; complete: () => void }) { 11 | this.#observer = new IntersectionObserver( 12 | (...args) => { 13 | callback.next(...args); 14 | }, 15 | { 16 | root: config.root, 17 | rootMargin: config.rootMargin, 18 | threshold: [...config.threshold], 19 | } 20 | ); 21 | 22 | this.#destroy = () => { 23 | this.#observer.disconnect(); 24 | callback.complete(); 25 | }; 26 | } 27 | 28 | public addNode(node: Element): void { 29 | this.#nodes.add(node); 30 | this.#observer.observe(node); 31 | } 32 | 33 | public deleteNode(node: Element): void { 34 | this.#nodes.delete(node); 35 | this.#nodes.size ? this.#observer.unobserve(node) : this.#destroy(); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /projects/ng-in-viewport/src/lib/utils/observer-cache.spec.ts: -------------------------------------------------------------------------------- 1 | import { Config } from '../values'; 2 | 3 | import { ObserverCache } from './observer-cache'; 4 | import { ObserverCacheItem } from './observer-cache-item'; 5 | 6 | let mockAddNode: (node: Element) => void; 7 | let mockDeleteNode: (node: Element) => void; 8 | 9 | jest.mock('./observer-cache-item', () => ({ 10 | ObserverCacheItem: jest.fn().mockImplementation((...args: ConstructorParameters) => { 11 | const { next, complete } = args[1]; 12 | const nodes = new Set(); 13 | 14 | mockAddNode = jest.fn().mockImplementation((node: Element) => { 15 | nodes.add(node); 16 | next([{ target: node } as IntersectionObserverEntry], {} as IntersectionObserver); 17 | }); 18 | 19 | mockDeleteNode = jest.fn().mockImplementation((node: Element) => { 20 | nodes.delete(node); 21 | complete(); 22 | }); 23 | 24 | return { 25 | addNode: mockAddNode, 26 | deleteNode: mockDeleteNode, 27 | }; 28 | }), 29 | })); 30 | 31 | describe('GIVEN ObserverCache', () => { 32 | describe('WHEN instance was created', () => { 33 | let callback: IntersectionObserverCallback; 34 | let instance: ObserverCache; 35 | 36 | beforeEach(() => { 37 | callback = jest.fn(); 38 | instance = new ObserverCache(callback); 39 | }); 40 | 41 | it('THEN instance should exists', () => { 42 | expect(instance).toBeTruthy(); 43 | }); 44 | 45 | describe('AND `addNode` method was called with config containing empty root', () => { 46 | const node = createNode('div'); 47 | const config = new Config(); 48 | 49 | beforeEach(() => { 50 | instance.addNode(node, config); 51 | }); 52 | 53 | it('THEN `addNode` from ObserverCacheItem should be called', () => { 54 | expect(mockAddNode).toHaveBeenCalledWith(node); 55 | }); 56 | 57 | describe('AND `addNode` with another config was called', () => { 58 | const nextNode = createNode('div'); 59 | const nextConfig = new Config({ rootMargin: '1px' }); 60 | 61 | beforeEach(() => { 62 | instance.addNode(nextNode, nextConfig); 63 | instance.deleteNode(nextNode, nextConfig); 64 | }); 65 | 66 | it('THEN `addNode` from ObserverCacheItem should be called', () => { 67 | expect(mockAddNode).toHaveBeenCalledWith(nextNode); 68 | }); 69 | 70 | it('THEN `deleteNode` from ObserverCacheItem should be called', () => { 71 | expect(mockDeleteNode).toHaveBeenCalledWith(nextNode); 72 | }); 73 | }); 74 | 75 | describe('AND `deleteNode` method was called', () => { 76 | it('THEN `deleteNode` from ObserverCacheItem should be called', () => { 77 | instance.deleteNode(node, config); 78 | 79 | expect(mockDeleteNode).toHaveBeenCalledWith(node); 80 | }); 81 | }); 82 | }); 83 | 84 | describe('AND `addNode` and `deleteNode` method was called with config containing root node', () => { 85 | const rootNode = createNode('div'); 86 | const node = createNode('div'); 87 | const config = new Config({ root: rootNode }); 88 | 89 | beforeEach(() => { 90 | instance.addNode(node, config); 91 | instance.deleteNode(node, config); 92 | }); 93 | 94 | it('THEN `addNode` from ObserverCacheItem should be called', () => { 95 | expect(mockAddNode).toHaveBeenCalledWith(node); 96 | }); 97 | 98 | it('THEN `deleteNode` from ObserverCacheItem should be called', () => { 99 | expect(mockDeleteNode).toHaveBeenCalledWith(node); 100 | }); 101 | }); 102 | }); 103 | }); 104 | 105 | function createNode(tagName: string): HTMLElement { 106 | return Object.assign(document.createElement(tagName), { 107 | classname: `c-${Date.now()}`, 108 | }); 109 | } 110 | -------------------------------------------------------------------------------- /projects/ng-in-viewport/src/lib/utils/observer-cache.ts: -------------------------------------------------------------------------------- 1 | import { Config, configHash } from '../values'; 2 | 3 | import { ObserverCacheItem } from './observer-cache-item'; 4 | 5 | export class ObserverCache { 6 | static readonly #EMPTY_ROOT = Object.create(null) as Element; 7 | 8 | readonly #cache = new WeakMap>(); 9 | 10 | readonly #callback: IntersectionObserverCallback; 11 | 12 | constructor(callback: IntersectionObserverCallback) { 13 | this.#callback = callback; 14 | } 15 | 16 | public addNode(node: Element, config: Config): void { 17 | const items = this.#cache.get(config.root ?? ObserverCache.#EMPTY_ROOT) ?? this.create(config); 18 | const hash = config[configHash]; 19 | 20 | // Scenario when configuration with new hash appears, 21 | // but root is already in cache. 22 | if (!items.has(hash)) { 23 | this.insert(items, config); 24 | } 25 | 26 | items.get(hash)!.addNode(node); 27 | } 28 | 29 | public deleteNode(node: Element, config: Config): void { 30 | const items = this.#cache.get(config.root ?? ObserverCache.#EMPTY_ROOT); 31 | const hash = config[configHash]; 32 | 33 | if (items && items.has(hash)) { 34 | items.get(hash)!.deleteNode(node); 35 | } 36 | 37 | if (items && items.size === 0) { 38 | this.#cache.delete(config.root ?? ObserverCache.#EMPTY_ROOT); 39 | } 40 | } 41 | 42 | private create(config: Config): Map { 43 | const items = new Map(); 44 | 45 | this.#cache.set(config.root ?? ObserverCache.#EMPTY_ROOT, items); 46 | 47 | this.insert(items, config); 48 | 49 | return items; 50 | } 51 | 52 | private insert(items: Map, config: Config): void { 53 | const hash = config[configHash]; 54 | 55 | const cacheItem = new ObserverCacheItem(config, { 56 | next: (...args) => { 57 | this.#callback(...args); 58 | }, 59 | complete: () => { 60 | items.delete(hash); 61 | }, 62 | }); 63 | 64 | items.set(hash, cacheItem); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /projects/ng-in-viewport/src/lib/utils/stringify.spec.ts: -------------------------------------------------------------------------------- 1 | import { stringify } from './stringify'; 2 | 3 | describe('GIVEN stringify', () => { 4 | describe('WHEN called with `abcd`', () => { 5 | it('THEN result should match', () => { 6 | expect( 7 | stringify({ 8 | foo: 'bar', 9 | nested: { 10 | xyz: 123, 11 | }, 12 | numbers: [1, 2, 3], 13 | letters: ['a', 'b', 'c'], 14 | }) 15 | ).toBe('foo:bar|letters:[a,b,c]|nested:xyz:123|numbers:[1,2,3]'); 16 | }); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /projects/ng-in-viewport/src/lib/utils/stringify.ts: -------------------------------------------------------------------------------- 1 | import { isObject } from './is-object'; 2 | 3 | export const stringify = (input: unknown): string => { 4 | if (Array.isArray(input)) { 5 | return `[${input.map(stringify).join(',')}]`; 6 | } 7 | 8 | if (isObject(input)) { 9 | return Object.entries(input) 10 | .sort(([a], [b]) => String(a).localeCompare(String(b))) 11 | .map(([key, value]) => `${key}:${stringify(value)}`) 12 | .join('|'); 13 | } 14 | 15 | return String(input); 16 | }; 17 | -------------------------------------------------------------------------------- /projects/ng-in-viewport/src/lib/utils/to-base64.spec.ts: -------------------------------------------------------------------------------- 1 | import { toBase64 } from './to-base64'; 2 | 3 | describe('GIVEN toBase64', () => { 4 | describe('WHEN called with `abcd`', () => { 5 | it('THEN result should match', () => { 6 | expect(toBase64('abcd')).toBe('YWJjZA=='); 7 | }); 8 | }); 9 | 10 | describe('WHEN called with invalid value', () => { 11 | it('THEN return back what was passed', () => { 12 | const value = Symbol(); 13 | 14 | // @ts-expect-error unit testing 15 | expect(toBase64(value)).toBe(value); 16 | }); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /projects/ng-in-viewport/src/lib/utils/to-base64.ts: -------------------------------------------------------------------------------- 1 | export const toBase64 = (value: string): string => { 2 | try { 3 | const input = globalThis.encodeURI(value); 4 | return globalThis.btoa(input); 5 | } catch { 6 | return value; 7 | } 8 | }; 9 | -------------------------------------------------------------------------------- /projects/ng-in-viewport/src/lib/values/check-fn.spec.ts: -------------------------------------------------------------------------------- 1 | import { CheckFn } from './check-fn'; 2 | 3 | describe('GIVEN CheckFn', () => { 4 | describe('WHEN created with non-nullable value', () => { 5 | let instance: CheckFn; 6 | const fn = jest.fn(); 7 | 8 | beforeEach(() => { 9 | instance = new CheckFn(fn); 10 | }); 11 | 12 | it('THEN value should match provided fn', () => { 13 | expect(instance.value).toBe(fn); 14 | }); 15 | 16 | it('THEN id should not be empty', () => { 17 | expect(instance.id?.startsWith('in-viewport-check-fn')).toBe(true); 18 | }); 19 | }); 20 | 21 | describe('WHEN created with nullable value', () => { 22 | let instance: CheckFn; 23 | const fn = null; 24 | 25 | beforeEach(() => { 26 | instance = new CheckFn(fn); 27 | }); 28 | 29 | it('THEN value should be empty', () => { 30 | expect(instance.value).toBe(undefined); 31 | }); 32 | 33 | it('THEN id should return empty fallback', () => { 34 | expect(instance.id).toBe('in-viewport-empty-check-fn'); 35 | }); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /projects/ng-in-viewport/src/lib/values/check-fn.ts: -------------------------------------------------------------------------------- 1 | import { isFunction, isNil, uniqueId } from 'lodash-es'; 2 | 3 | import { Config } from './config'; 4 | 5 | export interface InViewportCheckFnOptions { 6 | /** 7 | * Whether action was triggered programmatically. 8 | */ 9 | force: boolean; 10 | /** 11 | * When an action is triggered programmatically, this property will hold a simulated visibility state. 12 | */ 13 | forcedValue?: boolean; 14 | /** 15 | * Instance of a configuration object. 16 | */ 17 | config: Config; 18 | } 19 | 20 | export interface InViewportCheckFn { 21 | (entry: IntersectionObserverEntry | undefined, options: InViewportCheckFnOptions): T; 22 | } 23 | 24 | const ids = new WeakMap, string>(); 25 | const fallbackId = 'in-viewport-empty-check-fn'; 26 | 27 | export class CheckFn { 28 | readonly #value: InViewportCheckFn | undefined; 29 | 30 | readonly #id: string; 31 | 32 | public get value(): InViewportCheckFn | undefined { 33 | return this.#value; 34 | } 35 | 36 | public get id(): string { 37 | return this.#id; 38 | } 39 | 40 | constructor(value: InViewportCheckFn | null | undefined) { 41 | this.#value = isFunction(value) ? value : undefined; 42 | 43 | let id = ids.get(value!) ?? fallbackId; 44 | 45 | if (!isNil(value) && !ids.has(value)) { 46 | ids.set(value, (id = uniqueId('in-viewport-check-fn-'))); 47 | } 48 | 49 | this.#id = id; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /projects/ng-in-viewport/src/lib/values/config.spec.ts: -------------------------------------------------------------------------------- 1 | import { InViewportDirection } from '../enums'; 2 | 3 | import { Config, checkFnId, configHash } from './config'; 4 | 5 | describe('GIVEN Config', () => { 6 | it('todo', () => { 7 | expect(1).toBe(1); 8 | }); 9 | 10 | describe('WHEN created with nullable options', () => { 11 | let instance: Config; 12 | 13 | beforeEach(() => { 14 | instance = new Config(undefined); 15 | }); 16 | 17 | it('THEN instance should exists', () => { 18 | expect(instance).toBeTruthy(); 19 | }); 20 | 21 | it('THEN hash should match', () => { 22 | expect(instance[configHash]).toBe( 23 | 'Y2hlY2tGbjppbi12aWV3cG9ydC1lbXB0eS1jaGVjay1mbiU3Q2RpcmVjdGlvbjp2ZXJ0aWNhbCU3Q3BhcnRpYWw6dHJ1ZSU3Q3Jvb3RNYXJnaW46MHB4JTIwMHB4JTIwMHB4JTIwMHB4JTdDdGhyZXNob2xkOiU1QjAsMSU1RA==' 24 | ); 25 | }); 26 | }); 27 | 28 | describe('WHEN created with valid options', () => { 29 | let instance: Config; 30 | let node: HTMLDivElement; 31 | 32 | beforeEach(() => { 33 | node = Object.assign(document.createElement('div'), { 34 | classname: `c-${Date.now()}`, 35 | }); 36 | 37 | instance = new Config({ 38 | root: node, 39 | rootMargin: '1px 2px 3px 4px', 40 | threshold: [0, 0.24, 0.5, 0.75, 1], 41 | partial: false, 42 | direction: InViewportDirection.HORIZONTAL, 43 | }); 44 | }); 45 | 46 | it('THEN instance should exists', () => { 47 | expect(instance).toBeTruthy(); 48 | }); 49 | 50 | it('THEN root should match provided node', () => { 51 | expect(instance.root).toBe(node); 52 | }); 53 | 54 | it('THEN hash should match', () => { 55 | expect(instance[configHash]).toBe( 56 | 'Y2hlY2tGbjppbi12aWV3cG9ydC1lbXB0eS1jaGVjay1mbiU3Q2RpcmVjdGlvbjpob3Jpem9udGFsJTdDcGFydGlhbDpmYWxzZSU3Q3Jvb3RNYXJnaW46MXB4JTIwMnB4JTIwM3B4JTIwNHB4JTdDdGhyZXNob2xkOiU1QjAsMC4yNCwwLjUsMC43NSwxJTVE' 57 | ); 58 | }); 59 | 60 | it('THEN checkFnId should match', () => { 61 | expect(instance[checkFnId]).toBe('in-viewport-empty-check-fn'); 62 | }); 63 | }); 64 | 65 | describe('WHEN created with valid options and custom check fn', () => { 66 | let instance: Config; 67 | let node: HTMLDivElement; 68 | 69 | beforeEach(() => { 70 | node = Object.assign(document.createElement('div'), { 71 | classname: `c-${Date.now()}`, 72 | }); 73 | 74 | instance = new Config({ 75 | root: node, 76 | rootMargin: '1px 2px 3px 4px', 77 | threshold: [0, 0.24, 0.5, 0.75, 1], 78 | partial: false, 79 | direction: InViewportDirection.HORIZONTAL, 80 | checkFn: jest.fn(), 81 | }); 82 | }); 83 | 84 | it('THEN instance should exists', () => { 85 | expect(instance).toBeTruthy(); 86 | }); 87 | 88 | it('THEN root should match provided node', () => { 89 | expect(instance.root).toBe(node); 90 | }); 91 | 92 | it('THEN hash should match', () => { 93 | expect(instance[configHash]).toBe( 94 | 'Y2hlY2tGbjppbi12aWV3cG9ydC1jaGVjay1mbi0zJTdDZGlyZWN0aW9uOmhvcml6b250YWwlN0NwYXJ0aWFsOmZhbHNlJTdDcm9vdE1hcmdpbjoxcHglMjAycHglMjAzcHglMjA0cHglN0N0aHJlc2hvbGQ6JTVCMCwwLjI0LDAuNSwwLjc1LDElNUQ=' 95 | ); 96 | }); 97 | 98 | it('THEN checkFnId should match', () => { 99 | expect(instance[checkFnId]).toBe('in-viewport-check-fn-4'); 100 | }); 101 | }); 102 | }); 103 | -------------------------------------------------------------------------------- /projects/ng-in-viewport/src/lib/values/config.ts: -------------------------------------------------------------------------------- 1 | import { InViewportDirection } from '../enums'; 2 | import { stringify, toBase64 } from '../utils'; 3 | 4 | import { CheckFn, InViewportCheckFn } from './check-fn'; 5 | import { Direction } from './direction'; 6 | import { Partial } from './partial'; 7 | import { RootMargin } from './root-margin'; 8 | import { RootNode } from './root-node'; 9 | import { Threshold } from './threshold'; 10 | 11 | export const checkFnId = Symbol('InViewportCheckFnId'); 12 | export const configHash = Symbol('InViewportConfigHash'); 13 | 14 | export class Config { 15 | readonly #value: { 16 | root: RootNode; 17 | rootMargin: RootMargin; 18 | threshold: Threshold; 19 | partial: Partial; 20 | direction: Direction; 21 | checkFn: CheckFn; 22 | }; 23 | 24 | readonly #hash: string; 25 | 26 | public get root(): Element | null { 27 | return this.#value.root.value; 28 | } 29 | 30 | public get rootMargin(): string { 31 | return this.#value.rootMargin.value; 32 | } 33 | 34 | public get threshold(): readonly number[] { 35 | return this.#value.threshold.value; 36 | } 37 | 38 | public get partial(): boolean { 39 | return this.#value.partial.value; 40 | } 41 | 42 | public get direction(): `${InViewportDirection}` { 43 | return this.#value.direction.value; 44 | } 45 | 46 | public get checkFn(): InViewportCheckFn | undefined { 47 | return this.#value.checkFn.value; 48 | } 49 | 50 | public get [checkFnId](): string { 51 | return this.#value.checkFn.id; 52 | } 53 | 54 | public get [configHash](): string { 55 | return this.#hash; 56 | } 57 | 58 | constructor( 59 | options?: 60 | | (IntersectionObserverInit & { 61 | partial?: boolean; 62 | direction?: `${InViewportDirection}`; 63 | checkFn?: InViewportCheckFn; 64 | }) 65 | | null 66 | | undefined 67 | ) { 68 | options ??= {}; 69 | 70 | this.#value = Object.freeze({ 71 | root: new RootNode(options.root), 72 | rootMargin: new RootMargin(options.rootMargin), 73 | threshold: new Threshold(options.threshold), 74 | partial: new Partial(options.partial), 75 | direction: new Direction(options.direction), 76 | checkFn: new CheckFn(options.checkFn), 77 | }); 78 | 79 | this.#hash = toBase64( 80 | stringify({ 81 | rootMargin: this.rootMargin, 82 | threshold: this.threshold, 83 | partial: this.partial, 84 | direction: this.direction, 85 | checkFn: this[checkFnId], 86 | }) 87 | ); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /projects/ng-in-viewport/src/lib/values/direction.spec.ts: -------------------------------------------------------------------------------- 1 | import { InViewportDirection } from '../enums'; 2 | import { InvalidDirectionException } from '../exceptions'; 3 | 4 | import { Direction } from './direction'; 5 | 6 | describe('GIVEN Direction', () => { 7 | describe('WHEN created with value from `InViewportDirection`', () => { 8 | it('THEN value should match provided value', () => { 9 | const instance = new Direction(InViewportDirection.HORIZONTAL); 10 | 11 | expect(instance.value).toBe(InViewportDirection.HORIZONTAL); 12 | }); 13 | }); 14 | 15 | describe('WHEN created with nullable value', () => { 16 | it('THEN exception should be thrown', () => { 17 | // @ts-expect-error unit testing 18 | expect(() => new Direction(null)).toThrow(InvalidDirectionException); 19 | }); 20 | }); 21 | 22 | describe('WHEN created with invalid value', () => { 23 | it('THEN exception should be thrown', () => { 24 | // @ts-expect-error unit testing 25 | expect(() => new Direction('lorem-ipsum')).toThrow(InvalidDirectionException); 26 | }); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /projects/ng-in-viewport/src/lib/values/direction.ts: -------------------------------------------------------------------------------- 1 | import { InViewportDirection } from '../enums'; 2 | import { InvalidDirectionException } from '../exceptions'; 3 | 4 | export class Direction { 5 | readonly #value: `${InViewportDirection}`; 6 | 7 | public get value(): `${InViewportDirection}` { 8 | return this.#value; 9 | } 10 | 11 | constructor(value: `${InViewportDirection}` = InViewportDirection.VERTICAL) { 12 | if (!Object.values(InViewportDirection).includes(value)) { 13 | throw new InvalidDirectionException(); 14 | } 15 | 16 | this.#value = value; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /projects/ng-in-viewport/src/lib/values/index.ts: -------------------------------------------------------------------------------- 1 | export * from './check-fn'; 2 | export * from './direction'; 3 | export * from './config'; 4 | export * from './partial'; 5 | export * from './root-margin'; 6 | export * from './root-node'; 7 | export * from './threshold'; 8 | -------------------------------------------------------------------------------- /projects/ng-in-viewport/src/lib/values/partial.spec.ts: -------------------------------------------------------------------------------- 1 | import { Partial } from './partial'; 2 | 3 | describe('GIVEN Partial', () => { 4 | describe('WHEN created with boolean value', () => { 5 | it('THEN value should match provided value', () => { 6 | const instance = new Partial(false); 7 | 8 | expect(instance.value).toBe(false); 9 | }); 10 | }); 11 | 12 | describe('WHEN created with nullable value', () => { 13 | it('THEN value should return true', () => { 14 | const instance = new Partial(null); 15 | 16 | expect(instance.value).toBe(true); 17 | }); 18 | }); 19 | 20 | describe('WHEN created with invalid value', () => { 21 | it('THEN exception should be thrown', () => { 22 | // @ts-expect-error unit testing 23 | const instance = new Partial('lorem-ipsum'); 24 | 25 | expect(instance.value).toBe(true); 26 | }); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /projects/ng-in-viewport/src/lib/values/partial.ts: -------------------------------------------------------------------------------- 1 | import { isBoolean } from 'lodash-es'; 2 | 3 | export class Partial { 4 | readonly #value: boolean; 5 | 6 | public get value(): boolean { 7 | return this.#value; 8 | } 9 | 10 | constructor(value: boolean | null | undefined) { 11 | this.#value = isBoolean(value) ? value : true; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /projects/ng-in-viewport/src/lib/values/root-margin.spec.ts: -------------------------------------------------------------------------------- 1 | import { InvalidRootMarginException } from '../exceptions'; 2 | 3 | import { RootMargin } from './root-margin'; 4 | 5 | describe('GIVEN RootMargin', () => { 6 | describe('WHEN created with valid value', () => { 7 | it('THEN input `10px` should result to `10px 10px 10px 10xp`', () => { 8 | expect(new RootMargin('10px').value).toBe('10px 10px 10px 10px'); 9 | }); 10 | 11 | it('THEN input `10px 20px` should result to `10px 20px 10px 20xp`', () => { 12 | expect(new RootMargin('10px 20px').value).toBe('10px 20px 10px 20px'); 13 | }); 14 | 15 | it('THEN input `10px 20% 30px` should result to `10px 20% 30px 20%`', () => { 16 | expect(new RootMargin('10px 20% 30px').value).toBe('10px 20% 30px 20%'); 17 | }); 18 | 19 | it('THEN input `10px 20% 30px 40%` should result to `10px 20% 30px 40%`', () => { 20 | expect(new RootMargin('10px 20% 30px 40%').value).toBe('10px 20% 30px 40%'); 21 | }); 22 | }); 23 | 24 | describe('WHEN created with nullable value', () => { 25 | it('THEN value should return default value', () => { 26 | [null, undefined].forEach((val) => { 27 | expect(new RootMargin(val).value).toBe('0px 0px 0px 0px'); 28 | }); 29 | }); 30 | }); 31 | 32 | describe('WHEN created with invalid value', () => { 33 | it('THEN exception should be thrown', () => { 34 | ['1px, 2px, 3px, 4px', '1rem 2rem 3rem 4rem'].forEach((val) => { 35 | expect(() => new RootMargin(val)).toThrow(InvalidRootMarginException); 36 | }); 37 | }); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /projects/ng-in-viewport/src/lib/values/root-margin.ts: -------------------------------------------------------------------------------- 1 | import { isString } from 'lodash-es'; 2 | 3 | import { InvalidRootMarginException } from '../exceptions'; 4 | 5 | export class RootMargin { 6 | readonly #value: Values; 7 | 8 | public get value(): string { 9 | return this.#value; 10 | } 11 | 12 | constructor(value: string | null | undefined) { 13 | this.#value = RootMargin.parse(value); 14 | } 15 | 16 | private static parse(value: unknown): Values { 17 | const strValue = isString(value) ? value.trim() : '0px'; 18 | const values = strValue.split(/\s+/); 19 | 20 | if (values.length <= 4 && values.every((val) => /^-?\d*\.?\d+(px|%)$/.test(val))) { 21 | const [top, right = top!, bottom = top!, left = right!] = values as Value[]; 22 | 23 | return `${top} ${right} ${bottom} ${left}`; 24 | } 25 | 26 | throw new InvalidRootMarginException(); 27 | } 28 | } 29 | 30 | type Unit = 'px' | '%'; 31 | 32 | type Positive = `${number}` | `${number}.${number}`; 33 | 34 | type Negative = `-${Positive}`; 35 | 36 | type Value = `${Positive | Negative}${Unit}`; 37 | 38 | type Values = `${Value}` | `${Value} ${Value}` | `${Value} ${Value} ${Value}` | `${Value} ${Value} ${Value} ${Value}`; 39 | -------------------------------------------------------------------------------- /projects/ng-in-viewport/src/lib/values/root-node.spec.ts: -------------------------------------------------------------------------------- 1 | import { InvalidRootNodeException } from '../exceptions'; 2 | 3 | import { RootNode } from './root-node'; 4 | 5 | describe('GIVEN RootNode', () => { 6 | describe('WHEN created with DOM element node', () => { 7 | it('THEN value should match provided DOM element node', () => { 8 | const node = Object.assign(document.createElement('div'), { 9 | classname: `t-${Date.now()}`, 10 | }); 11 | 12 | expect(new RootNode(node).value).toBe(node); 13 | }); 14 | }); 15 | 16 | describe('WHEN created with nullable value', () => { 17 | it('THEN value should return null', () => { 18 | expect(new RootNode(null).value).toBe(null); 19 | expect(new RootNode(undefined).value).toBe(null); 20 | }); 21 | }); 22 | 23 | describe('WHEN created with invalid value', () => { 24 | it('THEN exception should be thrown', () => { 25 | const node = document.createTextNode('lorem ipsum'); 26 | 27 | // @ts-expect-error unit testing 28 | expect(() => new RootNode(node)).toThrow(InvalidRootNodeException); 29 | // @ts-expect-error unit testing 30 | expect(() => new RootNode('xyz')).toThrow(InvalidRootNodeException); 31 | }); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /projects/ng-in-viewport/src/lib/values/root-node.ts: -------------------------------------------------------------------------------- 1 | import { isNil } from 'lodash-es'; 2 | 3 | import { InvalidRootNodeException } from '../exceptions'; 4 | 5 | export class RootNode { 6 | readonly #value: Element | null; 7 | 8 | public get value(): Element | null { 9 | return this.#value; 10 | } 11 | 12 | constructor(node: Element | Document | null | undefined) { 13 | this.#value = RootNode.validate(node); 14 | } 15 | 16 | private static validate(node: unknown): Element | null { 17 | if (isNil(node)) { 18 | return null; 19 | } 20 | 21 | if (node instanceof Element && node.nodeType === Node.ELEMENT_NODE) { 22 | return node; 23 | } 24 | 25 | throw new InvalidRootNodeException(); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /projects/ng-in-viewport/src/lib/values/threshold.spec.ts: -------------------------------------------------------------------------------- 1 | import { InvalidThresholdException } from '../exceptions'; 2 | 3 | import { Threshold } from './threshold'; 4 | 5 | describe('GIVEN Threshold', () => { 6 | describe('WHEN created with number', () => { 7 | it('THEN value should return provided number wrapped in an array', () => { 8 | expect(new Threshold(0.5).value).toStrictEqual([0.5]); 9 | }); 10 | }); 11 | 12 | describe('WHEN created with array of numbers', () => { 13 | it('THEN value should return provided number wrapped in an array', () => { 14 | expect(new Threshold([0.25, 0.3]).value).toStrictEqual([0.25, 0.3]); 15 | }); 16 | }); 17 | 18 | describe('WHEN created with nullable value', () => { 19 | it('THEN value should return default value', () => { 20 | [null, undefined].forEach((val) => { 21 | expect(new Threshold(val).value).toStrictEqual([0, 1]); 22 | }); 23 | }); 24 | }); 25 | 26 | describe('WHEN created with invalid value', () => { 27 | it('THEN exception should be thrown', () => { 28 | // @ts-expect-error unit testing 29 | expect(() => new Threshold('1')).toThrow(InvalidThresholdException); 30 | 31 | // @ts-expect-error unit testing 32 | expect(() => new Threshold(['1', '2'])).toThrow(InvalidThresholdException); 33 | }); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /projects/ng-in-viewport/src/lib/values/threshold.ts: -------------------------------------------------------------------------------- 1 | import { isNumber } from 'lodash-es'; 2 | 3 | import { InvalidThresholdException } from '../exceptions'; 4 | 5 | export class Threshold { 6 | readonly #value: readonly number[]; 7 | 8 | public get value(): readonly number[] { 9 | return this.#value; 10 | } 11 | 12 | constructor(value: number | number[] | null | undefined) { 13 | this.#value = Threshold.validate(value ?? [0, 1]); 14 | } 15 | 16 | private static validate(value: unknown): number[] { 17 | if (Threshold.isValid(value)) { 18 | return [value]; 19 | } 20 | 21 | if (Array.isArray(value) && value.every((val) => Threshold.isValid(val))) { 22 | return value.sort(); 23 | } 24 | 25 | throw new InvalidThresholdException(); 26 | } 27 | 28 | private static isValid(value: unknown): value is number { 29 | return isNumber(value) && value >= 0 && value <= 1; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /projects/ng-in-viewport/src/public-api.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Public API Surface of ng-in-viewport 3 | */ 4 | 5 | export { 6 | DestroyableDirective, 7 | InViewportDirective, 8 | InViewportAction, 9 | InViewportOptions, 10 | InViewportMetadata, 11 | } from './lib/directives'; 12 | export * from './lib/enums'; 13 | export * from './lib/exceptions'; 14 | export { InViewportService } from './lib/services'; 15 | export { Config, InViewportCheckFn, InViewportCheckFnOptions } from './lib/values'; 16 | export { InViewportModule } from './lib/in-viewport.module'; 17 | -------------------------------------------------------------------------------- /projects/ng-in-viewport/tsconfig.lib.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../out-tsc/lib", 5 | "declaration": true, 6 | "declarationMap": true, 7 | "inlineSources": true, 8 | "types": [] 9 | }, 10 | "exclude": ["**/*.spec.ts"] 11 | } 12 | -------------------------------------------------------------------------------- /projects/ng-in-viewport/tsconfig.lib.prod.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.lib.json", 3 | "compilerOptions": { 4 | "declarationMap": false 5 | }, 6 | "angularCompilerOptions": { 7 | "compilationMode": "partial" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /projects/ng-in-viewport/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../out-tsc/spec", 5 | "module": "CommonJS", 6 | "target": "ES2016", 7 | "types": ["jest"] 8 | }, 9 | "include": ["src/**/*.spec.ts", "src/**/*.d.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": false, 3 | "compilerOptions": { 4 | "baseUrl": "./", 5 | "outDir": "./dist/out-tsc", 6 | "forceConsistentCasingInFileNames": true, 7 | "strict": true, 8 | "noImplicitOverride": true, 9 | "noPropertyAccessFromIndexSignature": true, 10 | "noImplicitReturns": true, 11 | "noFallthroughCasesInSwitch": true, 12 | "skipLibCheck": true, 13 | "esModuleInterop": true, 14 | "sourceMap": true, 15 | "declaration": false, 16 | "downlevelIteration": true, 17 | "experimentalDecorators": true, 18 | "moduleResolution": "node", 19 | "importHelpers": true, 20 | "target": "ES2022", 21 | "module": "ES2022", 22 | "useDefineForClassFields": false, 23 | "lib": ["ESNext", "DOM"], 24 | "paths": { 25 | "ng-in-viewport": ["projects/ng-in-viewport/src/public-api"] 26 | } 27 | }, 28 | "angularCompilerOptions": { 29 | "enableI18nLegacyMessageIdFormat": false, 30 | "strictInjectionParameters": true, 31 | "strictInputAccessModifiers": true, 32 | "strictTemplates": true 33 | } 34 | } 35 | --------------------------------------------------------------------------------