From 239bb989c1a59ebc5fee778b020b1ffaf3c65279 Mon Sep 17 00:00:00 2001 From: Hanbin Cho Date: Thu, 7 Aug 2025 10:44:42 +0900 Subject: [PATCH 01/28] Init Select comp --- .../src/components/Select/Select.stories.tsx | 26 +++++++++++++++++++ .../Select/__tests__/index.browser.test.tsx | 11 ++++++++ .../src/components/Select/index.tsx | 3 +++ 3 files changed, 40 insertions(+) create mode 100644 packages/components/src/components/Select/Select.stories.tsx create mode 100644 packages/components/src/components/Select/__tests__/index.browser.test.tsx create mode 100644 packages/components/src/components/Select/index.tsx diff --git a/packages/components/src/components/Select/Select.stories.tsx b/packages/components/src/components/Select/Select.stories.tsx new file mode 100644 index 00000000..136fde52 --- /dev/null +++ b/packages/components/src/components/Select/Select.stories.tsx @@ -0,0 +1,26 @@ +import { Meta, StoryObj } from '@storybook/react-vite' + +import { Select } from '.' + +type Story = StoryObj + +// More on how to set up stories at: https://storybook.js.org/docs/writing-stories#default-export +const meta: Meta = { + title: 'Devfive/Select', + component: Select, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} + +export const Default: Story = { + args: { + placeholder: 'Input text', + }, +} + +export default meta diff --git a/packages/components/src/components/Select/__tests__/index.browser.test.tsx b/packages/components/src/components/Select/__tests__/index.browser.test.tsx new file mode 100644 index 00000000..59b8f575 --- /dev/null +++ b/packages/components/src/components/Select/__tests__/index.browser.test.tsx @@ -0,0 +1,11 @@ +import { render } from '@testing-library/react' +import { describe, expect, it } from 'vitest' + +import { Select } from '..' + +describe('Select', () => { + it('should render', () => { + const { container } = render( + Select + + Option 1 + Option 2 + + Option 3 + Option 4 + + + ), } export default meta diff --git a/packages/components/src/components/Select/index.tsx b/packages/components/src/components/Select/index.tsx index 6ed60685..41d44742 100644 --- a/packages/components/src/components/Select/index.tsx +++ b/packages/components/src/components/Select/index.tsx @@ -1,3 +1,143 @@ -export function Select() { - return <> +'use client' + +import { Box, css, Flex, VStack } from '@devup-ui/react' +import clsx from 'clsx' +import { ComponentProps, createContext, useContext, useState } from 'react' + +import { Button } from '../Button' + +interface SelectProps { + open?: boolean + onOpenChange?: (open: boolean) => void + children: React.ReactNode +} + +const SelectContext = createContext<{ + open: boolean + setOpen: (open: boolean) => void +} | null>(null) + +export const useSelect = () => { + const context = useContext(SelectContext) + if (!context) { + throw new Error('useSelect must be used within a Select') + } + return context +} + +export function Select({ + children, + open: openProp, + onOpenChange, +}: SelectProps) { + const [open, setOpen] = useState(openProp ?? false) + const handleOpenChange = (open: boolean) => { + setOpen(open) + onOpenChange?.(open) + } + return ( + + + {children} + + + ) +} + +export function SelectTrigger({ + className, + children, + ...props +}: ComponentProps) { + const { open, setOpen } = useSelect() + const handleClick = () => { + setOpen(!open) + } + + return ( + + ) +} + +export function SelectContainer({ children, ...props }: ComponentProps<'div'>) { + const { open } = useSelect() + if (!open) return null + return ( + + {children} + + ) +} + +interface SelectOptionProps extends ComponentProps<'div'> { + disabled?: boolean +} + +export function SelectOption({ + disabled, + onClick, + children, + ...props +}: SelectOptionProps) { + const { setOpen } = useSelect() + const handleClick = (e: React.MouseEvent) => { + if (onClick) { + onClick(e) + return + } + setOpen(false) + } + + return ( + + {children} + + ) +} + +export function SelectDivider({ ...props }: ComponentProps<'div'>) { + return } From 48760a690c04dd1ac8a3f95338b017dc09017046 Mon Sep 17 00:00:00 2001 From: Hanbin Cho Date: Thu, 7 Aug 2025 13:59:20 +0900 Subject: [PATCH 03/28] Add value selection in Select --- .../src/components/Select/Checkbox.tsx | 30 +++++ .../src/components/Select/IconCheck.tsx | 22 ++++ .../src/components/Select/index.tsx | 110 +++++++++++++++++- 3 files changed, 157 insertions(+), 5 deletions(-) create mode 100644 packages/components/src/components/Select/Checkbox.tsx create mode 100644 packages/components/src/components/Select/IconCheck.tsx diff --git a/packages/components/src/components/Select/Checkbox.tsx b/packages/components/src/components/Select/Checkbox.tsx new file mode 100644 index 00000000..3b306b9f --- /dev/null +++ b/packages/components/src/components/Select/Checkbox.tsx @@ -0,0 +1,30 @@ +import { Box, css } from '@devup-ui/react' + +import { IconCheck } from './IconCheck' + +interface CheckboxProps { + isChecked: boolean +} + +export function Checkbox({ isChecked }: CheckboxProps) { + return ( + + {isChecked && ( + + )} + + ) +} diff --git a/packages/components/src/components/Select/IconCheck.tsx b/packages/components/src/components/Select/IconCheck.tsx new file mode 100644 index 00000000..fd2e2395 --- /dev/null +++ b/packages/components/src/components/Select/IconCheck.tsx @@ -0,0 +1,22 @@ +import { ComponentProps } from 'react' + +export function IconCheck({ ...props }: ComponentProps<'svg'>) { + return ( + + + + ) +} diff --git a/packages/components/src/components/Select/index.tsx b/packages/components/src/components/Select/index.tsx index 41d44742..8b84d8f1 100644 --- a/packages/components/src/components/Select/index.tsx +++ b/packages/components/src/components/Select/index.tsx @@ -5,16 +5,24 @@ import clsx from 'clsx' import { ComponentProps, createContext, useContext, useState } from 'react' import { Button } from '../Button' +import { IconCheck } from './IconCheck' + +type SelectType = 'default' | 'radio' | 'checkbox' +type SelectValue = T extends 'radio' ? string : string[] interface SelectProps { open?: boolean onOpenChange?: (open: boolean) => void children: React.ReactNode + type?: SelectType } const SelectContext = createContext<{ open: boolean setOpen: (open: boolean) => void + value: SelectValue + setValue: (value: string) => void + type: SelectType } | null>(null) export const useSelect = () => { @@ -26,17 +34,44 @@ export const useSelect = () => { } export function Select({ + type = 'default', children, open: openProp, onOpenChange, }: SelectProps) { const [open, setOpen] = useState(openProp ?? false) + const [value, setValue] = useState>( + type === 'checkbox' ? [] : '', + ) + const handleOpenChange = (open: boolean) => { setOpen(open) onOpenChange?.(open) } + + const handleValueChange = (nextValue: string) => { + if (type === 'default') return + if (type === 'radio') { + setValue(nextValue) + return + } + if (Array.isArray(value) && value.includes(nextValue)) { + setValue(value.filter((v) => v !== nextValue)) + } else { + setValue([...value, nextValue]) + } + } + return ( - + {children} @@ -105,15 +140,26 @@ export function SelectOption({ children, ...props }: SelectOptionProps) { - const { setOpen } = useSelect() + const { setOpen, setValue, value, type } = useSelect() + + const handleClose = () => { + if (type === 'checkbox') return + setOpen(false) + } + const handleClick = (e: React.MouseEvent) => { if (onClick) { onClick(e) return } - setOpen(false) + setValue(children as string) + handleClose() } + const isChecked = Array.isArray(value) + ? value.includes(children as string) + : value === children + return ( + { + { + checkbox: ( + + {isChecked && ( + + )} + + ), + radio: ( + <> + {isChecked && ( + + + + )} + + ), + default: null, + }[type] + } {children} ) From cbebb9e8ec3240feb3a3d174f5975c36f153ed17 Mon Sep 17 00:00:00 2001 From: Hanbin Cho Date: Thu, 7 Aug 2025 14:13:43 +0900 Subject: [PATCH 04/28] Add outside click close function --- .../src/components/Select/index.tsx | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/packages/components/src/components/Select/index.tsx b/packages/components/src/components/Select/index.tsx index 8b84d8f1..97d9305f 100644 --- a/packages/components/src/components/Select/index.tsx +++ b/packages/components/src/components/Select/index.tsx @@ -2,7 +2,14 @@ import { Box, css, Flex, VStack } from '@devup-ui/react' import clsx from 'clsx' -import { ComponentProps, createContext, useContext, useState } from 'react' +import { + ComponentProps, + createContext, + useContext, + useEffect, + useRef, + useState, +} from 'react' import { Button } from '../Button' import { IconCheck } from './IconCheck' @@ -39,11 +46,22 @@ export function Select({ open: openProp, onOpenChange, }: SelectProps) { + const ref = useRef(null) const [open, setOpen] = useState(openProp ?? false) const [value, setValue] = useState>( type === 'checkbox' ? [] : '', ) + useEffect(() => { + if (!ref.current) return + const handleOutsideClick = (e: MouseEvent) => { + if (ref.current?.contains(e.target as Node)) return + setOpen(false) + } + document.addEventListener('click', handleOutsideClick) + return () => document.removeEventListener('click', handleOutsideClick) + }, [open, setOpen]) + const handleOpenChange = (open: boolean) => { setOpen(open) onOpenChange?.(open) @@ -72,7 +90,7 @@ export function Select({ type, }} > - + {children} @@ -108,6 +126,7 @@ export function SelectTrigger({ export function SelectContainer({ children, ...props }: ComponentProps<'div'>) { const { open } = useSelect() + if (!open) return null return ( Date: Thu, 7 Aug 2025 14:24:01 +0900 Subject: [PATCH 05/28] Update devup.json --- packages/components/devup.json | 1143 ++++++++++++++++---------------- 1 file changed, 579 insertions(+), 564 deletions(-) diff --git a/packages/components/devup.json b/packages/components/devup.json index 7d9385b9..40781c5d 100644 --- a/packages/components/devup.json +++ b/packages/components/devup.json @@ -1,569 +1,584 @@ { - "theme": { - "colors": { - "light": { - "primary": "#674DC7", - "primaryHover": "#4D38AE", - "primaryActive": "#312395", - "primaryBg": "#F4F3FA", - "secondary": "#E3E0F2", - "link": "#7C8EE1", - "text": "#272727", - "background": "#F5F5F5", - "containerBackground": "#FFFFFF", - "border": "#E4E4E4", - "success": "#2CA353", - "warning": "#FF9800", - "error": "#D52B2E", - "info": "#2196F3", - "white": "#FFFFFF", - "black": "#000000", - "title": "#1A1A1A", - "caption": "#787878", - "black50": "#00000080", - "placeHolder": "#9D9D9D", - "base50": "#FFFFFF80", - "footerBackground": "#2F313B", - "footerAward": "#EAEAED", - "footerBody": "#FFFFFF", - "footerTitle": "#F2F2F2", - "footerCaption": "#CACACA", - "white10": "#FFFFFF1A", - "base": "#FFFFFF", - "negativeBase": "#000000", - "inputPlaceholder": "#A9A8AB", - "inputBg": "#FFFFFF", - "inputIcon": "#C3C2C8", - "inputDisabledBg": "#F0F0F3", - "inputDisabledText": "#D6D7DE", - "inputCaption": "#9B9BA6", - "negative20": "#00000033", - "negative10": "#0000001A", - "toggleBg": "#E4E4E4", - "primary50": "#614FC480", - "primary20": "#614FC433", - "tableSearch": "#FFED8A", - "black5": "#0000000D", - "filterBg": "#EFEEF2", - "footerLink": "#B7B5C0", - "familysiteBg": "#828389", - "familysiteTxt": "#272727", - "familyHover": "#A0A1A5", - "footerNavTitle": "#98989D", - "snackBg": "#29292CCC", - "snackLink": "#B2B0EF", - "containerHover": "#EEEEEE", - "kakoBg": "#FFE232", - "kakaoHover": "#F0C81A", - "textFixed": "#272727", - "primaryFocus": "#9385D3", - "containerPush": "#DADAE1", - "selectDisabled": "#C4C5D1", - "selectBoxBg": "#00000012", - "iconBold": "#8D8C9A", - "borderBold": "#BCBCBC", - "gnbBg": "#FFFFFFCC" - }, - "dark": { - "primary": "#8163E1", - "primaryHover": "#A290E7", - "primaryActive": "#BEB3ED", - "primaryBg": "#F4F3FA0D", - "secondary": "#272331", - "link": "#006BFF", - "text": "#F6F6F6", - "background": "#000000", - "containerBackground": "#1E1E1E", - "border": "#434343", - "success": "#4CAF50", - "warning": "#FF9800", - "error": "#FF5B5E", - "info": "#2196F3", - "white": "#FFFFFF", - "black": "#000000", - "title": "#FAFAFA", - "caption": "#787878", - "black50": "#00000080", - "placeHolder": "#9D9D9D", - "base50": "#00000080", - "footerBackground": "#D8D8D8", - "footerAward": "#3D3D3D", - "footerBody": "#404040", - "footerTitle": "#1F1F1F", - "footerCaption": "#7D7D7D", - "white10": "#0000001A", - "base": "#000000", - "negativeBase": "#FFFFFF", - "inputPlaceholder": "#CBCBCB", - "inputBg": "#2E2E2E", - "inputIcon": "#696A6F", - "inputDisabledBg": "#414244", - "inputDisabledText": "#373737", - "inputCaption": "#C3C2C8", - "negative20": "#FFFFFF66", - "negative10": "#FFFFFF1A", - "toggleBg": "#383838", - "primary50": "#7D6DD880", - "primary20": "#7D6DD833", - "tableSearch": "#B55100", - "black5": "#0000000D", - "filterBg": "#4B494F", - "footerLink": "#5E6063", - "familysiteBg": "#B0B0B0", - "familysiteTxt": "#272727", - "familyHover": "#94969E", - "footerNavTitle": "#747276", - "snackBg": "#29292CCC", - "snackLink": "#7C8EE1", - "containerHover": "#3A3A3A", - "kakoBg": "#FFE232", - "kakaoHover": "#FFE232", - "textFixed": "#272727", - "primaryFocus": "#927CE4", - "containerPush": "#606066", - "selectDisabled": "#45464D", - "selectBoxBg": "#FFFFFF12", - "iconBold": "#666577", - "borderBold": "#535353", - "gnbBg": "#000000CC" - } - }, - "typography": { - "buttonM": [ - { - "fontFamily": "Pretendard", - "fontStyle": "normal", - "fontWeight": 700, - "fontSize": "16px", - "lineHeight": 1.2, - "letterSpacing": "0px" + "theme": { + "colors": { + "light": { + "primary": "#674DC7", + "primaryHover": "#4D38AE", + "primaryActive": "#312395", + "primaryBg": "#F4F3FA", + "secondary": "#E3E0F2", + "link": "#7C8EE1", + "text": "#272727", + "background": "#F5F5F5", + "containerBackground": "#FFFFFF", + "border": "#E4E4E4", + "success": "#2CA353", + "warning": "#FF9800", + "error": "#D52B2E", + "info": "#2196F3", + "white": "#FFFFFF", + "black": "#000000", + "title": "#1A1A1A", + "caption": "#787878", + "black50": "#00000080", + "placeHolder": "#9D9D9D", + "base50": "#FFFFFF80", + "footerBackground": "#2F313B", + "footerAward": "#EAEAED", + "footerBody": "#FFFFFF", + "footerTitle": "#F2F2F2", + "footerCaption": "#CACACA", + "white10": "#FFFFFF1A", + "base": "#FFFFFF", + "negativeBase": "#000000", + "inputPlaceholder": "#A9A8AB", + "inputBg": "#FFFFFF", + "inputIcon": "#C3C2C8", + "inputDisabledBg": "#F0F0F3", + "inputDisabledText": "#D6D7DE", + "inputCaption": "#9B9BA6", + "negative20": "#00000033", + "negative10": "#0000001A", + "toggleBg": "#E4E4E4", + "primary50": "#614FC480", + "primary20": "#614FC433", + "tableSearch": "#FFED8A", + "black5": "#0000000D", + "filterBg": "#EFEEF2", + "footerLink": "#B7B5C0", + "familysiteBg": "#828389", + "familysiteTxt": "#272727", + "familyHover": "#A0A1A5", + "footerNavTitle": "#98989D", + "snackBg": "#29292CCC", + "snackLink": "#B2B0EF", + "containerHover": "#EEEEEE", + "kakoBg": "#FFE232", + "kakaoHover": "#F0C81A", + "textFixed": "#272727", + "primaryFocus": "#9385D3", + "containerPush": "#DADAE1", + "selectDisabled": "#C4C5D1", + "selectBoxBg": "#00000012", + "iconBold": "#8D8C9A", + "borderBold": "#BCBCBC", + "gnbBg": "#FFFFFFCC" + }, + "dark": { + "primary": "#8163E1", + "primaryHover": "#A290E7", + "primaryActive": "#BEB3ED", + "primaryBg": "#F4F3FA0D", + "secondary": "#272331", + "link": "#006BFF", + "text": "#F6F6F6", + "background": "#202020", + "containerBackground": "#1E1E1E", + "border": "#434343", + "success": "#4CAF50", + "warning": "#FF9800", + "error": "#FF5B5E", + "info": "#2196F3", + "white": "#FFFFFF", + "black": "#000000", + "title": "#FAFAFA", + "caption": "#787878", + "black50": "#00000080", + "placeHolder": "#9D9D9D", + "base50": "#00000080", + "footerBackground": "#D8D8D8", + "footerAward": "#3D3D3D", + "footerBody": "#404040", + "footerTitle": "#1F1F1F", + "footerCaption": "#7D7D7D", + "white10": "#0000001A", + "base": "#000000", + "negativeBase": "#FFFFFF", + "inputPlaceholder": "#CBCBCB", + "inputBg": "#2E2E2E", + "inputIcon": "#696A6F", + "inputDisabledBg": "#414244", + "inputDisabledText": "#373737", + "inputCaption": "#C3C2C8", + "negative20": "#FFFFFF66", + "negative10": "#FFFFFF1A", + "toggleBg": "#383838", + "primary50": "#7D6DD880", + "primary20": "#7D6DD833", + "tableSearch": "#B55100", + "black5": "#0000000D", + "filterBg": "#4B494F", + "footerLink": "#5E6063", + "familysiteBg": "#B0B0B0", + "familysiteTxt": "#272727", + "familyHover": "#94969E", + "footerNavTitle": "#747276", + "snackBg": "#29292CCC", + "snackLink": "#7C8EE1", + "containerHover": "#3A3A3A", + "kakoBg": "#FFE232", + "kakaoHover": "#FFE232", + "textFixed": "#272727", + "primaryFocus": "#927CE4", + "containerPush": "#606066", + "selectDisabled": "#45464D", + "selectBoxBg": "#FFFFFF12", + "iconBold": "#666577", + "borderBold": "#535353", + "gnbBg": "#000000CC" + } }, - null, - null, - null, - { - "fontFamily": "Pretendard", - "fontStyle": "normal", - "fontWeight": 700, - "fontSize": "18px", - "lineHeight": 1.2, - "letterSpacing": "-0.01em" + "typography": { + "buttonM": [ + { + "fontFamily": "Pretendard", + "fontStyle": "normal", + "fontWeight": 700, + "fontSize": "16px", + "lineHeight": 1.2, + "letterSpacing": "0px" + }, + null, + null, + null, + { + "fontFamily": "Pretendard", + "fontStyle": "normal", + "fontWeight": 700, + "fontSize": "18px", + "lineHeight": 1.2, + "letterSpacing": "-0.01em" + }, + null + ], + "inputBold": [ + { + "fontFamily": "Pretendard", + "fontStyle": "normal", + "fontWeight": 700, + "fontSize": "14px", + "lineHeight": 1.2, + "letterSpacing": "-0.02em" + }, + null, + null, + null, + { + "fontFamily": "Pretendard", + "fontStyle": "normal", + "fontWeight": 700, + "fontSize": "15px", + "lineHeight": 1.2, + "letterSpacing": "-0.02em" + }, + null + ], + "buttonS": [ + { + "fontFamily": "Pretendard", + "fontStyle": "normal", + "fontWeight": 700, + "fontSize": "15px", + "lineHeight": 1.2, + "letterSpacing": "0px" + }, + null, + null, + null, + { + "fontFamily": "Pretendard", + "fontStyle": "normal", + "fontWeight": 700, + "fontSize": "16px", + "lineHeight": 1.2, + "letterSpacing": "-0.01em" + }, + null + ], + "buttonxs": [ + { + "fontFamily": "Pretendard", + "fontStyle": "normal", + "fontWeight": 600, + "fontSize": "14px", + "lineHeight": 1.2, + "letterSpacing": "-0.02em" + }, + null, + null, + null, + { + "fontFamily": "Pretendard", + "fontStyle": "normal", + "fontWeight": 600, + "fontSize": "15px", + "lineHeight": 1.2, + "letterSpacing": "-0.03em" + }, + null + ], + "inputPlaceholder": [ + { + "fontFamily": "Pretendard", + "fontStyle": "normal", + "fontWeight": 400, + "fontSize": "13px", + "lineHeight": 1.2, + "letterSpacing": "-0.02em" + }, + null, + null, + null, + { + "fontFamily": "Pretendard", + "fontStyle": "normal", + "fontWeight": 400, + "fontSize": "14px", + "lineHeight": 1.2, + "letterSpacing": "-0.02em" + }, + null + ], + "inputText": [ + { + "fontFamily": "Pretendard", + "fontStyle": "normal", + "fontWeight": 400, + "fontSize": "14px", + "lineHeight": 1.2, + "letterSpacing": "-0.02em" + }, + null, + null, + null, + { + "fontFamily": "Pretendard", + "fontStyle": "normal", + "fontWeight": 400, + "fontSize": "15px", + "lineHeight": 1.2, + "letterSpacing": "-0.03em" + }, + null + ], + "inlineLabelL": [ + { + "fontFamily": "Pretendard", + "fontStyle": "normal", + "fontWeight": 500, + "fontSize": "16px", + "lineHeight": 1.3, + "letterSpacing": "-0.03em" + }, + null, + null, + null, + { + "fontFamily": "Pretendard", + "fontStyle": "normal", + "fontWeight": 500, + "fontSize": "18px", + "lineHeight": 1.3, + "letterSpacing": "-0.03em" + }, + null + ], + "inlineLabelS": { + "fontFamily": "Pretendard", + "fontStyle": "normal", + "fontWeight": 500, + "fontSize": "12px", + "lineHeight": 1.3, + "letterSpacing": "-0.03em" + }, + "body": [ + { + "fontFamily": "Pretendard", + "fontStyle": "normal", + "fontWeight": 400, + "fontSize": "15px", + "lineHeight": "24px", + "letterSpacing": "-0.01em" + }, + null, + null, + null, + { + "fontFamily": "Pretendard", + "fontStyle": "normal", + "fontWeight": 400, + "fontSize": "16px", + "lineHeight": "24px", + "letterSpacing": "-0.01em" + }, + null + ], + "inputPhBold": [ + { + "fontFamily": "Pretendard", + "fontStyle": "normal", + "fontWeight": 600, + "fontSize": "13px", + "lineHeight": 1.2, + "letterSpacing": "-0.02em" + }, + null, + null, + null, + { + "fontFamily": "Pretendard", + "fontStyle": "normal", + "fontWeight": 600, + "fontSize": "14px", + "lineHeight": 1.2, + "letterSpacing": "-0.02em" + }, + null + ], + "uploadButton": { + "fontFamily": "Pretendard", + "fontStyle": "normal", + "fontWeight": 600, + "fontSize": "14px", + "lineHeight": "24px", + "letterSpacing": "-0.01em" + }, + "pagination": { + "fontFamily": "Pretendard", + "fontStyle": "normal", + "fontWeight": 600, + "fontSize": "14px", + "lineHeight": 1.2, + "letterSpacing": "-0.02em" + }, + "paginationSelected": { + "fontFamily": "Pretendard", + "fontStyle": "normal", + "fontWeight": 900, + "fontSize": "15px", + "lineHeight": 1.2, + "letterSpacing": "-0.02em" + }, + "langMenu": { + "fontFamily": "Pretendard", + "fontStyle": "normal", + "fontWeight": 500, + "fontSize": "15px", + "lineHeight": 1.2, + "letterSpacing": "-0.02em" + }, + "langButton": [ + { + "fontFamily": "Pretendard", + "fontStyle": "normal", + "fontWeight": 700, + "fontSize": "14px", + "lineHeight": 1.2, + "letterSpacing": "-0.02em" + }, + null, + null, + null, + { + "fontFamily": "Pretendard", + "fontStyle": "normal", + "fontWeight": 700, + "fontSize": "16px", + "lineHeight": 1.2, + "letterSpacing": "-0.02em" + }, + null + ], + "tableTitle": [ + { + "fontFamily": "Pretendard", + "fontStyle": "normal", + "fontWeight": 600, + "fontSize": "15px", + "lineHeight": 1.2, + "letterSpacing": "-0.01em" + }, + null, + null, + null, + { + "fontFamily": "Pretendard", + "fontStyle": "normal", + "fontWeight": 600, + "fontSize": "16px", + "lineHeight": 1.2, + "letterSpacing": "-0.01em" + }, + null + ], + "footerL": { + "fontFamily": "Pretendard", + "fontStyle": "normal", + "fontWeight": 500, + "fontSize": "14px", + "lineHeight": 1.3, + "letterSpacing": "-0.02em" + }, + "footerM": { + "fontFamily": "Pretendard", + "fontStyle": "normal", + "fontWeight": 300, + "fontSize": "13px", + "lineHeight": "18px", + "letterSpacing": "-0.01em" + }, + "footerMsemibold": { + "fontFamily": "Pretendard", + "fontStyle": "normal", + "fontWeight": 600, + "fontSize": "13px", + "lineHeight": "18px", + "letterSpacing": "-0.01em" + }, + "footerS": { + "fontFamily": "Pretendard", + "fontStyle": "normal", + "fontWeight": 300, + "fontSize": "12px", + "lineHeight": 1.2, + "letterSpacing": "-0.02em" + }, + "footerxl": { + "fontFamily": "Pretendard", + "fontStyle": "normal", + "fontWeight": 700, + "fontSize": "15px", + "lineHeight": 1.3, + "letterSpacing": "-0.02em" + }, + "footerSsemibold": { + "fontFamily": "Pretendard", + "fontStyle": "normal", + "fontWeight": 600, + "fontSize": "12px", + "lineHeight": 1.2, + "letterSpacing": "-0.02em" + }, + "footerxs": { + "fontFamily": "Pretendard", + "fontStyle": "normal", + "fontWeight": 300, + "fontSize": "10px", + "lineHeight": 1.2, + "letterSpacing": "-0.02em" + }, + "footerXSsemibold": { + "fontFamily": "Pretendard", + "fontStyle": "normal", + "fontWeight": 600, + "fontSize": "10px", + "lineHeight": "18px", + "letterSpacing": "-0.02em" + }, + "footerNav": { + "fontFamily": "Pretendard", + "fontStyle": "normal", + "fontWeight": 500, + "fontSize": "15px", + "lineHeight": 1.3, + "letterSpacing": "-0.02em" + }, + "footerList": { + "fontFamily": "Pretendard", + "fontStyle": "normal", + "fontWeight": 500, + "fontSize": "14px", + "lineHeight": 1.2, + "letterSpacing": "-0.02em" + }, + "subMenu": [ + { + "fontFamily": "Pretendard", + "fontStyle": "normal", + "fontWeight": 600, + "fontSize": "15px", + "lineHeight": "24px", + "letterSpacing": "-0.01em" + }, + null, + null, + null, + { + "fontFamily": "Pretendard", + "fontStyle": "normal", + "fontWeight": 600, + "fontSize": "16px", + "lineHeight": "24px", + "letterSpacing": "-0.01em" + }, + null + ], + "tableText": [ + { + "fontFamily": "Pretendard", + "fontStyle": "normal", + "fontWeight": 400, + "fontSize": "14px", + "lineHeight": "24px", + "letterSpacing": "-0.01em" + }, + null, + null, + null, + { + "fontFamily": "Pretendard", + "fontStyle": "normal", + "fontWeight": 400, + "fontSize": "15px", + "lineHeight": 1.4, + "letterSpacing": "-0.01em" + }, + null + ], + "tableTextBold": [ + { + "fontFamily": "Pretendard", + "fontStyle": "normal", + "fontWeight": 600, + "fontSize": "14px", + "lineHeight": "24px", + "letterSpacing": "-0.01em" + }, + null, + null, + null, + { + "fontFamily": "Pretendard", + "fontStyle": "normal", + "fontWeight": 600, + "fontSize": "15px", + "lineHeight": 1.2, + "letterSpacing": "-0.01em" + }, + null + ], + "resetButton": [ + { + "fontFamily": "Pretendard", + "fontStyle": "normal", + "fontWeight": 700, + "fontSize": "14px", + "lineHeight": 1.2, + "letterSpacing": "-0.02em" + }, + null, + null, + null, + { + "fontFamily": "Pretendard", + "fontStyle": "normal", + "fontWeight": 700, + "fontSize": "15px", + "lineHeight": 1.2, + "letterSpacing": "-0.02em" + }, + null + ] } - ], - "inputBold": [ - { - "fontFamily": "Pretendard", - "fontStyle": "normal", - "fontWeight": 700, - "fontSize": "14px", - "lineHeight": 1.2, - "letterSpacing": "-0.02em" - }, - null, - null, - null, - { - "fontFamily": "Pretendard", - "fontStyle": "normal", - "fontWeight": 700, - "fontSize": "15px", - "lineHeight": 1.2, - "letterSpacing": "-0.02em" - } - ], - "buttonS": [ - { - "fontFamily": "Pretendard", - "fontStyle": "normal", - "fontWeight": 700, - "fontSize": "15px", - "lineHeight": 1.2, - "letterSpacing": "0px" - }, - null, - null, - null, - { - "fontFamily": "Pretendard", - "fontStyle": "normal", - "fontWeight": 700, - "fontSize": "16px", - "lineHeight": 1.2, - "letterSpacing": "-0.01em" - } - ], - "buttonxs": [ - { - "fontFamily": "Pretendard", - "fontStyle": "normal", - "fontWeight": 600, - "fontSize": "14px", - "lineHeight": 1.2, - "letterSpacing": "-0.02em" - }, - null, - null, - null, - { - "fontFamily": "Pretendard", - "fontStyle": "normal", - "fontWeight": 600, - "fontSize": "15px", - "lineHeight": 1.2, - "letterSpacing": "-0.03em" - } - ], - "inputPlaceholder": [ - { - "fontFamily": "Pretendard", - "fontStyle": "normal", - "fontWeight": 400, - "fontSize": "13px", - "lineHeight": 1.2, - "letterSpacing": "-0.02em" - }, - null, - null, - null, - { - "fontFamily": "Pretendard", - "fontStyle": "normal", - "fontWeight": 400, - "fontSize": "14px", - "lineHeight": 1.2, - "letterSpacing": "-0.02em" - } - ], - "inputText": [ - { - "fontFamily": "Pretendard", - "fontStyle": "normal", - "fontWeight": 400, - "fontSize": "14px", - "lineHeight": 1.2, - "letterSpacing": "-0.02em" - }, - null, - null, - null, - { - "fontFamily": "Pretendard", - "fontStyle": "normal", - "fontWeight": 400, - "fontSize": "15px", - "lineHeight": 1.2, - "letterSpacing": "-0.03em" - } - ], - "inlineLabelL": [ - { - "fontFamily": "Pretendard", - "fontStyle": "normal", - "fontWeight": 500, - "fontSize": "16px", - "lineHeight": 1.3, - "letterSpacing": "-0.03em" - }, - null, - null, - null, - { - "fontFamily": "Pretendard", - "fontStyle": "normal", - "fontWeight": 500, - "fontSize": "18px", - "lineHeight": 1.3, - "letterSpacing": "-0.03em" - } - ], - "inlineLabelS": { - "fontFamily": "Pretendard", - "fontStyle": "normal", - "fontWeight": 500, - "fontSize": "12px", - "lineHeight": 1.3, - "letterSpacing": "-0.03em" - }, - "body": [ - { - "fontFamily": "Pretendard", - "fontStyle": "normal", - "fontWeight": 400, - "fontSize": "15px", - "lineHeight": "24px", - "letterSpacing": "-0.01em" - }, - null, - null, - null, - { - "fontFamily": "Pretendard", - "fontStyle": "normal", - "fontWeight": 400, - "fontSize": "16px", - "lineHeight": "24px", - "letterSpacing": "-0.01em" - } - ], - "inputPhBold": [ - { - "fontFamily": "Pretendard", - "fontStyle": "normal", - "fontWeight": 600, - "fontSize": "13px", - "lineHeight": 1.2, - "letterSpacing": "-0.02em" - }, - null, - null, - null, - { - "fontFamily": "Pretendard", - "fontStyle": "normal", - "fontWeight": 600, - "fontSize": "14px", - "lineHeight": 1.2, - "letterSpacing": "-0.02em" - } - ], - "uploadButton": { - "fontFamily": "Pretendard", - "fontStyle": "normal", - "fontWeight": 600, - "fontSize": "14px", - "lineHeight": "24px", - "letterSpacing": "-0.01em" - }, - "pagination": { - "fontFamily": "Pretendard", - "fontStyle": "normal", - "fontWeight": 600, - "fontSize": "14px", - "lineHeight": 1.2, - "letterSpacing": "-0.02em" - }, - "paginationSelected": { - "fontFamily": "Pretendard", - "fontStyle": "normal", - "fontWeight": 900, - "fontSize": "15px", - "lineHeight": 1.2, - "letterSpacing": "-0.02em" - }, - "langMenu": { - "fontFamily": "Pretendard", - "fontStyle": "normal", - "fontWeight": 500, - "fontSize": "15px", - "lineHeight": 1.2, - "letterSpacing": "-0.02em" - }, - "langButton": [ - { - "fontFamily": "Pretendard", - "fontStyle": "normal", - "fontWeight": 700, - "fontSize": "14px", - "lineHeight": 1.2, - "letterSpacing": "-0.02em" - }, - null, - null, - null, - { - "fontFamily": "Pretendard", - "fontStyle": "normal", - "fontWeight": 700, - "fontSize": "16px", - "lineHeight": 1.2, - "letterSpacing": "-0.02em" - } - ], - "tableTitle": [ - { - "fontFamily": "Pretendard", - "fontStyle": "normal", - "fontWeight": 600, - "fontSize": "15px", - "lineHeight": 1.2, - "letterSpacing": "-0.01em" - }, - null, - null, - null, - { - "fontFamily": "Pretendard", - "fontStyle": "normal", - "fontWeight": 600, - "fontSize": "16px", - "lineHeight": 1.2, - "letterSpacing": "-0.01em" - } - ], - "footerL": { - "fontFamily": "Pretendard", - "fontStyle": "normal", - "fontWeight": 500, - "fontSize": "14px", - "lineHeight": 1.3, - "letterSpacing": "-0.02em" - }, - "footerM": { - "fontFamily": "Pretendard", - "fontStyle": "normal", - "fontWeight": 300, - "fontSize": "13px", - "lineHeight": "18px", - "letterSpacing": "-0.01em" - }, - "footerMsemibold": { - "fontFamily": "Pretendard", - "fontStyle": "normal", - "fontWeight": 600, - "fontSize": "13px", - "lineHeight": "18px", - "letterSpacing": "-0.01em" - }, - "footerS": { - "fontFamily": "Pretendard", - "fontStyle": "normal", - "fontWeight": 300, - "fontSize": "12px", - "lineHeight": 1.2, - "letterSpacing": "-0.02em" - }, - "footerxl": { - "fontFamily": "Pretendard", - "fontStyle": "normal", - "fontWeight": 700, - "fontSize": "15px", - "lineHeight": 1.3, - "letterSpacing": "-0.02em" - }, - "footerSsemibold": { - "fontFamily": "Pretendard", - "fontStyle": "normal", - "fontWeight": 600, - "fontSize": "12px", - "lineHeight": 1.2, - "letterSpacing": "-0.02em" - }, - "footerxs": { - "fontFamily": "Pretendard", - "fontStyle": "normal", - "fontWeight": 300, - "fontSize": "10px", - "lineHeight": 1.2, - "letterSpacing": "-0.02em" - }, - "footerXSsemibold": { - "fontFamily": "Pretendard", - "fontStyle": "normal", - "fontWeight": 600, - "fontSize": "10px", - "lineHeight": "18px", - "letterSpacing": "-0.02em" - }, - "footerNav": { - "fontFamily": "Pretendard", - "fontStyle": "normal", - "fontWeight": 500, - "fontSize": "15px", - "lineHeight": 1.3, - "letterSpacing": "-0.02em" - }, - "footerList": { - "fontFamily": "Pretendard", - "fontStyle": "normal", - "fontWeight": 500, - "fontSize": "14px", - "lineHeight": 1.2, - "letterSpacing": "-0.02em" - }, - "subMenu": [ - { - "fontFamily": "Pretendard", - "fontStyle": "normal", - "fontWeight": 600, - "fontSize": "15px", - "lineHeight": "24px", - "letterSpacing": "-0.01em" - }, - null, - null, - null, - { - "fontFamily": "Pretendard", - "fontStyle": "normal", - "fontWeight": 600, - "fontSize": "16px", - "lineHeight": "24px", - "letterSpacing": "-0.01em" - } - ], - "tableText": [ - { - "fontFamily": "Pretendard", - "fontStyle": "normal", - "fontWeight": 400, - "fontSize": "14px", - "lineHeight": "24px", - "letterSpacing": "-0.01em" - }, - null, - null, - null, - { - "fontFamily": "Pretendard", - "fontStyle": "normal", - "fontWeight": 400, - "fontSize": "15px", - "lineHeight": 1.4, - "letterSpacing": "-0.01em" - } - ], - "tableTextBold": [ - { - "fontFamily": "Pretendard", - "fontStyle": "normal", - "fontWeight": 600, - "fontSize": "14px", - "lineHeight": "24px", - "letterSpacing": "-0.01em" - }, - null, - null, - null, - { - "fontFamily": "Pretendard", - "fontStyle": "normal", - "fontWeight": 600, - "fontSize": "15px", - "lineHeight": 1.2, - "letterSpacing": "-0.01em" - } - ], - "resetButton": [ - { - "fontFamily": "Pretendard", - "fontStyle": "normal", - "fontWeight": 700, - "fontSize": "14px", - "lineHeight": 1.2, - "letterSpacing": "-0.02em" - }, - null, - null, - null, - { - "fontFamily": "Pretendard", - "fontStyle": "normal", - "fontWeight": 700, - "fontSize": "15px", - "lineHeight": 1.2, - "letterSpacing": "-0.02em" - } - ] } - } } \ No newline at end of file From 8759048d765cfe9546f1f872d49396399182a0aa Mon Sep 17 00:00:00 2001 From: Hanbin Cho Date: Thu, 7 Aug 2025 16:14:21 +0900 Subject: [PATCH 06/28] Update Select for sub menus --- .../src/components/Select/IconArrow.tsx | 24 ++++++++++ .../src/components/Select/Select.stories.tsx | 26 +++++++++++ .../src/components/Select/index.tsx | 44 +++++++++++++------ 3 files changed, 81 insertions(+), 13 deletions(-) create mode 100644 packages/components/src/components/Select/IconArrow.tsx diff --git a/packages/components/src/components/Select/IconArrow.tsx b/packages/components/src/components/Select/IconArrow.tsx new file mode 100644 index 00000000..f75f5822 --- /dev/null +++ b/packages/components/src/components/Select/IconArrow.tsx @@ -0,0 +1,24 @@ +import { css } from '@devup-ui/react' +import clsx from 'clsx' +import { ComponentProps } from 'react' + +export function IconArrow({ className, ...props }: ComponentProps<'svg'>) { + return ( + + + + ) +} diff --git a/packages/components/src/components/Select/Select.stories.tsx b/packages/components/src/components/Select/Select.stories.tsx index 26b8a040..be5d266a 100644 --- a/packages/components/src/components/Select/Select.stories.tsx +++ b/packages/components/src/components/Select/Select.stories.tsx @@ -1,3 +1,4 @@ +import { css, Flex } from '@devup-ui/react' import { Meta, StoryObj } from '@storybook/react-vite' import { @@ -7,6 +8,7 @@ import { SelectOption, SelectTrigger, } from '.' +import { IconArrow } from './IconArrow' type Story = StoryObj @@ -34,6 +36,30 @@ export const Default: Story = { Option 3 Option 4 + ), diff --git a/packages/components/src/components/Select/index.tsx b/packages/components/src/components/Select/index.tsx index 97d9305f..9c9f783a 100644 --- a/packages/components/src/components/Select/index.tsx +++ b/packages/components/src/components/Select/index.tsx @@ -3,8 +3,12 @@ import { Box, css, Flex, VStack } from '@devup-ui/react' import clsx from 'clsx' import { + Children, ComponentProps, createContext, + JSX, + JSXElementConstructor, + ReactElement, useContext, useEffect, useRef, @@ -17,7 +21,7 @@ import { IconCheck } from './IconCheck' type SelectType = 'default' | 'radio' | 'checkbox' type SelectValue = T extends 'radio' ? string : string[] -interface SelectProps { +interface SelectProps extends ComponentProps<'div'> { open?: boolean onOpenChange?: (open: boolean) => void children: React.ReactNode @@ -45,6 +49,7 @@ export function Select({ children, open: openProp, onOpenChange, + ...props }: SelectProps) { const ref = useRef(null) const [open, setOpen] = useState(openProp ?? false) @@ -90,28 +95,39 @@ export function Select({ type, }} > - + {children} ) } +interface SelectTriggerProps extends ComponentProps { + asChild?: boolean +} export function SelectTrigger({ className, children, + asChild, ...props -}: ComponentProps) { +}: SelectTriggerProps) { const { open, setOpen } = useSelect() const handleClick = () => { setOpen(!open) } + if (asChild) { + const element = Children.only(children) as ReactElement< + ComponentProps> + > + const Comp = element.type + return + } + return ( + + +`; + +exports[`ControlledRadio > should render 1`] = ` +
+
+ +
+
+`; + +exports[`Select > should render 1`] = ` +
+
+ +
+
+`; diff --git a/packages/components/src/components/Select/__tests__/index.browser.test.tsx b/packages/components/src/components/Select/__tests__/index.browser.test.tsx index 59b8f575..79933400 100644 --- a/packages/components/src/components/Select/__tests__/index.browser.test.tsx +++ b/packages/components/src/components/Select/__tests__/index.browser.test.tsx @@ -1,11 +1,207 @@ -import { render } from '@testing-library/react' +import { fireEvent, render } from '@testing-library/react' +import React from 'react' import { describe, expect, it } from 'vitest' -import { Select } from '..' +import { Select, SelectContainer, SelectOption, SelectTrigger } from '..' +import { Default } from '../Default' describe('Select', () => { it('should render', () => { - const { container } = render( + Select + + + Option 1 + + + Option 2 + + + , + ) + const selectToggle = container.querySelector('[aria-label="Select toggle"]') + fireEvent.click(selectToggle!) + const option2 = container.querySelector('[data-value="Option 2"]') + expect(option2).toBeInTheDocument() + fireEvent.click(option2!) + expect(onClick).toHaveBeenCalledWith('Option 2', expect.any(Object)) + }) + + it('should have a check mark when type is radio and defaultValue is provided', () => { + const { container } = render( + , + ) + const selectToggle = container.querySelector('[aria-label="Select toggle"]') + fireEvent.click(selectToggle!) + const option2 = container.querySelector('[data-value="Option 2"]') + expect(option2).toBeInTheDocument() + expect(option2?.querySelector('svg')).toBeInTheDocument() + }) + + it('should not have a check mark when type is radio and defaultValue is not provided', () => { + const { container } = render() + const selectToggle = container.querySelector('[aria-label="Select toggle"]') + fireEvent.click(selectToggle!) + const selectContainer = container.querySelector( + '[aria-label="Select container"]', + ) + expect(selectContainer).toBeInTheDocument() + expect(selectContainer?.querySelectorAll('svg')).toHaveLength(1) + }) + + it('should have 10px gap in an option when type is checkbox', () => { + const { container } = render() + const selectToggle = container.querySelector('[aria-label="Select toggle"]') + fireEvent.click(selectToggle!) + const option2 = container.querySelector('[data-value="Option 2"]') + expect(option2).toHaveClass('gap-0-10px--1') + }) + + it('should have 6px gap in an option when type is radio', () => { + const { container } = render() + const selectToggle = container.querySelector('[aria-label="Select toggle"]') + fireEvent.click(selectToggle!) + const option2 = container.querySelector('[data-value="Option 2"]') + expect(option2).toHaveClass('gap-0-6px--1') + }) + + it('should have 0 gap in an option when type is default', () => { + const { container } = render() + const selectToggle = container.querySelector('[aria-label="Select toggle"]') + fireEvent.click(selectToggle!) + const option2 = container.querySelector('[data-value="Option 2"]') + expect(option2).toHaveClass('gap-0-0--1') + }) + + it('should have undefined gap when type is not right', () => { + // @ts-expect-error - test for wrong type + const { container } = render() + const selectToggle = container.querySelector('[aria-label="Select toggle"]') + fireEvent.click(selectToggle!) + const option2 = container.querySelector('[data-value="Option 2"]') + expect(option2).not.toHaveClass('gap-0-0--1') + }) + + it('should handle ref.current being null by mocking useRef', () => { + const useRefSpy = vi + .spyOn(React, 'useRef') + .mockReturnValueOnce({ current: null }) + + const { container } = render() + + // The component should render without errors even with null ref + expect(container).toBeInTheDocument() + + // Trigger a click outside to test the useEffect logic with null ref + const selectToggle = container.querySelector('[aria-label="Select toggle"]') + fireEvent.click(selectToggle!) + expect(selectToggle).toHaveAttribute('aria-expanded', 'true') + + // Click outside - this should trigger the useEffect but return early due to null ref + fireEvent.click(document.body) + + // The select should still be open because the useEffect returned early + expect(useRefSpy).toHaveBeenCalledWith({ current: null }) + }) }) diff --git a/packages/components/src/components/Select/index.tsx b/packages/components/src/components/Select/index.tsx index 05a5b482..c8a70fe0 100644 --- a/packages/components/src/components/Select/index.tsx +++ b/packages/components/src/components/Select/index.tsx @@ -137,11 +137,20 @@ export function SelectTrigger({ ComponentProps> > const Comp = element.type - return + return ( + + ) } return (