├── .github └── workflows │ ├── ci.yml │ └── prettier.yml ├── .gitignore ├── .releaserc.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── integration ├── __snapshots__ │ ├── body.test.js.snap │ ├── deeply_nested_overflow.test.js.snap │ ├── iframe.test.js.snap │ ├── nested_overflow.test.js.snap │ ├── overflow_auto.test.js.snap │ ├── target_same_height.test.js.snap │ ├── viewport-100-percent.test.js.snap │ └── viewport.test.js.snap ├── body.html ├── body.test.js ├── deeply_nested_overflow.html ├── deeply_nested_overflow.test.js ├── iframe.html ├── iframe.test.js ├── jest.config.cjs ├── nested_overflow.html ├── nested_overflow.test.js ├── overflow_auto.html ├── overflow_auto.test.js ├── target_same_height.html ├── target_same_height.test.js ├── utils.js ├── viewport-100-percent.html ├── viewport-100-percent.test.js ├── viewport.html └── viewport.test.js ├── jest-puppeteer.config.cjs ├── package-lock.json ├── package.config.ts ├── package.json ├── renovate.json ├── src └── index.ts └── tsconfig.json /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: CI & Release 3 | 4 | # Workflow name based on selected inputs. Fallback to default Github naming when expression evaluates to empty string 5 | run-name: >- 6 | ${{ 7 | inputs.release && inputs.test && 'Build ➤ Test ➤ Publish to NPM' || 8 | inputs.release && !inputs.test && 'Build ➤ Skip Tests ➤ Publish to NPM' || 9 | github.event_name == 'workflow_dispatch' && inputs.test && 'Build ➤ Test' || 10 | github.event_name == 'workflow_dispatch' && !inputs.test && 'Build ➤ Skip Tests' || 11 | '' 12 | }} 13 | 14 | on: 15 | pull_request: 16 | push: 17 | branches: [main] 18 | workflow_dispatch: 19 | inputs: 20 | test: 21 | description: 'Run tests' 22 | required: true 23 | default: true 24 | type: boolean 25 | release: 26 | description: 'Publish new release' 27 | required: true 28 | default: false 29 | type: boolean 30 | 31 | concurrency: 32 | group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} 33 | cancel-in-progress: true 34 | 35 | jobs: 36 | build: 37 | runs-on: ubuntu-latest 38 | steps: 39 | - uses: actions/checkout@v4 40 | - uses: actions/setup-node@v3 41 | with: 42 | cache: npm 43 | node-version: lts/* 44 | - run: npm ci 45 | - run: npm run typecheck 46 | if: github.event.inputs.test != 'false' 47 | - run: npm run prepublishOnly 48 | 49 | test: 50 | needs: build 51 | if: github.event.inputs.test != 'false' 52 | runs-on: ${{ matrix.os }} 53 | strategy: 54 | fail-fast: false 55 | matrix: 56 | os: [macos-latest, ubuntu-latest, windows-latest] 57 | node: [lts/*] 58 | include: 59 | - os: ubuntu-latest 60 | node: lts/-2 61 | - os: ubuntu-latest 62 | node: current 63 | steps: 64 | - name: Set git to use LF 65 | if: matrix.os == 'windows-latest' 66 | run: | 67 | git config --global core.autocrlf false 68 | git config --global core.eol lf 69 | - uses: actions/checkout@v4 70 | - uses: actions/setup-node@v3 71 | with: 72 | cache: npm 73 | node-version: ${{ matrix.node }} 74 | - run: npm i 75 | - run: npm run build 76 | - run: npm test 77 | 78 | release: 79 | needs: [build, test] 80 | # only run if opt-in during workflow_dispatch 81 | if: always() && github.event.inputs.release == 'true' && needs.build.result != 'failure' && needs.test.result != 'failure' && needs.test.result != 'cancelled' 82 | runs-on: ubuntu-latest 83 | steps: 84 | - uses: actions/checkout@v4 85 | with: 86 | # Need to fetch entire commit history to 87 | # analyze every commit since last release 88 | fetch-depth: 0 89 | - uses: actions/setup-node@v3 90 | with: 91 | cache: npm 92 | node-version: lts/* 93 | - run: npm ci 94 | # Branches that will release new versions are defined in .releaserc.json 95 | - run: npx semantic-release 96 | # Don't allow interrupting the release step if the job is cancelled, as it can lead to an inconsistent state 97 | # e.g. git tags were pushed but it exited before `npm publish` 98 | if: always() 99 | env: 100 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 101 | NPM_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }} 102 | # Re-run semantic release with rich logs if it failed to publish for easier debugging 103 | - run: npx semantic-release --dry-run --debug 104 | if: failure() 105 | env: 106 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 107 | NPM_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }} 108 | -------------------------------------------------------------------------------- /.github/workflows/prettier.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Prettier 3 | 4 | on: 5 | push: 6 | branches: [main] 7 | workflow_dispatch: 8 | 9 | concurrency: 10 | group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} 11 | cancel-in-progress: true 12 | 13 | jobs: 14 | run: 15 | name: 🤔 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v4 19 | - uses: actions/setup-node@v3 20 | with: 21 | cache: npm 22 | node-version: lts/* 23 | - run: npm ci --ignore-scripts --only-dev 24 | - uses: actions/cache@v3 25 | with: 26 | path: node_modules/.cache/prettier/.prettier-cache 27 | key: prettier-${{ hashFiles('package-lock.json') }}-${{ hashFiles('.gitignore') }} 28 | - name: check if workflows needs prettier 29 | run: npx prettier --cache --check ".github/workflows/**/*.yml" || (echo "An action can't make changes to actions, you'll have to run prettier manually" && exit 1) 30 | - run: npx prettier --ignore-path .gitignore --cache --write . 31 | - uses: EndBug/add-and-commit@61a88be553afe4206585b31aa72387c64295d08b # v9 32 | with: 33 | default_author: github_actions 34 | commit: --no-verify 35 | message: 'chore(prettier): 🤖 ✨' 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # next.js build output 61 | .next 62 | 63 | lerna-debug.log 64 | dist 65 | reports 66 | /index.js 67 | junit.xml -------------------------------------------------------------------------------- /.releaserc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@sanity/semantic-release-preset", 3 | "branches": ["main"] 4 | } 5 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # 📓 Changelog 4 | 5 | All notable changes to this project will be documented in this file. See 6 | [Conventional Commits](https://conventionalcommits.org) for commit guidelines. 7 | 8 | ## [3.1.1](https://github.com/scroll-into-view/compute-scroll-into-view/compare/v3.1.0...v3.1.1) (2025-01-10) 9 | 10 | ### Bug Fixes 11 | 12 | - in if-needed mode, skip bounds checking for non-scrollable scrollingElement ([#915](https://github.com/scroll-into-view/compute-scroll-into-view/issues/915)) ([77ae1e8](https://github.com/scroll-into-view/compute-scroll-into-view/commit/77ae1e878fc897f26f1cef8171ebc9912ba2b187)) 13 | 14 | ## [3.1.0](https://github.com/scroll-into-view/compute-scroll-into-view/compare/v3.0.3...v3.1.0) (2023-09-30) 15 | 16 | ### Features 17 | 18 | - account for scroll margin ([#906](https://github.com/scroll-into-view/compute-scroll-into-view/issues/906)) ([b932154](https://github.com/scroll-into-view/compute-scroll-into-view/commit/b932154473451e92dc71112d2df3e1d674892283)) 19 | 20 | ### Bug Fixes 21 | 22 | - scrolling zero-dimension elements ([#901](https://github.com/scroll-into-view/compute-scroll-into-view/issues/901)) ([ffad204](https://github.com/scroll-into-view/compute-scroll-into-view/commit/ffad2040d7b35484e8cdc0332577977164653b62)), closes [#900](https://github.com/scroll-into-view/compute-scroll-into-view/issues/900) 23 | 24 | ## [3.0.3](https://github.com/scroll-into-view/compute-scroll-into-view/compare/v3.0.2...v3.0.3) (2023-04-10) 25 | 26 | ### Bug Fixes 27 | 28 | - use const replace let ([#889](https://github.com/scroll-into-view/compute-scroll-into-view/issues/889)) ([d6f778d](https://github.com/scroll-into-view/compute-scroll-into-view/commit/d6f778d5a91720f9cb1980b403612d744cfa24bc)) 29 | 30 | ## [3.0.2](https://github.com/scroll-into-view/compute-scroll-into-view/compare/v3.0.1...v3.0.2) (2023-04-09) 31 | 32 | ### Bug Fixes 33 | 34 | - revert package.json and utils.js [#882](https://github.com/scroll-into-view/compute-scroll-into-view/issues/882) ([#891](https://github.com/scroll-into-view/compute-scroll-into-view/issues/891)) ([1d57298](https://github.com/scroll-into-view/compute-scroll-into-view/commit/1d572980b5451eca90a4510e1864265fad65708f)) 35 | 36 | ## [3.0.1](https://github.com/scroll-into-view/compute-scroll-into-view/compare/v3.0.0...v3.0.1) (2023-04-08) 37 | 38 | ### Bug Fixes 39 | 40 | - support `—moduleResolution node16` ([#882](https://github.com/scroll-into-view/compute-scroll-into-view/issues/882)) ([d4ad5e1](https://github.com/scroll-into-view/compute-scroll-into-view/commit/d4ad5e1b53dcb0b1baa7fd6f696d467759c17cbc)) 41 | 42 | ## [3.0.0](https://github.com/scroll-into-view/compute-scroll-into-view/compare/v2.0.4...v3.0.0) (2023-02-14) 43 | 44 | ### ⚠ BREAKING CHANGES 45 | 46 | - **refactor:** use `import {compute}` instead of `import compute` 47 | 48 | ### Features 49 | 50 | - **refactor:** use named export instead of default ([8509530](https://github.com/scroll-into-view/compute-scroll-into-view/commit/850953006334264cf2ef9040bf8c4d7ae6700604)) 51 | 52 | ### Bug Fixes 53 | 54 | - drop `pageXOffset` and `pageYOffset` ([fc7bd97](https://github.com/scroll-into-view/compute-scroll-into-view/commit/fc7bd976d34f497da462d02772858eae718a75b2)) 55 | 56 | ## [2.0.4](https://github.com/scroll-into-view/compute-scroll-into-view/compare/v2.0.3...v2.0.4) (2023-01-08) 57 | 58 | ### Bug Fixes 59 | 60 | - **deps:** update dependency @sanity/pkg-utils to v2 ([#860](https://github.com/scroll-into-view/compute-scroll-into-view/issues/860)) ([7804e3d](https://github.com/scroll-into-view/compute-scroll-into-view/commit/7804e3dd13f8b82e82e560f1e28e3041bb1c5df4)) 61 | 62 | ## [2.0.3](https://github.com/scroll-into-view/compute-scroll-into-view/compare/v2.0.2...v2.0.3) (2022-12-18) 63 | 64 | ### Bug Fixes 65 | 66 | - **deps:** update devdependencies (non-major) ([#857](https://github.com/scroll-into-view/compute-scroll-into-view/issues/857)) ([d61db89](https://github.com/scroll-into-view/compute-scroll-into-view/commit/d61db8929370192d6d63d174381ae77bedec5fdd)) 67 | 68 | ## [2.0.2](https://github.com/scroll-into-view/compute-scroll-into-view/compare/v2.0.1...v2.0.2) (2022-12-01) 69 | 70 | ### Bug Fixes 71 | 72 | - improve typings, reduce bundlesize ([0246e86](https://github.com/scroll-into-view/compute-scroll-into-view/commit/0246e86d9a4a0aaf37451db197145de6d2be34a2)) 73 | 74 | ## [2.0.1](https://github.com/scroll-into-view/compute-scroll-into-view/compare/v2.0.0...v2.0.1) (2022-12-01) 75 | 76 | ### Bug Fixes 77 | 78 | - **docs:** update size badges ([8d6170d](https://github.com/scroll-into-view/compute-scroll-into-view/commit/8d6170dde25e6753e8ee611eb2a7c2eca027de43)) 79 | 80 | ## [2.0.0](https://github.com/scroll-into-view/compute-scroll-into-view/compare/v1.0.20...v2.0.0) (2022-12-01) 81 | 82 | ### ⚠ BREAKING CHANGES 83 | 84 | - drops umd builds, and ships more modern syntax with ESM as baseline 85 | 86 | ### Bug Fixes 87 | 88 | - update build tooling ([5960c1f](https://github.com/scroll-into-view/compute-scroll-into-view/commit/5960c1f4cfcddd1b1651438d73701d0a572f561c)) 89 | - use `typeof document` check for env detection ([ae9ebbd](https://github.com/scroll-into-view/compute-scroll-into-view/commit/ae9ebbddc1f4d3e815a82adbfc8e7c2f31c5f778)) 90 | 91 | ## [1.0.20](https://github.com/scroll-into-view/compute-scroll-into-view/compare/v1.0.19...v1.0.20) (2022-11-29) 92 | 93 | ### Bug Fixes 94 | 95 | - support of shadow DOM ([#829](https://github.com/scroll-into-view/compute-scroll-into-view/issues/829)) ([9b21d76](https://github.com/scroll-into-view/compute-scroll-into-view/commit/9b21d760744b5474bcb0f22f09dcb800296fbc4b)) 96 | 97 | ## [1.0.19](https://github.com/scroll-into-view/compute-scroll-into-view/compare/v1.0.18...v1.0.19) (2022-11-29) 98 | 99 | ### Bug Fixes 100 | 101 | - microbundle isn't exporting `.cjs` ([bef7bb2](https://github.com/scroll-into-view/compute-scroll-into-view/commit/bef7bb2d1c48dbf5ef2ece976acf8c33ee9d12f1)) 102 | 103 | ## [1.0.18](https://github.com/scroll-into-view/compute-scroll-into-view/compare/v1.0.17...v1.0.18) (2022-11-29) 104 | 105 | ### Bug Fixes 106 | 107 | - **#833:** ship `pkg.exports` ([5c09a37](https://github.com/scroll-into-view/compute-scroll-into-view/commit/5c09a377025860912bdca9097713d3c62d80880f)), closes [#833](https://github.com/scroll-into-view/compute-scroll-into-view/issues/833) 108 | - correct scroll distance when frame in a scaled box ([#705](https://github.com/scroll-into-view/compute-scroll-into-view/issues/705)) ([c99d96a](https://github.com/scroll-into-view/compute-scroll-into-view/commit/c99d96a061d27aaf5c90e5871a9f3e3f15cf3bd5)) 109 | - moved repo to `scroll-into-view` org ([e2de468](https://github.com/scroll-into-view/compute-scroll-into-view/commit/e2de4688f21b049c4fd75d8bf85743ed217e9f51)) 110 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Cody Olsen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![npm stat](https://img.shields.io/npm/dm/compute-scroll-into-view.svg?style=flat-square)](https://npm-stat.com/charts.html?package=compute-scroll-into-view) 2 | [![npm version](https://img.shields.io/npm/v/compute-scroll-into-view.svg?style=flat-square)](https://www.npmjs.com/package/compute-scroll-into-view) 3 | [![gzip size][gzip-badge]][unpkg-dist] 4 | [![size][size-badge]][unpkg-dist] 5 | [![semantic-release](https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg?style=flat-square)](https://github.com/semantic-release/semantic-release) 6 | 7 | ![compute-scroll-into-view](https://user-images.githubusercontent.com/81981/43024153-a2cc212c-8c6d-11e8-913b-b4d62efcf105.png) 8 | 9 | Lower level API that is used by the [ponyfill](https://ponyfill.com) [scroll-into-view-if-needed](https://github.com/scroll-into-view/scroll-into-view-if-needed) to compute where (if needed) elements should scroll based on [options defined in the spec](https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollIntoView) and the [`scrollMode: "if-needed"` draft spec proposal](https://github.com/w3c/csswg-drafts/pull/1805). 10 | Use this if you want the smallest possible bundlesize and is ok with implementing the actual scrolling yourself. 11 | 12 | Scrolling SVG elements are supported, as well as Shadow DOM elements. The [VisualViewport](https://developer.mozilla.org/en-US/docs/Web/API/VisualViewport) API is also supported, ensuring scrolling works properly on modern devices. Quirksmode is also supported as long as you polyfill [`document.scrollingElement`](https://developer.mozilla.org/en-US/docs/Web/API/document/scrollingElement). 13 | 14 | - [Install](#install) 15 | - [Usage](#usage) 16 | - [API](#api) 17 | - [compute(target, options)](#computetarget-options) 18 | - [options](#options) 19 | - [block](#block) 20 | - [inline](#inline) 21 | - [scrollMode](#scrollmode) 22 | - [boundary](#boundary) 23 | - [skipOverflowHiddenElements](#skipoverflowhiddenelements) 24 | 25 | # Install 26 | 27 | ```bash 28 | npm i compute-scroll-into-view 29 | ``` 30 | 31 | You can also use it from a CDN: 32 | 33 | ```js 34 | const { compute } = await import('https://esm.sh/compute-scroll-into-view') 35 | ``` 36 | 37 | # Usage 38 | 39 | ```js 40 | import { compute } from 'compute-scroll-into-view' 41 | 42 | const node = document.getElementById('hero') 43 | 44 | // same behavior as Element.scrollIntoView({block: "nearest", inline: "nearest"}) 45 | // see: https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollIntoView 46 | const actions = compute(node, { 47 | scrollMode: 'if-needed', 48 | block: 'nearest', 49 | inline: 'nearest', 50 | }) 51 | 52 | // same behavior as Element.scrollIntoViewIfNeeded(true) 53 | // see: https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollIntoViewIfNeeded 54 | const actions = compute(node, { 55 | scrollMode: 'if-needed', 56 | block: 'center', 57 | inline: 'center', 58 | }) 59 | 60 | // Then perform the scrolling, use scroll-into-view-if-needed if you don't want to implement this part 61 | actions.forEach(({ el, top, left }) => { 62 | el.scrollTop = top 63 | el.scrollLeft = left 64 | }) 65 | ``` 66 | 67 | # API 68 | 69 | ## compute(target, options) 70 | 71 | ## options 72 | 73 | Type: `Object` 74 | 75 | ### [block](https://scroll-into-view.dev/#scroll-alignment) 76 | 77 | Type: `'start' | 'center' | 'end' | 'nearest'`
Default: `'center'` 78 | 79 | Control the logical scroll position on the y-axis. The spec states that the `block` direction is related to the [writing-mode](https://developer.mozilla.org/en-US/docs/Web/CSS/writing-mode), but this is not implemented yet in this library. 80 | This means that `block: 'start'` aligns to the top edge and `block: 'end'` to the bottom. 81 | 82 | ### [inline](https://scroll-into-view.dev/#scroll-alignment) 83 | 84 | Type: `'start' | 'center' | 'end' | 'nearest'`
Default: `'nearest'` 85 | 86 | Like `block` this is affected by the [writing-mode](https://developer.mozilla.org/en-US/docs/Web/CSS/writing-mode). In left-to-right pages `inline: 'start'` will align to the left edge. In right-to-left it should be flipped. This will be supported in a future release. 87 | 88 | ### [scrollMode](https://scroll-into-view.dev/#scrolling-if-needed) 89 | 90 | Type: `'always' | 'if-needed'`
Default: `'always'` 91 | 92 | This is a proposed addition to the spec that you can track here: https://github.com/w3c/csswg-drafts/pull/5677 93 | 94 | This library will be updated to reflect any changes to the spec and will provide a migration path. 95 | To be backwards compatible with `Element.scrollIntoViewIfNeeded` if something is not 100% visible it will count as "needs scrolling". If you need a different visibility ratio your best option would be to implement an [Intersection Observer](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API). 96 | 97 | ### [boundary](https://scroll-into-view.dev/#limit-propagation) 98 | 99 | Type: `Element | Function` 100 | 101 | By default there is no boundary. All the parent elements of your target is checked until it reaches the viewport ([`document.scrollingElement`](https://developer.mozilla.org/en-US/docs/Web/API/document/scrollingElement)) when calculating layout and what to scroll. 102 | By passing a boundary you can short-circuit this loop depending on your needs: 103 | 104 | - Prevent the browser window from scrolling. 105 | - Scroll elements into view in a list, without scrolling container elements. 106 | 107 | You can also pass a function to do more dynamic checks to override the scroll scoping: 108 | 109 | ```js 110 | const actions = compute(target, { 111 | boundary: (parent) => { 112 | // By default `overflow: hidden` elements are allowed, only `overflow: visible | clip` is skipped as 113 | // this is required by the CSSOM spec 114 | if (getComputedStyle(parent)['overflow'] === 'hidden') { 115 | return false 116 | } 117 | 118 | return true 119 | }, 120 | }) 121 | ``` 122 | 123 | ### skipOverflowHiddenElements 124 | 125 | Type: `Boolean`
Default: `false` 126 | 127 | By default the [spec](https://drafts.csswg.org/cssom-view/#scrolling-box) states that `overflow: hidden` elements should be scrollable because it has [been used to allow programatic scrolling](https://drafts.csswg.org/css-overflow-3/#valdef-overflow-hidden). This behavior can sometimes lead to [scrolling issues](https://github.com/scroll-into-view/scroll-into-view-if-needed/pull/225#issue-186419520) when you have a node that is a child of an `overflow: hidden` node. 128 | 129 | This package follows the convention [adopted by Firefox](https://hg.mozilla.org/integration/fx-team/rev/c48c3ec05012#l7.18) of setting a boolean option to _not_ scroll all nodes with `overflow: hidden` set. 130 | 131 | [gzip-badge]: https://img.shields.io/bundlephobia/minzip/compute-scroll-into-view?label=gzip%20size&style=flat-square 132 | [size-badge]: https://img.shields.io/bundlephobia/min/compute-scroll-into-view?label=size&style=flat-square 133 | [unpkg-dist]: https://unpkg.com/compute-scroll-into-view/dist/ 134 | -------------------------------------------------------------------------------- /integration/__snapshots__/body.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`document.body !== document.scrollingElement edge cases should not attempt scrolling body 1`] = ` 4 | [ 5 | { 6 | "el": "html", 7 | "left": 0, 8 | "top": 750, 9 | }, 10 | ] 11 | `; 12 | -------------------------------------------------------------------------------- /integration/__snapshots__/deeply_nested_overflow.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`scrollMode: if-needed test 0, block start, inline start 1`] = ` 4 | [ 5 | { 6 | "el": "div.inner-scroll-container", 7 | "left": 56, 8 | "top": 56, 9 | }, 10 | { 11 | "el": "div.outer-scroll-container", 12 | "left": 300, 13 | "top": 300, 14 | }, 15 | { 16 | "el": "html", 17 | "left": 1000, 18 | "top": 1200, 19 | }, 20 | ] 21 | `; 22 | 23 | exports[`scrollMode: if-needed test 1, block start, inline center 1`] = ` 24 | [ 25 | { 26 | "el": "div.inner-scroll-container", 27 | "left": 100, 28 | "top": 56, 29 | }, 30 | { 31 | "el": "div.outer-scroll-container", 32 | "left": 200, 33 | "top": 300, 34 | }, 35 | { 36 | "el": "html", 37 | "left": 800, 38 | "top": 1200, 39 | }, 40 | ] 41 | `; 42 | 43 | exports[`scrollMode: if-needed test 2, block start, inline end 1`] = ` 44 | [ 45 | { 46 | "el": "div.inner-scroll-container", 47 | "left": 144, 48 | "top": 56, 49 | }, 50 | { 51 | "el": "div.outer-scroll-container", 52 | "left": 100, 53 | "top": 300, 54 | }, 55 | { 56 | "el": "html", 57 | "left": 600, 58 | "top": 1200, 59 | }, 60 | ] 61 | `; 62 | 63 | exports[`scrollMode: if-needed test 3, block center, inline start 1`] = ` 64 | [ 65 | { 66 | "el": "div.inner-scroll-container", 67 | "left": 56, 68 | "top": 100, 69 | }, 70 | { 71 | "el": "div.outer-scroll-container", 72 | "left": 300, 73 | "top": 200, 74 | }, 75 | { 76 | "el": "html", 77 | "left": 1000, 78 | "top": 1100, 79 | }, 80 | ] 81 | `; 82 | 83 | exports[`scrollMode: if-needed test 4, block center, inline center 1`] = ` 84 | [ 85 | { 86 | "el": "div.inner-scroll-container", 87 | "left": 100, 88 | "top": 100, 89 | }, 90 | { 91 | "el": "div.outer-scroll-container", 92 | "left": 200, 93 | "top": 200, 94 | }, 95 | { 96 | "el": "html", 97 | "left": 800, 98 | "top": 1100, 99 | }, 100 | ] 101 | `; 102 | 103 | exports[`scrollMode: if-needed test 5, block center, inline end 1`] = ` 104 | [ 105 | { 106 | "el": "div.inner-scroll-container", 107 | "left": 144, 108 | "top": 100, 109 | }, 110 | { 111 | "el": "div.outer-scroll-container", 112 | "left": 100, 113 | "top": 200, 114 | }, 115 | { 116 | "el": "html", 117 | "left": 600, 118 | "top": 1100, 119 | }, 120 | ] 121 | `; 122 | 123 | exports[`scrollMode: if-needed test 6, block end, inline start 1`] = ` 124 | [ 125 | { 126 | "el": "div.inner-scroll-container", 127 | "left": 56, 128 | "top": 144, 129 | }, 130 | { 131 | "el": "div.outer-scroll-container", 132 | "left": 300, 133 | "top": 100, 134 | }, 135 | { 136 | "el": "html", 137 | "left": 1000, 138 | "top": 1000, 139 | }, 140 | ] 141 | `; 142 | 143 | exports[`scrollMode: if-needed test 7, block end, inline center 1`] = ` 144 | [ 145 | { 146 | "el": "div.inner-scroll-container", 147 | "left": 100, 148 | "top": 144, 149 | }, 150 | { 151 | "el": "div.outer-scroll-container", 152 | "left": 200, 153 | "top": 100, 154 | }, 155 | { 156 | "el": "html", 157 | "left": 800, 158 | "top": 1000, 159 | }, 160 | ] 161 | `; 162 | 163 | exports[`scrollMode: if-needed test 8, block end, inline end 1`] = ` 164 | [ 165 | { 166 | "el": "div.inner-scroll-container", 167 | "left": 144, 168 | "top": 144, 169 | }, 170 | { 171 | "el": "div.outer-scroll-container", 172 | "left": 100, 173 | "top": 100, 174 | }, 175 | { 176 | "el": "html", 177 | "left": 600, 178 | "top": 1000, 179 | }, 180 | ] 181 | `; 182 | -------------------------------------------------------------------------------- /integration/__snapshots__/iframe.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`scrollable element is "overflow: visible" but hidden by iframe should scroll to inside iframe 1`] = ` 4 | [ 5 | { 6 | "el": "html", 7 | "left": 0, 8 | "top": 116, 9 | }, 10 | ] 11 | `; 12 | -------------------------------------------------------------------------------- /integration/__snapshots__/nested_overflow.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`scrollMode: if-needed target in view, container out of view 1`] = ` 4 | [ 5 | { 6 | "el": "div.container", 7 | "left": 250, 8 | "top": 250, 9 | }, 10 | { 11 | "el": "html", 12 | "left": 1250, 13 | "top": 1001, 14 | }, 15 | ] 16 | `; 17 | -------------------------------------------------------------------------------- /integration/__snapshots__/overflow_auto.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`scrollMode: always block: center, inline: center 1`] = ` 4 | { 5 | "left": 250, 6 | "top": 250, 7 | } 8 | `; 9 | 10 | exports[`scrollMode: always block: center, inline: end 1`] = ` 11 | { 12 | "left": 200, 13 | "top": 250, 14 | } 15 | `; 16 | 17 | exports[`scrollMode: always block: center, inline: nearest 1`] = ` 18 | { 19 | "left": 200, 20 | "top": 250, 21 | } 22 | `; 23 | 24 | exports[`scrollMode: always block: center, inline: nearest 2`] = ` 25 | { 26 | "left": 300, 27 | "top": 250, 28 | } 29 | `; 30 | 31 | exports[`scrollMode: always block: center, inline: start 1`] = ` 32 | { 33 | "left": 300, 34 | "top": 250, 35 | } 36 | `; 37 | 38 | exports[`scrollMode: always block: end, inline: center 1`] = ` 39 | { 40 | "left": 250, 41 | "top": 200, 42 | } 43 | `; 44 | 45 | exports[`scrollMode: always block: end, inline: end 1`] = ` 46 | { 47 | "left": 200, 48 | "top": 200, 49 | } 50 | `; 51 | 52 | exports[`scrollMode: always block: end, inline: nearest 1`] = ` 53 | { 54 | "left": 200, 55 | "top": 200, 56 | } 57 | `; 58 | 59 | exports[`scrollMode: always block: end, inline: nearest 2`] = ` 60 | { 61 | "left": 300, 62 | "top": 200, 63 | } 64 | `; 65 | 66 | exports[`scrollMode: always block: end, inline: start 1`] = ` 67 | { 68 | "left": 300, 69 | "top": 200, 70 | } 71 | `; 72 | 73 | exports[`scrollMode: always block: nearest, inline: center 1`] = ` 74 | { 75 | "left": 250, 76 | "top": 200, 77 | } 78 | `; 79 | 80 | exports[`scrollMode: always block: nearest, inline: center 2`] = ` 81 | { 82 | "left": 250, 83 | "top": 300, 84 | } 85 | `; 86 | 87 | exports[`scrollMode: always block: nearest, inline: end 1`] = ` 88 | { 89 | "left": 200, 90 | "top": 200, 91 | } 92 | `; 93 | 94 | exports[`scrollMode: always block: nearest, inline: end 2`] = ` 95 | { 96 | "left": 200, 97 | "top": 300, 98 | } 99 | `; 100 | 101 | exports[`scrollMode: always block: nearest, inline: nearest 1`] = ` 102 | { 103 | "left": 200, 104 | "top": 200, 105 | } 106 | `; 107 | 108 | exports[`scrollMode: always block: nearest, inline: nearest 2`] = ` 109 | { 110 | "left": 300, 111 | "top": 300, 112 | } 113 | `; 114 | 115 | exports[`scrollMode: always block: nearest, inline: start 1`] = ` 116 | { 117 | "left": 300, 118 | "top": 200, 119 | } 120 | `; 121 | 122 | exports[`scrollMode: always block: nearest, inline: start 2`] = ` 123 | { 124 | "left": 300, 125 | "top": 300, 126 | } 127 | `; 128 | 129 | exports[`scrollMode: always block: start, inline: center 1`] = ` 130 | { 131 | "left": 250, 132 | "top": 300, 133 | } 134 | `; 135 | 136 | exports[`scrollMode: always block: start, inline: end 1`] = ` 137 | { 138 | "left": 200, 139 | "top": 300, 140 | } 141 | `; 142 | 143 | exports[`scrollMode: always block: start, inline: nearest 1`] = ` 144 | { 145 | "left": 200, 146 | "top": 300, 147 | } 148 | `; 149 | 150 | exports[`scrollMode: always block: start, inline: nearest 2`] = ` 151 | { 152 | "left": 300, 153 | "top": 300, 154 | } 155 | `; 156 | 157 | exports[`scrollMode: always block: start, inline: start 1`] = ` 158 | { 159 | "left": 300, 160 | "top": 300, 161 | } 162 | `; 163 | 164 | exports[`scrollMode: if-needed horizontal completely overflowing 1`] = ` 165 | [ 166 | { 167 | "el": "div.container", 168 | "left": 200, 169 | "top": 250, 170 | }, 171 | ] 172 | `; 173 | 174 | exports[`scrollMode: if-needed horizontal partially overflowing 1`] = ` 175 | [ 176 | { 177 | "el": "div.container", 178 | "left": 200, 179 | "top": 250, 180 | }, 181 | ] 182 | `; 183 | 184 | exports[`scrollMode: if-needed vertical completely below the fold 1`] = ` 185 | [ 186 | { 187 | "el": {}, 188 | "left": 200, 189 | "top": 250, 190 | }, 191 | ] 192 | `; 193 | 194 | exports[`scrollMode: if-needed vertical partially below the fold 1`] = ` 195 | [ 196 | { 197 | "el": "div.container", 198 | "left": 200, 199 | "top": 250, 200 | }, 201 | ] 202 | `; 203 | -------------------------------------------------------------------------------- /integration/__snapshots__/target_same_height.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`the "nearest" option works with target and frame having equal dimensions block: "nearest" 1`] = ` 4 | [ 5 | { 6 | "el": "div.container", 7 | "left": 0, 8 | "top": 100, 9 | }, 10 | { 11 | "el": "html", 12 | "left": 0, 13 | "top": 0, 14 | }, 15 | ] 16 | `; 17 | -------------------------------------------------------------------------------- /integration/__snapshots__/viewport-100-percent.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`scrollMode: if-needed (outside the scrollingElement bounding box) horizontal completely in view 1`] = `[]`; 4 | 5 | exports[`scrollMode: if-needed (outside the scrollingElement bounding box) horizontal completely overflowing 1`] = ` 6 | [ 7 | { 8 | "el": "html", 9 | "left": 100, 10 | "top": 0, 11 | }, 12 | ] 13 | `; 14 | 15 | exports[`scrollMode: if-needed (outside the scrollingElement bounding box) horizontal fully negative overflowing 1`] = ` 16 | [ 17 | { 18 | "el": "html", 19 | "left": 800, 20 | "top": 0, 21 | }, 22 | ] 23 | `; 24 | 25 | exports[`scrollMode: if-needed (outside the scrollingElement bounding box) horizontal partially negative overflowing 1`] = ` 26 | [ 27 | { 28 | "el": "html", 29 | "left": 800, 30 | "top": 0, 31 | }, 32 | ] 33 | `; 34 | 35 | exports[`scrollMode: if-needed (outside the scrollingElement bounding box) horizontal partially overflowing 1`] = ` 36 | [ 37 | { 38 | "el": "html", 39 | "left": 100, 40 | "top": 0, 41 | }, 42 | ] 43 | `; 44 | 45 | exports[`scrollMode: if-needed (outside the scrollingElement bounding box) vertical completely above the fold 1`] = ` 46 | [ 47 | { 48 | "el": "html", 49 | "left": 0, 50 | "top": 350, 51 | }, 52 | ] 53 | `; 54 | 55 | exports[`scrollMode: if-needed (outside the scrollingElement bounding box) vertical completely below the fold 1`] = ` 56 | [ 57 | { 58 | "el": "html", 59 | "left": 0, 60 | "top": 350, 61 | }, 62 | ] 63 | `; 64 | 65 | exports[`scrollMode: if-needed (outside the scrollingElement bounding box) vertical completely in view 1`] = `[]`; 66 | 67 | exports[`scrollMode: if-needed (outside the scrollingElement bounding box) vertical partially above the fold 1`] = ` 68 | [ 69 | { 70 | "el": "html", 71 | "left": 0, 72 | "top": 350, 73 | }, 74 | ] 75 | `; 76 | 77 | exports[`scrollMode: if-needed (outside the scrollingElement bounding box) vertical partially below the fold 1`] = ` 78 | [ 79 | { 80 | "el": "html", 81 | "left": 0, 82 | "top": 350, 83 | }, 84 | ] 85 | `; 86 | -------------------------------------------------------------------------------- /integration/__snapshots__/viewport.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`scrollMode: always block: center, inline: center 1`] = ` 4 | { 5 | "left": 1250, 6 | "top": 950, 7 | } 8 | `; 9 | 10 | exports[`scrollMode: always block: center, inline: end 1`] = ` 11 | { 12 | "left": 900, 13 | "top": 950, 14 | } 15 | `; 16 | 17 | exports[`scrollMode: always block: center, inline: nearest 1`] = ` 18 | { 19 | "left": 900, 20 | "top": 950, 21 | } 22 | `; 23 | 24 | exports[`scrollMode: always block: center, inline: nearest 2`] = ` 25 | { 26 | "left": 1600, 27 | "top": 950, 28 | } 29 | `; 30 | 31 | exports[`scrollMode: always block: center, inline: start 1`] = ` 32 | { 33 | "left": 1600, 34 | "top": 950, 35 | } 36 | `; 37 | 38 | exports[`scrollMode: always block: end, inline: center 1`] = ` 39 | { 40 | "left": 1250, 41 | "top": 700, 42 | } 43 | `; 44 | 45 | exports[`scrollMode: always block: end, inline: end 1`] = ` 46 | { 47 | "left": 900, 48 | "top": 700, 49 | } 50 | `; 51 | 52 | exports[`scrollMode: always block: end, inline: nearest 1`] = ` 53 | { 54 | "left": 900, 55 | "top": 700, 56 | } 57 | `; 58 | 59 | exports[`scrollMode: always block: end, inline: nearest 2`] = ` 60 | { 61 | "left": 1600, 62 | "top": 700, 63 | } 64 | `; 65 | 66 | exports[`scrollMode: always block: end, inline: start 1`] = ` 67 | { 68 | "left": 1600, 69 | "top": 700, 70 | } 71 | `; 72 | 73 | exports[`scrollMode: always block: nearest, inline: center 1`] = ` 74 | { 75 | "left": 1250, 76 | "top": 700, 77 | } 78 | `; 79 | 80 | exports[`scrollMode: always block: nearest, inline: center 2`] = ` 81 | { 82 | "left": 1250, 83 | "top": 1200, 84 | } 85 | `; 86 | 87 | exports[`scrollMode: always block: nearest, inline: end 1`] = ` 88 | { 89 | "left": 900, 90 | "top": 700, 91 | } 92 | `; 93 | 94 | exports[`scrollMode: always block: nearest, inline: end 2`] = ` 95 | { 96 | "left": 900, 97 | "top": 1200, 98 | } 99 | `; 100 | 101 | exports[`scrollMode: always block: nearest, inline: nearest 1`] = ` 102 | { 103 | "left": 900, 104 | "top": 700, 105 | } 106 | `; 107 | 108 | exports[`scrollMode: always block: nearest, inline: nearest 2`] = ` 109 | { 110 | "left": 1600, 111 | "top": 1200, 112 | } 113 | `; 114 | 115 | exports[`scrollMode: always block: nearest, inline: start 1`] = ` 116 | { 117 | "left": 1600, 118 | "top": 700, 119 | } 120 | `; 121 | 122 | exports[`scrollMode: always block: nearest, inline: start 2`] = ` 123 | { 124 | "left": 1600, 125 | "top": 1200, 126 | } 127 | `; 128 | 129 | exports[`scrollMode: always block: start, inline: center 1`] = ` 130 | { 131 | "left": 1250, 132 | "top": 1200, 133 | } 134 | `; 135 | 136 | exports[`scrollMode: always block: start, inline: end 1`] = ` 137 | { 138 | "left": 900, 139 | "top": 1200, 140 | } 141 | `; 142 | 143 | exports[`scrollMode: always block: start, inline: nearest 1`] = ` 144 | { 145 | "left": 900, 146 | "top": 1200, 147 | } 148 | `; 149 | 150 | exports[`scrollMode: always block: start, inline: nearest 2`] = ` 151 | { 152 | "left": 1600, 153 | "top": 1200, 154 | } 155 | `; 156 | 157 | exports[`scrollMode: always block: start, inline: start 1`] = ` 158 | { 159 | "left": 1600, 160 | "top": 1200, 161 | } 162 | `; 163 | 164 | exports[`scrollMode: if-needed horizontal completely overflowing 1`] = ` 165 | [ 166 | { 167 | "el": "html", 168 | "left": 900, 169 | "top": 950, 170 | }, 171 | ] 172 | `; 173 | 174 | exports[`scrollMode: if-needed horizontal fully negative overflowing 1`] = ` 175 | [ 176 | { 177 | "el": "html", 178 | "left": 1600, 179 | "top": 950, 180 | }, 181 | ] 182 | `; 183 | 184 | exports[`scrollMode: if-needed horizontal partially negative overflowing 1`] = ` 185 | [ 186 | { 187 | "el": "html", 188 | "left": 1600, 189 | "top": 950, 190 | }, 191 | ] 192 | `; 193 | 194 | exports[`scrollMode: if-needed horizontal partially overflowing 1`] = ` 195 | [ 196 | { 197 | "el": "html", 198 | "left": 900, 199 | "top": 950, 200 | }, 201 | ] 202 | `; 203 | 204 | exports[`scrollMode: if-needed vertical completely above the fold 1`] = ` 205 | [ 206 | { 207 | "el": "html", 208 | "left": 1200, 209 | "top": 950, 210 | }, 211 | ] 212 | `; 213 | 214 | exports[`scrollMode: if-needed vertical completely below the fold 1`] = ` 215 | [ 216 | { 217 | "el": "html", 218 | "left": 1200, 219 | "top": 950, 220 | }, 221 | ] 222 | `; 223 | 224 | exports[`scrollMode: if-needed vertical partially above the fold 1`] = ` 225 | [ 226 | { 227 | "el": "html", 228 | "left": 1200, 229 | "top": 950, 230 | }, 231 | ] 232 | `; 233 | 234 | exports[`scrollMode: if-needed vertical partially below the fold 1`] = ` 235 | [ 236 | { 237 | "el": "html", 238 | "left": 1200, 239 | "top": 950, 240 | }, 241 | ] 242 | `; 243 | -------------------------------------------------------------------------------- /integration/body.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | 8 | 32 |
33 |
34 | -------------------------------------------------------------------------------- /integration/body.test.js: -------------------------------------------------------------------------------- 1 | beforeAll(async () => { 2 | await page.goto('http://localhost:3000/integration/body') 3 | }) 4 | 5 | describe('document.body !== document.scrollingElement edge cases', () => { 6 | test('should not attempt scrolling body', async () => { 7 | expect.assertions(3) 8 | const actual = await page.evaluate(() => { 9 | return window 10 | .computeScrollIntoView(document.querySelector('.target'), { 11 | scrollMode: 'always', 12 | }) 13 | .map(window.mapActions) 14 | }) 15 | expect(actual).toHaveLength(1) 16 | expect(actual[0]).toMatchObject({ el: 'html' }) 17 | expect(actual).toMatchSnapshot() 18 | }) 19 | }) 20 | -------------------------------------------------------------------------------- /integration/deeply_nested_overflow.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | 8 | 108 |
109 |
110 |
111 |
112 |
113 |
114 |
1
115 |
116 |
117 |
2
118 |
119 |
120 |
3
121 |
122 |
123 |
4
124 |
125 |
126 |
5
127 |
128 |
129 |
6
130 |
131 |
132 |
7
133 |
134 |
135 |
8
136 |
137 |
138 |
9
139 |
140 |
141 |
142 |
143 |
144 |
145 | -------------------------------------------------------------------------------- /integration/deeply_nested_overflow.test.js: -------------------------------------------------------------------------------- 1 | beforeAll(async () => { 2 | await page.goto('http://localhost:3000/integration/deeply_nested_overflow') 3 | }) 4 | 5 | describe('scrollMode: if-needed', () => { 6 | const instructions = [ 7 | [0, 'start', 'start'], 8 | [1, 'start', 'center'], 9 | [2, 'start', 'end'], 10 | [3, 'center', 'start'], 11 | [4, 'center', 'center'], 12 | [5, 'center', 'end'], 13 | [6, 'end', 'start'], 14 | [7, 'end', 'center'], 15 | [8, 'end', 'end'], 16 | ] 17 | instructions.forEach(([step, block, inline]) => { 18 | test(`test ${step}, block ${block}, inline ${inline}`, async () => { 19 | const actual = await page.evaluate( 20 | (i, block, inline) => { 21 | window.scrollTo(0, 0) 22 | return window 23 | .computeScrollIntoView( 24 | document.querySelectorAll('.scroll-tile')[i], 25 | { 26 | scrollMode: 'if-needed', 27 | block, 28 | inline, 29 | } 30 | ) 31 | .map(window.mapActions) 32 | }, 33 | step, 34 | block, 35 | inline 36 | ) 37 | expect(actual).toHaveLength(3) 38 | expect(actual).toMatchSnapshot() 39 | }) 40 | }) 41 | }) 42 | -------------------------------------------------------------------------------- /integration/iframe.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | 8 | 15 | -------------------------------------------------------------------------------- /integration/iframe.test.js: -------------------------------------------------------------------------------- 1 | beforeAll(async () => { 2 | await page.goto('http://localhost:3000/integration/iframe') 3 | }) 4 | 5 | describe('scrollable element is "overflow: visible" but hidden by iframe', () => { 6 | test('should scroll to inside iframe', async () => { 7 | expect.assertions(3) 8 | const actual = await page.evaluate(() => { 9 | const iframe = document.querySelector('iframe') 10 | const target = iframe.contentDocument.querySelector('.target') 11 | return window 12 | .computeScrollIntoView(target, { 13 | scrollMode: 'always', 14 | }) 15 | .map(window.mapActions) 16 | }) 17 | expect(actual).toHaveLength(1) 18 | expect(actual[0]).toMatchObject({ el: 'html' }) 19 | expect(actual).toMatchSnapshot() 20 | }) 21 | }) 22 | -------------------------------------------------------------------------------- /integration/jest.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'jest-puppeteer', 3 | testRegex: './*\\.test\\.js$', 4 | reporters: [ 5 | 'default', 6 | ['jest-junit', { output: 'reports/jest/results.xml' }], 7 | ], 8 | } 9 | -------------------------------------------------------------------------------- /integration/nested_overflow.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | 8 | 35 |
36 |
37 |
38 |
39 |
40 | -------------------------------------------------------------------------------- /integration/nested_overflow.test.js: -------------------------------------------------------------------------------- 1 | beforeAll(async () => { 2 | await page.goto('http://localhost:3000/integration/nested_overflow') 3 | }) 4 | 5 | describe('scrollMode: if-needed', () => { 6 | test('target in view, container out of view', async () => { 7 | const actual = await page.evaluate(() => { 8 | const container = document.querySelector('.container') 9 | window.scrollTo(0, 0) 10 | container.scrollTo(250, 250) 11 | return window 12 | .computeScrollIntoView(document.querySelector('.target'), { 13 | scrollMode: 'if-needed', 14 | }) 15 | .map(window.mapActions) 16 | }) 17 | expect(actual).toHaveLength(2) 18 | expect(actual).toMatchSnapshot() 19 | }) 20 | }) 21 | -------------------------------------------------------------------------------- /integration/overflow_auto.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | 8 | 32 |
33 |
34 |
35 |
36 |
37 | -------------------------------------------------------------------------------- /integration/overflow_auto.test.js: -------------------------------------------------------------------------------- 1 | beforeAll(async () => { 2 | await page.goto('http://localhost:3000/integration/overflow_auto') 3 | }) 4 | 5 | const ScrollLogicalPosition = ['start', 'center', 'end', 'nearest'] 6 | 7 | describe('scrollMode: always', () => { 8 | ScrollLogicalPosition.forEach((block) => { 9 | ScrollLogicalPosition.forEach((inline) => { 10 | test(`block: ${block}, inline: ${inline}`, async () => { 11 | const expected = await page.evaluate( 12 | (options) => { 13 | const container = document.querySelector('.container') 14 | container.scrollTo(0, 0) 15 | document.querySelector('.target').scrollIntoView(options) 16 | const { scrollLeft, scrollTop } = container 17 | return { left: scrollLeft, top: scrollTop } 18 | }, 19 | { block, inline } 20 | ) 21 | const actual = await page.evaluate( 22 | (options) => { 23 | const container = document.querySelector('.container') 24 | container.scrollTo(0, 0) 25 | const [{ left, top }] = window.computeScrollIntoView( 26 | document.querySelector('.target'), 27 | options 28 | ) 29 | return { left, top } 30 | }, 31 | { block, inline } 32 | ) 33 | expect(expected).toEqual(actual) 34 | expect(actual).toMatchSnapshot() 35 | 36 | // The 'nearest' cases can have diff behavior depending on the current scroll position 37 | if (block === 'nearest' || inline === 'nearest') { 38 | const expected = await page.evaluate( 39 | (options) => { 40 | const container = document.querySelector('.container') 41 | container.scrollTo( 42 | container.clientWidth * 3, 43 | container.clientHeight * 3 44 | ) 45 | document.querySelector('.target').scrollIntoView(options) 46 | const { scrollLeft, scrollTop } = container 47 | return { left: scrollLeft, top: scrollTop } 48 | }, 49 | { block, inline } 50 | ) 51 | const actual = await page.evaluate( 52 | (options) => { 53 | const container = document.querySelector('.container') 54 | container.scrollTo( 55 | container.clientWidth * 3, 56 | container.clientHeight * 3 57 | ) 58 | const [{ left, top }] = window.computeScrollIntoView( 59 | document.querySelector('.target'), 60 | options 61 | ) 62 | return { left, top } 63 | }, 64 | { block, inline } 65 | ) 66 | expect(expected).toEqual(actual) 67 | expect(actual).toMatchSnapshot() 68 | } 69 | }) 70 | }) 71 | }) 72 | }) 73 | 74 | describe('scrollMode: if-needed', () => { 75 | describe('vertical', () => { 76 | test('completely below the fold', async () => { 77 | const actual = await page.evaluate(() => { 78 | const container = document.querySelector('.container') 79 | container.scrollTo(100, 0) 80 | return window.computeScrollIntoView(document.querySelector('.target'), { 81 | scrollMode: 'if-needed', 82 | }) 83 | }) 84 | expect(actual).toHaveLength(1) 85 | expect(actual).toMatchSnapshot() 86 | }) 87 | 88 | test('partially below the fold', async () => { 89 | const actual = await page.evaluate(() => { 90 | const container = document.querySelector('.container') 91 | container.scrollTo(100, 50) 92 | return window 93 | .computeScrollIntoView(document.querySelector('.target'), { 94 | scrollMode: 'if-needed', 95 | }) 96 | .map(window.mapActions) 97 | }) 98 | expect(actual).toHaveLength(1) 99 | expect(actual).toMatchSnapshot() 100 | }) 101 | }) 102 | 103 | describe('horizontal', () => { 104 | test('completely overflowing', async () => { 105 | const actual = await page.evaluate(() => { 106 | const container = document.querySelector('.container') 107 | container.scrollTo(0, 100) 108 | return window 109 | .computeScrollIntoView(document.querySelector('.target'), { 110 | scrollMode: 'if-needed', 111 | }) 112 | .map(window.mapActions) 113 | }) 114 | expect(actual).toHaveLength(1) 115 | expect(actual).toMatchSnapshot() 116 | }) 117 | 118 | test('partially overflowing', async () => { 119 | const actual = await page.evaluate(() => { 120 | const container = document.querySelector('.container') 121 | container.scrollTo(50, 100) 122 | return window 123 | .computeScrollIntoView(document.querySelector('.target'), { 124 | scrollMode: 'if-needed', 125 | }) 126 | .map(window.mapActions) 127 | }) 128 | expect(actual).toHaveLength(1) 129 | expect(actual).toMatchSnapshot() 130 | }) 131 | }) 132 | }) 133 | -------------------------------------------------------------------------------- /integration/target_same_height.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | 8 | 33 |
34 |
35 |
36 |
37 |
38 | -------------------------------------------------------------------------------- /integration/target_same_height.test.js: -------------------------------------------------------------------------------- 1 | beforeAll(async () => { 2 | await page.goto('http://localhost:3000/integration/target_same_height') 3 | }) 4 | 5 | describe('the "nearest" option works with target and frame having equal dimensions', () => { 6 | test('block: "nearest"', async () => { 7 | expect.assertions(3) 8 | const actual = await page.evaluate(() => { 9 | return window 10 | .computeScrollIntoView(document.querySelector('.target'), { 11 | block: 'nearest', 12 | }) 13 | .map(window.mapActions) 14 | }) 15 | expect(actual).toHaveLength(2) 16 | expect(actual[0]).toMatchObject({ el: 'div.container', left: 0, top: 100 }) 17 | expect(actual).toMatchSnapshot() 18 | }) 19 | }) 20 | -------------------------------------------------------------------------------- /integration/utils.js: -------------------------------------------------------------------------------- 1 | import { compute } from '../dist/index.js' 2 | window.computeScrollIntoView = compute 3 | 4 | window.mapActions = (item) => ({ 5 | el: (item.el.tagName.toLowerCase() + '.' + item.el.className).replace( 6 | /\.$/, 7 | '' 8 | ), 9 | left: Math.round(item.left), 10 | top: Math.round(item.top), 11 | }) 12 | -------------------------------------------------------------------------------- /integration/viewport-100-percent.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | 8 | 38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 | -------------------------------------------------------------------------------- /integration/viewport-100-percent.test.js: -------------------------------------------------------------------------------- 1 | beforeAll(async () => { 2 | await page.goto('http://localhost:3000/integration/viewport-100-percent') 3 | }) 4 | 5 | describe('scrollMode: if-needed (outside the scrollingElement bounding box)', () => { 6 | describe('vertical', () => { 7 | test('completely below the fold', async () => { 8 | const actual = await page.evaluate(() => { 9 | window.scrollTo(0, 0) 10 | return window 11 | .computeScrollIntoView( 12 | document.querySelector('.vertical-scroll .target'), 13 | { 14 | scrollMode: 'if-needed', 15 | } 16 | ) 17 | .map(window.mapActions) 18 | }) 19 | expect(actual).toHaveLength(1) 20 | expect(actual).toMatchSnapshot() 21 | }) 22 | 23 | test('partially below the fold', async () => { 24 | const actual = await page.evaluate(() => { 25 | window.scrollTo(0, 50) 26 | return window 27 | .computeScrollIntoView( 28 | document.querySelector('.vertical-scroll .target'), 29 | { 30 | scrollMode: 'if-needed', 31 | } 32 | ) 33 | .map(window.mapActions) 34 | }) 35 | expect(actual).toHaveLength(1) 36 | expect(actual).toMatchSnapshot() 37 | }) 38 | 39 | test('completely in view', async () => { 40 | const actual = await page.evaluate(() => { 41 | window.scrollTo(0, window.innerHeight / 2) 42 | return window 43 | .computeScrollIntoView( 44 | document.querySelector('.vertical-scroll .target'), 45 | { 46 | scrollMode: 'if-needed', 47 | } 48 | ) 49 | .map(window.mapActions) 50 | }) 51 | expect(actual).toHaveLength(0) 52 | expect(actual).toMatchSnapshot() 53 | }) 54 | 55 | test('partially above the fold', async () => { 56 | const actual = await page.evaluate(() => { 57 | window.scrollTo(0, window.innerHeight + 50) 58 | return window 59 | .computeScrollIntoView( 60 | document.querySelector('.vertical-scroll .target'), 61 | { 62 | scrollMode: 'if-needed', 63 | } 64 | ) 65 | .map(window.mapActions) 66 | }) 67 | expect(actual).toHaveLength(1) 68 | expect(actual).toMatchSnapshot() 69 | }) 70 | 71 | test('completely above the fold', async () => { 72 | const actual = await page.evaluate(() => { 73 | window.scrollTo(0, window.innerHeight + 100) 74 | return window 75 | .computeScrollIntoView( 76 | document.querySelector('.vertical-scroll .target'), 77 | { 78 | scrollMode: 'if-needed', 79 | } 80 | ) 81 | .map(window.mapActions) 82 | }) 83 | expect(actual).toHaveLength(1) 84 | expect(actual).toMatchSnapshot() 85 | }) 86 | }) 87 | 88 | describe('horizontal', () => { 89 | test('completely overflowing', async () => { 90 | const actual = await page.evaluate(() => { 91 | window.scrollTo(0, 0) 92 | return window 93 | .computeScrollIntoView( 94 | document.querySelector('.horizontal-scroll .target'), 95 | { 96 | scrollMode: 'if-needed', 97 | } 98 | ) 99 | .map(window.mapActions) 100 | }) 101 | expect(actual).toHaveLength(1) 102 | expect(actual).toMatchSnapshot() 103 | }) 104 | 105 | test('partially overflowing', async () => { 106 | const actual = await page.evaluate(() => { 107 | window.scrollTo(50, 0) 108 | return window 109 | .computeScrollIntoView( 110 | document.querySelector('.horizontal-scroll .target'), 111 | { 112 | scrollMode: 'if-needed', 113 | } 114 | ) 115 | .map(window.mapActions) 116 | }) 117 | expect(actual).toHaveLength(1) 118 | expect(actual).toMatchSnapshot() 119 | }) 120 | 121 | test('completely in view', async () => { 122 | const actual = await page.evaluate(() => { 123 | window.scrollTo(window.innerWidth / 2, 0) 124 | return window 125 | .computeScrollIntoView( 126 | document.querySelector('.horizontal-scroll .target'), 127 | { 128 | scrollMode: 'if-needed', 129 | } 130 | ) 131 | .map(window.mapActions) 132 | }) 133 | expect(actual).toHaveLength(0) 134 | expect(actual).toMatchSnapshot() 135 | }) 136 | 137 | test('partially negative overflowing', async () => { 138 | const actual = await page.evaluate(() => { 139 | window.scrollTo(window.innerWidth + 50, 0) 140 | return window 141 | .computeScrollIntoView( 142 | document.querySelector('.horizontal-scroll .target'), 143 | { 144 | scrollMode: 'if-needed', 145 | } 146 | ) 147 | .map(window.mapActions) 148 | }) 149 | expect(actual).toHaveLength(1) 150 | expect(actual).toMatchSnapshot() 151 | }) 152 | 153 | test('fully negative overflowing', async () => { 154 | const actual = await page.evaluate(() => { 155 | window.scrollTo(window.innerWidth + 100, 0) 156 | return window 157 | .computeScrollIntoView( 158 | document.querySelector('.horizontal-scroll .target'), 159 | { 160 | scrollMode: 'if-needed', 161 | } 162 | ) 163 | .map(window.mapActions) 164 | }) 165 | expect(actual).toHaveLength(1) 166 | expect(actual).toMatchSnapshot() 167 | }) 168 | }) 169 | }) 170 | -------------------------------------------------------------------------------- /integration/viewport.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | 8 | 19 |
20 | -------------------------------------------------------------------------------- /integration/viewport.test.js: -------------------------------------------------------------------------------- 1 | beforeAll(async () => { 2 | await page.goto('http://localhost:3000/integration/viewport') 3 | }) 4 | 5 | const ScrollLogicalPosition = ['start', 'center', 'end', 'nearest'] 6 | 7 | describe('scrollMode: always', () => { 8 | ScrollLogicalPosition.forEach((block) => { 9 | ScrollLogicalPosition.forEach((inline) => { 10 | test(`block: ${block}, inline: ${inline}`, async () => { 11 | const expected = await page.evaluate( 12 | (options) => { 13 | window.scrollTo(0, 0) 14 | document.querySelector('.target').scrollIntoView(options) 15 | const { scrollLeft, scrollTop } = document.scrollingElement 16 | return { left: scrollLeft, top: scrollTop } 17 | }, 18 | { block, inline } 19 | ) 20 | const actual = await page.evaluate( 21 | (options) => { 22 | window.scrollTo(0, 0) 23 | const [{ left, top }] = window.computeScrollIntoView( 24 | document.querySelector('.target'), 25 | options 26 | ) 27 | return { left, top } 28 | }, 29 | { block, inline } 30 | ) 31 | expect(expected).toEqual(actual) 32 | expect(actual).toMatchSnapshot() 33 | 34 | // The 'nearest' cases can have diff behavior depending on the current scroll position 35 | if (block === 'nearest' || inline === 'nearest') { 36 | const expected = await page.evaluate( 37 | (options) => { 38 | window.scrollTo(window.innerWidth * 3, window.innerHeight * 3) 39 | document.querySelector('.target').scrollIntoView(options) 40 | const { scrollLeft, scrollTop } = document.scrollingElement 41 | return { left: scrollLeft, top: scrollTop } 42 | }, 43 | { block, inline } 44 | ) 45 | const actual = await page.evaluate( 46 | (options) => { 47 | window.scrollTo(window.innerWidth * 3, window.innerHeight * 3) 48 | const [{ left, top }] = window.computeScrollIntoView( 49 | document.querySelector('.target'), 50 | options 51 | ) 52 | return { left, top } 53 | }, 54 | { block, inline } 55 | ) 56 | expect(expected).toEqual(actual) 57 | expect(actual).toMatchSnapshot() 58 | } 59 | }) 60 | }) 61 | }) 62 | }) 63 | 64 | describe('scrollMode: if-needed', () => { 65 | describe('vertical', () => { 66 | test('completely below the fold', async () => { 67 | const actual = await page.evaluate(() => { 68 | window.scrollTo(window.innerWidth * 1.5, window.innerHeight) 69 | return window 70 | .computeScrollIntoView(document.querySelector('.target'), { 71 | scrollMode: 'if-needed', 72 | }) 73 | .map(window.mapActions) 74 | }) 75 | expect(actual).toHaveLength(1) 76 | expect(actual).toMatchSnapshot() 77 | }) 78 | 79 | test('partially below the fold', async () => { 80 | const actual = await page.evaluate(() => { 81 | window.scrollTo(window.innerWidth * 1.5, window.innerHeight + 50) 82 | return window 83 | .computeScrollIntoView(document.querySelector('.target'), { 84 | scrollMode: 'if-needed', 85 | }) 86 | .map(window.mapActions) 87 | }) 88 | expect(actual).toHaveLength(1) 89 | expect(actual).toMatchSnapshot() 90 | }) 91 | 92 | test('partially above the fold', async () => { 93 | const actual = await page.evaluate(() => { 94 | window.scrollTo(window.innerWidth * 1.5, window.innerHeight * 2 + 50) 95 | return window 96 | .computeScrollIntoView(document.querySelector('.target'), { 97 | scrollMode: 'if-needed', 98 | }) 99 | .map(window.mapActions) 100 | }) 101 | expect(actual).toHaveLength(1) 102 | expect(actual).toMatchSnapshot() 103 | }) 104 | 105 | test('completely above the fold', async () => { 106 | const actual = await page.evaluate(() => { 107 | window.scrollTo(window.innerWidth * 1.5, window.innerHeight * 2 + 400) 108 | return window 109 | .computeScrollIntoView(document.querySelector('.target'), { 110 | scrollMode: 'if-needed', 111 | }) 112 | .map(window.mapActions) 113 | }) 114 | expect(actual).toHaveLength(1) 115 | expect(actual).toMatchSnapshot() 116 | }) 117 | }) 118 | 119 | describe('horizontal', () => { 120 | test('completely overflowing', async () => { 121 | const actual = await page.evaluate(() => { 122 | window.scrollTo(window.innerWidth, window.innerHeight * 1.5) 123 | return window 124 | .computeScrollIntoView(document.querySelector('.target'), { 125 | scrollMode: 'if-needed', 126 | }) 127 | .map(window.mapActions) 128 | }) 129 | expect(actual).toHaveLength(1) 130 | expect(actual).toMatchSnapshot() 131 | }) 132 | 133 | test('partially overflowing', async () => { 134 | const actual = await page.evaluate(() => { 135 | window.scrollTo(window.innerWidth + 50, window.innerHeight * 1.5) 136 | return window 137 | .computeScrollIntoView(document.querySelector('.target'), { 138 | scrollMode: 'if-needed', 139 | }) 140 | .map(window.mapActions) 141 | }) 142 | expect(actual).toHaveLength(1) 143 | expect(actual).toMatchSnapshot() 144 | }) 145 | 146 | test('partially negative overflowing', async () => { 147 | const actual = await page.evaluate(() => { 148 | window.scrollTo(window.innerWidth * 2 + 50, window.innerHeight * 1.5) 149 | return window 150 | .computeScrollIntoView(document.querySelector('.target'), { 151 | scrollMode: 'if-needed', 152 | }) 153 | .map(window.mapActions) 154 | }) 155 | expect(actual).toHaveLength(1) 156 | expect(actual).toMatchSnapshot() 157 | }) 158 | 159 | test('fully negative overflowing', async () => { 160 | const actual = await page.evaluate(() => { 161 | window.scrollTo(window.innerWidth * 2 + 400, window.innerHeight * 1.5) 162 | return window 163 | .computeScrollIntoView(document.querySelector('.target'), { 164 | scrollMode: 'if-needed', 165 | }) 166 | .map(window.mapActions) 167 | }) 168 | expect(actual).toHaveLength(1) 169 | expect(actual).toMatchSnapshot() 170 | }) 171 | }) 172 | }) 173 | -------------------------------------------------------------------------------- /jest-puppeteer.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | server: { 3 | command: 'serve -l 3000', 4 | port: 3000, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /package.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from '@sanity/pkg-utils' 2 | 3 | export default defineConfig({ 4 | minify: true, 5 | }) 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "compute-scroll-into-view", 3 | "version": "3.1.1", 4 | "description": "The engine that powers scroll-into-view-if-needed", 5 | "keywords": [ 6 | "if-needed", 7 | "scroll", 8 | "scroll-into-view", 9 | "scroll-into-view-if-needed", 10 | "scrollIntoView", 11 | "scrollIntoViewIfNeeded", 12 | "scrollMode", 13 | "typescript" 14 | ], 15 | "homepage": "https://scroll-into-view.dev", 16 | "repository": { 17 | "type": "git", 18 | "url": "git+https://github.com/scroll-into-view/compute-scroll-into-view.git" 19 | }, 20 | "license": "MIT", 21 | "author": "Cody Olsen", 22 | "sideEffects": false, 23 | "type": "module", 24 | "exports": { 25 | ".": { 26 | "types": "./dist/index.d.ts", 27 | "source": "./src/index.ts", 28 | "require": "./dist/index.cjs", 29 | "import": "./dist/index.js", 30 | "default": "./dist/index.js" 31 | }, 32 | "./package.json": "./package.json" 33 | }, 34 | "main": "./dist/index.cjs", 35 | "module": "./dist/index.js", 36 | "source": "./src/index.ts", 37 | "typings": "./dist/index.d.ts", 38 | "files": [ 39 | "dist", 40 | "src" 41 | ], 42 | "scripts": { 43 | "prebuild": "npx rimraf 'dist'", 44 | "build": "pkg build --strict", 45 | "prepublishOnly": "npm run build", 46 | "test": "npx cross-env JEST_PUPPETEER_CONFIG='jest-puppeteer.config.cjs' jest -c integration/jest.config.cjs", 47 | "typecheck": "tsc" 48 | }, 49 | "browserslist": [ 50 | "> 0.2% and supports es6-module and supports es6-module-dynamic-import and not dead", 51 | "maintained node versions" 52 | ], 53 | "prettier": { 54 | "semi": false, 55 | "singleQuote": true 56 | }, 57 | "devDependencies": { 58 | "@sanity/pkg-utils": "^2.2.5", 59 | "@sanity/semantic-release-preset": "^4.0.0", 60 | "@types/expect-puppeteer": "^5.0.2", 61 | "@types/jest": "^29.4.0", 62 | "@types/jest-environment-puppeteer": "^5.0.3", 63 | "@types/puppeteer": "^7.0.4", 64 | "cross-env": "^7.0.3", 65 | "jest": "^29.5.0", 66 | "jest-junit": "^15.0.0", 67 | "jest-puppeteer": "^8.0.0", 68 | "prettier": "^2.8.4", 69 | "prettier-plugin-packagejson": "^2.4.3", 70 | "puppeteer": "^19.7.0", 71 | "rimraf": "^4.1.2", 72 | "serve": "^14.2.0", 73 | "typescript": "^5.0.0" 74 | }, 75 | "bundlesize": [ 76 | { 77 | "path": "./dist/index.js", 78 | "maxSize": "3 kB", 79 | "compression": "none" 80 | } 81 | ] 82 | } 83 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["github>stipsan/renovate-presets:auto"] 3 | } 4 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | // Compute what scrolling needs to be done on required scrolling boxes for target to be in view 2 | 3 | // The type names here are named after the spec to make it easier to find more information around what they mean: 4 | // To reduce churn and reduce things that need be maintained things from the official TS DOM library is used here 5 | // https://drafts.csswg.org/cssom-view/ 6 | 7 | // For a definition on what is "block flow direction" exactly, check this: https://drafts.csswg.org/css-writing-modes-4/#block-flow-direction 8 | 9 | /** 10 | * This new option is tracked in this PR, which is the most likely candidate at the time: https://github.com/w3c/csswg-drafts/pull/1805 11 | * @public 12 | */ 13 | export type ScrollMode = 'always' | 'if-needed' 14 | 15 | /** @public */ 16 | export interface Options { 17 | /** 18 | * Control the logical scroll position on the y-axis. The spec states that the `block` direction is related to the [writing-mode](https://developer.mozilla.org/en-US/docs/Web/CSS/writing-mode), but this is not implemented yet in this library. 19 | * This means that `block: 'start'` aligns to the top edge and `block: 'end'` to the bottom. 20 | * @defaultValue 'center' 21 | */ 22 | block?: ScrollLogicalPosition 23 | /** 24 | * Like `block` this is affected by the [writing-mode](https://developer.mozilla.org/en-US/docs/Web/CSS/writing-mode). In left-to-right pages `inline: 'start'` will align to the left edge. In right-to-left it should be flipped. This will be supported in a future release. 25 | * @defaultValue 'nearest' 26 | */ 27 | inline?: ScrollLogicalPosition 28 | /** 29 | * This is a proposed addition to the spec that you can track here: https://github.com/w3c/csswg-drafts/pull/5677 30 | * 31 | * This library will be updated to reflect any changes to the spec and will provide a migration path. 32 | * To be backwards compatible with `Element.scrollIntoViewIfNeeded` if something is not 100% visible it will count as "needs scrolling". If you need a different visibility ratio your best option would be to implement an [Intersection Observer](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API). 33 | * @defaultValue 'always' 34 | */ 35 | scrollMode?: ScrollMode 36 | /** 37 | * By default there is no boundary. All the parent elements of your target is checked until it reaches the viewport ([`document.scrollingElement`](https://developer.mozilla.org/en-US/docs/Web/API/document/scrollingElement)) when calculating layout and what to scroll. 38 | * By passing a boundary you can short-circuit this loop depending on your needs: 39 | * 40 | * - Prevent the browser window from scrolling. 41 | * - Scroll elements into view in a list, without scrolling container elements. 42 | * 43 | * You can also pass a function to do more dynamic checks to override the scroll scoping: 44 | * 45 | * ```js 46 | * let actions = compute(target, { 47 | * boundary: (parent) => { 48 | * // By default `overflow: hidden` elements are allowed, only `overflow: visible | clip` is skipped as 49 | * // this is required by the CSSOM spec 50 | * if (getComputedStyle(parent)['overflow'] === 'hidden') { 51 | * return false 52 | * } 53 | 54 | * return true 55 | * }, 56 | * }) 57 | * ``` 58 | * @defaultValue null 59 | */ 60 | boundary?: Element | ((parent: Element) => boolean) | null 61 | /** 62 | * New option that skips auto-scrolling all nodes with overflow: hidden set 63 | * See FF implementation: https://hg.mozilla.org/integration/fx-team/rev/c48c3ec05012#l7.18 64 | * @defaultValue false 65 | * @public 66 | */ 67 | skipOverflowHiddenElements?: boolean 68 | } 69 | 70 | /** @public */ 71 | export interface ScrollAction { 72 | el: Element 73 | top: number 74 | left: number 75 | } 76 | 77 | // @TODO better shadowdom test, 11 = document fragment 78 | const isElement = (el: any): el is Element => 79 | typeof el === 'object' && el != null && el.nodeType === 1 80 | 81 | const canOverflow = ( 82 | overflow: string | null, 83 | skipOverflowHiddenElements?: boolean 84 | ) => { 85 | if (skipOverflowHiddenElements && overflow === 'hidden') { 86 | return false 87 | } 88 | 89 | return overflow !== 'visible' && overflow !== 'clip' 90 | } 91 | 92 | const getFrameElement = (el: Element) => { 93 | if (!el.ownerDocument || !el.ownerDocument.defaultView) { 94 | return null 95 | } 96 | 97 | try { 98 | return el.ownerDocument.defaultView.frameElement 99 | } catch (e) { 100 | return null 101 | } 102 | } 103 | 104 | const isHiddenByFrame = (el: Element): boolean => { 105 | const frame = getFrameElement(el) 106 | if (!frame) { 107 | return false 108 | } 109 | 110 | return ( 111 | frame.clientHeight < el.scrollHeight || frame.clientWidth < el.scrollWidth 112 | ) 113 | } 114 | 115 | const isScrollable = (el: Element, skipOverflowHiddenElements?: boolean) => { 116 | if (el.clientHeight < el.scrollHeight || el.clientWidth < el.scrollWidth) { 117 | const style = getComputedStyle(el, null) 118 | return ( 119 | canOverflow(style.overflowY, skipOverflowHiddenElements) || 120 | canOverflow(style.overflowX, skipOverflowHiddenElements) || 121 | isHiddenByFrame(el) 122 | ) 123 | } 124 | 125 | return false 126 | } 127 | /** 128 | * Find out which edge to align against when logical scroll position is "nearest" 129 | * Interesting fact: "nearest" works similarily to "if-needed", if the element is fully visible it will not scroll it 130 | * 131 | * Legends: 132 | * ┌────────┐ ┏ ━ ━ ━ ┓ 133 | * │ target │ frame 134 | * └────────┘ ┗ ━ ━ ━ ┛ 135 | */ 136 | const alignNearest = ( 137 | scrollingEdgeStart: number, 138 | scrollingEdgeEnd: number, 139 | scrollingSize: number, 140 | scrollingBorderStart: number, 141 | scrollingBorderEnd: number, 142 | elementEdgeStart: number, 143 | elementEdgeEnd: number, 144 | elementSize: number 145 | ) => { 146 | /** 147 | * If element edge A and element edge B are both outside scrolling box edge A and scrolling box edge B 148 | * 149 | * ┌──┐ 150 | * ┏━│━━│━┓ 151 | * │ │ 152 | * ┃ │ │ ┃ do nothing 153 | * │ │ 154 | * ┗━│━━│━┛ 155 | * └──┘ 156 | * 157 | * If element edge C and element edge D are both outside scrolling box edge C and scrolling box edge D 158 | * 159 | * ┏ ━ ━ ━ ━ ┓ 160 | * ┌───────────┐ 161 | * │┃ ┃│ do nothing 162 | * └───────────┘ 163 | * ┗ ━ ━ ━ ━ ┛ 164 | */ 165 | if ( 166 | (elementEdgeStart < scrollingEdgeStart && 167 | elementEdgeEnd > scrollingEdgeEnd) || 168 | (elementEdgeStart > scrollingEdgeStart && elementEdgeEnd < scrollingEdgeEnd) 169 | ) { 170 | return 0 171 | } 172 | 173 | /** 174 | * If element edge A is outside scrolling box edge A and element height is less than scrolling box height 175 | * 176 | * ┌──┐ 177 | * ┏━│━━│━┓ ┏━┌━━┐━┓ 178 | * └──┘ │ │ 179 | * from ┃ ┃ to ┃ └──┘ ┃ 180 | * 181 | * ┗━ ━━ ━┛ ┗━ ━━ ━┛ 182 | * 183 | * If element edge B is outside scrolling box edge B and element height is greater than scrolling box height 184 | * 185 | * ┏━ ━━ ━┓ ┏━┌━━┐━┓ 186 | * │ │ 187 | * from ┃ ┌──┐ ┃ to ┃ │ │ ┃ 188 | * │ │ │ │ 189 | * ┗━│━━│━┛ ┗━│━━│━┛ 190 | * │ │ └──┘ 191 | * │ │ 192 | * └──┘ 193 | * 194 | * If element edge C is outside scrolling box edge C and element width is less than scrolling box width 195 | * 196 | * from to 197 | * ┏ ━ ━ ━ ━ ┓ ┏ ━ ━ ━ ━ ┓ 198 | * ┌───┐ ┌───┐ 199 | * │ ┃ │ ┃ ┃ │ ┃ 200 | * └───┘ └───┘ 201 | * ┗ ━ ━ ━ ━ ┛ ┗ ━ ━ ━ ━ ┛ 202 | * 203 | * If element edge D is outside scrolling box edge D and element width is greater than scrolling box width 204 | * 205 | * from to 206 | * ┏ ━ ━ ━ ━ ┓ ┏ ━ ━ ━ ━ ┓ 207 | * ┌───────────┐ ┌───────────┐ 208 | * ┃ │ ┃ │ ┃ ┃ │ 209 | * └───────────┘ └───────────┘ 210 | * ┗ ━ ━ ━ ━ ┛ ┗ ━ ━ ━ ━ ┛ 211 | */ 212 | if ( 213 | (elementEdgeStart <= scrollingEdgeStart && elementSize <= scrollingSize) || 214 | (elementEdgeEnd >= scrollingEdgeEnd && elementSize >= scrollingSize) 215 | ) { 216 | return elementEdgeStart - scrollingEdgeStart - scrollingBorderStart 217 | } 218 | 219 | /** 220 | * If element edge B is outside scrolling box edge B and element height is less than scrolling box height 221 | * 222 | * ┏━ ━━ ━┓ ┏━ ━━ ━┓ 223 | * 224 | * from ┃ ┃ to ┃ ┌──┐ ┃ 225 | * ┌──┐ │ │ 226 | * ┗━│━━│━┛ ┗━└━━┘━┛ 227 | * └──┘ 228 | * 229 | * If element edge A is outside scrolling box edge A and element height is greater than scrolling box height 230 | * 231 | * ┌──┐ 232 | * │ │ 233 | * │ │ ┌──┐ 234 | * ┏━│━━│━┓ ┏━│━━│━┓ 235 | * │ │ │ │ 236 | * from ┃ └──┘ ┃ to ┃ │ │ ┃ 237 | * │ │ 238 | * ┗━ ━━ ━┛ ┗━└━━┘━┛ 239 | * 240 | * If element edge C is outside scrolling box edge C and element width is greater than scrolling box width 241 | * 242 | * from to 243 | * ┏ ━ ━ ━ ━ ┓ ┏ ━ ━ ━ ━ ┓ 244 | * ┌───────────┐ ┌───────────┐ 245 | * │ ┃ │ ┃ │ ┃ ┃ 246 | * └───────────┘ └───────────┘ 247 | * ┗ ━ ━ ━ ━ ┛ ┗ ━ ━ ━ ━ ┛ 248 | * 249 | * If element edge D is outside scrolling box edge D and element width is less than scrolling box width 250 | * 251 | * from to 252 | * ┏ ━ ━ ━ ━ ┓ ┏ ━ ━ ━ ━ ┓ 253 | * ┌───┐ ┌───┐ 254 | * ┃ │ ┃ │ ┃ │ ┃ 255 | * └───┘ └───┘ 256 | * ┗ ━ ━ ━ ━ ┛ ┗ ━ ━ ━ ━ ┛ 257 | * 258 | */ 259 | if ( 260 | (elementEdgeEnd > scrollingEdgeEnd && elementSize < scrollingSize) || 261 | (elementEdgeStart < scrollingEdgeStart && elementSize > scrollingSize) 262 | ) { 263 | return elementEdgeEnd - scrollingEdgeEnd + scrollingBorderEnd 264 | } 265 | 266 | return 0 267 | } 268 | 269 | const getParentElement = (element: Node): Element | null => { 270 | const parent = element.parentElement 271 | if (parent == null) { 272 | return (element.getRootNode() as ShadowRoot).host || null 273 | } 274 | return parent 275 | } 276 | 277 | const getScrollMargins = (target: Element) => { 278 | const computedStyle = window.getComputedStyle(target) 279 | return { 280 | top: parseFloat(computedStyle.scrollMarginTop) || 0, 281 | right: parseFloat(computedStyle.scrollMarginRight) || 0, 282 | bottom: parseFloat(computedStyle.scrollMarginBottom) || 0, 283 | left: parseFloat(computedStyle.scrollMarginLeft) || 0, 284 | } 285 | } 286 | 287 | /** @public */ 288 | export const compute = (target: Element, options: Options): ScrollAction[] => { 289 | if (typeof document === 'undefined') { 290 | // If there's no DOM we assume it's not in a browser environment 291 | return [] 292 | } 293 | 294 | const { scrollMode, block, inline, boundary, skipOverflowHiddenElements } = 295 | options 296 | // Allow using a callback to check the boundary 297 | // The default behavior is to check if the current target matches the boundary element or not 298 | // If undefined it'll check that target is never undefined (can happen as we recurse up the tree) 299 | const checkBoundary = 300 | typeof boundary === 'function' ? boundary : (node: any) => node !== boundary 301 | 302 | if (!isElement(target)) { 303 | throw new TypeError('Invalid target') 304 | } 305 | 306 | // Used to handle the top most element that can be scrolled 307 | const scrollingElement = document.scrollingElement || document.documentElement 308 | 309 | // Collect all the scrolling boxes, as defined in the spec: https://drafts.csswg.org/cssom-view/#scrolling-box 310 | const frames: Element[] = [] 311 | let cursor: Element | null = target 312 | while (isElement(cursor) && checkBoundary(cursor)) { 313 | // Move cursor to parent 314 | cursor = getParentElement(cursor) 315 | 316 | // Stop when we reach the viewport 317 | if (cursor === scrollingElement) { 318 | frames.push(cursor) 319 | break 320 | } 321 | 322 | // Skip document.body if it's not the scrollingElement and documentElement isn't independently scrollable 323 | if ( 324 | cursor != null && 325 | cursor === document.body && 326 | isScrollable(cursor) && 327 | !isScrollable(document.documentElement) 328 | ) { 329 | continue 330 | } 331 | 332 | // Now we check if the element is scrollable, this code only runs if the loop haven't already hit the viewport or a custom boundary 333 | if (cursor != null && isScrollable(cursor, skipOverflowHiddenElements)) { 334 | frames.push(cursor) 335 | } 336 | } 337 | 338 | // Support pinch-zooming properly, making sure elements scroll into the visual viewport 339 | // Browsers that don't support visualViewport will report the layout viewport dimensions on document.documentElement.clientWidth/Height 340 | // and viewport dimensions on window.innerWidth/Height 341 | // https://www.quirksmode.org/mobile/viewports2.html 342 | // https://bokand.github.io/viewport/index.html 343 | const viewportWidth = window.visualViewport?.width ?? innerWidth 344 | const viewportHeight = window.visualViewport?.height ?? innerHeight 345 | const { scrollX, scrollY } = window 346 | 347 | const { 348 | height: targetHeight, 349 | width: targetWidth, 350 | top: targetTop, 351 | right: targetRight, 352 | bottom: targetBottom, 353 | left: targetLeft, 354 | } = target.getBoundingClientRect() 355 | const { 356 | top: marginTop, 357 | right: marginRight, 358 | bottom: marginBottom, 359 | left: marginLeft, 360 | } = getScrollMargins(target) 361 | 362 | // These values mutate as we loop through and generate scroll coordinates 363 | let targetBlock: number = 364 | block === 'start' || block === 'nearest' 365 | ? targetTop - marginTop 366 | : block === 'end' 367 | ? targetBottom + marginBottom 368 | : targetTop + targetHeight / 2 - marginTop + marginBottom // block === 'center 369 | let targetInline: number = 370 | inline === 'center' 371 | ? targetLeft + targetWidth / 2 - marginLeft + marginRight 372 | : inline === 'end' 373 | ? targetRight + marginRight 374 | : targetLeft - marginLeft // inline === 'start || inline === 'nearest 375 | 376 | // Collect new scroll positions 377 | const computations: ScrollAction[] = [] 378 | // In chrome there's no longer a difference between caching the `frames.length` to a var or not, so we don't in this case (size > speed anyways) 379 | for (let index = 0; index < frames.length; index++) { 380 | const frame = frames[index] 381 | 382 | // @TODO add a shouldScroll hook here that allows userland code to take control 383 | 384 | const { height, width, top, right, bottom, left } = 385 | frame.getBoundingClientRect() 386 | 387 | // If the element is already visible we can end it here 388 | // @TODO targetBlock and targetInline should be taken into account to be compliant with https://github.com/w3c/csswg-drafts/pull/1805/files#diff-3c17f0e43c20f8ecf89419d49e7ef5e0R1333 389 | if ( 390 | scrollMode === 'if-needed' && 391 | targetTop >= 0 && 392 | targetLeft >= 0 && 393 | targetBottom <= viewportHeight && 394 | targetRight <= viewportWidth && 395 | // scrollingElement is added to the frames array even if it's not scrollable, in which case checking its bounds is not required 396 | ((frame === scrollingElement && !isScrollable(frame)) || 397 | (targetTop >= top && 398 | targetBottom <= bottom && 399 | targetLeft >= left && 400 | targetRight <= right)) 401 | ) { 402 | // Break the loop and return the computations for things that are not fully visible 403 | return computations 404 | } 405 | 406 | const frameStyle = getComputedStyle(frame) 407 | const borderLeft = parseInt(frameStyle.borderLeftWidth as string, 10) 408 | const borderTop = parseInt(frameStyle.borderTopWidth as string, 10) 409 | const borderRight = parseInt(frameStyle.borderRightWidth as string, 10) 410 | const borderBottom = parseInt(frameStyle.borderBottomWidth as string, 10) 411 | 412 | let blockScroll: number = 0 413 | let inlineScroll: number = 0 414 | 415 | // The property existance checks for offfset[Width|Height] is because only HTMLElement objects have them, but any Element might pass by here 416 | // @TODO find out if the "as HTMLElement" overrides can be dropped 417 | const scrollbarWidth = 418 | 'offsetWidth' in frame 419 | ? (frame as HTMLElement).offsetWidth - 420 | (frame as HTMLElement).clientWidth - 421 | borderLeft - 422 | borderRight 423 | : 0 424 | const scrollbarHeight = 425 | 'offsetHeight' in frame 426 | ? (frame as HTMLElement).offsetHeight - 427 | (frame as HTMLElement).clientHeight - 428 | borderTop - 429 | borderBottom 430 | : 0 431 | 432 | const scaleX = 433 | 'offsetWidth' in frame 434 | ? (frame as HTMLElement).offsetWidth === 0 435 | ? 0 436 | : width / (frame as HTMLElement).offsetWidth 437 | : 0 438 | const scaleY = 439 | 'offsetHeight' in frame 440 | ? (frame as HTMLElement).offsetHeight === 0 441 | ? 0 442 | : height / (frame as HTMLElement).offsetHeight 443 | : 0 444 | 445 | if (scrollingElement === frame) { 446 | // Handle viewport logic (document.documentElement or document.body) 447 | 448 | if (block === 'start') { 449 | blockScroll = targetBlock 450 | } else if (block === 'end') { 451 | blockScroll = targetBlock - viewportHeight 452 | } else if (block === 'nearest') { 453 | blockScroll = alignNearest( 454 | scrollY, 455 | scrollY + viewportHeight, 456 | viewportHeight, 457 | borderTop, 458 | borderBottom, 459 | scrollY + targetBlock, 460 | scrollY + targetBlock + targetHeight, 461 | targetHeight 462 | ) 463 | } else { 464 | // block === 'center' is the default 465 | blockScroll = targetBlock - viewportHeight / 2 466 | } 467 | 468 | if (inline === 'start') { 469 | inlineScroll = targetInline 470 | } else if (inline === 'center') { 471 | inlineScroll = targetInline - viewportWidth / 2 472 | } else if (inline === 'end') { 473 | inlineScroll = targetInline - viewportWidth 474 | } else { 475 | // inline === 'nearest' is the default 476 | inlineScroll = alignNearest( 477 | scrollX, 478 | scrollX + viewportWidth, 479 | viewportWidth, 480 | borderLeft, 481 | borderRight, 482 | scrollX + targetInline, 483 | scrollX + targetInline + targetWidth, 484 | targetWidth 485 | ) 486 | } 487 | 488 | // Apply scroll position offsets and ensure they are within bounds 489 | // @TODO add more test cases to cover this 100% 490 | blockScroll = Math.max(0, blockScroll + scrollY) 491 | inlineScroll = Math.max(0, inlineScroll + scrollX) 492 | } else { 493 | // Handle each scrolling frame that might exist between the target and the viewport 494 | if (block === 'start') { 495 | blockScroll = targetBlock - top - borderTop 496 | } else if (block === 'end') { 497 | blockScroll = targetBlock - bottom + borderBottom + scrollbarHeight 498 | } else if (block === 'nearest') { 499 | blockScroll = alignNearest( 500 | top, 501 | bottom, 502 | height, 503 | borderTop, 504 | borderBottom + scrollbarHeight, 505 | targetBlock, 506 | targetBlock + targetHeight, 507 | targetHeight 508 | ) 509 | } else { 510 | // block === 'center' is the default 511 | blockScroll = targetBlock - (top + height / 2) + scrollbarHeight / 2 512 | } 513 | 514 | if (inline === 'start') { 515 | inlineScroll = targetInline - left - borderLeft 516 | } else if (inline === 'center') { 517 | inlineScroll = targetInline - (left + width / 2) + scrollbarWidth / 2 518 | } else if (inline === 'end') { 519 | inlineScroll = targetInline - right + borderRight + scrollbarWidth 520 | } else { 521 | // inline === 'nearest' is the default 522 | inlineScroll = alignNearest( 523 | left, 524 | right, 525 | width, 526 | borderLeft, 527 | borderRight + scrollbarWidth, 528 | targetInline, 529 | targetInline + targetWidth, 530 | targetWidth 531 | ) 532 | } 533 | 534 | const { scrollLeft, scrollTop } = frame 535 | // Ensure scroll coordinates are not out of bounds while applying scroll offsets 536 | blockScroll = 537 | scaleY === 0 538 | ? 0 539 | : Math.max( 540 | 0, 541 | Math.min( 542 | scrollTop + blockScroll / scaleY, 543 | frame.scrollHeight - height / scaleY + scrollbarHeight 544 | ) 545 | ) 546 | inlineScroll = 547 | scaleX === 0 548 | ? 0 549 | : Math.max( 550 | 0, 551 | Math.min( 552 | scrollLeft + inlineScroll / scaleX, 553 | frame.scrollWidth - width / scaleX + scrollbarWidth 554 | ) 555 | ) 556 | 557 | // Cache the offset so that parent frames can scroll this into view correctly 558 | targetBlock += scrollTop - blockScroll 559 | targetInline += scrollLeft - inlineScroll 560 | } 561 | 562 | computations.push({ el: frame, top: blockScroll, left: inlineScroll }) 563 | } 564 | 565 | return computations 566 | } 567 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "module": "esnext", 5 | "moduleResolution": "nodenext", 6 | "declaration": true, 7 | "rootDir": "src", 8 | "outDir": "dist", 9 | "declarationDir": "dist", 10 | "emitDeclarationOnly": true, 11 | "noImplicitReturns": true, 12 | "noFallthroughCasesInSwitch": true, 13 | "skipLibCheck": true, 14 | "noUnusedLocals": true, 15 | "noUnusedParameters": true, 16 | "esModuleInterop": true, 17 | "strict": true, 18 | "lib": ["dom", "dom.iterable", "esnext"] 19 | }, 20 | "exclude": ["node_modules", "dist", "**/*-test.ts", "package.config.ts"] 21 | } 22 | --------------------------------------------------------------------------------