├── .babelrc ├── .editorconfig ├── .eslintrc ├── .gitignore ├── .npmignore ├── .travis.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── __test__ └── index.js ├── docs.sh ├── package.json ├── postcss.config.js ├── src ├── js │ ├── app.js │ ├── footer.js │ ├── lib │ │ ├── scrollspy.js │ │ └── throttle.js │ ├── nav.js │ └── props-table.js ├── scss │ └── app.scss └── templates │ └── index.ejs ├── webpack.config.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/preset-env", { 5 | "targets": { 6 | "browsers": ["last 3 versions"], 7 | }, 8 | } 9 | ], 10 | "@babel/preset-react" 11 | ], 12 | "plugins": [ 13 | "@babel/plugin-transform-object-assign", 14 | "@babel/plugin-transform-runtime" 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | indent_style = space 6 | indent_size = 2 7 | charset = utf-8 8 | [*.html] 9 | indent_style = tab 10 | indent_size = 4 11 | [*.hbs] 12 | indent_style = tab 13 | indent_size = 4 14 | [*.js] 15 | indent_style = space 16 | indent_size = 2 17 | [*.jsx] 18 | indent_style = space 19 | indent_size = 2 20 | [*.json] 21 | indent_style = space 22 | indent_size = 2 23 | [*.css] 24 | indent_style = space 25 | indent_size = 2 26 | [*.scss] 27 | indent_style = space 28 | indent_size = 2 29 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | 4 | "plugins": [ 5 | "react" 6 | ], 7 | 8 | "env": { 9 | "browser": true, 10 | "node": true, 11 | "amd": true, 12 | "mocha": true, 13 | "es6": true, 14 | "worker": true 15 | }, 16 | 17 | "globals": { 18 | }, 19 | 20 | "rules": { 21 | "quotes": [1, "single"], 22 | "strict": [0], 23 | "max-params": [1, 4], 24 | "semi": [1, "never"], 25 | "no-undef": [2], 26 | "no-unused-vars": [1, {"vars": "all", "args": "after-used"}], 27 | "no-alert": [1], 28 | "valid-jsdoc": [1], 29 | "brace-style": [2, "1tbs"], 30 | "no-mixed-spaces-and-tabs": [2, true], 31 | "no-trailing-spaces": [2], 32 | "keyword-spacing": [2, {"before": true, "after": true}], 33 | "max-nested-callbacks": [1, 3], 34 | "space-infix-ops": [2], 35 | "radix": [2], 36 | "space-unary-ops": [2], 37 | "wrap-regex": [2], 38 | "no-mixed-requires": [1, true], 39 | "eol-last": [0], 40 | "no-bitwise": [1], 41 | "dot-notation": [1], 42 | "yoda": [2, "never"], 43 | "no-nested-ternary": [1], 44 | "no-delete-var": [1], 45 | "no-shadow": [2], 46 | "no-octal": [2], 47 | "no-extra-bind": [2], 48 | "no-extend-native": [2], 49 | "no-extra-parens": [0], 50 | "no-underscore-dangle": [0], 51 | "consistent-return": [1], 52 | "no-path-concat": [1], 53 | "no-process-exit": [1], 54 | "react/jsx-uses-vars": [2], 55 | "react/jsx-uses-react": [1] 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### https://raw.github.com/github/gitignore/8646d7f7d7a49a08f7112b01be8bb547070aef6d/Global/Vim.gitignore 2 | 3 | [._]*.s[a-w][a-z] 4 | [._]s[a-w][a-z] 5 | *.un~ 6 | Session.vim 7 | .netrwhist 8 | *~ 9 | 10 | 11 | ### https://raw.github.com/github/gitignore/8646d7f7d7a49a08f7112b01be8bb547070aef6d/Node.gitignore 12 | 13 | # Logs 14 | logs 15 | *.log 16 | 17 | # Runtime data 18 | pids 19 | *.pid 20 | *.seed 21 | 22 | # Directory for instrumented libs generated by jscoverage/JSCover 23 | lib-cov 24 | 25 | # Coverage directory used by tools like istanbul 26 | coverage 27 | 28 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # node-waf configuration 32 | .lock-wscript 33 | 34 | # Compiled binary addons (http://nodejs.org/api/addons.html) 35 | build/Release 36 | 37 | # Dependency directory 38 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git 39 | node_modules 40 | 41 | 42 | build/ 43 | release/ 44 | *.zip 45 | *.tar.gz 46 | .DS_Store 47 | .idea 48 | dist/ 49 | /lib/ 50 | /scrollspy.js -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/makotot/react-scrollspy/0be9e8acb1b822f29d2c14221e1832ed2938695f/.npmignore -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | env: 3 | global: 4 | - GH_REPO="makotot/react-scrollspy" 5 | - GIT_COMMITTER_NAME=makotot 6 | - GIT_COMMITTER_EMAIL=makoto.ttn@gmail.com 7 | - GIT_AUTHOR_NAME=makotot 8 | - GIT_AUTHOR_EMAIL=makoto.ttn@gmail.com 9 | - secure: G8ee2qd/yrzGBxlafPPq85WG7ii/dS9ORiNcHSIIzHxD9j6jsXwOCx8S9i3zs04r/pxHtkeIAwhGDDJbZHdSR+yaEg78ZM7PFTJc4zqrwsQiYmsNlSczdPxn1YYLGq6w1f6gb0BeqtNq+f/NJpynoR2EjRgIxkUO9bOsKyivhbPiqCxnKPOdBvqRa4lgNraVyLSG5sldE+IQeeE0et0BKiXFWuad4JCYdjVGMmenuZB06CCwdvGT3gOueoZLLCTvWOcIV0jyeG78RTUubMAjBHHrvT4EY8RkJihmkhJgm4JAh6Q+7RXX73Xf2Q5SzTG4nzTeLWp8X60wU2FKqA2/yy43sxAo/vBfm0xJKUrgUNjRNd5oiWE5BTqWTxuls2c8oKCoAbTjastPwnJ+vHN0zntz+ZysyKPn4TuDVOLpLIyZMmdfE9R2nDK5eKJvE+W7gKGS1t6zDiYP9LfCbbRhKlM1xx/B4QRJT/T35YP+I1OV/a00ZoE+uVq2jYw0HQlDehFPjTyr050Snu1dmVoko+qchZIV0RqsUjhljcy1wqAgcHvaqKGWpESDk7+U8MjUYHg2QIm7j+kD+kZot7HzcugBE/9VskEXdSDb1d49a4JKVrDIyAioBDNlMGuyPIxCGobBMXlK1buhYmtShc9bPPYkxk/3nvrGYRmMFhbzsmg= 10 | node_js: 11 | - '6' 12 | - '8' 13 | - '10' 14 | script: yarn run test 15 | cache: 16 | yarn: true 17 | directories: 18 | - node_modules 19 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 3.3.1 / 2017-08-16 2 | * 3.3.1 3 | * Merge pull request https://github.com/makotot/react-scrollspy/issues/71 from 4 | makotot/onupdate 5 | pass active el 6 | * pass active el 7 | * update changelog 8 | 9 | 3.3.0 / 2017-08-16 10 | * 3.3.0 11 | * Merge pull request https://github.com/makotot/react-scrollspy/issues/70 from 12 | makotot/onupdate 13 | add onupdate 14 | * mv 15 | * add onupdate 16 | 17 | 3.1.0 / 2017-05-05 18 | * 3.1.0 19 | * Merge pull request https://github.com/makotot/react-scrollspy/issues/59 from 20 | makotot/issue-48 21 | add roolEl props 22 | * add roolEl props 23 | 24 | 25 | 3.0.0 / 2017-04-22 26 | ================== 27 | 28 | * 3.0.0 29 | * Merge pull request [#55](https://github.com/makotot/react-scrollspy/issues/55) from makotot/export 30 | export as default 31 | * fix html 32 | * rm script tag 33 | * fix test 34 | * export as default 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2015-Present Makoto Tateno 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 | # DEPRECATED - no longer actively maintained. 2 | 3 | Please use [GhostUI](https://github.com/makotot/GhostUI) instead. 4 | 5 | # react-scrollspy 6 | 7 | [![npm version](https://img.shields.io/npm/v/react-scrollspy.svg?style=flat-square)](https://www.npmjs.com/package/react-scrollspy) 8 | [![travis](http://img.shields.io/travis/makotot/react-scrollspy.svg?style=flat-square)](https://travis-ci.org/makotot/react-scrollspy) 9 | [![dependencies](http://img.shields.io/david/makotot/react-scrollspy.svg?style=flat-square)](https://github.com/makotot/react-scrollspy) 10 | [![DevDependencies](http://img.shields.io/david/dev/makotot/react-scrollspy.svg?style=flat-square)](https://github.com/makotot/react-scrollspy) 11 | [![License](http://img.shields.io/npm/l/react-scrollspy.svg?style=flat-square)](https://github.com/makotot/react-scrollspy) 12 | [![downloads](https://img.shields.io/npm/dm/react-scrollspy.svg?style=flat-square)](https://www.npmjs.com/package/react-scrollspy) 13 | 14 | > Scrollspy component 15 | 16 | [Demo](http://makotot.github.io/react-scrollspy/) 17 | 18 | ## Install 19 | 20 | ```sh 21 | $ npm i react-scrollspy 22 | ``` 23 | 24 | ## Usage 25 | 26 | ```js 27 | import Scrollspy from 'react-scrollspy' 28 | 29 | ... 30 | 31 |
32 | 33 |
34 |
section 1
35 |
section 2
36 |
section 3
37 |
38 | 39 | 40 |
  • section 1
  • 41 |
  • section 2
  • 42 |
  • section 3
  • 43 |
    44 | 45 |
    46 | ``` 47 | 48 | ## Props 49 | 50 | ### `items={ Array }` 51 | 52 | Id list of target contents. 53 | 54 | ### `currentClassName={ String }` 55 | 56 | Class name that apply to the navigation element paired with the content element in viewport. 57 | 58 | ### `scrolledPastClassName={ String }` 59 | 60 | Class name that apply to the navigation elements that have been scrolled past [optional]. 61 | 62 | ### `componentTag={ String | React element type }` 63 | 64 | HTML tag or React Component type for Scrollspy component if you want to use something other than `ul` [optional]. 65 | 66 | ### `style={ Object }` 67 | 68 | Style attribute to be passed to the generated <ul/> element [optional]. 69 | 70 | ### `offset={ Number }` 71 | 72 | Offset value that adjusts to determine the elements are in the viewport [optional]. 73 | 74 | ### `rootEl={ String }` 75 | 76 | Name of the element of scrollable container that can be used with querySelector [optional]. 77 | 78 | ### `onUpdate={ Function }` 79 | 80 | Function to be executed when the active item has been updated [optional]. 81 | 82 | ## Methods 83 | 84 | ### `offEvent` 85 | 86 | Remove event listener of scrollspy. 87 | 88 | ### `onEvent` 89 | 90 | Add event listener of scrollspy. 91 | 92 | ## Development 93 | 94 | ```sh 95 | $ git clone https://github.com/makotot/react-scrollspy.git 96 | $ cd react-scrollspy 97 | $ npm i 98 | $ npm run start 99 | ``` 100 | 101 | ## Contributing 102 | 103 | Pull requests and [reporting an issue](https://github.com/makotot/react-scrollspy/issues/new) are always welcome :) 104 | 105 | ## License 106 | 107 | MIT 108 | -------------------------------------------------------------------------------- /__test__/index.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import React from 'react' 3 | import { shallow, mount, render } from 'enzyme' 4 | import Scrollspy from '../src/js/lib/scrollspy' 5 | 6 | test('renders correct children length', (t) => { 7 | const wrapper = shallow( 8 | 9 |
  • section 1
  • 10 |
  • section 2
  • 11 |
  • section 3
  • 12 |
    13 | ) 14 | t.is(wrapper.find('li').length, 3) 15 | }) 16 | 17 | test('renders children with correct props', (t) => { 18 | const wrapper = shallow( 19 | 20 |
  • section 1
  • 21 |
    22 | ) 23 | t.is(wrapper.find('li').prop('randomProp'), 'someText') 24 | }) 25 | 26 | test('renders expected html tag', (t) => { 27 | const defaultTag = shallow() 28 | const customTag = shallow() 29 | 30 | t.is(defaultTag.type(), 'ul') 31 | t.is(customTag.type(), 'div') 32 | }) 33 | 34 | test('renders expected React Component', (t) => { 35 | const MyComponent = () => {} 36 | const customTag = shallow() 37 | 38 | t.is(customTag.type(), MyComponent) 39 | }) 40 | -------------------------------------------------------------------------------- /docs.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | rm -rf gh_pages || exit 0; 4 | mkdir gh_pages 5 | ls -la 6 | cd gh_pages 7 | 8 | cp -r ../dist/* . 9 | 10 | git init 11 | git config user.name "${GIT_COMITTER_NAME}" 12 | git config user.email "${GIT_COMITTER_EMAIL}" 13 | 14 | ls -la 15 | git add . 16 | git commit -m "Deploy to gh-pages" 17 | git push -fq "https://${GH_TOKEN}@github.com/${GH_REPO}.git" master:gh-pages > /dev/null 2>&1 18 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-scrollspy", 3 | "version": "3.4.3", 4 | "description": "react scrollspy component", 5 | "main": "./lib/scrollspy.js", 6 | "files": [ 7 | "README.md", 8 | "lib/scrollspy.js", 9 | "lib/throttle.js", 10 | "package.json", 11 | "webpack.config.js", 12 | ".eslintrc" 13 | ], 14 | "scripts": { 15 | "serve": "NODE_ENV=development webpack-dev-server --inline --hot --color", 16 | "docs": "NODE_ENV=production webpack", 17 | "build": "NODE_ENV=production ./node_modules/.bin/babel ./src/js/lib --out-dir ./lib", 18 | "test": "NODE_ENV=test ./node_modules/.bin/ava __test__/*.js --verbose", 19 | "prepublish": "yarn run build", 20 | "deploy": "yarn run docs && gh-pages -d ./dist", 21 | "release": "standard-version" 22 | }, 23 | "ava": { 24 | "require": [ 25 | "@babel/register" 26 | ] 27 | }, 28 | "standard-version": { 29 | "skip": { 30 | "tag": true, 31 | "bump": true 32 | } 33 | }, 34 | "repository": { 35 | "type": "git", 36 | "url": "https://github.com/makotot/react-scrollspy.git" 37 | }, 38 | "author": "makotot", 39 | "license": "MIT", 40 | "devDependencies": { 41 | "@babel/cli": "^7.2.3", 42 | "@babel/core": "^7.3.4", 43 | "@babel/plugin-transform-object-assign": "^7.2.0", 44 | "@babel/plugin-transform-runtime": "^7.3.4", 45 | "@babel/preset-env": "^7.3.4", 46 | "@babel/preset-react": "^7.0.0", 47 | "@babel/register": "^7.0.0", 48 | "@babel/runtime": "^7.3.4", 49 | "autoprefixer": "^9.4.10", 50 | "ava": "^1.3.1", 51 | "babel-eslint": "^8.0.0", 52 | "babel-loader": "^8.0.5", 53 | "css-loader": "^2.1.1", 54 | "cssnano": "^4.1.10", 55 | "enzyme": "^2.9.1", 56 | "eslint": "^4.6.1", 57 | "eslint-loader": "^2.1.2", 58 | "eslint-plugin-react": "^7.12.4", 59 | "gh-pages": "^2.0.1", 60 | "html-webpack-plugin": "^3.2.0", 61 | "node-sass": "^4.13.0", 62 | "normalize.css": "^8.0.1", 63 | "postcss-flexbugs-fixes": "^4.1.0", 64 | "postcss-loader": "^3.0.0", 65 | "react-addons-perf": "^15.4.2", 66 | "react-highlight": "^0.10.0", 67 | "react-test-renderer": "^15.6.1", 68 | "sass-loader": "^7.1.0", 69 | "standard-version": "^5.0.1", 70 | "style-loader": "^0.23.1", 71 | "webpack": "^4.29.6", 72 | "webpack-cli": "^3.2.3", 73 | "webpack-dev-server": "^3.2.1" 74 | }, 75 | "peerDependencies": { 76 | "react": ">=0.14.0", 77 | "react-dom": ">=0.14.0" 78 | }, 79 | "dependencies": { 80 | "babel-runtime": "^6.26.0", 81 | "classnames": "^2.2.5", 82 | "prop-types": "^15.5.10" 83 | }, 84 | "keywords": [ 85 | "react", 86 | "react-component", 87 | "component", 88 | "scrollspy", 89 | "scroll" 90 | ] 91 | } 92 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = {} 2 | -------------------------------------------------------------------------------- /src/js/app.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | import Highlight from 'react-highlight' 4 | import Nav from './nav' 5 | import Footer from './footer' 6 | import PropTable from './props-table' 7 | 8 | import '../scss/app.scss' 9 | 10 | const version = require('../../package.json').version 11 | 12 | class App extends React.Component { 13 | render () { 14 | return ( 15 |
    16 |
    87 | ) 88 | } 89 | } 90 | 91 | ReactDOM.render(, document.getElementById('js-app')) 92 | -------------------------------------------------------------------------------- /src/js/footer.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | const Footer = () => ( 4 | 7 | ) 8 | 9 | export default Footer -------------------------------------------------------------------------------- /src/js/lib/scrollspy.js: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types' 2 | import React from 'react' 3 | import classNames from 'classnames' 4 | import throttle from './throttle' 5 | 6 | function isEqualArray(a, b) { 7 | return ( 8 | a.length === b.length 9 | && 10 | a.every((item, index) => { 11 | return item === b[index] 12 | }) 13 | ) 14 | } 15 | 16 | export default class Scrollspy extends React.Component { 17 | 18 | static get propTypes () { 19 | return { 20 | items: PropTypes.arrayOf(PropTypes.string).isRequired, 21 | currentClassName: PropTypes.string.isRequired, 22 | scrolledPastClassName: PropTypes.string, 23 | style: PropTypes.object, 24 | componentTag: PropTypes.oneOfType([PropTypes.string, PropTypes.elementType]), 25 | offset: PropTypes.number, 26 | rootEl: PropTypes.string, 27 | onUpdate: PropTypes.func, 28 | } 29 | } 30 | 31 | static get defaultProps () { 32 | return { 33 | items: [], 34 | currentClassName: '', 35 | style: {}, 36 | componentTag: 'ul', 37 | offset: 0, 38 | onUpdate() {}, 39 | } 40 | } 41 | 42 | constructor (props) { 43 | super(props) 44 | 45 | this.state = { 46 | targetItems: [], 47 | inViewState: [], 48 | isScrolledPast: [] 49 | } 50 | 51 | // manually bind as ES6 does not apply this 52 | // auto binding as React.createClass does 53 | this._handleSpy = this._handleSpy.bind(this) 54 | } 55 | 56 | _initSpyTarget (items) { 57 | const targetItems = items.map((item) => { 58 | 59 | return document.getElementById(item) 60 | }) 61 | 62 | return targetItems 63 | } 64 | 65 | // https://github.com/makotot/react-scrollspy/pull/45 66 | _fillArray (array, val) { 67 | let newArray = [] 68 | 69 | for (let i = 0, max = array.length; i < max; i++) { 70 | newArray[i] = val 71 | } 72 | 73 | return newArray 74 | } 75 | 76 | _isScrolled () { 77 | return this._getScrollDimension().scrollTop > 0 78 | } 79 | 80 | _getScrollDimension () { 81 | const doc = document 82 | const { rootEl } = this.props 83 | const scrollTop = rootEl ? doc.querySelector(rootEl).scrollTop : (doc.documentElement.scrollTop || doc.body.parentNode.scrollTop || doc.body.scrollTop) 84 | const scrollHeight = rootEl ? doc.querySelector(rootEl).scrollHeight : (doc.documentElement.scrollHeight || doc.body.parentNode.scrollHeight || doc.body.scrollHeight) 85 | 86 | return { 87 | scrollTop, 88 | scrollHeight, 89 | } 90 | } 91 | 92 | _getElemsViewState (targets) { 93 | let elemsInView = [] 94 | let elemsOutView = [] 95 | let viewStatusList = [] 96 | 97 | const targetItems = targets ? targets : this.state.targetItems 98 | 99 | let hasInViewAlready = false 100 | 101 | for (let i = 0, max = targetItems.length; i < max; i++) { 102 | let currentContent = targetItems[i] 103 | let isInView = hasInViewAlready ? false : this._isInView(currentContent) 104 | 105 | if (isInView) { 106 | hasInViewAlready = true 107 | elemsInView.push(currentContent) 108 | } else { 109 | elemsOutView.push(currentContent) 110 | } 111 | 112 | const isLastItem = i === max - 1 113 | const isScrolled = this._isScrolled() 114 | 115 | // https://github.com/makotot/react-scrollspy/pull/26#issue-167413769 116 | const isLastShortItemAtBottom = this._isAtBottom() && this._isInView(currentContent) && !isInView && isLastItem && isScrolled 117 | 118 | if (isLastShortItemAtBottom) { 119 | elemsOutView.pop() 120 | elemsOutView.push(...elemsInView) 121 | elemsInView = [currentContent] 122 | viewStatusList = this._fillArray(viewStatusList, false) 123 | isInView = true 124 | } 125 | 126 | viewStatusList.push(isInView) 127 | } 128 | 129 | return { 130 | inView: elemsInView, 131 | outView: elemsOutView, 132 | viewStatusList, 133 | scrolledPast: this.props.scrolledPastClassName && this._getScrolledPast(viewStatusList), 134 | } 135 | } 136 | 137 | _isInView (el) { 138 | if (!el) { 139 | return false 140 | } 141 | 142 | const { rootEl, offset } = this.props 143 | let rootRect 144 | 145 | if (rootEl) { 146 | rootRect = document.querySelector(rootEl).getBoundingClientRect() 147 | } 148 | 149 | const rect = el.getBoundingClientRect() 150 | const winH = rootEl ? rootRect.height : window.innerHeight 151 | const { scrollTop } = this._getScrollDimension() 152 | const scrollBottom = scrollTop + winH 153 | const elTop = rootEl ? 154 | rect.top + scrollTop - rootRect.top + offset 155 | : 156 | rect.top + scrollTop + offset 157 | const elBottom = elTop + el.offsetHeight 158 | 159 | return (elTop < scrollBottom) && (elBottom > scrollTop) 160 | } 161 | 162 | _isAtBottom () { 163 | const { rootEl } = this.props 164 | const { scrollTop, scrollHeight } = this._getScrollDimension() 165 | const winH = rootEl ? document.querySelector(rootEl).getBoundingClientRect().height : window.innerHeight 166 | const scrolledToBottom = (scrollTop + winH) >= scrollHeight 167 | 168 | return scrolledToBottom 169 | } 170 | 171 | _getScrolledPast (viewStatusList) { 172 | if (!viewStatusList.some((item) => item)) { 173 | return viewStatusList 174 | } 175 | 176 | let hasFoundInView = false 177 | 178 | const scrolledPastItems = viewStatusList.map((item) => { 179 | if (item && !hasFoundInView) { 180 | hasFoundInView = true 181 | 182 | return false 183 | } 184 | 185 | return !hasFoundInView 186 | }) 187 | 188 | return scrolledPastItems 189 | } 190 | 191 | _spy (targets) { 192 | const elemensViewState = this._getElemsViewState(targets) 193 | const currentStatuses = this.state.inViewState 194 | 195 | this.setState({ 196 | inViewState: elemensViewState.viewStatusList, 197 | isScrolledPast: elemensViewState.scrolledPast 198 | }, () => { 199 | this._update(currentStatuses) 200 | }) 201 | } 202 | 203 | _update (prevStatuses) { 204 | if (isEqualArray(this.state.inViewState, prevStatuses)) { 205 | return 206 | } 207 | 208 | this.props.onUpdate(this.state.targetItems[this.state.inViewState.indexOf(true)]) 209 | } 210 | 211 | _handleSpy () { 212 | throttle(this._spy(), 100) 213 | } 214 | 215 | _initFromProps () { 216 | const targetItems = this._initSpyTarget(this.props.items) 217 | 218 | this.setState({ 219 | targetItems, 220 | }) 221 | 222 | this._spy(targetItems) 223 | } 224 | 225 | offEvent() { 226 | const el = this.props.rootEl ? document.querySelector(this.props.rootEl) : window 227 | 228 | el.removeEventListener('scroll', this._handleSpy) 229 | } 230 | 231 | onEvent() { 232 | const el = this.props.rootEl ? document.querySelector(this.props.rootEl) : window 233 | 234 | el.addEventListener('scroll', this._handleSpy) 235 | } 236 | 237 | componentDidMount () { 238 | this._initFromProps() 239 | this.onEvent() 240 | } 241 | 242 | componentWillUnmount () { 243 | this.offEvent() 244 | } 245 | 246 | UNSAFE_componentWillReceiveProps () { 247 | this._initFromProps() 248 | } 249 | 250 | render () { 251 | const Tag = this.props.componentTag 252 | const { 253 | children, 254 | className, 255 | scrolledPastClassName, 256 | style, 257 | } = this.props 258 | let counter = 0 259 | const items = React.Children.map(children, (child, idx) => { 260 | if (!child) { 261 | return null 262 | } 263 | 264 | const ChildTag = child.type 265 | const isScrolledPast = scrolledPastClassName && this.state.isScrolledPast[idx] 266 | const childClass = classNames({ 267 | [`${ child.props.className }`]: child.props.className, 268 | [`${ this.props.currentClassName }`]: this.state.inViewState[idx], 269 | [`${ this.props.scrolledPastClassName }`]: isScrolledPast, 270 | }) 271 | 272 | return ( 273 | 274 | { child.props.children } 275 | 276 | ) 277 | }) 278 | 279 | const itemClass = classNames({ 280 | [`${ className }`]: className, 281 | }) 282 | 283 | return ( 284 | 285 | { items } 286 | 287 | ) 288 | } 289 | } 290 | -------------------------------------------------------------------------------- /src/js/lib/throttle.js: -------------------------------------------------------------------------------- 1 | const throttle = (fn, threshold = 100) => { 2 | let last 3 | let timer 4 | 5 | return () => { 6 | const now = +new Date() 7 | const timePassed = !!last && (now < last + threshold) 8 | 9 | if (timePassed) { 10 | clearTimeout(timer) 11 | 12 | timer = setTimeout(() => { 13 | last = now 14 | fn() 15 | }, threshold) 16 | } else { 17 | last = now 18 | fn() 19 | } 20 | } 21 | } 22 | 23 | export default throttle 24 | -------------------------------------------------------------------------------- /src/js/nav.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Scrollspy from './lib/scrollspy' 3 | 4 | const Nav = () => ( 5 | 28 | ) 29 | 30 | export default Nav -------------------------------------------------------------------------------- /src/js/props-table.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | const PropTable = () => ( 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 |
    PropsSummary
    itemsId list of target contents.
    currentClassNameClass name that apply to the navigation element paired with the content element in viewport.
    scrolledPastClassNameClass name that apply to the navigation elements that have been scrolled past [optional].
    componentTagHTML tag or React Component type for Scrollspy component if you want to use something other than {'
      '} [optional].
    styleStyle attribute to be passed to the generated {'
      '} element [optional].
    offsetOffset value that adjusts to determine the elements are in the viewport [optional].
    rootElName of the element of scrollable container that can be used with {'querySelector'} [optional].
    onUpdateFunction to be executed when the active item has been updated [optional].
    46 | ) 47 | 48 | export default PropTable 49 | -------------------------------------------------------------------------------- /src/scss/app.scss: -------------------------------------------------------------------------------- 1 | @charset "utf-8"; 2 | 3 | @import "../../node_modules/normalize.css/normalize.css"; 4 | 5 | html { 6 | font-size: 16px; 7 | -webkit-font-smoothing: antialiased; 8 | } 9 | 10 | body { 11 | font-family: --apple-system, BlinkMacSystemFont, Heivetica, Arial, sans-serif; 12 | font-weight: 300; 13 | } 14 | 15 | h1, h2, h3, h4, h5, h6 { 16 | margin: 0; 17 | } 18 | 19 | ul, ol { 20 | margin: 0; 21 | padding: 0; 22 | list-style: none; 23 | } 24 | 25 | .wrapper { 26 | background: #222; 27 | color: #fff; 28 | } 29 | 30 | .main { 31 | } 32 | 33 | .title { 34 | letter-spacing: 0.2rem; 35 | text-transform: uppercase; 36 | font-size: 2rem; 37 | font-style: italic; 38 | font-weight: 300; 39 | } 40 | 41 | .title + .description { 42 | margin-top: 1rem; 43 | } 44 | 45 | .description { 46 | display: flex; 47 | flex-direction: column; 48 | } 49 | 50 | .nav { 51 | position: sticky; 52 | display: flex; 53 | flex-direction: column; 54 | top: 0; 55 | background: #fff; 56 | overflow: auto; 57 | white-space: nowrap; 58 | 59 | @media (min-width: 480px) { 60 | flex-direction: row; 61 | } 62 | 63 | &__title { 64 | letter-spacing: 0.2rem; 65 | text-transform: uppercase; 66 | font-style: italic; 67 | font-weight: 300; 68 | } 69 | 70 | &__inner { 71 | display: flex; 72 | margin: 0; 73 | padding: 0; 74 | } 75 | 76 | &__item { 77 | list-style-type: none; 78 | border-bottom: 2px solid #fff; 79 | } 80 | 81 | &__item--inverse { 82 | background: linear-gradient(45deg, #5500f0, #3f5bff); 83 | border-bottom: 2px solid #5500f0; 84 | } 85 | 86 | &__item--active { 87 | font-weight: 700; 88 | border-bottom: 2px solid #5500f0; 89 | } 90 | 91 | &__link { 92 | display: flex; 93 | padding: 1rem; 94 | color: #5500f0; 95 | text-decoration: none; 96 | transition: background 0.3s ease-in; 97 | 98 | &:hover { 99 | background: #5500f0; 100 | color: #fff; 101 | } 102 | } 103 | 104 | &__item--inverse &__link { 105 | color: #fff; 106 | } 107 | } 108 | 109 | .section { 110 | min-height: 90vh; 111 | padding: 1rem; 112 | } 113 | 114 | .section:nth-child(even) { 115 | background: #333; 116 | } 117 | 118 | .section-title { 119 | padding: 2rem 0; 120 | display: flex; 121 | justify-content: center; 122 | letter-spacing: 0.2rem; 123 | text-transform: uppercase; 124 | font-weight: 300; 125 | } 126 | 127 | .section-title__inner { 128 | display: inline; 129 | border-bottom: 1px solid #fff; 130 | font-size: 1.4rem; 131 | line-height: 2; 132 | text-transform: uppercase; 133 | } 134 | 135 | .section-title + .section-body { 136 | margin-top: 2rem; 137 | } 138 | 139 | .section-body { 140 | max-width: 720px; 141 | margin: 0 auto; 142 | } 143 | 144 | .hero { 145 | display: flex; 146 | flex-direction: column; 147 | align-items: center; 148 | justify-content: center; 149 | height: 100vh; 150 | text-align: center; 151 | line-height: 2; 152 | } 153 | 154 | .link { 155 | color: #fff; 156 | 157 | &:hover { 158 | background: #5500f0; 159 | } 160 | } 161 | 162 | .footer { 163 | padding: 2rem; 164 | text-align: center; 165 | } 166 | 167 | .code { 168 | padding: 1rem; 169 | margin: 0; 170 | border-left: 0.4rem solid #5500f0; 171 | background-color: #f7f7f7; 172 | color: #5d5d5d; 173 | white-space: pre; 174 | overflow-x: auto; 175 | } 176 | 177 | .github-link { 178 | margin-top: 1rem; 179 | padding: 1rem; 180 | line-height: 1; 181 | border: 1px solid #fff; 182 | color: #fff; 183 | text-decoration: none; 184 | transition: background 0.3s ease-in; 185 | 186 | &:hover { 187 | background: #5500f0; 188 | border: 1px solid #5500f0; 189 | } 190 | } 191 | 192 | .table { 193 | table-layout: fixed; 194 | width: 100%; 195 | border-collapse: collapse; 196 | 197 | &__head { 198 | text-align: left; 199 | font-weight: 400; 200 | padding: 0.6rem; 201 | border-bottom: 0.4rem solid #5500f0; 202 | } 203 | 204 | &__data { 205 | padding: 0.6rem; 206 | font-weight: 300; 207 | } 208 | 209 | tr:nth-child(odd) > &__data { 210 | background-color: #222; 211 | } 212 | 213 | tr:nth-child(even) > &__data { 214 | background-color: #333; 215 | } 216 | } -------------------------------------------------------------------------------- /src/templates/index.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | React Scrollspy 8 | 9 | 16 | 17 | 18 |
    19 | 20 | 21 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack') 2 | const path = require('path') 3 | const HtmlWebpackPlguin = require('html-webpack-plugin') 4 | 5 | module.exports = { 6 | mode: 'production', 7 | entry: { 8 | 'app.min': [ 9 | './src/js/app.js', 10 | ], 11 | }, 12 | resolve: { 13 | extensions: ['*', '.js'], 14 | modules: [ 15 | 'node_modules', 16 | path.join(__dirname, 'src/js'), 17 | ], 18 | }, 19 | output: { 20 | path: path.join(__dirname, 'dist'), 21 | filename: 'js/[name].js', 22 | publicPath: './', 23 | }, 24 | module: { 25 | rules: [ 26 | { 27 | test: /\.scss$/, 28 | exclude: /(node_modules)/, 29 | include: [ 30 | path.resolve(__dirname, 'src/scss'), 31 | ], 32 | use: [ 33 | { 34 | loader: 'style-loader', 35 | }, 36 | { 37 | loader: 'css-loader', 38 | }, 39 | { 40 | loader: 'postcss-loader', 41 | options: { 42 | plugins: (loader) => [ 43 | require('postcss-flexbugs-fixes')(), 44 | require('autoprefixer')(), 45 | require('cssnano')(), 46 | ], 47 | }, 48 | }, 49 | { 50 | loader: 'sass-loader', 51 | }, 52 | ], 53 | }, 54 | { 55 | test: /\.(js|jsx)$/, 56 | exclude: /(node_modules)/, 57 | include: [ 58 | path.resolve(__dirname, 'src/js'), 59 | ], 60 | use: [ 61 | { 62 | loader: 'babel-loader', 63 | options: { 64 | cacheDirectory: false, 65 | }, 66 | }, 67 | ], 68 | }, 69 | { 70 | test: /\.(js|jsx)$/, 71 | exclude: /(node_modules)/, 72 | include: [ 73 | path.resolve(__dirname, 'src/js'), 74 | ], 75 | use: [ 76 | { 77 | loader: 'eslint-loader', 78 | options: { 79 | }, 80 | }, 81 | ], 82 | }, 83 | ], 84 | }, 85 | devServer: { 86 | contentBase: './dist', 87 | publicPath: '/', 88 | port: 8080, 89 | }, 90 | plugins: [ 91 | new HtmlWebpackPlguin({ 92 | filename: 'index.html', 93 | template: './src/templates/index.ejs', 94 | }), 95 | ], 96 | } 97 | --------------------------------------------------------------------------------