Skip to content

Commit

Permalink
Merge pull request #105 from sopt-makers/fix/select-toggle
Browse files Browse the repository at this point in the history
[FIX] Input/Select 컴포넌트의 드롭다운이 정상적으로 토글되지 않는 문제 수정.
  • Loading branch information
Brokyeom authored Jul 1, 2024
2 parents 59f1b3a + a39f883 commit 66a5466
Show file tree
Hide file tree
Showing 2 changed files with 109 additions and 43 deletions.
5 changes: 5 additions & 0 deletions .changeset/two-mails-sin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@sopt-makers/ui": patch
---

fix : [Input/Select] 드롭다운의 정확한 토글을 위한 buttonRef 추가.
147 changes: 104 additions & 43 deletions packages/ui/Input/Select.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import { IconChevronDown, IconUser } from '@sopt-makers/icons';
import * as S from './style.css';
import { useState, useRef, useEffect, useCallback } from "react";
import { IconChevronDown, IconUser } from "@sopt-makers/icons";
import * as S from "./style.css";

export interface Option<T> {
label: string;
Expand All @@ -13,23 +13,32 @@ export interface Option<T> {
interface SelectProps<T> {
className?: string;
placeholder?: string;
type: 'text' | 'textDesc' | 'textIcon' | 'userList' | 'userListDesc';
type: "text" | "textDesc" | "textIcon" | "userList" | "userListDesc";
options: Option<T>[];
visibleOptions?: number;
defaultValue?: T;
onChange: (value: T) => void;
}

function Select<T extends string | number | boolean>(props: SelectProps<T>) {
const { className, placeholder, type, options, visibleOptions = 5, defaultValue, onChange } = props;
const {
className,
placeholder,
type,
options,
visibleOptions = 5,
defaultValue,
onChange,
} = props;

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

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

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

const handleToggleClose = useCallback(() => {
Expand All @@ -39,63 +48,115 @@ function Select<T extends string | number | boolean>(props: SelectProps<T>) {
const calcMaxHeight = useCallback(() => {
const getOptionHeight = () => {
switch (type) {
case 'text':
case 'textIcon':
case "text":
case "textIcon":
return 42;
case 'textDesc':
case 'userListDesc':
case "textDesc":
case "userListDesc":
return 62;
case 'userList':
case "userList":
return 48;
}
}
};
const gapHeight = 6;
const paddingHeight = 8;

return getOptionHeight() * visibleOptions + gapHeight * (visibleOptions - 1) + paddingHeight * 2;
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)) {
if (
optionsRef.current &&
!optionsRef.current.contains(event.target as Node) &&
buttonRef.current &&
!buttonRef.current.contains(event.target as Node)
) {
handleToggleClose();
}
};
document.addEventListener('mousedown', handleClickOutside);
document.addEventListener("mousedown", handleClickOutside);

return () => { document.removeEventListener('mousedown', handleClickOutside); };
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [handleToggleClose]);

const handleOptionClick = (value: T) => {
setSelected(value);
onChange(value);
handleToggleClose();
}

const selectedLabel = selected ? options.find(option => option.value === selected)?.label : placeholder;

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

{open ? <ul className={S.optionList} ref={optionsRef} style={{ maxHeight: calcMaxHeight() }}>
{options.map(option =>
<li key={option.label}>
<button className={S.option} onClick={() => { handleOptionClick(option.value); }} 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}
</div>
};

const selectedLabel = selected
? options.find((option) => option.value === selected)?.label
: placeholder;

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>

{open ? (
<ul
className={S.optionList}
ref={optionsRef}
style={{ maxHeight: calcMaxHeight() }}
>
{options.map((option) => (
<li key={option.label}>
<button
className={S.option}
onClick={() => {
handleOptionClick(option.value);
}}
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}
</div>
);
}

export default Select;
export default Select;

0 comments on commit 66a5466

Please sign in to comment.