Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow containers other than window to scroll #41

Open
wants to merge 10 commits into
base: master
Choose a base branch
from
2 changes: 2 additions & 0 deletions example/src/components/App.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@ import Example1 from './Example1'
import Example2 from './Example2'
import Example3 from './Example3'
import Example4 from './Example4'
import Example5 from './Example5'
import ScrollableAnchor, { goToTop, goToAnchor, removeHash } from '../../../src'

const examples = [
{id: 'example1', label: 'Example 1', component: Example1},
{id: 'example2', label: 'Example 2', component: Example2},
{id: 'example3', label: 'Example 3', component: Example3},
{id: 'example4', label: 'Example 4', component: Example4},
{id: 'example5', label: 'Example 5', component: Example5},
]

const styles = {
Expand Down
58 changes: 58 additions & 0 deletions example/src/components/Example5.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import React, { Component } from 'react'
import ScrollableAnchor, { configureAnchors } from '../../../src'
import Section from './Section'

const sections = [
{id: 'section1', label: 'Section 1', backgroundColor: 'red'},
{id: 'section2', label: 'Section 2', backgroundColor: 'darkgray'},
{id: 'section3', label: 'Section 3', backgroundColor: 'green'},
{id: 'section4', label: 'Section 4', backgroundColor: 'brown'},
{id: 'section5', label: 'Section 5', backgroundColor: 'lightpink'}
]

const styles = {
offsetUp: {
marginTop: '-549px'
},
extraTall: {
height: '700px'
},
scrollingDiv: {
height: '50vh',
overflowY: 'scroll',
marginTop: '25vh',
width: '50%',
marginLeft: '25%',
position: 'relative'
}

}

export default class Example5 extends Component {

componentWillMount() {
configureAnchors({containerId: 'scrolling-div'})
}

renderSection = (section) => {
const props = {...section, sections, style: styles.extraTall}
return (
<div key={section.id}>
<ScrollableAnchor id={section.id}>
<Section {...props}/>
</ScrollableAnchor>
</div>
)
}

render() {
return (
<div>
{ this.props.renderHeader(true, sections, true) }
<div id='scrolling-div' style={styles.scrollingDiv}>
{ sections.map(this.renderSection) }
</div>
</div>
)
}
}
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,8 @@
},
"homepage": "https://github.com/gabergg/react-scrollable-anchor",
"dependencies": {
"jump.js": "1.0.2",
"prop-types": "^15.5.10"
"prop-types": "^15.5.10",
"zenscroll": "^4.0.2"
},
"peerDependencies": {
"react": "^15.3.0 || ^16.0.0",
Expand Down
45 changes: 28 additions & 17 deletions src/Manager.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import jump from 'jump.js'
import zenscroll from 'zenscroll'
import { debounce } from './utils/func'
import { getBestAnchorGivenScrollLocation, getScrollTop } from './utils/scroll'
import { getHash, updateHash, removeHash } from './utils/hash'

const defaultConfig = {
offset: 0,
scrollDuration: 400,
keepLastAnchorHash: false,
keepLastAnchorHash: false
}


class Manager {
constructor() {
this.anchors = {}
Expand All @@ -19,30 +20,44 @@ class Manager {
this.forceHashUpdate = debounce(this.handleHashChange, 1)
}

setContainer = () => {
// if we have a containerId, find the scrolling container, else set it to window
if (this.config.containerId) {
this.config.container = document.getElementById(this.config.containerId)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about this.config.containerId could be id's string or container's ref ?
As it is possible that parent component which is using ScrollableAnchor may maintain a container's ref~

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah that's a valid request I guess. I don't think this library is really maintained any more so it's unlikely this will ever get merged

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've added a version of this library to my library collection that has some added improvements already (Sadly no documentation yet, i'm working on it though)

https://github.com/leon-marzahn/marzahn-dev/tree/main/libs/react/scroll-anchor

this.config.scroller = zenscroll.createScroller(this.config.container, this.config.scrollDuration, this.config.offset)
} else {
this.config.container = window
this.config.scroller = zenscroll
}
}

addListeners = () => {
window.addEventListener('scroll', this.scrollHandler, false)
this.config.container.addEventListener('scroll', this.scrollHandler, false)
window.addEventListener('hashchange', this.handleHashChange)
}

removeListeners = () => {
window.removeEventListener('scroll', this.scrollHandler, false)
this.config.container.removeEventListener('scroll', this.scrollHandler, false)
window.removeEventListener('hashchange', this.handleHashChange)
}

configure = (config) => {
this.config = {
...defaultConfig,
...config,
...config
}
}

goToTop = () => {
if (getScrollTop() === 0) return
this.forcedHash = true
window.scroll(0,0)
if (getScrollTop(this.config.container) === 0) return
this.config.scroller.toY(0, this.config.scrollDuration)
}

addAnchor = (id, component) => {
// if container is not set, set container
if (!this.config.container) {
this.setContainer()
}
// if this is the first anchor, set up listeners
if (Object.keys(this.anchors).length === 0) {
this.addListeners()
Expand All @@ -61,7 +76,7 @@ class Manager {

handleScroll = () => {
const {offset, keepLastAnchorHash} = this.config
const bestAnchorId = getBestAnchorGivenScrollLocation(this.anchors, offset)
const bestAnchorId = getBestAnchorGivenScrollLocation(this.anchors, offset, this.config.container)

if (bestAnchorId && getHash() !== bestAnchorId) {
this.forcedHash = true
Expand All @@ -81,20 +96,16 @@ class Manager {

goToSection = (id) => {
let element = this.anchors[id]
let viewHeight = this.config.container.innerHeight || this.config.container.clientHeight
let offset = this.config.offset + viewHeight/2
if (element) {
jump(element, {
duration: this.config.scrollDuration,
offset: this.config.offset,
})
this.config.scroller.center(element, this.config.scrollDuration, offset)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm confused about why use scroller.center + this.config.offset + viewHeight/2 here?

If element's height is larger than container, it will not show the top of the element, but maybe we want to see the element from the beginning of it.

Maybe there is another solution? @dbramwell

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did this 10 months ago, so the details are a bit fuzzy. If you look at the demo, it contains elements that are larger than the container and it does work as expected. I think I did it this way because of the way zenscroll works. From the docs:

"If you want you can also define an offset. The top of the element will be upwards from the center of the screen by this amount of pixels."

So, with an offset of zero, the top of the element would end up in the centre of the container. We want the top of the element to be at the top of the container so we need an offset of half the container's height (plus any offset requested in the config).

Does that answer the question?

} else {
// make sure that standard hash anchors don't break.
// simply jump to them.
element = document.getElementById(id)
if (element) {
jump(element, {
duration: 0,
offset: this.config.offset,
})
this.config.scroller.center(element, 0, this.config.offset)
}
}
}
Expand Down
32 changes: 17 additions & 15 deletions src/utils/scroll.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
export const getScrollTop = () => {
return document.body.scrollTop || document.documentElement.scrollTop
export const getScrollTop = (container) => {
return container.scrollTop || document.body.scrollTop || document.documentElement.scrollTop
}

// get vertical offsets of element, taking scrollTop into consideration
export const getElementOffset = (element) => {
const scrollTop = getScrollTop()
export const getElementOffset = (element, container) => {
const scrollTop = getScrollTop(container)
const {top, bottom} = element.getBoundingClientRect()
return {
top: Math.floor(top + scrollTop),
Expand All @@ -13,17 +13,19 @@ export const getElementOffset = (element) => {
}

// does scrollTop live within element bounds?
export const doesElementContainScrollTop = (element, extraOffset = 0) => {
const scrollTop = getScrollTop()
const offsetTop = getElementOffset(element).top + extraOffset
export const doesElementContainScrollTop = (element, container, extraOffset = 0) => {
let scrollTop = getScrollTop(container)
const offsetTop = getElementOffset(element, container).top + extraOffset
// if scrolling within a container we need to add the position of the container to scrollTop
scrollTop += container.getBoundingClientRect ? container.getBoundingClientRect().top : 0
return scrollTop >= offsetTop && scrollTop < offsetTop + element.offsetHeight
}

// is el2's location more relevant than el2,
// parent-child relationship aside?
export const checkLocationRelevance = (el1, el2) => {
const {top: top1, bottom: bottom1} = getElementOffset(el1)
const {top: top2, bottom: bottom2} = getElementOffset(el2)
export const checkLocationRelevance = (el1, el2, container) => {
const {top: top1, bottom: bottom1} = getElementOffset(el1, container)
const {top: top2, bottom: bottom2} = getElementOffset(el2, container)
if (top1 === top2) {
if (bottom1 === bottom2) {
// top and bottom of compared elements are the same,
Expand All @@ -41,11 +43,11 @@ export const checkLocationRelevance = (el1, el2) => {

// check if el2 is more relevant than el1, considering child-parent
// relationships as well as node location.
export const checkElementRelevance = (el1, el2) => {
export const checkElementRelevance = (el1, el2, container) => {
if (el1.contains(el2)) {
// el2 is child, so it gains relevance priority
return true
} else if (!el2.contains(el1) && checkLocationRelevance(el1, el2)) {
} else if (!el2.contains(el1) && checkLocationRelevance(el1, el2, container)) {
// el1 and el2 are unrelated, but el2 has a better location,
// so it gains relevance priority
return true
Expand All @@ -62,13 +64,13 @@ export const checkElementRelevance = (el1, el2) => {
// 4. if neither node contains the other, and their top and bottom locations
// are the same, a node is chosen at random, in a deterministic way,
// to be more relevant.
export const getBestAnchorGivenScrollLocation = (anchors, offset) => {
export const getBestAnchorGivenScrollLocation = (anchors, offset, container) => {
let bestId, bestElement

Object.keys(anchors).forEach((id) => {
const element = anchors[id]
if (doesElementContainScrollTop(element, offset)) {
if (!bestElement || checkElementRelevance(bestElement, element)) {
if (doesElementContainScrollTop(element, container, offset)) {
if (!bestElement || checkElementRelevance(bestElement, element, container)) {
bestElement = element
bestId = id
}
Expand Down