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',