UNPKG

material-design-kit

Version:

Interactive web components inspired from Material Design, using vanilla CSS, JavaScript and HTML.

475 lines (399 loc) 12 kB
import { scrollTargetBehavior } from '../scroll-target-behavior' import { scrollEffectBehavior } from '../scroll-effect-behavior' import { handler } from 'dom-factory' // const RetargetMouseScroll = require('retarget-mouse-scroll') import {RetargetMouseScroll} from './retarget-mouse-scroll' // SCROLL EFFECTS import { SCROLL_EFFECTS } from '../scroll-effects' // HEADER SCROLL EFFECTS import { HEADER_SCROLL_EFFECTS } from '../header-scroll-effects' const MODULE = 'mdk-header' const CONTENT = `.${ MODULE }__content` const BG = `.${ MODULE }__bg` const FRONT_LAYER = `${ BG }-front` const REAR_LAYER = `${ BG }-rear` const MODIFIER_FIXED = `${ MODULE }--fixed` /** * A container element for navigation and other content at the top * of the screen with visual effects based on scroll position * * @param {HTMLElement} element * @return {Object} */ export const headerComponent = (element) => ({ /** * Public properties. * @type {Object} */ properties: { /** * Collapse the header when scrolling down, leaving only the `[primary]` element visible. * If there is no `[primary]` element, the first child remains visibile. * @type {Object} */ condenses: { type: Boolean, reflectToAttribute: true }, /** * Slides back the header when scrolling back up. * @type {Object} */ reveals: { type: Boolean, reflectToAttribute: true }, /** * Mantains the header fixed at the top. * @type {Object} */ fixed: { type: Boolean, reflectToAttribute: true }, /** * Disables all scroll effects. * @type {Object} */ disabled: { type: Boolean, reflectToAttribute: true }, retargetMouseScroll: { type: Boolean, reflectToAttribute: true, value: true } }, /** * Property change observers. * @type {Array} */ observers: [ '_handleFixedPositionedScroll(scrollTarget)', '_reset(condenses, reveals, fixed)' ], /** * Event listeners * @type {Array} */ listeners: [ 'window._debounceResize(resize)' ], /** * Compose mixins * @type {Array} */ mixins: [ scrollTargetBehavior(element), scrollEffectBehavior(element) ], // A cached offsetHeight of the element _height: 0, // The distance in pixels the header will be translated to when scrolling _dHeight: 0, // The offsetTop of `_primary` _primaryTop: 0, // The element that remains visibile when the header condenses _primary: null, // The header's top value used for the `transformY` _top: 0, // The current scroll progress _progress: 0, _wasScrollingDown: false, _initScrollTop: 0, _initTimestamp: 0, _lastTimestamp: 0, _lastScrollTop: 0, /** * Disables transform effects * @return {Boolean} */ get transformDisabled () { return this.disabled || this.element.dataset.transformDisabled || ! this._isPositionedFixedEmulated || this.condenses && ! this.willCondense() }, /** * Update `transform-disabled` attribute on `element` * @param {Boolean} value */ set transformDisabled (value) { this.element[value ? 'setAttribute' : 'removeAttribute']('data-transform-disabled', 'data-transform-disabled') }, /** * The distance the header is allowed to move away. * @return {number} */ get _maxHeaderTop () { return this.fixed ? this._dHeight : this._height + 5 }, get _isPositionedFixedEmulated () { return this.fixed || this.condenses || this.reveals }, get _isPositionedAbsolute () { return window.getComputedStyle(this.element).position === 'absolute' }, get _primaryisPositionedFixed () { return window.getComputedStyle(this._primary).position === 'fixed' }, /** * Returns true if the header will condense based on the size of the header * @return {Boolean} */ willCondense () { return this._dHeight > 0 && this.condenses }, /** * Returns true if the element is visible in the current viewport. * @return {Boolean} */ isOnScreen () { return this._height !== 0 && this._top < this._height }, /** * Returns true if there's content below the element. * @return {Boolean} */ isContentBelow () { if (this._top === 0) { return this._clampedScrollTop > 0 } return this._clampedScrollTop - this._maxHeaderTop >= 0 }, /** * Returns an object containing the progress value of the scroll * and the top position of the header. * @return {Object} */ getScrollState () { return { progress: this._progress, top: this._top } }, _setupBackgrounds () { let bgNode = this.element.querySelector(BG) if (!bgNode) { bgNode = document.createElement('DIV') this.element.insertBefore(bgNode, this.element.childNodes[0]) bgNode.classList.add(BG.substr(1)) } [FRONT_LAYER, REAR_LAYER].map(className => { let bgNodeLayer = bgNode.querySelector(className) if (!bgNodeLayer) { bgNodeLayer = document.createElement('DIV') bgNode.appendChild(bgNodeLayer) bgNodeLayer.classList.add(className.substr(1)) } }) }, _reset () { this._primary.classList[(this.fixed || this.condenses || this.reveals) ? 'add' : 'remove'](MODIFIER_FIXED) if (this.element.offsetWidth === 0 && this.element.offsetHeight === 0) { return } if (this._primaryisPositionedFixed) { this.element.style.paddingTop = this._primary.offsetHeight + 'px' } let scrollTop = this._clampedScrollTop let firstSetup = this._height === 0 || scrollTop === 0 this._height = this.element.offsetHeight this._primaryTop = this._primary ? this._primary.offsetTop : 0 this._dHeight = 0 if (this._mayMove()) { this._dHeight = this._primary ? this._height - this._primary.offsetHeight : 0 } this._setUpEffects() this._updateScrollState(firstSetup ? scrollTop : this._lastScrollTop, true) }, /** * Pass MouseWheel events from the scroll target * when the header is fixed and the scroll target is not the document */ _handleFixedPositionedScroll () { if (this._fixedPositionedScrollHandler !== undefined) { this._fixedPositionedScrollHandler.restore() } if (this._isValidScrollTarget() && this._isPositionedFixedEmulated && this.scrollTarget !== this._doc && this.retargetMouseScroll) { this._fixedPositionedScrollHandler = RetargetMouseScroll(this.element, this.scrollTarget) } }, /** * Returns a reference to the element that remains visible when the header condenses. * @return {HTMLElement} */ get _primary () { if (this._primaryElement) { return this._primaryElement } let primary let nodes = this.element.querySelector(CONTENT).children for (let i = 0; i < nodes.length; i++) { if (nodes[i].nodeType === Node.ELEMENT_NODE) { let node = nodes[i] if (node.dataset.primary !== undefined) { primary = node break } else if (!primary) { primary = node } } } this._primaryElement = primary return this._primaryElement }, /** * Updates the scroll state. * @param {number} scrollTop * @param {Boolean} forceUpdate */ _updateScrollState (scrollTop, forceUpdate) { if (this._height === 0 || this.disabled) { return } if (!forceUpdate && scrollTop === this._lastScrollTop) { return } let progress = 0 let top = 0 let lastTop = this._top let maxHeaderTop = this._maxHeaderTop let dScrollTop = scrollTop - this._lastScrollTop let absDScrollTop = Math.abs(dScrollTop) let isScrollingDown = scrollTop > this._lastScrollTop let now = Date.now() if (this._mayMove()) { top = this._clamp(this.reveals ? lastTop + dScrollTop : scrollTop, 0, maxHeaderTop) } if (scrollTop >= this._dHeight) { top = this.condenses ? Math.max(this._dHeight, top) : top } if (this.reveals && absDScrollTop < 100) { if (now - this._initTimestamp > 300 || this._wasScrollingDown !== isScrollingDown) { this._initScrollTop = scrollTop this._initTimestamp = now } if (scrollTop >= maxHeaderTop) { if (Math.abs(this._initScrollTop - scrollTop) > 30 || absDScrollTop > 10) { if (isScrollingDown && scrollTop >= maxHeaderTop) { top = maxHeaderTop } else if (!isScrollingDown && scrollTop >= this._dHeight) { top = this.condenses ? this._dHeight : 0 } let scrollVelocity = dScrollTop / (now - this._lastTimestamp) this._revealTransitionDuration = this._clamp((top - lastTop) / scrollVelocity, 0, 300) } else { top = this._top } } } if (this._dHeight === 0) { progress = scrollTop > 0 ? 1 : 0 } else { progress = top / this._dHeight } if (!forceUpdate) { this._lastScrollTop = scrollTop this._top = top this._wasScrollingDown = isScrollingDown this._lastTimestamp = now } if (forceUpdate || progress !== this._progress || lastTop !== top || scrollTop === 0) { this._progress = progress this._runEffects(progress, top) this._transformHeader(top) } }, /** * Transforms the header. * @param {number} top */ _transformHeader (top) { if (this.transformDisabled) { return } if (this._isPositionedAbsolute) { let transform = top if (this.scrollTarget === this._doc) { transform = 0 } if (top === transform) { this._transform(`translate3d(0, ${ transform * -1 }px, 0)`) } if (top >= this._primaryTop) { this._transform(`translate3d(0, ${ Math.min(top, this._dHeight) - this._primaryTop }px, 0)`, this._primary) } return } if (this.fixed && this._isPositionedFixed) { let transform = top this._transform(`translate3d(0, ${ transform * -1 }px, 0)`) if (top >= this._primaryTop) { this._transform(`translate3d(0, ${ Math.min(top, this._dHeight) - this._primaryTop }px, 0)`, this._primary) } return } let transform = 0 let duration = `${ this._revealTransitionDuration }ms` if (top > this._dHeight) { transform = -1 * (top - this._dHeight) if (this.reveals) { duration = '0ms' } } if (this.reveals && top <= this._dHeight) { this._primary.style.transitionDuration = duration } this._transform(`translate3d(0, ${ transform }px, 0)`, this._primary) if (this.reveals && top > this._dHeight) { setTimeout(() => this._primary.style.transitionDuration = duration) } }, _clamp (v, min, max) { return Math.min(max, Math.max(min, v)) }, /** * Returns true if the current header is allowed to move as the user scrolls. * @return {Boolean} */ _mayMove () { return this.condenses || !this.fixed }, /** * Handle the resize event every 50ms */ _debounceResize () { clearTimeout(this._onResizeTimeout) if (this._resizeWidth !== window.innerWidth) { this._onResizeTimeout = setTimeout(() => { this._resizeWidth = window.innerWidth this._reset() }, 50) } }, /** * Initialize component */ init () { this._resizeWidth = window.innerWidth this.attachToScrollTarget() this._handleFixedPositionedScroll() this._setupBackgrounds() this._primary.setAttribute('data-primary', 'data-primary') SCROLL_EFFECTS.concat(HEADER_SCROLL_EFFECTS).map(effect => this.registerEffect(effect.name, effect)) }, /** * Destroy component */ destroy () { clearTimeout(this._onResizeTimeout) this.detachFromScrollTarget() } }) handler.register(MODULE, headerComponent)