From e2a8c455fb33d7705b73b0e89ac97030b0a41478 Mon Sep 17 00:00:00 2001 From: Ye-Chan Kang Date: Wed, 31 Jan 2024 00:13:09 +0900 Subject: [PATCH] feat(common): dialog (#7) --- src/__tests__/ui.test.tsx | 83 +++++++++- src/components/Slot.tsx | 50 ++++++ src/components/common/Dialog/Dialog.tsx | 21 +++ src/components/common/Dialog/DialogClose.tsx | 35 +++++ .../common/Dialog/DialogContent.tsx | 146 ++++++++++++++++++ .../common/Dialog/DialogContext.tsx | 8 + src/components/common/Dialog/DialogToggle.tsx | 35 +++++ src/components/common/Dialog/index.tsx | 4 + src/components/common/index.ts | 1 + src/hooks/useContext.ts | 13 ++ src/stories/common/Dialog.stories.tsx | 140 +++++++++++++++++ 11 files changed, 535 insertions(+), 1 deletion(-) create mode 100644 src/components/Slot.tsx create mode 100644 src/components/common/Dialog/Dialog.tsx create mode 100644 src/components/common/Dialog/DialogClose.tsx create mode 100644 src/components/common/Dialog/DialogContent.tsx create mode 100644 src/components/common/Dialog/DialogContext.tsx create mode 100644 src/components/common/Dialog/DialogToggle.tsx create mode 100644 src/components/common/Dialog/index.tsx create mode 100644 src/hooks/useContext.ts create mode 100644 src/stories/common/Dialog.stories.tsx diff --git a/src/__tests__/ui.test.tsx b/src/__tests__/ui.test.tsx index e00fe13..6cf00b7 100644 --- a/src/__tests__/ui.test.tsx +++ b/src/__tests__/ui.test.tsx @@ -3,7 +3,24 @@ import React from 'react'; import { Route } from 'react-router-dom'; import { describe, expect, it, vi } from 'vitest'; -import { Button, Checkbox, Form, Input, InputDesc, InputGroup, Label, Textarea } from '../'; +import { + Button, + Checkbox, + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogToggle, + Form, + Input, + InputDesc, + InputGroup, + Label, + Textarea, +} from '../'; import { MockRouter, fireEvent, render, screen, waitFor } from '../libs/test'; describe('UI test', () => { @@ -91,4 +108,68 @@ describe('UI test', () => { console.error('styledEl is null.'); } }); + + it('Should appear DialogContent when click DialogToggle', () => { + render( + + + + + + + Create project + Great project names are short and memorable. + + + + + You can @mention other users to link to them. + + + + + + + + + + + , + ); + + const toggleBtn = screen.getByText('ToggleButton'); + expect(toggleBtn).toBeInTheDocument(); + + fireEvent.click(toggleBtn); + + const projectLabel = screen.getByLabelText(/project/i); + expect(projectLabel).toBeInTheDocument(); + fireEvent.change(projectLabel, { target: { value: 'blur blur' } }); + + const closeBtn = screen.getByText('Close'); + expect(closeBtn).toBeInTheDocument(); + + fireEvent.click(closeBtn); + + expect(projectLabel).not.toBeInTheDocument(); + + fireEvent.click(toggleBtn); + + const dialogTitle = screen.getByText('Create project'); + expect(dialogTitle).toBeInTheDocument(); + + const dialogBGs = document.getElementsByClassName('bmates-modal-bg'); + expect(dialogBGs.length).toBe(1); + + fireEvent.click(dialogBGs[0]); + + expect(closeBtn).not.toBeInTheDocument(); + }); }); diff --git a/src/components/Slot.tsx b/src/components/Slot.tsx new file mode 100644 index 0000000..71598b5 --- /dev/null +++ b/src/components/Slot.tsx @@ -0,0 +1,50 @@ +import * as React from 'react'; + +import { composeEventHandlers } from '@/libs/event'; + +export type AsChildProps = ({ asChild?: false } & T) | { asChild: true; children: React.ReactNode }; + +interface SlotProps extends React.HTMLProps { + children?: React.ReactNode; +} + +const Slot = ({ children, ...props }: SlotProps) => { + if (React.isValidElement(children)) { + return React.cloneElement(children, { + ...mergeProps(props, children.props), + }); + } + + if (React.Children.count(children) > 1) { + React.Children.only(null); + } + + return null; +}; +export default Slot; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type AnyProps = Record; +const mergeProps = (slotProps: AnyProps, childProps: AnyProps) => { + const overrideProps = { ...childProps }; + + for (const propName in childProps) { + const slotValue = slotProps[propName]; + const childValue = childProps[propName]; + + // on** Event compose + if (/^on[A-Z]/.test(propName)) { + if (slotValue && childValue) { + overrideProps[propName] = (...args: unknown[]) => composeEventHandlers(childValue(...args), slotValue(...args)); + } else if (slotValue) { + overrideProps[propName] = slotValue; + } + } else if (propName === 'style') { + overrideProps[propName] = { ...slotValue, ...childValue }; + } else if (propName === 'className') { + overrideProps[propName] = [slotValue, childValue].filter(Boolean).join(' '); + } + } + + return { ...slotProps, ...overrideProps }; +}; diff --git a/src/components/common/Dialog/Dialog.tsx b/src/components/common/Dialog/Dialog.tsx new file mode 100644 index 0000000..1bb4e1f --- /dev/null +++ b/src/components/common/Dialog/Dialog.tsx @@ -0,0 +1,21 @@ +import * as React from 'react'; + +import DialogContext from './DialogContext'; + +interface DialogProps extends React.ComponentPropsWithoutRef<'div'> {} + +/** + * Dialog Context Provider + * @returns + */ +export const Dialog = ({ children, ...props }: DialogProps) => { + const [showModal, setShowModal] = React.useState(false); + + return ( + + {children} + + ); +}; + +// const DialogProvider = styled.div``; diff --git a/src/components/common/Dialog/DialogClose.tsx b/src/components/common/Dialog/DialogClose.tsx new file mode 100644 index 0000000..edab7ae --- /dev/null +++ b/src/components/common/Dialog/DialogClose.tsx @@ -0,0 +1,35 @@ +import * as React from 'react'; + +import Slot from '@/components/Slot'; +import useContext from '@/hooks/useContext'; +import { composeEventHandlers } from '@/libs/event'; + +import { Button } from '..'; +import DialogContext from './DialogContext'; + +type ComponentPropsWithoutRef = React.ComponentPropsWithoutRef & { + asChild?: boolean; +}; + +/** + * DialogClose + * @returns + */ +export const DialogClose = React.forwardRef>( + ({ asChild, onClick, ...props }, ref) => { + const { setShowModal } = useContext(DialogContext); + + const Comp = asChild ? Slot : Button; + + return ( + { + setShowModal(false); + })} + {...props} + /> + ); + }, +); +DialogClose.displayName = 'DialogClose'; diff --git a/src/components/common/Dialog/DialogContent.tsx b/src/components/common/Dialog/DialogContent.tsx new file mode 100644 index 0000000..77b06c2 --- /dev/null +++ b/src/components/common/Dialog/DialogContent.tsx @@ -0,0 +1,146 @@ +import { keyframes } from '@emotion/react'; +import styled from '@emotion/styled'; +import * as React from 'react'; +import { createPortal } from 'react-dom'; + +import useContext from '@/hooks/useContext'; + +import DialogContext from './DialogContext'; + +interface ModalProps extends React.ComponentPropsWithoutRef<'div'> { + /* + Modal Background Event : close modal when click event + */ + outEvent?: boolean; + /* + Hide Close Button + */ + hideClose?: boolean; + /* + Modal max width + */ + maxWidth?: number | string; +} +/** + * DialogContent + * @returns + */ +export const DialogContent = React.forwardRef( + ({ outEvent = false, hideClose = false, maxWidth = '450px', ...props }, ref) => { + const { showModal, setShowModal } = useContext(DialogContext); + + const close = () => { + if (outEvent) setShowModal(false); + }; + const closeBtnHandler = () => { + setShowModal(false); + }; + + return ( + <> + {showModal && + createPortal( + <> + + + {props.children} + {!hideClose && ( + + + + + + + )} + + , + document.body, + )} + + ); + }, +); + +export const DialogHeader = styled.div` + display: flex; + flex-direction: column; +`; + +export const DialogFooter = styled.div<{ justify?: React.CSSProperties['justifyContent'] }>` + display: flex; + flex-direction: row; + gap: 0.5rem; + align-items: flex-start; + justify-content: ${({ justify }) => justify || 'flex-end'}; +`; + +export const DialogTitle = styled.h2` + font-weight: 600; + line-height: 1; + margin: 0px; +`; +export const DialogDescription = styled.p` + margin: 0.375rem 0px; + font-weight: 300; +`; + +const enter = keyframes` + 0% { opacity: 0; } + 100% { opacity: 1; } +`; + +const ModalBG = styled.div` + pointer-events: auto; + animation-name: ${enter}; + animation-duration: 0.15s; + background-color: rgba(0, 0, 0, 0.8); + z-index: 50; + inset: 0; + position: fixed; +`; + +const Modal = styled.div<{ maxWidth: number | string }>` + display: grid; + width: 100%; + max-width: ${({ maxWidth }) => { + if (typeof maxWidth === 'string') return maxWidth; + return `${maxWidth}px`; + }}; + padding: 1.5rem; + border-radius: 0.5rem; + position: fixed; + top: 50%; + left: 50%; + gap: 1rem; + transform: translate(-50%, -50%); + background-color: white; + pointer-events: auto; + animation-name: ${enter}; + animation-duration: 0.15s; + z-index: 50; +`; + +const ExitButton = styled.button` + position: absolute; + right: 1rem; + top: 1rem; + background: transparent; + border: none; + padding: 0px; + opacity: 0.5; + cursor: pointer; + svg { + width: 1rem; + height: 1rem; + } +`; diff --git a/src/components/common/Dialog/DialogContext.tsx b/src/components/common/Dialog/DialogContext.tsx new file mode 100644 index 0000000..fb45faa --- /dev/null +++ b/src/components/common/Dialog/DialogContext.tsx @@ -0,0 +1,8 @@ +import * as React from 'react'; + +interface DialogContextType { + showModal: boolean; + setShowModal: (value: boolean) => void; +} +const DialogContext = React.createContext(null); +export default DialogContext; diff --git a/src/components/common/Dialog/DialogToggle.tsx b/src/components/common/Dialog/DialogToggle.tsx new file mode 100644 index 0000000..bea10bb --- /dev/null +++ b/src/components/common/Dialog/DialogToggle.tsx @@ -0,0 +1,35 @@ +import * as React from 'react'; + +import Slot from '@/components/Slot'; +import useContext from '@/hooks/useContext'; +import { composeEventHandlers } from '@/libs/event'; + +import { Button } from '..'; +import DialogContext from './DialogContext'; + +type ComponentPropsWithoutRef = React.ComponentPropsWithoutRef & { + asChild?: boolean; +}; + +/** + * DialogToggle + * @returns + */ +export const DialogToggle = React.forwardRef>( + ({ asChild, onClick, ...props }, ref) => { + const { setShowModal } = useContext(DialogContext); + + const Comp = asChild ? Slot : Button; + + return ( + { + setShowModal(true); + })} + {...props} + /> + ); + }, +); +DialogToggle.displayName = 'DialogToggle'; diff --git a/src/components/common/Dialog/index.tsx b/src/components/common/Dialog/index.tsx new file mode 100644 index 0000000..dcc05b0 --- /dev/null +++ b/src/components/common/Dialog/index.tsx @@ -0,0 +1,4 @@ +export * from './Dialog'; +export * from './DialogContent'; +export * from './DialogToggle'; +export * from './DialogClose'; diff --git a/src/components/common/index.ts b/src/components/common/index.ts index e96730c..88930c1 100644 --- a/src/components/common/index.ts +++ b/src/components/common/index.ts @@ -2,6 +2,7 @@ export * from './Avatar'; export * from './Badge'; export * from './Button'; export * from './Card'; +export * from './Dialog'; export * from './Form'; export * from './Input'; export * from './Label'; diff --git a/src/hooks/useContext.ts b/src/hooks/useContext.ts new file mode 100644 index 0000000..29ecee4 --- /dev/null +++ b/src/hooks/useContext.ts @@ -0,0 +1,13 @@ +import * as React from 'react'; + +const useContext = (ctx: React.Context): T => { + const context = React.useContext(ctx); + + if (!context) { + throw new Error(ctx.displayName + 'must be used within a Provider'); + } + + return context; +}; + +export default useContext; diff --git a/src/stories/common/Dialog.stories.tsx b/src/stories/common/Dialog.stories.tsx new file mode 100644 index 0000000..1ef9394 --- /dev/null +++ b/src/stories/common/Dialog.stories.tsx @@ -0,0 +1,140 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import * as React from 'react'; + +import { + Button, + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogToggle, + Input, + InputDesc, + InputGroup, + Label, +} from '../..'; + +const meta = { + title: 'common/Dialog', + component: Dialog, + tags: ['autodocs'], + args: {}, + parameters: { + layout: 'centered', + componentSubtitle: 'Base Dialog', + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + children: ( + <> + Toggle + + Content + + + + + + ), + }, +}; + +export const HideCloseButton: Story = { + parameters: { + docs: { + description: { + story: '``', + }, + }, + }, + args: { + children: ( + <> + Toggle + + Content + + + + + + ), + }, +}; +export const EnableBackgroundEvent: Story = { + parameters: { + docs: { + description: { + story: '``', + }, + }, + }, + args: { + children: ( + <> + Toggle + + Content + + + + + + ), + }, +}; + +export const ToggleAsChild: Story = { + parameters: { + docs: { + description: { + story: + '` + + + + Create project + Great project names are short and memorable. + + + + + You can @mention other users to link to them. + + + + + + + + + + + + ), + }, +};