첫 커밋
This commit is contained in:
18
src/widgets/error-boundaries/common-error-boundary.tsx
Normal file
18
src/widgets/error-boundaries/common-error-boundary.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { useQueryErrorResetBoundary } from '@tanstack/react-query';
|
||||
import { PropsWithChildren } from 'react';
|
||||
import { ErrorBoundary, FallbackProps } from 'react-error-boundary';
|
||||
|
||||
type Props = PropsWithChildren<{
|
||||
FallbackComponent: React.ComponentType<FallbackProps>;
|
||||
}>;
|
||||
|
||||
export const CommonErrorBoundary = (props: Props) => {
|
||||
const { children, FallbackComponent } = props;
|
||||
const { reset } = useQueryErrorResetBoundary();
|
||||
|
||||
return (
|
||||
<ErrorBoundary FallbackComponent={FallbackComponent} onReset={reset}>
|
||||
{children}
|
||||
</ErrorBoundary>
|
||||
);
|
||||
};
|
||||
41
src/widgets/error-boundaries/global-api-error-boundary.tsx
Normal file
41
src/widgets/error-boundaries/global-api-error-boundary.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { useQueryErrorResetBoundary } from '@tanstack/react-query';
|
||||
import { PropsWithChildren } from 'react';
|
||||
import { ErrorBoundary, FallbackProps } from 'react-error-boundary';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
import { APIError } from '@/widgets/fallbacks/api-error';
|
||||
import { KickOutError } from '@/widgets/fallbacks/kick-out-error';
|
||||
import { checkIsAxiosError, checkIsKickOutError } from '@/shared/lib/error';
|
||||
|
||||
/**
|
||||
* 케이스별로 Fallback 컴포넌트를 반환하는 컴포넌트 (api에러가 아니면 상위 에러 바운더리로 위임)
|
||||
* - kickout
|
||||
* - force update
|
||||
* - maintenance
|
||||
* - network
|
||||
* - common
|
||||
*/
|
||||
function FallbackComponent({ error, resetErrorBoundary }: FallbackProps) {
|
||||
// api 에러가 아닌 경우 상위 에러 바운더리로 위임
|
||||
if (!checkIsAxiosError(error)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (checkIsKickOutError(error)) {
|
||||
return <KickOutError error={error} />;
|
||||
}
|
||||
|
||||
return <APIError error={error} resetErrorBoundary={resetErrorBoundary} />;
|
||||
}
|
||||
|
||||
type Props = PropsWithChildren;
|
||||
|
||||
export const GlobalAPIErrorBoundary = ({ children }: Props) => {
|
||||
const { reset } = useQueryErrorResetBoundary();
|
||||
const { key } = useLocation();
|
||||
return (
|
||||
<ErrorBoundary onReset={reset} resetKeys={[key]} FallbackComponent={FallbackComponent}>
|
||||
{children}
|
||||
</ErrorBoundary>
|
||||
);
|
||||
};
|
||||
10
src/widgets/error-boundaries/global-error-boundary.tsx
Normal file
10
src/widgets/error-boundaries/global-error-boundary.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { PropsWithChildren } from 'react';
|
||||
|
||||
import { CommonErrorBoundary } from '@/widgets/error-boundaries/common-error-boundary';
|
||||
import { CommonError } from '@/widgets/fallbacks/common-error';
|
||||
|
||||
type Props = PropsWithChildren;
|
||||
|
||||
export const GlobalErrorBoundary = ({ children }: Props) => {
|
||||
return <CommonErrorBoundary FallbackComponent={CommonError}>{children}</CommonErrorBoundary>;
|
||||
};
|
||||
2
src/widgets/error-boundaries/index.ts
Normal file
2
src/widgets/error-boundaries/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { GlobalAPIErrorBoundary } from './global-api-error-boundary';
|
||||
export { GlobalErrorBoundary } from './global-error-boundary';
|
||||
39
src/widgets/fallbacks/api-error.tsx
Normal file
39
src/widgets/fallbacks/api-error.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { useMemo } from 'react';
|
||||
import { FallbackProps } from 'react-error-boundary';
|
||||
import { useNavigate } from '@/shared/lib/hooks/use-navigate';
|
||||
|
||||
import { Dialog, DialogProps } from '@/shared/ui/dialogs/dialog';
|
||||
|
||||
type CommonErrorProps = FallbackProps & {
|
||||
height?: number;
|
||||
};
|
||||
export const APIError = ({ error, resetErrorBoundary }: CommonErrorProps) => {
|
||||
const { navigateBack } = useNavigate();
|
||||
const msg = useMemo(() => {
|
||||
let message: Partial<DialogProps> = {
|
||||
title: '일시적인 오류가 발생하였습니다.',
|
||||
message: '잠시 후 다시 시도해주세요.',
|
||||
};
|
||||
if (error?.response?.data?.message) {
|
||||
message = { message: error.response.data.message };
|
||||
}
|
||||
return message;
|
||||
}, [error]);
|
||||
|
||||
const handleCancel = () => {
|
||||
navigateBack();
|
||||
resetErrorBoundary();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
afterLeave={() => null}
|
||||
open={true}
|
||||
onClose={() => null}
|
||||
onConfirmClick={resetErrorBoundary}
|
||||
onCancelClick={handleCancel}
|
||||
message={msg.message}
|
||||
buttonLabel={['취소', '재시도']}
|
||||
/>
|
||||
);
|
||||
};
|
||||
35
src/widgets/fallbacks/common-error.tsx
Normal file
35
src/widgets/fallbacks/common-error.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { FallbackProps } from 'react-error-boundary';
|
||||
|
||||
import { Dialog, DialogProps } from '@/shared/ui/dialogs/dialog';
|
||||
|
||||
type CommonErrorProps = FallbackProps & {
|
||||
height?: number;
|
||||
};
|
||||
export const CommonError = ({ error, resetErrorBoundary }: CommonErrorProps) => {
|
||||
const [isOpen, setIsOpen] = useState(true);
|
||||
const msg = useMemo(() => {
|
||||
let message: Partial<DialogProps> = {
|
||||
title: '일시적인 오류가 발생하였습니다.',
|
||||
message: '잠시 후 다시 시도해주세요.',
|
||||
};
|
||||
if (error?.response?.data?.message) {
|
||||
message = { message: error.response.data.message };
|
||||
}
|
||||
return message;
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
afterLeave={() => null}
|
||||
open={isOpen}
|
||||
onClose={() => setIsOpen(false)}
|
||||
onConfirmClick={resetErrorBoundary}
|
||||
onCancelClick={() => {
|
||||
location.href = '/';
|
||||
}}
|
||||
message={msg.message}
|
||||
buttonLabel={['취소', '재시도']}
|
||||
/>
|
||||
);
|
||||
};
|
||||
2
src/widgets/fallbacks/index.ts
Normal file
2
src/widgets/fallbacks/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { CommonError } from './common-error';
|
||||
export { NotFoundError } from './not-found-error';
|
||||
42
src/widgets/fallbacks/kick-out-error.tsx
Normal file
42
src/widgets/fallbacks/kick-out-error.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
/* eslint-disable @cspell/spellchecker */
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { CBDCAxiosFallbackProps } from '@/shared/@types/error';
|
||||
import { Dialog, DialogProps } from '@/shared/ui/dialogs/dialog';
|
||||
|
||||
export const KickOutError = ({ error, resetErrorBoundary }: CBDCAxiosFallbackProps) => {
|
||||
useEffect(() => {
|
||||
console.error('[ErrorBoundary] Kickout Error', JSON.stringify(error.response?.data));
|
||||
// clearAuthData();
|
||||
}, [error]);
|
||||
|
||||
const msg = useMemo(() => {
|
||||
let message: Partial<DialogProps> = {
|
||||
title: '일시적인 오류가 발생하였습니다.',
|
||||
message: '잠시 후 다시 시도해주세요.',
|
||||
};
|
||||
if (error?.response?.data?.message) {
|
||||
message = { message: error.response.data.message };
|
||||
}
|
||||
return message;
|
||||
}, [error]);
|
||||
|
||||
const handleCancel = () => {
|
||||
resetErrorBoundary?.();
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
resetErrorBoundary?.();
|
||||
// kickOut();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
afterLeave={() => null}
|
||||
open={true}
|
||||
onClose={() => null}
|
||||
onConfirmClick={handleConfirm}
|
||||
onCancelClick={handleCancel}
|
||||
message={msg.message}
|
||||
/>
|
||||
);
|
||||
};
|
||||
10
src/widgets/fallbacks/not-found-error.tsx
Normal file
10
src/widgets/fallbacks/not-found-error.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
export const NotFoundError = () => {
|
||||
return (
|
||||
<div className="blank">
|
||||
<div className="blank-inner-box no-auth">
|
||||
<div className="icon">ICON</div>
|
||||
<p>접근할 수 없는 페이지입니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
15
src/widgets/ios-status-bar/index.tsx
Normal file
15
src/widgets/ios-status-bar/index.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Fragment } from 'react/jsx-runtime';
|
||||
import { useAppColor } from '@/shared/lib/hooks/use-change-bg-color';
|
||||
|
||||
export const IOSStatusBar = () => {
|
||||
const [appColor] = useAppColor();
|
||||
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
|
||||
|
||||
if(!isIOS){
|
||||
return <Fragment></Fragment>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ios-status-bar" style={{ backgroundColor: appColor }}></div>
|
||||
);
|
||||
};
|
||||
17
src/widgets/loadable/index.tsx
Normal file
17
src/widgets/loadable/index.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Suspense } from 'react';
|
||||
|
||||
import { Loading } from '@/widgets/loading/loading';
|
||||
|
||||
export const Loadable = (Component: React.ComponentType<any>) => {
|
||||
const LoadableComponent = (props: any) => {
|
||||
return (
|
||||
<Suspense fallback={<Loading />}>
|
||||
<Component {...props} />
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
|
||||
LoadableComponent.displayName = `Loadable(${Component.displayName || Component.name || 'Component'})`;
|
||||
|
||||
return LoadableComponent;
|
||||
};
|
||||
13
src/widgets/loading/loading.tsx
Normal file
13
src/widgets/loading/loading.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import LoadingImg from '@/shared/ui/assets/img/loading/loading.gif';
|
||||
|
||||
export const Loading = () => {
|
||||
return (
|
||||
<div className="loading">
|
||||
<div className="inner-container">
|
||||
<div className="loading-box">
|
||||
<img src={LoadingImg} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
22
src/widgets/more-menu/more-menu.tsx
Normal file
22
src/widgets/more-menu/more-menu.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { useClickAway } from 'react-use';
|
||||
|
||||
interface MoreMenuProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
export const MoreMenu = ({ children }: MoreMenuProps) => {
|
||||
const [isOnMoreMenu, setIsOnMoreMenu] = useState(false);
|
||||
const ref = useRef(null);
|
||||
useClickAway(ref, () => {
|
||||
setIsOnMoreMenu(false);
|
||||
});
|
||||
|
||||
return (
|
||||
<div ref={ref}>
|
||||
<button type="button" className="btn top-more-btn" onClick={() => setIsOnMoreMenu((prev) => !prev)}>
|
||||
<span>더보기 버튼</span>
|
||||
</button>
|
||||
{isOnMoreMenu && <div className="more-menu on">{children}</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
101
src/widgets/navigation/footer.tsx
Normal file
101
src/widgets/navigation/footer.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import { PATHS } from '@/shared/constants/paths';
|
||||
import { IMAGE_ROOT } from '@/shared/constants/common';
|
||||
import { useNavigate } from '@/shared/lib/hooks/use-navigate';
|
||||
import {
|
||||
FooterProps,
|
||||
FooterItemActiveKey
|
||||
} from '@/entities/common/model/types';
|
||||
|
||||
export const FooterNavigation = ({
|
||||
setMenuOn,
|
||||
footerCurrentPage
|
||||
}: FooterProps) => {
|
||||
const { navigate } = useNavigate();
|
||||
|
||||
const onClickToNavigate = (path?: string) => {
|
||||
if(!!path){
|
||||
navigate(path);
|
||||
}
|
||||
};
|
||||
const onClickToOpenMenu = () => {
|
||||
setMenuOn(true);
|
||||
};
|
||||
|
||||
const buttonItems = [
|
||||
{
|
||||
activeIcon: IMAGE_ROOT + '/home-active.svg',
|
||||
inactiveIcon: IMAGE_ROOT + '/home.svg',
|
||||
path: PATHS.home,
|
||||
activeKey: FooterItemActiveKey.Home,
|
||||
title: '홈'
|
||||
},
|
||||
{
|
||||
activeIcon: IMAGE_ROOT + '/chart-active.svg',
|
||||
inactiveIcon: IMAGE_ROOT + '/chart.svg',
|
||||
path: PATHS.transaction.allTransaction.list,
|
||||
activeKey: FooterItemActiveKey.Transaction,
|
||||
title: '거래조회'
|
||||
},
|
||||
{
|
||||
activeIcon: IMAGE_ROOT + '/money-active.svg',
|
||||
inactiveIcon: IMAGE_ROOT + '/money.svg',
|
||||
path: PATHS.settlement.list,
|
||||
activeKey: FooterItemActiveKey.Settlement,
|
||||
title: '정산내역'
|
||||
},
|
||||
{
|
||||
activeIcon: IMAGE_ROOT + '/users-active.svg',
|
||||
inactiveIcon: IMAGE_ROOT + '/users.svg',
|
||||
path: PATHS.account.user.manage,
|
||||
activeKey: FooterItemActiveKey.Account,
|
||||
title: '사용자관리'
|
||||
},
|
||||
{
|
||||
activeIcon: IMAGE_ROOT + '/more-active.svg',
|
||||
inactiveIcon: IMAGE_ROOT + '/more.svg',
|
||||
title: '더보기'
|
||||
},
|
||||
];
|
||||
|
||||
const getFooterButtonItems = () => {
|
||||
let rs = [];
|
||||
for(let i=0;i<buttonItems.length;i++){
|
||||
rs.push(
|
||||
<button
|
||||
key={ `footer-button-item-${i}` }
|
||||
className={ `tab-button ${((footerCurrentPage && footerCurrentPage === buttonItems[i]?.activeKey)? 'active': '')}` }
|
||||
data-tab={ `tab-${i}` }
|
||||
data-active-icon={ buttonItems[i]?.activeIcon }
|
||||
data-inactive-icon={ buttonItems[i]?.inactiveIcon }
|
||||
onClick={ () => {
|
||||
if(!!buttonItems[i]?.path){
|
||||
onClickToNavigate(buttonItems[i]?.path);
|
||||
}
|
||||
else{
|
||||
onClickToOpenMenu();
|
||||
}
|
||||
}
|
||||
}
|
||||
>
|
||||
<div className="tab-icon">
|
||||
<img
|
||||
className="tab-icon-img"
|
||||
src={ (footerCurrentPage && footerCurrentPage === buttonItems[i]?.activeKey)? buttonItems[i]?.activeIcon: buttonItems[i]?.inactiveIcon }
|
||||
alt={ buttonItems[i]?.title }
|
||||
/>
|
||||
</div>
|
||||
<span className="tab-text">{ buttonItems[i]?.title }</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
return rs;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<nav className="bottom-tabbar">
|
||||
{ getFooterButtonItems() }
|
||||
</nav>
|
||||
</>
|
||||
);
|
||||
};
|
||||
138
src/widgets/navigation/header.tsx
Normal file
138
src/widgets/navigation/header.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import { useNavigate } from '@/shared/lib/hooks/use-navigate';
|
||||
import { Menu } from '@/shared/ui/menu';
|
||||
import { IMAGE_ROOT } from '@/shared/constants/common';
|
||||
import { PATHS } from '@/shared/constants/paths';
|
||||
import {
|
||||
HeaderType,
|
||||
HeaderNavigationProps
|
||||
} from '@/entities/common/model/types';
|
||||
|
||||
export const HeaderNavigation = ({
|
||||
onBack,
|
||||
headerTitle,
|
||||
menuOn,
|
||||
headerType,
|
||||
setMenuOn
|
||||
}: HeaderNavigationProps) => {
|
||||
const {
|
||||
navigate,
|
||||
navigateBack
|
||||
} = useNavigate();
|
||||
|
||||
const handleBack = () => {
|
||||
if(onBack){
|
||||
onBack();
|
||||
}
|
||||
else{
|
||||
navigateBack();
|
||||
}
|
||||
};
|
||||
const handleClose = () => {
|
||||
if(onBack){
|
||||
onBack();
|
||||
}
|
||||
else{
|
||||
navigateBack();
|
||||
}
|
||||
};
|
||||
const onClickToGoToAlarm = () => {
|
||||
navigate(PATHS.alarm.list);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{
|
||||
(headerType === HeaderType.Home
|
||||
|| headerType === HeaderType.Alim
|
||||
|| headerType === HeaderType.LeftArrow
|
||||
) &&
|
||||
<Menu
|
||||
menuOn={ menuOn }
|
||||
setMenuOn={ setMenuOn }
|
||||
></Menu>
|
||||
}
|
||||
{
|
||||
(headerType !== HeaderType.NoHeader) &&
|
||||
<header className="header">
|
||||
{
|
||||
// 홈에서만 적용
|
||||
(headerType === HeaderType.Home) &&
|
||||
<div className="header-content">
|
||||
<div className="header-left">
|
||||
<h1 className="app-title blind">나이스가맹점관리자</h1>
|
||||
<div className="input-wrapper">
|
||||
<select className="heading-select">
|
||||
<option value="1">madzoneviper</option>
|
||||
<option value="2">stormflarewolf</option>
|
||||
<option value="3">blazefangco</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="header-right">
|
||||
<button
|
||||
className="header-btn notification-btn"
|
||||
onClick={ () => onClickToGoToAlarm() }
|
||||
>
|
||||
<span className="notification-icon"></span>
|
||||
<span className="notification-badge"></span>
|
||||
</button>
|
||||
{
|
||||
/*
|
||||
<button className="header-btn profile-btn" id="profileBtn">
|
||||
<span className="profile-icon">👤</span>
|
||||
</button>
|
||||
*/
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
{
|
||||
(headerType === HeaderType.Alim) &&
|
||||
<div className="header-content sub">
|
||||
<div className="header-center">{ headerTitle }</div>
|
||||
<div className="header-right">
|
||||
<button
|
||||
className="header-btn notification-btn"
|
||||
onClick={ () => onClickToGoToAlarm() }
|
||||
>
|
||||
<span className="notification-icon"></span>
|
||||
<span className="notification-badge"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
{
|
||||
(headerType === HeaderType.LeftArrow) &&
|
||||
<div className="header-content">
|
||||
<div className="header-left">
|
||||
<button className="header-btn">
|
||||
<img
|
||||
src={ IMAGE_ROOT + '/ico_back.svg' }
|
||||
alt="뒤로가기"
|
||||
onClick={ () => handleBack() }
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<div className="header-center">{ headerTitle }</div>
|
||||
</div>
|
||||
}
|
||||
{
|
||||
(headerType === HeaderType.RightClose) &&
|
||||
<div className="header-content sub">
|
||||
<div className="header-center">{ headerTitle }</div>
|
||||
<div className="header-right">
|
||||
<button className="header-btn">
|
||||
<img
|
||||
src={ IMAGE_ROOT + '/ico_close.svg' }
|
||||
alt="닫기"
|
||||
onClick={ () => handleClose() }
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</header>
|
||||
}
|
||||
</>
|
||||
);
|
||||
};
|
||||
14
src/widgets/protected-route/index.tsx
Normal file
14
src/widgets/protected-route/index.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { StorageKeys } from '@/shared/constants/local-storage';
|
||||
import { getLocalStorage } from '@/shared/lib';
|
||||
import { useSubLayoutContext } from '@/widgets/sub-layout/use-sub-layout';
|
||||
|
||||
export const ProtectedRoute = () => {
|
||||
const subLayoutContext = useSubLayoutContext();
|
||||
|
||||
const token = getLocalStorage(StorageKeys.AccessToken);
|
||||
// return token ? <Outlet context={{ ...subLayoutContext }} /> : <Navigate to={PATHS.start} replace />;
|
||||
return (
|
||||
<Outlet context={{ ...subLayoutContext }} />
|
||||
);
|
||||
};
|
||||
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,
|
||||
}
|
||||
17
src/widgets/splash/splash.tsx
Normal file
17
src/widgets/splash/splash.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import intro from '@/shared/ui/assets/img/lottie/intro.json';
|
||||
import { Player } from '@lottiefiles/react-lottie-player';
|
||||
|
||||
export const Splash = () => {
|
||||
return (
|
||||
<div className="intro">
|
||||
<Player
|
||||
src={intro}
|
||||
background="transparent"
|
||||
style={{ width: '150px', height: '150px' }}
|
||||
autoplay={true}
|
||||
speed={1}
|
||||
loop
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
71
src/widgets/sub-layout/index.tsx
Normal file
71
src/widgets/sub-layout/index.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import {
|
||||
Fragment,
|
||||
useState
|
||||
} from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { HeaderNavigation } from '@/widgets/navigation/header';
|
||||
import { FooterNavigation } from '@/widgets/navigation/footer';
|
||||
import { PullToRefresh } from '@/widgets/pull-to-refresh/pull-to-refresh';
|
||||
import { useNavigate } from '@/shared/lib/hooks';
|
||||
import { useScrollToTop } from '@/shared/lib/hooks/use-scroll-to-top';
|
||||
import { HeaderType } from '@/entities/common/model/types';
|
||||
export interface ContextType {
|
||||
setOnBack: (onBack: () => void) => void;
|
||||
setHeaderTitle: (title: string) => void;
|
||||
setIsPullToRefreshEnabled: (enabled: boolean) => void;
|
||||
setMenuOn:(on: boolean) => void;
|
||||
setHeaderType: (headerType: HeaderType) => void;
|
||||
setFooterMode:(footMode: boolean) => void;
|
||||
setFooterCurrentPage:(currentPage?: string | null) => void;
|
||||
};
|
||||
|
||||
export const SubLayout = () => {
|
||||
const { reload } = useNavigate();
|
||||
useScrollToTop();
|
||||
const [onBack, setOnBack] = useState(undefined);
|
||||
const [headerTitle, setHeaderTitle] = useState<string>('');
|
||||
const [isPullToRefreshEnabled, setIsPullToRefreshEnabled] = useState<boolean>(false);
|
||||
const [menuOn, setMenuOn] = useState<boolean>(false);
|
||||
const [headerType, setHeaderType] = useState<HeaderType>(HeaderType.NoHeader);
|
||||
const [footerMode, setFooterMode] = useState<boolean>(false);
|
||||
const [footerCurrentPage, setFooterCurrentPage] = useState<undefined | string | null>(undefined);
|
||||
|
||||
const wrapperClassName = 'wrapper';
|
||||
|
||||
return (
|
||||
<PullToRefresh
|
||||
className={ wrapperClassName }
|
||||
onRefresh={ reload }
|
||||
isPullable={ isPullToRefreshEnabled }
|
||||
>
|
||||
<Fragment>
|
||||
<HeaderNavigation
|
||||
onBack={ onBack }
|
||||
headerTitle={ headerTitle }
|
||||
menuOn={ menuOn }
|
||||
setMenuOn={ setMenuOn }
|
||||
headerType={ headerType }
|
||||
/>
|
||||
<Outlet
|
||||
context={{
|
||||
setOnBack,
|
||||
setHeaderTitle,
|
||||
setIsPullToRefreshEnabled,
|
||||
setMenuOn,
|
||||
setHeaderType,
|
||||
setFooterMode,
|
||||
setFooterCurrentPage
|
||||
}}
|
||||
/>
|
||||
{
|
||||
footerMode &&
|
||||
<FooterNavigation
|
||||
setMenuOn={ setMenuOn }
|
||||
footerCurrentPage={ footerCurrentPage }
|
||||
></FooterNavigation>
|
||||
}
|
||||
|
||||
</Fragment>
|
||||
</PullToRefresh>
|
||||
);
|
||||
};
|
||||
75
src/widgets/sub-layout/use-sub-layout.ts
Normal file
75
src/widgets/sub-layout/use-sub-layout.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
import { ReactNode, useEffect } from 'react';
|
||||
import { useOutletContext } from 'react-router';
|
||||
import { ContextType } from '.';
|
||||
import { FooterItemActiveKey } from '@/entities/common/model/types';
|
||||
import { HeaderType } from '@/entities/common/model/types';
|
||||
|
||||
export const useSubLayoutContext = () => {
|
||||
return useOutletContext<ContextType>();
|
||||
};
|
||||
|
||||
export const useSetOnBack = (fn: any) => {
|
||||
const { setOnBack } = useSubLayoutContext();
|
||||
useEffect(() => {
|
||||
setOnBack(() => fn);
|
||||
return () => {
|
||||
setOnBack(() => undefined);
|
||||
};
|
||||
}, [setOnBack]);
|
||||
return { setOnBack };
|
||||
};
|
||||
|
||||
export const useSetHeaderTitle = (title: string) => {
|
||||
const { setHeaderTitle } = useSubLayoutContext();
|
||||
useEffect(() => {
|
||||
setHeaderTitle(title);
|
||||
return () => setHeaderTitle('');
|
||||
}, [setHeaderTitle]);
|
||||
return { setHeaderTitle };
|
||||
};
|
||||
|
||||
export const useSetIsPullToRefreshEnabled = (enabled: boolean) => {
|
||||
const { setIsPullToRefreshEnabled } = useSubLayoutContext();
|
||||
useEffect(() => {
|
||||
setIsPullToRefreshEnabled(enabled);
|
||||
return () => setIsPullToRefreshEnabled(false);
|
||||
}, [setIsPullToRefreshEnabled, enabled]);
|
||||
return { setIsPullToRefreshEnabled };
|
||||
};
|
||||
|
||||
export const useSetMenuOn = (on: boolean) => {
|
||||
const { setMenuOn } = useSubLayoutContext();
|
||||
useEffect(() => {
|
||||
setMenuOn(on);
|
||||
return () => setMenuOn(false);
|
||||
}, [setMenuOn, on]);
|
||||
return { setMenuOn };
|
||||
};
|
||||
|
||||
export const useSetHeaderType = (headerType: HeaderType) => {
|
||||
const { setHeaderType } = useSubLayoutContext();
|
||||
useEffect(() => {
|
||||
setHeaderType(headerType);
|
||||
return () => setHeaderType(HeaderType.NoHeader);
|
||||
}, [setHeaderType]);
|
||||
return { setHeaderType };
|
||||
};
|
||||
|
||||
export const useSetFooterMode = (footerMode: boolean) => {
|
||||
const { setFooterMode } = useSubLayoutContext();
|
||||
useEffect(() => {
|
||||
setFooterMode(footerMode);
|
||||
return () => setFooterMode(false);
|
||||
}, [setFooterMode, footerMode]);
|
||||
return { setFooterMode };
|
||||
};
|
||||
|
||||
export const useSetFooterCurrentPage = (footerCurrentPage?: FooterItemActiveKey | null) => {
|
||||
const { setFooterCurrentPage } = useSubLayoutContext();
|
||||
useEffect(() => {
|
||||
setFooterCurrentPage(footerCurrentPage);
|
||||
return () => setFooterCurrentPage(undefined);
|
||||
}, [setFooterCurrentPage, footerCurrentPage]);
|
||||
return { setFooterCurrentPage };
|
||||
};
|
||||
21
src/widgets/top-button/handle-event-listeners.ts
Normal file
21
src/widgets/top-button/handle-event-listeners.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
interface EventListeners {
|
||||
element: HTMLElement | null;
|
||||
onScroll: () => void;
|
||||
onTouchStart: () => void;
|
||||
onTouchMove: () => void;
|
||||
onTouchEnd: () => void;
|
||||
}
|
||||
|
||||
export const addEventListeners = ({ element, onScroll, onTouchStart, onTouchMove, onTouchEnd }: EventListeners) => {
|
||||
element?.addEventListener('scroll', onScroll);
|
||||
element?.addEventListener('touchstart', onTouchStart, { passive: true });
|
||||
element?.addEventListener('touchmove', onTouchMove, { passive: true });
|
||||
element?.addEventListener('touchend', onTouchEnd, { passive: true });
|
||||
};
|
||||
|
||||
export const removeEventListeners = ({ element, onScroll, onTouchStart, onTouchMove, onTouchEnd }: EventListeners) => {
|
||||
element?.removeEventListener('scroll', onScroll);
|
||||
element?.removeEventListener('touchstart', onTouchStart);
|
||||
element?.removeEventListener('touchmove', onTouchMove);
|
||||
element?.removeEventListener('touchend', onTouchEnd);
|
||||
};
|
||||
103
src/widgets/top-button/index.tsx
Normal file
103
src/widgets/top-button/index.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
import { AnimatePresence, motion, useMotionValue } from 'framer-motion';
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
|
||||
import { useRouteChangeEffect } from './use-route-change-effect';
|
||||
import { useScrollEventListeners } from './use-scroll-event-listeners';
|
||||
import { useScrollTarget } from './use-scroll-target';
|
||||
|
||||
const TIME_OUT = 5000;
|
||||
const THRASH_HOLD = 30;
|
||||
|
||||
let isScrollingBackToTop = false;
|
||||
let lastScrollY = 0;
|
||||
let isTouching = false;
|
||||
|
||||
export const TopButton = () => {
|
||||
const scrollTarget = useScrollTarget();
|
||||
const scrollY = useMotionValue(scrollTarget.current?.scrollTop ?? 0);
|
||||
|
||||
const scrollTimeOutRef = useRef<NodeJS.Timeout | undefined>(undefined);
|
||||
const [showButton, setShowButton] = useState<boolean | undefined>();
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setShowButton(false);
|
||||
isScrollingBackToTop = false;
|
||||
isTouching = false;
|
||||
if (scrollTarget.current) {
|
||||
scrollTarget.current.scrollTop = 0;
|
||||
}
|
||||
clearTimeout(scrollTimeOutRef.current);
|
||||
}, []);
|
||||
|
||||
const initialize = useCallback(() => {
|
||||
reset();
|
||||
lastScrollY = 0;
|
||||
}, []);
|
||||
|
||||
const scrollBackToTop = useCallback(() => {
|
||||
reset();
|
||||
}, []);
|
||||
|
||||
const onScroll = () => {
|
||||
if (isTouching) return;
|
||||
const currentScrollY = scrollTarget.current?.scrollTop ?? 0;
|
||||
const direction = currentScrollY > lastScrollY ? 'down' : 'up';
|
||||
lastScrollY = currentScrollY <= 0 ? 0 : currentScrollY;
|
||||
|
||||
scrollY.set(currentScrollY);
|
||||
clearTimeout(scrollTimeOutRef.current);
|
||||
|
||||
setShowButton(direction === 'down' ? false : currentScrollY > THRASH_HOLD);
|
||||
|
||||
if (!isScrollingBackToTop && direction === 'down') {
|
||||
scrollTimeOutRef.current = setTimeout(() => {
|
||||
setShowButton(true);
|
||||
}, TIME_OUT);
|
||||
}
|
||||
if (currentScrollY === 0) {
|
||||
isScrollingBackToTop = false;
|
||||
}
|
||||
};
|
||||
|
||||
const onTouchStart = useCallback(() => {
|
||||
isTouching = true;
|
||||
}, []);
|
||||
const onTouchMove = useCallback(() => {
|
||||
isTouching = true;
|
||||
}, []);
|
||||
const onTouchEnd = useCallback(() => {
|
||||
isTouching = false;
|
||||
}, []);
|
||||
|
||||
useScrollEventListeners({
|
||||
scrollTarget,
|
||||
onScroll,
|
||||
onTouchStart,
|
||||
onTouchMove,
|
||||
onTouchEnd,
|
||||
});
|
||||
|
||||
useRouteChangeEffect({
|
||||
scrollTarget,
|
||||
onScroll,
|
||||
onTouchStart,
|
||||
onTouchMove,
|
||||
onTouchEnd,
|
||||
callback: initialize,
|
||||
});
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{showButton && (
|
||||
<motion.button
|
||||
onClick={scrollBackToTop}
|
||||
className={'btn goto-top-btn'}
|
||||
initial={{ translateY: '110%' }}
|
||||
animate={{ translateY: '0%' }}
|
||||
>
|
||||
위로
|
||||
</motion.button>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
};
|
||||
53
src/widgets/top-button/use-route-change-effect.ts
Normal file
53
src/widgets/top-button/use-route-change-effect.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
import { useIsFetching, useIsMutating } from '@tanstack/react-query';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
|
||||
import { useRouterListener } from '@/shared/lib/hooks';
|
||||
import { addEventListeners, removeEventListeners } from './handle-event-listeners';
|
||||
|
||||
interface RouteChangeEffect {
|
||||
scrollTarget: React.MutableRefObject<HTMLElement | null>;
|
||||
onScroll: () => void;
|
||||
onTouchStart: () => void;
|
||||
onTouchMove: () => void;
|
||||
onTouchEnd: () => void;
|
||||
callback: () => void;
|
||||
}
|
||||
|
||||
export const useRouteChangeEffect = ({
|
||||
scrollTarget,
|
||||
onScroll,
|
||||
onTouchStart,
|
||||
onTouchMove,
|
||||
onTouchEnd,
|
||||
callback,
|
||||
}: RouteChangeEffect) => {
|
||||
const isFetching = useIsFetching();
|
||||
const isMutating = useIsMutating();
|
||||
const isLoading = isFetching > 0 || isMutating > 0;
|
||||
|
||||
const resetScrollTarget = useCallback(() => {
|
||||
const rootEl = document.getElementById('root') as HTMLElement;
|
||||
const pullToRefreshEl = document.getElementsByClassName('ptr__children')[0] as HTMLElement;
|
||||
|
||||
scrollTarget.current = rootEl;
|
||||
if (pullToRefreshEl?.scrollHeight > window.innerHeight) {
|
||||
scrollTarget.current = pullToRefreshEl;
|
||||
addEventListeners({ element: pullToRefreshEl, onScroll, onTouchStart, onTouchMove, onTouchEnd });
|
||||
removeEventListeners({ element: rootEl, onScroll, onTouchStart, onTouchMove, onTouchEnd });
|
||||
} else {
|
||||
addEventListeners({ element: rootEl, onScroll, onTouchStart, onTouchMove, onTouchEnd });
|
||||
removeEventListeners({ element: pullToRefreshEl, onScroll, onTouchStart, onTouchMove, onTouchEnd });
|
||||
}
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (isLoading) return;
|
||||
resetScrollTarget();
|
||||
}, [isLoading]);
|
||||
|
||||
useRouterListener(() => {
|
||||
setTimeout(() => {
|
||||
callback?.();
|
||||
}, 0);
|
||||
});
|
||||
};
|
||||
33
src/widgets/top-button/use-scroll-event-listeners.ts
Normal file
33
src/widgets/top-button/use-scroll-event-listeners.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
import { MutableRefObject, useEffect } from 'react';
|
||||
|
||||
import { addEventListeners, removeEventListeners } from './handle-event-listeners';
|
||||
|
||||
interface EventListeners {
|
||||
scrollTarget: MutableRefObject<HTMLElement | null>;
|
||||
onScroll: () => void;
|
||||
onTouchStart: () => void;
|
||||
onTouchMove: () => void;
|
||||
onTouchEnd: () => void;
|
||||
}
|
||||
export const useScrollEventListeners = ({
|
||||
scrollTarget,
|
||||
onScroll,
|
||||
onTouchStart,
|
||||
onTouchMove,
|
||||
onTouchEnd,
|
||||
}: EventListeners) => {
|
||||
useEffect(() => {
|
||||
if(!scrollTarget.current){
|
||||
return () => {};
|
||||
}
|
||||
addEventListeners({ element: scrollTarget.current, onScroll, onTouchStart, onTouchMove, onTouchEnd });
|
||||
|
||||
return () => {
|
||||
const rootEl = document.getElementById('root') as HTMLElement;
|
||||
const pullToRefreshEl = document.getElementsByClassName('ptr__children')[0] as HTMLElement;
|
||||
removeEventListeners({ element: rootEl, onScroll, onTouchStart, onTouchMove, onTouchEnd });
|
||||
removeEventListeners({ element: pullToRefreshEl, onScroll, onTouchStart, onTouchMove, onTouchEnd });
|
||||
};
|
||||
}, []);
|
||||
};
|
||||
22
src/widgets/top-button/use-scroll-target.ts
Normal file
22
src/widgets/top-button/use-scroll-target.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
export const useScrollTarget = () => {
|
||||
const scrollTarget = useRef<HTMLElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const rootEl = document.getElementById('root') as HTMLElement;
|
||||
const pullToRefreshEl = document.getElementsByClassName('ptr__children')[0] as HTMLElement;
|
||||
|
||||
let target: HTMLElement | null = rootEl;
|
||||
if (pullToRefreshEl?.scrollHeight > window.innerHeight) {
|
||||
target = pullToRefreshEl;
|
||||
}
|
||||
scrollTarget.current = target;
|
||||
|
||||
return () => {
|
||||
scrollTarget.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return scrollTarget;
|
||||
};
|
||||
Reference in New Issue
Block a user