├── .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 | [](https://npm-stat.com/charts.html?package=compute-scroll-into-view)
2 | [](https://www.npmjs.com/package/compute-scroll-into-view)
3 | [![gzip size][gzip-badge]][unpkg-dist]
4 | [![size][size-badge]][unpkg-dist]
5 | [](https://github.com/semantic-release/semantic-release)
6 |
7 | 
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 |
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 |
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 |
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 |
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 |
43 |
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 |
--------------------------------------------------------------------------------