Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(web, shared/utils): Portal, Modal 컴포넌트, useDisclosure 훅, classNames 유틸 생성 #55

Merged
merged 8 commits into from
Feb 22, 2024
192 changes: 192 additions & 0 deletions apps/web/src/components/Modal/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
import { classNames } from '@favolink/utils';
import {
type ComponentPropsWithoutRef,
type ReactNode,
createContext,
forwardRef,
useContext,
} from 'react';
import * as styles from './styles.css';
import Portal from '../Portal';

type OverlayProps = Omit<ComponentPropsWithoutRef<'div'>, 'className'> & {
variant?: styles.OverlayVariant;
};

const Overlay = forwardRef<HTMLDivElement, OverlayProps>(
function Overlay(props, ref) {
const { variant = 'original', ...restProps } = props;

return <div {...restProps} ref={ref} className={styles.overlay[variant]} />;
},
);

Modal.Overlay = Overlay;

const Content = forwardRef<HTMLDivElement, ComponentPropsWithoutRef<'div'>>(
function Content(props, ref) {
const { children, className, ...restPorps } = props;

const { onClose, closeOnOverlayClick } = useContext(ModalContext);

return (
<>
<Overlay
onClick={closeOnOverlayClick ? onClose : undefined}
variant="withContent"
/>
<div
{...restPorps}
ref={ref}
className={classNames(styles.content, className)}
>
{children}
</div>
</>
);
},
);

Modal.Content = Content;

const Header = forwardRef<HTMLElement, ComponentPropsWithoutRef<'header'>>(
function Header(props, ref) {
const { children, className, ...restProps } = props;

return (
<header
{...restProps}
ref={ref}
className={classNames(styles.header, className)}
>
{children}
</header>
);
},
);

Modal.Header = Header;

type TopBarProps = ComponentPropsWithoutRef<'div'> & {
variant: styles.TopBarLayoutVariant;
};

const TopBar = forwardRef<HTMLDivElement, TopBarProps>(
function TopBar(props, ref) {
const { children, variant, className, ...restProps } = props;

const { onClose } = useContext(ModalContext);

const isCouple = variant === 'couple';

return (
<div
{...restProps}
ref={ref}
className={classNames(styles.topBarLayout[variant], className)}
>
{isCouple && <p>{children}</p>}
<p onClick={onClose}>닫기</p>
</div>
);
},
);

Modal.TopBar = TopBar;

type TitleProps = ComponentPropsWithoutRef<'h4'> & {
variant: styles.TitleLayoutVariant;
};

const Title = forwardRef<HTMLHeadingElement, TitleProps>(
function Title(props, ref) {
const { children, variant, className, ...restProps } = props;

return (
<div className={styles.titleLayout[variant]}>
<h4
{...restProps}
ref={ref}
className={classNames(styles.title, className)}
>
{children}
</h4>
</div>
);
},
);

Modal.Title = Title;

const Body = forwardRef<HTMLElement, ComponentPropsWithoutRef<'main'>>(
function Body(props, ref) {
const { children, className, ...restProps } = props;

return (
<main {...restProps} ref={ref} className={classNames(className)}>
{children}
</main>
);
},
);

Modal.Body = Body;

const Footer = forwardRef<HTMLElement, ComponentPropsWithoutRef<'footer'>>(
function Footer(props, ref) {
const { children, className, ...restProps } = props;

return (
<footer
{...restProps}
ref={ref}
className={classNames(styles.footer, className)}
>
{children}
</footer>
);
},
);

Modal.Footer = Footer;

type ModalContextOptions = {
onClose: () => void;
closeOnOverlayClick?: boolean;
};

const ModalContext = createContext<ModalContextOptions>({
onClose: () => {},
closeOnOverlayClick: false,
});

type ModalProviderProps = ModalContextOptions & {
children: ReactNode;
};

function Provider(props: ModalProviderProps) {
const { children, onClose, closeOnOverlayClick } = props;

return (
<ModalContext.Provider value={{ onClose, closeOnOverlayClick }}>
{children}
</ModalContext.Provider>
);
}

type ModalProps = ModalProviderProps & {
isOpen: boolean;
};

export default function Modal(props: ModalProps) {
const { children, ...restProps } = props;
const { isOpen, ...realRestProps } = restProps;

return (
isOpen && (
<Portal>
<Provider {...realRestProps}>{children}</Provider>
</Portal>
)
);
}
79 changes: 79 additions & 0 deletions apps/web/src/components/Modal/styles.css.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { h4Bold } from '@favolink/styles/text.css';
import { vars } from '@favolink/styles/theme.css';
import { style, styleVariants } from '@vanilla-extract/css';

const flex = style({
display: 'flex',
});

const flexGap = style([
flex,
{
gap: 16,
},
]);

export const content = style([
flexGap,
{
position: 'relative',
zIndex: 98,
flexDirection: 'column',
padding: 24,
minWidth: 300,
backgroundColor: vars.color.gray200,
borderRadius: 20,
},
]);

const baseOverlay = style({
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
});

export const overlay = styleVariants({
original: [
baseOverlay,
{ backgroundColor: 'rgba(0, 0, 0, 0.5)', zIndex: 96 },
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

z-index는 따로 상수로 관리해도 좋을거같은데 96으로 하신 이유가 있나요?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

우선 96이라는 숫자에 큰 의미는 없습니다!
상수로 관리한다는 말씀이 쓰이는 z-index마다 숫자가 아닌 상수 자체로 우선순위를 파악한다는 말씀이신가요?? 🤔

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

네 파일 한곳에서 모아두면 나중에 겹치는 일이 없을거같아서요

Copy link
Member Author

@sukvvon sukvvon Feb 22, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

image

쌓임 맥락도 고려해야해서 상수화를 하더라도 또 다른 비용이 들 수 있다는 생각이 들어요!
차후 리펙토링때 진행해도 될까요?? 🤔

],
withContent: [baseOverlay, { zIndex: 97 }],
});

export type OverlayVariant = keyof typeof overlay;

export const header = style([
flexGap,
{
flexDirection: 'column',
},
]);

export const topBarLayout = styleVariants({
single: [flex, { justifyContent: 'flex-end' }],
couple: [flex, { justifyContent: 'space-between' }],
});

export type TopBarLayoutVariant = keyof typeof topBarLayout;

export const titleLayout = styleVariants({
left: [flex, { justifyContent: 'flex-start' }],
center: [flex, { justifyContent: 'center' }],
right: [flex, { justifyContent: 'flex-end' }],
});

export type TitleLayoutVariant = keyof typeof titleLayout;

export const title = style([h4Bold, { color: vars.color.gray1000 }]);

export const footer = style([
flex,
{
justifyContent: 'flex-end',
},
]);
47 changes: 47 additions & 0 deletions apps/web/src/components/Portal/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { type ReactNode, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { portal } from './styles.css';

const PORTAL_CLASSNAME = `favolink-portal ${portal}`;

export default function Portal({ children }: { children: ReactNode }) {
const [tempNode, setTempNode] = useState<HTMLElement | null>(null);
const [, forceUpdate] = useState({});
const portal = useRef<HTMLDivElement | null>(null);

useEffect(() => {
if (!tempNode) {
return;
}

const doc = tempNode.ownerDocument;
const host = doc.body;

portal.current = doc.createElement('div');
portal.current.className = PORTAL_CLASSNAME;

host.appendChild(portal.current);

forceUpdate({});

const portalNode = portal.current;

return () => {
if (host.contains(portalNode)) {
host.removeChild(portalNode);
}
};
}, [tempNode]);

return portal.current ? (
createPortal(children, portal.current)
) : (
<span
ref={(spanElement) => {
if (spanElement) {
setTempNode(spanElement);
}
}}
/>
);
}
18 changes: 18 additions & 0 deletions apps/web/src/components/Portal/styles.css.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { style } from '@vanilla-extract/css';
import * as modalStyles from '../Modal/styles.css';

export const portal = style({
position: 'fixed',
top: 0,
left: 0,
display: 'flex',

selectors: {
[`&:has(> ${modalStyles.content})`]: {
right: 0,
bottom: 0,
justifyContent: 'center',
alignItems: 'center',
},
},
});
2 changes: 2 additions & 0 deletions apps/web/src/components/index.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
/* eslint-disable @stylistic/padding-line-between-statements */
export { default as Layout } from './Layout';
export { default as Modal } from './Modal';
export { default as Portal } from './Portal';
export { default as Wrapper } from './Wrapper';
2 changes: 2 additions & 0 deletions apps/web/src/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/* eslint-disable @stylistic/padding-line-between-statements */
export { default as useDisclosure } from './useDisclosure';
15 changes: 15 additions & 0 deletions apps/web/src/hooks/useDisclosure.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { useState } from 'react';

export default function useDisclosure(initialIsOpen = false) {
const [isOpen, setIsOpen] = useState(initialIsOpen);

function onOpen() {
setIsOpen(true);
}

function onClose() {
setIsOpen(false);
}

return { isOpen, onOpen, onClose };
}
5 changes: 4 additions & 1 deletion packages/eslint-config-favolink/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,10 @@ module.exports = {
'func-style': ['error', 'declaration'],
'no-console': 'warn',
'sort-imports': ['error', { ignoreDeclarationSort: true }],
'@typescript-eslint/consistent-type-imports': 'error',
'@typescript-eslint/consistent-type-imports': [
'error',
{ fixStyle: 'inline-type-imports' },
],
'@typescript-eslint/dot-notation': 'error',
'@typescript-eslint/method-signature-style': 'error',
'@typescript-eslint/sort-type-constituents': 'error',
Expand Down
3 changes: 3 additions & 0 deletions packages/shared/utils/src/classNames.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export function classNames(...classNames: unknown[]) {
return classNames.filter(Boolean).join(' ');
}
2 changes: 2 additions & 0 deletions packages/shared/utils/src/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
/* eslint-disable @stylistic/padding-line-between-statements */
export * from './classNames';
export * from './request';
Loading