Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(Select): Select를서브 컴포넌트로 분리하여 구성 #144

Merged
merged 11 commits into from
Sep 25, 2024
Merged
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>;
Comment on lines +40 to +41
Copy link
Member

@Brokyeom Brokyeom Sep 24, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

여기 있었군요!

해당 부분 에러를 제거하고, root 외부에서도 단순 UI 용도로 사용할 수 있게 사용하게끔 하는게 더 좋을것 같다는 의견이신가요~?!

음.. 에러처리를 할 만큼 엄격하게 제한하지 않아도 된다는 생각이에요
이미 일부 컴포넌트를 분리해서 사용하도록 만드는 것이 해당 작업의 요지인만큼 작업자들이 의도대로 사용했는데 에러가 발생한다면 작업에 제한이 생길 것이라 생각이 들구요
이 부분에 대해서는 스토리북 문서 보강등을 통해 정책으로 풀어나가야 할 것 같습니다!

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

아니면 경고 정도로 좀 더 가벼운 워딩을 사용하는건 어떨까 싶네요!

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

음... 저도 사실 문서화를 통해 사용법만 잘 안내된다면 Root 밖에서 사용하는 케이스는 드물 것 같다는 생각이 들어요.
하지만 이렇게 커스텀 에러 처리를 하는 것도 좋아보여요!! Root 밖에서 사용한 케이스에서 에러 처리를 하지 않으면 Context Provider가 존재하지 않는다는 에러가 발생할 것으로 예상이 되는데요, 합성 컴포넌트 사용 패턴에 익숙치 않은 메이커분들이 해당 에러를 마주하면 충분히 혼란스러울 수 있기에 에러 해결 방안을 메시지로 제공하는 것도 좋을 것 같다는 생각이 듭니다!

Copy link
Member Author

@suwonthugger suwonthugger Sep 24, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

에러를 처리한 이유에 대해서 설명드리자면, 정우 말대로, root 밖에서 서브 컴포넌트를 사용했을 경우 context로 내려받을 상태나 함수 등이 없어 실제 에러가 발생합니다. 그래서 커스텀 에러로 어떤 에러인지 명시해주는게 나을것 같다고 생각하여 에러를 적용해주었습니다.

  • root 컴포넌트의 밖에서 사용했을 때, 에러가 뜨지 않게 하고, 독립적인 사용을 해야할까에 대한 고민이 관건이네요..!
    흠 저는 root안에서 사용하더라도 trigger와 MenuItem이라는 서브 컴포넌트의 조합으로 사용하는 방식이 Select라는 컴포넌트의 기본동작을 보장하면서도 어느정도 custom 할수 있는 여지가 있어 충분하지 않나 하는 의견입니다. 다른 분들 생각도 궁금해요

root 컴포넌트의 밖에서 사용했을 때, 에러가 뜨지 않게 하고, 독립적인 사용을 해야할까에 대한 고민이 관건이네요..!

만약 위 내용을 따른다면, context를 받아 올 수 없는 경우, 있는 경우로 분기하여 context 관련 로직을 모두 조건부로 처리해서 구현하는 방법이 떠올랐는데, 어떻게 구현하면 좋을지 다양한 의견을 듣고싶어요!

Copy link
Member

@jungwoo3490 jungwoo3490 Sep 24, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저는 서브컴포넌트의 독립적인 사용을 허용하지 않는 게 맞다고 생각합니다!!
컨텍스트 단위로 묶어 내부에서만 합성할 수 있도록 자유를 주는 게 합성 컴포넌트의 의의에 맞다고 생각해요!! 서브컴포넌트들을 메인컴포넌트 외부에서 사용하게 되면 컨텍스트 단위로 묶음으로써 얻는 합성 컴포넌트의 장점도 떨어지고, 사용측에 과하게 자유도가 높아 잘못 사용하게 될 위험이 커진다고 생각합니다!!
메인컴포넌트 내부에 서브컴포넌트가 존재해야 하되, 서브컴포넌트들의 순서나 조합은 사용측에 맡기는 수준의 자유를 주는 게 좋아보이네요. 외부에서 사용했을 경우에는 지금과 같이 커스텀 에러 훅으로 처리하는 것도 저는 좋아보여요!

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

좋습니다. 저도 다시한번 읽어보니 잘못 이해한 부분도 있었네요!
root 안에 Menuitem과 Trigger를 넣어줌으로써 저희가 의도한 커스텀 트리거 ui를 만들 수 있는데, 분리하기 위해선 root 밖으로 빼야 한다고 잘못 이해했네요 😓
현재 코드에서 변경사항은 없어도 될 것 같습니다. 워딩도 충분하다고 생각이 되구요!

}

// 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,
};

Brokyeom marked this conversation as resolved.
Show resolved Hide resolved
export default Select;
Loading
Loading