첫 커밋

This commit is contained in:
focp212@naver.com
2025-09-05 15:36:48 +09:00
commit 05238b04c1
825 changed files with 176358 additions and 0 deletions

View 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);
}

View 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;
}

View 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;
}

View 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>
);
};

View 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>
);
};

View 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);
}
}

View 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);
}
}

View 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>
);
};

View File

@@ -0,0 +1,4 @@
export enum DIRECTION {
UP = -0b01,
DOWN = 0b01,
}