첫 커밋
This commit is contained in:
46
src/widgets/pull-to-refresh/is-scrollable.ts
Normal file
46
src/widgets/pull-to-refresh/is-scrollable.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { DIRECTION } from './types';
|
||||
|
||||
function isOverflowScrollable(element: HTMLElement): boolean {
|
||||
const overflowType: string = getComputedStyle(element).overflowY;
|
||||
if (element === document.scrollingElement && overflowType === 'visible') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (overflowType !== 'scroll' && overflowType !== 'auto') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function isScrollable(element: HTMLElement, direction: DIRECTION): boolean {
|
||||
if (!isOverflowScrollable(element)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (direction === DIRECTION.DOWN) {
|
||||
const bottomScroll = element.scrollTop + element.clientHeight;
|
||||
return bottomScroll < element.scrollHeight;
|
||||
}
|
||||
|
||||
if (direction === DIRECTION.UP) {
|
||||
return element.scrollTop > 0;
|
||||
}
|
||||
|
||||
throw new Error('unsupported direction');
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether a given element or any of its ancestors (up to rootElement) is scrollable in a given direction.
|
||||
*/
|
||||
export function isTreeScrollable(element: HTMLElement, direction: DIRECTION): boolean {
|
||||
if (isScrollable(element, direction)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (element.parentElement == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return isTreeScrollable(element.parentElement, direction);
|
||||
}
|
||||
74
src/widgets/pull-to-refresh/main.css
Normal file
74
src/widgets/pull-to-refresh/main.css
Normal file
@@ -0,0 +1,74 @@
|
||||
.ptr,
|
||||
.ptr__children {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
scroll-behavior: smooth;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.ptr.ptr--fetch-more-treshold-breached .ptr__fetch-more {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.ptr__fetch-more {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull down transition
|
||||
*/
|
||||
.ptr__children,
|
||||
.ptr__pull-down {
|
||||
transition: transform 0.2s cubic-bezier(0, 0, 0.31, 1);
|
||||
}
|
||||
|
||||
.ptr__pull-down {
|
||||
position: absolute;
|
||||
overflow: hidden;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
visibility: hidden;
|
||||
}
|
||||
.ptr__pull-down > div {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.ptr--dragging {
|
||||
/**
|
||||
* Hide PullMore content is treshold breached
|
||||
*/
|
||||
/**
|
||||
* Otherwize, display content
|
||||
*/
|
||||
}
|
||||
.ptr--dragging.ptr--pull-down-treshold-breached .ptr__pull-down--pull-more {
|
||||
display: block;
|
||||
}
|
||||
.ptr--dragging .ptr__pull-down--pull-more {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.ptr--pull-down-treshold-breached {
|
||||
/**
|
||||
* Force opacity to 1 is pull down trashold breached
|
||||
*/
|
||||
/**
|
||||
* And display loader
|
||||
*/
|
||||
}
|
||||
.ptr--pull-down-treshold-breached .ptr__pull-down {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
.ptr--pull-down-treshold-breached .ptr__pull-down--loading {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.ptr__loader {
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
}
|
||||
80
src/widgets/pull-to-refresh/main.scss
Normal file
80
src/widgets/pull-to-refresh/main.scss
Normal file
@@ -0,0 +1,80 @@
|
||||
.ptr,
|
||||
.ptr__children {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
scroll-behavior: smooth;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.ptr {
|
||||
&.ptr--fetch-more-treshold-breached {
|
||||
.ptr__fetch-more {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ptr__fetch-more {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull down transition
|
||||
*/
|
||||
.ptr__children,
|
||||
.ptr__pull-down {
|
||||
transition: transform 0.2s cubic-bezier(0, 0, 0.31, 1);
|
||||
}
|
||||
|
||||
.ptr__pull-down {
|
||||
position: absolute;
|
||||
overflow: hidden;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
visibility: hidden;
|
||||
> div {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.ptr--dragging {
|
||||
/**
|
||||
* Hide PullMore content is treshold breached
|
||||
*/
|
||||
&.ptr--pull-down-treshold-breached {
|
||||
.ptr__pull-down--pull-more {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Otherwize, display content
|
||||
*/
|
||||
.ptr__pull-down--pull-more {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.ptr--pull-down-treshold-breached {
|
||||
/**
|
||||
* Force opacity to 1 is pull down trashold breached
|
||||
*/
|
||||
.ptr__pull-down {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
/**
|
||||
* And display loader
|
||||
*/
|
||||
.ptr__pull-down--loading {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.ptr__loader {
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
}
|
||||
15
src/widgets/pull-to-refresh/pull-to-refresh-route.tsx
Normal file
15
src/widgets/pull-to-refresh/pull-to-refresh-route.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { useNavigate } from '@/shared/lib/hooks';
|
||||
import { PullToRefresh } from '@/widgets/pull-to-refresh/pull-to-refresh';
|
||||
import { useSubLayoutContext } from '@/widgets/sub-layout/use-sub-layout';
|
||||
|
||||
export const PullToRefreshRoute = () => {
|
||||
const { reload } = useNavigate();
|
||||
const subLayoutContext = useSubLayoutContext();
|
||||
|
||||
return (
|
||||
<PullToRefresh onRefresh={reload}>
|
||||
<Outlet context={{ ...subLayoutContext }} />
|
||||
</PullToRefresh>
|
||||
);
|
||||
};
|
||||
250
src/widgets/pull-to-refresh/pull-to-refresh.tsx
Normal file
250
src/widgets/pull-to-refresh/pull-to-refresh.tsx
Normal file
@@ -0,0 +1,250 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
/* eslint-disable @cspell/spellchecker */
|
||||
// import './main.scss';
|
||||
import { JSX } from 'react/jsx-runtime';
|
||||
import { DIRECTION } from './types';
|
||||
import { isTreeScrollable } from './is-scrollable';
|
||||
import { RefreshingContent } from './refreshing-content';
|
||||
import React, {
|
||||
useEffect,
|
||||
useRef
|
||||
} from 'react';
|
||||
|
||||
const MAX = 128;
|
||||
const k = 0.4;
|
||||
function appr(x: number) {
|
||||
return MAX * (1 - Math.exp((-k * x) / MAX));
|
||||
}
|
||||
|
||||
interface PullToRefreshProps {
|
||||
isPullable?: boolean;
|
||||
canFetchMore?: boolean;
|
||||
onRefresh: () => Promise<any>;
|
||||
onFetchMore?: () => Promise<any>;
|
||||
refreshingContent?: JSX.Element | string;
|
||||
children: JSX.Element;
|
||||
pullDownThreshold?: number;
|
||||
fetchMoreThreshold?: number;
|
||||
maxPullDownDistance?: number;
|
||||
backgroundColor?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const PullToRefresh: React.FC<PullToRefreshProps> = ({
|
||||
isPullable = true,
|
||||
canFetchMore = false,
|
||||
onRefresh,
|
||||
onFetchMore,
|
||||
refreshingContent = <RefreshingContent />,
|
||||
children,
|
||||
pullDownThreshold = 400,
|
||||
fetchMoreThreshold = 100,
|
||||
maxPullDownDistance = 99999, // max distance to scroll to trigger refresh
|
||||
backgroundColor,
|
||||
className = '',
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const childrenRef = useRef<HTMLDivElement>(null);
|
||||
const pullDownRef = useRef<HTMLDivElement>(null);
|
||||
const fetchMoreRef = useRef<HTMLDivElement>(null);
|
||||
let pullToRefreshThresholdBreached = false;
|
||||
let fetchMoreTresholdBreached = false; // if true, fetchMore loader is displayed
|
||||
let isDragging = false;
|
||||
let startY = 0;
|
||||
let currentY = 0;
|
||||
|
||||
useEffect(() => {
|
||||
if(!isPullable || !childrenRef?.current){
|
||||
return () => {};
|
||||
}
|
||||
const childrenEl = childrenRef.current;
|
||||
childrenEl.addEventListener('touchstart', onTouchStart, { passive: true });
|
||||
childrenEl.addEventListener('mousedown', onTouchStart);
|
||||
childrenEl.addEventListener('touchmove', onTouchMove, { passive: false });
|
||||
childrenEl.addEventListener('mousemove', onTouchMove);
|
||||
childrenEl.addEventListener('touchend', onEnd);
|
||||
childrenEl.addEventListener('mouseup', onEnd);
|
||||
document.body.addEventListener('mouseleave', onEnd);
|
||||
return () => {
|
||||
childrenEl.removeEventListener('touchstart', onTouchStart);
|
||||
childrenEl.removeEventListener('mousedown', onTouchStart);
|
||||
childrenEl.removeEventListener('touchmove', onTouchMove);
|
||||
childrenEl.removeEventListener('mousemove', onTouchMove);
|
||||
childrenEl.removeEventListener('touchend', onEnd);
|
||||
childrenEl.removeEventListener('mouseup', onEnd);
|
||||
document.body.removeEventListener('mouseleave', onEnd);
|
||||
};
|
||||
}, [children, isPullable, onRefresh, pullDownThreshold, maxPullDownDistance, canFetchMore, fetchMoreThreshold]);
|
||||
|
||||
/**
|
||||
* Check onMount / canFetchMore becomes true
|
||||
* if fetchMoreThreshold is already breached
|
||||
*/
|
||||
useEffect(() => {
|
||||
/**
|
||||
* Check if it is already in fetching more state
|
||||
*/
|
||||
if (!containerRef?.current) return;
|
||||
const isAlreadyFetchingMore = containerRef.current.classList.contains('ptr--fetch-more-treshold-breached');
|
||||
if (isAlreadyFetchingMore) return;
|
||||
/**
|
||||
* Proceed
|
||||
*/
|
||||
if (canFetchMore && getScrollToBottomValue() < fetchMoreThreshold && onFetchMore) {
|
||||
containerRef.current.classList.add('ptr--fetch-more-treshold-breached');
|
||||
fetchMoreTresholdBreached = true;
|
||||
onFetchMore().then(initContainer).catch(initContainer);
|
||||
}
|
||||
}, [canFetchMore, children]);
|
||||
|
||||
useEffect(() => {
|
||||
if (childrenRef.current) {
|
||||
childrenRef.current.style.height = isPullable ? '100%' : 'auto';
|
||||
}
|
||||
if (pullDownRef.current) {
|
||||
pullDownRef.current.style.opacity = isPullable ? '1' : '0';
|
||||
}
|
||||
if (containerRef.current) {
|
||||
containerRef.current.style.height = isPullable ? '100%' : 'auto';
|
||||
}
|
||||
}, [isPullable]);
|
||||
|
||||
/**
|
||||
* Returns distance to bottom of the container
|
||||
*/
|
||||
const getScrollToBottomValue = (): number => {
|
||||
if (!childrenRef?.current) return -1;
|
||||
const scrollTop = window.scrollY; // is the pixels hidden in top due to the scroll. With no scroll its value is 0.
|
||||
const { scrollHeight } = childrenRef.current; // is the pixels of the whole container
|
||||
return scrollHeight - scrollTop - window.innerHeight;
|
||||
};
|
||||
|
||||
const initContainer = (): void => {
|
||||
requestAnimationFrame(() => {
|
||||
/**
|
||||
* Reset Styles
|
||||
*/
|
||||
|
||||
if (childrenRef.current) {
|
||||
childrenRef.current.style.overflowX = 'hidden';
|
||||
childrenRef.current.style.overflowY = 'auto';
|
||||
childrenRef.current.style.transform = `unset`;
|
||||
childrenRef.current.style.height = isPullable ? '100%' : 'auto';
|
||||
}
|
||||
if (pullDownRef.current) {
|
||||
pullDownRef.current.style.opacity = isPullable ? '1' : '0';
|
||||
}
|
||||
if (containerRef.current) {
|
||||
containerRef.current.style.height = isPullable ? '100%' : 'auto';
|
||||
containerRef.current.classList.remove('ptr--pull-down-treshold-breached');
|
||||
containerRef.current.classList.remove('ptr--dragging');
|
||||
containerRef.current.classList.remove('ptr--fetch-more-treshold-breached');
|
||||
}
|
||||
|
||||
if (pullToRefreshThresholdBreached) pullToRefreshThresholdBreached = false;
|
||||
if (fetchMoreTresholdBreached) fetchMoreTresholdBreached = false;
|
||||
});
|
||||
};
|
||||
|
||||
const onTouchStart = (e: MouseEvent | TouchEvent): void => {
|
||||
isDragging = false;
|
||||
if (e instanceof MouseEvent) {
|
||||
startY = e.pageY;
|
||||
}
|
||||
|
||||
if (window.TouchEvent && e instanceof TouchEvent) {
|
||||
startY = e.touches?.[0]?.pageY ?? 0;
|
||||
}
|
||||
|
||||
currentY = startY;
|
||||
|
||||
// Check if element can be scrolled
|
||||
if (e.type === 'touchstart' && isTreeScrollable(e.target as HTMLElement, DIRECTION.UP)) {
|
||||
return;
|
||||
}
|
||||
// Top non visible so cancel
|
||||
if (childrenRef.current!.getBoundingClientRect().top < 0) {
|
||||
return;
|
||||
}
|
||||
isDragging = true;
|
||||
};
|
||||
|
||||
const onTouchMove = (e: MouseEvent | TouchEvent): void => {
|
||||
if (!isDragging) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (window.TouchEvent && e instanceof TouchEvent) {
|
||||
currentY = e.touches?.[0]?.pageY ?? 0;
|
||||
} else {
|
||||
currentY = (e as MouseEvent).pageY;
|
||||
}
|
||||
|
||||
containerRef.current!.classList.add('ptr--dragging');
|
||||
|
||||
if (currentY < startY) {
|
||||
isDragging = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.cancelable) {
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
const yDistanceMoved = Math.min(currentY - startY, maxPullDownDistance);
|
||||
|
||||
// Limit to trigger refresh has been breached
|
||||
if (yDistanceMoved >= pullDownThreshold) {
|
||||
isDragging = true;
|
||||
pullToRefreshThresholdBreached = true;
|
||||
containerRef.current!.classList.remove('ptr--dragging');
|
||||
containerRef.current!.classList.add('ptr--pull-down-treshold-breached');
|
||||
}
|
||||
|
||||
// maxPullDownDistance breached, stop the animation
|
||||
if (yDistanceMoved >= maxPullDownDistance) {
|
||||
return;
|
||||
}
|
||||
|
||||
// pullDownRef.current!.style.opacity = (yDistanceMoved / 65).toString();
|
||||
childrenRef.current!.style.overflow = 'visible';
|
||||
childrenRef.current!.style.transform = `translate(0px, ${appr(yDistanceMoved)}px)`;
|
||||
pullDownRef.current!.style.visibility = 'visible';
|
||||
};
|
||||
|
||||
const onEnd = (): void => {
|
||||
isDragging = false;
|
||||
startY = 0;
|
||||
currentY = 0;
|
||||
|
||||
// Container has not been dragged enough, put it back to it's initial state
|
||||
if (!pullToRefreshThresholdBreached) {
|
||||
if (pullDownRef.current) pullDownRef.current.style.visibility = 'visible';
|
||||
initContainer();
|
||||
return;
|
||||
}
|
||||
|
||||
if (childrenRef.current) {
|
||||
childrenRef.current.style.overflow = 'visible';
|
||||
childrenRef.current.style.transform = `translate(0px, ${pullDownThreshold}px)`;
|
||||
}
|
||||
|
||||
onRefresh().then(initContainer).catch(initContainer);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`ptr ${className}`} style={{ backgroundColor }} ref={containerRef}>
|
||||
{/*
|
||||
<div className="ptr__pull-down" ref={pullDownRef}>
|
||||
<div className="ptr__loader ptr__pull-down--loading">{refreshingContent}</div>
|
||||
</div>
|
||||
*/}
|
||||
<div className="ptr__children" ref={childrenRef}>
|
||||
{children}
|
||||
<div className="ptr__fetch-more" ref={fetchMoreRef}>
|
||||
<div className="ptr__loader ptr__fetch-more--loading">{refreshingContent}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
61
src/widgets/pull-to-refresh/refreshing-content.css
Normal file
61
src/widgets/pull-to-refresh/refreshing-content.css
Normal file
@@ -0,0 +1,61 @@
|
||||
.lds-ellipsis {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
}
|
||||
|
||||
.lds-ellipsis div {
|
||||
position: absolute;
|
||||
top: 27px;
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
border-radius: 50%;
|
||||
background: rgb(54, 54, 54);
|
||||
animation-timing-function: cubic-bezier(0, 1, 1, 0);
|
||||
}
|
||||
|
||||
.lds-ellipsis div:nth-child(1) {
|
||||
left: 6px;
|
||||
animation: lds-ellipsis1 0.6s infinite;
|
||||
}
|
||||
|
||||
.lds-ellipsis div:nth-child(2) {
|
||||
left: 6px;
|
||||
animation: lds-ellipsis2 0.6s infinite;
|
||||
}
|
||||
|
||||
.lds-ellipsis div:nth-child(3) {
|
||||
left: 26px;
|
||||
animation: lds-ellipsis2 0.6s infinite;
|
||||
}
|
||||
|
||||
.lds-ellipsis div:nth-child(4) {
|
||||
left: 45px;
|
||||
animation: lds-ellipsis3 0.6s infinite;
|
||||
}
|
||||
|
||||
@keyframes lds-ellipsis1 {
|
||||
0% {
|
||||
transform: scale(0);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
@keyframes lds-ellipsis3 {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
}
|
||||
100% {
|
||||
transform: scale(0);
|
||||
}
|
||||
}
|
||||
@keyframes lds-ellipsis2 {
|
||||
0% {
|
||||
transform: translate(0, 0);
|
||||
}
|
||||
100% {
|
||||
transform: translate(19px, 0);
|
||||
}
|
||||
}
|
||||
55
src/widgets/pull-to-refresh/refreshing-content.scss
Normal file
55
src/widgets/pull-to-refresh/refreshing-content.scss
Normal file
@@ -0,0 +1,55 @@
|
||||
.lds-ellipsis {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
}
|
||||
.lds-ellipsis div {
|
||||
position: absolute;
|
||||
top: 27px;
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
border-radius: 50%;
|
||||
background: rgb(54, 54, 54);
|
||||
animation-timing-function: cubic-bezier(0, 1, 1, 0);
|
||||
}
|
||||
.lds-ellipsis div:nth-child(1) {
|
||||
left: 6px;
|
||||
animation: lds-ellipsis1 0.6s infinite;
|
||||
}
|
||||
.lds-ellipsis div:nth-child(2) {
|
||||
left: 6px;
|
||||
animation: lds-ellipsis2 0.6s infinite;
|
||||
}
|
||||
.lds-ellipsis div:nth-child(3) {
|
||||
left: 26px;
|
||||
animation: lds-ellipsis2 0.6s infinite;
|
||||
}
|
||||
.lds-ellipsis div:nth-child(4) {
|
||||
left: 45px;
|
||||
animation: lds-ellipsis3 0.6s infinite;
|
||||
}
|
||||
@keyframes lds-ellipsis1 {
|
||||
0% {
|
||||
transform: scale(0);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
@keyframes lds-ellipsis3 {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
}
|
||||
100% {
|
||||
transform: scale(0);
|
||||
}
|
||||
}
|
||||
@keyframes lds-ellipsis2 {
|
||||
0% {
|
||||
transform: translate(0, 0);
|
||||
}
|
||||
100% {
|
||||
transform: translate(19px, 0);
|
||||
}
|
||||
}
|
||||
14
src/widgets/pull-to-refresh/refreshing-content.tsx
Normal file
14
src/widgets/pull-to-refresh/refreshing-content.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import './refreshing-content.scss';
|
||||
|
||||
// Source: https://loading.io/css/
|
||||
|
||||
export const RefreshingContent = () => {
|
||||
return (
|
||||
<div className="lds-ellipsis">
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
4
src/widgets/pull-to-refresh/types.ts
Normal file
4
src/widgets/pull-to-refresh/types.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export enum DIRECTION {
|
||||
UP = -0b01,
|
||||
DOWN = 0b01,
|
||||
}
|
||||
Reference in New Issue
Block a user