Skip to content

Commit

Permalink
Merge pull request #8 from Bandmators/feature/dialog
Browse files Browse the repository at this point in the history
feat(common): dialog (#7)
  • Loading branch information
kyechan99 authored Jan 30, 2024
2 parents bf420a6 + e2a8c45 commit 7545426
Show file tree
Hide file tree
Showing 11 changed files with 535 additions and 1 deletion.
83 changes: 82 additions & 1 deletion src/__tests__/ui.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -91,4 +108,68 @@ describe('UI test', () => {
console.error('styledEl is null.');
}
});

it('Should appear DialogContent when click DialogToggle', () => {
render(
<Dialog>
<DialogToggle asChild>
<Button
variant="outline"
onClick={() => {
console.log('Event Compose');
}}
>
ToggleButton
</Button>
</DialogToggle>
<DialogContent maxWidth={500} outEvent>
<DialogHeader>
<DialogTitle>Create project</DialogTitle>
<DialogDescription>Great project names are short and memorable.</DialogDescription>
</DialogHeader>
<InputGroup style={{ margin: '2rem 0rem' }}>
<Label htmlFor="project">Project</Label>
<Input id="project" />
<InputDesc>You can @mention other users to link to them.</InputDesc>
</InputGroup>
<DialogFooter justify="space-between">
<DialogClose asChild>
<Button variant="secondary">Close</Button>
</DialogClose>
<DialogClose asChild>
<Button variant="primary">Create</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>,
);

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();
});
});
50 changes: 50 additions & 0 deletions src/components/Slot.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import * as React from 'react';

import { composeEventHandlers } from '@/libs/event';

export type AsChildProps<T> = ({ asChild?: false } & T) | { asChild: true; children: React.ReactNode };

interface SlotProps extends React.HTMLProps<HTMLElement> {
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<string, any>;
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 };
};
21 changes: 21 additions & 0 deletions src/components/common/Dialog/Dialog.tsx
Original file line number Diff line number Diff line change
@@ -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<boolean>(false);

return (
<DialogContext.Provider value={{ showModal, setShowModal }} {...props}>
{children}
</DialogContext.Provider>
);
};

// const DialogProvider = styled.div``;
35 changes: 35 additions & 0 deletions src/components/common/Dialog/DialogClose.tsx
Original file line number Diff line number Diff line change
@@ -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<E extends React.ElementType> = React.ComponentPropsWithoutRef<E> & {
asChild?: boolean;
};

/**
* DialogClose
* @returns
*/
export const DialogClose = React.forwardRef<HTMLButtonElement, ComponentPropsWithoutRef<'button'>>(
({ asChild, onClick, ...props }, ref) => {
const { setShowModal } = useContext(DialogContext);

const Comp = asChild ? Slot : Button;

return (
<Comp
ref={ref}
onClick={composeEventHandlers(onClick, () => {
setShowModal(false);
})}
{...props}
/>
);
},
);
DialogClose.displayName = 'DialogClose';
146 changes: 146 additions & 0 deletions src/components/common/Dialog/DialogContent.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement, ModalProps>(
({ 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(
<>
<ModalBG onClick={close} className="bmates-modal-bg" />
<Modal ref={ref} maxWidth={maxWidth} {...props}>
{props.children}
{!hideClose && (
<ExitButton onClick={closeBtnHandler}>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</ExitButton>
)}
</Modal>
</>,
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;
}
`;
8 changes: 8 additions & 0 deletions src/components/common/Dialog/DialogContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import * as React from 'react';

interface DialogContextType {
showModal: boolean;
setShowModal: (value: boolean) => void;
}
const DialogContext = React.createContext<DialogContextType | null>(null);
export default DialogContext;
35 changes: 35 additions & 0 deletions src/components/common/Dialog/DialogToggle.tsx
Original file line number Diff line number Diff line change
@@ -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<E extends React.ElementType> = React.ComponentPropsWithoutRef<E> & {
asChild?: boolean;
};

/**
* DialogToggle
* @returns
*/
export const DialogToggle = React.forwardRef<HTMLButtonElement, ComponentPropsWithoutRef<'button'>>(
({ asChild, onClick, ...props }, ref) => {
const { setShowModal } = useContext(DialogContext);

const Comp = asChild ? Slot : Button;

return (
<Comp
ref={ref}
onClick={composeEventHandlers(onClick, () => {
setShowModal(true);
})}
{...props}
/>
);
},
);
DialogToggle.displayName = 'DialogToggle';
Loading

0 comments on commit 7545426

Please sign in to comment.