├── .editorconfig ├── .github └── workflows │ ├── ci.yml │ ├── plan-release.yml │ └── publish.yml ├── .gitignore ├── .prettierignore ├── .prettierrc.cjs ├── .release-plan.json ├── .template-lintrc.cjs ├── .try.mjs ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE.md ├── README.md ├── RELEASE.md ├── addon-main.cjs ├── babel.config.cjs ├── babel.publish.config.cjs ├── config └── ember-cli-update.json ├── eslint.config.mjs ├── index.html ├── package.json ├── pnpm-lock.yaml ├── rollup.config.mjs ├── src ├── components │ ├── memory-scroll.gts │ ├── remember-document-scroll.gts │ └── scroll-to.gts ├── index.ts ├── modifiers │ ├── memory-scroll.ts │ ├── remember-document-scroll.ts │ └── scroll-to.ts ├── services │ └── memory-scroll.ts └── template-registry.ts ├── testem.js ├── tests ├── memory-scroll-test.gts ├── modifier-test.gts ├── remember-document-scroll-test.gts ├── scroll-to-test.gts ├── test-helper.ts └── tsconfig.json ├── tsconfig.json ├── unpublished-development-types └── index.d.ts └── vite.config.mjs /.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 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: {} 8 | 9 | concurrency: 10 | group: ci-${{ github.head_ref || github.ref }} 11 | cancel-in-progress: true 12 | 13 | jobs: 14 | test: 15 | name: "Tests" 16 | runs-on: ubuntu-latest 17 | timeout-minutes: 10 18 | outputs: 19 | matrix: ${{ steps.set-matrix.outputs.matrix }} 20 | 21 | steps: 22 | - uses: actions/checkout@v4 23 | - uses: pnpm/action-setup@v4 24 | - uses: actions/setup-node@v4 25 | with: 26 | node-version: 18 27 | cache: pnpm 28 | - name: Install Dependencies 29 | run: pnpm install --frozen-lockfile 30 | - name: Lint 31 | run: pnpm lint 32 | - name: Run Tests 33 | run: pnpm test 34 | - id: set-matrix 35 | run: | 36 | echo "matrix=$(pnpm -s dlx @embroider/try list)" >> $GITHUB_OUTPUT 37 | 38 | compatibility: 39 | needs: ["test"] 40 | runs-on: ubuntu-latest 41 | timeout-minutes: 10 42 | strategy: 43 | fail-fast: false 44 | matrix: ${{fromJson(needs.test.outputs.matrix)}} 45 | 46 | name: "${{ matrix.name }}" 47 | 48 | steps: 49 | - uses: actions/checkout@v4 50 | - uses: pnpm/action-setup@v4 51 | - uses: actions/setup-node@v4 52 | with: 53 | node-version: 18 54 | cache: pnpm 55 | - name: Apply Scenario 56 | run: pnpm dlx @embroider/try apply ${{ matrix.name }} 57 | - name: Install Dependencies 58 | run: pnpm install --no-lockfile 59 | - name: Run Tests 60 | run: pnpm test 61 | env: ${{ matrix.env }} 62 | -------------------------------------------------------------------------------- /.github/workflows/plan-release.yml: -------------------------------------------------------------------------------- 1 | name: Plan Release 2 | on: 3 | workflow_dispatch: 4 | push: 5 | branches: 6 | - main 7 | pull_request_target: # This workflow has permissions on the repo, do NOT run code from PRs in this workflow. See https://securitylab.github.com/research/github-actions-preventing-pwn-requests/ 8 | types: 9 | - labeled 10 | - unlabeled 11 | 12 | concurrency: 13 | group: plan-release # only the latest one of these should ever be running 14 | cancel-in-progress: true 15 | 16 | jobs: 17 | is-this-a-release: 18 | name: "Is this a release?" 19 | runs-on: ubuntu-latest 20 | outputs: 21 | command: ${{ steps.check-release.outputs.command }} 22 | 23 | steps: 24 | - uses: actions/checkout@v4 25 | with: 26 | fetch-depth: 2 27 | ref: "main" 28 | # This will only cause the `is-this-a-release` job to have a "command" of `release` 29 | # when the .release-plan.json file was changed on the last commit. 30 | - id: check-release 31 | run: if git diff --name-only HEAD HEAD~1 | grep -w -q ".release-plan.json"; then echo "command=release"; fi >> $GITHUB_OUTPUT 32 | 33 | create-prepare-release-pr: 34 | name: Create Prepare Release PR 35 | runs-on: ubuntu-latest 36 | timeout-minutes: 5 37 | needs: is-this-a-release 38 | permissions: 39 | contents: write 40 | issues: read 41 | pull-requests: write 42 | # only run on push event or workflow dispatch if plan wasn't updated (don't create a release plan when we're releasing) 43 | # only run on labeled event if the PR has already been merged 44 | if: ((github.event_name == 'push' || github.event_name == 'workflow_dispatch') && needs.is-this-a-release.outputs.command != 'release') || (github.event_name == 'pull_request_target' && github.event.pull_request.merged == true) 45 | 46 | steps: 47 | - uses: actions/checkout@v4 48 | # We need to download lots of history so that 49 | # github-changelog can discover what's changed since the last release 50 | with: 51 | fetch-depth: 0 52 | ref: "main" 53 | - uses: pnpm/action-setup@v4 54 | - uses: actions/setup-node@v4 55 | with: 56 | node-version: 18 57 | cache: pnpm 58 | - run: pnpm install --frozen-lockfile 59 | - name: "Generate Explanation and Prep Changelogs" 60 | id: explanation 61 | run: | 62 | set +e 63 | pnpm release-plan prepare 2> >(tee -a release-plan-stderr.txt >&2) 64 | 65 | if [ $? -ne 0 ]; then 66 | release_plan_output=$(cat release-plan-stderr.txt) 67 | else 68 | release_plan_output=$(jq .description .release-plan.json -r) 69 | rm release-plan-stderr.txt 70 | 71 | if [ $(jq '.solution | length' .release-plan.json) -eq 1 ]; then 72 | new_version=$(jq -r '.solution[].newVersion' .release-plan.json) 73 | echo "new_version=v$new_version" >> $GITHUB_OUTPUT 74 | fi 75 | fi 76 | echo 'text<> $GITHUB_OUTPUT 77 | echo "$release_plan_output" >> $GITHUB_OUTPUT 78 | echo 'EOF' >> $GITHUB_OUTPUT 79 | env: 80 | GITHUB_AUTH: ${{ secrets.GITHUB_TOKEN }} 81 | 82 | - uses: peter-evans/create-pull-request@v7 83 | with: 84 | commit-message: "Prepare Release ${{ steps.explanation.outputs.new_version}} using 'release-plan'" 85 | labels: "internal" 86 | branch: release-preview 87 | title: Prepare Release ${{ steps.explanation.outputs.new_version }} 88 | body: | 89 | This PR is a preview of the release that [release-plan](https://github.com/embroider-build/release-plan) has prepared. To release you should just merge this PR 👍 90 | 91 | ----------------------------------------- 92 | 93 | ${{ steps.explanation.outputs.text }} 94 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | # For every push to the primary branch with .release-plan.json modified, 2 | # runs release-plan. 3 | 4 | name: Publish Stable 5 | 6 | on: 7 | workflow_dispatch: 8 | push: 9 | branches: 10 | - main 11 | paths: 12 | - ".release-plan.json" 13 | 14 | concurrency: 15 | group: publish-${{ github.head_ref || github.ref }} 16 | cancel-in-progress: true 17 | 18 | jobs: 19 | publish: 20 | name: "NPM Publish" 21 | runs-on: ubuntu-latest 22 | permissions: 23 | contents: write 24 | pull-requests: write 25 | id-token: write 26 | attestations: write 27 | 28 | steps: 29 | - uses: actions/checkout@v4 30 | - uses: pnpm/action-setup@v4 31 | - uses: actions/setup-node@v4 32 | with: 33 | node-version: 18 34 | # This creates an .npmrc that reads the NODE_AUTH_TOKEN environment variable 35 | registry-url: "https://registry.npmjs.org" 36 | cache: pnpm 37 | - run: pnpm install --frozen-lockfile 38 | - name: Publish to NPM 39 | run: NPM_CONFIG_PROVENANCE=true pnpm release-plan publish 40 | env: 41 | GITHUB_AUTH: ${{ secrets.GITHUB_TOKEN }} 42 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The authoritative copies of these live in the monorepo root (because they're 2 | # more useful on github that way), but the build copies them into here so they 3 | # will also appear in published NPM packages. 4 | /README.md 5 | /LICENSE.md 6 | 7 | # compiled output 8 | dist/ 9 | declarations/ 10 | 11 | # npm/pnpm/yarn pack output 12 | *.tgz 13 | 14 | # deps & caches 15 | node_modules/ 16 | .eslintcache 17 | .prettiercache 18 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # unconventional js 2 | /blueprints/*/files/ 3 | 4 | # compiled output 5 | /dist/ 6 | /declarations/ 7 | 8 | # misc 9 | /coverage/ 10 | within a package 11 | 12 | # misc 13 | !.* 14 | .lint-todo/ 15 | 16 | # ember-try 17 | /.node_modules.ember-try/ 18 | /pnpm-lock.ember-try.yaml 19 | *.yaml 20 | *.md 21 | 22 | -------------------------------------------------------------------------------- /.prettierrc.cjs: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | plugins: ['prettier-plugin-ember-template-tag'], 5 | overrides: [ 6 | { 7 | files: '*.{js,gjs,ts,gts,mjs,mts,cjs,cts}', 8 | options: { 9 | singleQuote: true, 10 | templateSingleQuote: false, 11 | }, 12 | }, 13 | ], 14 | }; 15 | -------------------------------------------------------------------------------- /.release-plan.json: -------------------------------------------------------------------------------- 1 | { 2 | "solution": { 3 | "memory-scroll": { 4 | "impact": "patch", 5 | "oldVersion": "2.0.0", 6 | "newVersion": "2.0.1", 7 | "tagName": "latest", 8 | "constraints": [ 9 | { 10 | "impact": "patch", 11 | "reason": "Appears in changelog section :bug: Bug Fix" 12 | } 13 | ], 14 | "pkgJSONPath": "./package.json" 15 | } 16 | }, 17 | "description": "## Release (2025-03-24)\n\n* memory-scroll 2.0.1 (patch)\n\n#### :bug: Bug Fix\n* `memory-scroll`\n * [#34](https://github.com/ef4/memory-scroll/pull/34) fix event listener in document ([@ef4](https://github.com/ef4))\n * [#32](https://github.com/ef4/memory-scroll/pull/32) don't inline the decorators runtime ([@ef4](https://github.com/ef4))\n\n#### Committers: 1\n- Edward Faulkner ([@ef4](https://github.com/ef4))\n" 18 | } 19 | -------------------------------------------------------------------------------- /.template-lintrc.cjs: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | extends: 'recommended', 5 | rules: { 6 | 'no-forbidden-elements': false, 7 | }, 8 | }; 9 | -------------------------------------------------------------------------------- /.try.mjs: -------------------------------------------------------------------------------- 1 | export default scenarios(); 2 | 3 | function scenarios() { 4 | return { 5 | scenarios: [ 6 | compatEmberScenario('ember-lts-5.8', '^5.8.0'), 7 | compatEmberScenario('ember-lts-5.12', '^5.12.0'), 8 | { 9 | name: 'ember-latest', 10 | npm: { 11 | devDependencies: { 12 | 'ember-source': 'npm:ember-source@latest', 13 | }, 14 | }, 15 | }, 16 | { 17 | name: 'ember-beta', 18 | npm: { 19 | devDependencies: { 20 | 'ember-source': 'npm:ember-source@beta', 21 | }, 22 | }, 23 | }, 24 | { 25 | name: 'ember-canary', 26 | npm: { 27 | devDependencies: { 28 | 'ember-source': 'npm:ember-source@alpha', 29 | }, 30 | }, 31 | }, 32 | ], 33 | }; 34 | } 35 | 36 | function emberCliBuildJS() { 37 | return `const EmberApp = require('ember-cli/lib/broccoli/ember-app'); 38 | const { compatBuild } = require('@embroider/compat'); 39 | module.exports = async function (defaults) { 40 | const { buildOnce } = await import('@embroider/vite'); 41 | let app = new EmberApp(defaults); 42 | return compatBuild(app, buildOnce); 43 | };`; 44 | } 45 | 46 | function compatEmberScenario(name, emberVersion) { 47 | return { 48 | name, 49 | npm: { 50 | devDependencies: { 51 | 'ember-source': emberVersion, 52 | '@embroider/compat': '^4.0.0-alpha.14', 53 | 'ember-cli': '^5.12.0', 54 | 'ember-auto-import': '^2.10.0', 55 | '@ember/optional-features': '^2.2.0', 56 | }, 57 | }, 58 | env: { 59 | ENABLE_COMPAT_BUILD: true, 60 | }, 61 | files: { 62 | 'ember-cli-build.js': emberCliBuildJS(), 63 | }, 64 | }; 65 | } 66 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## Release (2025-03-24) 4 | 5 | * memory-scroll 2.0.1 (patch) 6 | 7 | #### :bug: Bug Fix 8 | * `memory-scroll` 9 | * [#34](https://github.com/ef4/memory-scroll/pull/34) fix event listener in document ([@ef4](https://github.com/ef4)) 10 | * [#32](https://github.com/ef4/memory-scroll/pull/32) don't inline the decorators runtime ([@ef4](https://github.com/ef4)) 11 | 12 | #### Committers: 1 13 | - Edward Faulkner ([@ef4](https://github.com/ef4)) 14 | 15 | ## Release (2025-03-21) 16 | 17 | * memory-scroll 2.0.0 (major) 18 | 19 | #### :boom: Breaking Change 20 | * `memory-scroll` 21 | * [#28](https://github.com/ef4/memory-scroll/pull/28) V2 addon ([@ef4](https://github.com/ef4)) 22 | 23 | #### :house: Internal 24 | * `memory-scroll` 25 | * [#29](https://github.com/ef4/memory-scroll/pull/29) release plan ([@ef4](https://github.com/ef4)) 26 | 27 | #### Committers: 1 28 | - Edward Faulkner ([@ef4](https://github.com/ef4)) 29 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How To Contribute 2 | 3 | ## Installation 4 | 5 | - `git clone ` 6 | - `cd memory-scroll` 7 | - `pnpm install` 8 | 9 | ## Linting 10 | 11 | - `pnpm lint` 12 | - `pnpm lint:fix` 13 | 14 | ## Building the addon 15 | 16 | - `cd .` 17 | - `pnpm build` 18 | 19 | ## Running tests 20 | 21 | - `cd test-app` 22 | - `pnpm test` – Runs the test suite on the current Ember version 23 | - `pnpm test:watch` – Runs the test suite in "watch mode" 24 | 25 | ## Running the test application 26 | 27 | - `cd test-app` 28 | - `pnpm start` 29 | - Visit the test application at [http://localhost:4200](http://localhost:4200). 30 | 31 | For more information on using ember-cli, visit [https://cli.emberjs.com/release/](https://cli.emberjs.com/release/). 32 | -------------------------------------------------------------------------------- /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 | # Memory Scroll 2 | 3 | This addon provides Ember modifiers and components that help you avoiding losing 4 | the user's scroll positions unexpectedly as they navigate through the app. 5 | 6 | ### Installation 7 | 8 | ```sh 9 | pnpm add --save-dev memory-scroll 10 | ``` 11 | 12 | # Modifiers 13 | 14 | ## memoryScroll 15 | 16 | Example: 17 | 18 | ```glimmer-ts 19 | import memoryScroll from "memory-scroll/modifiers/memory-scroll"; 20 | 21 | 28 | ``` 29 | 30 | `memoryScroll` does just two things: when its about to be 31 | destroyed it saves its element's scroll position into a Service (which 32 | is Ember's standard way to maintain long-lived application state). And 33 | when it's just been rendered, it looks in the service to see if it 34 | should set its scroll position. 35 | 36 | The `key` attribute is mandatory and it determines what constitutes 37 | "the same" element that should share memory. The simplest usage is 38 | to use a constant string ID. A more advanced usage is to use part of 39 | your model data so the memory is context-dependent, like: 40 | 41 | ```hbs 42 |
43 | ``` 44 | 45 | ## rememberDocumentScroll 46 | 47 | If instead you want to remember the scroll position of the document itself, you can use: 48 | 49 | ```glimmer-ts 50 | import rememberDocumentScroll from "memory-scroll/modifiers/remember-document-scroll"; 51 | 52 | 53 | ``` 54 | 55 | Its key works the same way as `memoryScroll`, but it reads and writes `document.documentElement.scrollTop()`. 56 | 57 | ## scrollTo 58 | 59 | This modifier always scrolls the document to the given position when it renders and when the key changes. 60 | 61 | Example: 62 | 63 | ```glimmer-ts 64 | import scrollTo from "memory-scroll/modifiers/scroll-to"; 65 | 66 | 69 | ``` 70 | 71 | # Components 72 | 73 | ## MemoryScroll 74 | 75 | Example: 76 | 77 | ```glimmer-ts 78 | import MemoryScroll from "memory-scroll/components/memory-scroll"; 79 | 80 | 87 | ``` 88 | 89 | `MemoryScroll` does just two things: when its about to be 90 | destroyed it saves its element's scroll position into a Service (which 91 | is Ember's standard way to maintain long-lived application state). And 92 | when it's just been rendered, it looks in the service to see if it 93 | should set its scroll position. 94 | 95 | All the rest is up to you, so it's easy to use as a drop-in 96 | replacement for any `
` that is already styled for scrolling. 97 | 98 | The `key` attribute is mandatory and it determines what constitutes 99 | "the same" component that should share memory. The simplest usage is 100 | to use a constant string ID. A more advanced usage is to use part of 101 | your model data so the memory is context-dependent, like: 102 | 103 | ```glimmer-ts 104 | 105 | ``` 106 | 107 | ## RememberDocumentScroll 108 | 109 | If instead you want to remember the scroll position of the document itself, you can use: 110 | 111 | ```glimmer-ts 112 | import RememberDocumentScroll from "memory-scroll/components/remember-document-scroll"; 113 | 114 | 115 | ``` 116 | 117 | Its key works the same way as `MemoryScroll`, but it reads and writes `document.documentElement.scrollTop()`. 118 | 119 | ## ScrollTo 120 | 121 | This component always scrolls the document to the given position when it renders and when the key changes. 122 | 123 | Example: 124 | 125 | ```glimmer-ts 126 | import ScrollTo from "memory-scroll/components/scroll-to"; 127 | 128 | 131 | ``` 132 | 133 | -------------------------------------------------------------------------------- /RELEASE.md: -------------------------------------------------------------------------------- 1 | # Release Process 2 | 3 | Releases in this repo are mostly automated using [release-plan](https://github.com/embroider-build/release-plan/). Once you label all your PRs correctly (see below) you will have an automatically generated PR that updates your CHANGELOG.md file and a `.release-plan.json` that is used to prepare the release once the PR is merged. 4 | 5 | ## Preparation 6 | 7 | Since the majority of the actual release process is automated, the remaining tasks before releasing are: 8 | 9 | - correctly labeling **all** pull requests that have been merged since the last release 10 | - updating pull request titles so they make sense to our users 11 | 12 | Some great information on why this is important can be found at [keepachangelog.com](https://keepachangelog.com/en/1.1.0/), but the overall 13 | guiding principle here is that changelogs are for humans, not machines. 14 | 15 | When reviewing merged PR's the labels to be used are: 16 | 17 | - breaking - Used when the PR is considered a breaking change. 18 | - enhancement - Used when the PR adds a new feature or enhancement. 19 | - bug - Used when the PR fixes a bug included in a previous release. 20 | - documentation - Used when the PR adds or updates documentation. 21 | - internal - Internal changes or things that don't fit in any other category. 22 | 23 | **Note:** `release-plan` requires that **all** PRs are labeled. If a PR doesn't fit in a category it's fine to label it as `internal` 24 | 25 | ## Release 26 | 27 | Once the prep work is completed, the actual release is straight forward: you just need to merge the open [Plan Release](https://github.com/ef4/memory-scroll/pulls?q=is%3Apr+is%3Aopen+%22Prepare+Release%22+in%3Atitle) PR 28 | -------------------------------------------------------------------------------- /addon-main.cjs: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { addonV1Shim } = require('@embroider/addon-shim'); 4 | module.exports = addonV1Shim(__dirname); 5 | -------------------------------------------------------------------------------- /babel.config.cjs: -------------------------------------------------------------------------------- 1 | const { buildMacros } = require('@embroider/macros/babel'); 2 | 3 | const macros = buildMacros(); 4 | 5 | module.exports = { 6 | plugins: [ 7 | ['@babel/plugin-transform-typescript', { allowDeclareFields: true }], 8 | [ 9 | 'babel-plugin-ember-template-compilation', 10 | { 11 | transforms: [...macros.templateMacros], 12 | }, 13 | ], 14 | [ 15 | 'module:decorator-transforms', 16 | { 17 | runtime: { 18 | import: require.resolve('decorator-transforms/runtime-esm'), 19 | }, 20 | }, 21 | ], 22 | ...macros.babelMacros, 23 | ], 24 | 25 | generatorOpts: { 26 | compact: false, 27 | }, 28 | }; 29 | -------------------------------------------------------------------------------- /babel.publish.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: [ 3 | [ 4 | '@babel/plugin-transform-typescript', 5 | { 6 | allExtensions: true, 7 | onlyRemoveTypeImports: true, 8 | allowDeclareFields: true, 9 | }, 10 | ], 11 | [ 12 | 'babel-plugin-ember-template-compilation', 13 | { 14 | targetFormat: 'hbs', 15 | }, 16 | ], 17 | [ 18 | 'module:decorator-transforms', 19 | { 20 | runtime: { 21 | import: 'decorator-transforms/runtime-esm', 22 | }, 23 | }, 24 | ], 25 | ], 26 | 27 | generatorOpts: { 28 | compact: false, 29 | }, 30 | }; 31 | -------------------------------------------------------------------------------- /config/ember-cli-update.json: -------------------------------------------------------------------------------- 1 | { 2 | "schemaVersion": "1.0.0", 3 | "projectName": "memory-scroll", 4 | "packages": [ 5 | { 6 | "name": "@embroider/addon-blueprint", 7 | "version": "4.1.1", 8 | "blueprints": [ 9 | { 10 | "name": "@embroider/addon-blueprint", 11 | "isBaseBlueprint": true, 12 | "options": ["--ci-provider=github", "--pnpm", "--typescript"] 13 | } 14 | ] 15 | } 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * Debugging: 3 | * https://eslint.org/docs/latest/use/configure/debug 4 | * ---------------------------------------------------- 5 | * 6 | * Print a file's calculated configuration 7 | * 8 | * npx eslint --print-config path/to/file.js 9 | * 10 | * Inspecting the config 11 | * 12 | * npx eslint --inspect-config 13 | * 14 | */ 15 | import babelParser from '@babel/eslint-parser'; 16 | import js from '@eslint/js'; 17 | import prettier from 'eslint-config-prettier'; 18 | import ember from 'eslint-plugin-ember/recommended'; 19 | import importPlugin from 'eslint-plugin-import'; 20 | import n from 'eslint-plugin-n'; 21 | import globals from 'globals'; 22 | import ts from 'typescript-eslint'; 23 | import qunit from 'eslint-plugin-qunit'; 24 | 25 | const parserOptions = { 26 | esm: { 27 | js: { 28 | ecmaFeatures: { modules: true }, 29 | ecmaVersion: 'latest', 30 | }, 31 | ts: { 32 | projectService: true, 33 | project: true, 34 | tsconfigRootDir: import.meta.dirname, 35 | }, 36 | }, 37 | }; 38 | 39 | export default ts.config( 40 | js.configs.recommended, 41 | ember.configs.base, 42 | ember.configs.gjs, 43 | ember.configs.gts, 44 | prettier, 45 | /** 46 | * Ignores must be in their own object 47 | * https://eslint.org/docs/latest/use/configure/ignore 48 | */ 49 | { 50 | ignores: [ 51 | 'dist/', 52 | 'declarations/', 53 | 'node_modules/', 54 | 'coverage/', 55 | '!**/.*', 56 | 'testem.js', 57 | ], 58 | }, 59 | /** 60 | * https://eslint.org/docs/latest/use/configure/configuration-files#configuring-linter-options 61 | */ 62 | { 63 | linterOptions: { 64 | reportUnusedDisableDirectives: 'error', 65 | }, 66 | }, 67 | { 68 | files: ['**/*.js'], 69 | languageOptions: { 70 | parser: babelParser, 71 | }, 72 | }, 73 | { 74 | files: ['**/*.{js,gjs}'], 75 | languageOptions: { 76 | parserOptions: parserOptions.esm.js, 77 | globals: { 78 | ...globals.browser, 79 | }, 80 | }, 81 | }, 82 | { 83 | files: ['**/*.{ts,gts}'], 84 | languageOptions: { 85 | parser: ember.parser, 86 | parserOptions: parserOptions.esm.ts, 87 | }, 88 | extends: [...ts.configs.recommendedTypeChecked, ember.configs.gts], 89 | }, 90 | { 91 | files: ['src/**/*'], 92 | plugins: { 93 | import: importPlugin, 94 | }, 95 | rules: { 96 | // require relative imports use full extensions 97 | 'import/extensions': ['error', 'always', { ignorePackages: true }], 98 | 'prefer-const': 'off', 99 | 'no-console': 'error', 100 | }, 101 | }, 102 | /** 103 | * CJS node files 104 | */ 105 | { 106 | files: [ 107 | '**/*.cjs', 108 | '.prettierrc.js', 109 | '.stylelintrc.js', 110 | '.template-lintrc.js', 111 | 'addon-main.cjs', 112 | ], 113 | plugins: { 114 | n, 115 | }, 116 | 117 | languageOptions: { 118 | sourceType: 'script', 119 | ecmaVersion: 'latest', 120 | globals: { 121 | ...globals.node, 122 | }, 123 | }, 124 | }, 125 | /** 126 | * ESM node files 127 | */ 128 | { 129 | files: ['**/*.mjs'], 130 | plugins: { 131 | n, 132 | }, 133 | 134 | languageOptions: { 135 | sourceType: 'module', 136 | ecmaVersion: 'latest', 137 | parserOptions: parserOptions.esm.js, 138 | globals: { 139 | ...globals.node, 140 | }, 141 | }, 142 | }, 143 | { 144 | files: ['tests/**/*.{js,gjs,ts,gts}'], 145 | plugins: { 146 | qunit, 147 | }, 148 | }, 149 | ); 150 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | memory-scroll Tests 6 | 7 | 8 | 9 | 10 |
11 |
12 |
13 |
14 |
15 |
16 | 17 | 18 | 24 | 25 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "memory-scroll", 3 | "version": "2.0.1", 4 | "description": "An Ember component that remembers its scroll position.", 5 | "keywords": [ 6 | "ember-addon" 7 | ], 8 | "repository": "https://github.com/ef4/memory-scroll", 9 | "license": "MIT", 10 | "author": "Edward Faulkner ", 11 | "exports": { 12 | ".": { 13 | "types": "./declarations/index.d.ts", 14 | "default": "./dist/index.js" 15 | }, 16 | "./*": { 17 | "types": "./declarations/*.d.ts", 18 | "default": "./dist/*.js" 19 | }, 20 | "./addon-main.js": "./addon-main.cjs" 21 | }, 22 | "typesVersions": { 23 | "*": { 24 | "*": [ 25 | "declarations/*" 26 | ] 27 | } 28 | }, 29 | "files": [ 30 | "addon-main.cjs", 31 | "declarations", 32 | "dist" 33 | ], 34 | "scripts": { 35 | "build": "rollup --config", 36 | "format": "prettier . --cache --write", 37 | "lint": "concurrently \"pnpm:lint:*(!fix)\" --names \"lint:\" --prefixColors auto", 38 | "lint:fix": "concurrently \"pnpm:lint:*:fix\" --names \"fix:\" --prefixColors auto && pnpm run format", 39 | "lint:format": "prettier . --cache --check", 40 | "lint:format:fix": "prettier . --cache --check --write", 41 | "lint:hbs": "ember-template-lint . --no-error-on-unmatched-pattern", 42 | "lint:hbs:fix": "ember-template-lint . --fix --no-error-on-unmatched-pattern", 43 | "lint:js": "eslint . --cache", 44 | "lint:js:fix": "eslint . --fix", 45 | "lint:types": "glint", 46 | "prepack": "rollup --config", 47 | "start": "vite dev", 48 | "test": "vite build --mode=development && testem --file testem.js ci" 49 | }, 50 | "dependencies": { 51 | "@embroider/addon-shim": "^1.8.9", 52 | "decorator-transforms": "^2.2.2", 53 | "ember-modifier": "^4.2.0" 54 | }, 55 | "devDependencies": { 56 | "@babel/core": "^7.25.2", 57 | "@babel/eslint-parser": "^7.25.1", 58 | "@babel/plugin-transform-typescript": "^7.25.2", 59 | "@babel/runtime": "^7.25.6", 60 | "@ember/test-helpers": "^5.1.0", 61 | "@embroider/addon-dev": "^7.1.0", 62 | "@embroider/core": "^4.0.0-alpha.6", 63 | "@embroider/macros": "1.17.0-alpha.7", 64 | "@embroider/vite": "^1.0.0-alpha.7 ", 65 | "@eslint/js": "^9.17.0", 66 | "@glint/core": "^1.4.0", 67 | "@glint/environment-ember-loose": "^1.4.0", 68 | "@glint/environment-ember-template-imports": "^1.4.0", 69 | "@glint/template": "^1.4.0", 70 | "@rollup/plugin-babel": "^6.0.4", 71 | "@tsconfig/ember": "^3.0.8", 72 | "@types/qunit": "^2.19.12", 73 | "babel-plugin-ember-template-compilation": "^2.2.5", 74 | "concurrently": "^9.0.1", 75 | "ember-qunit": "^9.0.1", 76 | "ember-resolver": "^13.1.0", 77 | "ember-source": "^6.3.0", 78 | "ember-template-lint": "^6.0.0", 79 | "eslint": "^9.17.0", 80 | "eslint-config-prettier": "^9.1.0", 81 | "eslint-plugin-ember": "^12.3.3", 82 | "eslint-plugin-import": "^2.31.0", 83 | "eslint-plugin-n": "^17.15.1", 84 | "eslint-plugin-qunit": "^8.1.2", 85 | "globals": "^15.14.0", 86 | "prettier": "^3.4.2", 87 | "prettier-plugin-ember-template-tag": "^2.0.4", 88 | "qunit": "^2.24.1", 89 | "qunit-dom": "^3.4.0", 90 | "release-plan": "^0.16.0", 91 | "rollup": "^4.22.5", 92 | "rollup-plugin-copy": "^3.5.0", 93 | "testem": "^3.15.2", 94 | "tracked-built-ins": "^4.0.0", 95 | "typescript": "~5.6.0", 96 | "typescript-eslint": "^8.19.1", 97 | "vite": "^5.0.9" 98 | }, 99 | "packageManager": "pnpm@10.4.1", 100 | "ember": { 101 | "edition": "octane" 102 | }, 103 | "ember-addon": { 104 | "version": 2, 105 | "type": "addon", 106 | "main": "addon-main.cjs", 107 | "app-js": { 108 | "./components/memory-scroll.js": "./dist/_app_/components/memory-scroll.js", 109 | "./components/remember-document-scroll.js": "./dist/_app_/components/remember-document-scroll.js", 110 | "./components/scroll-to.js": "./dist/_app_/components/scroll-to.js", 111 | "./modifiers/memory-scroll.js": "./dist/_app_/modifiers/memory-scroll.js", 112 | "./modifiers/remember-document-scroll.js": "./dist/_app_/modifiers/remember-document-scroll.js", 113 | "./modifiers/scroll-to.js": "./dist/_app_/modifiers/scroll-to.js", 114 | "./services/memory-scroll.js": "./dist/_app_/services/memory-scroll.js" 115 | } 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /rollup.config.mjs: -------------------------------------------------------------------------------- 1 | import { babel } from '@rollup/plugin-babel'; 2 | import copy from 'rollup-plugin-copy'; 3 | import { Addon } from '@embroider/addon-dev/rollup'; 4 | import { resolve, dirname } from 'node:path'; 5 | import { fileURLToPath } from 'node:url'; 6 | 7 | const addon = new Addon({ 8 | srcDir: 'src', 9 | destDir: 'dist', 10 | }); 11 | 12 | export default { 13 | // This provides defaults that work well alongside `publicEntrypoints` below. 14 | // You can augment this if you need to. 15 | output: addon.output(), 16 | 17 | plugins: [ 18 | // These are the modules that users should be able to import from your 19 | // addon. Anything not listed here may get optimized away. 20 | // By default all your JavaScript modules (**/*.js) will be importable. 21 | // But you are encouraged to tweak this to only cover the modules that make 22 | // up your addon's public API. Also make sure your package.json#exports 23 | // is aligned to the config here. 24 | // See https://github.com/embroider-build/embroider/blob/main/docs/v2-faq.md#how-can-i-define-the-public-exports-of-my-addon 25 | addon.publicEntrypoints(['**/*.js', 'index.js', 'template-registry.js']), 26 | 27 | // These are the modules that should get reexported into the traditional 28 | // "app" tree. Things in here should also be in publicEntrypoints above, but 29 | // not everything in publicEntrypoints necessarily needs to go here. 30 | addon.appReexports([ 31 | 'components/**/*.js', 32 | 'helpers/**/*.js', 33 | 'modifiers/**/*.js', 34 | 'services/**/*.js', 35 | ]), 36 | 37 | // Follow the V2 Addon rules about dependencies. Your code can import from 38 | // `dependencies` and `peerDependencies` as well as standard Ember-provided 39 | // package names. 40 | addon.dependencies(), 41 | 42 | // This babel config should *not* apply presets or compile away ES modules. 43 | // It exists only to provide development niceties for you, like automatic 44 | // template colocation. 45 | // 46 | babel({ 47 | extensions: ['.js', '.gjs', '.ts', '.gts'], 48 | babelHelpers: 'bundled', 49 | configFile: resolve( 50 | dirname(fileURLToPath(import.meta.url)), 51 | './babel.publish.config.cjs', 52 | ), 53 | }), 54 | 55 | // Ensure that standalone .hbs files are properly integrated as Javascript. 56 | addon.hbs(), 57 | 58 | // Ensure that .gjs files are properly integrated as Javascript 59 | addon.gjs(), 60 | 61 | // Emit .d.ts declaration files 62 | addon.declarations('declarations'), 63 | 64 | // addons are allowed to contain imports of .css files, which we want rollup 65 | // to leave alone and keep in the published output. 66 | addon.keepAssets(['**/*.css']), 67 | 68 | // Remove leftover build artifacts when starting a new build. 69 | addon.clean(), 70 | 71 | // Copy Readme and License into published package 72 | copy({ 73 | targets: [ 74 | { src: '../README.md', dest: '.' }, 75 | { src: '../LICENSE.md', dest: '.' }, 76 | ], 77 | }), 78 | ], 79 | }; 80 | -------------------------------------------------------------------------------- /src/components/memory-scroll.gts: -------------------------------------------------------------------------------- 1 | import memoryScroll from '../modifiers/memory-scroll.ts'; 2 | import type { TemplateOnlyComponent } from '@ember/component/template-only'; 3 | 4 | const MemoryScroll: TemplateOnlyComponent<{ 5 | Element: HTMLElement; 6 | Args: { key: string | number }; 7 | Blocks: { 8 | default: []; 9 | }; 10 | }> = ; 15 | 16 | export default MemoryScroll; 17 | -------------------------------------------------------------------------------- /src/components/remember-document-scroll.gts: -------------------------------------------------------------------------------- 1 | import documentScroll from '../modifiers/remember-document-scroll.ts'; 2 | import type { TemplateOnlyComponent } from '@ember/component/template-only'; 3 | 4 | const RememberDocumentScroll: TemplateOnlyComponent<{ 5 | Element: HTMLElement; 6 | Args: { key: string | number }; 7 | Blocks: { 8 | default: []; 9 | }; 10 | }> = ; 15 | 16 | export default RememberDocumentScroll; 17 | -------------------------------------------------------------------------------- /src/components/scroll-to.gts: -------------------------------------------------------------------------------- 1 | import scrollTo from '../modifiers/scroll-to.ts'; 2 | import type { TemplateOnlyComponent } from '@ember/component/template-only'; 3 | 4 | const ScrollTo: TemplateOnlyComponent<{ 5 | Element: HTMLElement; 6 | Args: { position?: number; key?: string | number | undefined }; 7 | Blocks: { 8 | default: []; 9 | }; 10 | }> = ; 15 | 16 | export default ScrollTo; 17 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ef4/memory-scroll/ed127e18018c5b2051089a9260c4057988fbefbe/src/index.ts -------------------------------------------------------------------------------- /src/modifiers/memory-scroll.ts: -------------------------------------------------------------------------------- 1 | import { service } from '@ember/service'; 2 | import Modifier from 'ember-modifier'; 3 | import { registerDestructor } from '@ember/destroyable'; 4 | import type MemoryScrollService from '../services/memory-scroll'; 5 | import type { ArgsFor } from 'ember-modifier'; 6 | 7 | interface Signature { 8 | Element: Element; 9 | Args: { 10 | Named: { 11 | key: string | number; 12 | }; 13 | }; 14 | } 15 | 16 | export default class MemoryScrollModifier extends Modifier { 17 | @service('memoryScroll') declare memory: MemoryScrollService; 18 | 19 | #element: Element | undefined; 20 | #lastKey: string | number | undefined; 21 | 22 | // the element where we will read `scrollTop` 23 | protected scrollingElement(ownElement: Element): Element { 24 | return ownElement; 25 | } 26 | 27 | // the element where we will listen for scroll events 28 | protected eventElement( 29 | ownElement: Element, 30 | ): Pick { 31 | return ownElement; 32 | } 33 | 34 | modify( 35 | element: Element, 36 | _: ArgsFor['positional'], 37 | { key }: ArgsFor['named'], 38 | ) { 39 | if (!this.#element) { 40 | this.#element = this.scrollingElement(element); 41 | let handler = () => { 42 | this.#remember(this.#lastKey); 43 | }; 44 | let eventElement = this.eventElement(element); 45 | eventElement.addEventListener('scroll', handler); 46 | registerDestructor(this, () => { 47 | eventElement.removeEventListener('scroll', handler); 48 | }); 49 | } 50 | if (!key) { 51 | throw new Error( 52 | 'You must provide a key to memoryScroll like {{memoryScroll key="my-awesome-pane"}}.', 53 | ); 54 | } 55 | if (key !== this.#lastKey) { 56 | this.#remember(this.#lastKey); 57 | this.#lastKey = key; 58 | this.#restore(key); 59 | } 60 | } 61 | #remember(key: string | number | undefined) { 62 | if (key && this.#element) { 63 | let position = this.#element.scrollTop; 64 | this.memory.memory.set(key, position); 65 | } 66 | } 67 | 68 | #restore(key: string | number) { 69 | if (this.#element) { 70 | let position = this.memory.memory.get(key) ?? 0; 71 | this.#element.scrollTo({ top: position }); 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/modifiers/remember-document-scroll.ts: -------------------------------------------------------------------------------- 1 | import MemoryScrollModifier from './memory-scroll.ts'; 2 | 3 | export default class RememberDocumentScroll extends MemoryScrollModifier { 4 | scrollingElement() { 5 | return document.documentElement; 6 | } 7 | 8 | eventElement() { 9 | return document; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/modifiers/scroll-to.ts: -------------------------------------------------------------------------------- 1 | import Modifier from 'ember-modifier'; 2 | import type { ArgsFor } from 'ember-modifier'; 3 | 4 | interface Signature { 5 | Element: Element; 6 | Args: { 7 | Named: { 8 | position?: number; 9 | key?: string | number | undefined; 10 | }; 11 | }; 12 | } 13 | 14 | export default class ScrollToModifier extends Modifier { 15 | #lastKey: string | number | undefined = '__initial_state_815203_'; 16 | 17 | modify( 18 | _element: Element, 19 | _: ArgsFor['positional'], 20 | { key, position }: ArgsFor['named'], 21 | ) { 22 | if (key !== this.#lastKey) { 23 | this.#lastKey = key; 24 | document.documentElement.scrollTop = position ?? 0; 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/services/memory-scroll.ts: -------------------------------------------------------------------------------- 1 | import Service from '@ember/service'; 2 | 3 | export default class MemoryScrollService extends Service { 4 | memory = new Map(); 5 | } 6 | -------------------------------------------------------------------------------- /src/template-registry.ts: -------------------------------------------------------------------------------- 1 | // Easily allow apps, which are not yet using strict mode templates, to consume your Glint types, by importing this file. 2 | // Add all your components, helpers and modifiers to the template registry here, so apps don't have to do this. 3 | // See https://typed-ember.gitbook.io/glint/environments/ember/authoring-addons 4 | 5 | import type MemoryScroll from './components/memory-scroll.gts'; 6 | import type RememberDocumentScroll from './components/remember-document-scroll.gts'; 7 | import type ScrollTo from './components/scroll-to.gts'; 8 | 9 | import type memoryScroll from './modifiers/memory-scroll.ts'; 10 | import type rememberDocumentScroll from './modifiers/remember-document-scroll.ts'; 11 | import type scrollTo from './modifiers/scroll-to.ts'; 12 | 13 | export default interface Registry { 14 | MemoryScroll: typeof MemoryScroll; 15 | RememberDocumentScroll: typeof RememberDocumentScroll; 16 | ScrollTo: typeof ScrollTo; 17 | memoryScroll: typeof memoryScroll; 18 | rememberDocumentScrolll: typeof rememberDocumentScroll; 19 | scrollTo: typeof scrollTo; 20 | } 21 | -------------------------------------------------------------------------------- /testem.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | test_page: 'index.html?hidepassed', 5 | cwd: 'dist', 6 | disable_watching: true, 7 | launch_in_ci: ['Chrome'], 8 | launch_in_dev: ['Chrome'], 9 | browser_start_timeout: 120, 10 | browser_args: { 11 | Chrome: { 12 | ci: [ 13 | // --no-sandbox is needed when running Chrome inside a container 14 | process.env.CI ? '--no-sandbox' : null, 15 | '--headless', 16 | '--disable-dev-shm-usage', 17 | '--disable-software-rasterizer', 18 | '--mute-audio', 19 | '--remote-debugging-port=0', 20 | '--window-size=1440,900', 21 | ].filter(Boolean), 22 | }, 23 | }, 24 | }; 25 | -------------------------------------------------------------------------------- /tests/memory-scroll-test.gts: -------------------------------------------------------------------------------- 1 | import { module, test } from 'qunit'; 2 | import { setupRenderingTest } from 'ember-qunit'; 3 | import { render } from '@ember/test-helpers'; 4 | import { TrackedObject } from 'tracked-built-ins'; 5 | import { settled } from '@ember/test-helpers'; 6 | import MemoryScroll from '../src/components/memory-scroll'; 7 | import { scrollTo } from '@ember/test-helpers'; 8 | 9 | module('Integration | Component | memory scroll', function (hooks) { 10 | setupRenderingTest(hooks); 11 | 12 | test('it preserves scroll position when component is replaced', async function (assert) { 13 | const state = new TrackedObject({ 14 | showIt: false, 15 | }); 16 | 17 | await render( 18 | , 37 | ); 38 | 39 | state.showIt = true; 40 | await settled(); 41 | assert.dom('.sample').hasText('sample content'); 42 | await scrollTo('.sample', 0, 50); 43 | state.showIt = false; 44 | await settled(); 45 | assert.dom('.sample').doesNotExist(); 46 | state.showIt = true; 47 | await settled(); 48 | assert.equal(document.querySelector('.sample')!.scrollTop, 50); 49 | }); 50 | 51 | test('it preserves independent scroll positions per key when component is replaced', async function (assert) { 52 | const state: { showIt: string | false } = new TrackedObject({ 53 | showIt: '', 54 | }); 55 | 56 | await render( 57 | , 76 | ); 77 | 78 | state.showIt = 'first'; 79 | await settled(); 80 | assert.dom('.sample').hasText('sample content'); 81 | await scrollTo('.sample', 0, 50); 82 | state.showIt = false; 83 | await settled(); 84 | assert.dom('.sample').doesNotExist(); 85 | state.showIt = 'second'; 86 | await settled(); 87 | assert.equal(document.querySelector('.sample')!.scrollTop, 0); 88 | state.showIt = false; 89 | await settled(); 90 | state.showIt = 'first'; 91 | await settled(); 92 | assert.equal(document.querySelector('.sample')!.scrollTop, 50); 93 | }); 94 | 95 | test('it preserves independent scroll positions per key when key changes', async function (assert) { 96 | const state: { showIt: string } = new TrackedObject({ 97 | showIt: '', 98 | }); 99 | await render( 100 | , 119 | ); 120 | 121 | state.showIt = 'first'; 122 | await settled(); 123 | assert.dom('.sample').hasText('sample content'); 124 | await scrollTo('.sample', 0, 50); 125 | state.showIt = 'second'; 126 | await settled(); 127 | assert.equal(document.querySelector('.sample')!.scrollTop, 0); 128 | state.showIt = 'first'; 129 | await settled(); 130 | assert.equal(document.querySelector('.sample')!.scrollTop, 50); 131 | }); 132 | }); 133 | -------------------------------------------------------------------------------- /tests/modifier-test.gts: -------------------------------------------------------------------------------- 1 | import { module, test } from 'qunit'; 2 | import { setupRenderingTest } from 'ember-qunit'; 3 | import { render } from '@ember/test-helpers'; 4 | import { TrackedObject } from 'tracked-built-ins'; 5 | import { settled } from '@ember/test-helpers'; 6 | import memoryScroll from '../src/modifiers/memory-scroll'; 7 | import { scrollTo } from '@ember/test-helpers'; 8 | 9 | module('Integration | Modifier | memoryScroll', function (hooks) { 10 | setupRenderingTest(hooks); 11 | 12 | test('it preserves scroll position when component is replaced', async function (assert) { 13 | const state = new TrackedObject({ 14 | showIt: false, 15 | }); 16 | 17 | await render( 18 | , 37 | ); 38 | 39 | state.showIt = true; 40 | await settled(); 41 | assert.dom('.sample').hasText('sample content'); 42 | await scrollTo('.sample', 0, 50); 43 | state.showIt = false; 44 | await settled(); 45 | assert.dom('.sample').doesNotExist(); 46 | state.showIt = true; 47 | await settled(); 48 | assert.equal(document.querySelector('.sample')!.scrollTop, 50); 49 | }); 50 | 51 | test('it preserves independent scroll positions per key when component is replaced', async function (assert) { 52 | const state: { showIt: string | false } = new TrackedObject({ 53 | showIt: '', 54 | }); 55 | 56 | await render( 57 | , 76 | ); 77 | 78 | state.showIt = 'first'; 79 | await settled(); 80 | assert.dom('.sample').hasText('sample content'); 81 | await scrollTo('.sample', 0, 50); 82 | state.showIt = false; 83 | await settled(); 84 | assert.dom('.sample').doesNotExist(); 85 | state.showIt = 'second'; 86 | await settled(); 87 | assert.equal(document.querySelector('.sample')!.scrollTop, 0); 88 | state.showIt = false; 89 | await settled(); 90 | state.showIt = 'first'; 91 | await settled(); 92 | assert.equal(document.querySelector('.sample')!.scrollTop, 50); 93 | }); 94 | 95 | test('it preserves independent scroll positions per key when key changes', async function (assert) { 96 | const state: { showIt: string } = new TrackedObject({ 97 | showIt: '', 98 | }); 99 | await render( 100 | , 119 | ); 120 | 121 | state.showIt = 'first'; 122 | await settled(); 123 | assert.dom('.sample').hasText('sample content'); 124 | await scrollTo('.sample', 0, 50); 125 | state.showIt = 'second'; 126 | await settled(); 127 | assert.equal(document.querySelector('.sample')!.scrollTop, 0); 128 | state.showIt = 'first'; 129 | await settled(); 130 | assert.equal(document.querySelector('.sample')!.scrollTop, 50); 131 | }); 132 | }); 133 | -------------------------------------------------------------------------------- /tests/remember-document-scroll-test.gts: -------------------------------------------------------------------------------- 1 | import { module, test } from 'qunit'; 2 | import { setupRenderingTest } from 'ember-qunit'; 3 | import { render } from '@ember/test-helpers'; 4 | import { TrackedObject } from 'tracked-built-ins'; 5 | import RememberDocumentScroll from '../src/components/remember-document-scroll'; 6 | import { scrollTo } from '@ember/test-helpers'; 7 | import { settled } from '@ember/test-helpers'; 8 | 9 | module('Integration | Component | remember document scroll', function (hooks) { 10 | setupRenderingTest(hooks); 11 | 12 | test('it controls document scroll position', async function (assert) { 13 | const state: { showIt: string | false } = new TrackedObject({ 14 | showIt: false, 15 | }); 16 | 17 | await render( 18 | , 29 | ); 30 | 31 | state.showIt = 'first'; 32 | await scrollTo(document.documentElement, 0, 50); 33 | state.showIt = false; 34 | await scrollTo(document.documentElement, 0, 0); 35 | state.showIt = 'first'; 36 | await settled(); 37 | assert.equal(document.documentElement.scrollTop, 50); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /tests/scroll-to-test.gts: -------------------------------------------------------------------------------- 1 | import { module, test } from 'qunit'; 2 | import { setupRenderingTest } from 'ember-qunit'; 3 | import { render, settled } from '@ember/test-helpers'; 4 | import { TrackedObject } from 'tracked-built-ins'; 5 | import ScrollTo from '../src/components/scroll-to'; 6 | import { scrollTo } from '@ember/test-helpers'; 7 | 8 | module('Integration | Component | scroll to', function (hooks) { 9 | setupRenderingTest(hooks); 10 | 11 | test('it scrolls document at initial render', async function (assert) { 12 | const state: { showIt: string } = new TrackedObject({ showIt: '' }); 13 | 14 | await render( 15 | , 26 | ); 27 | state.showIt = 'first'; 28 | await settled(); 29 | assert.equal(document.documentElement.scrollTop, 17); 30 | }); 31 | 32 | test('it scrolls document at initial render with key', async function (assert) { 33 | const state: { showIt: string | undefined; key: number } = 34 | new TrackedObject({ 35 | showIt: undefined, 36 | key: 1, 37 | }); 38 | 39 | this.set('key', 1); 40 | await render( 41 | , 52 | ); 53 | state.showIt = 'first'; 54 | await settled(); 55 | assert.equal(document.documentElement.scrollTop, 17); 56 | }); 57 | 58 | test('it scrolls document when key changes', async function (assert) { 59 | const state: { showIt: string | undefined; key: number } = 60 | new TrackedObject({ 61 | showIt: undefined, 62 | key: 1, 63 | }); 64 | 65 | await render( 66 | , 77 | ); 78 | state.showIt = 'first'; 79 | await settled(); 80 | await scrollTo(document.documentElement, 0, 0); 81 | state.key = 2; 82 | await settled(); 83 | assert.equal(document.documentElement.scrollTop, 17); 84 | }); 85 | }); 86 | -------------------------------------------------------------------------------- /tests/test-helper.ts: -------------------------------------------------------------------------------- 1 | import EmberApp from '@ember/application'; 2 | import Resolver from 'ember-resolver'; 3 | import EmberRouter from '@ember/routing/router'; 4 | import * as MemoryScrollService from '../src/services/memory-scroll'; 5 | 6 | class Router extends EmberRouter { 7 | location = 'none'; 8 | rootURL = '/'; 9 | } 10 | 11 | class TestApp extends EmberApp { 12 | modulePrefix = 'test-app'; 13 | Resolver = Resolver.withModules({ 14 | 'test-app/router': { default: Router }, 15 | 'test-app/services/memory-scroll': MemoryScrollService, 16 | }); 17 | } 18 | 19 | Router.map(function () {}); 20 | 21 | import * as QUnit from 'qunit'; 22 | import { setApplication } from '@ember/test-helpers'; 23 | import { setup } from 'qunit-dom'; 24 | import { start as qunitStart, setupEmberOnerrorValidation } from 'ember-qunit'; 25 | 26 | export function start() { 27 | setApplication( 28 | TestApp.create({ 29 | autoboot: false, 30 | rootElement: '#ember-testing', 31 | }), 32 | ); 33 | setup(QUnit.assert); 34 | setupEmberOnerrorValidation(); 35 | qunitStart(); 36 | } 37 | -------------------------------------------------------------------------------- /tests/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "include": ["**/*.gts", "**/*.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/ember/tsconfig.json", 3 | "include": ["src/**/*", "unpublished-development-types/**/*"], 4 | "glint": { 5 | "environment": ["ember-loose", "ember-template-imports"] 6 | }, 7 | "compilerOptions": { 8 | "allowJs": true, 9 | "declarationDir": "declarations", 10 | /** 11 | https://www.typescriptlang.org/tsconfig#noEmit 12 | 13 | We want to emit declarations, so this option must be set to `false`. 14 | @tsconfig/ember sets this to `true`, which is incompatible with our need to set `emitDeclarationOnly`. 15 | @tsconfig/ember is more optimized for apps, which wouldn't emit anything, only type check. 16 | */ 17 | "noEmit": false, 18 | /** 19 | https://www.typescriptlang.org/tsconfig#emitDeclarationOnly 20 | We want to only emit declarations as we use Rollup to emit JavaScript. 21 | */ 22 | "emitDeclarationOnly": true, 23 | 24 | /** 25 | https://www.typescriptlang.org/tsconfig#noEmitOnError 26 | Do not block emit on TS errors. 27 | */ 28 | "noEmitOnError": false, 29 | 30 | /** 31 | https://www.typescriptlang.org/tsconfig#rootDir 32 | "Default: The longest common path of all non-declaration input files." 33 | 34 | Because we want our declarations' structure to match our rollup output, 35 | we need this "rootDir" to match the "srcDir" in the rollup.config.mjs. 36 | 37 | This way, we can have simpler `package.json#exports` that matches 38 | imports to files on disk 39 | */ 40 | "rootDir": "./src", 41 | 42 | /** 43 | https://www.typescriptlang.org/tsconfig#allowImportingTsExtensions 44 | 45 | We want our tooling to know how to resolve our custom files so the appropriate plugins 46 | can do the proper transformations on those files. 47 | */ 48 | "allowImportingTsExtensions": true, 49 | 50 | "types": ["ember-source/types"] 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /unpublished-development-types/index.d.ts: -------------------------------------------------------------------------------- 1 | // Add any types here that you need for local development only. 2 | // These will *not* be published as part of your addon, so be careful that your published code does not rely on them! 3 | 4 | import '@glint/environment-ember-loose'; 5 | import '@glint/environment-ember-template-imports'; 6 | 7 | // Uncomment if you need to support consuming projects in loose mode 8 | // 9 | // declare module '@glint/environment-ember-loose/registry' { 10 | // export default interface Registry { 11 | // // Add any registry entries from other addons here that your addon itself uses (in non-strict mode templates) 12 | // // See https://typed-ember.gitbook.io/glint/using-glint/ember/using-addons 13 | // } 14 | // } 15 | -------------------------------------------------------------------------------- /vite.config.mjs: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import { extensions, ember, classicEmberSupport } from '@embroider/vite'; 3 | import { babel } from '@rollup/plugin-babel'; 4 | 5 | export default defineConfig({ 6 | plugins: [ 7 | ...(process.env.ENABLE_COMPAT_BUILD ? [classicEmberSupport()] : []), 8 | ember(), 9 | babel({ 10 | babelHelpers: 'inline', 11 | extensions, 12 | }), 13 | ], 14 | build: { 15 | rollupOptions: { 16 | input: { 17 | tests: 'index.html', 18 | }, 19 | }, 20 | }, 21 | }); 22 | --------------------------------------------------------------------------------