├── .circleci └── config.yml ├── .eslintignore ├── .eslintrc.json ├── .gitattributes ├── .github └── workflows │ └── build.yml ├── .gitignore ├── .idea ├── codeStyles │ ├── Project.xml │ └── codeStyleConfig.xml ├── compiler.xml ├── encodings.xml ├── inspectionProfiles │ └── Project_Default.xml ├── misc.xml ├── modules.xml ├── react-sticky-headroom.iml ├── runConfigurations.xml └── vcs.xml ├── .prettierrc ├── LICENSE.txt ├── README.md ├── demo.gif ├── demo ├── cities.ts └── demo.tsx ├── docs └── index.html ├── jest.config.json ├── package-lock.json ├── package.json ├── src ├── Headroom.tsx ├── __tests__ │ └── Headroom.spec.tsx └── setupTest.ts ├── stylelint.config.js ├── tools ├── build.ts └── demo.webpack.config.ts └── tsconfig.json /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | jobs: 3 | test-and-build: 4 | docker: 5 | - image: cimg/node:lts 6 | steps: 7 | - checkout 8 | - restore_cache: 9 | name: Restore NPM Package Cache 10 | keys: 11 | - 0-npm-{{ arch }}-{{ checksum "package-lock.json" }} 12 | - 0-npm-{{ arch }}- 13 | - run: 14 | name: Install Dependencies 15 | command: npm ci 16 | - save_cache: 17 | name: Save NPM Package Cache 18 | key: 0-npm-{{ arch }}-{{ checksum "package-lock.json" }} 19 | paths: 20 | - node_modules 21 | - run: 22 | command: npm run ts:check 23 | - run: 24 | command: npm run lint 25 | - run: 26 | command: npm run test:coverage --ci 27 | - run: 28 | command: npm run build 29 | 30 | workflows: 31 | version: 2 32 | build-and-test: 33 | jobs: 34 | - test-and-build 35 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | __coverage__ 2 | /node_modules/ 3 | 4 | index.js 5 | index.d.ts 6 | /docs 7 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "extends": [ 4 | "standard", 5 | "eslint:recommended", 6 | "plugin:@typescript-eslint/recommended", 7 | "plugin:react/recommended", 8 | "plugin:jest/recommended", 9 | "prettier" 10 | ], 11 | "settings": { 12 | "react": { 13 | "version": "detect" 14 | } 15 | }, 16 | "env": { 17 | "node": true, 18 | "browser": true 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Automatically normalize line endings for all text-based files 2 | # http://git-scm.com/docs/gitattributes#_end_of_line_conversion 3 | * text=auto 4 | 5 | # For the following file types, normalize line endings to LF on 6 | # checkin and prevent conversion to CRLF when they are checked out 7 | # (this is required in order to prevent newline related issues like, 8 | # for example, after the build script is run) 9 | .* text eol=lf 10 | *.css text eol=lf 11 | *.html text eol=lf 12 | *.js text eol=lf 13 | *.json text eol=lf 14 | *.md text eol=lf 15 | *.txt text eol=lf 16 | 17 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build and Deploy 2 | on: [push] 3 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages 4 | permissions: 5 | contents: read 6 | pages: write 7 | id-token: write 8 | 9 | # Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. 10 | # However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. 11 | concurrency: 12 | group: 'pages' 13 | cancel-in-progress: false 14 | jobs: 15 | build-and-deploy: 16 | concurrency: ci-${{ github.ref }} # Recommended if you intend to make multiple deployments in quick succession. 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: Checkout 20 | uses: actions/checkout@v4 21 | - name: Install 22 | run: npm ci 23 | - name: Lint 24 | run: npm run lint 25 | - name: Typescript 26 | run: npm run ts:check 27 | - name: Build Library 28 | run: npm run build 29 | - name: Build Demo 30 | run: npm run build:demo 31 | - name: Setup Pages 32 | uses: actions/configure-pages@v4 33 | if: github.ref_name == 'main' 34 | - name: Upload artifact 35 | uses: actions/upload-pages-artifact@v3 36 | with: 37 | path: 'docs' 38 | if: github.ref_name == 'main' 39 | - name: Deploy to GitHub Pages 40 | id: deployment 41 | uses: actions/deploy-pages@v4 42 | if: github.ref_name == 'main' 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Project specific stuff 2 | 3 | index.tsx 4 | index.js 5 | index.js.map 6 | index.cjs 7 | index.cjs.map 8 | index.d.ts 9 | index.d.ts.map 10 | docs/demo.js 11 | docs/demo.js.LICENSE.txt 12 | 13 | !src/index.tsx 14 | 15 | junit.xml 16 | __coverage__ 17 | 18 | .swc 19 | 20 | 21 | # Yarn stuff 22 | .pnp.* 23 | .yarn/* 24 | !.yarn/patches 25 | !.yarn/plugins 26 | !.yarn/releases 27 | !.yarn/sdks 28 | !.yarn/versions 29 | 30 | # Created by .ignore support plugin (hsz.mobi) 31 | 32 | ### VisualStudioCode template 33 | .vscode/* 34 | !.vscode/settings.json 35 | !.vscode/tasks.json 36 | !.vscode/launch.json 37 | !.vscode/extensions.json 38 | 39 | ### Node template 40 | # Logs 41 | logs 42 | *.log 43 | npm-debug.log* 44 | yarn-debug.log* 45 | yarn-error.log* 46 | 47 | # Runtime data 48 | pids 49 | *.pid 50 | *.seed 51 | *.pid.lock 52 | 53 | # Directory for instrumented libs generated by jscoverage/JSCover 54 | lib-cov 55 | 56 | # Coverage directory used by tools like istanbul 57 | coverage 58 | 59 | # nyc test coverage 60 | .nyc_output 61 | 62 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 63 | .grunt 64 | 65 | # Bower dependency directory (https://bower.io/) 66 | bower_components 67 | 68 | # node-waf configuration 69 | .lock-wscript 70 | 71 | # Compiled binary addons (https://nodejs.org/api/addons.html) 72 | build/Release 73 | 74 | # Dependency directories 75 | node_modules/ 76 | jspm_packages/ 77 | 78 | # TypeScript v1 declaration files 79 | typings/ 80 | 81 | # Optional npm cache directory 82 | .npm 83 | 84 | # Optional eslint cache 85 | .eslintcache 86 | 87 | # Optional REPL history 88 | .node_repl_history 89 | 90 | # Output of 'npm pack' 91 | *.tgz 92 | 93 | # Yarn Integrity file 94 | .yarn-integrity 95 | 96 | # dotenv environment variables file 97 | .env 98 | 99 | # next.js build output 100 | .next 101 | ### JetBrains template 102 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm 103 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 104 | 105 | # User-specific stuff 106 | .idea/**/workspace.xml 107 | .idea/**/tasks.xml 108 | .idea/**/dictionaries 109 | .idea/**/shelf 110 | 111 | # Sensitive or high-churn files 112 | .idea/**/dataSources/ 113 | .idea/**/dataSources.ids 114 | .idea/**/dataSources.local.xml 115 | .idea/**/sqlDataSources.xml 116 | .idea/**/dynamic.xml 117 | .idea/**/uiDesigner.xml 118 | .idea/**/dbnavigator.xml 119 | 120 | # Gradle 121 | .idea/**/gradle.xml 122 | .idea/**/libraries 123 | 124 | # CMake 125 | cmake-build-debug/ 126 | cmake-build-release/ 127 | 128 | # Mongo Explorer plugin 129 | .idea/**/mongoSettings.xml 130 | 131 | # File-based project format 132 | *.iws 133 | 134 | # IntelliJ 135 | out/ 136 | 137 | # mpeltonen/sbt-idea plugin 138 | .idea_modules/ 139 | 140 | # JIRA plugin 141 | atlassian-ide-plugin.xml 142 | 143 | # Cursive Clojure plugin 144 | .idea/replstate.xml 145 | 146 | # Crashlytics plugin (for Android Studio and IntelliJ) 147 | com_crashlytics_export_strings.xml 148 | crashlytics.properties 149 | crashlytics-build.properties 150 | fabric.properties 151 | 152 | # Editor-based Rest Client 153 | .idea/httpRequests 154 | 155 | -------------------------------------------------------------------------------- /.idea/codeStyles/Project.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 13 | 14 | 19 | 20 | 33 | 34 | 35 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /.idea/codeStyles/codeStyleConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /.idea/compiler.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /.idea/encodings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/react-sticky-headroom.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /.idea/runConfigurations.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 10 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true, 4 | "arrowParens": "avoid", 5 | "printWidth": 120, 6 | "bracketSameLine": true, 7 | "jsxSingleQuote": true, 8 | "endOfLine": "auto" 9 | } 10 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Integreat - Tür an Tür – Digital Factory gGmbH 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ReactStickyHeadroom 2 | 3 | [![npm badge](https://img.shields.io/npm/v/@integreat-app/react-sticky-headroom.svg)](https://www.npmjs.com/package/@integreat-app/react-sticky-headroom) 4 | 5 | ReactStickyHeadroom is a React component, that hides your header when you scroll down and shows it 6 | once you're scrolling up again. 7 | It's designed for best performance and can only be used if you know the height of your header 8 | component (or more precisely the amount of pixels you want ReactStickyHeadroom to hide). 9 | This helps us avoid calculating the height ourselves and therefore browsers don't need to perform 10 | heavy Recalculate-Style-Phases. 11 | For more information read [here](https://developers.google.com/web/fundamentals/performance/rendering/). 12 | 13 | Since it's using [emotion](https://emotion.sh) internally, it's best to 14 | use it in apps where you already have emotion in place. 15 | 16 | The component is inspired by [react-headroom](https://kyleamathews.github.io/react-headroom/). 17 | 18 | ## Usage 19 | 20 | A basic usage example: 21 | 22 | ```jsx 23 | render () { 24 | return 25 |
MyHeader
26 |
27 | } 28 | ``` 29 | 30 | ## Demo 31 | 32 | Go to [https://digitalfabrik.github.io/react-sticky-headroom/](https://digitalfabrik.github.io/react-sticky-headroom/) to view a demo: 33 | 34 | [![Demo](https://raw.githubusercontent.com/digitalfabrik/react-sticky-headroom/main/demo.gif)](https://digitalfabrik.github.io/react-sticky-headroom/) 35 | 36 | ## API 37 | 38 | You can pass the following props to ReactStickyHeadroom: 39 | 40 | - `children: React.Node` The header component, that should be hidden and revealed 41 | - `scrollHeight: number` The maximum amount of px the header should move up when scrolling 42 | - `pinStart: number` (Default: `0`) The minimum scrollTop position where the transform should start 43 | - `height?: number` (Optional) The height of the `children` node. Used for calculating the stickyTop position for a sticky ancestor in `onStickyTopChanged` 44 | - `onStickyTopChanged?: (number) => void` Fired, when Headroom changes its state and `height` is provided. Passes the calculated stickyTop position of an ancestor node. 45 | - `positionStickyDisabled?: boolean` (Optional, Default: `false`) If true, the header will stay static (e.g. for edge 16 support) 46 | - `parent: ?HTMLElement` (Optional, Default: `document.documentElement`) The parent element firing the scroll event. 47 | - `zIndex: number` (Optional, Default: 1) The z-index used by the wrapper. 48 | - `className?: string` (Optional) A classname for applying custom styles to the wrapper. Use at your own risk. 49 | 50 | ## Support 51 | 52 | The component generally supports: 53 | 54 | - Internet Explorer 11 55 | - Edge >= 16 56 | - Chrome >= 41 57 | - Firefox >= 40 58 | - Safari >= 6.2 59 | 60 | However, if you want to support non-modern browsers, you are responsible for transpiling the code for your preferred target. 61 | The distributed files on npm are transpiled for ES2020. 62 | 63 | For hiding and revealing the header, the browser needs to support the css-property `position: sticky`. 64 | You can read about the browser support for that on [caniuse.com](https://caniuse.com/#feat=css-sticky). 65 | 'Partial-Support' is enough for ReactStickyHeadroom to work in most cases. 66 | 67 | Since version 2.x.x, ReactStickyHeadroom is written in TypeScript. 68 | Support for FlowJS types were dropped in version 2.0.0. 69 | 70 | Since version 3.x.x, ReactStickyHeadroom uses @emotion/styled instead of styled-components. 71 | 72 | ReactStickyHeadroom is a client-side library and hence does not support Server Side Rendering (SSR) a priori. 73 | For NextJS you can find more information on how to embed this library [here](https://nextjs.org/docs/advanced-features/dynamic-import#with-no-ssr). 74 | 75 | If there are any problems, please don't hesitate to open an issue on GitHub. 76 | -------------------------------------------------------------------------------- /demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/digitalfabrik/react-sticky-headroom/95987ce1221ce1e0068648159bab0b92c4059c02/demo.gif -------------------------------------------------------------------------------- /demo/cities.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | 'United States': [ 3 | 'Adak', 4 | 'Pago Pago', 5 | 'Honolulu', 6 | 'Hilo', 7 | 'Anchorage', 8 | 'Fairbanks', 9 | 'Sitka', 10 | 'Juneau', 11 | 'Adamstown', 12 | 'Portland', 13 | 'San Francisco', 14 | 'Jacksonville', 15 | 'Charlotte', 16 | 'Miami', 17 | 'Pittsburgh', 18 | 'Buffalo', 19 | 'Raleigh', 20 | 'Jamestown', 21 | 'Hagåtña', 22 | 'Dededo', 23 | 'Saipan', 24 | 'St. Louis', 25 | 'New York City', 26 | ], 27 | Canada: [ 28 | 'Whitehorse', 29 | 'Vancouver', 30 | 'Yellowknife', 31 | 'Calgary', 32 | 'Edmonton', 33 | 'Saskatoon', 34 | 'Regina', 35 | 'Winnipeg', 36 | 'Toronto', 37 | 'Ottawa', 38 | 'Montreal', 39 | 'Iqaluit', 40 | 'Fredericton', 41 | 'Charlottetown', 42 | "St. John's", 43 | 'Quebec City', 44 | 'City of Halifax', 45 | ], 46 | Mexico: [ 47 | 'Tijuana', 48 | 'Mexicali', 49 | 'Chihuahua', 50 | 'Durango', 51 | 'Zapopan', 52 | 'Guadalajara', 53 | 'Monterrey', 54 | 'Mexico City', 55 | 'Puebla', 56 | 'Veracruz', 57 | 'Mérida', 58 | ], 59 | Chile: [ 60 | 'Hanga Roa', 61 | 'Valdivia', 62 | 'Concepción', 63 | 'Valparaíso', 64 | 'La Serena', 65 | 'Punta Arenas', 66 | 'Santiago', 67 | 'Antofagasta', 68 | 'Iquique', 69 | 'Calama', 70 | 'Puerto Williams', 71 | ], 72 | Malaysia: [ 73 | 'George Town', 74 | 'Ipoh', 75 | 'Kuala Lumpur', 76 | 'Kota Bharu', 77 | 'Johor Bahru', 78 | 'Kuching', 79 | 'Miri', 80 | 'Kota Kinabalu', 81 | 'Alor Star', 82 | 'Malacca Town', 83 | ], 84 | Brazil: [ 85 | 'Rio Branco', 86 | 'Porto Velho', 87 | 'Boa Vista', 88 | 'Manaus', 89 | 'Cuiabá', 90 | 'Campo Grande', 91 | 'Chuí', 92 | 'Pelotas', 93 | 'Porto Alegre', 94 | 'Macapá', 95 | 'Curitiba', 96 | 'Belém', 97 | 'Brasília', 98 | 'Campinas', 99 | 'São Paulo', 100 | 'Vitória', 101 | 'Ilhéus', 102 | 'Fortaleza', 103 | 'Maceió', 104 | 'Recife', 105 | 'Praia', 106 | 'Porto', 107 | 'São Tomé', 108 | 'Bern', 109 | 'São José dos Campos', 110 | 'Rio de Janeiro', 111 | ], 112 | Argentina: [ 113 | 'Mendoza', 114 | 'Ushuaia', 115 | 'Córdoba', 116 | 'Bahía Blanca', 117 | 'Rosario', 118 | 'Buenos Aires', 119 | 'San Carlos de Bariloche', 120 | 'Ciudad del Este', 121 | ], 122 | Italy: ['Assis', 'Turin', 'Milan', 'Rome', 'Naples'], 123 | Portugal: ['Ponta Delgada', 'Lisbon', 'Horta (Azores)', 'Angra do Heroísmo'], 124 | Morocco: ['Casablanca', 'Rabat', 'Marrakech'], 125 | Spain: [ 126 | 'Seville', 127 | 'Málaga', 128 | 'Madrid', 129 | 'Bilbao', 130 | 'Valencia', 131 | 'Ibiza', 132 | 'Barcelona', 133 | 'Palma', 134 | 'Santa Cruz de Tenerife', 135 | 'Las Palmas de Gran Canaria', 136 | ], 137 | 'United Kingdom': [ 138 | 'Belfast', 139 | 'Glasgow', 140 | 'Cardiff', 141 | 'Edinburgh', 142 | 'Liverpool', 143 | 'Manchester', 144 | 'Aberdeen', 145 | 'Birmingham', 146 | 'Leeds', 147 | 'London', 148 | 'Greenwich', 149 | 'Newcastle', 150 | ], 151 | Australia: [ 152 | 'Douglas', 153 | 'Mandurah', 154 | 'Perth', 155 | 'Port Hedland', 156 | 'Darwin', 157 | 'Adelaide', 158 | 'Geelong', 159 | 'Melbourne', 160 | 'Cairns', 161 | 'Townsville', 162 | 'Hobart', 163 | 'Canberra', 164 | 'Rockhampton', 165 | 'Wollongong', 166 | 'Sydney', 167 | 'Brisbane', 168 | 'Gold Coast', 169 | ], 170 | France: ['Nantes', 'Toulouse', 'Paris', 'Lyon', 'Marseille', 'Cannes', 'Nice', 'Strasbourg'], 171 | Nigeria: ['Lagos', 'Ibadan', 'Abuja', 'Enugu', 'Kano'], 172 | Netherlands: ['The Hague', 'Rotterdam', 'Amsterdam'], 173 | Germany: [ 174 | 'Düsseldorf', 175 | 'Cologne', 176 | 'Frankfurt', 177 | 'Stuttgart', 178 | 'Hanover', 179 | 'Hamburg', 180 | 'Munich', 181 | 'Leipzig', 182 | 'Berlin', 183 | 'Dresden', 184 | ], 185 | 'South Africa': ['Cape Town', 'Port Elizabeth', 'Bloemfontein', 'Johannesburg', 'Pretoria', 'Durban'], 186 | Poland: ['Kraków', 'Warsaw', 'Gdańsk'], 187 | Russia: [ 188 | 'Kaliningrad', 189 | 'Saint Petersburg', 190 | 'Murmansk', 191 | 'Moscow', 192 | 'Samara', 193 | 'Perm', 194 | 'Yekaterinburg', 195 | 'Omsk', 196 | 'Novosibirsk', 197 | 'Norilsk', 198 | 'Bratsk', 199 | 'Yakutsk', 200 | 'Vladivostok', 201 | 'Magadan', 202 | 'Nizhny Novgorod', 203 | 'Petropavlovsk-Kamchatsky', 204 | ], 205 | Greece: ['Athens', 'Thessaloniki'], 206 | Ukraine: ['Lviv', 'Odessa', 'Simferopol', 'Kharkiv', 'Kiev'], 207 | Turkey: ['Istanbul', 'Bursa', 'Konya', 'Ankara', 'Mersin', 'Adana', 'Gaziantep', 'İzmir'], 208 | Egypt: ['Alexandria', 'Cairo', 'Port Said', 'Suez', 'Luxor'], 209 | Swaziland: ['Mbabane', 'Lobamba', 'Manzini'], 210 | Tanzania: ['Mwanza', 'Dodoma', 'Zanzibar City', 'Dar es Salaam'], 211 | 'Saudi Arabia': ['Jeddah', 'Medina', 'Mecca', 'Riyadh', 'Dammam'], 212 | Pakistan: ['Karachi', 'Multan', 'Peshawar', 'Faisalabad', 'Rawalpindi', 'Islamabad', 'Lahore'], 213 | India: [ 214 | 'Hyderabad', 215 | 'Ahmedabad', 216 | 'Mumbai', 217 | 'Surat', 218 | 'Pune', 219 | 'Srinagar', 220 | 'Amritsar', 221 | 'Jaipur', 222 | 'Ludhiana', 223 | 'New Delhi', 224 | 'Bangalore', 225 | 'Nagpur', 226 | 'Chennai', 227 | 'Kanpur', 228 | 'Lucknow', 229 | 'Patna', 230 | 'Gangtok', 231 | 'Agartala', 232 | 'Guwahati', 233 | 'Shillong', 234 | 'Port Blair', 235 | 'Dibrugarh', 236 | 'Kolkata', 237 | ], 238 | 'Sri Lanka': ['Colombo', 'Kandy', 'Batticaloa', 'Sri Jayawardenapura-Kotte'], 239 | China: [ 240 | 'Ürümqi', 241 | 'Shigatse', 242 | 'Lhasa', 243 | 'Xining', 244 | 'Kunming', 245 | 'Lanzhou', 246 | 'Chengdu', 247 | 'Chongqing', 248 | 'Nanning', 249 | 'Taiyuan', 250 | 'Guangzhou', 251 | 'Zhengzhou', 252 | 'Dongguan', 253 | 'Shenzhen', 254 | 'Wuhan', 255 | 'Handan', 256 | 'Shijiazhuang', 257 | 'Beijing', 258 | 'Jinan', 259 | 'Tianjin', 260 | 'Nanjing', 261 | 'Hangzhou', 262 | 'Qingdao', 263 | 'Shanghai', 264 | 'Dalian', 265 | 'Shenyang', 266 | 'Changchun', 267 | 'Harbin', 268 | "Xi'an", 269 | ], 270 | Indonesia: [ 271 | 'Banda Aceh', 272 | 'Medan', 273 | 'Padang', 274 | 'Pekanbaru', 275 | 'Palembang', 276 | 'Jakarta', 277 | 'Bogor', 278 | 'Bandung', 279 | 'Pontianak', 280 | 'Semarang', 281 | 'Malang', 282 | 'Surabaya', 283 | 'Denpasar', 284 | 'Balikpapan', 285 | 'Makassar', 286 | 'Ambon', 287 | 'Jayapura', 288 | 'Yogyakarta (city)', 289 | ], 290 | Thailand: [ 291 | 'Chiang Mai', 292 | 'Surat Thani', 293 | 'Hat Yai', 294 | 'Bangkok', 295 | 'Pattaya', 296 | 'Nakhon Ratchasima', 297 | 'Udon Thani', 298 | 'Phuket (city)', 299 | ], 300 | Vietnam: ['Hanoi', 'Hai Phong', 'Ho Chi Minh City', 'Da Nang', 'Huế'], 301 | 'South Korea': ['Incheon', 'Seoul', 'Daegu', 'Busan'], 302 | Japan: [ 303 | 'Okinawa', 304 | 'Fukuoka', 305 | 'Hiroshima', 306 | 'Kobe', 307 | 'Osaka', 308 | 'Kyoto', 309 | 'Nagoya', 310 | 'Yokohama', 311 | 'Kawasaki', 312 | 'Sapporo', 313 | 'Tokyo', 314 | ], 315 | 'New Zealand': ['Invercargill', 'Dunedin', 'Christchurch', 'Wellington', 'Auckland'], 316 | } as const 317 | -------------------------------------------------------------------------------- /demo/demo.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { createRoot } from 'react-dom/client' 3 | import StickyHeadroom from '../src/Headroom' 4 | import styled from '@emotion/styled' 5 | import CITIES from './cities' 6 | 7 | const Header = styled.header` 8 | height: 100px; 9 | display: flex; 10 | flex-direction: column; 11 | justify-content: center; 12 | align-items: center; 13 | background: dodgerblue; 14 | 15 | h2 { 16 | margin: 10px; 17 | } 18 | ` 19 | 20 | const PreHeader = styled.div` 21 | height: 150px; 22 | display: flex; 23 | flex-direction: column; 24 | justify-content: center; 25 | align-items: center; 26 | background: aquamarine; 27 | ` 28 | 29 | const Country = styled.div<{ stickyTop: number }>` 30 | position: sticky; 31 | top: ${props => props.stickyTop}px; 32 | padding: 20px 10px; 33 | background-color: #bbbbbb; 34 | box-shadow: 1px 1px 2px rgba(0, 0, 0, 0.5); 35 | transition: top 0.2s ease-out; 36 | ` 37 | 38 | const City = styled.div` 39 | padding: 5px 20px; 40 | ` 41 | 42 | class Demo extends React.PureComponent, { stickyTop: number; secondStickyTop: number }> { 43 | state = { 44 | stickyTop: 0, 45 | secondStickyTop: 0, 46 | } 47 | 48 | secondScroller = React.createRef() 49 | resolveSecondScroller = () => this.secondScroller.current 50 | 51 | onStickyTopChanged = (stickyTop: number) => { 52 | this.setState({ stickyTop }) 53 | } 54 | 55 | onSecondStickyTopChanged = (secondStickyTop: number) => { 56 | this.setState({ secondStickyTop }) 57 | } 58 | 59 | render() { 60 | const { stickyTop, secondStickyTop } = this.state 61 | return ( 62 | <> 63 | 64 |

Small Preheader

65 |

66 | This is a demo for ReactStickyHeadroom{' '} 67 | (Github)! 68 |

69 |
70 | 71 |
72 |

ReactStickyHeadroom

73 |
Submenu is always there for you, so keep on scrolling!
74 |
75 |
76 |
77 |

78 | You can look at the underlying code{' '} 79 | here. 80 |

81 |

Look at all these cities grouped by their countries:

82 |
83 | {(Object.keys(CITIES) as Array).map(key => ( 84 |
85 | {key} 86 | {CITIES[key].map(city => ( 87 | {city} 88 | ))} 89 |
90 | ))} 91 | 92 |
93 | 94 |

Small Preheader

95 |

This is a demo for StickyHeadroom in a second scrolling box!

96 |
97 | 103 |
104 |

ReactStickyHeadroom

105 |
Second submenu is always visible in the box, so keep on scrolling!
106 |
107 |
108 | {(Object.keys(CITIES) as Array).map(key => ( 109 |
110 | {key} 111 | {CITIES[key].map(city => ( 112 | {city} 113 | ))} 114 |
115 | ))} 116 |
117 | 118 | ) 119 | } 120 | } 121 | 122 | const container = document.getElementById('react-container') 123 | 124 | if (container == null) { 125 | throw new Error("Couldn't find element with id 'react-container'.") 126 | } 127 | 128 | const root = createRoot(container) 129 | root.render() 130 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ReactStickyHeadroom 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /jest.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "rootDir": "src/", 3 | "verbose": true, 4 | "automock": false, 5 | "testEnvironment": "jsdom", 6 | "setupFiles": ["/setupTest.ts"], 7 | "globals": { 8 | "__DEV__": false 9 | }, 10 | "transform": { 11 | "^.+\\.(t|j)sx?$": "@swc/jest" 12 | }, 13 | "collectCoverageFrom": ["**/*.{ts,tsx}"], 14 | "coverageDirectory": "../__coverage__", 15 | "reporters": ["default", "jest-junit"] 16 | } 17 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@integreat-app/react-sticky-headroom", 3 | "version": "3.0.0", 4 | "engines": { 5 | "node": ">=18", 6 | "npm": ">=10" 7 | }, 8 | "license": "MIT", 9 | "description": "ReactStickyHeadroom is a React Component for hiding the header when scrolling.", 10 | "author": "Michael Markl ", 11 | "repository": { 12 | "type": "git", 13 | "url": "git+https://github.com/integreat/react-sticky-headroom.git" 14 | }, 15 | "files": [ 16 | "index.js", 17 | "index.js.map", 18 | "index.cjs", 19 | "index.cjs.map", 20 | "index.d.ts", 21 | "index.d.ts.map", 22 | "index.tsx" 23 | ], 24 | "sideEffects": false, 25 | "exports": { 26 | "import": "./index.js", 27 | "require": "./index.cjs", 28 | "types": "./index.d.ts" 29 | }, 30 | "publishConfig": { 31 | "access": "public" 32 | }, 33 | "keywords": [ 34 | "headroom", 35 | "sticky", 36 | "react", 37 | "hide", 38 | "header", 39 | "emotion", 40 | "typescript" 41 | ], 42 | "module": "./index.js", 43 | "scripts": { 44 | "build": "ts-node tools/build.ts", 45 | "build:demo": "webpack --config tools/demo.webpack.config.ts", 46 | "test": "jest --config jest.config.json", 47 | "test:coverage": "jest --config jest.config.json --coverage", 48 | "test:watch": "jest --config jest.config.json --watchAll", 49 | "test:update": "jest --config jest.config.json -u", 50 | "lint": "npm run eslint && npm run stylelint", 51 | "lint:fix": "eslint . --fix && npm run stylelint", 52 | "eslint": "eslint .", 53 | "stylelint": "stylelint './src/**/*.{ts,tsx}'", 54 | "ts:check": "tsc", 55 | "prepublishOnly": "npm run build && npm run build:demo && npm run test && npm run lint && npm run ts:check" 56 | }, 57 | "//": "browserslist only affects the build of the demo app, not the library itself.", 58 | "browserslist": [ 59 | "ie >= 11", 60 | "edge >= 16", 61 | "chrome >= 41", 62 | "firefox >= 40", 63 | "safari >= 6.2" 64 | ], 65 | "peerDependencies": { 66 | "react": "16.x.x || 17.x.x || 18.x.x || 19.x.x", 67 | "@emotion/react": "11.x.x", 68 | "@emotion/styled": "11.x.x" 69 | }, 70 | "devDependencies": { 71 | "@emotion/react": "^11.14.0", 72 | "@emotion/styled": "^11.14.0", 73 | "@swc/core": "^1.11.24", 74 | "@swc/jest": "^0.2.38", 75 | "@swc/plugin-emotion": "^9.0.4", 76 | "@types/jest": "^29.5.14", 77 | "@types/node": "^22.15.21", 78 | "@types/react-dom": "^19.1.5", 79 | "@typescript-eslint/eslint-plugin": "^8.32.1", 80 | "@typescript-eslint/parser": "^8.32.1", 81 | "browserslist": "^4.24.5", 82 | "eslint": "^8.57.1", 83 | "eslint-config-prettier": "^10.1.5", 84 | "eslint-config-standard": "^17.1.0", 85 | "eslint-plugin-import": "^2.31.0", 86 | "eslint-plugin-jest": "^28.11.0", 87 | "eslint-plugin-node": "^11.1.0", 88 | "eslint-plugin-promise": "^6.6.0", 89 | "eslint-plugin-react": "^7.37.5", 90 | "jest": "^29.7.0", 91 | "jest-environment-jsdom": "^29.7.0", 92 | "jest-junit": "^16.0.0", 93 | "postcss": "^8.5.3", 94 | "postcss-styled-syntax": "^0.7.1", 95 | "prettier": "^3.5.3", 96 | "raf": "^3.4.1", 97 | "react": "^19.1.0", 98 | "react-dom": "^19.1.0", 99 | "stylelint": "^16.19.1", 100 | "stylelint-config-recommended": "^16.0.0", 101 | "swc-loader": "^0.2.6", 102 | "ts-node": "^10.9.2", 103 | "typescript": "^5.8.3", 104 | "webpack": "^5.99.9", 105 | "webpack-cli": "^6.0.1" 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/Headroom.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled from '@emotion/styled' 3 | import { css, keyframes } from '@emotion/react' 4 | 5 | const DIRECTION_UP = 'up' 6 | const DIRECTION_DOWN = 'down' 7 | 8 | const MODE_UNPINNED = 'unpinned' 9 | const MODE_PINNED = 'pinned' 10 | const MODE_STATIC = 'static' 11 | 12 | const TRANSITION_NONE = 'none' 13 | const TRANSITION_NORMAL = 'normal' 14 | const TRANSITION_PINNED_TO_STATIC = 'pinned-to-static' 15 | 16 | type ModeType = typeof MODE_PINNED | typeof MODE_UNPINNED | typeof MODE_STATIC 17 | type DirectionType = typeof DIRECTION_UP | typeof DIRECTION_DOWN 18 | type TransitionType = typeof TRANSITION_NONE | typeof TRANSITION_NORMAL | typeof TRANSITION_PINNED_TO_STATIC 19 | 20 | type PropsType = { 21 | /** The child node to be displayed as a header */ 22 | children: React.ReactNode 23 | /** The maximum amount of px the header should move up when scrolling */ 24 | scrollHeight: number 25 | /** The minimum scrollTop position where the transform should start */ 26 | pinStart: number 27 | /** Used for calculating the stickyTop position of an ancestor */ 28 | height?: number 29 | /** Fired, when Headroom changes its state. Passes stickyTop of the ancestor. */ 30 | onStickyTopChanged?: (stickyTop: number) => void 31 | /** True, if sticky position should be disabled (e.g. for edge 16 support) */ 32 | positionStickyDisabled?: boolean 33 | /** The parent element firing the scroll event. Defaults to document.documentElement */ 34 | parent?: HTMLElement | null 35 | /** The z-index used by the wrapper. Defaults to 1. */ 36 | zIndex?: number 37 | /** A classname for applying custom styles to the wrapper. Use at your own risk. */ 38 | className?: string 39 | } 40 | 41 | type StateType = { 42 | mode: ModeType 43 | transition: TransitionType 44 | animateUpFrom: number | null 45 | } 46 | 47 | const HeaderWrapper = styled.div<{ 48 | $positionStickyDisabled: boolean 49 | $translateY: number 50 | $transition: TransitionType 51 | $animateUpFrom: number | null 52 | $zIndex?: number 53 | $top: number 54 | $static: boolean 55 | }>` 56 | position: ${props => (props.$positionStickyDisabled ? 'static' : 'sticky')}; 57 | top: ${props => props.$top}px; 58 | z-index: ${props => props.$zIndex}; 59 | transform: translateY(${props => props.$translateY}px); 60 | animation-duration: 0.2s; 61 | animation-timing-function: ease-out; 62 | ${props => (props.$transition === TRANSITION_NORMAL && !props.$static ? 'transition: transform 0.2s ease-out;' : '')} 63 | ${props => 64 | props.$transition === TRANSITION_PINNED_TO_STATIC && props.$animateUpFrom !== null 65 | ? css` 66 | animation-name: ${keyframesMoveUpFrom(props.$animateUpFrom)}; 67 | ` 68 | : ''} 69 | ${props => (props.$static ? 'transition: none;' : '')} 70 | ` 71 | 72 | const keyframesMoveUpFrom = (from: number) => keyframes` 73 | from { 74 | transform: translateY(${Math.max(from, 0)}px) 75 | } 76 | 77 | to { 78 | transform: translateY(0) 79 | } 80 | ` 81 | 82 | class Headroom extends React.PureComponent { 83 | static defaultProps: { pinStart: number; zIndex: number; parent: HTMLElement | null } = { 84 | pinStart: 0, 85 | zIndex: 1, 86 | parent: window.document.documentElement, 87 | } 88 | 89 | state: StateType = { 90 | mode: MODE_STATIC, 91 | transition: TRANSITION_NONE, 92 | animateUpFrom: null, 93 | } 94 | 95 | /** the very last scrollTop which we know about (to determine direction changes) */ 96 | lastKnownScrollTop: number = 0 97 | 98 | /** 99 | * @returns {number} the current scrollTop position of the window 100 | */ 101 | getScrollTop(): number { 102 | const parent = this.props.parent 103 | if (parent && parent.scrollTop !== undefined && parent !== document.documentElement) { 104 | return parent.scrollTop 105 | } 106 | if (parent !== document.documentElement) { 107 | console.warn('Could not determine scrollTop from parent for StickyHeadroom. Defaulting to window.pageYOffset.') 108 | } 109 | if (window.pageYOffset === undefined) { 110 | console.error('window.pageYOffset is undefined. Defaulting to 0.') 111 | return 0 112 | } 113 | return window.pageYOffset 114 | } 115 | 116 | componentDidMount() { 117 | this.addScrollListener(this.props.parent) 118 | } 119 | 120 | addScrollListener(parent?: HTMLElement | null) { 121 | if (parent === window.document.documentElement) { 122 | window.addEventListener('scroll', this.handleEvent) 123 | } else if (parent) { 124 | parent.addEventListener('scroll', this.handleEvent) 125 | } else { 126 | console.debug("'parent' prop of Headroom is null. Assuming, it will be set soon...") 127 | } 128 | } 129 | 130 | removeScrollListener(parent?: HTMLElement | null) { 131 | if (parent === window.document.documentElement) { 132 | window.removeEventListener('scroll', this.handleEvent) 133 | } else if (parent) { 134 | parent.removeEventListener('scroll', this.handleEvent) 135 | } 136 | } 137 | 138 | componentDidUpdate(prevProps: PropsType) { 139 | if (prevProps.parent !== this.props.parent) { 140 | this.removeScrollListener(prevProps.parent) 141 | this.addScrollListener(this.props.parent) 142 | } 143 | } 144 | 145 | componentWillUnmount() { 146 | this.removeScrollListener(this.props.parent) 147 | } 148 | 149 | /** 150 | * If we're already static and pinStart + scrollHeight >= scrollTop, then we should stay static. 151 | * If we're not already static, then we should set the header static, only when pinStart >= scrollTop (regardless of 152 | * scrollHeight, so the header doesn't jump up, when scrolling upwards to the trigger). 153 | * Else we shouldn't set it static. 154 | * @param scrollTop the currentScrollTop position 155 | * @param direction the current direction 156 | * @returns {boolean} if we should set the header static 157 | */ 158 | shouldSetStatic(scrollTop: number, direction: DirectionType): boolean { 159 | if (this.state.mode === MODE_STATIC || (this.state.mode === MODE_PINNED && direction === DIRECTION_DOWN)) { 160 | return this.props.pinStart + this.props.scrollHeight >= scrollTop 161 | } else { 162 | return this.props.pinStart >= scrollTop 163 | } 164 | } 165 | 166 | /** 167 | * Determines the mode depending on the scrollTop position and the current direction 168 | * @param {number} scrollTop 169 | * @param {string} direction 170 | * @returns {string} the next mode of Headroom 171 | */ 172 | determineMode(scrollTop: number, direction: DirectionType): ModeType { 173 | if (this.shouldSetStatic(scrollTop, direction)) { 174 | return MODE_STATIC 175 | } else { 176 | return direction === DIRECTION_UP ? MODE_PINNED : MODE_UNPINNED 177 | } 178 | } 179 | 180 | /** 181 | * @returns {TransitionType} determines the kind of transition 182 | */ 183 | determineTransition(mode: ModeType, direction: DirectionType): TransitionType { 184 | // Handle special case: If we're pinned and going to static, we need a special transition using css animation 185 | if (this.state.mode === MODE_PINNED && mode === MODE_STATIC) { 186 | return TRANSITION_PINNED_TO_STATIC 187 | } 188 | // If mode is static, then no transition, because we're already in the right spot 189 | // (and want to change transform and top properties seamlessly) 190 | if (mode === MODE_STATIC) { 191 | return this.state.transition === TRANSITION_NONE ? TRANSITION_NONE : TRANSITION_PINNED_TO_STATIC 192 | } 193 | // mode is not static, transition when moving upwards or when we've lastly did the transition 194 | return direction === DIRECTION_UP || this.state.transition === TRANSITION_NORMAL 195 | ? TRANSITION_NORMAL 196 | : TRANSITION_NONE 197 | } 198 | 199 | /** 200 | * Checks the current scrollTop position and updates the state accordingly 201 | */ 202 | update: () => void = () => { 203 | const currentScrollTop = this.getScrollTop() 204 | const newState: Partial = {} 205 | if (currentScrollTop === this.lastKnownScrollTop) { 206 | return 207 | } 208 | const direction = this.lastKnownScrollTop < currentScrollTop ? DIRECTION_DOWN : DIRECTION_UP 209 | newState.mode = this.determineMode(currentScrollTop, direction) 210 | newState.transition = this.determineTransition(newState.mode, direction) 211 | 212 | const { onStickyTopChanged, height, scrollHeight, pinStart } = this.props 213 | if (this.state.mode === MODE_PINNED && newState.mode === MODE_STATIC) { 214 | // animation in the special case from pinned to static 215 | newState.animateUpFrom = currentScrollTop - pinStart 216 | } 217 | if (onStickyTopChanged && newState.mode !== this.state.mode && height) { 218 | onStickyTopChanged(Headroom.calcStickyTop(newState.mode, height, scrollHeight)) 219 | } 220 | this.setState(newState as StateType) 221 | this.lastKnownScrollTop = currentScrollTop 222 | } 223 | 224 | handleEvent: () => void = () => { 225 | window.requestAnimationFrame(this.update) 226 | } 227 | 228 | static calcStickyTop(mode: ModeType, height: number, scrollHeight: number): number { 229 | return mode === MODE_PINNED ? height : height - scrollHeight 230 | } 231 | 232 | render(): React.ReactElement { 233 | const { children, scrollHeight, positionStickyDisabled, zIndex, className } = this.props 234 | const { mode, transition, animateUpFrom } = this.state 235 | const transform = mode === MODE_UNPINNED ? -scrollHeight : 0 236 | const ownStickyTop = mode === MODE_STATIC ? -scrollHeight : 0 237 | return ( 238 | 247 | {children} 248 | 249 | ) 250 | } 251 | } 252 | 253 | export default Headroom 254 | -------------------------------------------------------------------------------- /src/__tests__/Headroom.spec.tsx: -------------------------------------------------------------------------------- 1 | import Headroom from '../Headroom' 2 | 3 | describe('Headroom', () => { 4 | it('mock test', () => { 5 | expect(Headroom).toBeInstanceOf(Function) 6 | }) 7 | }) 8 | 9 | // TODO: Rewrite tests #37 10 | // import { ReactWrapper, mount } from 'enzyme' 11 | // import Headroom from '../Headroom' 12 | // import React, { ComponentProps } from 'react' 13 | // 14 | // const pinStart = 10 15 | // const height = 100 16 | // const scrollHeight = 50 17 | // 18 | // type MountedHeadroom = ReactWrapper, object, InstanceType> 19 | // 20 | // describe('Headroom', () => { 21 | // const MockNode = () =>
22 | // 23 | // const createComponent = (props: Partial> = {}) => 24 | // mount( 25 | // 26 | // 27 | // , 28 | // ) as MountedHeadroom 29 | // 30 | // it('should have correct default state', () => { 31 | // const component = createComponent() 32 | // expect(component.state()).toEqual({ 33 | // mode: 'static', 34 | // transition: 'none', 35 | // animateUpFrom: null, 36 | // }) 37 | // expect(component.prop('pinStart')).toEqual(0) 38 | // }) 39 | // 40 | // it('should render with values from its state', () => { 41 | // const component = createComponent() 42 | // component.setState({ transform: 42, stickyTop: 24 }) 43 | // expect(component.childAt(0).props()).toEqual({ 44 | // $animateUpFrom: null, 45 | // children: , 46 | // className: undefined, 47 | // $positionStickyDisabled: false, 48 | // $static: true, 49 | // $top: -50, 50 | // $transition: 'none', 51 | // $translateY: 0, 52 | // $zIndex: 1, 53 | // }) 54 | // }) 55 | // 56 | // it('should attach and detach listener for onscroll event', () => { 57 | // const originalAdd = window.addEventListener 58 | // const originalRemove = window.removeEventListener 59 | // window.addEventListener = jest.fn() 60 | // window.removeEventListener = jest.fn() 61 | // 62 | // const component = mount( 63 | // 64 | // 65 | // , 66 | // ) as MountedHeadroom 67 | // const handleEventCallback = component.instance().handleEvent 68 | // expect(window.addEventListener).toHaveBeenCalledWith('scroll', handleEventCallback) 69 | // component.unmount() 70 | // expect(window.removeEventListener).toHaveBeenCalledWith('scroll', handleEventCallback) 71 | // 72 | // window.addEventListener = originalAdd 73 | // window.removeEventListener = originalRemove 74 | // }) 75 | // 76 | // it('should request animation frame and update, on handleEvent', () => { 77 | // const originalRaf = window.requestAnimationFrame 78 | // const requestAnimationFrameMock = jest.fn() 79 | // window.requestAnimationFrame = requestAnimationFrameMock 80 | // const component = mount( 81 | // 82 | // 83 | // , 84 | // ) as MountedHeadroom 85 | // component.instance().update = jest.fn() 86 | // 87 | // // Call first time 88 | // component.instance().handleEvent() 89 | // expect(window.requestAnimationFrame).toHaveBeenCalledTimes(1) 90 | // expect(component.instance().update).toHaveBeenCalledTimes(0) 91 | // // Now perform the raf 92 | // requestAnimationFrameMock.mock.calls[0][0]() 93 | // expect(component.instance().update).toHaveBeenCalledTimes(1) 94 | // 95 | // window.requestAnimationFrame = originalRaf 96 | // }) 97 | // 98 | // describe('update', () => { 99 | // const scrollTo = (scrollTo: number, component: ReturnType) => { 100 | // window.pageYOffset = scrollTo 101 | // component.instance().update() 102 | // } 103 | // it("should set correct state, if user hasn't scrolled beyond pinStart", () => { 104 | // const component = createComponent({ pinStart, height, scrollHeight }) 105 | // scrollTo(0, component) 106 | // scrollTo(pinStart / 2, component) 107 | // expect(component.state()).toEqual({ 108 | // mode: 'static', 109 | // transition: 'none', 110 | // animateUpFrom: null, 111 | // }) 112 | // }) 113 | // 114 | // it('should set correct state, if user has scrolled down to pinStart + scrollHeight/2', () => { 115 | // const component = createComponent({ pinStart, height, scrollHeight }) 116 | // scrollTo(0, component) 117 | // scrollTo(pinStart + scrollHeight / 2, component) 118 | // expect(component.state()).toEqual({ 119 | // mode: 'static', 120 | // transition: 'none', 121 | // animateUpFrom: null, 122 | // }) 123 | // }) 124 | // 125 | // it('should set correct state, if user has scrolled down and back up again', () => { 126 | // const component = createComponent({ pinStart, height, scrollHeight }) 127 | // scrollTo(pinStart, component) 128 | // expect(component.state()).toEqual({ 129 | // mode: 'static', 130 | // transition: 'none', 131 | // animateUpFrom: null, 132 | // }) 133 | // 134 | // const offset = 5 135 | // scrollTo(pinStart + scrollHeight, component) 136 | // // Header is completely transformed to the top 137 | // expect(component.state()).toEqual({ 138 | // mode: 'static', 139 | // transition: 'none', 140 | // animateUpFrom: null, 141 | // }) 142 | // 143 | // scrollTo(pinStart + scrollHeight + offset, component) 144 | // // Header should be unpinned now, transitions should be off though 145 | // expect(component.state()).toEqual({ 146 | // mode: 'unpinned', 147 | // transition: 'none', 148 | // animateUpFrom: null, 149 | // }) 150 | // 151 | // scrollTo(pinStart + offset / 2, component) 152 | // // Header should be pinned with transition, because we're scrolling upwards 153 | // expect(component.state()).toEqual({ 154 | // mode: 'pinned', 155 | // transition: 'normal', 156 | // animateUpFrom: null, 157 | // }) 158 | // 159 | // scrollTo(pinStart + offset, component) 160 | // expect(component.state()).toEqual({ 161 | // mode: 'static', 162 | // transition: 'pinned-to-static', 163 | // animateUpFrom: offset, 164 | // }) 165 | // }) 166 | // 167 | // it("shouldn't update state if update is called with same scrollTop", () => { 168 | // const component = createComponent({ pinStart, height, scrollHeight }) 169 | // const offset = 5 170 | // scrollTo(pinStart + scrollHeight + offset, component) 171 | // expect(component.state()).toEqual({ 172 | // mode: 'unpinned', 173 | // transition: 'none', 174 | // animateUpFrom: null, 175 | // }) 176 | // scrollTo(pinStart + scrollHeight + offset, component) 177 | // expect(component.state()).toEqual({ 178 | // mode: 'unpinned', 179 | // transition: 'none', 180 | // animateUpFrom: null, 181 | // }) 182 | // }) 183 | // 184 | // it('should call onStickyTopChanged if mode has changed', () => { 185 | // const onStickyTopChanged = jest.fn() 186 | // const component = createComponent({ 187 | // pinStart, 188 | // height, 189 | // scrollHeight, 190 | // onStickyTopChanged, 191 | // }) 192 | // 193 | // scrollTo(pinStart + scrollHeight + 10, component) 194 | // expect(onStickyTopChanged).toHaveBeenCalledWith(scrollHeight) 195 | // scrollTo(0, component) 196 | // expect(onStickyTopChanged).toHaveBeenCalledWith(height - scrollHeight) 197 | // }) 198 | // }) 199 | // 200 | // it('should render correct if state is static, no transition', () => { 201 | // const component = createComponent({ pinStart, height, scrollHeight }) 202 | // component.setState({ mode: 'static', transition: 'none' }) 203 | // expect(component.childAt(0).props()).toEqual({ 204 | // $animateUpFrom: null, 205 | // children: , 206 | // className: undefined, 207 | // $positionStickyDisabled: false, 208 | // $static: true, 209 | // $top: -50, 210 | // $transition: 'none', 211 | // $translateY: 0, 212 | // $zIndex: 1, 213 | // }) 214 | // }) 215 | // 216 | // it('should render correct if state is unpinned, no transition', () => { 217 | // const component = createComponent({ pinStart, height, scrollHeight }) 218 | // component.setState({ mode: 'unpinned', transition: 'none' }) 219 | // expect(component.childAt(0).props()).toEqual({ 220 | // $animateUpFrom: null, 221 | // children: , 222 | // className: undefined, 223 | // $positionStickyDisabled: false, 224 | // $static: false, 225 | // $top: 0, 226 | // $transition: 'none', 227 | // $translateY: -50, 228 | // $zIndex: 1, 229 | // }) 230 | // }) 231 | // 232 | // it('should render correct if state is unpinned, transition', () => { 233 | // const component = createComponent({ pinStart, height, scrollHeight }) 234 | // component.setState({ mode: 'unpinned', transition: 'normal' }) 235 | // expect(component.childAt(0).props()).toEqual({ 236 | // $animateUpFrom: null, 237 | // children: , 238 | // className: undefined, 239 | // $positionStickyDisabled: false, 240 | // $static: false, 241 | // $top: 0, 242 | // $transition: 'normal', 243 | // $translateY: -50, 244 | // $zIndex: 1, 245 | // }) 246 | // }) 247 | // 248 | // it('should render correct if state is pinned, transition', () => { 249 | // const component = createComponent({ pinStart, height, scrollHeight }) 250 | // component.setState({ mode: 'pinned', transition: 'normal' }) 251 | // expect(component.childAt(0).props()).toEqual({ 252 | // $animateUpFrom: null, 253 | // children: , 254 | // className: undefined, 255 | // $positionStickyDisabled: false, 256 | // $static: false, 257 | // $top: 0, 258 | // $transition: 'normal', 259 | // $translateY: 0, 260 | // $zIndex: 1, 261 | // }) 262 | // }) 263 | // }) 264 | -------------------------------------------------------------------------------- /src/setupTest.ts: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import 'raf/polyfill' 4 | 5 | // $FlowFixMe 6 | console.error = error => { 7 | throw Error(error) 8 | } 9 | 10 | // $FlowFixMe 11 | console.warn = warn => { 12 | throw Error(warn) 13 | } 14 | -------------------------------------------------------------------------------- /stylelint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['stylelint-config-recommended'], 3 | customSyntax: 'postcss-styled-syntax', 4 | } 5 | -------------------------------------------------------------------------------- /tools/build.ts: -------------------------------------------------------------------------------- 1 | import { writeFileSync, copyFileSync } from 'fs' 2 | import { CompilerOptions, createCompilerHost, createProgram } from 'typescript' 3 | import { transformFileSync } from '@swc/core' 4 | 5 | function compile(fileNames: string[], options: CompilerOptions): Record { 6 | // Create a Program with an in-memory emit 7 | const createdFiles: Record = {} 8 | const host = createCompilerHost(options) 9 | host.writeFile = (fileName: string, contents: string) => (createdFiles[fileName] = contents) 10 | 11 | // Prepare and emit the d.ts files 12 | const program = createProgram(fileNames, options, host) 13 | program.emit() 14 | 15 | // Loop through all the input files 16 | return createdFiles 17 | } 18 | 19 | // We generate these files: 20 | // * index.tsx (a copy of src/Headroom.tsx) - using typescript 21 | // * index.d.ts (which is the typescript declaration) - using typescript 22 | // * index.d.ts.map (the source map for index.d.ts) - using typescript 23 | // * index.js (which strips the typescript annotations and optimizes emotion) - using swc 24 | // * index.js.map (the source map for index.js) - using swc 25 | // * index.cjs (a CommonJS version of index.js for legacy tooling) - using swc 26 | // * index.cjs.map (the source map for index.cjs) - using swc 27 | 28 | { 29 | // Generating index.tsx, index.d.ts, and index.d.ts.map 30 | 31 | copyFileSync('src/Headroom.tsx', 'index.tsx') 32 | console.log('emitted index.tsx') 33 | 34 | const createdFiles = compile(['index.tsx'], { 35 | allowJs: true, 36 | declaration: true, 37 | emitDeclarationOnly: true, 38 | declarationMap: true, 39 | }) 40 | 41 | const declarationFileName = 'index.d.ts' 42 | if (!(declarationFileName in createdFiles)) { 43 | throw Error('Failed to generate index.d.ts') 44 | } 45 | writeFileSync('./index.d.ts', createdFiles[declarationFileName]) 46 | console.log('emitted index.d.ts') 47 | 48 | const declarationMapFileName = 'index.d.ts.map' 49 | if (!(declarationMapFileName in createdFiles)) { 50 | throw Error('Failed to generate index.d.ts.map') 51 | } 52 | writeFileSync('./index.d.ts.map', createdFiles[declarationMapFileName]) 53 | console.log('emitted index.d.ts.map') 54 | } 55 | 56 | { 57 | // Generating index.js and index.js.map 58 | const transpiled = transformFileSync('index.tsx', { 59 | minify: false, 60 | module: { 61 | type: 'es6', 62 | }, 63 | jsc: { 64 | target: 'es2020', 65 | experimental: { 66 | plugins: [ 67 | [ 68 | '@swc/plugin-emotion', 69 | { 70 | displayName: false, 71 | ssr: false, 72 | }, 73 | ], 74 | ], 75 | }, 76 | }, 77 | sourceMaps: true, 78 | }) 79 | 80 | if (!transpiled) { 81 | throw Error('Failed to generate index.js') 82 | } 83 | const codeWithSourceMapUrl = transpiled.code + '//# sourceMappingURL=index.js.map' 84 | 85 | writeFileSync('./index.js', codeWithSourceMapUrl) 86 | console.log('emitted index.js') 87 | 88 | const sourceMap = transpiled.map 89 | if (!sourceMap) { 90 | throw Error('Failed to generate index.js.map') 91 | } 92 | writeFileSync('./index.cjs.map', sourceMap) 93 | console.log('emitted index.js.map') 94 | } 95 | 96 | { 97 | // Generating index.cjs and index.cjs.map 98 | const transpiled = transformFileSync('index.tsx', { 99 | minify: false, 100 | module: { 101 | type: 'commonjs', 102 | }, 103 | jsc: { 104 | target: 'es2020', 105 | experimental: { 106 | plugins: [ 107 | [ 108 | '@swc/plugin-emotion', 109 | { 110 | displayName: false, 111 | ssr: false, 112 | }, 113 | ], 114 | ], 115 | }, 116 | }, 117 | sourceMaps: true, 118 | }) 119 | 120 | if (!transpiled) { 121 | throw Error('Failed to generate index.cjs') 122 | } 123 | const codeWithSourceMapUrl = transpiled.code + '//# sourceMappingURL=index.cjs.map' 124 | 125 | writeFileSync('./index.cjs', codeWithSourceMapUrl) 126 | console.log('emitted index.cjs') 127 | 128 | const sourceMap = transpiled.map 129 | if (!sourceMap) { 130 | throw Error('Failed to generate index.cjs.map') 131 | } 132 | writeFileSync('./index.cjs.map', sourceMap) 133 | console.log('emitted index.cjs.map') 134 | } 135 | -------------------------------------------------------------------------------- /tools/demo.webpack.config.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from 'path' 2 | import * as webpack from 'webpack' 3 | 4 | const webpackConfig: webpack.Configuration = { 5 | mode: 'production', 6 | resolve: { 7 | extensions: ['.js', '.ts', '.tsx'], 8 | }, 9 | context: resolve(__dirname, '../demo'), 10 | entry: './demo.tsx', 11 | output: { 12 | path: resolve(__dirname, '../docs/'), 13 | filename: 'demo.js', 14 | }, 15 | plugins: [ 16 | new webpack.DefinePlugin({ 17 | 'process.env.NODE_ENV': '"production"', 18 | }), 19 | new webpack.LoaderOptionsPlugin({ minimize: true }), 20 | new webpack.optimize.AggressiveMergingPlugin(), 21 | ], 22 | module: { 23 | rules: [ 24 | { 25 | test: /\.tsx?|\.js$/, 26 | loader: 'swc-loader', 27 | options: { 28 | jsc: { 29 | experimental: { 30 | plugins: [ 31 | [ 32 | '@swc/plugin-emotion', 33 | { 34 | displayName: false, 35 | ssr: false, 36 | }, 37 | ], 38 | ], 39 | }, 40 | }, 41 | }, 42 | }, 43 | ], 44 | }, 45 | } 46 | 47 | export default webpackConfig 48 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "moduleResolution": "Node10", 5 | "resolveJsonModule": true, 6 | "target": "ES6", 7 | "skipLibCheck": true, 8 | "lib": ["dom", "dom.iterable", "ES6"], 9 | "allowJs": true, 10 | "allowSyntheticDefaultImports": true, 11 | "importHelpers": true, 12 | "strict": true, 13 | "forceConsistentCasingInFileNames": true, 14 | "noFallthroughCasesInSwitch": true, 15 | "noImplicitReturns": true, 16 | "noUnusedLocals": true, 17 | "noUnusedParameters": true, 18 | "isolatedModules": true, 19 | "jsx": "react", 20 | "types": ["node", "jest"], 21 | "noEmit": true 22 | }, 23 | "include": ["src/**/*", "demo/**/*", "tools/*"] 24 | } 25 | --------------------------------------------------------------------------------