Created
June 30, 2021 10:34
-
-
Save IPRIT/2e5e50b69f56c53d7a3e84abcd5e812a to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { hasClass } from './hasClass'; | |
/** | |
* Add specific class name to specific element | |
*/ | |
export function addClass(element: HTMLElement, className: string) { | |
if (!hasClass(element, className)) { | |
if (element.classList) { | |
element.classList.add(className); | |
} else { | |
element.className += ' ' + className; | |
} | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* Determine is the element has specific class name | |
*/ | |
export function hasClass(element: HTMLElement, className: string) { | |
if (element.classList) { | |
return element.classList.contains(className); | |
} | |
return new RegExp('(^| )' + className + '( |$)', 'gi').test(element.className); | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
export * from './addClass'; | |
export * from './removeClass'; | |
export * from './hasClass'; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { hasClass } from './hasClass'; | |
/** | |
* Remove specific class name to specific element | |
*/ | |
export function removeClass(element: HTMLElement, className: string) { | |
if (hasClass(element, className)) { | |
if (element.classList) { | |
element.classList.remove(className); | |
} else { | |
element.className = element.className | |
.replace(new RegExp('\\b' + className.split(' ').join('|') + '\\b', 'gi'), ' '); | |
} | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { withNaming } from '@bem-react/classname'; | |
// mg-sticky шифруется aab, | |
// чтобы сгенерированный класс совпадал с css — хардкодим целиком | |
export const cls = withNaming({ e: '__' })('mg-sticky'); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
export const DEFAULTS = { | |
topSpacing: 0, | |
bottomSpacing: 0, | |
containerSelector: false, | |
stickyClass: 'is-affixed', | |
resizeSensor: true, | |
minWidth: false, | |
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { getElementOffset } from 'neo/lib/client/getElementOffset'; | |
import { isPassiveSupported } from 'neo/lib/client/isPassiveSupported'; | |
import { EAffixedType, EScrollDirection, EStickyEvent, EStickyNativeEvent, IStickyOptions } from './Sticky.types'; | |
import { addClass, removeClass } from './helpers'; | |
import { DEFAULTS } from './Sticky.const'; | |
export class StickyContent { | |
public options: IStickyOptions; | |
public content: HTMLElement; | |
protected parent: HTMLElement; | |
protected contentInner: HTMLElement; | |
protected affixedType = EAffixedType.STATIC; | |
protected scrollDirection = EScrollDirection.DOWN; | |
protected dimensions = { | |
translateY: 0, | |
maxTranslateY: 0, | |
topSpacing: 0, | |
lastTopSpacing: 0, | |
bottomSpacing: 0, | |
lastBottomSpacing: 0, | |
contentLeft: 0, | |
contentWidth: 0, | |
contentHeight: 0, | |
parentTop: 0, | |
parentBottom: 0, | |
parentHeight: 0, | |
viewportTop: 0, | |
viewportBottom: 0, | |
viewportHeight: 0, | |
viewportLeft: 0, | |
lastViewportTop: 0, | |
}; | |
private initialized = false; | |
private reStyle = false; | |
private breakpoint = false; | |
private scrolling = false; | |
constructor(content: HTMLElement, options: Partial<IStickyOptions> = {}) { | |
this.options = { ...DEFAULTS, ...options } as IStickyOptions; | |
this.content = content; | |
this.parent = this.content.parentElement as HTMLElement; | |
this.contentInner = this.content.firstElementChild as HTMLElement; | |
this.handleEvent = this.handleEvent.bind(this); | |
// initialize sticky content for the first time | |
this.initialize(); | |
} | |
/** | |
* Initializes the sticky content by adding inner wrapper, define its parent, | |
* min-width breakpoint, calculating dimensions, adding helper classes and inline style | |
*/ | |
public initialize() { | |
// breakdown sticky content if screen width below `options.minWidth` | |
this.widthBreakpoint(); | |
// calculate dimensions of content, parent and viewport | |
this.calculateDimensions(); | |
// affix content in proper position | |
this.stickyPosition(); | |
// bind all events | |
this.bindEvents(); | |
// inform other properties the sticky content is initialized | |
this.initialized = true; | |
} | |
/** | |
* Handles all events | |
*/ | |
public handleEvent(event: Event) { | |
this.updateSticky(event); | |
} | |
public resetOffset() { | |
const dims = this.dimensions; | |
dims.viewportTop = Math.min(dims.maxTranslateY, document.documentElement.scrollTop || document.body.scrollTop); | |
dims.translateY = dims.viewportTop; | |
this.scrollDirection = EScrollDirection.UP; | |
this.stickyPosition(true); | |
} | |
/** | |
* Switches between functions stack for each event type, if there's no | |
* event, it will re-initialize sticky content | |
*/ | |
public updateSticky(event?: Event): void { | |
if (event?.type === EStickyNativeEvent.SCROLL && !this.scrolling) { | |
this.scrolling = true; | |
return void requestAnimationFrame(() => { | |
// when browser is scrolling and re-calculate just dimensions within scroll | |
this.calculateDimensionsWithScroll(); | |
this.observeScrollDirection(); | |
this.stickyPosition(); | |
this.scrolling = false; | |
}); | |
} | |
if (event?.type === EStickyEvent.RESET_OFFSET) { | |
return void requestAnimationFrame(() => { | |
this.resetOffset(); | |
}); | |
} | |
// when browser is resizing or there's no event, | |
// observe width breakpoint and re-calculate dimensions. | |
requestAnimationFrame(() => { | |
this.widthBreakpoint(); | |
this.calculateDimensions(); | |
this.stickyPosition(true); | |
requestAnimationFrame(() => { | |
this.calculateDimensions(); | |
this.stickyPosition(true); | |
}); | |
}); | |
} | |
public destroy() { | |
const options = isPassiveSupported ? { capture: false } : false; | |
window.removeEventListener('resize', this, options); | |
window.removeEventListener('scroll', this, options); | |
this.content.classList.remove(this.options.stickyClass); | |
this.content.style.minHeight = ''; | |
document.removeEventListener(EStickyEvent.UPDATE, this); | |
this.parent.removeEventListener(EStickyEvent.UPDATE, this); | |
this.parent.removeEventListener(EStickyEvent.RESET_OFFSET, this); | |
const styleReset = { | |
inner: { position: '', top: '', left: '', bottom: '', width: '', transform: '' }, | |
outer: { height: '', position: '' }, | |
}; | |
for (const key in styleReset.outer) { | |
// @ts-ignore | |
this.content.style[key] = styleReset.outer[key]; | |
} | |
for (const key in styleReset.inner) { | |
// @ts-ignore | |
this.contentInner.style[key] = styleReset.inner[key]; | |
} | |
} | |
/** | |
* Calculates dimensions of content, parent and screen viewpoint | |
*/ | |
private calculateDimensions() { | |
if (this.breakpoint) { | |
return; | |
} | |
const dims = this.dimensions; | |
dims.parentTop = getElementOffset(this.parent).top; | |
dims.parentHeight = this.parent.clientHeight; | |
dims.parentBottom = dims.parentTop + dims.parentHeight; | |
dims.contentWidth = this.content.offsetWidth; | |
dims.contentHeight = this.contentInner.offsetHeight; | |
// screen viewport dimensions | |
dims.viewportHeight = window.innerHeight; | |
// maximum content translate Y | |
dims.maxTranslateY = dims.parentHeight - dims.contentHeight; | |
this.calculateDimensionsWithScroll(); | |
} | |
/** | |
* Some dimensions values need to be up-to-date when scrolling the page | |
*/ | |
private calculateDimensionsWithScroll() { | |
const dims = this.dimensions; | |
dims.contentLeft = getElementOffset(this.content).left; | |
dims.viewportTop = document.documentElement.scrollTop || document.body.scrollTop; | |
dims.viewportBottom = dims.viewportTop + dims.viewportHeight; | |
dims.viewportLeft = document.documentElement.scrollLeft || document.body.scrollLeft; | |
if (dims.viewportLeft > 0) { | |
this.reStyle = true; | |
} | |
dims.topSpacing = this.options.topSpacing; | |
dims.bottomSpacing = this.options.bottomSpacing; | |
if (this.affixedType === EAffixedType.VIEWPORT_TOP) { | |
// adjust translate Y in the case decrease top spacing value | |
if (dims.topSpacing < dims.lastTopSpacing) { | |
dims.translateY += dims.lastTopSpacing - dims.topSpacing; | |
this.reStyle = true; | |
} | |
} else if (this.affixedType === EAffixedType.VIEWPORT_BOTTOM) { | |
// adjust translate Y in the case decrease bottom spacing value | |
if (dims.bottomSpacing < dims.lastBottomSpacing) { | |
dims.translateY += dims.lastBottomSpacing - dims.bottomSpacing; | |
this.reStyle = true; | |
} | |
} | |
dims.lastTopSpacing = dims.topSpacing; | |
dims.lastBottomSpacing = dims.bottomSpacing; | |
} | |
/** | |
* Bind all events of sticky content | |
*/ | |
private bindEvents() { | |
const options = isPassiveSupported | |
? { passive: true, capture: false } | |
: false; | |
window.addEventListener('resize', this, options); | |
window.addEventListener('scroll', this, options); | |
document.addEventListener(EStickyEvent.UPDATE, this); | |
this.parent.addEventListener(EStickyEvent.UPDATE, this); | |
this.parent.addEventListener(EStickyEvent.RESET_OFFSET, this); | |
} | |
/** | |
* Determine whether the content is bigger than viewport | |
*/ | |
private isContentFitsViewport() { | |
const dims = this.dimensions; | |
const offset = this.scrollDirection === EScrollDirection.DOWN | |
? dims.lastBottomSpacing | |
: dims.lastTopSpacing; | |
return this.dimensions.contentHeight + offset | |
< this.dimensions.viewportHeight - dims.bottomSpacing - dims.topSpacing; | |
} | |
/** | |
* Observe browser scrolling direction top and down | |
*/ | |
private observeScrollDirection() { | |
const dims = this.dimensions; | |
if (dims.lastViewportTop === dims.viewportTop) { | |
return; | |
} | |
const clampFn = this.scrollDirection === EScrollDirection.DOWN | |
? Math.min | |
: Math.max; | |
// if the browser is scrolling not in the same direction | |
if (dims.viewportTop === clampFn(dims.viewportTop, dims.lastViewportTop)) { | |
this.scrollDirection = this.scrollDirection === EScrollDirection.DOWN | |
? EScrollDirection.UP | |
: EScrollDirection.DOWN; | |
} | |
} | |
/** | |
* Gets affix type of content according to current scroll top and scrolling direction. | |
*/ | |
private getAffixType() { | |
this.calculateDimensionsWithScroll(); | |
const dims = this.dimensions; | |
const colliderTop = dims.viewportTop + dims.topSpacing; | |
let affixType = this.affixedType; | |
if (colliderTop <= dims.parentTop || dims.parentHeight <= dims.contentHeight) { | |
dims.translateY = 0; | |
affixType = EAffixedType.STATIC; | |
} else { | |
affixType = this.scrollDirection === EScrollDirection.UP | |
? this.getAffixTypeScrollingUp() | |
: this.getAffixTypeScrollingDown(); | |
} | |
// make sure the translate Y is not bigger than parent height | |
dims.translateY = Math.round( | |
Math.min(dims.parentHeight, Math.max(0, dims.translateY)), | |
); | |
dims.lastViewportTop = dims.viewportTop; | |
return affixType; | |
} | |
/** | |
* Get affix type while scrolling down | |
*/ | |
private getAffixTypeScrollingDown() { | |
const dims = this.dimensions; | |
const contentBottom = dims.contentHeight + dims.parentTop; | |
const colliderTop = dims.viewportTop + dims.topSpacing; | |
const colliderBottom = dims.viewportBottom - dims.bottomSpacing; | |
let affixType = this.affixedType; | |
if (this.isContentFitsViewport()) { | |
if (dims.contentHeight + colliderTop >= dims.parentBottom) { | |
dims.translateY = dims.parentBottom - contentBottom; | |
affixType = EAffixedType.CONTAINER_BOTTOM; | |
} else if (colliderTop >= dims.parentTop) { | |
dims.translateY = colliderTop - dims.parentTop; | |
affixType = EAffixedType.VIEWPORT_TOP; | |
} | |
} else if (dims.parentBottom <= colliderBottom) { | |
dims.translateY = dims.parentBottom - contentBottom; | |
affixType = EAffixedType.CONTAINER_BOTTOM; | |
} else if (contentBottom + dims.translateY <= colliderBottom) { | |
dims.translateY = colliderBottom - contentBottom; | |
affixType = EAffixedType.VIEWPORT_BOTTOM; | |
} else if ( | |
dims.parentTop + dims.translateY <= colliderTop | |
&& dims.translateY !== 0 | |
&& dims.maxTranslateY !== dims.translateY | |
) { | |
affixType = EAffixedType.VIEWPORT_UNBOTTOM; | |
} | |
return affixType; | |
} | |
/** | |
* Get affix type while scrolling up | |
*/ | |
private getAffixTypeScrollingUp() { | |
const dims = this.dimensions; | |
const contentBottom = dims.contentHeight + dims.parentTop; | |
const colliderTop = dims.viewportTop + dims.topSpacing; | |
const colliderBottom = dims.viewportBottom - dims.bottomSpacing; | |
let affixType = this.affixedType; | |
if (colliderTop <= dims.translateY + dims.parentTop) { | |
dims.translateY = colliderTop - dims.parentTop; | |
affixType = EAffixedType.VIEWPORT_TOP; | |
} else if (dims.parentBottom <= colliderBottom) { | |
dims.translateY = dims.parentBottom - contentBottom; | |
affixType = EAffixedType.CONTAINER_BOTTOM; | |
} else if ( | |
!this.isContentFitsViewport() | |
&& dims.parentTop <= colliderTop | |
&& dims.translateY !== 0 | |
&& dims.maxTranslateY !== dims.translateY) { | |
affixType = EAffixedType.VIEWPORT_UNBOTTOM; | |
} | |
return affixType; | |
} | |
/** | |
* Gets inline style of sticky content wrapper and inner wrapper according | |
* to its affix type | |
*/ | |
private getStyle(affixType: EAffixedType) { | |
const dims = this.dimensions; | |
const style = { inner: {}, outer: {} }; | |
// inner styles | |
switch (affixType) { | |
case EAffixedType.VIEWPORT_TOP: | |
style.inner = { | |
position: 'fixed', | |
top: dims.topSpacing, | |
left: dims.contentLeft - dims.viewportLeft, | |
width: dims.contentWidth, | |
}; | |
break; | |
case EAffixedType.VIEWPORT_BOTTOM: | |
style.inner = { | |
position: 'fixed', | |
top: 'auto', | |
left: dims.contentLeft - dims.viewportLeft, | |
bottom: dims.bottomSpacing, | |
width: dims.contentWidth, | |
}; | |
break; | |
case EAffixedType.CONTAINER_BOTTOM: | |
case EAffixedType.VIEWPORT_UNBOTTOM: | |
style.inner = { | |
transform: this.interpolateTranslate(0, dims.translateY + 'px'), | |
}; | |
break; | |
} | |
// outer styles | |
switch (affixType) { | |
case EAffixedType.VIEWPORT_TOP: | |
case EAffixedType.VIEWPORT_BOTTOM: | |
case EAffixedType.VIEWPORT_UNBOTTOM: | |
case EAffixedType.CONTAINER_BOTTOM: | |
style.outer = { | |
position: 'relative', | |
height: dims.contentHeight, | |
}; | |
break; | |
} | |
style.outer = Object.assign({ | |
height: '', | |
position: '', | |
}, style.outer); | |
style.inner = Object.assign({ | |
position: 'relative', | |
top: '', | |
left: '', | |
bottom: '', | |
width: '', | |
transform: '', | |
}, style.inner); | |
return style; | |
} | |
/** | |
* Cause the content to be sticky according to affix type by adding inline | |
* style, adding helper class and trigger events | |
*/ | |
private stickyPosition(force = false) { | |
if (this.breakpoint) { | |
return; | |
} | |
force = this.reStyle || force || false; | |
const affixType = this.getAffixType(); | |
const style = this.getStyle(affixType); | |
if (this.affixedType !== affixType || force) { | |
if (affixType === EAffixedType.STATIC) { | |
removeClass(this.content, this.options.stickyClass); | |
} else { | |
addClass(this.content, this.options.stickyClass); | |
} | |
for (const key in style.outer) { | |
// @ts-ignore | |
this.content.style[key] = style.outer[key] + this.getUnit(style.outer[key]); | |
} | |
for (const key in style.inner) { | |
// @ts-ignore | |
this.contentInner.style[key] = style.inner[key] + this.getUnit(style.inner[key]); | |
} | |
} else if (this.initialized) { | |
// @ts-ignore | |
this.contentInner.style.left = style.inner.left; | |
} | |
this.affixedType = affixType; | |
} | |
/** | |
* Breakdown sticky content when window width is below `options.minWidth` value | |
*/ | |
private widthBreakpoint() { | |
if (window.innerWidth <= this.options.minWidth) { | |
this.breakpoint = true; | |
this.affixedType = EAffixedType.STATIC; | |
this.content.removeAttribute('style'); | |
this.contentInner.removeAttribute('style'); | |
removeClass(this.content, this.options.stickyClass); | |
} else { | |
this.breakpoint = false; | |
} | |
} | |
private interpolateTranslate( | |
x: string | number = 0, | |
y: string | number = 0, | |
z: string | number = 0, | |
) { | |
return 'translate3d(' + x + ', ' + y + ', ' + z + ')'; | |
} | |
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */ | |
private getUnit(value: any) { | |
return typeof value === 'number' ? 'px' : ''; | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { triggerEvent } from 'neo/lib/client/triggerEvent'; | |
import { EStickyEvent } from 'mg/components/Sticky/Sticky.types'; | |
export function updateSticky(fromElement?: HTMLElement) { | |
triggerEvent(EStickyEvent.UPDATE, {}, fromElement); | |
} | |
export function resetStickyOffset(fromElement?: HTMLElement) { | |
triggerEvent(EStickyEvent.RESET_OFFSET, {}, fromElement); | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
.mg-sticky { | |
height: 100%; | |
&__content { | |
will-change: height, min-height; | |
} | |
&__inner { | |
transform: translate3d(0, 0, 0); | |
will-change: position, transform; | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* eslint-disable react-hooks/exhaustive-deps */ | |
import React, { Ref, RefObject, useEffect, useRef } from 'react'; | |
import { StickyContent } from './Sticky.content'; | |
import { IProps } from './Sticky.types'; | |
import { cls } from './Sticky.cn'; | |
import './Sticky.scss'; | |
export const Sticky = React.forwardRef(function Sticky( | |
props: IProps, | |
ref: Ref<HTMLDivElement>, | |
): JSX.Element { | |
const { | |
children, | |
className = '', | |
options = {}, | |
} = props; | |
const contentRef = useRef<HTMLDivElement>() as RefObject<HTMLDivElement>; | |
useEffect(() => { | |
const sticky = new StickyContent(contentRef.current!, options); | |
return () => sticky.destroy(); | |
}, []); | |
return ( | |
<div className={cls(null, [className])} ref={ref}> | |
<div className={cls('content')} ref={contentRef}> | |
<div className={cls('inner')}>{children}</div> | |
</div> | |
</div> | |
); | |
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { ReactNode } from 'react'; | |
export interface ICommonProps { | |
children: ReactNode; | |
className?: string; | |
} | |
export interface IProps extends ICommonProps { | |
options?: Partial<IStickyOptions>; | |
} | |
export interface IStickyOptions { | |
/** | |
* Additional top spacing of the element when it becomes sticky | |
*/ | |
topSpacing: number; | |
/** | |
* Additional bottom spacing of the element when it becomes sticky | |
*/ | |
bottomSpacing: number, | |
/** | |
* The name of CSS class to apply to elements when they have become stuck | |
*/ | |
stickyClass: string, | |
/** | |
* Detect when sidebar and its container change height so re-calculate their dimensions | |
*/ | |
resizeSensor: boolean, | |
/** | |
* The sidebar returns to its normal position if its width below this value | |
*/ | |
minWidth: number, | |
} | |
export enum EStickyEvent { | |
UPDATE = 'sticky.update', | |
RESET_OFFSET = 'sticky.resetOffset', | |
} | |
export enum EStickyNativeEvent { | |
SCROLL = 'scroll', | |
RESIZE = 'resize', | |
} | |
export enum EAffixedType { | |
STATIC = 'static', | |
VIEWPORT_TOP = 'viewport-top', | |
VIEWPORT_BOTTOM = 'viewport-bottom', | |
VIEWPORT_UNBOTTOM = 'viewport-unbottom', | |
CONTAINER_BOTTOM = 'container-bottom' | |
} | |
export enum EScrollDirection { | |
UP, | |
DOWN, | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment