diff --git a/src/__tests__/ui.test.tsx b/src/__tests__/ui.test.tsx
index 6cf00b7..702f5b8 100644
--- a/src/__tests__/ui.test.tsx
+++ b/src/__tests__/ui.test.tsx
@@ -14,6 +14,13 @@ import {
DialogHeader,
DialogTitle,
DialogToggle,
+ Dropdown,
+ DropdownContent,
+ DropdownDivider,
+ DropdownItem,
+ DropdownLabel,
+ DropdownShortcut,
+ DropdownToggle,
Form,
Input,
InputDesc,
@@ -172,4 +179,38 @@ describe('UI test', () => {
expect(closeBtn).not.toBeInTheDocument();
});
+
+ it('Should appear DropdownContent when click DropdownToggle', () => {
+ render(
+
+ DropdownToggle
+
+ Share Social
+
+
+ GitHub
+ ⌘+T
+
+
+ Facebook
+ Twitter
+
+ ,
+ );
+
+ const toggleBtn = screen.getByText('DropdownToggle');
+ expect(toggleBtn).toBeInTheDocument();
+
+ fireEvent.click(toggleBtn);
+
+ const dropdownLabel = screen.getByText('Share Social');
+ expect(dropdownLabel).toBeInTheDocument();
+
+ const dropdownItem = screen.getByText('Twitter');
+ expect(dropdownItem).toBeInTheDocument();
+
+ fireEvent.click(dropdownItem);
+
+ expect(dropdownLabel).not.toBeInTheDocument();
+ });
});
diff --git a/src/components/common/Button/index.tsx b/src/components/common/Button/index.tsx
index 3b7ad80..05e6c07 100644
--- a/src/components/common/Button/index.tsx
+++ b/src/components/common/Button/index.tsx
@@ -5,21 +5,32 @@ import React, { ComponentPropsWithoutRef } from 'react';
import { SizeType } from '@/types/size';
import { SpecialVariantType } from '@/types/variant';
-const ButtonVariantStyles = ({ theme, variant }: { theme: Theme; variant: SpecialVariantType }) => {
+type ButtonVariantType = SpecialVariantType | 'default';
+
+const ButtonVariantStyles = ({ theme, variant }: { theme: Theme; variant: ButtonVariantType }) => {
switch (variant) {
case 'secondary':
return css`
background-color: ${theme.colors.secondary};
+ &:hover {
+ opacity: 0.8;
+ }
`;
case 'danger':
return css`
color: ${theme.colors.white};
background-color: ${theme.colors.danger};
+ &:hover {
+ opacity: 0.8;
+ }
`;
case 'warning':
return css`
color: ${theme.colors.white};
background-color: ${theme.colors.warning};
+ &:hover {
+ opacity: 0.8;
+ }
`;
case 'outline':
return css`
@@ -34,10 +45,21 @@ const ButtonVariantStyles = ({ theme, variant }: { theme: Theme; variant: Specia
background-color: transparent;
`;
case 'primary':
- default:
return css`
color: white;
background-color: ${theme.colors.primary};
+ &:hover {
+ opacity: 0.8;
+ }
+ `;
+ default:
+ return css`
+ color: ${theme.colors.black};
+ background-color: ${theme.colors.background};
+ border: 1px solid ${theme.colors.gray['300']};
+ &:hover {
+ opacity: 0.8;
+ }
`;
}
};
@@ -103,7 +125,7 @@ export interface ButtonVariantProps {
/*
Button variant
*/
- variant?: SpecialVariantType;
+ variant?: ButtonVariantType;
/*
Button size
*/
@@ -116,7 +138,7 @@ export interface ButtonVariantProps {
export interface ButtonProps extends ComponentPropsWithoutRef<'button'>, ButtonVariantProps {}
export const Button = React.forwardRef(
- ({ className, variant = 'primary', size = 'md', children, full = false, ...props }, ref) => {
+ ({ className, variant = 'default', size = 'md', children, full = false, ...props }, ref) => {
return (
{children}
diff --git a/src/components/common/Dialog/Dialog.tsx b/src/components/common/Dialog/Dialog.tsx
index 1bb4e1f..f55fd0b 100644
--- a/src/components/common/Dialog/Dialog.tsx
+++ b/src/components/common/Dialog/Dialog.tsx
@@ -1,5 +1,7 @@
import * as React from 'react';
+import useModal from '@/hooks/useModal';
+
import DialogContext from './DialogContext';
interface DialogProps extends React.ComponentPropsWithoutRef<'div'> {}
@@ -9,7 +11,7 @@ interface DialogProps extends React.ComponentPropsWithoutRef<'div'> {}
* @returns
*/
export const Dialog = ({ children, ...props }: DialogProps) => {
- const [showModal, setShowModal] = React.useState(false);
+ const [showModal, setShowModal] = useModal();
return (
@@ -17,5 +19,3 @@ export const Dialog = ({ children, ...props }: DialogProps) => {
);
};
-
-// const DialogProvider = styled.div``;
diff --git a/src/components/common/Dialog/DialogContent.tsx b/src/components/common/Dialog/DialogContent.tsx
index 77b06c2..c117337 100644
--- a/src/components/common/Dialog/DialogContent.tsx
+++ b/src/components/common/Dialog/DialogContent.tsx
@@ -71,29 +71,6 @@ export const DialogContent = React.forwardRef(
},
);
-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; }
diff --git a/src/components/common/Dialog/index.tsx b/src/components/common/Dialog/index.tsx
index dcc05b0..ffd7891 100644
--- a/src/components/common/Dialog/index.tsx
+++ b/src/components/common/Dialog/index.tsx
@@ -1,4 +1,30 @@
+import styled from '@emotion/styled';
+
+/* eslint-disable react-refresh/only-export-components */
export * from './Dialog';
export * from './DialogContent';
export * from './DialogToggle';
export * from './DialogClose';
+
+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;
+`;
diff --git a/src/components/common/Dropdown/Dropdown.tsx b/src/components/common/Dropdown/Dropdown.tsx
new file mode 100644
index 0000000..172397a
--- /dev/null
+++ b/src/components/common/Dropdown/Dropdown.tsx
@@ -0,0 +1,24 @@
+import * as React from 'react';
+
+import useModal from '@/hooks/useModal';
+
+import DropdownContext, { DropdownAlignType } from './DropdownContext';
+
+interface DropdownProps extends React.ComponentPropsWithoutRef<'div'> {
+ align?: DropdownAlignType;
+}
+
+/**
+ * Dropdown Context Provider
+ * @returns
+ */
+export const Dropdown = ({ children, align = 'center', ...props }: DropdownProps) => {
+ const [showModal, setShowModal] = useModal();
+ const [toggleRect, setToggleRect] = React.useState();
+
+ return (
+
+ {children}
+
+ );
+};
diff --git a/src/components/common/Dropdown/DropdownContent.tsx b/src/components/common/Dropdown/DropdownContent.tsx
new file mode 100644
index 0000000..ea576d9
--- /dev/null
+++ b/src/components/common/Dropdown/DropdownContent.tsx
@@ -0,0 +1,109 @@
+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 { composeRefs } from '@/libs/ref';
+
+import DropdownContext from './DropdownContext';
+
+interface ModalProps extends React.ComponentPropsWithoutRef<'div'> {
+ width?: React.CSSProperties['width'];
+}
+
+type PositionType = {
+ x: number;
+ y: number;
+};
+
+/**
+ * DropdownContent
+ * @returns
+ */
+export const DropdownContent = React.forwardRef(({ width, ...props }, ref) => {
+ const { showModal, setShowModal, toggleRect, align } = useContext(DropdownContext);
+ const modalRef = React.useRef(null);
+ const [reorgPos, setReorgPos] = React.useState({ x: 0, y: 0 });
+
+ const close = () => {
+ setShowModal(false);
+ };
+
+ React.useEffect(() => {
+ if (modalRef.current && toggleRect) {
+ const rect = modalRef.current.getBoundingClientRect();
+
+ const isOverflowing = rect.bottom > window.innerHeight;
+ const reorgPos = { x: 0, y: 0 };
+
+ switch (align) {
+ case 'center':
+ reorgPos.x = toggleRect.x + toggleRect.width / 2 - rect.width / 2;
+ break;
+ case 'start':
+ reorgPos.x = toggleRect.x;
+ break;
+ case 'end':
+ reorgPos.x = toggleRect.x + toggleRect.width - rect.width;
+ break;
+ }
+
+ if (isOverflowing) {
+ reorgPos.y = toggleRect.y - rect.height;
+ } else {
+ reorgPos.y = toggleRect.y + toggleRect.height;
+ }
+
+ setReorgPos(reorgPos);
+ }
+ }, [align, showModal, toggleRect]);
+
+ return (
+ <>
+ {showModal &&
+ createPortal(
+ <>
+
+
+ {props.children}
+
+ >,
+ document.body,
+ )}
+ >
+ );
+});
+DropdownContent.displayName = 'DropdownContent';
+
+const enter = keyframes`
+ 0% { opacity: 0; }
+ 100% { opacity: 1; }
+`;
+
+const ModalBG = styled.div`
+ position: fixed;
+ background-color: transparent;
+ pointer-events: auto;
+ z-index: 50;
+ inset: 0;
+`;
+
+const Modal = styled.div<{ width?: React.CSSProperties['width']; position: PositionType }>`
+ display: grid;
+ min-width: max-content;
+ ${({ width }) => width && `width: ${typeof width === 'string' ? width : `${width}px`};`}
+ padding: 0.25rem;
+ border-radius: 0.5rem;
+ position: fixed;
+ top: 0px;
+ left: 0px;
+ transform: ${({ position }) => `translate(${position.x}px, ${position.y}px)`};
+ background-color: white;
+ pointer-events: auto;
+ z-index: 50;
+ animation-name: ${enter};
+ animation-duration: 0.15s;
+ border: 1px solid ${({ theme }) => theme.colors.gray['300']};
+ box-shadow: 0px 2px 2px 0px ${({ theme }) => theme.colors.gray['300']};
+`;
diff --git a/src/components/common/Dropdown/DropdownContext.tsx b/src/components/common/Dropdown/DropdownContext.tsx
new file mode 100644
index 0000000..6a761cb
--- /dev/null
+++ b/src/components/common/Dropdown/DropdownContext.tsx
@@ -0,0 +1,13 @@
+import * as React from 'react';
+
+export type DropdownAlignType = 'start' | 'center' | 'end';
+
+interface DropdownContextType {
+ showModal: boolean;
+ setShowModal: (value: boolean) => void;
+ toggleRect: DOMRect | undefined;
+ setToggleRect: (value: DOMRect) => void;
+ align: DropdownAlignType;
+}
+const DropdownContext = React.createContext(null);
+export default DropdownContext;
diff --git a/src/components/common/Dropdown/DropdownItem.tsx b/src/components/common/Dropdown/DropdownItem.tsx
new file mode 100644
index 0000000..6c4fca3
--- /dev/null
+++ b/src/components/common/Dropdown/DropdownItem.tsx
@@ -0,0 +1,55 @@
+import { css } from '@emotion/react';
+import styled from '@emotion/styled';
+import * as React from 'react';
+
+import useContext from '@/hooks/useContext';
+import { composeEventHandlers } from '@/libs/event';
+
+import DropdownContext from './DropdownContext';
+
+interface DropdownItemProps extends React.ComponentPropsWithoutRef<'div'> {
+ disabled?: boolean;
+}
+export const DropdownItem = React.forwardRef(
+ ({ disabled = false, ...props }, ref) => {
+ const { setShowModal } = useContext(DropdownContext);
+
+ const onClickHandler = () => {
+ if (!disabled) setShowModal(false);
+ };
+
+ return (
+
+ );
+ },
+);
+DropdownItem.displayName = 'DropdownItem';
+
+const DropdownItemStyled = styled.div<{ disabled: boolean }>`
+ display: flex;
+ position: relative;
+ align-items: center;
+ padding: 0.375rem 0.5rem;
+ border-radius: 0.25rem;
+ font-size: 0.875rem;
+ line-height: 1.25rem;
+ outline: 2px solid transparent;
+ outline-offset: 2px;
+ cursor: default;
+ ${({ disabled, theme }) =>
+ disabled
+ ? css`
+ opacity: 0.5;
+ `
+ : css`
+ &:hover {
+ background-color: ${theme.colors.gray['100']};
+ }
+ `}
+`;
diff --git a/src/components/common/Dropdown/DropdownToggle.tsx b/src/components/common/Dropdown/DropdownToggle.tsx
new file mode 100644
index 0000000..d2a1e6d
--- /dev/null
+++ b/src/components/common/Dropdown/DropdownToggle.tsx
@@ -0,0 +1,42 @@
+import * as React from 'react';
+
+import Slot from '@/components/Slot';
+import useContext from '@/hooks/useContext';
+import { composeEventHandlers } from '@/libs/event';
+import { composeRefs } from '@/libs/ref';
+
+import { Button } from '..';
+import DropdownContext from './DropdownContext';
+
+type ComponentPropsWithoutRef = React.ComponentPropsWithoutRef & {
+ asChild?: boolean;
+};
+
+/**
+ * DropdownToggle
+ * @returns
+ */
+export const DropdownToggle = React.forwardRef>(
+ ({ asChild, onClick, ...props }, ref) => {
+ const { setShowModal, setToggleRect } = useContext(DropdownContext);
+ const compRef = React.useRef(null);
+
+ const Comp = asChild ? Slot : Button;
+
+ return (
+ {
+ if (compRef.current) {
+ const rect = compRef.current.getBoundingClientRect();
+ setToggleRect(rect);
+ }
+
+ setShowModal(true);
+ })}
+ {...props}
+ />
+ );
+ },
+);
+DropdownToggle.displayName = 'DropdownToggle';
diff --git a/src/components/common/Dropdown/index.tsx b/src/components/common/Dropdown/index.tsx
new file mode 100644
index 0000000..9df397c
--- /dev/null
+++ b/src/components/common/Dropdown/index.tsx
@@ -0,0 +1,33 @@
+import styled from '@emotion/styled';
+
+/* eslint-disable react-refresh/only-export-components */
+export * from './Dropdown';
+export * from './DropdownContent';
+export * from './DropdownToggle';
+export * from './DropdownItem';
+
+export const DropdownLabel = styled.h2`
+ font-weight: 600;
+ font-size: 0.875rem;
+ line-height: 1.25rem;
+ padding: 0.375rem 0.5rem;
+ margin: 0px;
+`;
+
+export const DropdownDivider = styled.div`
+ height: 1px;
+ margin: 0.25rem -0.25rem;
+ background: ${({ theme }) => theme.colors.gray['300']};
+`;
+export const DropdownDescription = styled.p`
+ margin: 0.375rem 0px;
+ font-weight: 300;
+`;
+
+export const DropdownShortcut = styled.span`
+ font-size: 0.75rem;
+ letter-spacing: 0.1em;
+ line-height: 1rem;
+ margin-left: auto;
+ opacity: 0.5;
+`;
diff --git a/src/components/common/index.ts b/src/components/common/index.ts
index 88930c1..528f75b 100644
--- a/src/components/common/index.ts
+++ b/src/components/common/index.ts
@@ -3,6 +3,7 @@ export * from './Badge';
export * from './Button';
export * from './Card';
export * from './Dialog';
+export * from './Dropdown';
export * from './Form';
export * from './Input';
export * from './Label';
diff --git a/src/hooks/useModal.ts b/src/hooks/useModal.ts
new file mode 100644
index 0000000..3328043
--- /dev/null
+++ b/src/hooks/useModal.ts
@@ -0,0 +1,21 @@
+import * as React from 'react';
+
+const useModal = (): [boolean, React.Dispatch>] => {
+ const [showModal, setShowModal] = React.useState(false);
+
+ React.useEffect(() => {
+ if (showModal) {
+ const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
+
+ document.body.style.setProperty('overflow', 'hidden', 'important');
+ document.body.style.marginRight = `${scrollbarWidth}px`;
+ } else {
+ document.body.style.overflow = 'auto';
+ document.body.style.marginRight = `${0}px`;
+ }
+ }, [showModal]);
+
+ return [showModal, setShowModal];
+};
+
+export default useModal;
diff --git a/src/libs/ref.ts b/src/libs/ref.ts
new file mode 100644
index 0000000..606add9
--- /dev/null
+++ b/src/libs/ref.ts
@@ -0,0 +1,12 @@
+type RefType = React.Ref | undefined;
+
+export const composeRefs = (...refs: RefType[]) => {
+ return (node: T) =>
+ refs.forEach(ref => {
+ if (typeof ref === 'function') {
+ ref(node);
+ } else if (ref) {
+ (ref as React.MutableRefObject).current = node;
+ }
+ });
+};
diff --git a/src/stories/common/Button.stories.tsx b/src/stories/common/Button.stories.tsx
index f142517..f449000 100644
--- a/src/stories/common/Button.stories.tsx
+++ b/src/stories/common/Button.stories.tsx
@@ -7,6 +7,9 @@ const meta = {
title: 'common/Button',
component: Button,
tags: ['autodocs'],
+ args: {
+ children: 'Button',
+ },
argTypes: {},
parameters: {
layout: 'centered',
@@ -23,50 +26,67 @@ export default meta;
type Story = StoryObj;
-export const Primary: Story = {
+export const Default: Story = {
args: {
children: 'Button',
},
};
+export const Primary: Story = {
+ args: {
+ variant: 'primary',
+ },
+};
+
export const Secondary: Story = {
args: {
variant: 'secondary',
- ...Primary.args,
},
};
export const Danger: Story = {
args: {
variant: 'danger',
- ...Primary.args,
},
};
export const Warning: Story = {
args: {
variant: 'warning',
- ...Primary.args,
},
};
export const Outline: Story = {
args: {
variant: 'outline',
- ...Primary.args,
},
};
export const Ghost: Story = {
args: {
variant: 'ghost',
- ...Primary.args,
},
};
export const Icon: Story = {
args: {
- ...Ghost.args,
+ size: 'icon',
+ children: (
+
+ ),
},
decorators: [
Story => {
@@ -95,6 +115,5 @@ export const Icon: Story = {
export const Disabled: Story = {
args: {
disabled: true,
- ...Primary.args,
},
};
diff --git a/src/stories/common/Dropdown.stories.tsx b/src/stories/common/Dropdown.stories.tsx
new file mode 100644
index 0000000..626f281
--- /dev/null
+++ b/src/stories/common/Dropdown.stories.tsx
@@ -0,0 +1,153 @@
+import type { Meta, StoryObj } from '@storybook/react';
+import * as React from 'react';
+
+import {
+ Dropdown,
+ DropdownContent,
+ DropdownDivider,
+ DropdownItem,
+ DropdownLabel,
+ DropdownShortcut,
+ DropdownToggle,
+} from '../../';
+
+const meta = {
+ title: 'common/Dropdown',
+ component: Dropdown,
+ tags: ['autodocs'],
+ args: {
+ align: 'center',
+ },
+ parameters: {
+ layout: 'centered',
+ componentSubtitle: 'Base Dropdown',
+ },
+} satisfies Meta;
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {
+ args: {},
+ decorators: [
+ () => {
+ return (
+
+ Dropdown
+
+ Share Social
+ GitHub
+
+
+ );
+ },
+ ],
+};
+
+export const AlignStart: Story = {
+ parameters: {
+ docs: {
+ description: {
+ story: '``',
+ },
+ },
+ },
+ args: {
+ align: 'start',
+ children: (
+ <>
+ Dropdown
+
+ Share Social
+
+
+ GitHub
+ ⌘+T
+
+
+ Facebook
+ Twitter
+
+ >
+ ),
+ },
+};
+
+export const AlignCenter: Story = {
+ parameters: {
+ docs: {
+ description: {
+ story: '``',
+ },
+ },
+ },
+ args: {
+ align: 'center',
+ children: (
+ <>
+ Dropdown
+
+ Share Social
+
+
+ GitHub
+ ⌘+T
+
+
+ Facebook
+ Twitter
+
+ >
+ ),
+ },
+};
+
+export const AlignEnd: Story = {
+ parameters: {
+ docs: {
+ description: {
+ story: '``',
+ },
+ },
+ },
+ args: {
+ align: 'end',
+ children: (
+ <>
+ Dropdown
+
+ Share Social
+
+
+ GitHub
+ ⌘+T
+
+ Facebook
+ Twitter
+
+ >
+ ),
+ },
+};
+
+export const It13123em: Story = {
+ args: {
+ children: (
+ <>
+ Dropdown
+
+ Share Social
+
+
+ GitHub
+ ⌘+T
+
+
+ Facebook
+ Twitter
+
+ >
+ ),
+ },
+};
diff --git a/src/styles/theme.ts b/src/styles/theme.ts
index fe89a02..e40268c 100644
--- a/src/styles/theme.ts
+++ b/src/styles/theme.ts
@@ -1,7 +1,7 @@
import { Theme } from '@emotion/react';
export const BMateColors = {
- bg: '#FFF',
+ background: '#FFF',
white: '#FAFAFA',
black: '#212121',
primary: '#212121',