diff --git a/.changeset/spicy-ducks-beam.md b/.changeset/spicy-ducks-beam.md new file mode 100644 index 0000000..1016668 --- /dev/null +++ b/.changeset/spicy-ducks-beam.md @@ -0,0 +1,5 @@ +--- +'@sopt-makers/ui': minor +--- + +Input/SelectV2 컴포넌트 출시 diff --git a/packages/ui/Input/Select.tsx b/packages/ui/Input/Select.tsx index ddd784b..41397eb 100644 --- a/packages/ui/Input/Select.tsx +++ b/packages/ui/Input/Select.tsx @@ -1,4 +1,4 @@ -import { useState, useRef, useEffect, useCallback } from 'react'; +import { useState, useRef, useEffect, useCallback, createContext, useContext } from 'react'; import { IconChevronDown, IconUser } from '@sopt-makers/icons'; import * as S from './style.css'; @@ -12,27 +12,46 @@ export interface Option { interface SelectProps { className?: string; - placeholder?: string; type: 'text' | 'textDesc' | 'textIcon' | 'userList' | 'userListDesc'; - options: Option[]; visibleOptions?: number; defaultValue?: Option; - onChange: (value: T) => void; + onChange?: (value: T) => void; + children: React.ReactNode; +} + +interface SelectContextProps { + open: boolean; + setOpen: (open: boolean) => void; + selected: Option | null; + handleOptionClick: (option: Option) => void; + type: SelectProps['type']; + buttonRef: React.RefObject; + optionsRef: React.RefObject; + calcMaxHeight: () => number; } -function Select(props: SelectProps) { - const { className, placeholder, type, options, visibleOptions = 5, defaultValue, onChange } = props; +// SelectContext: Select.root 하위 컴포넌트들이 사용할 Context +const SelectContext = createContext({}); + +// useSelectContext: Select 컴포넌트 외부에서 서브 컴포넌트들이 사용됐을 때 에러 처리 +function useSelectContext() { + const context = useContext(SelectContext); + if (Object.keys(context).length === 0) { + throw new Error('Select 컴포넌트는 Select.Root 내에서 사용되어야 합니다.'); + } + return context as SelectContextProps; +} + +// SelectRoot 컴포넌트: Select 컴포넌트에게 context를 제공 +function SelectRoot(props: SelectProps) { + const { children, onChange, defaultValue, type, visibleOptions = 5, className } = props; - const optionsRef = useRef(null); const buttonRef = useRef(null); + const optionsRef = useRef(null); const [selected, setSelected] = useState | null>(defaultValue ?? null); const [open, setOpen] = useState(false); - const handleToggleOpen = useCallback(() => { - setOpen((prev) => !prev); - }, []); - const handleToggleClose = useCallback(() => { setOpen(false); }, []); @@ -67,69 +86,157 @@ function Select(props: SelectProps) { handleToggleClose(); } }; - document.addEventListener('mousedown', handleClickOutside); + if (open) { + document.addEventListener('mousedown', handleClickOutside); + } else { + document.removeEventListener('mousedown', handleClickOutside); + } return () => { document.removeEventListener('mousedown', handleClickOutside); }; - }, [handleToggleClose]); + }, [handleToggleClose, open]); - const handleOptionClick = (value: Option) => { - setSelected(value); - onChange(value.value); // value 자체가 아닌 value.value를 onChange에 전달 + const handleOptionClick = (option: Option) => { + setSelected(option); handleToggleClose(); + + if (onChange) { + onChange(option.value); + } }; - const selectedLabel = selected ? options.find((option) => option.value === selected.value)?.label : placeholder; + const contextValue: SelectContextProps = { + open, + setOpen, + selected, + handleOptionClick, + type, + buttonRef, + optionsRef, + calcMaxHeight, + }; return ( -
- + +
{children}
+
+ ); +} - {open ? ( -
    - {options.map((option) => ( -
  • - -
  • - ))} -
- ) : null} +// Select.Trigger 컴포넌트: 메뉴를 열고 닫는 trigger +interface SelectTriggerProps { + children: React.ReactNode; +} + +function SelectTrigger({ children }: SelectTriggerProps) { + const { open, setOpen, buttonRef } = useSelectContext(); + + const handleClick = () => { + setOpen(!open); + }; + + return ( + + ); +} + +interface SelectTriggerContentProps { + className?: string; + placeholder?: string; +} + +// Select.TriggerContent 컴포넌트: trigger의 미리 정의된 UI +function SelectTriggerContent({ className, placeholder }: SelectTriggerContentProps) { + const { open, selected } = useSelectContext(); + + const selectedLabel = selected ? selected.label : placeholder; + + return ( +
+

{selectedLabel}

+
); } +interface SelectMenuProps { + children: React.ReactNode; +} + +// SelectMenu 컴포넌트: 옵션 목록을 렌더링 +function SelectMenu({ children }: SelectMenuProps) { + const { open, optionsRef, calcMaxHeight } = useSelectContext(); + + if (!open) { + return null; + } + + return ( +
    + {children} +
+ ); +} + +interface SelectMenuItemProps { + option: Option; + onClick?: () => void; +} + +// SelectMenuItem 컴포넌트: 옵션 목록 하나의 UI +function SelectMenuItem({ option, onClick }: SelectMenuItemProps) { + const { open, type, handleOptionClick } = useSelectContext(); + + const handleClick = () => { + handleOptionClick(option); + + if (onClick) { + onClick(); + } + }; + + if (!open) { + return null; + } + + return ( +
  • + +
  • + ); +} + +const Select = { + Root: SelectRoot, + Trigger: SelectTrigger, + TriggerContent: SelectTriggerContent, + Menu: SelectMenu, + MenuItem: SelectMenuItem, +}; + export default Select; diff --git a/packages/ui/Input/deprecated/Select.tsx b/packages/ui/Input/deprecated/Select.tsx new file mode 100644 index 0000000..ded7e15 --- /dev/null +++ b/packages/ui/Input/deprecated/Select.tsx @@ -0,0 +1,140 @@ +import { useState, useRef, useEffect, useCallback } from 'react'; +import { IconChevronDown, IconUser } from '@sopt-makers/icons'; +import * as S from '../style.css'; + +export interface Option { + label: string; + value: T; + description?: string; + icon?: React.ReactNode; + profileUrl?: string; +} + +interface SelectProps { + className?: string; + placeholder?: string; + type: 'text' | 'textDesc' | 'textIcon' | 'userList' | 'userListDesc'; + options: Option[]; + visibleOptions?: number; + defaultValue?: Option; + onChange: (value: T) => void; +} + +/** + * @deprecated + * Select 컴포넌트는 더 이상 권장되지 않지만 계속 사용할 수 있습니다. + * 새로운 `SelectV2` 컴포넌트 사용을 권장합니다. + */ +function Select(props: SelectProps) { + const { className, placeholder, type, options, visibleOptions = 5, defaultValue, onChange } = props; + + const optionsRef = useRef(null); + const buttonRef = useRef(null); + + const [selected, setSelected] = useState | null>(defaultValue ?? null); + const [open, setOpen] = useState(false); + + const handleToggleOpen = useCallback(() => { + setOpen((prev) => !prev); + }, []); + + const handleToggleClose = useCallback(() => { + setOpen(false); + }, []); + + const calcMaxHeight = useCallback(() => { + const getOptionHeight = () => { + switch (type) { + case 'text': + case 'textIcon': + return 42; + case 'textDesc': + case 'userListDesc': + return 62; + case 'userList': + return 48; + } + }; + const gapHeight = 6; + const paddingHeight = 8; + + return getOptionHeight() * visibleOptions + gapHeight * (visibleOptions - 1) + paddingHeight * 2; + }, [visibleOptions, type]); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + optionsRef.current && + !optionsRef.current.contains(event.target as Node) && + buttonRef.current && + !buttonRef.current.contains(event.target as Node) + ) { + handleToggleClose(); + } + }; + document.addEventListener('mousedown', handleClickOutside); + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [handleToggleClose]); + + const handleOptionClick = (value: Option) => { + setSelected(value); + onChange(value.value); // value 자체가 아닌 value.value를 onChange에 전달 + handleToggleClose(); + }; + + const selectedLabel = selected ? options.find((option) => option.value === selected.value)?.label : placeholder; + + return ( +
    + + + {open ? ( +
      + {options.map((option) => ( +
    • + +
    • + ))} +
    + ) : null} +
    + ); +} + +export default Select; diff --git a/packages/ui/Input/index.tsx b/packages/ui/Input/index.tsx index 13a7c7d..e6f74e7 100644 --- a/packages/ui/Input/index.tsx +++ b/packages/ui/Input/index.tsx @@ -1,5 +1,10 @@ +// 현행 컴포넌트 + export { default as TextField } from './TextField'; export { default as TextArea } from './TextArea'; export { default as SearchField } from './SearchField'; -export { default as Select } from './Select'; -export { default as UserMention } from './UserMention'; \ No newline at end of file +export { default as SelectV2 } from './Select'; +export { default as UserMention } from './UserMention'; + +// deprecated 컴포넌트 +export { default as Select } from './deprecated/Select'; diff --git a/packages/ui/Input/style.css.ts b/packages/ui/Input/style.css.ts index 3ac168f..399157c 100644 --- a/packages/ui/Input/style.css.ts +++ b/packages/ui/Input/style.css.ts @@ -1,71 +1,71 @@ -import { globalStyle, style, keyframes } from "@vanilla-extract/css"; -import theme from "../theme.css"; +import { globalStyle, style, keyframes } from '@vanilla-extract/css'; +import theme from '../theme.css'; const fadeIn = keyframes({ - "0%": { opacity: 0, transform: "translateY(0)" }, - "100%": { opacity: 1, transform: "translateY(10px)" }, + '0%': { opacity: 0, transform: 'translateY(0)' }, + '100%': { opacity: 1, transform: 'translateY(10px)' }, }); export const label = style({ ...theme.fontsObject.LABEL_3_14_SB, - display: "flex", - flexDirection: "column", - textAlign: "left", + display: 'flex', + flexDirection: 'column', + textAlign: 'left', color: theme.colors.white, }); export const input = style({ ...theme.fontsObject.BODY_2_16_M, - background: theme.colors.gray800, - border: "1px solid transparent", - borderRadius: "10px", - width: "100%", - height: "48px", - padding: "10px 16px", - color: theme.colors.white, - boxSizing: "border-box", - - "::placeholder": { + 'background': theme.colors.gray800, + 'border': '1px solid transparent', + 'borderRadius': '10px', + 'width': '100%', + 'height': '48px', + 'padding': '10px 16px', + 'color': theme.colors.white, + 'boxSizing': 'border-box', + + '::placeholder': { color: theme.colors.gray300, }, - ":focus": { + ':focus': { border: `1px solid ${theme.colors.gray200}`, - outline: "none", + outline: 'none', }, - ":disabled": { + ':disabled': { color: theme.colors.gray500, }, - ":read-only": { - cursor: "default", + ':read-only': { + cursor: 'default', }, }); export const textarea = style({ - resize: "none", - paddingRight: 0, - display: "block", + 'resize': 'none', + 'paddingRight': 0, + 'display': 'block', - "::-webkit-scrollbar": { - width: "48px", + '::-webkit-scrollbar': { + width: '48px', }, - "::-webkit-scrollbar-thumb": { + '::-webkit-scrollbar-thumb': { backgroundColor: theme.colors.gray500, - backgroundClip: "padding-box", - border: "4px solid transparent", + backgroundClip: 'padding-box', + border: '4px solid transparent', boxShadow: `inset -36px 0 0 ${theme.colors.gray800}`, - borderRadius: "6px", + borderRadius: '6px', }, - "::-webkit-scrollbar-track": { - backgroundColor: "transparent", + '::-webkit-scrollbar-track': { + backgroundColor: 'transparent', }, }); export const searchField = style({ - paddingRight: "48px", + paddingRight: '48px', }); export const inputWrap = style({ - textAlign: "left", + textAlign: 'left', }); export const inputError = style({ @@ -73,27 +73,27 @@ export const inputError = style({ }); export const inputBottom = style({ - marginTop: "8px", - display: "flex", - justifyContent: "space-between", - alignItems: "center", + marginTop: '8px', + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', }); export const required = style({ color: theme.colors.secondary, - marginLeft: "4px", + marginLeft: '4px', }); export const description = style({ ...theme.fontsObject.LABEL_4_12_SB, color: theme.colors.gray300, - marginBottom: "8px", + marginBottom: '8px', }); export const errorMessage = style({ - display: "flex", - alignItems: "center", - gap: "4px", + display: 'flex', + alignItems: 'center', + gap: '4px', color: theme.colors.error, ...theme.fontsObject.LABEL_4_12_SB, }); @@ -108,42 +108,42 @@ export const maxCount = style({ }); export const submitButton = style({ - background: "none", - border: "none", - width: "48px", - height: "48px", - position: "absolute", - right: 0, - ":hover": { - cursor: "pointer", + 'background': 'none', + 'border': 'none', + 'width': '48px', + 'height': '48px', + 'position': 'absolute', + 'right': 0, + ':hover': { + cursor: 'pointer', }, - ":disabled": { - cursor: "not-allowed", + ':disabled': { + cursor: 'not-allowed', }, }); export const selectWrap = style({ - position: "relative", - display: "inline-block", + position: 'relative', + display: 'inline-block', }); export const select = style({ ...theme.fontsObject.BODY_2_16_M, - color: theme.colors.white, - width: "160px", - height: "48px", - borderRadius: "10px", - background: theme.colors.gray800, - border: "1px solid transparent", - padding: "11px 16px", - display: "flex", - justifyContent: "space-between", - alignItems: "center", - gap: "12px", - cursor: "pointer", - transition: "border 0.2s", - - ":focus": { + 'color': theme.colors.white, + 'width': '160px', + 'height': '48px', + 'borderRadius': '10px', + 'background': theme.colors.gray800, + 'border': '1px solid transparent', + 'padding': '11px 16px', + 'display': 'flex', + 'justifyContent': 'space-between', + 'alignItems': 'center', + 'gap': '12px', + 'cursor': 'pointer', + 'transition': 'border 0.2s', + + ':focus': { border: `1px solid ${theme.colors.gray200}`, }, }); @@ -153,50 +153,50 @@ export const selectPlaceholder = style({ }); export const optionList = style({ - position: "absolute", - display: "flex", - flexDirection: "column", - width: "max-content", - gap: "6px", - padding: "8px", - borderRadius: "13px", - minWidth: "160px", - background: theme.colors.gray800, - overflow: "scroll", - transformOrigin: "top", - animation: `${fadeIn} 0.5s forwards`, - overflowX: "hidden", - - "::-webkit-scrollbar": { - width: "16px", + 'position': 'absolute', + 'display': 'flex', + 'flexDirection': 'column', + 'width': 'max-content', + 'gap': '6px', + 'padding': '8px', + 'borderRadius': '13px', + 'minWidth': '160px', + 'background': theme.colors.gray800, + 'overflow': 'scroll', + 'transformOrigin': 'top', + 'animation': `${fadeIn} 0.5s forwards`, + 'overflowX': 'hidden', + + '::-webkit-scrollbar': { + width: '16px', }, - "::-webkit-scrollbar-thumb": { + '::-webkit-scrollbar-thumb': { background: theme.colors.gray500, - backgroundClip: "padding-box", - border: "4px solid transparent", + backgroundClip: 'padding-box', + border: '4px solid transparent', boxShadow: `inset -3px 0 0 ${theme.colors.gray800}`, - borderRadius: "8px", + borderRadius: '8px', }, }); export const option = style({ ...theme.fontsObject.BODY_2_16_M, - border: "none", - background: "none", - borderRadius: "8px", - color: theme.colors.gray10, - padding: "8px 12px", - width: "100%", - textAlign: "left", - cursor: "pointer", - display: "flex", - alignItems: "center", - gap: "10px", - - ":hover": { + 'border': 'none', + 'background': 'none', + 'borderRadius': '8px', + 'color': theme.colors.gray10, + 'padding': '8px 12px', + 'width': '100%', + 'textAlign': 'left', + 'cursor': 'pointer', + 'display': 'flex', + 'alignItems': 'center', + 'gap': '10px', + + ':hover': { background: theme.colors.gray700, }, - ":active": { + ':active': { background: theme.colors.gray600, }, }); @@ -204,25 +204,25 @@ export const option = style({ export const optionDesc = style({ ...theme.fontsObject.BODY_4_13_R, color: theme.colors.gray100, - overflow: "hidden", - whiteSpace: "nowrap", - textOverflow: "ellipsis", + overflow: 'hidden', + whiteSpace: 'nowrap', + textOverflow: 'ellipsis', }); export const optionProfileImg = style({ - width: "32px", - height: "32px", - borderRadius: "50%", + width: '32px', + height: '32px', + borderRadius: '50%', }); export const optionProfileEmpty = style({ - width: "32px", - height: "32px", - borderRadius: "50%", + width: '32px', + height: '32px', + borderRadius: '50%', background: theme.colors.gray700, - display: "flex", - justifyContent: "center", - alignItems: "center", + display: 'flex', + justifyContent: 'center', + alignItems: 'center', }); export const userMention = style({ @@ -231,10 +231,10 @@ export const userMention = style({ }); export const userMentionItem = style({ - ":hover": { + ':hover': { background: theme.colors.gray800, }, - ":active": { + ':active': { background: theme.colors.gray700, }, }); @@ -244,15 +244,23 @@ globalStyle(`${inputWrap} > ${description}`, { }); globalStyle(`${label} > span`, { - marginBottom: "8px", + marginBottom: '8px', }); globalStyle(`${option} > svg`, { - width: "16px", - height: "16px", + width: '16px', + height: '16px', }); globalStyle(`${optionProfileEmpty} > svg`, { - width: "20px", - height: "20px", + width: '20px', + height: '20px', +}); + +export const buttonWithNoStyle = style({ + background: 'none', + border: 'none', + padding: 0, + margin: 0, + cursor: 'pointer', }); diff --git a/packages/ui/index.ts b/packages/ui/index.ts index c039bff..3ca876a 100644 --- a/packages/ui/index.ts +++ b/packages/ui/index.ts @@ -2,16 +2,16 @@ export * from './cssVariables'; // component exports export { default as Button } from './Button'; -export { CheckBox, Toggle, Radio } from "./Control"; +export { CheckBox, Toggle, Radio } from './Control'; export { Dialog, DialogContext, DialogProvider, useDialog } from './Dialog'; export type { DialogOptionType } from './Dialog'; export { ToastProvider, useToast, Toast } from './Toast'; export type { ToastOptionType } from './Toast'; -export { TextField, TextArea, SearchField, Select, UserMention } from "./Input"; -export { default as Tag } from "./Tag"; +export { TextField, TextArea, SearchField, SelectV2, Select, UserMention } from './Input'; +export { default as Tag } from './Tag'; export { default as Chip } from './Chip'; -export { default as Callout } from "./Callout"; -export { default as Tab } from "./Tab"; +export { default as Callout } from './Callout'; +export { default as Tab } from './Tab'; // test component export { default as Test } from './Test';