44 | `);
45 | await scrollTo('#wrapper', 0, 60);
46 | await new Promise((r) => setTimeout(r, 150));
47 | await scrollTo('#wrapper', 0, 260);
48 | await new Promise((r) => setTimeout(r, 150));
49 | });
50 | });
51 |
--------------------------------------------------------------------------------
/tests/dummy/app/components/my-component.js:
--------------------------------------------------------------------------------
1 | import Component from '@glimmer/component';
2 | import { tracked } from '@glimmer/tracking';
3 | import { action } from '@ember/object';
4 | import { inject as service } from '@ember/service';
5 |
6 | export default class MyComponent extends Component {
7 | @service inViewport;
8 | @tracked viewportEntered;
9 |
10 | // can't use preset id, because some tests use more than one instance on page
11 | elementRef;
12 |
13 | @action
14 | setupViewport(elementRef) {
15 | this.elementRef = elementRef;
16 | let options = {};
17 |
18 | let {
19 | viewportSpyOverride,
20 | viewportIntersectionObserverOverride,
21 | viewportToleranceOverride,
22 | viewportRAFOverride,
23 | scrollableAreaOverride,
24 | intersectionThresholdOverride,
25 | } = this.args;
26 |
27 | if (viewportSpyOverride !== undefined) {
28 | options.viewportSpy = viewportSpyOverride;
29 | }
30 | if (viewportIntersectionObserverOverride !== undefined) {
31 | options.viewportUseIntersectionObserver =
32 | viewportIntersectionObserverOverride;
33 | }
34 | if (viewportToleranceOverride !== undefined) {
35 | options.viewportTolerance = viewportToleranceOverride;
36 | }
37 | if (viewportRAFOverride !== undefined) {
38 | options.viewportUseRAF = viewportRAFOverride;
39 | }
40 | if (scrollableAreaOverride !== undefined) {
41 | options.scrollableArea = scrollableAreaOverride;
42 | }
43 | if (intersectionThresholdOverride !== undefined) {
44 | options.intersectionThreshold = intersectionThresholdOverride;
45 | }
46 |
47 | const { onEnter, onExit } = this.inViewport.watchElement(
48 | elementRef,
49 | options
50 | );
51 | onEnter(this.didEnterViewport.bind(this));
52 | onExit(this.didExitViewport.bind(this));
53 | }
54 |
55 | didEnterViewport() {
56 | this.viewportEntered = true;
57 |
58 | if (this.args.infinityLoad) {
59 | this.args.infinityLoad();
60 | }
61 | }
62 |
63 | didExitViewport() {
64 | this.viewportEntered = false;
65 | }
66 |
67 | willDestroy() {
68 | super.willDestroy(...arguments);
69 | if (this.elementRef) this.inViewport.stopWatching(this.elementRef);
70 | }
71 | }
72 |
--------------------------------------------------------------------------------
/addon/-private/observer-admin.js:
--------------------------------------------------------------------------------
1 | import IntersectionObserverAdmin from 'intersection-observer-admin';
2 |
3 | /**
4 | * Static administrator to ensure use one IntersectionObserver per combination of root + observerOptions
5 | * Use `root` (viewport) as lookup property and weakly referenced
6 | * `root` will have many keys with each value being and object containing one IntersectionObserver instance and all the elements to observe
7 | * Provided callbacks will ensure consumer of this service is able to react to enter or exit of intersection observer
8 | * This provides important optimizations since we are not instantiating a new IntersectionObserver instance for every element and
9 | * instead reusing the instance.
10 | *
11 | * @class ObserverAdmin
12 | */
13 | export default class ObserverAdmin {
14 | /** @private **/
15 | constructor() {
16 | this.instance = new IntersectionObserverAdmin();
17 | }
18 |
19 | /**
20 | * @method add
21 | * @param HTMLElement element
22 | * @param Object observerOptions
23 | * @param Function enterCallback
24 | * @param Function exitCallback
25 | * @void
26 | */
27 | add(element, observerOptions, enterCallback, exitCallback) {
28 | if (enterCallback) {
29 | this.addEnterCallback(element, enterCallback);
30 | }
31 | if (exitCallback) {
32 | this.addExitCallback(element, exitCallback);
33 | }
34 |
35 | return this.instance.observe(element, observerOptions);
36 | }
37 |
38 | addEnterCallback(element, enterCallback) {
39 | this.instance.addEnterCallback(element, enterCallback);
40 | }
41 |
42 | addExitCallback(element, exitCallback) {
43 | this.instance.addExitCallback(element, exitCallback);
44 | }
45 |
46 | /**
47 | * This method takes a target element, observerOptions and a the scrollable area.
48 | * The latter two act as unique identifiers to figure out which intersection observer instance
49 | * needs to be used to call `unobserve`
50 | *
51 | * @method unobserve
52 | * @param HTMLElement target
53 | * @param Object observerOptions
54 | * @param String scrollableArea
55 | * @void
56 | */
57 | unobserve(...args) {
58 | this.instance.unobserve(...args);
59 | }
60 |
61 | destroy(...args) {
62 | this.instance.destroy(...args);
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # How to contribute
2 |
3 | ## Improve documentation
4 |
5 | We are always looking to improve our documentation. If at some moment you are
6 | reading the documentation and something is not clear, or you can't find what you
7 | are looking for, then please open an issue with the repository. This gives us a
8 | chance to answer your question and to improve the documentation if needed.
9 |
10 | Pull requests correcting spelling or grammar mistakes are always welcome.
11 |
12 | ## Found a bug?
13 |
14 | Please try to answer at least the following questions when reporting a bug:
15 |
16 | - Which version of the project did you use when you noticed the bug?
17 | - How do you reproduce the error condition?
18 | - What happened that you think is a bug?
19 | - What should it do instead?
20 |
21 | It would really help the maintainers if you could provide a reduced test case
22 | that reproduces the error condition.
23 |
24 | ## Have a feature request?
25 |
26 | Please provide some thoughful commentary and code samples on what this feature
27 | should do and why it should be added (your use case). The minimal questions you
28 | should answer when submitting a feature request should be:
29 |
30 | - What will it allow you to do that you can't do today?
31 | - Why do you need this feature and how will it benefit other users?
32 | - Are there any drawbacks to this feature?
33 |
34 | ## Submitting a pull-request?
35 |
36 | Here are some things that will increase the chance that your pull-request will
37 | get accepted:
38 | - Did you confirm this fix/feature is something that is needed?
39 | - Did you write tests, preferably in a test driven style?
40 | - Did you add documentation for the changes you made?
41 | - Did you follow our [styleguide](https://github.com/dockyard/styleguides)?
42 |
43 | If your pull-request addresses an issue then please add the corresponding
44 | issue's number to the description of your pull-request.
45 |
46 | # How to work with this project locally
47 |
48 | ## Installation
49 |
50 | First clone this repository:
51 |
52 | ```sh
53 | git clone https://github.com/DockYard/ember-in-viewport.git
54 | ```
55 |
56 |
57 |
58 | ## Running tests
59 |
60 |
61 |
--------------------------------------------------------------------------------
/config/ember-try.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const getChannelURL = require('ember-source-channel-url');
4 | const { embroiderSafe, embroiderOptimized } = require('@embroider/test-setup');
5 |
6 | module.exports = async function () {
7 | return {
8 | scenarios: [
9 | {
10 | name: 'ember-lts-3.16',
11 | npm: {
12 | devDependencies: {
13 | 'ember-source': '~3.16.10',
14 | },
15 | },
16 | },
17 | {
18 | name: 'ember-lts-3.20',
19 | npm: {
20 | devDependencies: {
21 | 'ember-source': '~3.20.5',
22 | },
23 | },
24 | },
25 | {
26 | name: 'ember-lts-3.24',
27 | npm: {
28 | devDependencies: {
29 | 'ember-source': '~3.24.3',
30 | },
31 | },
32 | },
33 | {
34 | name: 'ember-lts-3.28',
35 | npm: {
36 | devDependencies: {
37 | 'ember-source': '~3.28.4',
38 | },
39 | },
40 | },
41 | {
42 | name: 'ember-release',
43 | npm: {
44 | devDependencies: {
45 | 'ember-source': await getChannelURL('release'),
46 | },
47 | },
48 | },
49 | {
50 | name: 'ember-beta',
51 | npm: {
52 | devDependencies: {
53 | 'ember-source': await getChannelURL('beta'),
54 | },
55 | },
56 | },
57 | {
58 | name: 'ember-canary',
59 | npm: {
60 | devDependencies: {
61 | 'ember-source': await getChannelURL('canary'),
62 | },
63 | },
64 | },
65 | {
66 | name: 'ember-modifier@2',
67 | npm: {
68 | dependencies: {
69 | 'ember-modifier': '^2.1.2',
70 | },
71 | },
72 | },
73 | {
74 | name: 'ember-modifier@3.1',
75 | npm: {
76 | dependencies: {
77 | 'ember-modifier': '^3.1.0',
78 | },
79 | },
80 | },
81 | {
82 | name: 'ember-modifier@3.2',
83 | npm: {
84 | dependencies: {
85 | 'ember-modifier': '^3.2.7',
86 | },
87 | },
88 | },
89 | {
90 | name: 'ember-modifier@^4.0.0-beta.1',
91 | npm: {
92 | dependencies: {
93 | 'ember-modifier': '^4.0.0-beta.1',
94 | },
95 | },
96 | },
97 | {
98 | name: 'ember-classic',
99 | env: {
100 | EMBER_OPTIONAL_FEATURES: JSON.stringify({
101 | 'application-template-wrapper': true,
102 | 'default-async-observers': false,
103 | 'template-only-glimmer-components': false,
104 | }),
105 | },
106 | npm: {
107 | devDependencies: {
108 | 'ember-source': '~3.28.0',
109 | },
110 | ember: {
111 | edition: 'classic',
112 | },
113 | },
114 | },
115 | embroiderSafe(),
116 | embroiderOptimized(),
117 | ],
118 | };
119 | };
120 |
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | # Contributor Covenant Code of Conduct
2 |
3 | ## Our Pledge
4 |
5 | In the interest of fostering an open and welcoming environment, we as
6 | contributors and maintainers pledge to making participation in our project and
7 | our community a harassment-free experience for everyone, regardless of age, body
8 | size, disability, ethnicity, gender identity and expression, level of experience,
9 | nationality, personal appearance, race, religion, or sexual identity and
10 | orientation.
11 |
12 | ## Our Standards
13 |
14 | Examples of behavior that contributes to creating a positive environment
15 | include:
16 |
17 | * Using welcoming and inclusive language
18 | * Being respectful of differing viewpoints and experiences
19 | * Gracefully accepting constructive criticism
20 | * Focusing on what is best for the community
21 | * Showing empathy towards other community members
22 |
23 | Examples of unacceptable behavior by participants include:
24 |
25 | * The use of sexualized language or imagery and unwelcome sexual attention or
26 | advances
27 | * Trolling, insulting/derogatory comments, and personal or political attacks
28 | * Public or private harassment
29 | * Publishing others' private information, such as a physical or electronic
30 | address, without explicit permission
31 | * Other conduct which could reasonably be considered inappropriate in a
32 | professional setting
33 |
34 | ## Our Responsibilities
35 |
36 | Project maintainers are responsible for clarifying the standards of acceptable
37 | behavior and are expected to take appropriate and fair corrective action in
38 | response to any instances of unacceptable behavior.
39 |
40 | Project maintainers have the right and responsibility to remove, edit, or
41 | reject comments, commits, code, wiki edits, issues, and other contributions
42 | that are not aligned to this Code of Conduct, or to ban temporarily or
43 | permanently any contributor for other behaviors that they deem inappropriate,
44 | threatening, offensive, or harmful.
45 |
46 | ## Scope
47 |
48 | This Code of Conduct applies both within project spaces and in public spaces
49 | when an individual is representing the project or its community. Examples of
50 | representing a project or community include using an official project e-mail
51 | address, posting via an official social media account, or acting as an appointed
52 | representative at an online or offline event. Representation of a project may be
53 | further defined and clarified by project maintainers.
54 |
55 | ## Enforcement
56 |
57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be
58 | reported by contacting the project team at brian@dockyard.com. All
59 | complaints will be reviewed and investigated and will result in a response that
60 | is deemed necessary and appropriate to the circumstances. The project team is
61 | obligated to maintain confidentiality with regard to the reporter of an incident.
62 | Further details of specific enforcement policies may be posted separately.
63 |
64 | Project maintainers who do not follow or enforce the Code of Conduct in good
65 | faith may face temporary or permanent repercussions as determined by other
66 | members of the project's leadership.
67 |
68 | ## Attribution
69 |
70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
71 | available at [http://contributor-covenant.org/version/1/4][version]
72 |
73 | [homepage]: http://contributor-covenant.org
74 | [version]: http://contributor-covenant.org/version/1/4/
75 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "ember-in-viewport",
3 | "version": "4.1.0",
4 | "description": "Detect if an Ember View or Component is in the viewport @ 60FPS",
5 | "directories": {
6 | "doc": "doc",
7 | "test": "tests"
8 | },
9 | "scripts": {
10 | "build": "ember build --environment=production",
11 | "lint": "npm-run-all --aggregate-output --continue-on-error --parallel \"lint:!(fix)\"",
12 | "lint:hbs": "ember-template-lint .",
13 | "lint:js": "eslint .",
14 | "start": "ember serve",
15 | "test": "npm-run-all lint:* test:*",
16 | "test:ember": "ember test",
17 | "contributors": "npx contributor-faces -e \"(*-bot|*\\[bot\\]|*-tomster|homu|bors)\"",
18 | "release": "release-it"
19 | },
20 | "repository": "https://github.com/dockyard/ember-in-viewport",
21 | "engines": {
22 | "node": "12.* || 14.* || >= 16"
23 | },
24 | "author": "Scott Newcomer",
25 | "license": "MIT",
26 | "dependencies": {
27 | "@embroider/macros": "^1.8.3",
28 | "ember-auto-import": "^2.2.3",
29 | "ember-cli-babel": "^7.26.6",
30 | "ember-destroyable-polyfill": "^2.0.3",
31 | "ember-modifier": "^2.1.2 || ^3.0.0 || ^4.0.0",
32 | "fast-deep-equal": "^2.0.1",
33 | "intersection-observer-admin": "~0.3.2",
34 | "raf-pool": "~0.1.4"
35 | },
36 | "devDependencies": {
37 | "@ember/optional-features": "^2.0.0",
38 | "@ember/render-modifiers": "^2.0.0",
39 | "@ember/test-helpers": "^2.5.0",
40 | "@embroider/compat": "^1.8.3",
41 | "@embroider/core": "^1.8.3",
42 | "@embroider/test-setup": "^1.8.3",
43 | "@embroider/webpack": "^1.8.3",
44 | "@glimmer/component": "^1.0.4",
45 | "@glimmer/tracking": "^1.0.4",
46 | "babel-eslint": "^10.1.0",
47 | "broccoli-asset-rev": "^3.0.0",
48 | "ember-cli": "~3.28.2",
49 | "ember-cli-dependency-checker": "^3.2.0",
50 | "ember-cli-htmlbars": "^5.7.1",
51 | "ember-cli-inject-live-reload": "^2.1.0",
52 | "ember-cli-sri": "^2.1.1",
53 | "ember-cli-terser": "^4.0.2",
54 | "ember-decorators": "^6.0.0",
55 | "ember-decorators-polyfill": "^1.1.5",
56 | "ember-disable-prototype-extensions": "^1.1.3",
57 | "ember-export-application-global": "^2.0.1",
58 | "ember-load-initializers": "^2.1.2",
59 | "ember-maybe-import-regenerator": "^1.0.0",
60 | "ember-qunit": "^5.1.5",
61 | "ember-resolver": "^8.0.3",
62 | "ember-responsive": "^4.0.2",
63 | "ember-source": "~3.28.1",
64 | "ember-source-channel-url": "^3.0.0",
65 | "ember-template-lint": "^3.10.0",
66 | "ember-truth-helpers": "^3.0.0",
67 | "ember-try": "^1.4.0",
68 | "eslint": "^7.32.0",
69 | "eslint-config-prettier": "^8.3.0",
70 | "eslint-plugin-ember": "^10.5.7",
71 | "eslint-plugin-node": "^11.1.0",
72 | "eslint-plugin-prettier": "^4.0.0",
73 | "eslint-plugin-qunit": "^6.2.0",
74 | "lerna-changelog": "^1.0.1",
75 | "loader.js": "^4.7.0",
76 | "npm-run-all": "^4.1.5",
77 | "prettier": "^2.4.1",
78 | "qunit": "^2.17.2",
79 | "qunit-dom": "^2.0.0",
80 | "release-it": "^13.6.6",
81 | "release-it-lerna-changelog": "^2.3.0",
82 | "webpack": "^5.58.2"
83 | },
84 | "keywords": [
85 | "ember-addon",
86 | "ember",
87 | "viewport",
88 | "intersection observer",
89 | "lazy load",
90 | "scrollspy"
91 | ],
92 | "ember": {
93 | "edition": "octane"
94 | },
95 | "ember-addon": {
96 | "configPath": "tests/dummy/config"
97 | },
98 | "volta": {
99 | "node": "14.18.1"
100 | }
101 | }
102 |
--------------------------------------------------------------------------------
/tests/unit/utils/is-in-viewport-test.js:
--------------------------------------------------------------------------------
1 | import isInViewport from 'ember-in-viewport/utils/is-in-viewport';
2 | import { module, test } from 'qunit';
3 | import { setupTest } from 'ember-qunit';
4 |
5 | let fakeRectNotInViewport,
6 | fakeRectInViewport,
7 | fakeWindow,
8 | fakeNoTolerance,
9 | fakeTolerance;
10 |
11 | module('Unit | Utility | is in viewport', function (hooks) {
12 | setupTest(hooks);
13 |
14 | hooks.beforeEach(function () {
15 | fakeRectNotInViewport = {
16 | top: 450,
17 | left: 150,
18 | bottom: 550,
19 | right: 1130,
20 | height: 1,
21 | width: 1,
22 | };
23 |
24 | fakeRectInViewport = {
25 | top: 300,
26 | left: 150,
27 | bottom: 400,
28 | right: 1130,
29 | height: 1,
30 | width: 1,
31 | };
32 |
33 | fakeWindow = {
34 | innerHeight: 400,
35 | innerWidth: 1280,
36 | };
37 |
38 | fakeNoTolerance = {
39 | top: 0,
40 | left: 0,
41 | bottom: 0,
42 | right: 0,
43 | };
44 |
45 | fakeTolerance = {
46 | top: 200,
47 | bottom: 200,
48 | };
49 | });
50 |
51 | test('returns true if dimensions are within viewport', function (assert) {
52 | const { innerHeight, innerWidth } = fakeWindow;
53 | const result = isInViewport(
54 | fakeRectInViewport,
55 | innerHeight,
56 | innerWidth,
57 | fakeNoTolerance
58 | );
59 | assert.ok(result);
60 | });
61 |
62 | test('returns false if dimensions not within viewport', function (assert) {
63 | const { innerHeight, innerWidth } = fakeWindow;
64 | const result = isInViewport(
65 | fakeRectNotInViewport,
66 | innerHeight,
67 | innerWidth,
68 | fakeNoTolerance
69 | );
70 | assert.false(result);
71 | });
72 |
73 | test('returns true if dimensions not within viewport but within tolerance', function (assert) {
74 | const { innerHeight, innerWidth } = fakeWindow;
75 | const result = isInViewport(
76 | fakeRectNotInViewport,
77 | innerHeight,
78 | innerWidth,
79 | fakeTolerance
80 | );
81 | assert.ok(result);
82 | });
83 |
84 | test('returns true if rect with subpixel height is within viewport', function (assert) {
85 | const innerHeight = 400;
86 | const innerWidth = 1280;
87 | const fakeRectWithSubpixelsInViewport = {
88 | top: 300,
89 | left: 150,
90 | bottom: 400.4,
91 | right: 1130,
92 | height: 1,
93 | width: 1,
94 | };
95 | const result = isInViewport(
96 | fakeRectWithSubpixelsInViewport,
97 | innerHeight,
98 | innerWidth,
99 | fakeNoTolerance
100 | );
101 | assert.ok(result);
102 | });
103 |
104 | test('returns true if rect with subpixel width is within viewport', function (assert) {
105 | const innerHeight = 400;
106 | const innerWidth = 1280;
107 | const fakeRectWithSubpixelsInViewport = {
108 | top: 300,
109 | left: 150,
110 | bottom: 400,
111 | right: 1280.4,
112 | height: 1,
113 | width: 1,
114 | };
115 | const result = isInViewport(
116 | fakeRectWithSubpixelsInViewport,
117 | innerHeight,
118 | innerWidth,
119 | fakeNoTolerance
120 | );
121 | assert.ok(result);
122 | });
123 |
124 | test('returns false if rect with subpixel height is not within viewport', function (assert) {
125 | const innerHeight = 400;
126 | const innerWidth = 1280;
127 | const fakeRectWithSubpixelsInViewport = {
128 | top: 300,
129 | left: 150,
130 | bottom: 400.8,
131 | right: 1130,
132 | height: 0,
133 | width: 0,
134 | };
135 | const result = isInViewport(
136 | fakeRectWithSubpixelsInViewport,
137 | innerHeight,
138 | innerWidth,
139 | fakeNoTolerance
140 | );
141 | assert.notOk(result);
142 | });
143 |
144 | test('returns false if rect with subpixel width is not within viewport', function (assert) {
145 | const innerHeight = 400;
146 | const innerWidth = 1280;
147 | const fakeRectWithSubpixelsInViewport = {
148 | top: 300,
149 | left: 150,
150 | bottom: 400,
151 | right: 1280.7,
152 | height: 0,
153 | width: 0,
154 | };
155 | const result = isInViewport(
156 | fakeRectWithSubpixelsInViewport,
157 | innerHeight,
158 | innerWidth,
159 | fakeNoTolerance
160 | );
161 | assert.notOk(result);
162 | });
163 | });
164 |
--------------------------------------------------------------------------------
/addon/-private/raf-admin.js:
--------------------------------------------------------------------------------
1 | import RafPool from 'raf-pool';
2 | import isInViewport from 'ember-in-viewport/utils/is-in-viewport';
3 |
4 | /**
5 | * ensure use on requestAnimationFrame, no matter how many components
6 | * on the page are using this class
7 | *
8 | * @class RAFAdmin
9 | */
10 | export default class RAFAdmin {
11 | /** @private **/
12 | constructor() {
13 | this._rafPool = new RafPool();
14 | this.elementRegistry = new WeakMap();
15 | }
16 |
17 | add(...args) {
18 | return this._rafPool.add(...args);
19 | }
20 |
21 | flush() {
22 | return this._rafPool.flush();
23 | }
24 |
25 | remove(...args) {
26 | return this._rafPool.remove(...args);
27 | }
28 |
29 | reset(...args) {
30 | this._rafPool.reset(...args);
31 | this._rafPool.stop(...args);
32 | }
33 |
34 | /**
35 | * We provide our own element registry to add callbacks the user creates
36 | *
37 | * @method addEnterCallback
38 | * @param {HTMLElement} element
39 | * @param {Function} enterCallback
40 | */
41 | addEnterCallback(element, enterCallback) {
42 | this.elementRegistry.set(
43 | element,
44 | Object.assign({}, this.elementRegistry.get(element), { enterCallback })
45 | );
46 | }
47 |
48 | /**
49 | * We provide our own element registry to add callbacks the user creates
50 | *
51 | * @method addExitCallback
52 | * @param {HTMLElement} element
53 | * @param {Function} exitCallback
54 | */
55 | addExitCallback(element, exitCallback) {
56 | this.elementRegistry.set(
57 | element,
58 | Object.assign({}, this.elementRegistry.get(element), { exitCallback })
59 | );
60 | }
61 | }
62 |
63 | /**
64 | * This is a recursive function that adds itself to raf-pool to be executed on a set schedule
65 | *
66 | * @method startRAF
67 | * @param {HTMLElement} element
68 | * @param {Object} configurationOptions
69 | * @param {Function} enterCallback
70 | * @param {Function} exitCallback
71 | * @param {Function} addRAF
72 | * @param {Function} removeRAF
73 | */
74 | export function startRAF(
75 | element,
76 | { scrollableArea, viewportTolerance, viewportSpy = false },
77 | enterCallback,
78 | exitCallback,
79 | addRAF, // bound function from service to add elementId to raf pool
80 | removeRAF // bound function from service to remove elementId to raf pool
81 | ) {
82 | const domScrollableArea =
83 | typeof scrollableArea === 'string' && scrollableArea
84 | ? document.querySelector(scrollableArea)
85 | : scrollableArea instanceof HTMLElement
86 | ? scrollableArea
87 | : undefined;
88 |
89 | const height = domScrollableArea
90 | ? domScrollableArea.offsetHeight +
91 | domScrollableArea.getBoundingClientRect().top
92 | : window.innerHeight;
93 | const width = scrollableArea
94 | ? domScrollableArea.offsetWidth +
95 | domScrollableArea.getBoundingClientRect().left
96 | : window.innerWidth;
97 | const boundingClientRect = element.getBoundingClientRect();
98 |
99 | if (boundingClientRect) {
100 | const viewportEntered = element.getAttribute('data-in-viewport-entered');
101 |
102 | triggerDidEnterViewport(
103 | element,
104 | isInViewport(boundingClientRect, height, width, viewportTolerance),
105 | viewportSpy,
106 | enterCallback,
107 | exitCallback,
108 | viewportEntered
109 | );
110 |
111 | if (viewportSpy || viewportEntered !== 'true') {
112 | // recursive
113 | // add to pool of requestAnimationFrame listeners and executed on set schedule
114 | addRAF(
115 | startRAF.bind(
116 | this,
117 | element,
118 | { scrollableArea, viewportTolerance, viewportSpy },
119 | enterCallback,
120 | exitCallback,
121 | addRAF,
122 | removeRAF
123 | )
124 | );
125 | } else {
126 | removeRAF();
127 | }
128 | }
129 | }
130 |
131 | function triggerDidEnterViewport(
132 | element,
133 | hasEnteredViewport,
134 | viewportSpy,
135 | enterCallback,
136 | exitCallback,
137 | viewportEntered = false
138 | ) {
139 | const didEnter =
140 | (!viewportEntered || viewportEntered === 'false') && hasEnteredViewport;
141 | const didLeave = viewportEntered === 'true' && !hasEnteredViewport;
142 |
143 | if (didEnter) {
144 | element.setAttribute('data-in-viewport-entered', true);
145 | enterCallback();
146 | }
147 |
148 | if (didLeave) {
149 | exitCallback();
150 |
151 | // reset so we can call again
152 | if (viewportSpy) {
153 | element.setAttribute('data-in-viewport-entered', false);
154 | }
155 | }
156 | }
157 |
--------------------------------------------------------------------------------
/addon/modifiers/in-viewport.js:
--------------------------------------------------------------------------------
1 | import { assert } from '@ember/debug';
2 | import { action } from '@ember/object';
3 | import { inject as service } from '@ember/service';
4 | import { DEBUG } from '@glimmer/env';
5 | import Modifier from 'ember-modifier';
6 | import deepEqual from 'fast-deep-equal';
7 | import { registerDestructor } from '@ember/destroyable';
8 | import { macroCondition, dependencySatisfies } from '@embroider/macros';
9 |
10 | const WATCHED_ELEMENTS = DEBUG ? new WeakSet() : undefined;
11 |
12 | let modifier;
13 |
14 | if (macroCondition(dependencySatisfies('ember-modifier', '>=3.2.0 || 4.x'))) {
15 | modifier = class InViewportModifier extends Modifier {
16 | @service inViewport;
17 |
18 | name = 'in-viewport';
19 |
20 | lastOptions;
21 | element = null;
22 |
23 | modify(element, positional, named) {
24 | this.element = element;
25 | this.positional = positional;
26 | this.named = named;
27 | this.validateArguments();
28 |
29 | if (!this.didSetup) {
30 | this.setupWatcher(element);
31 | registerDestructor(() => this.destroyWatcher(element));
32 | } else if (this.hasStaleOptions) {
33 | this.destroyWatcher(element);
34 | this.setupWatcher(element);
35 | }
36 | }
37 |
38 | get options() {
39 | // eslint-disable-next-line no-unused-vars
40 | const { onEnter, onExit, ...options } = this.named;
41 | return options;
42 | }
43 |
44 | get hasStaleOptions() {
45 | return !deepEqual(this.options, this.lastOptions);
46 | }
47 |
48 | validateArguments() {
49 | assert(
50 | `'{{in-viewport}}' does not accept positional parameters. Specify listeners via 'onEnter' / 'onExit'.`,
51 | this.positional.length === 0
52 | );
53 | assert(
54 | `'{{in-viewport}}' either expects 'onEnter', 'onExit' or both to be present.`,
55 | typeof this.named.onEnter === 'function' ||
56 | typeof this.named.onExit === 'function'
57 | );
58 | }
59 |
60 | @action
61 | onEnter(...args) {
62 | if (this.named.onEnter) {
63 | this.named.onEnter.call(null, this.element, ...args);
64 | }
65 |
66 | if (!this.options.viewportSpy) {
67 | this.inViewport.stopWatching(this.element);
68 | }
69 | }
70 |
71 | @action
72 | onExit(...args) {
73 | if (this.named.onExit) {
74 | this.named.onExit.call(null, this.element, ...args);
75 | }
76 | }
77 |
78 | setupWatcher(element) {
79 | assert(
80 | `'${element}' is already being watched. Make sure that '{{in-viewport}}' is only used once on this element and that you are not calling 'inViewport.watchElement(element)' in other places.`,
81 | !WATCHED_ELEMENTS.has(element)
82 | );
83 | if (DEBUG) WATCHED_ELEMENTS.add(element);
84 | this.inViewport.watchElement(
85 | element,
86 | this.options,
87 | this.onEnter,
88 | this.onExit
89 | );
90 | this.lastOptions = this.options;
91 | }
92 |
93 | destroyWatcher(element) {
94 | if (DEBUG) WATCHED_ELEMENTS.delete(element);
95 | this.inViewport.stopWatching(element);
96 | }
97 | };
98 | } else {
99 | modifier = class InViewportModifier extends Modifier {
100 | @service inViewport;
101 |
102 | name = 'in-viewport';
103 |
104 | lastOptions;
105 |
106 | get options() {
107 | // eslint-disable-next-line no-unused-vars
108 | const { onEnter, onExit, ...options } = this.args.named;
109 | return options;
110 | }
111 |
112 | get hasStaleOptions() {
113 | return !deepEqual(this.options, this.lastOptions);
114 | }
115 |
116 | validateArguments() {
117 | assert(
118 | `'{{in-viewport}}' does not accept positional parameters. Specify listeners via 'onEnter' / 'onExit'.`,
119 | this.args.positional.length === 0
120 | );
121 | assert(
122 | `'{{in-viewport}}' either expects 'onEnter', 'onExit' or both to be present.`,
123 | typeof this.args.named.onEnter === 'function' ||
124 | typeof this.args.named.onExit === 'function'
125 | );
126 | }
127 |
128 | @action
129 | onEnter(...args) {
130 | if (this.args.named.onEnter) {
131 | this.args.named.onEnter.call(null, this.element, ...args);
132 | }
133 |
134 | if (!this.options.viewportSpy) {
135 | this.inViewport.stopWatching(this.element);
136 | }
137 | }
138 |
139 | @action
140 | onExit(...args) {
141 | if (this.args.named.onExit) {
142 | this.args.named.onExit.call(null, this.element, ...args);
143 | }
144 | }
145 |
146 | setupWatcher() {
147 | assert(
148 | `'${this.element}' is already being watched. Make sure that '{{in-viewport}}' is only used once on this element and that you are not calling 'inViewport.watchElement(element)' in other places.`,
149 | !WATCHED_ELEMENTS.has(this.element)
150 | );
151 | if (DEBUG) WATCHED_ELEMENTS.add(this.element);
152 | this.inViewport.watchElement(
153 | this.element,
154 | this.options,
155 | this.onEnter,
156 | this.onExit
157 | );
158 | this.lastOptions = this.options;
159 | }
160 |
161 | destroyWatcher() {
162 | if (DEBUG) WATCHED_ELEMENTS.delete(this.element);
163 | this.inViewport.stopWatching(this.element);
164 | }
165 |
166 | didInstall() {
167 | this.setupWatcher();
168 | }
169 |
170 | didUpdateArguments() {
171 | if (this.hasStaleOptions) {
172 | this.destroyWatcher();
173 | this.setupWatcher();
174 | }
175 | }
176 |
177 | didReceiveArguments() {
178 | this.validateArguments();
179 | }
180 |
181 | willRemove() {
182 | this.destroyWatcher();
183 | }
184 | };
185 | }
186 |
187 | export default modifier;
188 |
--------------------------------------------------------------------------------
/addon/services/in-viewport.js:
--------------------------------------------------------------------------------
1 | import Service from '@ember/service';
2 | import { set, setProperties } from '@ember/object';
3 | import { getOwner } from '@ember/application';
4 | import { warn } from '@ember/debug';
5 | import { schedule } from '@ember/runloop';
6 | import isInViewport from 'ember-in-viewport/utils/is-in-viewport';
7 | import canUseRAF from 'ember-in-viewport/utils/can-use-raf';
8 | import canUseIntersectionObserver from 'ember-in-viewport/utils/can-use-intersection-observer';
9 | import ObserverAdmin from 'ember-in-viewport/-private/observer-admin';
10 | import RAFAdmin, { startRAF } from 'ember-in-viewport/-private/raf-admin';
11 |
12 | const noop = () => {};
13 |
14 | /**
15 | * ensure use on requestAnimationFrame, no matter how many components
16 | * on the page are using this class
17 | *
18 | * @class InViewport
19 | * @module Ember.Service
20 | */
21 | export default class InViewport extends Service {
22 | constructor() {
23 | super(...arguments);
24 |
25 | set(this, 'registry', new WeakMap());
26 |
27 | let options = Object.assign(
28 | {
29 | viewportUseRAF: canUseRAF(),
30 | },
31 | this._buildOptions()
32 | );
33 |
34 | // set viewportUseIntersectionObserver after merging users config to avoid errors in browsers that lack support (https://github.com/DockYard/ember-in-viewport/issues/146)
35 | options = Object.assign(options, {
36 | viewportUseIntersectionObserver: canUseIntersectionObserver(),
37 | });
38 |
39 | setProperties(this, options);
40 | }
41 |
42 | startIntersectionObserver() {
43 | this.observerAdmin = new ObserverAdmin();
44 | }
45 |
46 | startRAF() {
47 | this.rafAdmin = new RAFAdmin();
48 | }
49 |
50 | /** Any strategy **/
51 |
52 | /**
53 | * @method watchElement
54 | * @param HTMLElement element
55 | * @param Object configOptions
56 | * @param Function enterCallback
57 | * @param Function exitCallback
58 | * @void
59 | */
60 | watchElement(element, configOptions = {}, enterCallback, exitCallback) {
61 | if (this.viewportUseIntersectionObserver) {
62 | if (!this.observerAdmin) {
63 | this.startIntersectionObserver();
64 | }
65 | const observerOptions = this.buildObserverOptions(configOptions);
66 |
67 | schedule(
68 | 'afterRender',
69 | this,
70 | this.setupIntersectionObserver,
71 | element,
72 | observerOptions,
73 | enterCallback,
74 | exitCallback
75 | );
76 | } else {
77 | if (!this.rafAdmin) {
78 | this.startRAF();
79 | }
80 | schedule(
81 | 'afterRender',
82 | this,
83 | this._startRaf,
84 | element,
85 | configOptions,
86 | enterCallback,
87 | exitCallback
88 | );
89 | }
90 |
91 | return {
92 | onEnter: this.addEnterCallback.bind(this, element),
93 | onExit: this.addExitCallback.bind(this, element),
94 | };
95 | }
96 |
97 | /**
98 | * @method addEnterCallback
99 | * @void
100 | */
101 | addEnterCallback(element, enterCallback) {
102 | if (this.viewportUseIntersectionObserver) {
103 | this.observerAdmin.addEnterCallback(element, enterCallback);
104 | } else {
105 | this.rafAdmin.addEnterCallback(element, enterCallback);
106 | }
107 | }
108 |
109 | /**
110 | * @method addExitCallback
111 | * @void
112 | */
113 | addExitCallback(element, exitCallback) {
114 | if (this.viewportUseIntersectionObserver) {
115 | this.observerAdmin.addExitCallback(element, exitCallback);
116 | } else {
117 | this.rafAdmin.addExitCallback(element, exitCallback);
118 | }
119 | }
120 |
121 | /** IntersectionObserver **/
122 |
123 | /**
124 | * In order to track elements and the state that comes with them, we need to keep track
125 | * of elements in order to get at them at a later time, specifically to unobserve
126 | *
127 | * @method addToRegistry
128 | * @void
129 | */
130 | addToRegistry(element, observerOptions) {
131 | if (this.registry) {
132 | this.registry.set(element, { observerOptions });
133 | } else {
134 | warn('in-viewport Service has already been destroyed');
135 | }
136 | }
137 |
138 | /**
139 | * @method setupIntersectionObserver
140 | * @param HTMLElement element
141 | * @param Object observerOptions
142 | * @param Function enterCallback
143 | * @param Function exitCallback
144 | * @void
145 | */
146 | setupIntersectionObserver(
147 | element,
148 | observerOptions,
149 | enterCallback,
150 | exitCallback
151 | ) {
152 | if (this.isDestroyed || this.isDestroying) {
153 | return;
154 | }
155 |
156 | this.addToRegistry(element, observerOptions);
157 |
158 | this.observerAdmin.add(
159 | element,
160 | observerOptions,
161 | enterCallback,
162 | exitCallback
163 | );
164 | }
165 |
166 | buildObserverOptions({
167 | intersectionThreshold = 0,
168 | scrollableArea = null,
169 | viewportTolerance = {},
170 | }) {
171 | const domScrollableArea =
172 | typeof scrollableArea === 'string' && scrollableArea
173 | ? document.querySelector(scrollableArea)
174 | : scrollableArea instanceof HTMLElement
175 | ? scrollableArea
176 | : undefined;
177 |
178 | // https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API
179 | // IntersectionObserver takes either a Document Element or null for `root`
180 | const { top = 0, left = 0, bottom = 0, right = 0 } = viewportTolerance;
181 | return {
182 | root: domScrollableArea,
183 | rootMargin: `${top}px ${right}px ${bottom}px ${left}px`,
184 | threshold: intersectionThreshold,
185 | };
186 | }
187 |
188 | unobserveIntersectionObserver(target) {
189 | if (!target) {
190 | return;
191 | }
192 |
193 | const registeredTarget = this.registry.get(target);
194 | if (typeof registeredTarget === 'object') {
195 | this.observerAdmin.unobserve(target, registeredTarget.observerOptions);
196 | }
197 | }
198 |
199 | /** RAF **/
200 |
201 | addRAF(elementId, fn) {
202 | this.rafAdmin.add(elementId, fn);
203 | }
204 |
205 | removeRAF(elementId) {
206 | if (this.rafAdmin) {
207 | this.rafAdmin.remove(elementId);
208 | }
209 | }
210 |
211 | isInViewport(...args) {
212 | return isInViewport(...args);
213 | }
214 |
215 | /** other **/
216 | stopWatching(target) {
217 | if (this.observerAdmin) {
218 | this.unobserveIntersectionObserver(target);
219 | }
220 | if (this.rafAdmin) {
221 | this.removeRAF(target);
222 | }
223 | }
224 |
225 | willDestroy() {
226 | set(this, 'registry', null);
227 | if (this.observerAdmin) {
228 | this.observerAdmin.destroy();
229 | set(this, 'observerAdmin', null);
230 | }
231 | if (this.rafAdmin) {
232 | this.rafAdmin.reset();
233 | set(this, 'rafAdmin', null);
234 | }
235 | }
236 |
237 | _buildOptions(defaultOptions = {}) {
238 | const owner = getOwner(this);
239 |
240 | if (owner) {
241 | return Object.assign(defaultOptions, owner.lookup('config:in-viewport'));
242 | }
243 | }
244 |
245 | _startRaf(element, configOptions, enterCallback, exitCallback) {
246 | if (this.isDestroyed || this.isDestroying) {
247 | return;
248 | }
249 |
250 | enterCallback = enterCallback || noop;
251 | exitCallback = exitCallback || noop;
252 |
253 | // this isn't using the same functions as the RAFAdmin, but that is b/c it is a bit harder to unwind.
254 | // So just rewrote it with pure functions for now
255 | startRAF(
256 | element,
257 | configOptions,
258 | enterCallback,
259 | exitCallback,
260 | this.addRAF.bind(this, element.id),
261 | this.removeRAF.bind(this, element.id)
262 | );
263 | }
264 | }
265 |
--------------------------------------------------------------------------------
/tests/acceptance/infinity-test.js:
--------------------------------------------------------------------------------
1 | import { module, test } from 'qunit';
2 | import { setupApplicationTest } from 'ember-qunit';
3 | import {
4 | find,
5 | findAll,
6 | visit,
7 | settled,
8 | waitFor,
9 | waitUntil,
10 | } from '@ember/test-helpers';
11 |
12 | module('Acceptance | infinity-scrollable', function (hooks) {
13 | setupApplicationTest(hooks);
14 |
15 | hooks.beforeEach(function () {
16 | // bring testem window and the browser up to the top.
17 | document.getElementById('ember-testing-container').scrollTop = 0;
18 | });
19 |
20 | test('IntersectionObserver Component fetches more data when scrolled into viewport', async function (assert) {
21 | await visit('/infinity-scrollable');
22 |
23 | assert.equal(findAll('.infinity-svg').length, 10);
24 | assert.equal(
25 | findAll('.infinity-scrollable.inactive').length,
26 | 1,
27 | 'component is inactive before fetching more data'
28 | );
29 | document.querySelector('.infinity-scrollable').scrollIntoView(false);
30 |
31 | await waitFor('.infinity-scrollable.inactive');
32 | await waitUntil(() => {
33 | return findAll('.infinity-svg').length === 20;
34 | });
35 |
36 | assert.equal(findAll('.infinity-svg').length, 20);
37 | });
38 |
39 | test('works with in-viewport modifier', async function (assert) {
40 | await visit('/infinity-built-in-modifiers');
41 |
42 | assert.equal(findAll('.infinity-item').length, 10, 'has items to start');
43 |
44 | document.querySelector('.infinity-item-9').scrollIntoView(false);
45 |
46 | await waitUntil(
47 | () => {
48 | return findAll('.infinity-item').length === 20;
49 | },
50 | { timeoutMessage: 'did not find all items in time' }
51 | );
52 |
53 | await settled();
54 |
55 | assert.equal(
56 | findAll('.infinity-item').length,
57 | 20,
58 | 'after infinity has more items'
59 | );
60 | assert.equal(
61 | find('h1').textContent.trim(),
62 | '{{in-viewport}} modifier',
63 | 'has title'
64 | );
65 |
66 | document.querySelector('.infinity-item-19').scrollIntoView(false);
67 |
68 | await waitUntil(
69 | () => {
70 | return findAll('.infinity-item').length === 30;
71 | },
72 | { timeoutMessage: 'did not find all items in time' }
73 | );
74 |
75 | await settled();
76 |
77 | assert.equal(
78 | findAll('.infinity-item').length,
79 | 30,
80 | 'after infinity has more items'
81 | );
82 | assert.equal(
83 | find('h1').textContent.trim(),
84 | '{{in-viewport}} modifier',
85 | 'has title'
86 | );
87 | });
88 |
89 | test('works with in-viewport modifier (rAF)', async function (assert) {
90 | let inViewportService = this.owner.lookup('service:in-viewport');
91 |
92 | inViewportService.set('viewportUseIntersectionObserver', false);
93 |
94 | await visit('/infinity-built-in-modifiers');
95 |
96 | assert.equal(findAll('.infinity-item').length, 10, 'has items to start');
97 |
98 | document.querySelector('.infinity-item-9').scrollIntoView(false);
99 |
100 | await waitUntil(
101 | () => {
102 | return findAll('.infinity-item').length === 20;
103 | },
104 | { timeoutMessage: 'did not find all items in time' }
105 | );
106 |
107 | await settled();
108 |
109 | assert.equal(
110 | findAll('.infinity-item').length,
111 | 20,
112 | 'after infinity has more items'
113 | );
114 | });
115 |
116 | test('ember-in-viewport works with classes', async function (assert) {
117 | await visit('/infinity-class');
118 |
119 | assert.equal(findAll('.infinity-class-item').length, 20);
120 | document.querySelector('#loader').scrollIntoView(false);
121 |
122 | await waitUntil(() => {
123 | return findAll('.infinity-class-item').length === 40;
124 | });
125 |
126 | assert.equal(findAll('.infinity-class-item').length, 40);
127 | });
128 |
129 | test('IntersectionObserver Component fetches more data when left to right scrolling', async function (assert) {
130 | await visit('/infinity-right-left');
131 |
132 | assert.equal(findAll('.infinity-svg').length, 10);
133 | assert.equal(
134 | findAll('.infinity-scrollable.inactive').length,
135 | 1,
136 | 'component is inactive before fetching more data'
137 | );
138 | document.querySelector('.infinity-scrollable').scrollIntoView(false);
139 |
140 | await waitFor('.infinity-scrollable.inactive');
141 |
142 | // assert.equal(findAll('.infinity-svg').length, 20);
143 | });
144 |
145 | test('rAF Component fetches more data when scrolled into viewport', async function (assert) {
146 | await visit('/infinity-scrollable-raf');
147 |
148 | assert.equal(findAll('.infinity-svg-rAF').length, 10);
149 | assert.equal(
150 | findAll('.infinity-scrollable-rAF.inactive').length,
151 | 1,
152 | 'component is inactive before fetching more data'
153 | );
154 | document.querySelector('.infinity-scrollable-rAF').scrollIntoView(false);
155 |
156 | await waitUntil(() => {
157 | return findAll('.infinity-svg-rAF').length === 20;
158 | });
159 | await waitFor('.infinity-scrollable-rAF.inactive');
160 |
161 | assert.equal(findAll('.infinity-svg-rAF').length, 20);
162 | assert.equal(
163 | findAll('.infinity-scrollable-rAF.inactive').length,
164 | 1,
165 | 'component is inactive after fetching more data'
166 | );
167 | });
168 |
169 | test('rAF (second) component does not fetch after first call (viewportSpy is false)', async function (assert) {
170 | await visit('/infinity-scrollable-raf');
171 |
172 | assert.equal(findAll('.infinity-svg-rAF-bottom').length, 10);
173 | assert.equal(
174 | findAll('.infinity-scrollable-rAF-bottom.inactive').length,
175 | 1,
176 | 'component is inactive before fetching more data'
177 | );
178 | document
179 | .querySelector('.infinity-scrollable-rAF-bottom')
180 | .scrollIntoView(false);
181 |
182 | await waitUntil(() => {
183 | return findAll('.infinity-svg-rAF-bottom').length === 20;
184 | });
185 | await waitFor('.infinity-scrollable-rAF-bottom.inactive');
186 |
187 | document
188 | .querySelector('.infinity-scrollable-rAF-bottom')
189 | .scrollIntoView(false);
190 |
191 | await waitUntil(() => {
192 | // one tick is enough to check
193 | return findAll('.infinity-svg-rAF-bottom').length === 20;
194 | });
195 | await waitFor('.infinity-scrollable-rAF-bottom.inactive');
196 | });
197 |
198 | test('scrollEvent Component fetches more data when scrolled into viewport', async function (assert) {
199 | await visit('/infinity-scrollable-scrollevent');
200 |
201 | assert.equal(findAll('.infinity-svg-scrollEvent').length, 10);
202 | assert.equal(
203 | findAll('.infinity-scrollable-scrollEvent.inactive').length,
204 | 1,
205 | 'component is inactive before fetching more data'
206 | );
207 | await document
208 | .querySelector('.infinity-scrollable-scrollEvent')
209 | .scrollIntoView(false);
210 |
211 | await waitUntil(() => {
212 | return findAll('.infinity-svg-scrollEvent').length === 20;
213 | });
214 |
215 | assert.equal(findAll('.infinity-svg-scrollEvent').length, 20);
216 | assert.ok(
217 | find('.infinity-scrollable-scrollEvent.active'),
218 | 'component is still active after fetching more data'
219 | );
220 | // scroll 1px to trigger inactive state
221 | let elem = document.getElementsByClassName('list-scrollEvent')[0];
222 | elem.scrollTop = elem.scrollTop + 5;
223 |
224 | await waitUntil(() => {
225 | return find('.infinity-scrollable-scrollEvent.inactive');
226 | });
227 |
228 | assert.ok(
229 | find('.infinity-scrollable-scrollEvent.inactive'),
230 | 'component is inactive after scrolling'
231 | );
232 | });
233 |
234 | test('works with custom elements', async function (assert) {
235 | await visit('/infinity-custom-element');
236 |
237 | assert.equal(findAll('.infinity-item').length, 10, 'has items to start');
238 |
239 | document.querySelector('custom-sentinel').scrollIntoView(false);
240 |
241 | await waitUntil(
242 | () => {
243 | return findAll('.infinity-item').length === 20;
244 | },
245 | { timeoutMessage: 'did not find all items in time' }
246 | );
247 |
248 | await settled();
249 |
250 | // assert.equal(findAll('.infinity-item').length, 20, 'after infinity has more items');
251 | });
252 | });
253 |
--------------------------------------------------------------------------------
/tests/dummy/app/components/dummy-artwork.js:
--------------------------------------------------------------------------------
1 | import Component from '@glimmer/component';
2 | import { htmlSafe } from '@ember/string';
3 | import { action, set } from '@ember/object';
4 | import { inject as service } from '@ember/service';
5 | import { artworkProfiles, artworkFallbacks, viewports } from '../-config';
6 | import { guidFor } from '@ember/object/internals';
7 | import ENV from 'dummy/config/environment';
8 |
9 | /**
10 | * This function generates a value for the `srcset` attribute
11 | * based on a URL and image options.
12 | *
13 | * where options:
14 | * - `{w}` is the width placeholder
15 | * - `{h}` is the height placeholder
16 | * - `{c}` is the crop placeholder
17 | * - `{f}` is the file type placeholder
18 | *
19 | * The options object specified is expected to have `width`,
20 | * `height`, `crop`, and `fileType` key/value pairs.
21 | *
22 | * @method buildSrcset
23 | * @param {String} rawURL The raw URL
24 | * @param {Object} options The image options
25 | * @return {String} The `srcset` attribute value
26 | * @public
27 | */
28 | export function buildSrcset(url, options, pixelDensity = 1) {
29 | return `${url} ${options.width * pixelDensity}w`;
30 | }
31 |
32 | /**
33 | * #### Usage
34 | *
35 | * ```hbs
36 | *
37 | * ```
38 | *
39 | * @class DummyArtwork
40 | * @module Components
41 | * @extends {Ember.Component}
42 | * @public
43 | */
44 | export default class DummyArtwork extends Component {
45 | @service inViewport;
46 | @service media;
47 |
48 | rootURL = ENV.rootURL;
49 |
50 | /**
51 | * Provide an `alt` attribute for the `` tag. Default is an empty string.
52 | *
53 | * @property alt
54 | * @type String
55 | * @public
56 | */
57 |
58 | /**
59 | * @property isDownloaded
60 | * @type {Boolean}
61 | * @default false
62 | * @public
63 | */
64 | isDownloaded = false;
65 |
66 | /**
67 | * @property isErrored
68 | * @type {Boolean}
69 | * @default false
70 | * @public
71 | */
72 | isErrored = false;
73 |
74 | /**
75 | * @property fileType
76 | * @type String
77 | * @public
78 | */
79 | fileType = 'jpg';
80 |
81 | /**
82 | * @property lazyLoad
83 | * @type {Boolean}
84 | * @default true
85 | * @public
86 | */
87 | lazyLoad = true;
88 |
89 | /**
90 | * The value to be used for background-color CSS property
91 | * when addBgColor is true. This will override
92 | * any property included in the artwork data.
93 | * @property overrideBgColor
94 | * @type {String}
95 | * @public
96 | */
97 | overrideBgColor;
98 |
99 | /**
100 | * Indicates if a background color should be added to
101 | * display while loading
102 | *
103 | * @property addBgColor
104 | * @type {Boolean}
105 | * @public
106 | */
107 | addBgColor;
108 |
109 | constructor(...args) {
110 | super(...args);
111 |
112 | // for use in template
113 | this.boundOnError = this.onError.bind(this);
114 | this.boundOnLoad = this.onLoad.bind(this);
115 | set(this, 'guid', guidFor(this));
116 | }
117 |
118 | /**
119 | *
120 | * @property userInitials
121 | * @type {String}
122 | * @public
123 | */
124 | get userInitials() {
125 | return this.actualArtwork.userInitials;
126 | }
127 |
128 | /**
129 | *
130 | * @property isFallbackArtwork
131 | * @type {Boolean}
132 | * @public
133 | */
134 | get isFallbackArtwork() {
135 | return this.actualArtwork.isFallback;
136 | }
137 |
138 | /**
139 | *
140 | * @property isUserMonogram
141 | * @type {Boolean}
142 | * @public
143 | */
144 | get isUserMonogram() {
145 | return this.isFallbackArtwork && !!this.userInitials;
146 | }
147 |
148 | /**
149 | * @property artworkClasses
150 | * @type {String}
151 | * @public
152 | */
153 | get artworkClasses() {
154 | let classes = this.class || '';
155 | if (this.isDownloaded) {
156 | classes += ' dummy-artwork--downloaded ';
157 | }
158 |
159 | return classes.trim();
160 | }
161 |
162 | /**
163 | * @property height
164 | * @type {String|Number}
165 | * @public
166 | */
167 | get height() {
168 | const [viewport = 'medium'] = this.media.matches;
169 | if (this.profiles && this.profiles[viewport]) {
170 | return this.profiles[viewport].height;
171 | }
172 |
173 | return this.profiles.large && this.profiles.large.height;
174 | }
175 |
176 | /**
177 | * @property width
178 | * @type {String|Number}
179 | * @public
180 | */
181 | get width() {
182 | const [viewport = 'medium'] = this.media.matches;
183 | if (this.profiles && this.profiles[viewport]) {
184 | return this.profiles[viewport].width;
185 | }
186 |
187 | // no profile if no artwork
188 | return this.profiles.large && this.profiles.large.width;
189 | }
190 |
191 | /**
192 | * The background color inline style for the artwork.
193 | * This will be visible while the image is loading.
194 | *
195 | * @property bgColor
196 | * @type String
197 | * @public
198 | */
199 | get bgColor() {
200 | if (!this.actualArtwork || this.actualArtwork.hasAlpha) {
201 | return htmlSafe('');
202 | }
203 | const { overrideBgColor, addBgColor } = this;
204 | const bgColor = overrideBgColor || this.actualArtwork.bgColor;
205 | if (addBgColor && bgColor) {
206 | return `#${bgColor}`;
207 | }
208 | }
209 |
210 | get imgBgColor() {
211 | if (this.bgColor) {
212 | return htmlSafe(`background-color: ${this.bgColor};`);
213 | }
214 | }
215 |
216 | /**
217 | * @property aspectRatio The aspect ratio of the artwork from the config
218 | * @type Number
219 | * @private
220 | */
221 | get aspectRatio() {
222 | return this.width / this.height;
223 | }
224 |
225 | /**
226 | * This is the aspect ratio of the artwork itself, as opposed to the desired width/height
227 | * passed in by the consumer.
228 | *
229 | * @property mediaQueries The aspect ratio of the artwork from the server
230 | * @type number
231 | * @private
232 | */
233 | get mediaQueries() {
234 | return viewports
235 | .map(({ mediaQueryStrict, name }) => {
236 | if (!this.profiles[name]) {
237 | return;
238 | }
239 | return `${mediaQueryStrict} ${this.profiles[name].width}px`;
240 | })
241 | .filter(Boolean)
242 | .join(', ')
243 | .trim();
244 | }
245 |
246 | /**
247 | * An artworkProfile may be a string or object.
248 | * There may not be different viewport defined sizes for an artwork profile.
249 | * As a result, we dont want to avoid duplicate * work and tell the browser that the same size
250 | * exists for each lg/medium/small viewport.
251 | *
252 | * { large: { height, width, crop }, medium: { height, width, crop }, small: { ... } }
253 | *
254 | * or just this
255 | *
256 | * e.g. { large: { height, width, crop } }
257 | *
258 | * @property profile
259 | * @type Object
260 | * @public
261 | */
262 | get profile() {
263 | let profile = {};
264 | if (typeof this.args.artworkProfile === 'string') {
265 | profile = artworkProfiles[this.args.artworkProfile];
266 | } else if (typeof this.args.artworkProfile === 'object') {
267 | profile = this.args.artworkProfile;
268 | }
269 |
270 | return profile;
271 | }
272 |
273 | /**
274 | * @property profiles
275 | * @type Object
276 | * @public
277 | */
278 | get profiles() {
279 | // eslint-disable-next-line arrow-body-style
280 | return viewports.reduce((acc, view) => {
281 | // the artwork-profile might not define a size at a specific viewport defined in app/breakpoints.js
282 | if (this.profile[view.name]) {
283 | const { height, width, crop } = this.profile[view.name];
284 | acc[view.name] = { width, height, crop };
285 | }
286 |
287 | return acc;
288 | }, {});
289 | }
290 |
291 | /**
292 | * we render the fallback src directly in the image with no srcset
293 | *
294 | * @property fallbackSrc
295 | * @type String
296 | * @private
297 | */
298 | get fallbackSrc() {
299 | const {
300 | actualArtwork: { url, isFallback = false },
301 | } = this;
302 | if (isFallback) {
303 | return url;
304 | }
305 | }
306 |
307 | /**
308 | * @property srcset
309 | * @type String
310 | * @private
311 | */
312 | get srcset() {
313 | const {
314 | actualArtwork: { url, isFallback = false },
315 | } = this;
316 | return [1, 2]
317 | .map((pixelDensity) =>
318 | viewports.map(({ name }) => {
319 | const settings = Object.assign(
320 | {},
321 | { fileType: this.fileType },
322 | this.profiles[name]
323 | );
324 | // Build a srcset from patterned URL
325 | if (isFallback) {
326 | return;
327 | }
328 | return buildSrcset(url, settings, pixelDensity);
329 | })
330 | )
331 | .join(', ');
332 | }
333 |
334 | /**
335 | * @property actualArtwork
336 | * @type Object
337 | * @private
338 | */
339 | get actualArtwork() {
340 | const { url } = this.args.artwork || {};
341 | const { fallbackArtwork, isErrored } = this;
342 |
343 | if ((!url && fallbackArtwork) || isErrored) {
344 | return Object.assign({}, fallbackArtwork, { isFallback: true });
345 | }
346 |
347 | return this.args.artwork;
348 | }
349 |
350 | /**
351 | * If the fallback profile provided exists, we find the corresponding
352 | * fallback artwork object from the app config. This is used whenever
353 | * the main artwork object is missing or invalid.
354 | *
355 | * @property fallbackArtwork
356 | * @type object
357 | * @private
358 | */
359 | get fallbackArtwork() {
360 | const { fallbackProfile } = this;
361 | if (typeof fallbackProfile === 'object') {
362 | return fallbackProfile;
363 | }
364 | const fallbackArtwork = artworkFallbacks[fallbackProfile];
365 |
366 | if (fallbackArtwork) {
367 | return fallbackArtwork;
368 | }
369 |
370 | return null;
371 | }
372 |
373 | /**
374 | * Inline style to properly scale the img element.
375 | *
376 | * @property imgStyle
377 | * @type String
378 | * @public
379 | */
380 | get imgStyle() {
381 | return Object.keys(this.profiles)
382 | .map((name) => {
383 | const source = this.profiles[name];
384 | let style = '';
385 | if (source.width > 0) {
386 | style = `#${this.guid}, #${this.guid}::before {
387 | width: ${source.width}px;
388 | height: ${source.height}px;
389 | }
390 | #${this.guid}::before {
391 | padding-top: ${(source.height / source.width) * 100}%;
392 | }`;
393 | }
394 |
395 | if (source.mediaQuery && style.length > 0) {
396 | return `@media ${source.mediaQuery} {
397 | ${style}
398 | }`;
399 | }
400 |
401 | return style;
402 | })
403 | .reverse()
404 | .join('\n');
405 | }
406 |
407 | @action
408 | setupInViewport(element) {
409 | if (this.lazyLoad) {
410 | // find distance of top left corner of artwork to bottom of screen. Shave off 50px so user has to scroll slightly to trigger load
411 | window.requestAnimationFrame(() => {
412 | const { onEnter } = this.inViewport.watchElement(element, {
413 | viewportTolerance: { top: 200, right: 200, bottom: 200, left: 200 },
414 | });
415 |
416 | onEnter(this.didEnterViewport.bind(this));
417 | });
418 | }
419 | }
420 |
421 | /**
422 | * in-viewport hook to set src and srset based on data-* attrs
423 | *
424 | * @method didEnterViewport
425 | * @private
426 | */
427 | didEnterViewport() {
428 | if (this.isDestroyed || this.isDestroying) {
429 | return;
430 | }
431 |
432 | this._swapSource();
433 | this.inViewport.stopWatching(document.getElementById(this.guid));
434 | }
435 |
436 | /**
437 | * @method onError
438 | */
439 | onError() {
440 | if (this.isDestroyed || this.isDestroying) {
441 | return;
442 | }
443 |
444 | set(this, 'isErrored', true);
445 | }
446 |
447 | /**
448 | * @method onLoad
449 | */
450 | onLoad() {
451 | if (this.isDestroyed || this.isDestroying) {
452 | return;
453 | }
454 | set(this, 'isDownloaded', true);
455 | }
456 |
457 | willDestroy(...args) {
458 | this.inViewport.stopWatching(document.getElementById(this.guid));
459 |
460 | super.willDestroy(...args);
461 | }
462 |
463 | /**
464 | * swap src and srset with data attributes that hold the real src
465 | *
466 | * @method _swapSource
467 | * @private
468 | */
469 | _swapSource() {
470 | const { lazyLoad, isDownloaded, isFallbackArtwork } = this;
471 | const element = document.getElementById(this.guid);
472 |
473 | if (lazyLoad && element && !isDownloaded && !isFallbackArtwork) {
474 | const img = element.querySelector('img');
475 | if (img && img.dataset) {
476 | img.onload = this.onLoad.bind(this);
477 | img.srcset = img.dataset.srcset;
478 | }
479 | }
480 | }
481 | }
482 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # ember-in-viewport
2 | *Detect if an Ember View or Component is in the viewport @ 60FPS*
3 |
4 | **[ember-in-viewport is built and maintained by DockYard, contact us for expert Ember.js consulting](https://dockyard.com/ember-consulting)**.
5 |
6 | [Read the blogpost](https://medium.com/delightful-ui-for-ember-apps/creating-an-ember-cli-addon-detecting-ember-js-components-entering-or-leaving-the-viewport-7d95ceb4f5ed)
7 |
8 | 
9 | [](http://badge.fury.io/js/ember-in-viewport)
10 | [](https://github.com/DockYard/ember-in-viewport/actions/workflows/ci.yml?query=branch%3Amaster)
11 | [](http://emberobserver.com/addons/ember-in-viewport)
12 |
13 | This Ember addon adds a simple, highly performant Service or modifier to your app. This library will allow you to check if a `Component` or DOM element has entered the browser's viewport. By default, this uses the `IntersectionObserver` API if it detects it the DOM element is in your user's browser – failing which, it falls back to using `requestAnimationFrame`, then if not available, the Ember run loop and event listeners.
14 |
15 | We utilize pooling techniques to reuse Intersection Observers and rAF observers in order to make your app as performant as possible and do as little works as possible.
16 |
17 | ## Demo or examples
18 | - Lazy loading responsive images (see `dummy-artwork` for an example artwork component). Visit `http://localhost:4200/infinity-modifier` to see it in action
19 | - Dummy app (`ember serve`): https://github.com/DockYard/ember-in-viewport/tree/master/tests/dummy
20 | - Use with Ember [Modifiers](#modifiers) and [@ember/render-modifiers](https://github.com/emberjs/ember-render-modifiers)
21 | - Use with [Native Classes](#classes)
22 | - [ember-infinity](https://github.com/ember-infinity/ember-infinity)
23 | - [ember-light-table](https://github.com/offirgolan/ember-light-table)
24 | - Tracking advertisement impressions
25 | - Occlusion culling
26 |
27 |
28 | # Table of Contents
29 |
30 | - [Installation](#installation)
31 | * [Usage](#usage)
32 | + [Configuration](#configuration)
33 | + [Global options](#global-options)
34 | + [Modifiers](#modifiers)
35 | * [**IntersectionObserver**'s Browser Support](#intersectionobservers-browser-supportscrollableArea)
36 | + [Out of the box](#out-of-the-box)
37 | * [Running](#running)
38 | * [Running Tests](#running-tests)
39 | * [Building](#building)
40 | * [Legal](#legal)
41 |
42 |
43 |
44 | # Installation
45 |
46 | ```
47 | ember install ember-in-viewport
48 | ```
49 |
50 | ## Usage
51 | Usage is simple. First, inject the service to your component and start "watching" DOM elements.
52 |
53 | ```js
54 | import Component from '@glimmer/component';
55 | import { action } from '@ember/object';
56 | import { inject as service } from '@ember/service';
57 |
58 | export default class MyClass extends Component {
59 | @service inViewport
60 |
61 | @action
62 | setupInViewport() {
63 | const loader = document.getElementById('loader');
64 | const viewportTolerance = { bottom: 200 };
65 | const { onEnter, _onExit } = this.inViewport.watchElement(loader, { viewportTolerance });
66 | // pass the bound method to `onEnter` or `onExit`
67 | onEnter(this.didEnterViewport.bind(this));
68 | }
69 |
70 | didEnterViewport() {
71 | // do some other stuff
72 | this.infinityLoad();
73 | }
74 |
75 | willDestroy() {
76 | // need to manage cache yourself
77 | const loader = document.getElementById('loader');
78 | this.inViewport.stopWatching(loader);
79 |
80 | super.willDestroy(...arguments);
81 | }
82 | }
83 | ```
84 |
85 | ```hbs
86 |
87 |
88 | ...
89 |
90 |
91 | ```
92 |
93 | You can also use [`Modifiers`](#modifiers) as well. Using modifiers cleans up the boilerplate needed and is shown in a later example.
94 |
95 | ### Configuration
96 | To use with the service based approach, simply pass in the options to `watchElement` as the second argument.
97 |
98 | ```js
99 | import Component from '@glimmer/component';
100 | import { inject as service } from '@ember/service';
101 |
102 | export default class MyClass extends Component {
103 | @service inViewport
104 |
105 | @action
106 | setupInViewport() {
107 | const loader = document.getElementById('loader');
108 |
109 | const { onEnter, _onExit } = this.inViewport.watchElement(
110 | loader,
111 | {
112 | viewportTolerance: { bottom: 200 },
113 | intersectionThreshold: 0.25,
114 | scrollableArea: '#scrollable-area'
115 | }
116 | );
117 | }
118 | }
119 | ```
120 |
121 | ### Global options
122 |
123 | You can set application wide defaults for `ember-in-viewport` in your app (they are still manually overridable inside of a Component). To set new defaults, just add a config object to `config/environment.js`, like so:
124 |
125 | ```js
126 | module.exports = function(environment) {
127 | var ENV = {
128 | // ...
129 | viewportConfig: {
130 | viewportUseRAF : true,
131 | viewportSpy : false,
132 | viewportListeners : [],
133 | intersectionThreshold : 0,
134 | scrollableArea : null,
135 | viewportTolerance: {
136 | top : 0,
137 | left : 0,
138 | bottom : 0,
139 | right : 0
140 | }
141 | }
142 | };
143 | };
144 |
145 | // Note if you want to disable right and left in-viewport triggers, set these values to `Infinity`.
146 | ```
147 |
148 | ### Modifiers
149 |
150 | Using with [Modifiers](https://blog.emberjs.com/2019/03/06/coming-soon-in-ember-octane-part-4.html) is easy.
151 |
152 | You can either use our built in modifier `{{in-viewport}}` or a more verbose, but potentially more flexible generic modifier. Let's start with the former.
153 |
154 | 1. Use `{{in-viewport}}` modifier on target element
155 | 2. Ensure you have a callbacks in context for enter and/or exit
156 | 3. `options` are optional - see [Advanced usage (options)](#advanced-usage-options)
157 |
158 | ```hbs
159 |
160 |
161 |
162 |
163 | List sentinel
164 |
165 |
166 | ```
167 |
168 | This modifier is useful for a variety of scenarios where you need to watch a sentinel. With template only components, functionality like this is even more important! If you have logic that currently uses the `did-insert` modifier to start watching an element, try this one out!
169 |
170 | If you need more than our built in modifier...
171 |
172 | 1. Install [@ember/render-modifiers](https://github.com/emberjs/ember-render-modifiers)
173 | 2. Use the `did-insert` hook inside a component
174 | 3. Wire up the component like so
175 |
176 | Note - This is in lieu of a `did-enter-viewport` modifier, which we plan on adding in the future. Compared to the solution below, `did-enter-viewport` won't need a container (`this`) passed to it. But for now, to start using modifiers, this is the easy path.
177 |
178 | ```js
179 | import Component from '@glimmer/component';
180 | import { action } from '@ember/object';
181 | import { inject as service } from '@ember/service';
182 |
183 | export default class MyClass extends Component {
184 | @service inViewport
185 |
186 | @action
187 | setupInViewport() {
188 | const loader = document.getElementById('loader');
189 | const viewportTolerance = { bottom: 200 };
190 | const { onEnter, _onExit } = this.inViewport.watchElement(loader, { viewportTolerance });
191 | onEnter(this.didEnterViewport.bind(this));
192 | }
193 |
194 | didEnterViewport() {
195 | // do some other stuff
196 | this.infinityLoad();
197 | }
198 |
199 | willDestroy() {
200 | // need to manage cache yourself
201 | const loader = document.getElementById('loader');
202 | this.inViewport.stopWatching(loader);
203 |
204 | super.willDestroy(...arguments);
205 | }
206 | }
207 | ```
208 |
209 | ```hbs
210 |
211 | {{yield}}
212 |
213 | ```
214 |
215 | Options as the second argument to `inViewport.watchElement` include:
216 | - `intersectionThreshold: decimal or array`
217 |
218 | Default: 0
219 |
220 | A single number or array of numbers between 0.0 and 1.0. A value of 0.0 means the target will be visible when the first pixel enters the viewport. A value of 1.0 means the entire target must be visible to fire the didEnterViewport hook.
221 | Similarily, [0, .25, .5, .75, 1] will fire didEnterViewport every 25% of the target that is visible.
222 | (https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API#Thresholds)
223 |
224 | Some notes:
225 | - If the target is offscreen, you will get a notification via `didExitViewport` that the target is initially offscreen. Similarily, this is possible to notify if onscreen when your site loads.
226 | - If intersectionThreshold is set to anything greater than 0, you will not see `didExitViewport` hook fired due to our use of the `isIntersecting` property. See last comment here: https://bugs.chromium.org/p/chromium/issues/detail?id=713819 for purpose of `isIntersecting`
227 | - To get around the above issue and have `didExitViewport` fire, set your `intersectionThreshold` to `[0, 1.0]`. When set to just `1.0`, when the element is 99% visible and still has isIntersecting as true, when the element leaves the viewport, the element isn't applicable to the observer anymore, so the callback isn't called again.
228 | - If your intersectionThreshold is set to 0 you will get notified if the target `didEnterViewport` and `didExitViewport` at the appropriate time.
229 |
230 | - `scrollableArea: string | HTMLElement`
231 |
232 | Default: null
233 |
234 | A CSS selector for the scrollable area. e.g. `".my-list"`
235 |
236 | - `viewportSpy: boolean`
237 |
238 | Default: `false`
239 |
240 | `viewportSpy: true` is often useful when you have "infinite lists" that need to keep loading more data.
241 | `viewportSpy: false` is often useful for one time loading of artwork, metrics, etc when the come into the viewport.
242 |
243 | If you support IE11 and detect and run logic `onExit`, then it is necessary to have this `true` to that the requestAnimationFrame watching your sentinel is not torn down.
244 |
245 | When `true`, the library will continually watch the `Component` and re-fire hooks whenever it enters or leaves the viewport. Because this is expensive, this behaviour is opt-in. When false, the intersection observer will only watch the `Component` until it enters the viewport once, and then it unbinds listeners. This reduces the load on the Ember run loop and your application.
246 |
247 | NOTE: If using IntersectionObserver (default), viewportSpy wont put too much of a tax on your application. However, for browsers (Safari < 12.1) that don't currently support IntersectionObserver, we fallback to rAF. Depending on your use case, the default of `false` may be acceptable.
248 |
249 | - `viewportTolerance: object`
250 |
251 | Default: `{ top: 0, left: 0, bottom: 0, right: 0 }`
252 |
253 | This option determines how accurately the `Component` needs to be within the viewport for it to be considered as entered. Add bottom margin to preemptively trigger didEnterViewport.
254 |
255 | For IntersectionObserver, this property interpolates to [rootMargin](https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserver/rootMargin).
256 | For rAF, this property will use `bottom` tolerance and measure against the height of the container to determine when to trigger didEnterViewport.
257 |
258 | Also, if your sentinel (the watched element) is a zero-height element, ensure that the sentinel actually is able to enter the viewport.
259 |
260 |
261 | ## [**IntersectionObserver**'s Browser Support](https://platform-status.mozilla.org/)
262 |
263 | ### Out of the box
264 |
265 |