Skip to content

Commit

Permalink
feat(Select): Select를서브 컴포넌트로 분리하여 구성 (#144)
Browse files Browse the repository at this point in the history
* feat(Select): Select 컴포넌트를 서브 컴포넌트로 분리

* fix(Select): 불필요한 코드 수정

* feat(Select): Menu, MenuItem 컴포넌트 분리 및 options prop 삭제

* fix(Select): placeholder prop 위치 수정 및 불필요한 context 삭제

* refactor(Select): onChange 함수 조건부로 변경

* fix(Select): 함수 위치 일부 수정

* feat(Select): deprecated 폴더 추가 및 기존 컴포넌트 생성, jsdocs로 설명추가

* remove(Select): displayName 제거 및 불필요한 prop 삭제

* �docs(Select): jsdoc @deprecated 명시

Co-authored-by: HyeongKyeom Kim <97586683+Brokyeom@users.noreply.github.com>

* cs

---------

Co-authored-by: HyeongKyeom Kim <97586683+Brokyeom@users.noreply.github.com>
Co-authored-by: Brokyeom <goindol40@gmail.com>
  • Loading branch information
3 people authored Sep 25, 2024
1 parent e7164f2 commit 932893f
Show file tree
Hide file tree
Showing 6 changed files with 457 additions and 192 deletions.
5 changes: 5 additions & 0 deletions .changeset/spicy-ducks-beam.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sopt-makers/ui': minor
---

Input/SelectV2 컴포넌트 출시
229 changes: 168 additions & 61 deletions packages/ui/Input/Select.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -12,27 +12,46 @@ export interface Option<T> {

interface SelectProps<T> {
className?: string;
placeholder?: string;
type: 'text' | 'textDesc' | 'textIcon' | 'userList' | 'userListDesc';
options: Option<T>[];
visibleOptions?: number;
defaultValue?: Option<T>;
onChange: (value: T) => void;
onChange?: (value: T) => void;
children: React.ReactNode;
}

interface SelectContextProps<T> {
open: boolean;
setOpen: (open: boolean) => void;
selected: Option<T> | null;
handleOptionClick: (option: Option<T>) => void;
type: SelectProps<T>['type'];
buttonRef: React.RefObject<HTMLButtonElement>;
optionsRef: React.RefObject<HTMLUListElement>;
calcMaxHeight: () => number;
}

function Select<T extends string | number | boolean>(props: SelectProps<T>) {
const { className, placeholder, type, options, visibleOptions = 5, defaultValue, onChange } = props;
// SelectContext: Select.root 하위 컴포넌트들이 사용할 Context
const SelectContext = createContext({});

// useSelectContext: Select 컴포넌트 외부에서 서브 컴포넌트들이 사용됐을 때 에러 처리
function useSelectContext<T>() {
const context = useContext(SelectContext);
if (Object.keys(context).length === 0) {
throw new Error('Select 컴포넌트는 Select.Root 내에서 사용되어야 합니다.');
}
return context as SelectContextProps<T>;
}

// SelectRoot 컴포넌트: Select 컴포넌트에게 context를 제공
function SelectRoot<T extends string | number | boolean>(props: SelectProps<T>) {
const { children, onChange, defaultValue, type, visibleOptions = 5, className } = props;

const optionsRef = useRef<HTMLUListElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
const optionsRef = useRef<HTMLUListElement>(null);

const [selected, setSelected] = useState<Option<T> | null>(defaultValue ?? null);
const [open, setOpen] = useState(false);

const handleToggleOpen = useCallback(() => {
setOpen((prev) => !prev);
}, []);

const handleToggleClose = useCallback(() => {
setOpen(false);
}, []);
Expand Down Expand Up @@ -67,69 +86,157 @@ function Select<T extends string | number | boolean>(props: SelectProps<T>) {
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<T>) => {
setSelected(value);
onChange(value.value); // value 자체가 아닌 value.value를 onChange에 전달
const handleOptionClick = (option: Option<T>) => {
setSelected(option);
handleToggleClose();

if (onChange) {
onChange(option.value);
}
};

const selectedLabel = selected ? options.find((option) => option.value === selected.value)?.label : placeholder;
const contextValue: SelectContextProps<T> = {
open,
setOpen,
selected,
handleOptionClick,
type,
buttonRef,
optionsRef,
calcMaxHeight,
};

return (
<div className={`${S.selectWrap} ${className}`}>
<button className={S.select} onClick={handleToggleOpen} ref={buttonRef} type='button'>
<p className={!selected ? S.selectPlaceholder : ''}>{selectedLabel}</p>
<IconChevronDown
style={{
width: 20,
height: 20,
transform: open ? 'rotate(-180deg)' : '',
transition: 'all 0.5s',
}}
/>
</button>
<SelectContext.Provider value={contextValue}>
<div className={`${S.selectWrap} ${className}`}>{children}</div>
</SelectContext.Provider>
);
}

{open ? (
<ul className={S.optionList} ref={optionsRef} style={{ maxHeight: calcMaxHeight() }}>
{options.map((option) => (
<li key={option.label}>
<button
className={S.option}
onClick={() => {
handleOptionClick(option); // Option<T> 타입으로 전달
}}
type='button'
>
{type === 'textIcon' && option.icon}
{(type === 'userList' || type === 'userListDesc') &&
(option.profileUrl ? (
<img alt={option.label} className={S.optionProfileImg} src={option.profileUrl} />
) : (
<div className={S.optionProfileEmpty}>
<IconUser />
</div>
))}

<div>
<p>{option.label}</p>
{(type === 'textDesc' || type === 'userListDesc') && (
<p className={S.optionDesc}>{option.description}</p>
)}
</div>
</button>
</li>
))}
</ul>
) : null}
// Select.Trigger 컴포넌트: 메뉴를 열고 닫는 trigger
interface SelectTriggerProps {
children: React.ReactNode;
}

function SelectTrigger({ children }: SelectTriggerProps) {
const { open, setOpen, buttonRef } = useSelectContext();

const handleClick = () => {
setOpen(!open);
};

return (
<button className={S.buttonWithNoStyle} onClick={handleClick} ref={buttonRef} type='button'>
{children}
</button>
);
}

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 (
<div className={`${S.select} ${className ? className : ''}`}>
<p className={!selected ? S.selectPlaceholder : ''}>{selectedLabel}</p>
<IconChevronDown
style={{
width: 20,
height: 20,
transform: open ? 'rotate(-180deg)' : '',
transition: 'all 0.3s ease',
}}
/>
</div>
);
}

interface SelectMenuProps {
children: React.ReactNode;
}

// SelectMenu 컴포넌트: 옵션 목록을 렌더링
function SelectMenu({ children }: SelectMenuProps) {
const { open, optionsRef, calcMaxHeight } = useSelectContext();

if (!open) {
return null;
}

return (
<ul className={S.optionList} ref={optionsRef} style={{ maxHeight: calcMaxHeight() }}>
{children}
</ul>
);
}

interface SelectMenuItemProps<T> {
option: Option<T>;
onClick?: () => void;
}

// SelectMenuItem 컴포넌트: 옵션 목록 하나의 UI
function SelectMenuItem<T>({ option, onClick }: SelectMenuItemProps<T>) {
const { open, type, handleOptionClick } = useSelectContext();

const handleClick = () => {
handleOptionClick(option);

if (onClick) {
onClick();
}
};

if (!open) {
return null;
}

return (
<li>
<button className={S.option} onClick={handleClick} type='button'>
{type === 'textIcon' && option.icon}
{(type === 'userList' || type === 'userListDesc') &&
(option.profileUrl ? (
<img alt={option.label} className={S.optionProfileImg} src={option.profileUrl} />
) : (
<div className={S.optionProfileEmpty}>
<IconUser />
</div>
))}

<div>
<p>{option.label}</p>
{(type === 'textDesc' || type === 'userListDesc') && <p className={S.optionDesc}>{option.description}</p>}
</div>
</button>
</li>
);
}

const Select = {
Root: SelectRoot,
Trigger: SelectTrigger,
TriggerContent: SelectTriggerContent,
Menu: SelectMenu,
MenuItem: SelectMenuItem,
};

export default Select;
Loading

0 comments on commit 932893f

Please sign in to comment.