###**
Scrolling viewports
===================
The `up.viewport` module controls the scroll position of scrollable containers ("viewports").
The default viewport for any web application is the main document. An application may
define additional viewports by giving the CSS property `{ overflow-y: scroll }` to any `
`.
\#\#\# Revealing new content
When following a [link to a fragment](/a-up-target) Unpoly will automatically
scroll the document's viewport to [reveal](/up.viewport) the updated content.
You should [make Unpoly aware](/up.viewport.config#config.fixedTop) of fixed elements in your
layout, such as navigation bars or headers. Unpoly will respect these sticky
elements when [revealing updated fragments](/up.reveal).
You should also [tell Unpoly](/up.viewport.config#config.viewports) when your application has more than one viewport,
so Unpoly can pick the right viewport to scroll for each fragment update.
\#\#\# Bootstrap integration
When using Bootstrap integration (`unpoly-bootstrap3.js` and `unpoly-bootstrap3.css`)
Unpoly will automatically be aware of sticky Bootstrap components such as
[fixed navbar](https://getbootstrap.com/examples/navbar-fixed-top/).
@module up.viewport
###
up.viewport = do ->
u = up.util
e = up.element
###**
Configures the application layout.
@property up.viewport.config
@param {Array} [config.viewports]
An array of CSS selectors that find viewports
(containers that scroll their contents).
@param {Array} [config.fixedTop]
An array of CSS selectors that find elements fixed to the
top edge of the screen (using `position: fixed`).
See [`[up-fixed="top"]`](/up-fixed-top) for details.
@param {Array} [config.fixedBottom]
An array of CSS selectors that find elements fixed to the
bottom edge of the screen (using `position: fixed`).
See [`[up-fixed="bottom"]`](/up-fixed-bottom) for details.
@param {Array} [config.anchoredRight]
An array of CSS selectors that find elements anchored to the
right edge of the screen (using `right:0` with `position: fixed` or `position: absolute`).
See [`[up-anchored="right"]`](/up-anchored-right) for details.
@param {number} [config.revealSnap=50]
When [revealing](/up.reveal) elements, Unpoly will scroll an viewport
to the top when the revealed element is closer to the top than `config.revealSnap`.
@param {number} [config.revealPadding=0]
The desired padding between a [revealed](/up.reveal) element and the
closest [viewport](/up.viewport) edge (in pixels).
@param {number} [config.scrollSpeed=1]
The speed of the scrolling motion when [scrolling](/up.scroll) with `{ behavior: 'smooth' }`.
The default value (`1`) roughly corresponds to the speed of Chrome's
[native smooth scrolling](https://developer.mozilla.org/en-US/docs/Web/API/ScrollToOptions/behavior).
@stable
###
config = new up.Config
duration: 0
viewports: ['.up-modal-viewport', '[up-viewport]', '[up-fixed]']
fixedTop: ['[up-fixed~=top]']
fixedBottom: ['[up-fixed~=bottom]']
anchoredRight: ['[up-anchored~=right]', '[up-fixed~=top]', '[up-fixed~=bottom]', '[up-fixed~=right]']
revealSnap: 50
revealPadding: 0,
scrollSpeed: 1
# up.legacy.renamedProperty(config, 'snap', 'revealSnap')
# up.legacy.removedProperty(config, 'easing')
# up.legacy.removedProperty(config, 'duration')
lastScrollTops = new up.Cache
size: 30,
key: up.history.normalizeUrl
scrollingController = new up.MotionController('scrolling')
reset = ->
config.reset()
lastScrollTops.clear()
scrollingController.reset()
###**
Scrolls the given viewport to the given Y-position.
A "viewport" is an element that has scrollbars, e.g. `` or
a container with `overflow-x: scroll`.
\#\#\# Example
This will scroll a `
...
` to a Y-position of 100 pixels:
up.scroll('.main', 100)
\#\#\# Animating the scrolling motion
The scrolling can (optionally) be animated.
up.scroll('.main', 100, { behavior: 'smooth' })
If the given viewport is already in a scroll animation when `up.scroll()`
is called a second time, the previous animation will instantly jump to the
last frame before the next animation is started.
@function up.scroll
@param {string|Element|jQuery} viewport
The container element to scroll.
@param {number} scrollPos
The absolute number of pixels to set the scroll position to.
@param {string}[options.behavior='auto']
When set to `'auto'`, this will immediately scroll to the new position.
When set to `'smooth'`, this will scroll smoothly to the new position.
@param {number}[options.speed]
The speed of the scrolling motion when scrolling with `{ behavior: 'smooth' }`.
Defaults to `up.viewport.config.scrollSpeed`.
@return {Promise}
A promise that will be fulfilled when the scrolling ends.
@experimental
###
scroll = (viewport, scrollTop, options) ->
motion = new up.ScrollMotion(viewport, scrollTop, options)
scrollingController.startMotion(viewport, motion, options)
###**
Finishes scrolling animations in the given element, its ancestors or its descendants.
@function up.viewport.finishScrolling
@param {string|Element|jQuery}
@return {Promise}
@internal
###
finishScrolling = (element) ->
# Don't emit expensive events if no animation can be running anyway
return Promise.resolve() unless up.motion.isEnabled()
scrollable = closest(element)
scrollingController.finish(scrollable)
###**
@function up.viewport.anchoredRight
@internal
###
anchoredRight = ->
selector = config.anchoredRight.join(',')
e.all(selector)
###**
@function measureObstruction
@return {Object}
@internal
###
measureObstruction = (viewportHeight) ->
composeHeight = (obstructor, distanceFromEdgeProps) ->
distanceFromEdge = u.sum(distanceFromEdgeProps, (prop) -> e.styleNumber(obstructor, prop)) || 0
distanceFromEdge + obstructor.offsetHeight
measureTopObstructor = (obstructor) ->
composeHeight(obstructor, ['top', 'margin-top'])
measureBottomObstructor = (obstructor) ->
composeHeight(obstructor, ['bottom', 'margin-bottom'])
topObstructors = e.all(config.fixedTop.join(', '))
bottomObstructors = e.all(config.fixedBottom.join(', '))
topObstructions = u.map(topObstructors, measureTopObstructor)
bottomObstructions = u.map(bottomObstructors, measureBottomObstructor)
top: Math.max(0, topObstructions...)
bottom: Math.max(0, bottomObstructions...)
###**
Scroll's the given element's viewport so the first rows of the
element are visible for the user.
By default Unpoly will always reveal an element before
updating it with JavaScript functions like [`up.replace()`](/up.replace)
or UJS behavior like [`[up-target]`](/a-up-target).
\#\#\# How Unpoly finds the viewport
The viewport (the container that is going to be scrolled)
is the closest parent of the element that is either:
- the currently open [modal](/up.modal)
- an element with the attribute `[up-viewport]`
- the `` element
- an element matching the selector you have configured using `up.viewport.config.viewports.push('my-custom-selector')`
\#\#\# Fixed elements obstruction the viewport
Many applications have a navigation bar fixed to the top or bottom,
obstructing the view on an element.
You can make `up.reveal()` aware of these fixed elements
so it can scroll the viewport far enough so the revealed element is fully visible.
To make `up.reveal()` aware fixed elements you can either:
- give the element an attribute [`up-fixed="top"`](/up-fixed-top) or [`up-fixed="bottom"`](up-fixed-bottom)
- [configure default options](/up.viewport.config) for `fixedTop` or `fixedBottom`
@function up.reveal
@param {string|Element|jQuery} element
@param {number} [options.speed]
@param {string} [options.snap]
@param {string|Element|jQuery} [options.viewport]
@param {boolean} [options.top=false]
Whether to scroll the viewport so that the first element row aligns
with the top edge of the viewport.
@param {string}[options.behavior='auto']
When set to `'auto'`, this will immediately scroll to the new position.
When set to `'smooth'`, this will scroll smoothly to the new position.
@param {number}[options.speed]
The speed of the scrolling motion when scrolling with `{ behavior: 'smooth' }`.
Defaults to `up.viewport.config.scrollSpeed`.
@param {number} [config.padding=0]
The desired padding between the revealed element and the
closest [viewport](/up.viewport) edge (in pixels).
@param {number|boolean} [config.snap]
Whether to snap to the top of the viewport if the new scroll position
after revealing the element is close to the top edge.
You may pass a maximum number of pixels under which to snap to the top.
Passing `false` will disable snapping.
Passing `true` will use the snap pixel value from `up.viewport.config.revealSnap`.
@return {Promise}
A promise that fulfills when the element is revealed.
@stable
###
reveal = (elementOrSelector, options) ->
element = e.get(elementOrSelector)
motion = new up.RevealMotion(element, options)
scrollingController.startMotion(element, motion, options)
###**
@function up.viewport.scrollAfterInsertFragment
@param {boolean|object} [options.restoreScroll]
@param {boolean|string|jQuery|Element} [options.reveal]
@param {boolean|string} [options.reveal]
@return {Promise}
A promise that is fulfilled when the scrolling has finished.
@internal
###
scrollAfterInsertFragment = (element, options = {}) ->
hashOpt = options.hash
revealOpt = options.reveal
restoreScrollOpt = options.restoreScroll
scrollOptions = u.only(options, 'scrollBehavior', 'scrollSpeed')
if restoreScrollOpt
# If options.restoreScroll is an object, its keys map viewport selectors
# to scroll positions. If it is just true, we leave the scrollTops option
# undefined and let restoreScroll() retrieve previous scrollTops from cache.
givenTops = u.presence(restoreScrollOpt, u.isObject)
return restoreScroll(around: element, scrollTops: givenTops)
else if hashOpt && revealOpt == true # hash revealing can be disabled with { reveal: false }
return revealHash(hashOpt, scrollOptions)
else if revealOpt
# We allow to pass another element as { reveal } option
if u.isElement(revealOpt) || u.isJQuery(revealOpt)
element = e.get(revealOpt)
# We allow to pass a selector as { reveal } option
else if u.isString(revealOpt)
selector = e.resolveSelector(revealOpt, options.origin)
element = up.fragment.first(selector)
else
# We reveal the given element
# If selectorOrElement was a CSS selector, don't blow up by calling reveal()
# with an empty jQuery collection. This might happen if a failed form submission
# reveals the first validation error message, but the error is shown in an
# unexpected element.
if element
return reveal(element, scrollOptions)
else
# If we didn't need to scroll above, just return a resolved promise
# to fulfill this function's signature.
return Promise.resolve()
###**
[Reveals](/up.reveal) an element matching the given `#hash` anchor.
Other than the default behavior found in browsers, `up.revealHash` works with
[multiple viewports](/up-viewport) and honors [fixed elements](/up-fixed-top) obstructing the user's
view of the viewport.
When the page loads initially, this function is automatically called with the hash from
the current URL.
If no element matches the given `#hash` anchor, a resolved promise is returned.
\#\#\# Example
up.revealHash('#chapter2')
@function up.viewport.revealHash
@param {string} hash
@return {Promise}
A promise that is fulfilled when scroll position has changed to match the location hash.
@experimental
###
revealHash = (hash) ->
if (hash) && (match = firstHashTarget(hash))
reveal(match, top: true)
else
Promise.resolve()
allSelector = ->
# On Edge the document viewport can be changed from CSS
[rootSelector(), config.viewports...].join(',')
###**
Returns the scrolling container for the given element.
Returns the [document's scrolling element](/up.viewport.root)
if no closer viewport exists.
@function up.viewport.closest
@param {string|Element|jQuery} selectorOrElement
@return {Element}
@experimental
###
closest = (selectorOrElement) ->
element = e.get(selectorOrElement)
e.closest(element, allSelector())
###**
Returns a jQuery collection of all the viewports contained within the
given selector or element.
@function up.viewport.subtree
@param {string|Element|jQuery} selectorOrElement
@return List
@internal
###
getSubtree = (selectorOrElement) ->
element = e.get(selectorOrElement)
e.subtree(element, allSelector())
getAround = (selectorOrElement) ->
element = e.get(selectorOrElement)
e.list(closest(element), getSubtree(element))
###**
Returns a list of all the viewports on the screen.
@function up.viewport.all
@internal
###
getAll = ->
e.all(allSelector())
rootSelector = ->
# The spec says this should be in standards mode
# and in quirks mode. However, it is currently (2018-07)
# always in Webkit browsers (not Blink). Luckily Webkit
# also supports document.scrollingElement.
if element = document.scrollingElement
element.tagName
else
# IE11
'html'
###**
Return the [scrolling element](https://developer.mozilla.org/en-US/docs/Web/API/document/scrollingElement)
for the browser's main content area.
@function up.viewport.root
@return {Element}
@experimental
###
getRoot = ->
document.querySelector(rootSelector())
rootWidth = ->
# This should happen on the element, regardless of document.scrollingElement
e.root().clientWidth
rootHeight = ->
# This should happen on the element, regardless of document.scrollingElement
e.root().clientHeight
isRoot = (element) ->
e.matches(element, rootSelector())
###**
Returns whether the given element is currently showing a vertical scrollbar.
@function up.viewport.rootHasVerticalScrollbar
@internal
###
rootHasVerticalScrollbar = ->
# We could also check if scrollHeight > offsetHeight for the document viewport.
# However, we would also need to check overflow-y for that element.
# Also we have no control whether developers set the property on or .
# https://tylercipriani.com/blog/2014/07/12/crossbrowser-javascript-scrollbar-detection/
window.innerWidth > document.documentElement.offsetWidth
###**
Returns the element that controls the `overflow-y` behavior for the
[document viewport](/up.viewport.root()).
@function up.viewport.rootOverflowElement
@internal
###
rootOverflowElement = ->
body = document.body
html = document.documentElement
element = u.find([html, body], wasChosenAsOverflowingElement)
element || getRoot()
###**
Returns whether the given element was chosen as the overflowing
element by the developer.
We have no control whether developers set the property on or
. The developer also won't know what is going to be the
[scrolling element](/up.viewport.root()) on the user's brower.
@function wasChosenAsOverflowingElement
@internal
###
wasChosenAsOverflowingElement = (element) ->
overflowY = e.style(element, 'overflow-y')
overflowY == 'auto' || overflowY == 'scroll'
###**
Returns the width of a scrollbar.
This only runs once per page load.
@function up.viewport.scrollbarWidth
@internal
###
scrollbarWidth = u.memoize ->
# This is how Bootstrap does it also:
# https://github.com/twbs/bootstrap/blob/c591227602996c542b9fd0cb65cff3cc9519bdd5/dist/js/bootstrap.js#L1187
outerStyle =
position: 'absolute'
top: '0'
left: '0'
width: '100px'
height: '100px' # Firefox needs at least 100px to show a scrollbar
overflowY: 'scroll'
outer = up.element.affix(document.body, '[up-viewport]', { style: outerStyle })
width = outer.offsetWidth - outer.clientWidth
up.element.remove(outer)
width
scrollTopKey = (viewport) ->
e.toSelector(viewport)
###**
Returns a hash with scroll positions.
Each key in the hash is a viewport selector. The corresponding
value is the viewport's top scroll position:
up.viewport.scrollTops()
=> { '.main': 0, '.sidebar': 73 }
@function up.viewport.scrollTops
@return Object
@internal
###
scrollTops = ->
u.mapObject getAll(), (viewport) ->
[scrollTopKey(viewport), viewport.scrollTop]
###**
@function up.viewport.fixedElements
@internal
###
fixedElements = (root = document) ->
queryParts = ['[up-fixed]'].concat(config.fixedTop).concat(config.fixedBottom)
root.querySelectorAll(queryParts.join(','))
###**
Saves the top scroll positions of all the
viewports configured in [`up.viewport.config.viewports`](/up.viewport.config).
The scroll positions will be associated with the current URL.
They can later be restored by calling [`up.viewport.restoreScroll()`](/up.viewport.restoreScroll)
at the same URL.
Unpoly automatically saves scroll positions whenever a fragment was updated on the page.
@function up.viewport.saveScroll
@param {string} [options.url]
@param {Object} [options.tops]
@experimental
###
saveScroll = (options = {}) ->
url = options.url ? up.history.url()
tops = options.tops ? scrollTops()
lastScrollTops.set(url, tops)
###**
Restores [previously saved](/up.viewport.saveScroll) scroll positions of viewports
viewports configured in [`up.viewport.config.viewports`](/up.viewport.config).
Unpoly automatically restores scroll positions when the user presses the back button.
You can disable this behavior by setting [`up.history.config.restoreScroll = false`](/up.history.config).
@function up.viewport.restoreScroll
@param {Element} [options.around]
If set, only restores viewports that are either an ancestor
or descendant of the given element.
@return {Promise}
A promise that will be fulfilled once scroll positions have been restored.
@experimental
###
restoreScroll = (options = {}) ->
url = up.history.url()
viewports = if options.around then getAround(options.around) else getAll()
scrollTopsForUrl = options.scrollTops || lastScrollTops.get(url) || {}
up.log.group 'Restoring scroll positions for URL %s to %o', url, scrollTopsForUrl, ->
allScrollPromises = u.map viewports, (viewport) ->
key = scrollTopKey(viewport)
scrollTop = scrollTopsForUrl[key] || 0
scroll(viewport, scrollTop, duration: 0)
Promise.all(allScrollPromises)
###**
@internal
###
absolutize = (elementOrSelector, options = {}) ->
element = e.get(elementOrSelector)
viewport = up.viewport.closest(element)
viewportRect = viewport.getBoundingClientRect()
originalRect = element.getBoundingClientRect()
boundsRect = new up.Rect
left: originalRect.left - viewportRect.left
top: originalRect.top - viewportRect.top
width: originalRect.width
height: originalRect.height
# Allow the caller to run code before we start shifting elements around.
options.afterMeasure?()
e.setStyle element,
# If the element had a layout context before, make sure the
# ghost will have layout context as well (and vice versa).
position: if element.style.position == 'static' then 'static' else 'relative'
top: 'auto' # CSS default
right: 'auto' # CSS default
bottom: 'auto' # CSS default
left: 'auto' # CSS default
width: '100%' # stretch to the .up-bounds width we set below
height: '100%' # stretch to the .up-bounds height we set below
# Wrap the ghost in another container so its margin can expand
# freely. If we would position the element directly (old implementation),
# it would gain a layout context which cannot be crossed by margins.
bounds = e.createFromSelector('.up-bounds')
# Insert the bounds object before our element, then move element into it.
e.insertBefore(element, bounds)
bounds.appendChild(element)
moveBounds = (diffX, diffY) ->
boundsRect.left += diffX
boundsRect.top += diffY
e.setStyle(bounds, boundsRect)
# Position the bounds initially
moveBounds(0, 0)
# In theory, element should not have moved visually. However, element
# (or a child of element) might collapse its margin against a previous
# sibling element, and now that it is absolute it does not have the
# same sibling. So we manually correct element's top position so it aligns
# with the previous top position.
newElementRect = element.getBoundingClientRect()
moveBounds(originalRect.left - newElementRect.left, originalRect.top - newElementRect.top)
u.each(fixedElements(element), e.fixedToAbsolute)
bounds: bounds
moveBounds: moveBounds
###**
Marks this element as a scrolling container ("viewport").
Apply this attribute if your app uses a custom panel layout with fixed positioning
instead of scrolling ``. As an alternative you can also push a selector
matching your custom viewport to the [`up.viewport.config.viewports`](/up.viewport.config) array.
[`up.reveal()`](/up.reveal) will always try to scroll the viewport closest
to the element that is being revealed. By default this is the `` element.
\#\#\# Example
Here is an example for a layout for an e-mail client, showing a list of e-mails
on the left side and the e-mail text on the right side:
.side {
position: fixed;
top: 0;
bottom: 0;
left: 0;
width: 100px;
overflow-y: scroll;
}
.main {
position: fixed;
top: 0;
bottom: 0;
left: 100px;
right: 0;
overflow-y: scroll;
}
This would be the HTML (notice the `up-viewport` attribute):
Re: Your Invoice
Lorem ipsum dolor sit amet, consetetur sadipscing elitr.
Stet clita kasd gubergren, no sea takimata sanctus est.
@selector [up-viewport]
@stable
###
###**
Marks this element as being fixed to the top edge of the screen
using `position: fixed`.
When [following a fragment link](/a-up-target), the viewport is scrolled
so the targeted element becomes visible. By using this attribute you can make
Unpoly aware of fixed elements that are obstructing the viewport contents.
Unpoly will then scroll the viewport far enough that the revealed element is fully visible.
Instead of using this attribute,
you can also configure a selector in [`up.viewport.config.fixedTop`](/up.viewport.config#config.fixedTop).
\#\#\# Example
...
@selector [up-fixed=top]
@stable
###
###**
Marks this element as being fixed to the bottom edge of the screen
using `position: fixed`.
When [following a fragment link](/a-up-target), the viewport is scrolled
so the targeted element becomes visible. By using this attribute you can make
Unpoly aware of fixed elements that are obstructing the viewport contents.
Unpoly will then scroll the viewport far enough that the revealed element is fully visible.
Instead of using this attribute,
you can also configure a selector in [`up.viewport.config.fixedBottom`](/up.viewport.config#config.fixedBottom).
\#\#\# Example
...
@selector [up-fixed=bottom]
@stable
###
###**
Marks this element as being anchored to the right edge of the screen,
typically fixed navigation bars.
Since [modal dialogs](/up.modal) hide the document scroll bar,
elements anchored to the right appear to jump when the dialog opens or
closes. Applying this attribute to anchored elements will make Unpoly
aware of the issue and adjust the `right` property accordingly.
You should give this attribute to layout elements
with a CSS of `right: 0` with `position: fixed` or `position:absolute`.
Instead of giving this attribute to any affected element,
you can also configure a selector in [`up.viewport.config.anchoredRight`](/up.viewport.config#config.anchoredRight).
\#\#\# Example
Here is the CSS for a navigation bar that is anchored to the top edge of the screen:
.top-nav {
position: fixed;
top: 0;
left: 0;
right: 0;
}
By adding an `up-anchored="right"` attribute to the element, we can prevent the
`right` edge from jumping when a [modal dialog](/up.modal) opens or closes:
...
@selector [up-anchored=right]
@stable
###
###**
@function up.viewport.firstHashTarget
@internal
###
firstHashTarget = (hash) ->
if hash = pureHash(hash)
selector = [
# First match an <* up-id="hash">. This won't be picked up without JS,
# preventing the scroll position from jump if up.viewport.revealPadding
# is set.
e.attributeSelector('up-id', hash),
# Match an <* id="hash">
e.attributeSelector('id', hash),
# Match an
'a' + e.attributeSelector('name', hash)
].join(',')
up.fragment.first(selector)
###**
Returns `'foo'` if the hash is `'#foo'`.
Returns undefined if the hash is `'#'`, `''` or `undefined`.
@function pureHash
@internal
###
pureHash = (value) ->
if value && value[0] == '#'
value = value.substr(1)
u.presence(value)
up.on 'up:app:booted', -> revealHash(location.hash)
up.on 'up:framework:reset', reset
<% if ENV['JS_KNIFE'] %>knife: eval(Knife.point)<% end %>
reveal: reveal
revealHash: revealHash
firstHashTarget: firstHashTarget
scroll: scroll
config: config
closest: closest
subtree: getSubtree
around: getAround
all: getAll
rootSelector: rootSelector
root: getRoot
rootWidth: rootWidth
rootHeight: rootHeight
rootHasVerticalScrollbar: rootHasVerticalScrollbar
rootOverflowElement: rootOverflowElement
isRoot: isRoot
scrollbarWidth: scrollbarWidth
scrollTops: scrollTops
saveScroll: saveScroll
restoreScroll: restoreScroll
scrollAfterInsertFragment: scrollAfterInsertFragment
anchoredRight: anchoredRight
fixedElements: fixedElements
absolutize: absolutize
up.scroll = up.viewport.scroll
up.reveal = up.viewport.reveal
up.revealHash = up.viewport.revealHash
up.legacy.renamedModule 'layout', 'viewport'