Skip to content

Instantly share code, notes, and snippets.

@IPRIT
Created June 30, 2021 10:34
Show Gist options
  • Save IPRIT/2e5e50b69f56c53d7a3e84abcd5e812a to your computer and use it in GitHub Desktop.
Save IPRIT/2e5e50b69f56c53d7a3e84abcd5e812a to your computer and use it in GitHub Desktop.
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;
}
}
}
/**
* 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);
}
export * from './addClass';
export * from './removeClass';
export * from './hasClass';
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'), ' ');
}
}
}
import { withNaming } from '@bem-react/classname';
// mg-sticky шифруется aab,
// чтобы сгенерированный класс совпадал с css — хардкодим целиком
export const cls = withNaming({ e: '__' })('mg-sticky');
export const DEFAULTS = {
topSpacing: 0,
bottomSpacing: 0,
containerSelector: false,
stickyClass: 'is-affixed',
resizeSensor: true,
minWidth: false,
};
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' : '';
}
}
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);
}
.mg-sticky {
height: 100%;
&__content {
will-change: height, min-height;
}
&__inner {
transform: translate3d(0, 0, 0);
will-change: position, transform;
}
}
/* 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>
);
});
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