├── .github ├── dependabot.yml └── workflows │ └── ci.yml ├── .gitignore ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE.md ├── README.md ├── RELEASE.md ├── package.json ├── pnpm-lock.yaml ├── pnpm-workspace.yaml └── vertical-collection ├── .editorconfig ├── .ember-cli ├── .eslintignore ├── .eslintrc.js ├── .gitignore ├── .npmignore ├── .prettierignore ├── .prettierrc.js ├── .stylelintignore ├── .stylelintrc.js ├── .template-lintrc.js ├── .watchmanconfig ├── addon ├── -private │ ├── data-view │ │ ├── elements │ │ │ ├── occluded-content.js │ │ │ ├── viewport-container.js │ │ │ └── virtual-component.js │ │ ├── radar │ │ │ ├── dynamic-radar.js │ │ │ ├── radar.js │ │ │ └── static-radar.js │ │ ├── skip-list.js │ │ ├── utils │ │ │ ├── insert-range-before.js │ │ │ ├── mutation-checkers.js │ │ │ ├── object-at.js │ │ │ ├── round-to.js │ │ │ ├── scroll-handler.js │ │ │ └── supports-passive.js │ │ └── viewport-container.js │ ├── ember-internals │ │ ├── identity.js │ │ └── key-for-item.js │ ├── index.js │ └── utils │ │ ├── document-shim.js │ │ └── element │ │ ├── closest.js │ │ ├── estimate-element-height.js │ │ └── get-scaled-client-rect.js ├── .gitkeep ├── components │ └── vertical-collection │ │ ├── component.js │ │ └── template.hbs └── styles │ └── app.css ├── app ├── .gitkeep └── components │ └── vertical-collection.js ├── bin ├── restore-env.sh ├── run-tests-with-retry.sh └── stash-env.sh ├── ember-cli-build.js ├── index.js ├── package.json ├── scripts └── write-snippets.mjs ├── testem.js ├── tests ├── .eslintrc.js ├── acceptance │ └── acceptance-tests │ │ └── record-array-test.js ├── dummy │ ├── app │ │ ├── adapters │ │ │ └── application.js │ │ ├── app.js │ │ ├── components │ │ │ ├── code-snippet.hbs │ │ │ └── code-snippet.js │ │ ├── helpers │ │ │ ├── either-or.js │ │ │ ├── html-safe.js │ │ │ └── join-strings.js │ │ ├── index.html │ │ ├── lib │ │ │ ├── get-data.js │ │ │ ├── get-images.js │ │ │ └── get-numbers.js │ │ ├── models │ │ │ └── number-item.js │ │ ├── resolver.js │ │ ├── router.js │ │ ├── routes │ │ │ ├── acceptance-tests │ │ │ │ └── record-array │ │ │ │ │ ├── controller.js │ │ │ │ │ ├── route.js │ │ │ │ │ └── template.hbs │ │ │ ├── application │ │ │ │ ├── route.js │ │ │ │ └── template.hbs │ │ │ ├── components │ │ │ │ └── number-slide │ │ │ │ │ ├── component.js │ │ │ │ │ └── template.hbs │ │ │ ├── examples │ │ │ │ ├── dbmon │ │ │ │ │ ├── components │ │ │ │ │ │ └── dbmon-row │ │ │ │ │ │ │ ├── component.js │ │ │ │ │ │ │ └── template.hbs │ │ │ │ │ ├── controller.js │ │ │ │ │ ├── route.js │ │ │ │ │ └── template.hbs │ │ │ │ ├── flexible-layout │ │ │ │ │ ├── controller.js │ │ │ │ │ ├── route.js │ │ │ │ │ └── template.hbs │ │ │ │ ├── index │ │ │ │ │ └── template.hbs │ │ │ │ ├── infinite-scroll │ │ │ │ │ ├── controller.js │ │ │ │ │ ├── route.js │ │ │ │ │ └── template.hbs │ │ │ │ ├── reduce-debug │ │ │ │ │ ├── controller.js │ │ │ │ │ ├── route.js │ │ │ │ │ └── template.hbs │ │ │ │ └── scrollable-body │ │ │ │ │ ├── controller.js │ │ │ │ │ ├── route.js │ │ │ │ │ └── template.hbs │ │ │ ├── index │ │ │ │ ├── controller.js │ │ │ │ ├── route.js │ │ │ │ └── template.hbs │ │ │ └── settings │ │ │ │ ├── snippets │ │ │ │ └── defaults.js │ │ │ │ └── template.hbs │ │ ├── serializers │ │ │ └── application.js │ │ └── styles │ │ │ └── app.css │ ├── config │ │ ├── ember-cli-toolbelts.json │ │ ├── ember-cli-update.json │ │ ├── ember-try.js │ │ ├── environment.js │ │ ├── optional-features.json │ │ └── targets.js │ └── public │ │ ├── HTML-Next.png │ │ ├── crossdomain.xml │ │ └── robots.txt ├── helpers │ ├── array.js │ ├── destroy-app.js │ ├── index.js │ ├── measurement.js │ ├── module-for-acceptance.js │ ├── scroll-to.js │ ├── start-app.js │ └── test-scenarios.js ├── index.html ├── integration │ ├── a11y-test.js │ ├── basic-test.js │ ├── debug-test.js │ ├── measure-test.js │ ├── measurement-unit-test.js │ ├── modern-ember-test.js │ ├── mutation-test.js │ ├── recycle-test.js │ └── scroll-test.js ├── test-helper.js └── unit │ ├── -private │ └── data-view │ │ └── utils │ │ └── scroll-handler-test.js │ └── .gitkeep ├── tsconfig.declarations.json └── vendor └── debug.css /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 2 | 3 | version: 2 4 | updates: 5 | - package-ecosystem: "npm" # See documentation for possible values 6 | directory: "/" # Location of package manifests 7 | versioning-strategy: lockfile-only 8 | schedule: 9 | interval: "weekly" 10 | open-pull-requests-limit: 4 11 | labels: 12 | - "dependencies" 13 | 14 | - package-ecosystem: "github-actions" 15 | # Workflow files stored in the 16 | # default location of `.github/workflows` 17 | directory: "/.github" 18 | schedule: 19 | interval: "weekly" 20 | open-pull-requests-limit: 2 21 | labels: 22 | - "dependencies" 23 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - master 8 | pull_request: {} 9 | 10 | concurrency: 11 | group: ci-${{ github.head_ref || github.ref }} 12 | cancel-in-progress: true 13 | 14 | jobs: 15 | lint: 16 | name: "Lints" 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@v4 20 | - uses: wyvox/action-setup-pnpm@v3 21 | - run: pnpm lint 22 | 23 | test: 24 | name: "Tests" 25 | runs-on: ubuntu-latest 26 | timeout-minutes: 12 27 | 28 | steps: 29 | - uses: actions/checkout@v4 30 | - uses: wyvox/action-setup-pnpm@v3 31 | - run: . bin/restore-env.sh && pnpm ember build 32 | working-directory: vertical-collection 33 | - name: Run Tests 34 | uses: nick-fields/retry@v2 35 | with: 36 | timeout_minutes: 2 37 | max_attempts: 5 38 | command: pnpm test:ci 39 | 40 | floating: 41 | name: "Floating Dependencies" 42 | runs-on: ubuntu-latest 43 | timeout-minutes: 12 44 | 45 | steps: 46 | - uses: actions/checkout@v4 47 | - uses: wyvox/action-setup-pnpm@v3 48 | with: 49 | args: --no-lockfile 50 | - run: pnpm ember build 51 | working-directory: vertical-collection 52 | - name: Run Tests 53 | uses: nick-fields/retry@v2 54 | with: 55 | timeout_minutes: 2 56 | max_attempts: 5 57 | command: cd vertical-collection && CI=true pnpm ember test --path=dist 58 | 59 | try-scenarios: 60 | name: ${{ matrix.try-scenario }} 61 | runs-on: ubuntu-latest 62 | timeout-minutes: 12 63 | 64 | strategy: 65 | fail-fast: false 66 | matrix: 67 | try-scenario: 68 | - ember-lts-3.28 69 | - ember-lts-4.12 70 | - ember-lts-5.12 71 | - ember-6.1 72 | - ember-release 73 | - ember-beta 74 | - ember-canary 75 | - embroider-safe 76 | - embroider-optimized 77 | 78 | steps: 79 | - uses: actions/checkout@v4 80 | - uses: wyvox/action-setup-pnpm@v3 81 | - name: Ember-Try Setup 82 | run: node_modules/.bin/ember try:one ${{ matrix.try-scenario }} --skip-cleanup --- bin/stash-env.sh 83 | working-directory: vertical-collection 84 | - name: Run Build 85 | run: . bin/restore-env.sh && pnpm ember build 86 | working-directory: vertical-collection 87 | - name: Run Tests 88 | uses: nick-fields/retry@v2 89 | with: 90 | timeout_minutes: 2 91 | max_attempts: 5 92 | command: pnpm test:ci 93 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | declarations/ 3 | node_modules/ 4 | 5 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | Changelog 2 | ========= 3 | 4 | 5 | 6 | 7 | ## v4.0.2 (2022-11-17) 8 | 9 | #### :bug: Bug Fix 10 | * [#396](https://github.com/html-next/vertical-collection/pull/396) Fix build for ember-source 4.0+ ([@wagenet](https://github.com/wagenet)) 11 | 12 | #### Committers: 1 13 | - Peter Wagenet ([@wagenet](https://github.com/wagenet)) 14 | 15 | ## v4.0.1 (2022-11-07) 16 | 17 | #### :memo: Documentation 18 | * [#381](https://github.com/html-next/vertical-collection/pull/381) Fix README ([@runspired](https://github.com/runspired)) 19 | 20 | #### Committers: 4 21 | - Alex Navasardyan ([@twokul](https://github.com/twokul)) 22 | - Chris Thoburn ([@runspired](https://github.com/runspired)) 23 | - Matthew Beale ([@mixonic](https://github.com/mixonic)) 24 | - [@Atrue](https://github.com/Atrue) 25 | 26 | 27 | ## v4.0.0 (2022-09-12) 28 | 29 | * Drops support for Ember < 3.12-LTS. 30 | * Drops support for Ember CLI 2.x. https://github.com/html-next/vertical-collection/pull/379 31 | * No change in Node support. 32 | * Drop the positional param for `items` on the vertical collection component. 33 | * Drop ember-compatibility-helpers https://github.com/html-next/vertical-collection/pull/375 34 | * Refactor a bunch of debug code to DEBUG https://github.com/html-next/vertical-collection/pull/388 35 | * Adopt angle bracket invocation 36 | * Adopt native getters 37 | 38 | 39 | ## v4.0.0-beta.2 (2022-09-08) 40 | 41 | 42 | ## v4.0.0-beta.1 (2022-09-07) 43 | 44 | 45 | ## v4.0.0-beta.0 (2022-08-28) 46 | 47 | * Drop support for Ember versions prior to 3.12 48 | * Drop support for Ember CLI 2.x 49 | * Adopt native getters 50 | * Adopt angle bracket invocation 51 | * Drop positional param argument for `item` 52 | 53 | 54 | ## v3.1.0 (2022-08-04) 55 | 56 | #### :rocket: Enhancement 57 | * [#380](https://github.com/html-next/vertical-collection/pull/380) fix: enable parallel builds ([@runspired](https://github.com/runspired)) 58 | 59 | #### :bug: Bug Fix 60 | * [#380](https://github.com/html-next/vertical-collection/pull/380) fix: enable parallel builds ([@runspired](https://github.com/runspired)) 61 | * [#358](https://github.com/html-next/vertical-collection/pull/358) Delete virtual element ([@Atrue](https://github.com/Atrue)) 62 | 63 | #### Committers: 3 64 | - Chris Thoburn ([@runspired](https://github.com/runspired)) 65 | - Matthew Beale ([@mixonic](https://github.com/mixonic)) 66 | - [@Atrue](https://github.com/Atrue) 67 | 68 | ## v3.0.0 (2022-05-03) 69 | 70 | 71 | ## v3.0.0-1 (2022-03-01) 72 | 73 | 74 | ## v3.0.0-0 (2021-12-09) 75 | 76 | #### What's new 77 | 78 | * Drop support for Ember < 2.18, add support for Ember 4.0+ (#343) (3343ecc) 79 | * 4 retries on CI, 1s sleep (#352) (4a75d99) 80 | * Extend timeout for base tests, tweak retry (#351) (8fff4c7) 81 | * Remove implicit this in tests (#349) (5f58de6) 82 | 83 | 84 | ## v2.1.0 (2021-12-09) 85 | 86 | Changelog: 87 | 88 | * Upgrade ember-cli and dev deps (#348) (e8924e9) 89 | * Drop Ember global use in favor of native API (#347) (d101d8a) 90 | * Proper runloop imports (#346) (a765241) 91 | * Remove property fallback lookup (no implicit this) (#345) (506d798) 92 | * Modernize `htmlSafe` module imports / More cleanup (#344) (32e9460) 93 | * Update CI for vertical-collection v2 (#342) (5613faa) 94 | 95 | 96 | ## v2.0.1 (2021-12-07) 97 | 98 | #### :bug: Bug Fix 99 | * [#322](https://github.com/html-next/vertical-collection/pull/322) Remove comma in selector list in css ([@CubeSquared](https://github.com/CubeSquared)) 100 | 101 | #### :house: Internal 102 | * [#336](https://github.com/html-next/vertical-collection/pull/336) Add rwjblue release-it setup ([@rwwagner90](https://github.com/rwwagner90)) 103 | 104 | #### Committers: 2 105 | - Matthew Jacobs ([@CubeSquared](https://github.com/CubeSquared)) 106 | - Robert Wagner ([@rwwagner90](https://github.com/rwwagner90)) 107 | 108 | 109 | ## 0.5.5 110 | 111 | ### Pull Requests 112 | 113 | - [#128](https://github.com/runspired/smoke-and-mirrors/pull/128) **fix**: only update scroll handler length when elements array changes *by [adamjmcgrath](https://github.com/adamjmcgrath)* 114 | - [#126](https://github.com/runspired/smoke-and-mirrors/pull/126) **fix**: update height after render when the item is shown *by [adamjmcgrath](https://github.com/adamjmcgrath)* 115 | - [#136](https://github.com/runspired/smoke-and-mirrors/pull/136) New Release *by [Chris Thoburn](https://github.com/runspired)* 116 | - [#137](https://github.com/runspired/smoke-and-mirrors/pull/137) 0.5.5 *by [Chris Thoburn](https://github.com/runspired)* 117 | 118 | #### Commits 119 | 120 | - [dc04f499](https://github.com/runspired/smoke-and-mirrors/commit/dc04f49924e5b3379df4d97692e5405ad8c393a6) **feat(code-stripping)**: remove classCallChecks for perf, strip unused code from builds *by [Chris Thoburn](https://github.com/runspired)* 121 | - [bf1c02b4](https://github.com/runspired/smoke-and-mirrors/commit/bf1c02b4fa4c5d32c3bad0df9ab3a9e2086184a5) **fix(scroll-handler)**: only update scroll handler length when elements array changes *by [adamjmcgrath](https://github.com/adamjmcgrath)* 122 | - [c6399038](https://github.com/runspired/smoke-and-mirrors/commit/c6399038759d46b234f585f48b8e52a1434a1b46) **fix(vertical-item)**: update height after render when the item is shown *by [adamjmcgrath](https://github.com/adamjmcgrath)* 123 | - [441f7635](https://github.com/runspired/smoke-and-mirrors/commit/441f76359695439ce91bae21cc34309409b6e0bc) **fix(readme)**: cleanup style *by [Chris Thoburn](https://github.com/runspired)* 124 | 125 | ## 0.5.4 126 | 127 | ### Pull Requests 128 | 129 | - [#106](https://github.com/runspired/smoke-and-mirrors/pull/106) Patch Release *by [Chris Thoburn](https://github.com/runspired)* 130 | - [#107](https://github.com/runspired/smoke-and-mirrors/pull/107) fix addon dependency issue *by [Chris Thoburn](https://github.com/runspired)* 131 | 132 | ## 0.5.3 133 | 134 | ## 0.5.2 135 | 136 | ### Pull Requests 137 | 138 | - [#105](https://github.com/runspired/smoke-and-mirrors/pull/105) Fixing links to runspired blog *by [pete_the_pete](https://github.com/pete-the-pete)* 139 | - [#107](https://github.com/runspired/smoke-and-mirrors/pull/107) fix addon dependency issue *by [Chris Thoburn](https://github.com/runspired)* 140 | 141 | ## 0.5.1 142 | 143 | ### Pull Requests 144 | 145 | - [#102](https://github.com/runspired/smoke-and-mirrors/pull/102) patch log output *by [Chris Thoburn](https://github.com/runspired)* 146 | - [#104](https://github.com/runspired/smoke-and-mirrors/pull/104) Include ember-getowner-polyfill in dependencies. *by [Chris Thoburn](https://github.com/runspired)* 147 | 148 | ## 0.5.0 149 | 150 | ### Pull Requests 151 | 152 | - [#100](https://github.com/runspired/smoke-and-mirrors/pull/100) Released 0.4.7 *by [Chris Thoburn](https://github.com/runspired)* 153 | 154 | #### Commits 155 | 156 | - [fc36460c](https://github.com/runspired/smoke-and-mirrors/commit/fc36460c463da33e53c72c6374373e25b3fde996) **fix(changelog)**: updates changelog from last release *by [Chris Thoburn](https://github.com/runspired)* 157 | - [132216e5](https://github.com/runspired/smoke-and-mirrors/commit/132216e5acc0fdd0754a23323ffe97ee6018f2c9) **fix(container)**: use getOwner to resolve deprecation *by [Chris Thoburn](https://github.com/runspired)* 158 | - [e68fcb43](https://github.com/runspired/smoke-and-mirrors/commit/e68fcb43f19517c976ec0cd31b0e3dfe4ee0babe) **fix(visualizer)**: improves teardown *by [Chris Thoburn](https://github.com/runspired)* 159 | - [90138118](https://github.com/runspired/smoke-and-mirrors/commit/90138118c154144c7c29f5d8abab2a60f0d7571d) **fix(geography)**: ensures that destroy sequence completes before update sequence *by [Chris Thoburn](https://github.com/runspired)* 160 | - [a6afb468](https://github.com/runspired/smoke-and-mirrors/commit/a6afb46808a3caa7424c09687d80ed658015c995) **fix(geography)**: don't cycle if we don't have any components *by [Chris Thoburn](https://github.com/runspired)* 161 | 162 | ## 0.4.7 163 | 164 | - [c4aabc20](https://github.com/runspired/smoke-and-mirrors/commit/c4aabc209f2d42668c12b5c121a363384c7b42c4) **fix(changelog-config)**: generate changelog from the develop branch *by [Chris Thoburn](https://github.com/runspired)* 165 | - [917e2e8c](https://github.com/runspired/smoke-and-mirrors/commit/917e2e8c2886116092f38fefb8bf5da2dd70adca) **fix(vertical-collection)**: removes template logic that crept in from smart-collection *by [Chris Thoburn](https://github.com/runspired)* 166 | - [#97](https://github.com/runspired/smoke-and-mirrors/pull/97) Chore/dependencies *by [Chris Thoburn](https://github.com/runspired/chore)* 167 | - [#99](https://github.com/runspired/smoke-and-mirrors/pull/99) Fix (Firefox) *by [Chris Thoburn](https://github.com/runspired)* 168 | 169 | ## 0.4.6 170 | 171 | ## 0.0.0 172 | 173 | - Hold Your Horses, 174 | - Pack Your Parachutes, 175 | - We're Coming, 176 | - But we haven't released anything yet. 177 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How To Contribute 2 | 3 | ## Installation 4 | 5 | * `git clone html-next-vertical-collection` 6 | * `cd html-next-vertical-collection` 7 | * `pnpm install` 8 | 9 | ## Linting 10 | 11 | * `pnpm lint:hbs` 12 | * `pnpm lint:js` 13 | * `pnpm lint:js --fix` 14 | 15 | ## Running tests 16 | 17 | * `ember test` – Runs the test suite on the current Ember version 18 | * `ember test --server` – Runs the test suite in "watch mode" 19 | * `ember try:each` – Runs the test suite against multiple Ember versions 20 | 21 | ## Running the dummy application 22 | 23 | * `ember serve` 24 | * Visit the dummy application at [http://localhost:4200](http://localhost:4200). 25 | 26 | For more information on using ember-cli, visit [https://cli.emberjs.com/release/](https://cli.emberjs.com/release/). 27 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # @html-next/vertical-collection 2 | 3 | [![Greenkeeper badge](https://badges.greenkeeper.io/html-next/vertical-collection.svg)](https://greenkeeper.io/) 4 | 5 | [![Build Status](https://travis-ci.org/html-next/vertical-collection.svg)](https://travis-ci.org/html-next/vertical-collection) 6 | 7 | Infinite Scroll and Occlusion at > 60FPS 8 | 9 | `vertical-collection` is an `ember-addon` that is part of the `smoke-and-mirrors` framework. It 10 | focuses on improving initial and re-render performance in high-stress situations by providing a 11 | component for performant lists and `svelte renders` to match a core belief: 12 | **Don't render the universe, render the scene.** 13 | 14 | ## Compatibility 15 | 16 | * Ember.js v3.28.0 or above 17 | * Ember CLI v4.4 or above 18 | * Node.js v16 or above 19 | 20 | #### TL;DR svelte render: the fewer things you need to render, the faster your renders will be. 21 | 22 | Your web page is a universe, your viewport is the scene. Much like you wouldn't expect a video game to render 23 | out-of-scene content, your application should smartly cull the content it doesn't need to care about. Trimming 24 | excess content lets the browser perform both initial renders and re-renders at far higher frame-rates, as the only 25 | content it needs to focus on for layout is the content the user can see. 26 | 27 | `vertical-collection` augments your existing app, it doesn't ask you to rewrite layouts or logic in order to use it. 28 | It will try its best to allow you to keep the conventions, structures, and layouts you want. 29 | 30 | ## Installation 31 | 32 | ```bash 33 | ember install @html-next/vertical-collection 34 | ``` 35 | 36 | ## Usage 37 | 38 | ```htmlbars 39 | 53 |
  • 54 | {{item.number}} {{i}} 55 |
  • 56 |
    57 | ``` 58 | 59 | ### Actions 60 | 61 | `firstReached` - Triggered when scroll reaches the first element in the collection 62 | 63 | `lastReached`- Triggered when scroll reaches the last element in the collection 64 | 65 | `firstVisibleChanged` - Triggered when the first element in the viewport changes 66 | 67 | `lastVisibleChanged` - Triggered when the last element in the viewport changes 68 | 69 | ## Support Matrix 70 | 71 | | `vertical-collection` version | Supported Ember versions | Supported Node versions | 72 | | ----------------------------- | ------------------------ | ----------------------- | 73 | | `^v1.x.x` | `v1.12.0 - v3.8.x` | `?` | 74 | | `^v2.x.x` | `v2.8.0 - v3.26.x` | `v12 - ?` | 75 | | `^v3.x.x` | `v2.18.0+` | `v14+` | 76 | | `^v4.x.x` | `v3.12.0+` | `v14+` | 77 | 78 | ## Support, Questions, Collaboration 79 | 80 | Join the [Ember community on Discord](https://discord.gg/zT3asNS) 81 | 82 | ## Features 83 | 84 | ### Infinite Scroll (bi-directional) 85 | 86 | Infinite scroll that remains performant even for very long lists is easily achievable 87 | with the [`vertical-collection`](http://html-next.github.io/vertical-collection/#/settings). 88 | It works via a scrollable div or scrollable body. 89 | 90 | - [bi-directional scrollable div](http://html-next.github.io/vertical-collection/#/examples/infinite-scroll) 91 | - [scrollable body](http://html-next.github.io/vertical-collection/#/examples/scrollable-body) 92 | - [dynamic content sizes](http://html-next.github.io/vertical-collection/#/examples/flexible-layout) 93 | - [as a table](http://html-next.github.io/vertical-collection/#/examples/dbmon) 94 | 95 | ### Svelte Everything 96 | 97 | If it can be trimmer, vertical-collection likes to trim it. 98 | 99 | ## Status 100 | 101 | [Changelog](./CHANGELOG.md) 102 | 103 | [![Build Status](https://travis-ci.org/html-next/vertical-collection.svg)](https://travis-ci.org/html-next/vertical-collection) 104 | [![dependencies](https://david-dm.org/html-next/vertical-collection.svg)](https://david-dm.org/html-next/vertical-collection) 105 | [![devDependency Status](https://david-dm.org/html-next/vertical-collection/dev-status.svg)](https://david-dm.org/html-next/vertical-collection#info=devDependencies) 106 | [![Coverage Status](https://coveralls.io/repos/html-next/vertical-collection/badge.svg?branch=master&service=github)](https://coveralls.io/github/html-next/vertical-collection?branch=master) 107 | 108 | ## Documentation 109 | 110 | For updated documentation and demos see [http://html-next.github.io/vertical-collection/](http://html-next.github.io/vertical-collection/) 111 | 112 | ## Contributing 113 | 114 | - Open an Issue for discussion first if you're unsure a feature/fix is wanted. 115 | - Branch off of `master` (default branch) 116 | - Use descriptive branch names (e.g. `/`) 117 | - PR against `master` (default branch). 118 | 119 | ### Testing 120 | 121 | Make sure you register the test waiter from [ember-raf-scheduler](https://github.com/html-next/ember-raf-scheduler). So `ember-test-helpers`'s `wait` is aware of the scheduled updates. 122 | 123 | An example can be found [here](https://github.com/html-next/vertical-collection/blob/master/tests/test-helper.js#L2) 124 | 125 | ## License 126 | 127 | This project is licensed under the [MIT License](LICENSE.md). 128 | -------------------------------------------------------------------------------- /RELEASE.md: -------------------------------------------------------------------------------- 1 | # Release Process 2 | 3 | Releases are mostly automated using 4 | [release-it](https://github.com/release-it/release-it/) and 5 | [lerna-changelog](https://github.com/lerna/lerna-changelog/). 6 | 7 | ## Preparation 8 | 9 | Since the majority of the actual release process is automated, the primary 10 | remaining task prior to releasing is confirming that all pull requests that 11 | have been merged since the last release have been labeled with the appropriate 12 | `lerna-changelog` labels and the titles have been updated to ensure they 13 | represent something that would make sense to our users. Some great information 14 | on why this is important can be found at 15 | [keepachangelog.com](https://keepachangelog.com/en/1.0.0/), but the overall 16 | guiding principle here is that changelogs are for humans, not machines. 17 | 18 | When reviewing merged PR's the labels to be used are: 19 | 20 | * breaking - Used when the PR is considered a breaking change. 21 | * enhancement - Used when the PR adds a new feature or enhancement. 22 | * bug - Used when the PR fixes a bug included in a previous release. 23 | * documentation - Used when the PR adds or updates documentation. 24 | * internal - Used for internal changes that still require a mention in the 25 | changelog/release notes. 26 | 27 | ## Release 28 | 29 | Once the prep work is completed, the actual release is straight forward: 30 | 31 | * First, ensure that you have installed your projects dependencies: 32 | 33 | ```sh 34 | pnpm install 35 | ``` 36 | 37 | * Second, ensure that you have obtained a 38 | [GitHub personal access token][generate-token] with the `repo` scope (no 39 | other permissions are needed). Make sure the token is available as the 40 | `GITHUB_AUTH` environment variable. 41 | 42 | For instance: 43 | 44 | ```bash 45 | export GITHUB_AUTH=abc123def456 46 | ``` 47 | 48 | [generate-token]: https://github.com/settings/tokens/new?scopes=repo&description=GITHUB_AUTH+env+variable 49 | 50 | * And last (but not least 😁) do your release. 51 | 52 | ```sh 53 | npx release-it 54 | ``` 55 | 56 | [release-it](https://github.com/release-it/release-it/) manages the actual 57 | release process. It will prompt you to to choose the version number after which 58 | you will have the chance to hand tweak the changelog to be used (for the 59 | `CHANGELOG.md` and GitHub release), then `release-it` continues on to tagging, 60 | pushing the tag and commits, etc. 61 | 62 | To start a prerelease branch for a new major use: 63 | 64 | ```sh 65 | npx release-it major --preRelease=beta 66 | ``` 67 | 68 | On subsequent prerelease run: 69 | 70 | ```sh 71 | npx release-it --preRelease 72 | ``` 73 | 74 | For more guidance see https://github.com/release-it/release-it/blob/master/docs/pre-releases.md 75 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "root", 3 | "version": "0.0.0", 4 | "private": true, 5 | "packageManager": "pnpm@10.10.0", 6 | "scripts": { 7 | "build": "pnpm --filter '*' build", 8 | "lint": "pnpm --filter '*' lint", 9 | "lint:fix": "pnpm --filter '*' lint:fix", 10 | "test:ci": "ls -la ; pnpm --filter '*' test:ci" 11 | }, 12 | "volta": { 13 | "node": "18.20.5", 14 | "pnpm": "10.10.0" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - vertical-collection 3 | -------------------------------------------------------------------------------- /vertical-collection/.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps developers define and maintain consistent 2 | # coding styles between different editors and IDEs 3 | # editorconfig.org 4 | 5 | root = true 6 | 7 | [*] 8 | end_of_line = lf 9 | charset = utf-8 10 | trim_trailing_whitespace = true 11 | insert_final_newline = true 12 | indent_style = space 13 | indent_size = 2 14 | 15 | [*.hbs] 16 | insert_final_newline = false 17 | 18 | [*.{diff,md}] 19 | trim_trailing_whitespace = false 20 | -------------------------------------------------------------------------------- /vertical-collection/.ember-cli: -------------------------------------------------------------------------------- 1 | { 2 | /** 3 | Ember CLI sends analytics information by default. The data is completely 4 | anonymous, but there are times when you might want to disable this behavior. 5 | 6 | Setting `disableAnalytics` to true will prevent any data from being sent. 7 | */ 8 | "disableAnalytics": false, 9 | 10 | /** 11 | Setting `isTypeScriptProject` to true will force the blueprint generators to generate TypeScript 12 | rather than JavaScript by default, when a TypeScript version of a given blueprint is available. 13 | */ 14 | "isTypeScriptProject": false 15 | } 16 | -------------------------------------------------------------------------------- /vertical-collection/.eslintignore: -------------------------------------------------------------------------------- 1 | # unconventional js 2 | /blueprints/*/files/ 3 | 4 | # compiled output 5 | /dist/ 6 | 7 | # misc 8 | /coverage/ 9 | !.* 10 | .*/ 11 | 12 | # ember-try 13 | /.node_modules.ember-try/ 14 | 15 | .eslintrc.js 16 | -------------------------------------------------------------------------------- /vertical-collection/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: '@babel/eslint-parser', 4 | parserOptions: { 5 | ecmaVersion: 'latest', 6 | sourceType: 'module', 7 | requireConfigFile: false, 8 | babelOptions: { 9 | plugins: [ 10 | ['@babel/plugin-proposal-decorators', { decoratorsBeforeExport: true }], 11 | ], 12 | }, 13 | }, 14 | plugins: [ 15 | 'ember' 16 | ], 17 | extends: [ 18 | 'eslint:recommended', 19 | 'plugin:ember/recommended' 20 | ], 21 | env: { 22 | browser: true 23 | }, 24 | globals: { 25 | ArrayBuffer: true, 26 | Float32Array: true 27 | }, 28 | rules: { 29 | 'quotes': ['error', 'single', { 'allowTemplateLiterals': true, 'avoidEscape': true }], 30 | 31 | 'ember/closure-actions': 'off', 32 | 'ember/no-get': 'off', 33 | 'ember/no-classic-components': 'off', 34 | 'ember/require-tagless-components': 'off', 35 | 'ember/no-classic-classes': 'off', 36 | 'ember/no-actions-hash': 'off', 37 | }, 38 | overrides: [ 39 | // node files 40 | { 41 | files: [ 42 | './.eslintrc.js', 43 | './.prettierrc.js', 44 | './.stylelintrc.js', 45 | './.template-lintrc.js', 46 | './ember-cli-build.js', 47 | './index.js', 48 | './testem.js', 49 | './blueprints/*/index.js', 50 | './config/**/*.js', 51 | './tests/dummy/config/**/*.js', 52 | ], 53 | parserOptions: { 54 | sourceType: 'script' 55 | }, 56 | env: { 57 | browser: false, 58 | node: true 59 | }, 60 | extends: ['plugin:n/recommended'], 61 | }, 62 | { 63 | // test files 64 | files: ['tests/**/*-test.{js,ts}'], 65 | extends: ['plugin:qunit/recommended'], 66 | }, 67 | ], 68 | }; 69 | -------------------------------------------------------------------------------- /vertical-collection/.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist/ 3 | /declarations/ 4 | 5 | # dependencies 6 | /node_modules/ 7 | 8 | # misc 9 | /.env* 10 | /.pnp* 11 | /.eslintcache 12 | /coverage/ 13 | /npm-debug.log* 14 | /testem.log 15 | /yarn-error.log 16 | 17 | # ember-try 18 | /.node_modules.ember-try/ 19 | /npm-shrinkwrap.json.ember-try 20 | /package.json.ember-try 21 | /package-lock.json.ember-try 22 | /yarn.lock.ember-try 23 | 24 | # broccoli-debug 25 | /DEBUG/ 26 | 27 | # custom 28 | .idea/ 29 | .DS_Store 30 | tests/dummy/app/snippets.js 31 | -------------------------------------------------------------------------------- /vertical-collection/.npmignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist/ 3 | /tmp/ 4 | 5 | # misc 6 | /.editorconfig 7 | /.ember-cli 8 | /.env* 9 | /.eslintcache 10 | /.eslintignore 11 | /.eslintrc.js 12 | /.git/ 13 | /.github/ 14 | /.gitignore 15 | /.prettierignore 16 | /.prettierrc.js 17 | /.stylelintignore 18 | /.stylelintrc.js 19 | /.template-lintrc.js 20 | /.travis.yml 21 | /.watchmanconfig 22 | /CONTRIBUTING.md 23 | /ember-cli-build.js 24 | /testem.js 25 | /tests/ 26 | /yarn-error.log 27 | /yarn.lock 28 | .gitkeep 29 | 30 | # ember-try 31 | /.node_modules.ember-try/ 32 | /npm-shrinkwrap.json.ember-try 33 | /package.json.ember-try 34 | /package-lock.json.ember-try 35 | /yarn.lock.ember-try 36 | 37 | # custom 38 | .idea/ 39 | -------------------------------------------------------------------------------- /vertical-collection/.prettierignore: -------------------------------------------------------------------------------- 1 | # unconventional js 2 | /blueprints/*/files/ 3 | 4 | # compiled output 5 | /dist/ 6 | 7 | # misc 8 | /coverage/ 9 | !.* 10 | .*/ 11 | 12 | # ember-try 13 | /.node_modules.ember-try/ 14 | -------------------------------------------------------------------------------- /vertical-collection/.prettierrc.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | overrides: [ 5 | { 6 | files: '*.{js,ts}', 7 | options: { 8 | singleQuote: true, 9 | }, 10 | }, 11 | ], 12 | }; 13 | -------------------------------------------------------------------------------- /vertical-collection/.stylelintignore: -------------------------------------------------------------------------------- 1 | # unconventional files 2 | /blueprints/*/files/ 3 | 4 | # compiled output 5 | /dist/ 6 | 7 | # addons 8 | /.node_modules.ember-try/ 9 | -------------------------------------------------------------------------------- /vertical-collection/.stylelintrc.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | extends: ['stylelint-config-standard', 'stylelint-prettier/recommended'], 5 | rules: { 6 | 'selector-class-pattern': null, 7 | } 8 | }; 9 | -------------------------------------------------------------------------------- /vertical-collection/.template-lintrc.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | extends: 'recommended', 5 | rules: { 6 | 'no-attrs-in-components': false, 7 | 'no-inline-styles': false, 8 | 'no-triple-curlies': false, 9 | 'no-unbound': false, 10 | 'no-unnecessary-concat': false 11 | } 12 | }; 13 | -------------------------------------------------------------------------------- /vertical-collection/.watchmanconfig: -------------------------------------------------------------------------------- 1 | { 2 | "ignore_dirs": ["dist"] 3 | } 4 | -------------------------------------------------------------------------------- /vertical-collection/addon/-private/data-view/elements/occluded-content.js: -------------------------------------------------------------------------------- 1 | import { set } from '@ember/object'; 2 | import { DEBUG } from '@glimmer/env'; 3 | 4 | import document from '../../utils/document-shim'; 5 | 6 | let OC_IDENTITY = 0; 7 | 8 | export default class OccludedContent { 9 | constructor(tagName) { 10 | this.id = `OC-${OC_IDENTITY++}`; 11 | this.isOccludedContent = true; 12 | 13 | // We check to see if the document exists in Fastboot. Since RAF won't run in 14 | // Fastboot, we'll never have to use these text nodes for measurements, so they 15 | // can be empty 16 | if (document !== undefined) { 17 | this.element = document.createElement(tagName); 18 | this.element.className += 'occluded-content'; 19 | 20 | this.upperBound = document.createTextNode(''); 21 | this.lowerBound = document.createTextNode(''); 22 | } else { 23 | this.element = null; 24 | } 25 | 26 | this.isOccludedContent = true; 27 | this.rendered = false; 28 | 29 | if (DEBUG) { 30 | Object.preventExtensions(this); 31 | } 32 | } 33 | 34 | getBoundingClientRect() { 35 | if (this.element !== null) { 36 | return this.element.getBoundingClientRect(); 37 | } 38 | } 39 | 40 | addEventListener(event, listener) { 41 | if (this.element !== null) { 42 | this.element.addEventListener(event, listener); 43 | } 44 | } 45 | 46 | removeEventListener(event, listener) { 47 | if (this.element !== null) { 48 | this.element.removeEventListener(event, listener); 49 | } 50 | } 51 | 52 | get realUpperBound() { 53 | return this.upperBound; 54 | } 55 | 56 | get realLowerBound() { 57 | return this.lowerBound; 58 | } 59 | 60 | get parentNode() { 61 | return this.element !== null ? this.element.parentNode : null; 62 | } 63 | 64 | get style() { 65 | return this.element !== null ? this.element.style : {}; 66 | } 67 | 68 | set innerHTML(value) { 69 | if (this.element !== null) { 70 | this.element.innerHTML = value; 71 | } 72 | } 73 | 74 | destroy() { 75 | set(this, 'element', null); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /vertical-collection/addon/-private/data-view/elements/viewport-container.js: -------------------------------------------------------------------------------- 1 | /* 2 | * There are significant differences between browsers 3 | * in how they implement "scroll" on document.body 4 | * 5 | * The only cross-browser listener for scroll on body 6 | * is to listen on window with capture. 7 | * 8 | * They also implement different standards for how to 9 | * access the scroll position. 10 | * 11 | * This singleton class provides a cross-browser way 12 | * to access and set the scrollTop and scrollLeft properties. 13 | * 14 | */ 15 | export function ViewportContainer() { 16 | 17 | // A bug occurs in Chrome when we reload the browser at a lower 18 | // scrollTop, window.scrollY becomes stuck on a single value. 19 | Object.defineProperty(this, 'scrollTop', { 20 | get() { 21 | return document.body.scrollTop 22 | || document.documentElement.scrollTop; 23 | }, 24 | set(v) { 25 | document.body.scrollTop = document.documentElement.scrollTop = v; 26 | } 27 | }); 28 | 29 | Object.defineProperty(this, 'scrollLeft', { 30 | get() { 31 | return window.scrollX 32 | || window.pageXOffset 33 | || document.body.scrollLeft 34 | || document.documentElement.scrollLeft; 35 | }, 36 | set(v) { 37 | window.scrollX 38 | = window.pageXOffset 39 | = document.body.scrollLeft 40 | = document.documentElement.scrollLeft = v; 41 | } 42 | }); 43 | 44 | Object.defineProperty(this, 'offsetHeight', { 45 | get() { 46 | return window.innerHeight; 47 | } 48 | }); 49 | } 50 | 51 | ViewportContainer.prototype.addEventListener = function addEventListener(event, handler, options) { 52 | return window.addEventListener(event, handler, options); 53 | }; 54 | 55 | ViewportContainer.prototype.removeEventListener = function addEventListener(event, handler, options) { 56 | return window.removeEventListener(event, handler, options); 57 | }; 58 | 59 | ViewportContainer.prototype.getBoundingClientRect = function getBoundingClientRect() { 60 | return { 61 | height: window.innerHeight, 62 | width: window.innerWidth, 63 | top: 0, 64 | left: 0, 65 | right: window.innerWidth, 66 | bottom: window.innerHeight 67 | }; 68 | }; 69 | 70 | export default new ViewportContainer(); 71 | -------------------------------------------------------------------------------- /vertical-collection/addon/-private/data-view/elements/virtual-component.js: -------------------------------------------------------------------------------- 1 | import { set } from '@ember/object'; 2 | import { assert } from '@ember/debug'; 3 | import { DEBUG } from '@glimmer/env'; 4 | 5 | import document from '../../utils/document-shim'; 6 | 7 | let VC_IDENTITY = 0; 8 | 9 | export default class VirtualComponent { 10 | constructor(content = null, index = null) { 11 | this.id = `VC-${VC_IDENTITY++}`; 12 | 13 | this.content = content; 14 | this.index = index; 15 | 16 | // We check to see if the document exists in Fastboot. Since RAF won't run in 17 | // Fastboot, we'll never have to use these text nodes for measurements, so they 18 | // can be empty 19 | this.upperBound = document !== undefined ? document.createTextNode('') : null; 20 | this.lowerBound = document !== undefined ? document.createTextNode('') : null; 21 | 22 | this.rendered = false; 23 | 24 | if (DEBUG) { 25 | Object.preventExtensions(this); 26 | } 27 | } 28 | 29 | get realUpperBound() { 30 | return this.upperBound; 31 | } 32 | 33 | get realLowerBound() { 34 | return this.lowerBound; 35 | } 36 | 37 | getBoundingClientRect() { 38 | let { upperBound, lowerBound } = this; 39 | 40 | let top = Infinity; 41 | let bottom = -Infinity; 42 | 43 | while (upperBound !== lowerBound) { 44 | upperBound = upperBound.nextSibling; 45 | 46 | if (upperBound instanceof Element) { 47 | top = Math.min(top, upperBound.getBoundingClientRect().top); 48 | bottom = Math.max(bottom, upperBound.getBoundingClientRect().bottom); 49 | } 50 | 51 | if (DEBUG) { 52 | if (upperBound instanceof Element) { 53 | continue; 54 | } 55 | 56 | const text = upperBound.textContent; 57 | 58 | assert(`All content inside of vertical-collection must be wrapped in an element. Detected a text node with content: ${text}`, text === '' || text.match(/^\s+$/)); 59 | } 60 | } 61 | 62 | assert('Items in a vertical collection require atleast one element in them', top !== Infinity && bottom !== -Infinity); 63 | 64 | const height = bottom - top; 65 | 66 | return { top, bottom, height }; 67 | } 68 | 69 | recycle(newContent, newIndex) { 70 | assert(`You cannot set an item's content to undefined`, newContent); 71 | 72 | if (this.index !== newIndex) { 73 | set(this, 'index', newIndex); 74 | } 75 | 76 | if (this.content !== newContent) { 77 | set(this, 'content', newContent); 78 | } 79 | } 80 | 81 | destroy() { 82 | set(this, 'upperBound', null); 83 | set(this, 'lowerBound', null); 84 | set(this, 'content', null); 85 | set(this, 'index', null); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /vertical-collection/addon/-private/data-view/radar/dynamic-radar.js: -------------------------------------------------------------------------------- 1 | import { DEBUG } from '@glimmer/env'; 2 | 3 | import Radar from './radar'; 4 | import SkipList from '../skip-list'; 5 | import roundTo from '../utils/round-to'; 6 | import getScaledClientRect from '../../utils/element/get-scaled-client-rect'; 7 | 8 | export default class DynamicRadar extends Radar { 9 | constructor(parentToken, options) { 10 | super(parentToken, options); 11 | 12 | this._firstItemIndex = 0; 13 | this._lastItemIndex = 0; 14 | 15 | this._totalBefore = 0; 16 | this._totalAfter = 0; 17 | 18 | this._minHeight = Infinity; 19 | 20 | this._nextIncrementalRender = null; 21 | 22 | this.skipList = null; 23 | 24 | if (DEBUG) { 25 | Object.preventExtensions(this); 26 | } 27 | } 28 | 29 | willDestroy() { 30 | super.willDestroy(); 31 | this.skipList = null; 32 | } 33 | 34 | scheduleUpdate(didUpdateItems, promiseResolve) { 35 | // Cancel incremental render check, since we'll be remeasuring anyways 36 | if (this._nextIncrementalRender !== null) { 37 | this._nextIncrementalRender.cancel(); 38 | this._nextIncrementalRender = null; 39 | } 40 | 41 | super.scheduleUpdate(didUpdateItems, promiseResolve); 42 | } 43 | 44 | afterUpdate() { 45 | // Schedule a check to see if we should rerender 46 | if (this._nextIncrementalRender === null && this._nextUpdate === null) { 47 | this._nextIncrementalRender = this.schedule('sync', () => { 48 | this._nextIncrementalRender = null; 49 | 50 | if (this._shouldScheduleRerender()) { 51 | this.update(); 52 | } 53 | }); 54 | } 55 | 56 | super.afterUpdate(); 57 | } 58 | 59 | _updateConstants() { 60 | super._updateConstants(); 61 | 62 | if (this._calculatedEstimateHeight < this._minHeight) { 63 | this._minHeight = this._calculatedEstimateHeight; 64 | } 65 | 66 | // Create the SkipList only after the estimateHeight has been calculated the first time 67 | if (this.skipList === null) { 68 | this.skipList = new SkipList(this.totalItems, this._calculatedEstimateHeight); 69 | } else { 70 | this.skipList.defaultValue = this._calculatedEstimateHeight; 71 | } 72 | } 73 | 74 | _updateIndexes() { 75 | const { 76 | bufferSize, 77 | skipList, 78 | visibleTop, 79 | visibleBottom, 80 | totalItems, 81 | 82 | _didReset 83 | } = this; 84 | 85 | if (totalItems === 0) { 86 | this._firstItemIndex = 0; 87 | this._lastItemIndex = -1; 88 | this._totalBefore = 0; 89 | this._totalAfter = 0; 90 | 91 | return; 92 | } 93 | 94 | // Don't measure if the radar has just been instantiated or reset, as we are rendering with a 95 | // completely new set of items and won't get an accurate measurement until after they render the 96 | // first time. 97 | if (_didReset === false) { 98 | this._measure(); 99 | } 100 | 101 | const { values } = skipList; 102 | 103 | let { totalBefore, index: firstVisibleIndex } = this.skipList.find(visibleTop); 104 | let { totalAfter, index: lastVisibleIndex } = this.skipList.find(visibleBottom); 105 | 106 | const maxIndex = totalItems - 1; 107 | 108 | let firstItemIndex = firstVisibleIndex; 109 | let lastItemIndex = lastVisibleIndex; 110 | 111 | // Add buffers 112 | for (let i = bufferSize; i > 0 && firstItemIndex > 0; i--) { 113 | firstItemIndex--; 114 | totalBefore -= values[firstItemIndex]; 115 | } 116 | 117 | for (let i = bufferSize; i > 0 && lastItemIndex < maxIndex; i--) { 118 | lastItemIndex++; 119 | totalAfter -= values[lastItemIndex]; 120 | } 121 | 122 | this._firstItemIndex = firstItemIndex; 123 | this._lastItemIndex = lastItemIndex; 124 | this._totalBefore = totalBefore; 125 | this._totalAfter = totalAfter; 126 | } 127 | 128 | _calculateScrollDiff() { 129 | const { 130 | firstItemIndex, 131 | _prevFirstVisibleIndex, 132 | _prevFirstItemIndex 133 | } = this; 134 | 135 | let beforeVisibleDiff = 0; 136 | 137 | if (firstItemIndex < _prevFirstItemIndex) { 138 | // Measurement only items that could affect scrollTop. This will necesarilly be the 139 | // minimum of the either the total number of items that are rendered up to the first 140 | // visible item, OR the number of items that changed before the first visible item 141 | // (the delta). We want to measure the delta of exactly this number of items, because 142 | // items that are after the first visible item should not affect the scroll position, 143 | // and neither should items already rendered before the first visible item. 144 | const measureLimit = Math.min(Math.abs(firstItemIndex - _prevFirstItemIndex), _prevFirstVisibleIndex - firstItemIndex); 145 | 146 | beforeVisibleDiff = Math.round(this._measure(measureLimit)); 147 | } 148 | 149 | return beforeVisibleDiff + super._calculateScrollDiff(); 150 | } 151 | 152 | _shouldScheduleRerender() { 153 | const { 154 | firstItemIndex, 155 | lastItemIndex 156 | } = this; 157 | 158 | this._updateConstants(); 159 | this._measure(); 160 | 161 | // These indexes could change after the measurement, and in the incremental render 162 | // case we want to check them _after_ the change. 163 | const { firstVisibleIndex, lastVisibleIndex } = this; 164 | 165 | return firstVisibleIndex < firstItemIndex || lastVisibleIndex > lastItemIndex; 166 | } 167 | 168 | _measure(measureLimit = null) { 169 | const { 170 | orderedComponents, 171 | skipList, 172 | 173 | _occludedContentBefore, 174 | _transformScale 175 | } = this; 176 | 177 | const numToMeasure = measureLimit !== null 178 | ? Math.min(measureLimit, orderedComponents.length) 179 | : orderedComponents.length; 180 | 181 | let totalDelta = 0; 182 | 183 | for (let i = 0; i < numToMeasure; i++) { 184 | const currentItem = orderedComponents[i]; 185 | const previousItem = orderedComponents[i - 1]; 186 | const itemIndex = currentItem.index; 187 | 188 | const { 189 | top: currentItemTop, 190 | height: currentItemHeight 191 | } = getScaledClientRect(currentItem, _transformScale); 192 | 193 | let margin; 194 | 195 | if (previousItem !== undefined) { 196 | margin = currentItemTop - getScaledClientRect(previousItem, _transformScale).bottom; 197 | } else { 198 | margin = currentItemTop - getScaledClientRect(_occludedContentBefore, _transformScale).bottom; 199 | } 200 | 201 | const newHeight = roundTo(currentItemHeight + margin); 202 | const itemDelta = skipList.set(itemIndex, newHeight); 203 | 204 | if (newHeight < this._minHeight) { 205 | this._minHeight = newHeight; 206 | } 207 | 208 | if (itemDelta !== 0) { 209 | totalDelta += itemDelta; 210 | } 211 | } 212 | 213 | return totalDelta; 214 | } 215 | 216 | _didEarthquake(scrollDiff) { 217 | return scrollDiff > (this._minHeight / 2); 218 | } 219 | 220 | get total() { 221 | return this.skipList.total; 222 | } 223 | 224 | get totalBefore() { 225 | return this._totalBefore; 226 | } 227 | 228 | get totalAfter() { 229 | return this._totalAfter; 230 | } 231 | 232 | get firstItemIndex() { 233 | return this._firstItemIndex; 234 | } 235 | 236 | get lastItemIndex() { 237 | return this._lastItemIndex; 238 | } 239 | 240 | get firstVisibleIndex() { 241 | const { 242 | visibleTop 243 | } = this; 244 | 245 | const { index } = this.skipList.find(visibleTop); 246 | 247 | return index; 248 | } 249 | 250 | get lastVisibleIndex() { 251 | const { 252 | visibleBottom, 253 | totalItems 254 | } = this; 255 | 256 | const { index } = this.skipList.find(visibleBottom); 257 | 258 | return Math.min(index, totalItems - 1); 259 | } 260 | 261 | prepend(numPrepended) { 262 | super.prepend(numPrepended); 263 | 264 | this.skipList.prepend(numPrepended); 265 | } 266 | 267 | append(numAppended) { 268 | super.append(numAppended); 269 | 270 | this.skipList.append(numAppended); 271 | } 272 | 273 | reset() { 274 | super.reset(); 275 | 276 | this.skipList.reset(this.totalItems); 277 | } 278 | 279 | /* 280 | * Public API to query the skiplist for the offset of an item 281 | */ 282 | getOffsetForIndex(index) { 283 | this._measure(); 284 | 285 | return this.skipList.getOffset(index); 286 | } 287 | } 288 | -------------------------------------------------------------------------------- /vertical-collection/addon/-private/data-view/radar/static-radar.js: -------------------------------------------------------------------------------- 1 | import { DEBUG } from '@glimmer/env'; 2 | 3 | import Radar from './radar'; 4 | 5 | export default class StaticRadar extends Radar { 6 | constructor(parentToken, options) { 7 | super(parentToken, options); 8 | 9 | this._firstItemIndex = 0; 10 | this._lastItemIndex = 0; 11 | 12 | if (DEBUG) { 13 | Object.preventExtensions(this); 14 | } 15 | } 16 | 17 | _updateIndexes() { 18 | const { 19 | bufferSize, 20 | totalItems, 21 | visibleMiddle, 22 | _calculatedEstimateHeight, 23 | _calculatedScrollContainerHeight 24 | } = this; 25 | 26 | if (totalItems === 0) { 27 | this._firstItemIndex = 0; 28 | this._lastItemIndex = -1; 29 | 30 | return; 31 | } 32 | 33 | const maxIndex = totalItems - 1; 34 | 35 | const middleItemIndex = Math.floor(visibleMiddle / _calculatedEstimateHeight); 36 | 37 | const shouldRenderCount = Math.min(Math.ceil(_calculatedScrollContainerHeight / _calculatedEstimateHeight), totalItems); 38 | 39 | let firstItemIndex = middleItemIndex - Math.floor(shouldRenderCount / 2); 40 | let lastItemIndex = middleItemIndex + Math.ceil(shouldRenderCount / 2) - 1; 41 | 42 | if (firstItemIndex < 0) { 43 | firstItemIndex = 0; 44 | lastItemIndex = shouldRenderCount - 1; 45 | } 46 | 47 | if (lastItemIndex > maxIndex) { 48 | lastItemIndex = maxIndex; 49 | firstItemIndex = maxIndex - (shouldRenderCount - 1); 50 | } 51 | 52 | firstItemIndex = Math.max(firstItemIndex - bufferSize, 0); 53 | lastItemIndex = Math.min(lastItemIndex + bufferSize, maxIndex); 54 | 55 | this._firstItemIndex = firstItemIndex; 56 | this._lastItemIndex = lastItemIndex; 57 | } 58 | 59 | _didEarthquake(scrollDiff) { 60 | return scrollDiff > (this._calculatedEstimateHeight / 2); 61 | } 62 | 63 | get total() { 64 | return this.totalItems * this._calculatedEstimateHeight; 65 | } 66 | 67 | get totalBefore() { 68 | return this.firstItemIndex * this._calculatedEstimateHeight; 69 | } 70 | 71 | get totalAfter() { 72 | return this.total - ((this.lastItemIndex + 1) * this._calculatedEstimateHeight); 73 | } 74 | 75 | get firstItemIndex() { 76 | return this._firstItemIndex; 77 | } 78 | 79 | get lastItemIndex() { 80 | return this._lastItemIndex; 81 | } 82 | 83 | get firstVisibleIndex() { 84 | return Math.ceil(this.visibleTop / this._calculatedEstimateHeight); 85 | } 86 | 87 | get lastVisibleIndex() { 88 | return Math.min(Math.ceil(this.visibleBottom / this._calculatedEstimateHeight), this.totalItems) - 1; 89 | } 90 | 91 | /* 92 | * Public API to query for the offset of an item 93 | */ 94 | getOffsetForIndex(index) { 95 | return index * this._calculatedEstimateHeight + 1; 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /vertical-collection/addon/-private/data-view/skip-list.js: -------------------------------------------------------------------------------- 1 | import { assert } from '@ember/debug'; 2 | import { DEBUG } from '@glimmer/env'; 3 | 4 | import roundTo from './utils/round-to'; 5 | 6 | /* 7 | * `SkipList` is a data structure designed with two main uses in mind: 8 | * 9 | * - Given a target value, find the index i in the list such that 10 | * `sum(list[0]..list[i]) <= value < sum(list[0]..list[i + 1])` 11 | * 12 | * - Given the index i (the fulcrum point) from above, get `sum(list[0]..list[i])` 13 | * and `sum(list[i + 1]..list[-1])` 14 | * 15 | * The idea is that given a list of arbitrary heights or widths in pixels, we want to find 16 | * the index of the item such that when all of the items before it are added together, it will 17 | * be as close to the target (scrollTop of our container) as possible. 18 | * 19 | * This data structure acts somewhat like a Binary Search Tree. Given a list of size n, the 20 | * retreival time for the index is O(log n) and the update time should any values change is 21 | * O(log n). The space complexity is O(n log n) in bytes (using Float32Arrays helps a lot 22 | * here), and the initialization time is O(n log n). 23 | * 24 | * It works by constructing layer arrays, each of which is setup such that 25 | * `layer[i] = prevLayer[i * 2] + prevLayer[(i * 2) + 1]`. This allows us to traverse the layers 26 | * downward using a binary search to arrive at the index we want. We also add the values up as we 27 | * traverse to get the total value before and after the final index. 28 | */ 29 | 30 | function fill(array, value, start = 0, end = array.length) { 31 | if (typeof array.fill === 'function') { 32 | array.fill(value, start, end); 33 | } else { 34 | for (; start < end; start++) { 35 | array[start] = value; 36 | } 37 | 38 | return array; 39 | } 40 | } 41 | 42 | function subarray(array, start, end) { 43 | if (typeof array.subarray === 'function') { 44 | return array.subarray(start, end); 45 | } else { 46 | return array.slice(start, end); 47 | } 48 | } 49 | 50 | export default class SkipList { 51 | constructor(length, defaultValue) { 52 | const values = new Float32Array(new ArrayBuffer(length * 4)); 53 | fill(values, defaultValue); 54 | 55 | this.length = length; 56 | this.defaultValue = defaultValue; 57 | 58 | this._initializeLayers(values, defaultValue); 59 | 60 | if (DEBUG) { 61 | Object.preventExtensions(this); 62 | } 63 | } 64 | 65 | _initializeLayers(values, defaultValue) { 66 | const layers = [values]; 67 | let i, length, layer, prevLayer, left, right; 68 | 69 | prevLayer = layer = values; 70 | length = values.length; 71 | 72 | while (length > 2) { 73 | length = Math.ceil(length / 2); 74 | 75 | layer = new Float32Array(new ArrayBuffer(length * 4)); 76 | 77 | if (defaultValue !== undefined) { 78 | // If given a default value we assume that we can fill each 79 | // layer of the skip list with the previous layer's value * 2. 80 | // This allows us to use the `fill` method on Typed arrays, which 81 | // an order of magnitude faster than manually calculating each value. 82 | defaultValue = defaultValue * 2; 83 | fill(layer, defaultValue); 84 | 85 | left = prevLayer[(length - 1) * 2] || 0; 86 | right = prevLayer[((length - 1) * 2) + 1] || 0; 87 | 88 | // Layers are not powers of 2, and sometimes they may by odd sizes. 89 | // Only the last value of a layer will be different, so we calculate 90 | // its value manually. 91 | layer[length - 1] = left + right; 92 | } else { 93 | for (i = 0; i < length; i++) { 94 | left = prevLayer[i * 2]; 95 | right = prevLayer[(i * 2) + 1]; 96 | layer[i] = right ? left + right : left; 97 | } 98 | } 99 | 100 | layers.unshift(layer); 101 | prevLayer = layer; 102 | } 103 | 104 | this.total = layer.length > 0 ? layer.length > 1 ? layer[0] + layer[1] : layer[0] : 0; 105 | 106 | assert('total must be a number', typeof this.total === 'number'); 107 | 108 | this.layers = layers; 109 | this.values = values; 110 | } 111 | 112 | find(targetValue) { 113 | const { layers, total, length, values } = this; 114 | const numLayers = layers.length; 115 | 116 | if (length === 0) { 117 | return { index: 0, totalBefore: 0, totalAfter: 0 }; 118 | } 119 | 120 | let i, layer, left, leftIndex, rightIndex; 121 | let index = 0; 122 | let totalBefore = 0; 123 | let totalAfter = 0; 124 | 125 | targetValue = Math.min(total - 1, targetValue); 126 | 127 | assert('targetValue must be a number', typeof targetValue === 'number'); 128 | assert('targetValue must be greater than or equal to 0', targetValue >= 0); 129 | assert('targetValue must be no more than total', targetValue < total); 130 | 131 | for (i = 0; i < numLayers; i++) { 132 | layer = layers[i]; 133 | 134 | leftIndex = index; 135 | rightIndex = index + 1; 136 | 137 | left = layer[leftIndex]; 138 | 139 | if (targetValue >= totalBefore + left) { 140 | totalBefore = totalBefore + left; 141 | index = rightIndex * 2; 142 | } else { 143 | index = leftIndex * 2; 144 | } 145 | } 146 | 147 | index = index / 2; 148 | 149 | assert('index must be a number', typeof index === 'number'); 150 | assert('index must be within bounds', index >= 0 && index < this.values.length); 151 | 152 | totalAfter = total - (totalBefore + values[index]); 153 | 154 | return { index, totalBefore, totalAfter }; 155 | } 156 | 157 | getOffset(targetIndex) { 158 | const { layers, length, values } = this; 159 | const numLayers = layers.length; 160 | 161 | if (length === 0) { 162 | return 0; 163 | } 164 | 165 | let index = 0; 166 | let offset = 0; 167 | 168 | for (let i = 0; i < numLayers - 1; i++) { 169 | const layer = layers[i]; 170 | 171 | const leftIndex = index; 172 | const rightIndex = index + 1; 173 | 174 | if (targetIndex >= rightIndex * Math.pow(2, numLayers - i - 1)) { 175 | offset = offset + layer[leftIndex]; 176 | index = rightIndex * 2; 177 | } else { 178 | index = leftIndex * 2; 179 | } 180 | } 181 | 182 | if (index + 1 === targetIndex) { 183 | offset += values[index]; 184 | } 185 | 186 | return offset; 187 | } 188 | 189 | set(index, value) { 190 | assert('value must be a number', typeof value === 'number'); 191 | assert('value must non-negative', value >= 0); 192 | assert('index must be a number', typeof index === 'number'); 193 | assert('index must be within bounds', index >= 0 && index < this.values.length); 194 | 195 | const { layers } = this; 196 | const oldValue = layers[layers.length - 1][index]; 197 | const delta = roundTo(value - oldValue); 198 | 199 | if (delta === 0) { 200 | return delta; 201 | } 202 | 203 | let i, layer; 204 | 205 | for (i = layers.length - 1; i >= 0; i--) { 206 | layer = layers[i]; 207 | 208 | layer[index] += delta; 209 | 210 | index = Math.floor(index / 2); 211 | } 212 | 213 | this.total += delta; 214 | 215 | return delta; 216 | } 217 | 218 | prepend(numPrepended) { 219 | const { 220 | values: oldValues, 221 | length: oldLength, 222 | defaultValue 223 | } = this; 224 | 225 | const newLength = numPrepended + oldLength; 226 | 227 | const newValues = new Float32Array(new ArrayBuffer(newLength * 4)); 228 | 229 | newValues.set(oldValues, numPrepended); 230 | fill(newValues, defaultValue, 0, numPrepended); 231 | 232 | this.length = newLength; 233 | this._initializeLayers(newValues); 234 | } 235 | 236 | append(numAppended) { 237 | const { 238 | values: oldValues, 239 | length: oldLength, 240 | defaultValue 241 | } = this; 242 | 243 | const newLength = numAppended + oldLength; 244 | 245 | const newValues = new Float32Array(new ArrayBuffer(newLength * 4)); 246 | 247 | newValues.set(oldValues); 248 | fill(newValues, defaultValue, oldLength); 249 | 250 | this.length = newLength; 251 | this._initializeLayers(newValues); 252 | } 253 | 254 | reset(newLength) { 255 | const { 256 | values: oldValues, 257 | length: oldLength, 258 | defaultValue 259 | } = this; 260 | 261 | if (oldLength === newLength) { 262 | return; 263 | } 264 | 265 | const newValues = new Float32Array(new ArrayBuffer(newLength * 4)); 266 | 267 | if (oldLength < newLength) { 268 | newValues.set(oldValues); 269 | fill(newValues, defaultValue, oldLength); 270 | } else { 271 | newValues.set(subarray(oldValues, 0, newLength)); 272 | } 273 | 274 | this.length = newLength; 275 | 276 | if (oldLength === 0) { 277 | this._initializeLayers(newValues, defaultValue); 278 | } else { 279 | this._initializeLayers(newValues); 280 | } 281 | } 282 | } 283 | -------------------------------------------------------------------------------- /vertical-collection/addon/-private/data-view/utils/insert-range-before.js: -------------------------------------------------------------------------------- 1 | export default function insertRangeBefore(parent, element, firstNode, lastNode) { 2 | let nextNode; 3 | 4 | while (firstNode) { 5 | nextNode = firstNode.nextSibling; 6 | parent.insertBefore(firstNode, element); 7 | 8 | if (firstNode === lastNode) { 9 | break; 10 | } 11 | 12 | firstNode = nextNode; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /vertical-collection/addon/-private/data-view/utils/mutation-checkers.js: -------------------------------------------------------------------------------- 1 | import { get } from '@ember/object'; 2 | import objectAt from './object-at'; 3 | import keyForItem from '../../ember-internals/key-for-item'; 4 | 5 | export function isPrepend(lenDiff, newItems, key, oldFirstKey, oldLastKey) { 6 | const newItemsLength = get(newItems, 'length'); 7 | 8 | if (lenDiff <= 0 || lenDiff >= newItemsLength || newItemsLength === 0) { 9 | return false; 10 | } 11 | 12 | const newFirstKey = keyForItem(objectAt(newItems, lenDiff), key, lenDiff); 13 | const newLastKey = keyForItem(objectAt(newItems, newItemsLength - 1), key, newItemsLength - 1); 14 | 15 | return oldFirstKey === newFirstKey && oldLastKey === newLastKey; 16 | } 17 | 18 | export function isAppend(lenDiff, newItems, key, oldFirstKey, oldLastKey) { 19 | const newItemsLength = get(newItems, 'length'); 20 | 21 | if (lenDiff <= 0 || lenDiff >= newItemsLength || newItemsLength === 0) { 22 | return false; 23 | } 24 | 25 | const newFirstKey = keyForItem(objectAt(newItems, 0), key, 0); 26 | const newLastKey = keyForItem(objectAt(newItems, newItemsLength - lenDiff - 1), key, newItemsLength - lenDiff - 1); 27 | 28 | return oldFirstKey === newFirstKey && oldLastKey === newLastKey; 29 | } 30 | -------------------------------------------------------------------------------- /vertical-collection/addon/-private/data-view/utils/object-at.js: -------------------------------------------------------------------------------- 1 | import { assert } from '@ember/debug'; 2 | 3 | export default function objectAt(arr, index) { 4 | assert('arr must be an instance of a Javascript Array or implement `objectAt`', Array.isArray(arr) || typeof arr.objectAt === 'function'); 5 | 6 | return arr.objectAt ? arr.objectAt(index) : arr[index]; 7 | } 8 | -------------------------------------------------------------------------------- /vertical-collection/addon/-private/data-view/utils/round-to.js: -------------------------------------------------------------------------------- 1 | export default function roundTo(number, decimal = 2) { 2 | const exp = Math.pow(10, decimal); 3 | return Math.round(number * exp) / exp; 4 | } 5 | -------------------------------------------------------------------------------- /vertical-collection/addon/-private/data-view/utils/scroll-handler.js: -------------------------------------------------------------------------------- 1 | import { begin, end } from '@ember/runloop'; 2 | import { scheduler } from 'ember-raf-scheduler'; 3 | import SUPPORTS_PASSIVE from './supports-passive'; 4 | const DEFAULT_ARRAY_SIZE = 10; 5 | const UNDEFINED_VALUE = Object.create(null); 6 | 7 | export class ScrollHandler { 8 | constructor() { 9 | this.elements = new Array(DEFAULT_ARRAY_SIZE); 10 | this.maxLength = DEFAULT_ARRAY_SIZE; 11 | this.length = 0; 12 | this.handlers = new Array(DEFAULT_ARRAY_SIZE); 13 | this.isPolling = false; 14 | this.isUsingPassive = SUPPORTS_PASSIVE; 15 | } 16 | 17 | addScrollHandler(element, handler) { 18 | let index = this.elements.indexOf(element); 19 | let handlers, cache; 20 | 21 | if (index === -1) { 22 | index = this.length++; 23 | 24 | if (index === this.maxLength) { 25 | this.maxLength *= 2; 26 | this.elements.length = this.maxLength; 27 | this.handlers.length = this.maxLength; 28 | } 29 | 30 | handlers = [handler]; 31 | 32 | this.elements[index] = element; 33 | cache = this.handlers[index] = { 34 | top: element.scrollTop, 35 | left: element.scrollLeft, 36 | handlers 37 | }; 38 | // TODO add explicit test 39 | if (SUPPORTS_PASSIVE) { 40 | cache.passiveHandler = function() { 41 | ScrollHandler.triggerElementHandlers(element, cache); 42 | }; 43 | } else { 44 | cache.passiveHandler = UNDEFINED_VALUE; 45 | } 46 | } else { 47 | cache = this.handlers[index]; 48 | handlers = cache.handlers; 49 | handlers.push(handler); 50 | } 51 | 52 | // TODO add explicit test 53 | if (this.isUsingPassive && handlers.length === 1) { 54 | element.addEventListener('scroll', cache.passiveHandler, { capture: true, passive: true }); 55 | 56 | // TODO add explicit test 57 | } else if (!this.isPolling) { 58 | this.poll(); 59 | } 60 | } 61 | 62 | removeScrollHandler(element, handler) { 63 | let index = this.elements.indexOf(element); 64 | let elementCache = this.handlers[index]; 65 | // TODO add explicit test 66 | if (elementCache && elementCache.handlers) { 67 | let index = elementCache.handlers.indexOf(handler); 68 | 69 | if (index === -1) { 70 | throw new Error('Attempted to remove an unknown handler'); 71 | } 72 | 73 | elementCache.handlers.splice(index, 1); 74 | 75 | // cleanup element entirely if needed 76 | // TODO add explicit test 77 | if (!elementCache.handlers.length) { 78 | index = this.elements.indexOf(element); 79 | this.handlers.splice(index, 1); 80 | this.elements.splice(index, 1); 81 | 82 | this.length--; 83 | this.maxLength--; 84 | 85 | if (this.length === 0) { 86 | this.isPolling = false; 87 | } 88 | 89 | // TODO add explicit test 90 | if (this.isUsingPassive) { 91 | element.removeEventListener('scroll', elementCache.passiveHandler, { capture: true, passive: true }); 92 | } 93 | } 94 | 95 | } else { 96 | throw new Error('Attempted to remove a handler from an unknown element or an element with no handlers'); 97 | } 98 | } 99 | 100 | static triggerElementHandlers(element, meta) { 101 | let cachedTop = element.scrollTop; 102 | let cachedLeft = element.scrollLeft; 103 | let topChanged = cachedTop !== meta.top; 104 | let leftChanged = cachedLeft !== meta.left; 105 | 106 | meta.top = cachedTop; 107 | meta.left = cachedLeft; 108 | 109 | let event = { top: cachedTop, left: cachedLeft }; 110 | 111 | // TODO add explicit test 112 | if (topChanged || leftChanged) { 113 | begin(); 114 | for (let j = 0; j < meta.handlers.length; j++) { 115 | meta.handlers[j](event); 116 | } 117 | end(); 118 | } 119 | } 120 | 121 | poll() { 122 | this.isPolling = true; 123 | 124 | scheduler.schedule('sync', () => { 125 | // TODO add explicit test 126 | if (!this.isPolling) { 127 | return; 128 | } 129 | 130 | for (let i = 0; i < this.length; i++) { 131 | let element = this.elements[i]; 132 | let info = this.handlers[i]; 133 | 134 | ScrollHandler.triggerElementHandlers(element, info); 135 | } 136 | 137 | this.isPolling = this.length > 0; 138 | // TODO add explicit test 139 | if (this.isPolling) { 140 | this.poll(); 141 | } 142 | }); 143 | } 144 | } 145 | 146 | const instance = new ScrollHandler(); 147 | 148 | export function addScrollHandler(element, handler) { 149 | instance.addScrollHandler(element, handler); 150 | } 151 | 152 | export function removeScrollHandler(element, handler) { 153 | instance.removeScrollHandler(element, handler); 154 | } 155 | 156 | export default instance; 157 | -------------------------------------------------------------------------------- /vertical-collection/addon/-private/data-view/utils/supports-passive.js: -------------------------------------------------------------------------------- 1 | let supportsPassive = false; 2 | 3 | try { 4 | let opts = Object.defineProperty({}, 'passive', { 5 | get() { 6 | supportsPassive = true; 7 | return supportsPassive; 8 | } 9 | }); 10 | 11 | window.addEventListener('test', null, opts); 12 | } catch(e) { 13 | // do nothing 14 | } 15 | 16 | export default supportsPassive; 17 | -------------------------------------------------------------------------------- /vertical-collection/addon/-private/data-view/viewport-container.js: -------------------------------------------------------------------------------- 1 | /* 2 | * There are significant differences between browsers 3 | * in how they implement "scroll" on document.body 4 | * 5 | * The only cross-browser listener for scroll on body 6 | * is to listen on window with capture. 7 | * 8 | * They also implement different standards for how to 9 | * access the scroll position. 10 | * 11 | * This singleton class provides a cross-browser way 12 | * to access and set the scrollTop and scrollLeft properties. 13 | * 14 | */ 15 | export function ViewportContainer() { 16 | 17 | // A bug occurs in Chrome when we reload the browser at a lower 18 | // scrollTop, window.scrollY becomes stuck on a single value. 19 | Object.defineProperty(this, 'scrollTop', { 20 | get() { 21 | return document.body.scrollTop 22 | || document.documentElement.scrollTop; 23 | }, 24 | set(v) { 25 | document.body.scrollTop = document.documentElement.scrollTop = v; 26 | } 27 | }); 28 | 29 | Object.defineProperty(this, 'scrollLeft', { 30 | get() { 31 | return window.scrollX 32 | || window.pageXOffset 33 | || document.body.scrollLeft 34 | || document.documentElement.scrollLeft; 35 | }, 36 | set(v) { 37 | window.scrollX 38 | = window.pageXOffset 39 | = document.body.scrollLeft 40 | = document.documentElement.scrollLeft = v; 41 | } 42 | }); 43 | 44 | Object.defineProperty(this, 'offsetHeight', { 45 | get() { 46 | return window.innerHeight; 47 | } 48 | }); 49 | } 50 | 51 | ViewportContainer.prototype.addEventListener = function addEventListener(event, handler, options) { 52 | return window.addEventListener(event, handler, options); 53 | }; 54 | 55 | ViewportContainer.prototype.removeEventListener = function addEventListener(event, handler, options) { 56 | return window.removeEventListener(event, handler, options); 57 | }; 58 | 59 | ViewportContainer.prototype.getBoundingClientRect = function getBoundingClientRect() { 60 | return { 61 | height: window.innerHeight, 62 | width: window.innerWidth, 63 | top: 0, 64 | left: 0, 65 | right: window.innerWidth, 66 | bottom: window.innerHeight 67 | }; 68 | }; 69 | 70 | export default new ViewportContainer(); 71 | -------------------------------------------------------------------------------- /vertical-collection/addon/-private/ember-internals/identity.js: -------------------------------------------------------------------------------- 1 | import { guidFor } from '@ember/object/internals'; 2 | 3 | export default function identity(item) { 4 | let key; 5 | const type = typeof item; 6 | 7 | if (type === 'string' || type === 'number') { 8 | key = item; 9 | } else { 10 | key = guidFor(item); 11 | } 12 | 13 | return key; 14 | } 15 | -------------------------------------------------------------------------------- /vertical-collection/addon/-private/ember-internals/key-for-item.js: -------------------------------------------------------------------------------- 1 | import { get } from '@ember/object'; 2 | import { assert } from '@ember/debug'; 3 | 4 | import identity from './identity'; 5 | 6 | export default function keyForItem(item, keyPath, index) { 7 | let key; 8 | 9 | assert(`keyPath must be a string, received: ${keyPath}`, typeof keyPath === 'string'); 10 | 11 | switch (keyPath) { 12 | case '@index': 13 | assert(`A numerical index must be supplied for keyForItem when keyPath is @index, received: ${index}`, typeof index === 'number'); 14 | key = index; 15 | break; 16 | case '@identity': 17 | key = identity(item); 18 | break; 19 | default: 20 | key = get(item, keyPath); 21 | } 22 | 23 | if (typeof key === 'number') { 24 | key = String(key); 25 | } 26 | 27 | return key; 28 | } 29 | -------------------------------------------------------------------------------- /vertical-collection/addon/-private/index.js: -------------------------------------------------------------------------------- 1 | export { default as keyForItem } from './ember-internals/key-for-item'; 2 | 3 | export { default as closestElement } from './utils/element/closest'; 4 | 5 | export { default as DynamicRadar } from './data-view/radar/dynamic-radar'; 6 | export { default as StaticRadar } from './data-view/radar/static-radar'; 7 | 8 | export { default as ViewportContainer } from './data-view/viewport-container'; 9 | export { default as objectAt } from './data-view/utils/object-at'; 10 | 11 | export { 12 | addScrollHandler, 13 | removeScrollHandler, 14 | ScrollHandler 15 | } from './data-view/utils/scroll-handler'; 16 | -------------------------------------------------------------------------------- /vertical-collection/addon/-private/utils/document-shim.js: -------------------------------------------------------------------------------- 1 | export default window ? window.document : undefined; 2 | -------------------------------------------------------------------------------- /vertical-collection/addon/-private/utils/element/closest.js: -------------------------------------------------------------------------------- 1 | const VENDOR_MATCH_FNS = ['matches', 'webkitMatchesSelector', 'mozMatchesSelector', 'msMatchesSelector', 'oMatchesSelector']; 2 | let ELEMENT_MATCH_FN; 3 | 4 | function setElementMatchFn(el) { 5 | VENDOR_MATCH_FNS.forEach((fn) => { 6 | if ((ELEMENT_MATCH_FN === undefined) && (typeof el[fn] === 'function')) { 7 | ELEMENT_MATCH_FN = fn; 8 | } 9 | }); 10 | } 11 | 12 | export default function closest(el, selector) { 13 | if (ELEMENT_MATCH_FN === undefined) { 14 | setElementMatchFn(el); 15 | } 16 | while (el) { 17 | // TODO add explicit test 18 | if (el[ELEMENT_MATCH_FN](selector)) { 19 | return el; 20 | } 21 | el = el.parentElement; 22 | } 23 | 24 | return null; 25 | } 26 | -------------------------------------------------------------------------------- /vertical-collection/addon/-private/utils/element/estimate-element-height.js: -------------------------------------------------------------------------------- 1 | import { assert } from '@ember/debug'; 2 | 3 | export default function estimateElementHeight(element, fallbackHeight) { 4 | assert(`You called estimateElement height without a fallbackHeight`, fallbackHeight); 5 | assert(`You called estimateElementHeight without an element`, element); 6 | 7 | if (fallbackHeight.indexOf('%') !== -1) { 8 | return getPercentageHeight(element, fallbackHeight); 9 | } 10 | 11 | if (fallbackHeight.indexOf('em') !== -1) { 12 | return getEmHeight(element, fallbackHeight); 13 | } 14 | 15 | return parseInt(fallbackHeight, 10); 16 | } 17 | 18 | function getPercentageHeight(element, fallbackHeight) { 19 | // We use offsetHeight here to get the element's true height, rather than the 20 | // bounding rect which may be scaled with transforms 21 | let parentHeight = element.offsetHeight; 22 | let percent = parseFloat(fallbackHeight); 23 | 24 | return (percent * parentHeight / 100.0); 25 | } 26 | 27 | function getEmHeight(element, fallbackHeight) { 28 | const fontSizeElement = fallbackHeight.indexOf('rem') !== -1 ? document.documentElement : element; 29 | const fontSize = window.getComputedStyle(fontSizeElement).getPropertyValue('font-size'); 30 | 31 | return (parseFloat(fallbackHeight) * parseFloat(fontSize)); 32 | } 33 | -------------------------------------------------------------------------------- /vertical-collection/addon/-private/utils/element/get-scaled-client-rect.js: -------------------------------------------------------------------------------- 1 | export default function getScaledClientRect(element, scale) { 2 | const rect = element.getBoundingClientRect(); 3 | 4 | if (scale === 1) { 5 | return rect; 6 | } 7 | 8 | const scaled = {}; 9 | 10 | for (let key in rect) { 11 | scaled[key] = rect[key] * scale; 12 | } 13 | 14 | return scaled; 15 | } 16 | -------------------------------------------------------------------------------- /vertical-collection/addon/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/html-next/vertical-collection/1990e9fa0a78a2c052dd48ba984fad0f104743aa/vertical-collection/addon/.gitkeep -------------------------------------------------------------------------------- /vertical-collection/addon/components/vertical-collection/template.hbs: -------------------------------------------------------------------------------- 1 | {{#each this.virtualComponents key="id" as |virtualComponent| ~}} 2 | {{~unbound virtualComponent.upperBound~}} 3 | {{~#if virtualComponent.isOccludedContent ~}} 4 | {{{unbound virtualComponent.element}}} 5 | {{~else~}} 6 | {{~yield virtualComponent.content virtualComponent.index ~}} 7 | {{~/if~}} 8 | {{~unbound virtualComponent.lowerBound~}} 9 | {{~/each}} 10 | 11 | {{#if this.shouldYieldToInverse}} 12 | {{yield to="inverse"}} 13 | {{/if}} 14 | -------------------------------------------------------------------------------- /vertical-collection/addon/styles/app.css: -------------------------------------------------------------------------------- 1 | .occluded-content { 2 | display: block; 3 | position: relative; 4 | width: 100%; 5 | 6 | /* prevents margin overflow on item container */ 7 | min-height: 0.01px; 8 | 9 | /* hides text visually while still being readable by screen readers */ 10 | color: rgb(0 0 0 / 0%); 11 | } 12 | 13 | table .occluded-content, 14 | tbody .occluded-content, 15 | thead .occluded-content, 16 | tfoot .occluded-content { 17 | display: table-row; 18 | position: relative; 19 | width: 100%; 20 | } 21 | 22 | ul .occluded-content, 23 | ol .occluded-content { 24 | display: list-item; 25 | position: relative; 26 | width: 100%; 27 | list-style-type: none; 28 | height: 0; 29 | } 30 | -------------------------------------------------------------------------------- /vertical-collection/app/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/html-next/vertical-collection/1990e9fa0a78a2c052dd48ba984fad0f104743aa/vertical-collection/app/.gitkeep -------------------------------------------------------------------------------- /vertical-collection/app/components/vertical-collection.js: -------------------------------------------------------------------------------- 1 | export { default } from '@html-next/vertical-collection/components/vertical-collection/component'; 2 | -------------------------------------------------------------------------------- /vertical-collection/bin/restore-env.sh: -------------------------------------------------------------------------------- 1 | export EMBER_OPTIONAL_FEATURES=$(cat __env) 2 | -------------------------------------------------------------------------------- /vertical-collection/bin/run-tests-with-retry.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | function retry { 4 | command="$*" 5 | retval=1 6 | attempt=1 7 | until [[ $retval -eq 0 ]] || [[ $attempt -gt 4 ]]; do 8 | # Execute inside of a subshell in case parent 9 | # script is running with "set -e" 10 | ( 11 | set +e 12 | $command 13 | ) 14 | retval=$? 15 | attempt=$(( $attempt + 1 )) 16 | if [[ $retval -ne 0 ]]; then 17 | # If there was an error wait 10 seconds 18 | sleep 1 19 | fi 20 | done 21 | exit $retval 22 | } 23 | 24 | pnpm ember build && retry pnpm ember test --path=dist 25 | -------------------------------------------------------------------------------- /vertical-collection/bin/stash-env.sh: -------------------------------------------------------------------------------- 1 | printf '%s' "$EMBER_OPTIONAL_FEATURES" > __env 2 | -------------------------------------------------------------------------------- /vertical-collection/ember-cli-build.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const EmberAddon = require('ember-cli/lib/broccoli/ember-addon'); 4 | 5 | module.exports = function (defaults) { 6 | const app = new EmberAddon(defaults, { 7 | // Add options here 8 | emberData: { 9 | deprecations: { 10 | // New projects can safely leave this deprecation disabled. 11 | // If upgrading, to opt-into the deprecated behavior, set this to true and then follow: 12 | // https://deprecations.emberjs.com/id/ember-data-deprecate-store-extends-ember-object 13 | // before upgrading to Ember Data 6.0 14 | DEPRECATE_STORE_EXTENDS_EMBER_OBJECT: false, 15 | }, 16 | }, 17 | }); 18 | 19 | let bootstrapPath = 'node_modules/bootstrap/dist/'; 20 | app.import(`${bootstrapPath}css/bootstrap.css`); 21 | app.import(`${bootstrapPath}fonts/glyphicons-halflings-regular.eot`, { destDir: '/fonts' }); 22 | app.import(`${bootstrapPath}fonts/glyphicons-halflings-regular.svg`, { destDir: '/fonts' }); 23 | app.import(`${bootstrapPath}fonts/glyphicons-halflings-regular.ttf`, { destDir: '/fonts' }); 24 | app.import(`${bootstrapPath}fonts/glyphicons-halflings-regular.woff`, { destDir: '/fonts' }); 25 | app.import(`${bootstrapPath}fonts/glyphicons-halflings-regular.woff2`, { destDir: '/fonts' }); 26 | 27 | /* 28 | This build file specifes the options for the dummy test app of this 29 | addon, located in `/tests/dummy` 30 | This build file does *not* influence how the addon or the app using it 31 | behave. You most likely want to be modifying `./index.js` or app's build file 32 | */ 33 | 34 | return app.toTree(); 35 | }; 36 | -------------------------------------------------------------------------------- /vertical-collection/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const StripClassCallCheckPlugin = require.resolve('babel6-plugin-strip-class-callcheck'); 4 | const Funnel = require('broccoli-funnel'); 5 | const Rollup = require('broccoli-rollup'); 6 | const merge = require('broccoli-merge-trees'); 7 | const VersionChecker = require('ember-cli-version-checker'); 8 | 9 | module.exports = { 10 | name: require('./package').name, 11 | 12 | init() { 13 | this._super.init && this._super.init.apply(this, arguments); 14 | 15 | this.options = this.options || {}; 16 | }, 17 | 18 | getOutputDirForVersion() { 19 | return ''; 20 | }, 21 | 22 | // Borrowed from ember-cli-babel 23 | _emberVersionRequiresModulesAPIPolyfill() { 24 | let checker = this.checker.for('ember-source', 'npm'); 25 | 26 | if (!checker.exists()) { 27 | return true; 28 | } 29 | 30 | return checker.lt('3.27.0-alpha.1'); 31 | }, 32 | 33 | treeForAddon(tree) { 34 | let babel = this.addons.find((addon) => addon.name === 'ember-cli-babel'); 35 | let withPrivate = new Funnel(tree, { include: ['-private/**'] }); 36 | let withoutPrivate = new Funnel(tree, { 37 | exclude: [ 38 | '**/**.hbs', 39 | '-private' 40 | ], 41 | destDir: '@html-next/vertical-collection' 42 | }); 43 | 44 | let privateTree = babel.transpileTree(withPrivate, { 45 | babel: this.options.babel, 46 | 'ember-cli-babel': { 47 | // we leave our output as valid ES 48 | // for the consuming app's config to transpile as desired 49 | // so we don't want to compileModules to amd here 50 | compileModules: false, 51 | 52 | disableEmberModulesAPIPolyfill: !this._emberVersionRequiresModulesAPIPolyfill(), 53 | 54 | // TODO for the embroider world we want to leave our -private module 55 | // as an es module and only transpile the few things we genuinely care about. 56 | // ideally this would occur as a pre-publish step so that consuming apps would 57 | // just see a `-private.js` file and not pay any additional costs. 58 | // CURRENTLY we transpile the -private module fully acccording to the 59 | // consuming app's config, so we must leave these enabled. 60 | disablePresetEnv: false, 61 | disableDebugTooling: false, 62 | disableDecoratorTransforms: false, 63 | enableTypeScriptTransform: true, 64 | 65 | // consuming app will take care of this if needed, 66 | // we don't need to also include 67 | includePolyfill: false, 68 | 69 | extensions: ['js', 'ts'], 70 | }, 71 | }); 72 | 73 | const templateTree = new Funnel(tree, { 74 | include: ['**/**.hbs'] 75 | }); 76 | 77 | // use the default options 78 | const addonTemplateTree = this._super(templateTree); 79 | let publicTree = babel.transpileTree(withoutPrivate); 80 | 81 | privateTree = new Rollup(privateTree, { 82 | rollup: { 83 | input: '-private/index.js', 84 | output: [ 85 | { 86 | file: '@html-next/vertical-collection/-private.js', 87 | format: 'amd', 88 | amd: { 89 | id: '@html-next/vertical-collection/-private' 90 | } 91 | } 92 | ], 93 | external(id) { 94 | return ( 95 | id.startsWith('@ember/') || 96 | ['ember', 'ember-raf-scheduler'].includes(id) 97 | ); 98 | }, 99 | }, 100 | }); 101 | 102 | let destDir = this.getOutputDirForVersion(); 103 | publicTree = new Funnel(publicTree, { destDir }); 104 | privateTree = new Funnel(privateTree, { destDir }); 105 | 106 | return merge([ 107 | addonTemplateTree, 108 | publicTree, 109 | privateTree 110 | ]); 111 | }, 112 | 113 | _hasSetupBabelOptions: false, 114 | buildBabelOptions(originalOptions) { 115 | const plugins = originalOptions.plugins || []; 116 | 117 | const opts = { 118 | loose: true, 119 | plugins, 120 | postTransformPlugins: [[StripClassCallCheckPlugin, {}]] 121 | }; 122 | 123 | return opts; 124 | }, 125 | _setupBabelOptions() { 126 | if (this._hasSetupBabelOptions) { 127 | return; 128 | } 129 | 130 | this.options.babel = this.buildBabelOptions(this.options.babel); 131 | 132 | this._hasSetupBabelOptions = true; 133 | }, 134 | 135 | included(app) { 136 | this._super.included.apply(this, arguments); 137 | this.checker = new VersionChecker(app); 138 | 139 | while (typeof app.import !== 'function' && app.app) { 140 | app = app.app; 141 | } 142 | 143 | if (typeof app.import !== 'function') { 144 | throw new Error('vertical-collection is being used within another addon or engine ' 145 | + 'and is having trouble registering itself to the parent application.'); 146 | } 147 | 148 | this._env = app.env; 149 | this._setupBabelOptions(app.env); 150 | 151 | if (!/production/.test(app.env) && !/test/.test(app.env)) { 152 | this.import('vendor/debug.css'); 153 | } 154 | } 155 | }; 156 | -------------------------------------------------------------------------------- /vertical-collection/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@html-next/vertical-collection", 3 | "version": "4.0.2", 4 | "description": "infinite-scroll, done right. done.", 5 | "keywords": [ 6 | "occlusion", 7 | "infinite", 8 | "infinite-scroll", 9 | "collection", 10 | "grid", 11 | "list-view", 12 | "recycling", 13 | "cloaking", 14 | "performance", 15 | "lists", 16 | "ember-addon" 17 | ], 18 | "homepage": "https://github.com/html-next/vertical-collection", 19 | "bugs": "https://github.com/html-next/vertical-collection/issues", 20 | "repository": "https://github.com/html-next/vertical-collection.git", 21 | "license": "MIT", 22 | "author": { 23 | "name": "Chris Thoburn (@runspired)", 24 | "url": "https://runspired.com" 25 | }, 26 | "contributors": [ 27 | { 28 | "name": "Chris Garrett (@pzuraq)", 29 | "email": "me@pzuraq.com", 30 | "url": "https://github.com/pzuraq" 31 | }, 32 | { 33 | "name": "Robert Wagner (@rwwagner90)", 34 | "email": "rwwagner90@gmail.com", 35 | "url": "https://github.com/rwwagner90" 36 | } 37 | ], 38 | "directories": { 39 | "doc": "doc", 40 | "test": "tests" 41 | }, 42 | "scripts": { 43 | "build": "node ./scripts/write-snipets.mjs && ember build --environment=production", 44 | "lint": "concurrently \"npm:lint:*(!fix)\" --names \"lint:\"", 45 | "lint:css": "stylelint \"**/*.css\"", 46 | "lint:css:fix": "concurrently \"npm:lint:css -- --fix\"", 47 | "lint:fix": "concurrently \"npm:lint:*:fix\" --names \"fix:\"", 48 | "lint:js": "eslint . --cache", 49 | "lint:js:fix": "eslint . --fix", 50 | "start": "node ./scripts/write-snippets.mjs && ember serve", 51 | "test": "concurrently \"npm:lint\" \"npm:test:*\" --names \"lint,test:\"", 52 | "test:ember": "ember test", 53 | "test:ci": ". bin/restore-env.sh && CI=true pnpm ember test --path=dist", 54 | "test:ember-compatibility": "ember try:each" 55 | }, 56 | "dependencies": { 57 | "babel6-plugin-strip-class-callcheck": "^6.0.0", 58 | "broccoli-funnel": "^3.0.8", 59 | "broccoli-merge-trees": "^4.2.0", 60 | "broccoli-rollup": "^5.0.0", 61 | "ember-cli-babel": "^8.0.0", 62 | "ember-auto-import": "^2.6.3", 63 | "ember-cli-htmlbars": "^6.3.0", 64 | "ember-cli-version-checker": "^5.1.2", 65 | "ember-raf-scheduler": "^0.3.0" 66 | }, 67 | "devDependencies": { 68 | "ember-inflector": "^6.0.0", 69 | "@babel/eslint-parser": "^7.22.10", 70 | "@babel/plugin-proposal-decorators": "^7.22.10", 71 | "@ember/optional-features": "^2.0.0", 72 | "@ember/string": "^3.1.1", 73 | "@ember/test-helpers": "~3.3.1", 74 | "@ember/test-waiters": "~3.1.0", 75 | "@embroider/test-setup": "^3.0.1", 76 | "@glimmer/component": "^1.0.0", 77 | "@glimmer/tracking": "^1.0.0", 78 | "@warp-drive/build-config": "4.13.0-alpha.8", 79 | "bootstrap": "~3.3.5", 80 | "broccoli-asset-rev": "^3.0.0", 81 | "concurrently": "^8.2.0", 82 | "ember-cli": "~5.2.0", 83 | "ember-cli-clean-css": "^3.0.0", 84 | "ember-cli-dependency-checker": "^3.3.2", 85 | "ember-cli-github-pages": "^0.2.2", 86 | "ember-cli-inject-live-reload": "^2.1.0", 87 | "ember-cli-sri": "^2.1.1", 88 | "ember-cli-terser": "^4.0.2", 89 | "ember-data": "~4.13.0-alpha.8", 90 | "ember-load-initializers": "^2.1.2", 91 | "ember-perf-timeline": "^2.0.0", 92 | "ember-qunit": "^7.0.0", 93 | "ember-resolver": "^11.0.1", 94 | "ember-source": "~5.2.0", 95 | "ember-source-channel-url": "^3.0.0", 96 | "ember-template-lint": "^5.11.2", 97 | "ember-try": "^3.0.0", 98 | "eslint": "^8.47.0", 99 | "eslint-config-prettier": "^9.0.0", 100 | "eslint-plugin-ember": "^11.10.0", 101 | "eslint-plugin-n": "^16.0.1", 102 | "eslint-plugin-prettier": "^5.0.0", 103 | "eslint-plugin-qunit": "^8.0.0", 104 | "loader.js": "^4.7.0", 105 | "prettier": "^3.0.2", 106 | "qunit": "^2.19.4", 107 | "qunit-dom": "^2.0.0", 108 | "release-it": "^14.2.1", 109 | "release-it-lerna-changelog": "^3.1.0", 110 | "stylelint": "^15.10.3", 111 | "stylelint-config-standard": "^34.0.0", 112 | "stylelint-prettier": "^4.0.2", 113 | "webpack": "^5.88.2" 114 | }, 115 | "peerDependencies": { 116 | "ember-source": ">= 3.28.0" 117 | }, 118 | "engines": { 119 | "node": ">= 18" 120 | }, 121 | "volta": { 122 | "node": "18.20.5", 123 | "pnpm": "10.10.0" 124 | }, 125 | "publishConfig": { 126 | "registry": "https://registry.npmjs.org" 127 | }, 128 | "ember": { 129 | "edition": "octane" 130 | }, 131 | "ember-addon": { 132 | "configPath": "tests/dummy/config" 133 | }, 134 | "release-it": { 135 | "plugins": { 136 | "release-it-lerna-changelog": { 137 | "infile": "CHANGELOG.md", 138 | "launchEditor": true 139 | } 140 | }, 141 | "git": { 142 | "tagName": "v${version}" 143 | }, 144 | "github": { 145 | "release": true, 146 | "tokenRef": "GITHUB_AUTH" 147 | } 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /vertical-collection/scripts/write-snippets.mjs: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs'; 2 | import * as path from 'path'; 3 | 4 | const extractAndAppendSnippets = (filepath, snippetsList) => { 5 | let content = fs.readFileSync(filepath, 'utf-8'); 6 | let rows = content.split('\n'); 7 | while (rows.length) { 8 | let row = rows.shift(); 9 | let match = row.match(/! BEGIN-SNIPPET (.*) /); 10 | if (match) { 11 | let snippetContent = []; 12 | do { 13 | row = rows.shift(); 14 | if (row.match(/! END-SNIPPET /)) { 15 | break; 16 | } 17 | snippetContent.push(row); 18 | } while (rows.length) 19 | snippetsData[match[1]] = { 20 | source: filepath, 21 | content: snippetContent.join('\n') 22 | }; 23 | } 24 | } 25 | } 26 | 27 | const buildSnippetsListData = (dir, snippetsData={}) => { 28 | const files = fs.readdirSync(dir); 29 | 30 | for (let file of files) { 31 | const filepath = path.join(dir, file); 32 | const stat = fs.statSync(filepath); 33 | if (stat.isDirectory()) { 34 | buildSnippetsListData(filepath, snippetsData); 35 | } else if (file !== 'snippets.js' && file.match(/\.(js|hbs)$/)) { 36 | extractAndAppendSnippets(filepath, snippetsData); 37 | } 38 | } 39 | } 40 | 41 | let snippetsData = {}; 42 | buildSnippetsListData('tests/dummy/app/', snippetsData); 43 | 44 | fs.writeSync(fs.openSync('tests/dummy/app/snippets.js', 'w'), `export default ${JSON.stringify(snippetsData)}`, 0, 'utf8'); 45 | -------------------------------------------------------------------------------- /vertical-collection/testem.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | test_page: 'tests/index.html?hidepassed', 5 | disable_watching: true, 6 | launch_in_ci: ['Chrome'], 7 | launch_in_dev: ['Chrome'], 8 | browser_start_timeout: 120, 9 | browser_args: { 10 | Chrome: { 11 | ci: [ 12 | // --no-sandbox is needed when running Chrome inside a container 13 | process.env.CI ? '--no-sandbox' : null, 14 | '--headless', 15 | '--disable-dev-shm-usage', 16 | '--disable-software-rasterizer', 17 | '--mute-audio', 18 | '--remote-debugging-port=0', 19 | '--window-size=1440,900', 20 | ].filter(Boolean), 21 | }, 22 | }, 23 | }; 24 | -------------------------------------------------------------------------------- /vertical-collection/tests/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | 'embertest': true 4 | }, 5 | globals: { 6 | server: true 7 | }, 8 | rules: { 9 | 'ember-suave/no-direct-property-access': 0 10 | } 11 | }; 12 | -------------------------------------------------------------------------------- /vertical-collection/tests/acceptance/acceptance-tests/record-array-test.js: -------------------------------------------------------------------------------- 1 | import { module, test } from 'qunit'; 2 | import { setupApplicationTest } from '../../helpers'; 3 | import { scheduler } from 'ember-raf-scheduler'; 4 | 5 | import { click, find, findAll, visit as newVisit } from '@ember/test-helpers'; 6 | import scrollTo from '../../helpers/scroll-to'; 7 | 8 | module('Acceptance | Record Array', function(hooks) { 9 | setupApplicationTest(hooks); 10 | 11 | test('RecordArrays render correctly', async function(assert) { 12 | await newVisit('/acceptance-tests/record-array'); 13 | 14 | assert.strictEqual(findAll('number-slide').length, 15, 'correct number of items rendered'); 15 | assert.strictEqual(find('number-slide:first-of-type').textContent.replace(/\s/g, ''), '0(0)', 'correct first item rendered'); 16 | assert.strictEqual(find('number-slide:last-of-type').textContent.replace(/\s/g, ''), '14(14)', 'correct last item rendered'); 17 | }); 18 | 19 | test('RecordArrays update correctly after scrolling and updating items', async function(assert) { 20 | await newVisit('/acceptance-tests/record-array'); 21 | 22 | assert.strictEqual(findAll('number-slide').length, 15, 'correct number of items rendered'); 23 | 24 | await scrollTo('.table-wrapper', 0, 600); 25 | 26 | await click('#update-items-button'); 27 | 28 | assert.strictEqual(findAll('number-slide').length, 5, 'correct number of items rendered'); 29 | }); 30 | 31 | test('RecordArrays update correctly after partial update', async function(assert) { 32 | await newVisit('/acceptance-tests/record-array'); 33 | 34 | assert.strictEqual(findAll('number-slide').length, 15, 'correct number of items rendered'); 35 | 36 | await click('#partial-update-button'); 37 | 38 | assert.strictEqual(findAll('number-slide').length, 5, 'correct number of items rendered'); 39 | }); 40 | 41 | test('RecordArrays update correctly after being hidden and shown', async function(assert) { 42 | await newVisit('/acceptance-tests/record-array'); 43 | 44 | assert.strictEqual(findAll('number-slide').length, 15, 'correct number of items rendered'); 45 | 46 | await click('#hide-vc-button'); 47 | 48 | assert.strictEqual(findAll('number-slide').length, 0, 'correct number of items rendered'); 49 | 50 | await click('#show-vc-button'); 51 | 52 | assert.strictEqual(findAll('number-slide').length, 15, 'correct number of items rendered'); 53 | }); 54 | 55 | test('RecordArrays updates correctly after deleting items', async function(assert) { 56 | await newVisit('/acceptance-tests/record-array'); 57 | 58 | assert.strictEqual(findAll('number-slide').length, 15, 'correct number of items rendered'); 59 | await click('#update-items-button'); 60 | assert.strictEqual(findAll('number-slide').length, 5, 'correct number of items rendered'); 61 | assert.deepEqual(findAll('number-slide').map(s => s.textContent.trim()[0]), ['0', '1', '2', '3', '4'], 'correct items order'); 62 | await click('#show-prefixed-button'); 63 | assert.strictEqual(findAll('number-slide').length, 5, 'correct number of items rendered and nothing crashes'); 64 | assert.deepEqual(findAll('number-slide').map(s => s.textContent.trim()[0]), ['0', '1', '2', '3', '4'], 'correct items order'); 65 | }); 66 | 67 | test('RecordArrays fires firstVisibleChanged correctly after scrolling and fast-switching items', async function(assert) { 68 | function waitForMeasure() { 69 | return new Promise(resolve => { 70 | scheduler.schedule('sync', () => { 71 | scheduler.schedule('measure', resolve); 72 | }); 73 | }); 74 | } 75 | 76 | await newVisit('/acceptance-tests/record-array'); 77 | 78 | assert.strictEqual(find('#first-visible-id').value, '0', 'the first item is the first visible id'); 79 | 80 | await click('#last-25-button'); 81 | assert.strictEqual(find('#first-visible-id').value, '75', 'the first visible id is updated correctly after updating items'); 82 | 83 | await scrollTo('.table-wrapper', 0, 1000); 84 | assert.strictEqual(find('#first-visible-id').value, '86', 'the first visible id is updated correctly after scrolling'); 85 | 86 | 87 | click('#show-all'); 88 | await waitForMeasure(); 89 | await click('#last-25-button'); 90 | await scrollTo('.table-wrapper', 0, 0); 91 | await scrollTo('.table-wrapper', 0, 1000); // restore sort position 92 | 93 | assert.strictEqual(find('#first-visible-id').value, '86', 'the first visible id is the same after fast-switching items'); 94 | 95 | await scrollTo('.table-wrapper', 0, 0); 96 | assert.strictEqual(find('#first-visible-id').value, '75', 'the first visible id is updated correctly after scrolling'); 97 | }); 98 | }); 99 | -------------------------------------------------------------------------------- /vertical-collection/tests/dummy/app/adapters/application.js: -------------------------------------------------------------------------------- 1 | import JSONAPIAdapter from '@ember-data/adapter/json-api'; 2 | 3 | const NUMBERS = { 4 | data: [] 5 | }; 6 | 7 | for (let i = 0; i < 100; i++) { 8 | NUMBERS.data.push({ 9 | type: 'number-item', 10 | id: `${i}`, 11 | attributes: { 12 | number: i 13 | } 14 | }); 15 | } 16 | 17 | export default class extends JSONAPIAdapter { 18 | async findAll() { 19 | return NUMBERS; 20 | } 21 | async query(store, model, query) { 22 | const queryData = { ...NUMBERS }; 23 | queryData.data = NUMBERS.data.slice(0, query.length); 24 | return queryData; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /vertical-collection/tests/dummy/app/app.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable ember/avoid-leaking-state-in-ember-objects */ 2 | import Application from '@ember/application'; 3 | import Resolver from './resolver'; 4 | import loadInitializers from 'ember-load-initializers'; 5 | import config from './config/environment'; 6 | 7 | class App extends Application { 8 | modulePrefix=config.modulePrefix 9 | podModulePrefix=config.podModulePrefix 10 | Resolver=Resolver 11 | customEvents = { 12 | touchstart: null, 13 | touchmove: null, 14 | touchend: null, 15 | touchcancel: null, 16 | keydown: null, 17 | keyup: null, 18 | keypress: null, 19 | mousedown: null, 20 | mouseup: null, 21 | contextmenu: null, 22 | dblclick: null, 23 | mousemove: null, 24 | focusin: null, 25 | focusout: null, 26 | mouseenter: null, 27 | mouseleave: null, 28 | submit: null, 29 | change: null, 30 | dragstart: null, 31 | drag: null, 32 | dragenter: null, 33 | dragleave: null, 34 | dragover: null, 35 | drop: null, 36 | dragend: null 37 | } 38 | } 39 | 40 | loadInitializers(App, config.modulePrefix); 41 | 42 | export default App; 43 | -------------------------------------------------------------------------------- /vertical-collection/tests/dummy/app/components/code-snippet.hbs: -------------------------------------------------------------------------------- 1 |
    
    2 | {{~ this.snippet ~}}
    3 | 
    4 | -------------------------------------------------------------------------------- /vertical-collection/tests/dummy/app/components/code-snippet.js: -------------------------------------------------------------------------------- 1 | import Component from '@glimmer/component'; 2 | import snippets from '../snippets'; 3 | 4 | export default class extends Component { 5 | get snippet() { 6 | return snippets[this.args.name]?.content; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /vertical-collection/tests/dummy/app/helpers/either-or.js: -------------------------------------------------------------------------------- 1 | import Helper, { helper } from '@ember/component/helper'; 2 | import Ember from 'ember'; 3 | 4 | let eitherOrHelper; 5 | 6 | if (Helper) { 7 | eitherOrHelper = helper(function(params) { 8 | return params[0] || params[1]; 9 | }); 10 | } else { 11 | eitherOrHelper = Ember.Handlebars.makeBoundHelper(function(...params) { 12 | return params[0] || params[1]; 13 | }); 14 | } 15 | 16 | export default eitherOrHelper; 17 | -------------------------------------------------------------------------------- /vertical-collection/tests/dummy/app/helpers/html-safe.js: -------------------------------------------------------------------------------- 1 | import { htmlSafe } from '@ember/template'; 2 | import Helper, { helper } from '@ember/component/helper'; 3 | import Ember from 'ember'; 4 | 5 | let htmlSafeHelper; 6 | 7 | if (Helper) { 8 | htmlSafeHelper = helper(function(params) { 9 | return htmlSafe(params[0]); 10 | }); 11 | } else { 12 | htmlSafeHelper = Ember.Handlebars.makeBoundHelper(function(...params) { 13 | return htmlSafe(params[0]); 14 | }); 15 | } 16 | 17 | export default htmlSafeHelper; 18 | -------------------------------------------------------------------------------- /vertical-collection/tests/dummy/app/helpers/join-strings.js: -------------------------------------------------------------------------------- 1 | import Helper, { helper } from '@ember/component/helper'; 2 | import Ember from 'ember'; 3 | 4 | let joinStringsHelper; 5 | 6 | if (Helper) { 7 | joinStringsHelper = helper(function(params) { 8 | return params.join(''); 9 | }); 10 | } else { 11 | joinStringsHelper = Ember.Handlebars.makeBoundHelper(function(...params) { 12 | return params.join(''); 13 | }); 14 | } 15 | 16 | export default joinStringsHelper; 17 | -------------------------------------------------------------------------------- /vertical-collection/tests/dummy/app/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | HTML Next | Vertical Collection 6 | 7 | 8 | 9 | {{content-for "head"}} 10 | 11 | 12 | 13 | 14 | {{content-for "head-footer"}} 15 | 16 | 17 | {{content-for "body"}} 18 | 19 | 20 | 21 | 22 | {{content-for "body-footer"}} 23 | 24 | 25 | -------------------------------------------------------------------------------- /vertical-collection/tests/dummy/app/lib/get-data.js: -------------------------------------------------------------------------------- 1 | /* eslint camelcase: 0 */ 2 | 3 | const DEFAULT_ROWS = 20; 4 | 5 | export default function getData(ROWS) { 6 | ROWS = ROWS || DEFAULT_ROWS; 7 | 8 | // generate some dummy data 9 | const data = { 10 | start_at: new Date().getTime() / 1000, 11 | databases: [] 12 | }; 13 | 14 | for (let i = 1; i <= ROWS; i++) { 15 | 16 | data.databases.push({ 17 | id: `cluster${i}`, 18 | queries: [] 19 | }); 20 | 21 | data.databases.push({ 22 | id: `cluster${i}slave`, 23 | queries: [] 24 | }); 25 | 26 | } 27 | 28 | data.databases.forEach(function(info) { 29 | const r = Math.floor((Math.random() * 10) + 1); 30 | 31 | for (let i = 0; i < r; i++) { 32 | const q = { 33 | canvas_action: null, 34 | canvas_context_id: null, 35 | canvas_controller: null, 36 | canvas_hostname: null, 37 | canvas_job_tag: null, 38 | canvas_pid: null, 39 | elapsed: Math.random() * 15, 40 | query: 'SELECT blah FROM something', 41 | waiting: Math.random() < 0.5 42 | }; 43 | 44 | if (Math.random() < 0.2) { 45 | q.query = ' in transaction'; 46 | } 47 | 48 | if (Math.random() < 0.1) { 49 | q.query = 'vacuum'; 50 | } 51 | 52 | info.queries.push(q); 53 | } 54 | 55 | info.queries = info.queries.sort(function(a, b) { 56 | return b.elapsed - a.elapsed; 57 | }); 58 | }); 59 | 60 | return data; 61 | } 62 | -------------------------------------------------------------------------------- /vertical-collection/tests/dummy/app/lib/get-images.js: -------------------------------------------------------------------------------- 1 | const DEFAULT_COUNT = 10; 2 | const URL_BASE = 'http://lorempixel.com'; 3 | const CATEGORIES = [ 4 | 'abstract', 5 | 'city', 6 | 'people', 7 | 'transport', 8 | 'food', 9 | 'nature', 10 | 'business', 11 | 'nightlife', 12 | 'sports', 13 | 'cats', 14 | 'fashion', 15 | 'technics' 16 | ]; 17 | 18 | export function booleanToss() { 19 | return Math.round(Math.random()); 20 | } 21 | 22 | function isGray() { 23 | return booleanToss(); 24 | } 25 | 26 | function getRandomNumber(min, max) { 27 | return Math.floor(Math.random() * (max + 1 - min) + min); 28 | } 29 | 30 | function getWidth() { 31 | return getRandomNumber(1500, 1920); 32 | } 33 | 34 | export function getDynamicHeight() { 35 | return getRandomNumber(300, 600); 36 | } 37 | 38 | export function getDynamicWidth(height, isPortrait) { 39 | return Math.round(isPortrait ? height / 16 * 9 : height / 9 * 16); 40 | } 41 | 42 | function generateImageId(index) { 43 | return `${((new Date()).getTime())}-${index}`; 44 | } 45 | function getId() { 46 | return getRandomNumber(0, 10); 47 | } 48 | 49 | function getCategoryIndex() { 50 | return getRandomNumber(0, CATEGORIES.length - 1); 51 | } 52 | 53 | function generateImageSrc(index) { 54 | const parts = []; 55 | const preview = []; 56 | 57 | parts.push(URL_BASE); 58 | preview.push(URL_BASE); 59 | if (isGray()) { 60 | parts.push('g'); 61 | preview.push('g'); 62 | } 63 | 64 | const width = getWidth(); 65 | 66 | parts.push(width); 67 | parts.push(width); 68 | 69 | const small = 250; 70 | preview.push(small); 71 | preview.push(small); 72 | 73 | const cat = CATEGORIES[getCategoryIndex()]; 74 | 75 | parts.push(cat); 76 | preview.push(cat); 77 | 78 | const id = getId(); 79 | 80 | parts.push(id); 81 | preview.push(id); 82 | 83 | return { 84 | large: parts.join('/'), 85 | small: preview.join('/'), 86 | id: generateImageId(index) 87 | }; 88 | } 89 | 90 | function generateDynamicImageSrc(index) { 91 | const parts = []; 92 | const preview = []; 93 | 94 | parts.push(URL_BASE); 95 | preview.push(URL_BASE); 96 | if (isGray()) { 97 | parts.push('g'); 98 | preview.push('g'); 99 | } 100 | 101 | const height = getDynamicHeight(); 102 | const isPortrait = booleanToss(); 103 | const width = getDynamicWidth(height, isPortrait); 104 | 105 | parts.push(width); 106 | parts.push(height); 107 | 108 | const smallWidth = 100; 109 | const smallHeight = getDynamicWidth(smallWidth, isPortrait); 110 | 111 | preview.push(smallWidth); 112 | preview.push(smallHeight); 113 | 114 | const cat = CATEGORIES[getCategoryIndex()]; 115 | 116 | parts.push(cat); 117 | preview.push(cat); 118 | 119 | const id = getId(); 120 | 121 | parts.push(id); 122 | preview.push(id); 123 | 124 | return { 125 | large: parts.join('/'), 126 | small: preview.join('/'), 127 | id: generateImageId(index), 128 | width, 129 | height, 130 | previewWidth: smallWidth, 131 | previewHeight: smallHeight 132 | }; 133 | } 134 | 135 | function getImages(count) { 136 | count = count || DEFAULT_COUNT; 137 | const imageUrls = []; 138 | 139 | for (let i = 1; i <= count; i++) { 140 | imageUrls.push(generateImageSrc(i)); 141 | } 142 | 143 | return imageUrls; 144 | } 145 | 146 | function getDynamicImages(count) { 147 | count = count || DEFAULT_COUNT; 148 | const imageUrls = []; 149 | 150 | for (let i = 1; i <= count; i++) { 151 | imageUrls.push(generateDynamicImageSrc(i)); 152 | } 153 | 154 | return imageUrls; 155 | } 156 | 157 | export default getImages; 158 | 159 | export { 160 | getImages, 161 | getDynamicImages 162 | }; 163 | -------------------------------------------------------------------------------- /vertical-collection/tests/dummy/app/lib/get-numbers.js: -------------------------------------------------------------------------------- 1 | import { 2 | getDynamicHeight, 3 | booleanToss, 4 | getDynamicWidth 5 | } from './get-images'; 6 | 7 | export default function(start, total, prefix = '') { 8 | let ret = []; 9 | let height; 10 | 11 | for (let i = start; i < start + total; i++) { 12 | height = Math.max(getDynamicHeight() * Math.random(), 40); 13 | ret.push({ 14 | number: i, 15 | height, 16 | width: getDynamicWidth(height, booleanToss()), 17 | prefixed: prefix + i 18 | }); 19 | } 20 | 21 | return ret; 22 | } 23 | -------------------------------------------------------------------------------- /vertical-collection/tests/dummy/app/models/number-item.js: -------------------------------------------------------------------------------- 1 | import { computed } from '@ember/object'; 2 | import Model, { attr } from '@ember-data/model'; 3 | 4 | export default Model.extend({ 5 | number: attr('number'), 6 | prefixed: computed('number', function() { 7 | return `${this.number}`; 8 | }) 9 | }); 10 | -------------------------------------------------------------------------------- /vertical-collection/tests/dummy/app/resolver.js: -------------------------------------------------------------------------------- 1 | import Resolver from 'ember-resolver'; 2 | 3 | export default Resolver; 4 | -------------------------------------------------------------------------------- /vertical-collection/tests/dummy/app/router.js: -------------------------------------------------------------------------------- 1 | import EmberRouter from '@ember/routing/router'; 2 | import config from './config/environment'; 3 | 4 | class Router extends EmberRouter { 5 | location=config.locationType 6 | rootURL=config.rootURL 7 | } 8 | 9 | Router.map(function() { 10 | this.route('examples', function() { 11 | this.route('dbmon'); 12 | this.route('infinite-scroll'); 13 | this.route('flexible-layout'); 14 | this.route('scrollable-body'); 15 | }); 16 | 17 | this.route('settings'); 18 | 19 | // For tests 20 | this.route('acceptance-tests', function() { 21 | this.route('record-array'); 22 | }); 23 | }); 24 | 25 | export default Router; 26 | -------------------------------------------------------------------------------- /vertical-collection/tests/dummy/app/routes/acceptance-tests/record-array/controller.js: -------------------------------------------------------------------------------- 1 | import Controller from '@ember/controller'; 2 | import { action } from '@ember/object'; 3 | import { inject as service } from '@ember/service'; 4 | import { tracked } from '@glimmer/tracking'; 5 | 6 | /* eslint-disable ember/no-computed-properties-in-native-classes */ 7 | import { or } from '@ember/object/computed'; 8 | 9 | export default class extends Controller { 10 | @service() store; 11 | @tracked prefixed=true; 12 | @tracked vcShown=true; 13 | 14 | @tracked partial=undefined; 15 | @or('partial', 'model') items; 16 | 17 | @tracked firstVisibleId=undefined; 18 | 19 | @action 20 | updateItems() { 21 | this.store.unloadAll('number-item'); 22 | this.store.query('number-item', { length: 5 }); 23 | } 24 | 25 | @action 26 | showLast(count) { 27 | let length = this.model.length; 28 | this.partial = this.model.slice(length - count); 29 | } 30 | 31 | @action 32 | showAll() { 33 | this.partial = undefined; 34 | } 35 | 36 | @action 37 | showPrefixed() { 38 | this.prefixed = !this.prefixed; 39 | } 40 | 41 | @action 42 | hideVC() { 43 | this.vcShown = false; 44 | } 45 | 46 | @action 47 | showVC() { 48 | this.vcShown = true; 49 | } 50 | 51 | @action 52 | firstVisibleChanged(item) { 53 | this.firstVisibleId = item.id; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /vertical-collection/tests/dummy/app/routes/acceptance-tests/record-array/route.js: -------------------------------------------------------------------------------- 1 | import Route from '@ember/routing/route'; 2 | import { inject as service } from '@ember/service'; 3 | 4 | export default class extends Route { 5 | @service() store; 6 | 7 | model() { 8 | return this.store.findAll('number-item'); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /vertical-collection/tests/dummy/app/routes/acceptance-tests/record-array/template.hbs: -------------------------------------------------------------------------------- 1 | {{#if this.vcShown}} 2 |
    3 | 11 | 12 | 13 |
    14 | {{/if}} 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /vertical-collection/tests/dummy/app/routes/application/route.js: -------------------------------------------------------------------------------- 1 | import Route from '@ember/routing/route'; 2 | 3 | export default Route.extend({}); 4 | -------------------------------------------------------------------------------- /vertical-collection/tests/dummy/app/routes/application/template.hbs: -------------------------------------------------------------------------------- 1 |
    2 |
    3 |

    4 | 5 | HTML Next Logo 6 | Vertical Collection 7 | 8 |

    9 | 13 |
    14 |
    15 |
    16 |
    17 |
    18 | {{outlet}} 19 |
    20 |
    21 |
    22 | 23 | -------------------------------------------------------------------------------- /vertical-collection/tests/dummy/app/routes/components/number-slide/component.js: -------------------------------------------------------------------------------- 1 | import { alias } from '@ember/object/computed'; 2 | import Component from '@ember/component'; 3 | import { computed } from '@ember/object'; 4 | import { htmlSafe } from '@ember/template'; 5 | import layout from './template'; 6 | 7 | function numberToOpacity(number) { 8 | let r = number % 255; 9 | 10 | if (r === 0) { 11 | return 1; 12 | } 13 | if (r === 254) { 14 | return 0; 15 | } 16 | 17 | return (255 / r).toFixed(3); 18 | } 19 | 20 | export default Component.extend({ 21 | tagName: 'number-slide', 22 | attributeBindings: ['style'], 23 | isDynamic: false, 24 | prefixed: false, 25 | style: computed('isDynamic', 'item', function() { 26 | let item = this.item; 27 | let isDynamic = this.isDynamic; 28 | 29 | let { 30 | height, 31 | number 32 | } = item; 33 | 34 | let opacity = numberToOpacity(number); 35 | let styleStr = `background: rgba(0,125,255,${opacity});`; 36 | 37 | if (isDynamic) { 38 | styleStr += `height: ${Math.round(height)}px; box-sizing: content-box;`; 39 | } 40 | 41 | return htmlSafe(styleStr); 42 | }), 43 | layout, 44 | itemIndex: 0, 45 | item: null, 46 | number: alias('item.number') 47 | }); 48 | -------------------------------------------------------------------------------- /vertical-collection/tests/dummy/app/routes/components/number-slide/template.hbs: -------------------------------------------------------------------------------- 1 |
    {{if this.prefixed this.number this.item.prefixed}}
    2 |
    ({{this.itemIndex}})
    3 | -------------------------------------------------------------------------------- /vertical-collection/tests/dummy/app/routes/examples/dbmon/components/dbmon-row/component.js: -------------------------------------------------------------------------------- 1 | import { alias } from '@ember/object/computed'; 2 | import Component from '@ember/component'; 3 | import { computed } from '@ember/object'; 4 | 5 | export default Component.extend({ 6 | 7 | tagName: 'tr', 8 | 9 | queries: alias('db.queries'), 10 | 11 | topFiveQueries: computed('queries', function() { 12 | let queries = this.queries || []; 13 | let topFiveQueries = queries.slice(0, 5); 14 | 15 | while (topFiveQueries.length < 5) { 16 | topFiveQueries.push({ query: '' }); 17 | } 18 | 19 | return topFiveQueries.map(function(query, index) { 20 | return { 21 | key: String(index), 22 | query: query.query, 23 | elapsed: query.elapsed ? formatElapsed(query.elapsed) : '', 24 | className: elapsedClass(query.elapsed) 25 | }; 26 | }); 27 | 28 | }), 29 | 30 | countClassName: computed('queries', function() { 31 | let queries = this.queries || []; 32 | let countClassName = 'label'; 33 | 34 | if (queries.length >= 20) { 35 | countClassName += ' label-important'; 36 | } else if (queries.length >= 10) { 37 | countClassName += ' label-warning'; 38 | } else { 39 | countClassName += ' label-success'; 40 | } 41 | 42 | return countClassName; 43 | }) 44 | 45 | }); 46 | 47 | function elapsedClass(elapsed) { 48 | if (elapsed >= 10.0) { 49 | return 'elapsed warn_long'; 50 | } else if (elapsed >= 1.0) { 51 | return 'elapsed warn'; 52 | } else { 53 | return 'elapsed short'; 54 | } 55 | } 56 | 57 | const _base = String.prototype; 58 | 59 | _base.lpad = _base.lpad || function(padding, toLength) { 60 | return padding.repeat((toLength - this.length) / padding.length).concat(this); 61 | }; 62 | 63 | function formatElapsed(value) { 64 | let str = parseFloat(value).toFixed(2); 65 | 66 | if (value > 60) { 67 | const minutes = Math.floor(value / 60); 68 | const comps = (value % 60).toFixed(2).split('.'); 69 | const seconds = comps[0].lpad('0', 2); 70 | str = `${minutes}:${seconds}.${comps[1]}`; 71 | } 72 | return str; 73 | } 74 | -------------------------------------------------------------------------------- /vertical-collection/tests/dummy/app/routes/examples/dbmon/components/dbmon-row/template.hbs: -------------------------------------------------------------------------------- 1 | 2 | {{this.db.id}} 3 | 4 | 5 | 6 | {{this.queries.length}} 7 | 8 | 9 | {{#each this.topFiveQueries key="@index" as |query|}} 10 | {{query.elapsed}} 11 |
    12 |
    {{query.query}}
    13 |
    14 |
    15 | {{/each}} 16 | -------------------------------------------------------------------------------- /vertical-collection/tests/dummy/app/routes/examples/dbmon/controller.js: -------------------------------------------------------------------------------- 1 | import Controller from '@ember/controller'; 2 | 3 | export default Controller.extend({}); 4 | -------------------------------------------------------------------------------- /vertical-collection/tests/dummy/app/routes/examples/dbmon/route.js: -------------------------------------------------------------------------------- 1 | import Route from '@ember/routing/route'; 2 | import { action } from '@ember/object'; 3 | import { later, next, cancel } from '@ember/runloop'; 4 | import getData from 'dummy/lib/get-data'; 5 | import { tracked } from '@glimmer/tracking'; 6 | 7 | class ModelData { 8 | @tracked data; 9 | } 10 | 11 | export default class extends Route { 12 | numRows=100; 13 | _nextLoad=null; 14 | 15 | model() { 16 | let model = new ModelData(); 17 | model.data = getData(this.numRows); 18 | return model; 19 | } 20 | 21 | afterModel() { 22 | later(this, this.loadSamples, 100); 23 | } 24 | 25 | loadSamples() { 26 | this.currentModel.data = getData(this.numRows); 27 | this._nextLoad = next(this, this.loadSamples); 28 | } 29 | 30 | @action 31 | addRow() { 32 | this.numRows++; 33 | } 34 | 35 | @action 36 | removeRow() { 37 | this.numRows--; 38 | } 39 | 40 | @action 41 | willTransition() { 42 | cancel(this._nextLoad); 43 | this.currentModel.data = null; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /vertical-collection/tests/dummy/app/routes/examples/dbmon/template.hbs: -------------------------------------------------------------------------------- 1 |
    2 |
    3 |
    4 |

    Demo

    5 |
    6 | {{! BEGIN-SNIPPET dbmon-example }} 7 |
    8 | 9 | 10 | 19 | 20 | 21 | 22 |
    23 |
    24 | {{! END-SNIPPET }} 25 |
    26 |
    27 |
    28 |

    Vertical Collection

    29 |

    30 | The Vertical Collection smartly hides and removes off screen content. 31 |

    32 |

    33 | This makes building high frame-rate or render expensive components easier by 34 | focusing only on what's on screen now. 35 |

    36 |

    Code for Demo

    37 | 38 |
    39 |
    40 |
    41 | -------------------------------------------------------------------------------- /vertical-collection/tests/dummy/app/routes/examples/flexible-layout/controller.js: -------------------------------------------------------------------------------- 1 | import Controller from '../infinite-scroll/controller'; 2 | 3 | export default class extends Controller {} 4 | -------------------------------------------------------------------------------- /vertical-collection/tests/dummy/app/routes/examples/flexible-layout/route.js: -------------------------------------------------------------------------------- 1 | import Route from '../infinite-scroll/route'; 2 | 3 | export default Route.extend({}); 4 | -------------------------------------------------------------------------------- /vertical-collection/tests/dummy/app/routes/examples/flexible-layout/template.hbs: -------------------------------------------------------------------------------- 1 |
    2 |
    3 |
    4 |

    Demo

    5 | {{! BEGIN-SNIPPET flexible-layout-example }} 6 |
    7 | 13 | 14 | 15 |
    16 | {{! END-SNIPPET }} 17 |
    18 |
    19 |

    Flexible Layouts

    20 |

    21 | The Vertical Collection doesn't care if your items are of a uniform 22 | size or type. 23 |

    24 |

    25 | This layout agnostic behavior will improve even more in upcoming releases 26 | with integration with the pre-render component. 27 |

    28 |

    Code for Demo

    29 | 30 |
    31 |
    32 |
    33 | -------------------------------------------------------------------------------- /vertical-collection/tests/dummy/app/routes/examples/index/template.hbs: -------------------------------------------------------------------------------- 1 |

    Demos

    2 |
    3 |
    4 |
    5 |
      6 |
    • 7 | dbMon 8 |
    • 9 |
    • 10 | Infinite Scroll 11 |
    • 12 |
    • 13 | Flexible Layout 14 |
    • 15 | 16 |
    • 17 | Scrollable Body 18 |
    • 19 |
    20 |
    21 |
    22 |

    Don't see what you're looking for?

    23 |

    24 | If you think you need to support a behavior and are unsure if 25 | smoke-and-mirrors can handle the situation, open an issue or 26 | ask on discord 27 |

    28 |
    29 |
    30 |
    31 | -------------------------------------------------------------------------------- /vertical-collection/tests/dummy/app/routes/examples/infinite-scroll/controller.js: -------------------------------------------------------------------------------- 1 | import Controller from '@ember/controller'; 2 | import getNumbers from 'dummy/lib/get-numbers'; 3 | import { action } from '@ember/object'; 4 | import { tracked } from '@glimmer/tracking'; 5 | 6 | export default class extends Controller { 7 | 8 | @tracked numImages = 5; 9 | @tracked someProperty = 50; 10 | 11 | @action 12 | loadAbove() { 13 | let first = this.model.data.first; 14 | let numbers = getNumbers(first - 20, 20); 15 | let model = this.model.data.numbers; 16 | model.unshiftObjects(numbers); 17 | this.model.set('data.first', first - 20); 18 | } 19 | 20 | @action 21 | loadBelow() { 22 | let last = this.model.data.last; 23 | let numbers = getNumbers(last, 20); 24 | let model = this.model.data.numbers; 25 | model.pushObjects(numbers); 26 | this.model.set('data.last', last + 20); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /vertical-collection/tests/dummy/app/routes/examples/infinite-scroll/route.js: -------------------------------------------------------------------------------- 1 | import { A } from '@ember/array'; 2 | import Route from '@ember/routing/route'; 3 | import getNumbers from 'dummy/lib/get-numbers'; 4 | import { tracked } from '@glimmer/tracking'; 5 | 6 | class ModelData { 7 | @tracked data; 8 | } 9 | 10 | export default Route.extend({ 11 | model() { 12 | let model = new ModelData(); 13 | model.data = { 14 | numbers: A(getNumbers(0, 100)), 15 | first: 0, 16 | last: 100 17 | }; 18 | return model; 19 | }, 20 | 21 | actions: { 22 | willTransition() { 23 | this.currentModel = null; 24 | } 25 | } 26 | }); 27 | -------------------------------------------------------------------------------- /vertical-collection/tests/dummy/app/routes/examples/infinite-scroll/template.hbs: -------------------------------------------------------------------------------- 1 |
    2 |
    3 |
    4 |

    Demo

    5 | {{! BEGIN-SNIPPET infinite-scroll-example }} 6 |
    7 | 15 | 16 | 17 |
    18 | {{! END-SNIPPET }} 19 |
    20 |
    21 |

    Infinite Scroll

    22 |

    23 | The Vertical Collection can be used to quickly build a robust infinite scroll. 24 |

    25 |

    26 | Your infinite scroll can be bi-directional, loading new content above or 27 | below. 28 |

    29 |

    30 | Combined with `idForFirstItem`, this makes it very easy to load a user 31 | in-media-res: ideal for caching their position in a long feed for when 32 | they return. 33 |

    34 |

    Code for Demo

    35 | 36 |
    37 |
    38 |
    39 | -------------------------------------------------------------------------------- /vertical-collection/tests/dummy/app/routes/examples/reduce-debug/controller.js: -------------------------------------------------------------------------------- 1 | import Controller from '@ember/controller'; 2 | import { action } from '@ember/object'; 3 | import { tracked } from '@glimmer/tracking'; 4 | 5 | export default class extends Controller { 6 | @tracked numImages = 50; 7 | 8 | @tracked isFiltered = false; 9 | 10 | @action 11 | filter() { 12 | let model = this.model.numbers; 13 | this.isFiltered = !this.isFiltered; 14 | 15 | if (!this.isFiltered) { 16 | this.model.set('filtered', model); 17 | } else { 18 | let filtered = model.filter((item) => item.number < 25); 19 | this.model.set('filtered', filtered); 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /vertical-collection/tests/dummy/app/routes/examples/reduce-debug/route.js: -------------------------------------------------------------------------------- 1 | import Route from '@ember/routing/route'; 2 | import getNumbers from 'dummy/lib/get-numbers'; 3 | 4 | export default Route.extend({ 5 | 6 | model() { 7 | let numbers = getNumbers(0, 50); 8 | return { 9 | data: { 10 | numbers, 11 | first: 0, 12 | last: 50, 13 | filtered: numbers 14 | } 15 | }; 16 | }, 17 | 18 | actions: { 19 | willTransition() { 20 | this.currentModel = null; 21 | } 22 | } 23 | 24 | }); 25 | -------------------------------------------------------------------------------- /vertical-collection/tests/dummy/app/routes/examples/reduce-debug/template.hbs: -------------------------------------------------------------------------------- 1 |
    2 |
    3 |
    4 |

    Demo

    5 |
    6 | 11 |
    12 | 13 |
    14 |
    15 |
    16 |
    17 |
    18 |

    Reduce Debug

    19 |

    20 | Ensure we can shrink the array safely. 21 |

    22 |

    23 | 24 |

    25 |

    Code for Demo

    26 |
    27 |
    28 |
    29 | -------------------------------------------------------------------------------- /vertical-collection/tests/dummy/app/routes/examples/scrollable-body/controller.js: -------------------------------------------------------------------------------- 1 | import Controller from '../infinite-scroll/controller'; 2 | 3 | export default Controller.extend({}); 4 | -------------------------------------------------------------------------------- /vertical-collection/tests/dummy/app/routes/examples/scrollable-body/route.js: -------------------------------------------------------------------------------- 1 | import Route from '../infinite-scroll/route'; 2 | 3 | export default Route.extend({}); 4 | -------------------------------------------------------------------------------- /vertical-collection/tests/dummy/app/routes/examples/scrollable-body/template.hbs: -------------------------------------------------------------------------------- 1 |
    2 |
    3 |
    4 | {{! BEGIN-SNIPPET scrollable-body-example }} 5 | 10 | 11 | 12 | {{! END-SNIPPET }} 13 |
    14 |
    15 |

    Scrolling based on the whole page.

    16 |

    Code for Demo

    17 | 18 |
    19 |
    20 |
    21 | -------------------------------------------------------------------------------- /vertical-collection/tests/dummy/app/routes/index/controller.js: -------------------------------------------------------------------------------- 1 | import Controller from '@ember/controller'; 2 | import config from 'dummy/config/environment'; 3 | import { VERSION } from '@ember/version'; 4 | 5 | export default Controller.extend({ 6 | version: config.VERSION, 7 | emberVersion: VERSION 8 | }); 9 | -------------------------------------------------------------------------------- /vertical-collection/tests/dummy/app/routes/index/route.js: -------------------------------------------------------------------------------- 1 | import Route from '@ember/routing/route'; 2 | 3 | export default Route.extend({}); 4 | -------------------------------------------------------------------------------- /vertical-collection/tests/dummy/app/routes/index/template.hbs: -------------------------------------------------------------------------------- 1 |

    Overview

    2 |

    3 | Sometimes being "ambitious" gets you in trouble. When it does, Smoke-and-mirrors is here to put out your Ember fire. 4 |

    5 |
    6 |
    7 |
    8 |

    Updates

    9 |
      10 |
    • 11 | This repo is currently running vertical-collection {{this.version}} against Ember {{this.emberVersion}} 12 |
    • 13 |
    14 |
    15 |
    16 |

    Philosophy

    17 |

    18 | Every component and primitive offered in this library has been designed with 19 | two goals in mind. 20 |

    21 |
      22 |
    1. Large, high performance Javascript applications should be easy to build.
    2. 23 |
    3. Performance should not sacrifice flexibility.
    4. 24 |
    25 |

    26 | Smoke and Mirrors will try it's best to allow you to keep the conventions, structures, 27 | and layouts you want. If you can't figure out how to do something you want to do, 28 | open an issue, and either we will point you to an existing demo, or create a new one. 29 |

    30 |

    31 | Feel free to ask questions on the Ember discord server. 32 |

    33 |
    34 |
    35 |
    36 | 37 | -------------------------------------------------------------------------------- /vertical-collection/tests/dummy/app/routes/settings/snippets/defaults.js: -------------------------------------------------------------------------------- 1 | export default 2 | /* ! BEGIN-SNIPPET vertical-collection-defaults-example */ 3 | { 4 | // basics 5 | tagName: '', 6 | 7 | // required 8 | 9 | // Positional parameter, e.g. 10 | // 11 | // `` 12 | // 13 | // Note: An alias for this property named `content` 14 | // exists solely for Ember 1.11 support. The alias 15 | // should not be used with any more recent version 16 | // of Ember and will be removed in future versions. 17 | items: null, 18 | 19 | // Can be an integer, but also attempts to work 20 | // with em, rem, px, and percentage values for things 21 | // like flex. 22 | estimateHeight: null, 23 | 24 | // performance 25 | 26 | // This key is the property used by the collection 27 | // to determine whether an array mutation is an 28 | // append, prepend, or complete replacement. It is 29 | // also the key that is passed to the actions, and 30 | // can be used to restore scroll position with 31 | // `idForFirstItem`. 32 | // 33 | // Note: `@identity` is a randomly generated value. 34 | // If you want to save the id, use a unique property 35 | // on your model (e.g. the `id` field on Ember Data 36 | // models) 37 | key: '@identity', 38 | 39 | // Determines the rendering strategy. If set to true, 40 | // will use a simpler strategy that is much faster, 41 | // but requires all item heights to be the same. 42 | staticHeight: false, 43 | 44 | // The size of the buffer before and after the 45 | // collection. Represents a static number of components 46 | // that will be added, such that: 47 | // 48 | // numComponents === Math.ceil(containerHeight / estimateHeight) + (bufferSize * 2) + 1 49 | bufferSize: 0, 50 | 51 | // actions 52 | 53 | // Each action has the signature (item, index) => {} 54 | firstReached: null, 55 | lastReached: null, 56 | firstVisibleChanged: null, 57 | lastVisibleChanged: null, 58 | 59 | // initial state 60 | 61 | // Id for the first item to be rendered. Will be the 62 | // top item by default, and the bottom item if 63 | // `renderFromLast` is set. 64 | idForFirstItem: null, 65 | 66 | // Tells the collection to render from the last item. 67 | renderFromLast: false, 68 | 69 | // scroll setup 70 | 71 | // Selector for the scrollContainer. The collection 72 | // will traverse its ancestry to find the first element 73 | // that matches the selector. Defaults to '*', which 74 | // will match the immediate parent of the collection. 75 | containerSelector: '*' 76 | } 77 | /* ! END-SNIPPET vertical-collection-defaults-example */ 78 | ; 79 | -------------------------------------------------------------------------------- /vertical-collection/tests/dummy/app/routes/settings/template.hbs: -------------------------------------------------------------------------------- 1 |
    2 |
    3 |
    4 |

    Available Settings (with defaults)

    5 | 6 |
    7 |
    8 |

    Vertical Collection

    9 |
    10 |
    11 |
    12 | -------------------------------------------------------------------------------- /vertical-collection/tests/dummy/app/serializers/application.js: -------------------------------------------------------------------------------- 1 | import JSONAPISerializer from '@ember-data/serializer/json-api'; 2 | 3 | export default JSONAPISerializer.extend({ 4 | normalizeResponse(_, __, payload) { 5 | return payload; 6 | } 7 | }); 8 | -------------------------------------------------------------------------------- /vertical-collection/tests/dummy/app/styles/app.css: -------------------------------------------------------------------------------- 1 | body { 2 | height: 100%; 3 | } 4 | 5 | h1 { 6 | color: #222; 7 | font-size: 2rem; 8 | line-height: 2.1em; 9 | margin: 0.25em auto; 10 | padding: 0; 11 | } 12 | 13 | header { 14 | background: #efd967; 15 | border-bottom: solid 1px #e4ce5c; 16 | } 17 | 18 | nav { 19 | font-size: 2rem; 20 | line-height: 2.5em; 21 | color: #333; 22 | text-align: center; 23 | } 24 | 25 | nav a { 26 | color: #222; 27 | } 28 | 29 | .Query { 30 | position: relative; 31 | } 32 | 33 | .Query:hover .popover { 34 | top: 10px; 35 | left: -100px; 36 | width: 200px; 37 | display: block; 38 | } 39 | 40 | .table-wrapper, 41 | .scrollable { 42 | display: block; 43 | height: 500px; 44 | overflow: scroll; 45 | position: relative; 46 | background: #fff; 47 | } 48 | 49 | .scrollable.with-pixel-max-height { 50 | height: initial; 51 | max-height: 200px; 52 | } 53 | 54 | .scrollable.with-percent-max-height { 55 | height: initial; 56 | max-height: 50%; 57 | } 58 | 59 | .scrollable.with-em-max-height { 60 | height: initial; 61 | max-height: 10em; 62 | } 63 | 64 | .scrollable.with-rem-max-height { 65 | height: initial; 66 | max-height: 20rem; 67 | } 68 | 69 | .table-wrapper.dark, 70 | .bg-dark { 71 | background: #d0d0d0; 72 | } 73 | 74 | .demo-wrapper { 75 | margin-top: 25px; 76 | padding: 15px; 77 | } 78 | 79 | .table-wrapper table { 80 | height: 500px; 81 | } 82 | 83 | .table-wrapper table tr { 84 | height: 37px; 85 | } 86 | 87 | .image-slide { 88 | width: 250px; 89 | height: 270px; 90 | margin: 0 auto; 91 | padding: 10px 0; 92 | position: relative; 93 | } 94 | 95 | number-slide { 96 | display: flex; 97 | align-items: center; 98 | justify-content: center; 99 | font-size: 2rem; 100 | width: 250px; 101 | height: 4em; 102 | line-height: 4em; 103 | margin: 10px auto; 104 | background: #1f2f30; 105 | border: 1px solid #bbb; 106 | box-shadow: 1px 1px 5px 0 #d0d0d0; 107 | color: #fff; 108 | } 109 | 110 | .grid-item number-slide { 111 | width: 100px; 112 | height: 100px; 113 | margin: 0 auto; 114 | } 115 | 116 | number-slide .number { 117 | font-size: 1.2em; 118 | font-weight: 900; 119 | } 120 | 121 | .grid-item number-slide .number { 122 | font-size: 0.8em; 123 | } 124 | 125 | number-slide .index { 126 | font-size: 2rem; 127 | color: #555; 128 | margin: 0 0.5em; 129 | } 130 | 131 | .grid-item number-slide .index { 132 | font-size: 1rem; 133 | } 134 | 135 | .image-slide.flexible { 136 | height: auto; 137 | } 138 | 139 | img.async-image { 140 | background: #1f2f30; 141 | border: 1px solid #bbb; 142 | box-shadow: 1px 1px 5px 0 #d0d0d0; 143 | } 144 | 145 | .grid-item, 146 | img.async-image.grid-image { 147 | background: #efefef; 148 | border: 1px solid #e0e0e0; 149 | width: 20%; 150 | height: 100px; 151 | margin: 0; 152 | box-sizing: border-box; 153 | float: left; 154 | } 155 | 156 | .grid-page { 157 | width: 100%; 158 | float: left; 159 | height: auto; 160 | min-height: 500px; 161 | } 162 | 163 | .grid-page::after { 164 | content: " "; 165 | display: block; 166 | float: none; 167 | clear: both; 168 | width: 100%; 169 | } 170 | 171 | .auto-box { 172 | float: left; 173 | } 174 | 175 | .image-slide.flexible img.async-image { 176 | height: auto; 177 | min-height: 250px; 178 | } 179 | 180 | .image-slide .loading-spinner { 181 | background: url("../spinnerLarge.gif") center center no-repeat; 182 | width: 100%; 183 | height: 100%; 184 | display: block; 185 | position: relative; 186 | float: left; 187 | } 188 | 189 | .text-white, 190 | .text-white:hover, 191 | .text-white:active, 192 | .text-white:focus { 193 | color: #fff; 194 | } 195 | 196 | .text-small { 197 | font-size: 1.4rem; 198 | } 199 | 200 | nav.text-small { 201 | line-height: 4.2rem; 202 | margin: 0.55rem 0; 203 | } 204 | 205 | nav.text-small a { 206 | margin: 0 0.5em; 207 | } 208 | 209 | img.auto-size { 210 | width: 100%; 211 | height: auto; 212 | } 213 | 214 | .display-inline { 215 | display: inline-block; 216 | } 217 | 218 | vertical-item { 219 | display: block; 220 | } 221 | -------------------------------------------------------------------------------- /vertical-collection/tests/dummy/config/ember-cli-toolbelts.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /vertical-collection/tests/dummy/config/ember-cli-update.json: -------------------------------------------------------------------------------- 1 | { 2 | "schemaVersion": "1.0.0", 3 | "packages": [ 4 | { 5 | "name": "ember-cli", 6 | "version": "5.2.0", 7 | "blueprints": [ 8 | { 9 | "name": "addon", 10 | "outputRepo": "https://github.com/ember-cli/ember-addon-output", 11 | "codemodsSource": "ember-addon-codemods-manifest@1", 12 | "isBaseBlueprint": true, 13 | "options": [ 14 | "--pnpm", 15 | "--no-welcome" 16 | ] 17 | } 18 | ] 19 | } 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /vertical-collection/tests/dummy/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 | usePnpm: true, 9 | scenarios: [ 10 | { 11 | name: 'ember-lts-3.28', 12 | npm: { 13 | devDependencies: { 14 | '@ember/test-helpers': '^2.9.3', 15 | 'ember-cli': '~4.12.0', 16 | 'ember-qunit': '^6.0.0', 17 | 'ember-source': '~3.28.0', 18 | }, 19 | }, 20 | }, 21 | { 22 | name: 'ember-lts-4.12', 23 | npm: { 24 | devDependencies: { 25 | 'ember-source': '~4.12.0', 26 | }, 27 | }, 28 | }, 29 | { 30 | name: 'ember-lts-5.12', 31 | npm: { 32 | devDependencies: { 33 | 'ember-source': '~5.12.0', 34 | }, 35 | }, 36 | }, 37 | { 38 | name: 'ember-6.1', 39 | npm: { 40 | devDependencies: { 41 | 'ember-source': '~6.1.0', 42 | }, 43 | }, 44 | }, 45 | { 46 | name: 'ember-release', 47 | npm: { 48 | devDependencies: { 49 | 'ember-source': await getChannelURL('release'), 50 | }, 51 | }, 52 | }, 53 | { 54 | name: 'ember-beta', 55 | npm: { 56 | devDependencies: { 57 | 'ember-source': await getChannelURL('beta'), 58 | }, 59 | }, 60 | }, 61 | { 62 | name: 'ember-canary', 63 | npm: { 64 | devDependencies: { 65 | 'ember-source': await getChannelURL('canary'), 66 | }, 67 | }, 68 | }, 69 | // The default `.travis.yml` runs this scenario via `yarn test`, 70 | // not via `ember try`. It's still included here so that running 71 | // `ember try:each` manually or from a customized CI config will run it 72 | // along with all the other scenarios. 73 | { 74 | name: 'ember-default', 75 | npm: { 76 | devDependencies: {}, 77 | }, 78 | }, 79 | embroiderSafe(), 80 | embroiderOptimized(), 81 | ], 82 | }; 83 | }; 84 | -------------------------------------------------------------------------------- /vertical-collection/tests/dummy/config/environment.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | let pkg = require('../../../package.json'); 3 | 4 | module.exports = function (environment) { 5 | let DEBUG = false; 6 | 7 | const ENV = { 8 | modulePrefix: 'dummy', 9 | podModulePrefix: 'dummy/routes', 10 | environment, 11 | rootURL: '/', 12 | locationType: 'hash', 13 | EmberENV: { 14 | EXTEND_PROTOTYPES: false, 15 | FEATURES: { 16 | // Here you can enable experimental features on an ember canary build 17 | // e.g. EMBER_NATIVE_DECORATOR_SUPPORT: true 18 | }, 19 | }, 20 | 21 | VERSION: pkg.version, 22 | 23 | APP: { 24 | // Here you can pass flags/options to your application instance 25 | // when it is created 26 | } 27 | }; 28 | 29 | // debugging 30 | if (DEBUG) { 31 | ENV.APP.LOG_LFANIMATION_RESOLUTION = true; 32 | ENV.APP.debugMode = true; 33 | ENV.APP.LOG_ACTIVE_GENERATION = true; 34 | ENV.APP.LOG_BINDINGS = true; 35 | ENV.APP.LOG_RESOLVER = true; 36 | ENV.APP.LOG_STACKTRACE_ON_DEPRECATION = true; 37 | ENV.APP.LOG_TRANSITIONS = true; 38 | ENV.APP.LOG_TRANSITIONS_INTERNAL = true; 39 | ENV.APP.LOG_VERSION = true; 40 | ENV.APP.LOG_VIEW_LOOKUPS = true; 41 | } else { 42 | ENV.APP.LOG_LFANIMATION_RESOLUTION = false; 43 | ENV.APP.debugMode = false; 44 | ENV.APP.LOG_ACTIVE_GENERATION = false; 45 | ENV.APP.LOG_BINDINGS = false; 46 | ENV.APP.LOG_RESOLVER = false; 47 | ENV.APP.LOG_STACKTRACE_ON_DEPRECATION = false; 48 | ENV.APP.LOG_TRANSITIONS = false; 49 | ENV.APP.LOG_TRANSITIONS_INTERNAL = false; 50 | ENV.APP.LOG_VERSION = false; 51 | ENV.APP.LOG_VIEW_LOOKUPS = false; 52 | } 53 | 54 | if (environment === 'test') { 55 | // Testem prefers this... 56 | ENV.locationType = 'none'; 57 | ENV.APP.rootElement = '#ember-testing'; 58 | ENV.APP.autoboot = false; 59 | } 60 | 61 | if (environment === 'production') { 62 | ENV.locationType = 'hash'; 63 | ENV.rootURL = '/vertical-collection'; 64 | } 65 | 66 | return ENV; 67 | }; 68 | -------------------------------------------------------------------------------- /vertical-collection/tests/dummy/config/optional-features.json: -------------------------------------------------------------------------------- 1 | { 2 | "application-template-wrapper": false, 3 | "default-async-observers": true, 4 | "jquery-integration": false, 5 | "template-only-glimmer-components": true 6 | } 7 | -------------------------------------------------------------------------------- /vertical-collection/tests/dummy/config/targets.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const browsers = [ 4 | 'last 1 Chrome versions', 5 | 'last 1 Firefox versions', 6 | 'last 1 Safari versions' 7 | ]; 8 | 9 | module.exports = { 10 | browsers 11 | }; 12 | -------------------------------------------------------------------------------- /vertical-collection/tests/dummy/public/HTML-Next.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/html-next/vertical-collection/1990e9fa0a78a2c052dd48ba984fad0f104743aa/vertical-collection/tests/dummy/public/HTML-Next.png -------------------------------------------------------------------------------- /vertical-collection/tests/dummy/public/crossdomain.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 15 | 16 | -------------------------------------------------------------------------------- /vertical-collection/tests/dummy/public/robots.txt: -------------------------------------------------------------------------------- 1 | # http://www.robotstxt.org 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /vertical-collection/tests/helpers/array.js: -------------------------------------------------------------------------------- 1 | import EmberArray, { A } from '@ember/array'; 2 | import { run } from '@ember/runloop'; 3 | import { settled } from '@ember/test-helpers'; 4 | 5 | export function prepend(context, itemsToPrepend) { 6 | const items = context.items; 7 | 8 | run(() => { 9 | if (items.unshiftObjects) { 10 | items.unshiftObjects(itemsToPrepend); 11 | } else { 12 | context.set('items', itemsToPrepend.concat(items)); 13 | } 14 | }); 15 | 16 | return settled(); 17 | } 18 | 19 | export function append(context, itemsToAppend) { 20 | const items = context.items; 21 | 22 | run(() => { 23 | if (items.pushObjects) { 24 | items.pushObjects(itemsToAppend); 25 | } else { 26 | context.set('items', items.concat(itemsToAppend)); 27 | } 28 | }); 29 | 30 | return settled(); 31 | } 32 | 33 | export function emptyArray(context) { 34 | const items = context.items; 35 | 36 | run(() => { 37 | if (items.clear) { 38 | items.clear(); 39 | } else { 40 | context.set('items', []); 41 | } 42 | }); 43 | 44 | return settled(); 45 | } 46 | 47 | export function replaceArray(context, items) { 48 | const oldItems = context.items; 49 | 50 | run(() => { 51 | if (EmberArray.detect(oldItems)) { 52 | context.set('items', A(items)); 53 | } else { 54 | context.set('items', items); 55 | } 56 | }); 57 | 58 | return settled(); 59 | } 60 | 61 | export function move(context, sourceItemIdx, destItemIdx) { 62 | const items = context.items; 63 | let destItem, sourceItem; 64 | 65 | run(() => { 66 | if (items.objectAt && items.removeObject && items.insertAt) { 67 | // Ember Array 68 | destItem = items.objectAt(destItemIdx); 69 | sourceItem = items.objectAt(sourceItemIdx); 70 | items.removeObject(sourceItem); 71 | destItemIdx = items.indexOf(destItem) + 1; 72 | items.insertAt(destItemIdx, sourceItem); 73 | } else { 74 | // native array 75 | destItem = items[destItemIdx]; 76 | sourceItem = items[sourceItemIdx]; 77 | items.splice(sourceItemIdx, 1); 78 | destItemIdx = items.indexOf(destItem) + 1; 79 | items.splice(destItemIdx, 0, sourceItem); 80 | // if we are not using Ember Arrays we need to set `items` to a new array 81 | // instance to trigger a recompute on `virtualComponents` 82 | context.set('items', [].concat(items)); 83 | } 84 | }); 85 | 86 | return settled(); 87 | } 88 | -------------------------------------------------------------------------------- /vertical-collection/tests/helpers/destroy-app.js: -------------------------------------------------------------------------------- 1 | import { run } from '@ember/runloop'; 2 | 3 | export default function destroyApp(application) { 4 | run(application, 'destroy'); 5 | } 6 | -------------------------------------------------------------------------------- /vertical-collection/tests/helpers/index.js: -------------------------------------------------------------------------------- 1 | import { 2 | setupApplicationTest as upstreamSetupApplicationTest, 3 | setupRenderingTest as upstreamSetupRenderingTest, 4 | setupTest as upstreamSetupTest, 5 | } from 'ember-qunit'; 6 | 7 | // This file exists to provide wrappers around ember-qunit's / ember-mocha's 8 | // test setup functions. This way, you can easily extend the setup that is 9 | // needed per test type. 10 | 11 | function setupApplicationTest(hooks, options) { 12 | upstreamSetupApplicationTest(hooks, options); 13 | 14 | // Additional setup for application tests can be done here. 15 | // 16 | // For example, if you need an authenticated session for each 17 | // application test, you could do: 18 | // 19 | // hooks.beforeEach(async function () { 20 | // await authenticateSession(); // ember-simple-auth 21 | // }); 22 | // 23 | // This is also a good place to call test setup functions coming 24 | // from other addons: 25 | // 26 | // setupIntl(hooks); // ember-intl 27 | // setupMirage(hooks); // ember-cli-mirage 28 | } 29 | 30 | function setupRenderingTest(hooks, options) { 31 | upstreamSetupRenderingTest(hooks, options); 32 | 33 | // Additional setup for rendering tests can be done here. 34 | } 35 | 36 | function setupTest(hooks, options) { 37 | upstreamSetupTest(hooks, options); 38 | 39 | // Additional setup for unit tests can be done here. 40 | } 41 | 42 | export { setupApplicationTest, setupRenderingTest, setupTest }; 43 | -------------------------------------------------------------------------------- /vertical-collection/tests/helpers/measurement.js: -------------------------------------------------------------------------------- 1 | export function containerHeight(itemContainer) { 2 | return itemContainer.offsetHeight; 3 | } 4 | 5 | export function paddingBefore(itemContainer) { 6 | return itemContainer.firstElementChild.offsetHeight; 7 | } 8 | 9 | export function paddingAfter(itemContainer) { 10 | return itemContainer.lastElementChild.offsetHeight; 11 | } 12 | -------------------------------------------------------------------------------- /vertical-collection/tests/helpers/module-for-acceptance.js: -------------------------------------------------------------------------------- 1 | import { Promise } from 'rsvp'; 2 | import { module } from 'qunit'; 3 | import startApp from '../helpers/start-app'; 4 | import destroyApp from '../helpers/destroy-app'; 5 | 6 | export default function(name, options = {}) { 7 | module(name, { 8 | beforeEach() { 9 | this.application = startApp(); 10 | 11 | if (options.beforeEach) { 12 | return options.beforeEach.apply(this, arguments); 13 | } 14 | }, 15 | 16 | afterEach() { 17 | let afterEach = options.afterEach && options.afterEach.apply(this, arguments); 18 | return Promise.resolve(afterEach).then(() => destroyApp(this.application)); 19 | } 20 | }); 21 | } 22 | -------------------------------------------------------------------------------- /vertical-collection/tests/helpers/scroll-to.js: -------------------------------------------------------------------------------- 1 | import { settled, triggerEvent } from '@ember/test-helpers'; 2 | import { Promise } from 'rsvp'; 3 | 4 | /** 5 | 6 | Ported from https://github.com/emberjs/ember-test-helpers/blob/ea591697a98975737647b4c0043477cc6796569b/addon-test-support/%40ember/test-helpers/dom/scroll-to.ts 7 | 8 | This can be dropped in favor of the test-helpers provided scrollTo after 9 | Ember 2.18 support is dropped and test-helpers is upgraded. 10 | 11 | */ 12 | 13 | /** 14 | Scrolls DOM element or selector to the given coordinates. 15 | @public 16 | @param {string|HTMLElement} target the element or selector to trigger scroll on 17 | @param {Number} x x-coordinate 18 | @param {Number} y y-coordinate 19 | @return {Promise} resolves when settled 20 | 21 | @example 22 | 23 | Scroll DOM element to specific coordinates 24 | 25 | 26 | scrollTo('#my-long-div', 0, 0); // scroll to top 27 | scrollTo('#my-long-div', 0, 100); // scroll down 28 | */ 29 | export default function scrollTo( 30 | target, 31 | x, 32 | y 33 | ) { 34 | return Promise.resolve() 35 | .then(() => { 36 | if (!target) { 37 | throw new Error('Must pass an element or selector to `scrollTo`.'); 38 | } 39 | 40 | if (x === undefined || y === undefined) { 41 | throw new Error('Must pass both x and y coordinates to `scrollTo`.'); 42 | } 43 | 44 | let element = document.querySelector(target); 45 | if (!element) { 46 | throw new Error( 47 | `Element not found when calling \`scrollTo('${target}')\`.` 48 | ); 49 | } 50 | 51 | element.scrollTop = y; 52 | element.scrollLeft = x; 53 | 54 | triggerEvent(element, 'scroll'); 55 | 56 | return settled(); 57 | }); 58 | } 59 | -------------------------------------------------------------------------------- /vertical-collection/tests/helpers/start-app.js: -------------------------------------------------------------------------------- 1 | import { run } from '@ember/runloop'; 2 | import { merge } from '@ember/polyfills'; 3 | import Application from '../../app'; 4 | import config from '../../config/environment'; 5 | 6 | export default function startApp(attrs) { 7 | let attributes = merge({}, config.APP); 8 | attributes = merge(attributes, attrs); // use defaults, but you can override; 9 | attributes = merge(attributes, { autoboot: true }); // autoboot; 10 | 11 | return run(() => { 12 | let application = Application.create(attributes); 13 | application.setupForTesting(); 14 | application.injectTestHelpers(); 15 | return application; 16 | }); 17 | } 18 | -------------------------------------------------------------------------------- /vertical-collection/tests/helpers/test-scenarios.js: -------------------------------------------------------------------------------- 1 | import { A } from '@ember/array'; 2 | import ArrayProxy from '@ember/array/proxy'; 3 | import PromiseProxyMixin from '@ember/object/promise-proxy-mixin'; 4 | import { Promise } from 'rsvp'; 5 | import { test } from 'qunit'; 6 | import { hbs } from 'ember-cli-htmlbars'; 7 | import { settled, render } from '@ember/test-helpers'; 8 | 9 | const PromiseArray = ArrayProxy.extend(PromiseProxyMixin); 10 | 11 | export function testScenarios(description, scenarios, template, testFn, preRenderTestFn, setValuesBeforeRender) { 12 | for (const scenarioName in scenarios) { 13 | const scenario = scenarios[scenarioName]; 14 | 15 | test(`${description} | ${scenarioName}`, async function(assert) { 16 | for (let key in scenario) { 17 | const value = typeof scenario[key] === 'function' ? scenario[key]() : scenario[key]; 18 | this.set(key, value); 19 | } 20 | 21 | // An extra function to set values before render. Mostly to set the closure actions 22 | if (setValuesBeforeRender) { 23 | await setValuesBeforeRender.call(this, assert); 24 | } 25 | 26 | let renderCompletionPromise = render(template); 27 | 28 | if (preRenderTestFn) { 29 | await preRenderTestFn.call(this, assert); 30 | } else if(testFn) { 31 | await settled(); 32 | await testFn.call(this, assert); 33 | } 34 | 35 | await renderCompletionPromise; 36 | }); 37 | } 38 | } 39 | 40 | export const dynamicSimpleScenarioFor = generateScenario('Dynamic Standard Array', {}); 41 | export const dynamicEmberArrayScenarioFor = generateScenario('Dynamic Ember Array', {}, A); 42 | export const dynamicArrayProxyScenarioFor = generateScenario('Dynamic ArrayProxy', {}, createArrayProxy); 43 | export const dynamicPromiseArrayScenarioFor = generateScenario('Dynamic PromiseArray', {}, createPromiseArrayFunction); 44 | 45 | export const staticSimpleScenarioFor = generateScenario('Static Standard Array', { staticHeight: true }); 46 | export const staticEmberArrayScenarioFor = generateScenario('Static Standard Array', { staticHeight: true }, A); 47 | export const staticArrayProxyScenarioFor = generateScenario('Static ArrayProxy', { staticHeight: true }, createArrayProxy); 48 | export const staticPromiseArrayScenarioFor = generateScenario('Static PromiseArray', { staticHeight: true }, createPromiseArrayFunction); 49 | 50 | export const simpleScenariosFor = mergeScenarioGenerators( 51 | dynamicSimpleScenarioFor, 52 | staticSimpleScenarioFor 53 | ); 54 | 55 | export const standardScenariosFor = mergeScenarioGenerators( 56 | dynamicSimpleScenarioFor, 57 | dynamicEmberArrayScenarioFor, 58 | dynamicArrayProxyScenarioFor, 59 | 60 | staticSimpleScenarioFor, 61 | staticEmberArrayScenarioFor, 62 | staticArrayProxyScenarioFor 63 | ); 64 | 65 | export const scenariosFor = mergeScenarioGenerators( 66 | dynamicSimpleScenarioFor, 67 | dynamicEmberArrayScenarioFor, 68 | dynamicArrayProxyScenarioFor, 69 | dynamicPromiseArrayScenarioFor, 70 | 71 | staticSimpleScenarioFor, 72 | staticEmberArrayScenarioFor, 73 | staticArrayProxyScenarioFor, 74 | staticPromiseArrayScenarioFor 75 | ); 76 | 77 | export const standardTemplate = hbs` 78 |
    79 | 98 |
    102 | {{item.number}} {{i}} 103 |
    104 |
    105 |
    106 | `; 107 | 108 | function createArrayProxy(items) { 109 | return ArrayProxy.create({ content: A(items) }); 110 | } 111 | 112 | function createPromiseArrayFunction(items) { 113 | return function() { 114 | const promise = new Promise((resolve) => setTimeout(() => resolve(A(items.slice())), 10)); 115 | 116 | return PromiseArray.create({ promise }); 117 | }; 118 | } 119 | 120 | function generateScenario(name, defaultOptions, initializer) { 121 | return function(baseItems, options) { 122 | const items = initializer ? initializer(baseItems.slice()) : baseItems.slice(); 123 | const scenario = { items }; 124 | 125 | Object.assign(scenario, options); 126 | Object.assign(scenario, defaultOptions); 127 | 128 | return { [name]: scenario }; 129 | }; 130 | } 131 | 132 | function mergeScenarioGenerators(...scenarioGenerators) { 133 | return function(items, options) { 134 | return scenarioGenerators.reduce((scenarios, generator) => { 135 | return Object.assign(scenarios, generator(items, options)); 136 | }, {}); 137 | }; 138 | } 139 | -------------------------------------------------------------------------------- /vertical-collection/tests/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Dummy Tests 6 | 7 | 8 | 9 | {{content-for "head"}} 10 | {{content-for "test-head"}} 11 | 12 | 13 | 14 | 15 | 16 | {{content-for "head-footer"}} 17 | {{content-for "test-head-footer"}} 18 | 19 | 20 | {{content-for "body"}} 21 | {{content-for "test-body"}} 22 | 23 | 26 | 27 |
    28 |
    29 |
    30 |
    31 |
    32 |
    33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | {{content-for "body-footer"}} 41 | {{content-for "test-body-footer"}} 42 | 43 | 44 | -------------------------------------------------------------------------------- /vertical-collection/tests/integration/a11y-test.js: -------------------------------------------------------------------------------- 1 | import { module } from 'qunit'; 2 | import { setupRenderingTest } from '../helpers'; 3 | import { 4 | click, 5 | find, 6 | findAll, 7 | settled 8 | } from '@ember/test-helpers'; 9 | import scrollTo from '../helpers/scroll-to'; 10 | 11 | import getNumbers from 'dummy/lib/get-numbers'; 12 | 13 | import { 14 | testScenarios, 15 | scenariosFor, 16 | standardTemplate 17 | } from 'dummy/tests/helpers/test-scenarios'; 18 | 19 | module('vertical-collection', 'Integration | A11y Tests', function(hooks) { 20 | setupRenderingTest(hooks); 21 | 22 | testScenarios( 23 | 'The collection renders all items when renderAll is set', 24 | scenariosFor(getNumbers(0, 20), { renderAll: true }), 25 | standardTemplate, 26 | 27 | async function(assert) { 28 | assert.equal(findAll('.vertical-item').length, 20, 'correct number of items rendered'); 29 | } 30 | ); 31 | 32 | testScenarios( 33 | 'The collection can switch on renderAll after being rendered', 34 | scenariosFor(getNumbers(0, 20)), 35 | standardTemplate, 36 | 37 | async function(assert) { 38 | assert.equal(findAll('.vertical-item').length, 10, 'correct number of items rendered before'); 39 | 40 | this.set('renderAll', true); 41 | await settled(); // Wait for changes 42 | 43 | assert.equal(findAll('.vertical-item').length, 20, 'correct number of items rendered before'); 44 | } 45 | ); 46 | 47 | testScenarios( 48 | 'The collection renders occluded item labels correctly', 49 | scenariosFor(getNumbers(0, 20)), 50 | standardTemplate, 51 | 52 | async function(assert) { 53 | const occludedBefore = find('.occluded-content:first-of-type'); 54 | const occludedAfter = find('.occluded-content:last-of-type'); 55 | 56 | assert.equal(occludedBefore.textContent.trim(), '', 'occluded before text correct when no items before'); 57 | assert.equal(occludedAfter.textContent.trim(), 'And 10 items after', 'occluded after text correct when some items after'); 58 | 59 | await scrollTo('.scrollable', 0, 20); 60 | 61 | assert.equal(occludedBefore.textContent.trim(), 'And 1 item before', 'occluded before text correct when one item before'); 62 | assert.equal(occludedAfter.textContent.trim(), 'And 9 items after', 'occluded after text correct when some items after'); 63 | 64 | await scrollTo('.scrollable', 0, 180); 65 | 66 | assert.equal(occludedBefore.textContent.trim(), 'And 9 items before', 'occluded before text correct when some items before'); 67 | assert.equal(occludedAfter.textContent.trim(), 'And 1 item after', 'occluded after text correct when one item after'); 68 | 69 | await scrollTo('.scrollable', 0, 200); 70 | 71 | assert.equal(occludedBefore.textContent.trim(), 'And 10 items before', 'occluded before text correct when some items before'); 72 | assert.equal(occludedAfter.textContent.trim(), '', 'occluded after text correct when no items after'); 73 | } 74 | ); 75 | 76 | testScenarios( 77 | 'The collection pages correctly when occluded labels are clicked', 78 | scenariosFor(getNumbers(0, 20)), 79 | standardTemplate, 80 | 81 | async function(assert) { 82 | const occludedBefore = find('.occluded-content:first-of-type'); 83 | const occludedAfter = find('.occluded-content:last-of-type'); 84 | 85 | assert.equal(find('.vertical-item:first-of-type').textContent.trim(), '0 0', 'correct first item rendered'); 86 | assert.equal(find('.vertical-item:last-of-type').textContent.trim(), '9 9', 'correct last item rendered'); 87 | 88 | await click(occludedAfter); 89 | 90 | assert.equal(find('.vertical-item:first-of-type').textContent.trim(), '10 10', 'correct first item rendered'); 91 | assert.equal(find('.vertical-item:last-of-type').textContent.trim(), '19 19', 'correct last item rendered'); 92 | 93 | await click(occludedBefore); 94 | 95 | assert.equal(find('.vertical-item:first-of-type').textContent.trim(), '0 0', 'correct first item rendered'); 96 | assert.equal(find('.vertical-item:last-of-type').textContent.trim(), '9 9', 'correct last item rendered'); 97 | } 98 | ); 99 | }); 100 | -------------------------------------------------------------------------------- /vertical-collection/tests/integration/basic-test.js: -------------------------------------------------------------------------------- 1 | import { module, test } from 'qunit'; 2 | import { setupRenderingTest } from '../helpers'; 3 | import { hbs } from 'ember-cli-htmlbars'; 4 | import { 5 | find, 6 | findAll, 7 | settled, 8 | render 9 | } from '@ember/test-helpers'; 10 | import scrollTo from '../helpers/scroll-to'; 11 | 12 | import getNumbers from 'dummy/lib/get-numbers'; 13 | 14 | import { 15 | testScenarios, 16 | 17 | dynamicSimpleScenarioFor, 18 | staticSimpleScenarioFor, 19 | simpleScenariosFor, 20 | scenariosFor, 21 | standardTemplate 22 | } from 'dummy/tests/helpers/test-scenarios'; 23 | 24 | // Assert an odd timing: After initial render but before settledness. 25 | // 26 | async function assertAfterInitialRender(renderFn, assertFn) { 27 | renderFn(); 28 | await new Promise(resolve => requestAnimationFrame(resolve)); 29 | assertFn(); 30 | } 31 | 32 | module('vertical-collection', 'Integration | Basic Tests', function(hooks) { 33 | setupRenderingTest(hooks); 34 | 35 | testScenarios( 36 | 'The collection renders', 37 | scenariosFor(getNumbers(0, 1)), 38 | standardTemplate, 39 | 40 | async function(assert) { 41 | assert.strictEqual(findAll('.vertical-item').length, 1); 42 | } 43 | ); 44 | 45 | testScenarios( 46 | 'The collection renders when content is empty', 47 | scenariosFor([]), 48 | standardTemplate, 49 | 50 | async function(assert) { 51 | assert.strictEqual(findAll('.vertical-item').length, 0); 52 | } 53 | ); 54 | 55 | testScenarios( 56 | 'The collection renders with a key path set', 57 | scenariosFor([{ id: 1 }, { id: 2 }, { id: 3 }], { key: 'id' }), 58 | standardTemplate, 59 | 60 | async function(assert) { 61 | await settled(); 62 | const items = await findAll('.vertical-item'); 63 | assert.strictEqual(items.length, 3); 64 | } 65 | ); 66 | 67 | testScenarios( 68 | 'The collection renders correct number of components with bufferSize set', 69 | scenariosFor(getNumbers(0, 10), { estimateHeight: 200, bufferSize: 1 }), 70 | standardTemplate, 71 | 72 | async function(assert) { 73 | // Should render buffer on the bottom 74 | assert.strictEqual(findAll('.vertical-item').length, 2); 75 | 76 | await scrollTo('.scrollable', 0, 200); 77 | 78 | // Should render buffers on both sides 79 | assert.strictEqual(findAll('.vertical-item').length, 3); 80 | 81 | await scrollTo('.scrollable', 0, 2000); 82 | 83 | // Should render buffer on the top 84 | assert.strictEqual(findAll('.vertical-item').length, 2); 85 | } 86 | ); 87 | 88 | testScenarios( 89 | 'The collection renders with containerSelector set', 90 | simpleScenariosFor(getNumbers(0, 10)), 91 | 92 | hbs` 93 |
    94 |
    95 | 102 | 103 | {{item.number}} {{i}} 104 | 105 | 106 |
    107 |
    108 | `, 109 | 110 | async function(assert) { 111 | assert.strictEqual(findAll('vertical-item').length, 5); 112 | } 113 | ); 114 | 115 | testScenarios( 116 | 'The collection renders in the correct initial position when offset', 117 | staticSimpleScenarioFor(getNumbers(0, 10)), 118 | 119 | hbs` 120 |
    121 |
    122 | 129 | 130 | {{item.number}} {{i}} 131 | 132 | 133 |
    134 |
    135 | `, 136 | 137 | async function(assert) { 138 | let occludedBoundaries = findAll('.occluded-content'); 139 | 140 | assert.strictEqual(occludedBoundaries[0].getAttribute('style'), 'height: 0px;', 'Occluded height above is correct'); 141 | assert.strictEqual(occludedBoundaries[1].getAttribute('style'), 'height: 100px;', 'Occluded height below is correct'); 142 | assert.strictEqual(findAll('vertical-item').length, 5, 'Rendered correct number of items'); 143 | } 144 | ); 145 | 146 | testScenarios( 147 | 'The collection renders in the correct initial position with dynamic heights', 148 | dynamicSimpleScenarioFor(getNumbers(0, 10)), 149 | 150 | hbs` 151 |
    152 |
    153 | 159 | 160 | {{item.number}} {{i}} 161 | 162 | 163 |
    164 |
    165 | `, 166 | 167 | async function(assert) { 168 | let occludedBoundaries = findAll('.occluded-content'); 169 | 170 | assert.strictEqual(occludedBoundaries[0].getAttribute('style'), 'height: 0px;', 'Occluded height above is correct'); 171 | assert.strictEqual(occludedBoundaries[1].getAttribute('style'), 'height: 100px;', 'Occluded height below is correct'); 172 | assert.strictEqual(findAll('vertical-item').length, 5, 'Rendered correct number of items'); 173 | } 174 | ); 175 | 176 | testScenarios( 177 | 'The collection renders when yielded item has conditional', 178 | simpleScenariosFor([{ shouldRender: true }]), 179 | 180 | hbs` 181 |
    182 | 187 |
    188 | Content 189 | {{#if item.shouldRender}} 190 |
    191 | Conditional Content 192 |
    193 | {{/if}} 194 |
    195 |
    196 |
    197 | `, 198 | 199 | async function(assert) { 200 | assert.ok(true, 'No errors were thrown in the process'); 201 | } 202 | ); 203 | 204 | // eslint-disable-next-line qunit/require-expect 205 | test('The collection renders the initialRenderCount correctly', async function(assert) { 206 | assert.expect(5); 207 | this.set('items', getNumbers(0, 10)); 208 | 209 | assertAfterInitialRender(() => { 210 | render(hbs` 211 |
    212 | 217 | 218 | {{item.number}} {{i}} 219 | 220 | 221 |
    222 | `); 223 | }, () => { 224 | assert.strictEqual(findAll('vertical-item').length, 1, 'correct number of items rendered on initial pass'); 225 | assert.strictEqual(find('vertical-item').textContent.trim(), '0 0', 'correct item rendered'); 226 | }); 227 | 228 | await settled(); 229 | 230 | assert.strictEqual(findAll('vertical-item').length, 10, 'correctly updates the number of items rendered on second pass'); 231 | assert.strictEqual(find('vertical-item:first-of-type').textContent.trim(), '0 0', 'correct first item rendered'); 232 | assert.strictEqual(find('vertical-item:last-of-type').textContent.trim(), '9 9', 'correct last item rendered'); 233 | }); 234 | 235 | // eslint-disable-next-line qunit/require-expect 236 | test('The collection renders the initialRenderCount correctly if idForFirstItem is set', async function(assert) { 237 | assert.expect(5); 238 | this.set('items', getNumbers(0, 100)); 239 | 240 | assertAfterInitialRender(() => { 241 | render(hbs` 242 |
    243 | 250 | 251 | {{item.number}} {{i}} 252 | 253 | 254 |
    255 | `); 256 | }, () => { 257 | assert.strictEqual(findAll('vertical-item').length, 1, 'correct number of items rendered on initial pass'); 258 | assert.strictEqual(find('vertical-item').textContent.trim(), '20 20', 'correct item rendered'); 259 | }); 260 | 261 | await settled(); 262 | 263 | assert.strictEqual(findAll('vertical-item').length, 12, 'correctly updates the number of items rendered on second pass'); 264 | assert.strictEqual(find('vertical-item:first-of-type').textContent.trim(), '19 19', 'correct first item rendered'); 265 | assert.strictEqual(find('vertical-item:last-of-type').textContent.trim(), '30 30', 'correct last item rendered'); 266 | }); 267 | 268 | // eslint-disable-next-line qunit/require-expect 269 | test('The collection renders the initialRenderCount correctly if the count is more than the number of items', async function(assert) { 270 | assert.expect(4); 271 | this.set('items', getNumbers(0, 1)); 272 | 273 | assertAfterInitialRender(() => { 274 | render(hbs` 275 |
    276 | 281 | 282 | {{item.number}} {{i}} 283 | 284 | 285 |
    286 | `); 287 | }, () => { 288 | assert.strictEqual(findAll('vertical-item').length, 1, 'correct number of items rendered on initial pass'); 289 | assert.strictEqual(find('vertical-item').textContent.trim(), '0 0', 'correct item rendered'); 290 | }); 291 | 292 | await settled(); 293 | 294 | assert.strictEqual(findAll('vertical-item').length, 1, 'correctly updates the number of items rendered on second pass'); 295 | assert.strictEqual(find('vertical-item').textContent.trim(), '0 0', 'correct first item rendered'); 296 | }); 297 | 298 | testScenarios( 299 | 'The collection renders in the correctly when starting offscreen', 300 | scenariosFor(getNumbers(0, 100)), 301 | 302 | hbs` 303 |
    304 |
    305 | 311 | 312 | {{item.number}} {{i}} 313 | 314 | 315 |
    316 |
    317 | `, 318 | 319 | async function(assert) { 320 | assert.expect(2); 321 | 322 | assert.strictEqual(findAll('vertical-item').length, 7, 'Rendered correct number of items'); 323 | 324 | await scrollTo('.scrollable', 0, 500); 325 | 326 | assert.strictEqual(findAll('vertical-item').length, 9, 'Rendered correct number of items'); 327 | } 328 | ); 329 | 330 | testScenarios( 331 | 'The collection respects initial scroll position when rendered', 332 | simpleScenariosFor(getNumbers(0, 100)), 333 | 334 | hbs` 335 |
    336 |
    337 | 338 | {{#if this.renderCollection}} 339 | 340 |
    341 | Content 342 |
    343 |
    344 | {{/if}} 345 |
    346 | `, 347 | 348 | async function(assert) { 349 | let scrollContainer = find('.scrollable'); 350 | 351 | await scrollTo('.scrollable', 0, 500); 352 | 353 | assert.strictEqual(scrollContainer.scrollTop, 500, 'scrolled to correct position'); 354 | 355 | this.set('renderCollection', true); 356 | 357 | await settled(); 358 | 359 | assert.strictEqual(scrollContainer.scrollTop, 500, 'scroll position remains the same'); 360 | } 361 | ); 362 | }); 363 | -------------------------------------------------------------------------------- /vertical-collection/tests/integration/debug-test.js: -------------------------------------------------------------------------------- 1 | import { module } from 'qunit'; 2 | import { setupRenderingTest } from '../helpers'; 3 | import scrollTo from '../helpers/scroll-to'; 4 | 5 | import getNumbers from 'dummy/lib/get-numbers'; 6 | 7 | import { 8 | testScenarios, 9 | scenariosFor, 10 | standardTemplate 11 | } from 'dummy/tests/helpers/test-scenarios'; 12 | 13 | module('vertical-collection', 'Integration | Debug Tests', function(hooks) { 14 | setupRenderingTest(hooks); 15 | 16 | testScenarios( 17 | 'The collection renders the debug visualization when debugVis is set', 18 | scenariosFor(getNumbers(0, 100), { debugVis: true }), 19 | standardTemplate, 20 | 21 | async function(assert) { 22 | assert.ok(document.querySelector('.vertical-collection-visual-debugger'), 'visualization renders'); 23 | assert.equal(document.querySelectorAll('.vc_visualization-virtual-component').length, 20, 'correct number of visualization items rendered'); 24 | 25 | await scrollTo('.scrollable', 0, 400); 26 | 27 | assert.equal(document.querySelectorAll('.vc_visualization-virtual-component').length, 30, 'correct number of visualization items rendered'); 28 | 29 | await scrollTo('.scrollable', 0, 10000); 30 | 31 | assert.equal(document.querySelectorAll('.vc_visualization-virtual-component').length, 20, 'correct number of visualization items rendered'); 32 | } 33 | ); 34 | }); 35 | -------------------------------------------------------------------------------- /vertical-collection/tests/integration/measure-test.js: -------------------------------------------------------------------------------- 1 | import { module } from 'qunit'; 2 | import { setupRenderingTest } from '../helpers'; 3 | import { hbs } from 'ember-cli-htmlbars'; 4 | 5 | import { 6 | find, 7 | findAll, 8 | } from '@ember/test-helpers'; 9 | import scrollTo from '../helpers/scroll-to'; 10 | 11 | import getNumbers from 'dummy/lib/get-numbers'; 12 | 13 | import { paddingBefore, paddingAfter } from 'dummy/tests/helpers/measurement'; 14 | import { prepend, replaceArray } from 'dummy/tests/helpers/array'; 15 | 16 | import { 17 | testScenarios, 18 | dynamicSimpleScenarioFor, 19 | standardTemplate 20 | } from 'dummy/tests/helpers/test-scenarios'; 21 | 22 | module('vertical-collection', 'Integration | Measure Tests', function(hooks) { 23 | setupRenderingTest(hooks); 24 | 25 | testScenarios( 26 | 'The collection correctly remeasures items when scrolling down', 27 | dynamicSimpleScenarioFor(getNumbers(0, 20)), 28 | standardTemplate, 29 | 30 | async function(assert) { 31 | assert.expect(2); 32 | 33 | const itemContainer = find('.scrollable'); 34 | assert.equal(paddingBefore(itemContainer), 0, 'itemContainer padding is correct on initial render'); 35 | 36 | find('.vertical-item:first-of-type').style.height = '50px'; 37 | 38 | await scrollTo('.scrollable', 0, 51); 39 | 40 | assert.equal(paddingBefore(itemContainer), 50, 'itemContainer padding is the height of the modified first element'); 41 | } 42 | ); 43 | 44 | testScenarios( 45 | 'The collection correctly remeasures items when scrolling up', 46 | dynamicSimpleScenarioFor(getNumbers(0, 20)), 47 | standardTemplate, 48 | 49 | async function(assert) { 50 | assert.expect(3); 51 | 52 | const itemContainer = find('.scrollable'); 53 | 54 | assert.equal(paddingAfter(itemContainer), 200, 'itemContainer padding is correct on initial render'); 55 | 56 | await scrollTo('.scrollable', 0, 20); 57 | 58 | assert.equal(paddingAfter(itemContainer), 180, 'itemContainer padding is correct after scrolling down'); 59 | 60 | find('.vertical-item:last-of-type').style.height = '50px'; 61 | await scrollTo('.scrollable', 0, 0); 62 | 63 | assert.equal(paddingAfter(itemContainer), 230, 'itemContainer padding has the height of the modified last element'); 64 | } 65 | ); 66 | 67 | testScenarios( 68 | 'Can scroll correctly in dynamic list of items that has non-integer heights', 69 | dynamicSimpleScenarioFor(getNumbers(0, 20), { itemHeight: 20.5 }), 70 | standardTemplate, 71 | 72 | async function(assert) { 73 | assert.expect(2); 74 | 75 | await scrollTo('.scrollable', 0, 400); 76 | 77 | const itemContainer = find('.scrollable'); 78 | 79 | // Floats aren't perfect, neither is browser rendering/measuring, but any subpixel errors 80 | // should be amplified to the point where they are very noticeable at this point, so rounding 81 | // should provide some safety. 82 | assert.equal(Math.round(paddingBefore(itemContainer)), 205, 'Occluded content has the correct height before'); 83 | assert.equal(paddingAfter(itemContainer), 0, 'Occluded content has the correct height after'); 84 | } 85 | ); 86 | 87 | testScenarios( 88 | 'Can measure and affect correctly in list of items with non-integer heights', 89 | dynamicSimpleScenarioFor(getNumbers(0, 20), { itemHeight: 30.1, key: '@index', idForFirstItem: '10', bufferSize: 1 }), 90 | standardTemplate, 91 | 92 | async function(assert) { 93 | assert.expect(1); 94 | 95 | assert.equal(find('.scrollable').scrollTop, 210, 'scrollTop set to correct value'); 96 | } 97 | ); 98 | 99 | testScenarios( 100 | 'Measurements are correct after a prepend', 101 | dynamicSimpleScenarioFor(getNumbers(0, 20), { itemHeight: 40 }), 102 | standardTemplate, 103 | 104 | async function(assert) { 105 | assert.expect(3); 106 | 107 | await prepend(this, getNumbers(-20, 20)); 108 | 109 | assert.equal(find('.scrollable').scrollTop, 400, 'scrollTop set to correct value'); 110 | 111 | const itemContainer = find('.scrollable'); 112 | assert.equal(paddingBefore(itemContainer), 400, 'Occluded content has the correct height before'); 113 | assert.equal(paddingAfter(itemContainer), 400, 'Occluded content has the correct height after'); 114 | } 115 | ); 116 | 117 | testScenarios( 118 | 'Measurements are correct after a reset', 119 | dynamicSimpleScenarioFor(getNumbers(0, 20), { itemHeight: 40 }), 120 | standardTemplate, 121 | 122 | async function(assert) { 123 | assert.expect(6); 124 | 125 | await scrollTo('.scrollable', 0, 400); 126 | 127 | assert.equal(find('.scrollable').scrollTop, 400, 'scrollTop set to correct value'); 128 | assert.equal(find('.vertical-item:first-of-type').textContent.trim(), '10 10', 'the first rendered item is correct'); 129 | assert.equal(find('.vertical-item:last-of-type').textContent.trim(), '19 19', 'the last rendered item is correct'); 130 | 131 | // Trigger measurements 132 | await scrollTo('.scrollable', 0, 420); 133 | await scrollTo('.scrollable', 0, 400); 134 | 135 | await replaceArray(this, getNumbers(20, 20)); 136 | 137 | assert.equal(find('.scrollable').scrollTop, 400, 'scrollTop set to correct value'); 138 | assert.equal(find('.vertical-item:first-of-type').textContent.trim(), '30 10', 'the first rendered item is correct'); 139 | assert.equal(find('.vertical-item:last-of-type').textContent.trim(), '34 14', 'the last rendered item is correct'); 140 | } 141 | ); 142 | 143 | testScenarios( 144 | 'The collection renders correctly when scaled', 145 | dynamicSimpleScenarioFor(getNumbers(0, 100)), 146 | 147 | hbs` 148 |
    149 |
    150 | 155 | 156 | {{item.number}} {{i}} 157 | 158 | 159 |
    160 |
    161 | `, 162 | 163 | async function(assert) { 164 | await scrollTo('.scrollable', 0, 150); 165 | 166 | assert.equal(paddingBefore(find('.scrollable')), 150, 'Rendered correct number of items'); 167 | } 168 | ); 169 | 170 | testScenarios( 171 | 'The collection shrinks the pool if items are much larger than expected', 172 | dynamicSimpleScenarioFor(getNumbers(0, 20), { estimateHeight: 20, itemHeight: 200 }), 173 | standardTemplate, 174 | 175 | async function(assert) { 176 | await scrollTo('.scrollable', 0, 20); 177 | await scrollTo('.scrollable', 0, 0); 178 | 179 | assert.equal(findAll('.vertical-item').length, 1, 'scrollTop set to correct value'); 180 | assert.equal(find('.vertical-item:first-of-type').textContent.trim(), '0 0', 'the last rendered item is correct'); 181 | } 182 | ); 183 | 184 | testScenarios( 185 | 'The collection renders incrementally until the entire scroll container is covered', 186 | dynamicSimpleScenarioFor(getNumbers(0, 20), { estimateHeight: 200, itemHeight: 20 }), 187 | standardTemplate, 188 | 189 | async function(assert) { 190 | assert.equal(findAll('.vertical-item').length, 10, 'scrollTop set to correct value'); 191 | assert.equal(find('.vertical-item:first-of-type').textContent.trim(), '0 0', 'the first rendered item is correct'); 192 | assert.equal(find('.vertical-item:last-of-type').textContent.trim(), '9 9', 'the last rendered item is correct'); 193 | } 194 | ); 195 | }); 196 | -------------------------------------------------------------------------------- /vertical-collection/tests/integration/measurement-unit-test.js: -------------------------------------------------------------------------------- 1 | import { module } from 'qunit'; 2 | import { setupRenderingTest } from '../helpers'; 3 | import { hbs } from 'ember-cli-htmlbars'; 4 | import { 5 | findAll 6 | } from '@ember/test-helpers'; 7 | 8 | import getNumbers from 'dummy/lib/get-numbers'; 9 | 10 | import { 11 | testScenarios, 12 | 13 | simpleScenariosFor, 14 | scenariosFor 15 | } from 'dummy/tests/helpers/test-scenarios'; 16 | 17 | module('vertical-collection', 'Integration | Measurement Unit Tests', function(hooks) { 18 | setupRenderingTest(hooks); 19 | 20 | testScenarios( 21 | 'The collection renders correctly when em estimateHeight is used', 22 | scenariosFor(getNumbers(0, 10)), 23 | 24 | hbs` 25 |
    26 | 32 | 33 | {{item.number}} {{i}} 34 | 35 | 36 |
    37 | `, 38 | 39 | async function(assert) { 40 | assert.strictEqual(findAll('vertical-item').length, 5); 41 | } 42 | ); 43 | 44 | testScenarios( 45 | 'The collection renders correctly when rem estimateHeight is used', 46 | scenariosFor(getNumbers(0, 10)), 47 | 48 | hbs` 49 |
    50 | 56 | 57 | {{item.number}} {{i}} 58 | 59 | 60 |
    61 | `, 62 | 63 | async function(assert) { 64 | assert.strictEqual(findAll('vertical-item').length, 5); 65 | } 66 | ); 67 | 68 | testScenarios( 69 | 'The collection renders correctly when percent estimateHeight is used', 70 | scenariosFor(getNumbers(0, 10)), 71 | 72 | hbs` 73 |
    74 | 80 | 81 | {{item.number}} {{i}} 82 | 83 | 84 |
    85 | `, 86 | 87 | async function(assert) { 88 | assert.equal(findAll('vertical-item').length, 2); 89 | } 90 | ); 91 | 92 | testScenarios( 93 | 'The collection renders correctly when em height is used', 94 | scenariosFor(getNumbers(0, 10)), 95 | 96 | hbs` 97 |
    98 | 104 | 105 | {{item.number}} {{i}} 106 | 107 | 108 |
    109 | `, 110 | 111 | async function(assert) { 112 | assert.strictEqual(findAll('vertical-item').length, 5); 113 | } 114 | ); 115 | 116 | testScenarios( 117 | 'The collection renders correctly with a scroll parent with a pixel based max height', 118 | simpleScenariosFor(getNumbers(0, 20)), 119 | 120 | hbs` 121 |
    122 |
    123 | 130 | 131 | {{item.number}} {{i}} 132 | 133 | 134 |
    135 |
    136 | `, 137 | 138 | function(assert) { 139 | assert.strictEqual(findAll('vertical-item').length, 10); 140 | } 141 | ); 142 | 143 | testScenarios( 144 | 'The collection renders correctly with a scroll parent with a percentage based max height', 145 | simpleScenariosFor(getNumbers(0, 20)), 146 | 147 | hbs` 148 |
    149 |
    150 |
    151 | 158 | 159 | {{item.number}} {{i}} 160 | 161 | 162 |
    163 |
    164 |
    165 | `, 166 | 167 | function(assert) { 168 | assert.strictEqual(findAll('vertical-item').length, 10); 169 | } 170 | ); 171 | 172 | testScenarios( 173 | 'The collection renders correctly with a scroll parent with an em based max height', 174 | simpleScenariosFor(getNumbers(0, 20)), 175 | 176 | hbs` 177 |
    178 |
    179 |
    180 | 187 | 188 | {{item.number}} {{i}} 189 | 190 | 191 |
    192 |
    193 |
    194 | `, 195 | 196 | function(assert) { 197 | assert.strictEqual(findAll('vertical-item').length, 10); 198 | } 199 | ); 200 | 201 | testScenarios( 202 | 'The collection renders correctly with a scroll parent with a rem based max height', 203 | simpleScenariosFor(getNumbers(0, 20)), 204 | 205 | hbs` 206 |
    207 |
    208 | 215 | 216 | {{item.number}} {{i}} 217 | 218 | 219 |
    220 |
    221 | `, 222 | 223 | function(assert) { 224 | assert.strictEqual(findAll('vertical-item').length, 10); 225 | } 226 | ); 227 | }); 228 | -------------------------------------------------------------------------------- /vertical-collection/tests/integration/modern-ember-test.js: -------------------------------------------------------------------------------- 1 | import { module, test } from 'qunit'; 2 | import { setupRenderingTest } from '../helpers'; 3 | import { hbs } from 'ember-cli-htmlbars'; 4 | import { find, render } from '@ember/test-helpers'; 5 | 6 | module('vertical-collection', 'Integration | Modern Ember Features Tests', function(hooks) { 7 | setupRenderingTest(hooks); 8 | 9 | test('Yields to inverse when no content is provided', async function(assert) { 10 | this.set('items', []); 11 | 12 | await render(hbs` 13 |
    14 | {{#vertical-collection 15 | items=this.items 16 | estimateHeight=20 17 | staticHeight=true 18 | }} 19 | {{else}} 20 | Foobar 21 | {{/vertical-collection}} 22 |
    23 | `); 24 | 25 | assert.notStrictEqual(find('.scrollable').textContent.indexOf('Foobar'), -1); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /vertical-collection/tests/integration/mutation-test.js: -------------------------------------------------------------------------------- 1 | import { module } from 'qunit'; 2 | import { setupRenderingTest } from '../helpers'; 3 | import { 4 | find, 5 | findAll, 6 | } from '@ember/test-helpers'; 7 | import scrollTo from '../helpers/scroll-to'; 8 | 9 | import getNumbers from 'dummy/lib/get-numbers'; 10 | import { 11 | testScenarios, 12 | dynamicSimpleScenarioFor, 13 | scenariosFor, 14 | standardTemplate 15 | } from 'dummy/tests/helpers/test-scenarios'; 16 | 17 | import { 18 | prepend, 19 | append, 20 | emptyArray, 21 | replaceArray, 22 | move 23 | } from 'dummy/tests/helpers/array'; 24 | import { paddingBefore, paddingAfter } from 'dummy/tests/helpers/measurement'; 25 | 26 | module('vertical-collection', 'Integration | Mutation Tests', function(hooks) { 27 | setupRenderingTest(hooks); 28 | 29 | testScenarios( 30 | 'Collection prepends correctly', 31 | scenariosFor(getNumbers(0, 100)), 32 | standardTemplate, 33 | 34 | async function(assert) { 35 | assert.expect(10); 36 | 37 | const scrollContainer = find('.scrollable'); 38 | 39 | assert.equal(find('.vertical-item:first-of-type').textContent.trim(), '0 0', 'first item rendered correctly before prepend'); 40 | assert.equal(find('.vertical-item:last-of-type').textContent.trim(), '9 9', 'last item rendered correctly before prepnd'); 41 | assert.equal(scrollContainer.scrollTop, 0, 'scrollTop is correct before prepend'); 42 | assert.equal(paddingBefore(scrollContainer), 0, 'padding before is correct before prepend'); 43 | assert.equal(paddingAfter(scrollContainer), 1800, 'padding after is correct before prepend'); 44 | 45 | await prepend(this, getNumbers(-20, 20)); 46 | 47 | assert.equal(find('.vertical-item:first-of-type').textContent.trim(), '0 20', 'first item rendered correctly after prepend'); 48 | assert.equal(find('.vertical-item:last-of-type').textContent.trim(), '9 29', 'last item rendered correctly after prepend'); 49 | assert.equal(scrollContainer.scrollTop, 400, 'scrollTop is correct after prepend'); 50 | assert.equal(paddingBefore(scrollContainer), 400, 'padding before is correct after prepend'); 51 | assert.equal(paddingAfter(scrollContainer), 1800, 'padding after is correct after prepend'); 52 | } 53 | ); 54 | 55 | testScenarios( 56 | 'Collection appends correctly', 57 | scenariosFor(getNumbers(0, 100)), 58 | standardTemplate, 59 | 60 | async function(assert) { 61 | assert.expect(10); 62 | 63 | const scrollContainer = find('.scrollable'); 64 | 65 | assert.equal(find('.vertical-item:first-of-type').textContent.trim(), '0 0', 'first item rendered correctly before append'); 66 | assert.equal(find('.vertical-item:last-of-type').textContent.trim(), '9 9', 'last item rendered correctly before append'); 67 | assert.equal(scrollContainer.scrollTop, 0, 'scrollTop is correct before append'); 68 | assert.equal(paddingBefore(scrollContainer), 0, 'padding after is correct after append'); 69 | assert.equal(paddingAfter(scrollContainer), 1800, 'padding after is correct before prepend'); 70 | 71 | await append(this, getNumbers(100, 20)); 72 | 73 | assert.equal(find('.vertical-item:first-of-type').textContent.trim(), '0 0', 'first item rendered correctly after append'); 74 | assert.equal(find('.vertical-item:last-of-type').textContent.trim(), '9 9', 'last item rendered correctly after append'); 75 | assert.equal(scrollContainer.scrollTop, 0, 'scrollTop is correct after append'); 76 | assert.equal(paddingBefore(scrollContainer), 0, 'b height is correct after append'); 77 | assert.equal(paddingAfter(scrollContainer), 2200, 'padding after is correct before prepend'); 78 | } 79 | ); 80 | 81 | testScenarios( 82 | 'Collection prepends correctly if prepend would cause more VCs to be shown', 83 | scenariosFor(getNumbers(0, 10), { bufferSize: 5 }), 84 | standardTemplate, 85 | 86 | async function(assert) { 87 | assert.expect(6); 88 | 89 | const scrollContainer = find('.scrollable'); 90 | 91 | assert.equal(find('.vertical-item:first-of-type').textContent.trim(), '0 0', 'first item rendered correctly before prepend'); 92 | assert.equal(find('.vertical-item:last-of-type').textContent.trim(), '9 9', 'last item rendered correctly before prepend'); 93 | assert.equal(scrollContainer.scrollTop, 0, 'scrollTop is correct before prepend'); 94 | 95 | await prepend(this, getNumbers(-5, 5)); 96 | 97 | assert.equal(find('.vertical-item:first-of-type').textContent.trim(), '-5 0', 'first item rendered correctly after prepend'); 98 | assert.equal(find('.vertical-item:last-of-type').textContent.trim(), '9 14', 'last item rendered correctly after prepend'); 99 | assert.equal(scrollContainer.scrollTop, 100, 'scrollTop is correct after prepend'); 100 | } 101 | ); 102 | 103 | testScenarios( 104 | 'Collection appends correctly if append would cause more VCs to be shown', 105 | scenariosFor(getNumbers(0, 5)), 106 | standardTemplate, 107 | 108 | async function(assert) { 109 | assert.expect(6); 110 | 111 | const scrollContainer = find('.scrollable'); 112 | 113 | assert.equal(find('.vertical-item:first-of-type').textContent.trim(), '0 0', 'first item rendered correctly before append'); 114 | assert.equal(find('.vertical-item:last-of-type').textContent.trim(), '4 4', 'last item rendered correctly before append'); 115 | assert.equal(scrollContainer.scrollTop, 0, 'scrollTop is correct before append'); 116 | 117 | await append(this, getNumbers(5, 5)); 118 | 119 | assert.equal(find('.vertical-item:first-of-type').textContent.trim(), '0 0', 'first item rendered correctly after append'); 120 | assert.equal(find('.vertical-item:last-of-type').textContent.trim(), '9 9', 'last item rendered correctly after append'); 121 | assert.equal(scrollContainer.scrollTop, 0, 'scrollTop is correct after append'); 122 | } 123 | ); 124 | 125 | testScenarios( 126 | 'Collection can shrink number of items if would cause fewer VCs to be shown', 127 | scenariosFor(getNumbers(0, 10)), 128 | standardTemplate, 129 | 130 | async function(assert) { 131 | assert.expect(6); 132 | 133 | assert.equal(findAll('.vertical-item').length, 10, 'correct number of VCs rendered before reset'); 134 | assert.equal(find('.vertical-item:first-of-type').textContent.trim(), '0 0', 'first item rendered correctly before reset'); 135 | assert.equal(find('.vertical-item:last-of-type').textContent.trim(), '9 9', 'last item rendered correctly before reset'); 136 | 137 | await replaceArray(this, getNumbers(0, 5)); 138 | 139 | assert.equal(findAll('.vertical-item').length, 5, 'correct number of VCs rendered after reset'); 140 | assert.equal(find('.vertical-item:first-of-type').textContent.trim(), '0 0', 'first item rendered correctly after reset'); 141 | assert.equal(find('.vertical-item:last-of-type').textContent.trim(), '4 4', 'last item rendered correctly after reset'); 142 | } 143 | ); 144 | 145 | testScenarios( 146 | 'Collection can shrink number of items if would cause fewer VCs to be shown and scroll would change', 147 | scenariosFor(getNumbers(0, 20)), 148 | standardTemplate, 149 | 150 | async function(assert) { 151 | assert.expect(6); 152 | 153 | await scrollTo('.scrollable', 0, 200); 154 | 155 | assert.equal(findAll('.vertical-item').length, 10, 'correct number of VCs rendered before reset'); 156 | assert.equal(find('.vertical-item:first-of-type').textContent.trim(), '10 10', 'first item rendered correctly before reset'); 157 | assert.equal(find('.vertical-item:last-of-type').textContent.trim(), '19 19', 'last item rendered correctly before reset'); 158 | 159 | await replaceArray(this, getNumbers(0, 5)); 160 | 161 | assert.equal(findAll('.vertical-item').length, 5, 'correct number of VCs rendered after reset'); 162 | assert.equal(find('.vertical-item:first-of-type').textContent.trim(), '0 0', 'first item rendered correctly before reset'); 163 | assert.equal(find('.vertical-item:last-of-type').textContent.trim(), '4 4', 'last item rendered correctly before reset'); 164 | } 165 | ); 166 | 167 | testScenarios( 168 | 'Collection can shrink number of items to empty collection', 169 | scenariosFor(getNumbers(0, 10)), 170 | standardTemplate, 171 | 172 | async function(assert) { 173 | assert.expect(4); 174 | 175 | assert.equal(findAll('.vertical-item').length, 10, 'correct number of VCs rendered before reset'); 176 | assert.equal(find('.vertical-item:first-of-type').textContent.trim(), '0 0', 'first item rendered correctly before reset'); 177 | assert.equal(find('.vertical-item:last-of-type').textContent.trim(), '9 9', 'last item rendered correctly before reset'); 178 | 179 | await emptyArray(this); 180 | 181 | assert.equal(findAll('.vertical-item').length, 0, 'correct number of VCs rendered after reset'); 182 | } 183 | ); 184 | 185 | testScenarios( 186 | 'Collection can shrink number of items to empty collection (after scroll has changed)', 187 | scenariosFor(getNumbers(0, 20)), 188 | standardTemplate, 189 | 190 | async function(assert) { 191 | assert.expect(4); 192 | 193 | await scrollTo('.scrollable', 0, 200); 194 | 195 | assert.equal(findAll('.vertical-item').length, 10, 'correct number of VCs rendered before reset'); 196 | assert.equal(find('.vertical-item:first-of-type').textContent.trim(), '10 10', 'first item rendered correctly before reset'); 197 | assert.equal(find('.vertical-item:last-of-type').textContent.trim(), '19 19', 'last item rendered correctly before reset'); 198 | 199 | await emptyArray(this); 200 | 201 | assert.equal(findAll('.vertical-item').length, 0, 'correct number of VCs rendered after reset'); 202 | } 203 | ); 204 | 205 | testScenarios( 206 | 'Collection will rerender items after reset', 207 | scenariosFor(getNumbers(0, 10)), 208 | standardTemplate, 209 | 210 | async function(assert) { 211 | assert.expect(4); 212 | 213 | assert.equal(find('.vertical-item:first-of-type').textContent.trim(), '0 0', 'first item rendered correctly before append'); 214 | assert.equal(find('.vertical-item:last-of-type').textContent.trim(), '9 9', 'last item rendered correctly before append'); 215 | 216 | await replaceArray(this, getNumbers(10, 10)); 217 | 218 | assert.equal(find('.vertical-item:first-of-type').textContent.trim(), '10 0', 'first item rendered correctly after reset'); 219 | assert.equal(find('.vertical-item:last-of-type').textContent.trim(), '19 9', 'last item rendered correctly after reset'); 220 | } 221 | ); 222 | 223 | testScenarios( 224 | 'Dynamic collection maintains state if the same list is passed in twice', 225 | dynamicSimpleScenarioFor(getNumbers(0, 20), { itemHeight: 40 }), 226 | standardTemplate, 227 | 228 | async function(assert) { 229 | assert.expect(4); 230 | 231 | const itemContainer = find('.scrollable'); 232 | 233 | // Occlude a single item, 234 | await scrollTo('.scrollable', 0, 41); 235 | 236 | assert.equal(find('.vertical-item:first-of-type').textContent.trim(), '1 1', 'first item rendered correctly after initial scroll set'); 237 | assert.equal(paddingBefore(itemContainer), 40, 'itemContainer padding correct before same items set'); 238 | 239 | await replaceArray(this, this.items.slice()); 240 | 241 | assert.equal(find('.vertical-item:first-of-type').textContent.trim(), '1 1', 'first item rendered correctly after same items set'); 242 | assert.equal(paddingBefore(itemContainer), 40, 'itemContainer padding correct after same items set'); 243 | } 244 | ); 245 | 246 | testScenarios( 247 | 'Collection reorders correctly', 248 | scenariosFor(getNumbers(0, 5)), 249 | standardTemplate, 250 | 251 | async function(assert) { 252 | assert.expect(8); 253 | 254 | assert.equal(find('.vertical-item:first-of-type').textContent.trim(), '0 0', 'first item rendered correctly before move'); 255 | assert.equal(find('.vertical-item:nth-of-type(2)').textContent.trim(), '1 1', 'second item starts in second'); 256 | assert.equal(find('.vertical-item:nth-of-type(4)').textContent.trim(), '3 3', 'foruth item starts in fourth'); 257 | assert.equal(find('.vertical-item:last-of-type').textContent.trim(), '4 4', 'last item rendered correctly before move'); 258 | 259 | // move second object to the second last position 260 | await move(this, 1, 3); 261 | 262 | assert.equal(find('.vertical-item:first-of-type').textContent.trim(), '0 0', 'first item rendered correctly after move'); 263 | assert.equal(find('.vertical-item:nth-of-type(2)').textContent.trim(), '2 1', 'third item drops to second'); 264 | assert.equal(find('.vertical-item:nth-of-type(4)').textContent.trim(), '1 3', 'second item is now in fourth position'); 265 | assert.equal(find('.vertical-item:last-of-type').textContent.trim(), '4 4', 'last item rendered correctly before move'); 266 | } 267 | ); 268 | }); 269 | -------------------------------------------------------------------------------- /vertical-collection/tests/integration/recycle-test.js: -------------------------------------------------------------------------------- 1 | import { module } from 'qunit'; 2 | import { setupRenderingTest } from '../helpers'; 3 | import { hbs } from 'ember-cli-htmlbars'; 4 | import { 5 | find, 6 | findAll, 7 | } from '@ember/test-helpers'; 8 | import scrollTo from '../helpers/scroll-to'; 9 | 10 | import getNumbers from 'dummy/lib/get-numbers'; 11 | 12 | import { 13 | testScenarios, 14 | 15 | simpleScenariosFor 16 | } from 'dummy/tests/helpers/test-scenarios'; 17 | 18 | module('vertical-collection', 'Integration | Recycle Tests', function(hooks) { 19 | setupRenderingTest(hooks); 20 | 21 | testScenarios( 22 | 'The collection does not recycle when shouldRecycle is set to false', 23 | simpleScenariosFor(getNumbers(0, 20)), 24 | 25 | hbs` 26 |
    27 |
    28 | 36 | 37 | {{unbound item.number}} {{unbound i}} 38 | 39 | 40 |
    41 |
    42 | `, 43 | 44 | async function(assert) { 45 | assert.expect(2); 46 | 47 | assert.equal(findAll('vertical-item').length, 10); 48 | 49 | await scrollTo('.scrollable', 0, 20); 50 | 51 | assert.equal(find('vertical-item:last-of-type').textContent.trim(), '10 10', 'component was not recycled'); 52 | } 53 | ); 54 | 55 | testScenarios( 56 | 'The collection does recycle when shouldRecycle is set to true', 57 | simpleScenariosFor(getNumbers(0, 20)), 58 | 59 | hbs` 60 |
    61 |
    62 | 70 | 71 | {{unbound item.number}} {{unbound i}} 72 | 73 | 74 |
    75 |
    76 | `, 77 | 78 | async function(assert) { 79 | assert.expect(2); 80 | 81 | assert.equal(findAll('vertical-item').length, 10); 82 | 83 | await scrollTo('.scrollable', 0, 20); 84 | 85 | assert.equal(find('vertical-item:last-of-type').textContent.trim(), '0 0', 'component was recycled'); 86 | } 87 | ); 88 | }); 89 | -------------------------------------------------------------------------------- /vertical-collection/tests/test-helper.js: -------------------------------------------------------------------------------- 1 | import registerWaiter from 'ember-raf-scheduler/test-support/register-waiter'; 2 | import Application from '../app'; 3 | import config from '../config/environment'; 4 | import { setApplication } from '@ember/test-helpers'; 5 | import QUnit from 'qunit'; 6 | import { start } from 'ember-qunit'; 7 | 8 | QUnit.config.testTimeout = 5000; 9 | 10 | setApplication(Application.create(config.APP)); 11 | 12 | registerWaiter(); 13 | 14 | start(); 15 | -------------------------------------------------------------------------------- /vertical-collection/tests/unit/-private/data-view/utils/scroll-handler-test.js: -------------------------------------------------------------------------------- 1 | import { module, test } from 'qunit'; 2 | import { ScrollHandler } from '@html-next/vertical-collection/-private'; 3 | 4 | import { scheduler } from 'ember-raf-scheduler'; 5 | 6 | const dom = document; 7 | 8 | function afterNextScrollUpdate(method) { 9 | scheduler.schedule('measure', method); 10 | } 11 | 12 | function createScrollable() { 13 | let div = dom.createElement('div'); 14 | let innerDiv = dom.createElement('div'); 15 | div.style.overflowY = 'scroll'; 16 | div.style.height = '100px'; 17 | div.style.position = 'relative'; 18 | innerDiv.style.height = '200px'; 19 | innerDiv.style.position = 'relative'; 20 | 21 | div.appendChild(innerDiv); 22 | dom.body.appendChild(div); 23 | 24 | return div; 25 | } 26 | 27 | function destroyScrollable(scrollable) { 28 | scrollable.parentNode.removeChild(scrollable); 29 | } 30 | 31 | module('Unit | Radar Utils | Scroll Handler'); 32 | 33 | test('We can add, trigger, and remove a scroll handler', (assert) => { 34 | let scrollHandlers = new ScrollHandler(); 35 | let done = assert.async(2); 36 | let scrollable = createScrollable(); 37 | let handler = () => { 38 | assert.ok('handler was triggered'); 39 | done(); 40 | }; 41 | 42 | assert.strictEqual(scrollHandlers.length, 0, `We initially have no elements to watch.`); 43 | 44 | // test adding a single handler 45 | scrollHandlers.addScrollHandler(scrollable, handler); 46 | 47 | assert.strictEqual(scrollHandlers.length, 1, `We have one element to watch.`); 48 | 49 | let scrollableIndex = scrollHandlers.elements.indexOf(scrollable); 50 | assert.notStrictEqual(scrollableIndex, -1, `The scrollable was added to the watched elements list.`); 51 | let cache = scrollHandlers.handlers[scrollableIndex]; 52 | assert.strictEqual(cache.handlers.length, 1); 53 | 54 | // test triggering that handler 55 | assert.strictEqual(scrollable.scrollTop, 0, `The scrollable is initially unscrolled`); 56 | 57 | afterNextScrollUpdate(() => { 58 | scrollable.scrollTop = 10; 59 | assert.strictEqual(scrollable.scrollTop, 10, `We updated the scrollable's scroll position`); 60 | 61 | afterNextScrollUpdate(() => { 62 | // test removing that handler 63 | scrollHandlers.removeScrollHandler(scrollable, handler); 64 | let newScrollableIndex = scrollHandlers.elements.indexOf(scrollable); 65 | 66 | assert.strictEqual(cache.handlers.length, 0, `The handler was removed from the listener cache.`); 67 | assert.strictEqual(newScrollableIndex, -1, `Removing the last handler removed the element from the watched elements list.`); 68 | assert.strictEqual(scrollHandlers.handlers.indexOf(cache), -1, `Removing the last handler removed the cache.`); 69 | 70 | assert.strictEqual(scrollHandlers.length, 0, `We have no more elements to watch.`); 71 | assert.false(scrollHandlers.isPolling, `We are no longer polling the elements.`); 72 | 73 | destroyScrollable(scrollable); 74 | done(); 75 | }); 76 | }); 77 | }); 78 | 79 | test('Adding/removing multiple handlers to an element works as expected', (assert) => { 80 | let scrollHandlers = new ScrollHandler(); 81 | let done = assert.async(3); 82 | let scrollable = createScrollable(); 83 | let handler1 = () => { 84 | assert.ok('handler1 was triggered'); 85 | done(); 86 | }; 87 | let handler2 = () => { 88 | assert.ok('handler2 was triggered'); 89 | done(); 90 | }; 91 | 92 | // test adding the handlers 93 | assert.strictEqual(scrollHandlers.length, 0, `We initially have no elements to watch.`); 94 | scrollHandlers.addScrollHandler(scrollable, handler1); 95 | scrollHandlers.addScrollHandler(scrollable, handler2); 96 | 97 | assert.strictEqual(scrollHandlers.length, 1, `We have one element to watch.`); 98 | 99 | let scrollableIndex = scrollHandlers.elements.indexOf(scrollable); 100 | assert.notStrictEqual(scrollableIndex, -1, `The scrollable was added to the watched elements list.`); 101 | let cache = scrollHandlers.handlers[scrollableIndex]; 102 | assert.strictEqual(cache.handlers.length, 2); 103 | 104 | // test triggering that handler 105 | assert.strictEqual(scrollable.scrollTop, 0, `The scrollable is initially unscrolled`); 106 | 107 | afterNextScrollUpdate(() => { 108 | scrollable.scrollTop = 10; 109 | assert.strictEqual(scrollable.scrollTop, 10, `We updated the scrollable's scroll position`); 110 | 111 | afterNextScrollUpdate(() => { 112 | // test removing that handler 113 | scrollHandlers.removeScrollHandler(scrollable, handler1); 114 | let newScrollableIndex = scrollHandlers.elements.indexOf(scrollable); 115 | 116 | assert.strictEqual(cache.handlers.length, 1, `The handler was removed from the listener cache.`); 117 | assert.notStrictEqual(newScrollableIndex, -1, `When an element has other handlers, it is not removed from the watched elements list.`); 118 | assert.notStrictEqual(scrollHandlers.handlers.indexOf(cache), -1, `When an element has other handlers, ths cache is not removed.`); 119 | assert.strictEqual(scrollHandlers.length, 1, `We have one element to watch.`); 120 | 121 | scrollHandlers.removeScrollHandler(scrollable, handler2); 122 | newScrollableIndex = scrollHandlers.elements.indexOf(scrollable); 123 | assert.strictEqual(cache.handlers.length, 0, `The handler was removed from the listener cache.`); 124 | assert.strictEqual(newScrollableIndex, -1, `Removing the last handler removed the element from the watched elements list.`); 125 | assert.strictEqual(scrollHandlers.handlers.indexOf(cache), -1, `Removing the last handler removed the cache.`); 126 | 127 | assert.strictEqual(scrollHandlers.length, 0, `We have no more elements to watch.`); 128 | assert.false(scrollHandlers.isPolling, `We are no longer polling the elements.`); 129 | 130 | destroyScrollable(scrollable); 131 | done(); 132 | }); 133 | }); 134 | }); 135 | 136 | test('Multiple elements with handlers works as expected', (assert) => { 137 | let scrollHandlers = new ScrollHandler(); 138 | let done = assert.async(3); 139 | let scrollable1 = createScrollable(); 140 | let scrollable2 = createScrollable(); 141 | let handler1 = () => { 142 | assert.ok('handler1 was triggered'); 143 | done(); 144 | }; 145 | let handler2 = () => { 146 | assert.ok('handler2 was triggered'); 147 | done(); 148 | }; 149 | 150 | // test adding the handlers 151 | assert.strictEqual(scrollHandlers.length, 0, `We initially have no elements to watch.`); 152 | scrollHandlers.addScrollHandler(scrollable1, handler1); 153 | scrollHandlers.addScrollHandler(scrollable2, handler2); 154 | 155 | assert.strictEqual(scrollHandlers.length, 2, `We have two elements to watch.`); 156 | 157 | let scrollable1Index = scrollHandlers.elements.indexOf(scrollable1); 158 | let scrollable2Index = scrollHandlers.elements.indexOf(scrollable1); 159 | 160 | assert.notStrictEqual(scrollable1Index, -1, `The scrollable was added to the watched elements list.`); 161 | assert.notStrictEqual(scrollable2Index, -1, `The scrollable was added to the watched elements list.`); 162 | let cache1 = scrollHandlers.handlers[scrollable1Index]; 163 | let cache2 = scrollHandlers.handlers[scrollable2Index]; 164 | 165 | assert.strictEqual(cache1.handlers.length, 1, `We added the handler`); 166 | assert.strictEqual(cache2.handlers.length, 1, `We added the handler`); 167 | 168 | // test triggering that handler 169 | assert.strictEqual(scrollable1.scrollTop, 0, `The scrollable is initially unscrolled`); 170 | assert.strictEqual(scrollable2.scrollTop, 0, `The scrollable is initially unscrolled`); 171 | 172 | afterNextScrollUpdate(() => { 173 | scrollable1.scrollTop = 10; 174 | scrollable2.scrollTop = 20; 175 | assert.strictEqual(scrollable1.scrollTop, 10, `We updated the scrollable's scroll position`); 176 | assert.strictEqual(scrollable2.scrollTop, 20, `We updated the scrollable's scroll position`); 177 | 178 | afterNextScrollUpdate(() => { 179 | // test removing that handler 180 | scrollHandlers.removeScrollHandler(scrollable1, handler1); 181 | let newScrollableIndex = scrollHandlers.elements.indexOf(scrollable1); 182 | 183 | assert.strictEqual(cache1.handlers.length, 0, `The handler was removed from the listener cache.`); 184 | assert.strictEqual(newScrollableIndex, -1, `The element was removed from the watched elements list.`); 185 | assert.strictEqual(scrollHandlers.handlers.indexOf(cache1), -1, `The cache was also removed.`); 186 | assert.strictEqual(scrollHandlers.length, 1, `We were removed entirely`); 187 | 188 | scrollHandlers.removeScrollHandler(scrollable2, handler2); 189 | newScrollableIndex = scrollHandlers.elements.indexOf(scrollable2); 190 | assert.strictEqual(cache2.handlers.length, 0, `The handler was removed from the listener cache.`); 191 | assert.strictEqual(newScrollableIndex, -1, `Removing the last handler removed the element from the watched elements list.`); 192 | assert.strictEqual(scrollHandlers.handlers.indexOf(cache2), -1, `Removing the last handler removed the cache.`); 193 | 194 | assert.strictEqual(scrollHandlers.length, 0, `We have no more elements to watch.`); 195 | assert.false(scrollHandlers.isPolling, `We are no longer polling the elements.`); 196 | 197 | destroyScrollable(scrollable1); 198 | destroyScrollable(scrollable2); 199 | done(); 200 | }); 201 | }); 202 | }); 203 | -------------------------------------------------------------------------------- /vertical-collection/tests/unit/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/html-next/vertical-collection/1990e9fa0a78a2c052dd48ba984fad0f104743aa/vertical-collection/tests/unit/.gitkeep -------------------------------------------------------------------------------- /vertical-collection/tsconfig.declarations.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "declarationDir": "declarations", 5 | "emitDeclarationOnly": true, 6 | "noEmit": false, 7 | "rootDir": "." 8 | }, 9 | "include": ["addon", "addon-test-support"] 10 | } 11 | -------------------------------------------------------------------------------- /vertical-collection/vendor/debug.css: -------------------------------------------------------------------------------- 1 | /** ! 2 | Visualization Classes for debugging the vertical-collection 3 | */ 4 | .vertical-collection-visual-debugger { 5 | height: 100%; 6 | position: fixed; 7 | z-index: 1000; 8 | top: 0; 9 | left: 0; 10 | display: flex; 11 | align-items: center; 12 | justify-content: left; 13 | background: rgb(50 50 50 / 100%); 14 | width: 125px; 15 | } 16 | 17 | .vertical-collection-visual-debugger .vc_visualization-container { 18 | transform: scale(0.25); 19 | left: 0; 20 | position: relative; 21 | } 22 | 23 | .vertical-collection-visual-debugger .vc_visualization-screen { 24 | position: absolute; 25 | background: transparent; 26 | box-sizing: content-box; 27 | border-top: 2px dashed yellow; 28 | border-bottom: 2px dashed yellow; 29 | width: 500px; 30 | } 31 | 32 | .vertical-collection-visual-debugger .vc_visualization-scroll-container { 33 | position: absolute; 34 | width: 500px; 35 | background: rgb(100 230 100 / 65%); 36 | } 37 | 38 | .vertical-collection-visual-debugger .vc_visualization-item-container { 39 | position: absolute; 40 | width: 500px; 41 | background: rgb(255 255 255 / 15%); 42 | } 43 | 44 | .vertical-collection-visual-debugger .vc_visualization-virtual-component { 45 | box-sizing: border-box; 46 | background: rgb(230 100 230 / 60%); 47 | border: 1px dotted #bbb; 48 | border-top: 0; 49 | color: #fff; 50 | text-align: center; 51 | font-size: 2.5em; 52 | width: 250px; 53 | } 54 | 55 | .vertical-collection-visual-debugger 56 | .vc_visualization-virtual-component:first-of-type { 57 | border-top: 1px dotted #bbb; 58 | } 59 | 60 | .vertical-collection-visual-debugger 61 | .vc_visualization-virtual-component.culled { 62 | background: transparent; 63 | } 64 | --------------------------------------------------------------------------------