Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
2f29c2a
feat: 커스텀 드롭다운 컴포넌트 를 추가한다.
lepitaaar Sep 15, 2025
a137afb
feat: 드롭다운 메뉴 스타일을 자유롭게 변경할 수 있게 수정
lepitaaar Sep 15, 2025
e2f589c
feat: 드롭다운 메뉴를 커스텀 드롭다운으로 교체
lepitaaar Sep 15, 2025
f17ebbe
refactor: default 값을 전체로 변경
lepitaaar Sep 15, 2025
7954ef8
refactor: 기존 드롭다운 메뉴를 CustomDropDown 컴포넌트로 리팩토링
lepitaaar Sep 15, 2025
6462c46
feat: click outside close dropdown menu
lepitaaar Sep 16, 2025
8433910
feat: prevent 선택 메뉴
lepitaaar Sep 21, 2025
a698eb9
feat: 전체, 이름순 정렬 옵션 추가
lepitaaar Sep 21, 2025
875de8d
feat: 텍스트 가운데 정렬
lepitaaar Sep 21, 2025
228bde8
feat: change figma style guide
lepitaaar Sep 21, 2025
a96c1ef
feat: figma 디자인 시스템 적용
lepitaaar Sep 21, 2025
19dbdba
feat: 선택되었을때의 색상 통일
lepitaaar Sep 21, 2025
69f0f7b
style: 디자인에 맞게 변경
lepitaaar Oct 3, 2025
5165a86
fix: 여러개의 드롭다운 메뉴가 펼쳐지는 문제 해결
lepitaaar Oct 3, 2025
9b6c2e5
refactor: 과한 타입정의 제거
lepitaaar Oct 3, 2025
f63681d
refactor: onToggle 함수에 isOpen 인자 추가 및 실행 위치 재설계
lepitaaar Oct 3, 2025
1cd6e7f
style: 드롭다운 메뉴 호버시 마우스포인터 표시 변경
lepitaaar Oct 3, 2025
b24b4cb
refactor: 재설계된 dropdown에 맞게 리팩토링
lepitaaar Oct 3, 2025
9bc9af1
refactor: 재설계된 dropdown에 맞게 리팩토링
lepitaaar Oct 3, 2025
e3711ec
refactor: refactor to transient props
lepitaaar Oct 3, 2025
65c6fc9
fix: dropdowncontextprop과 dropdownprop의 type 불일치
lepitaaar Oct 3, 2025
bdc96ac
refactor: add default aria
lepitaaar Oct 3, 2025
d8843ca
refactor: aria 추가 및 제네릭 타입 제한
lepitaaar Oct 3, 2025
ac9ab83
refactor: remove memoization
lepitaaar Oct 3, 2025
a1c0351
refactor: 잘못된 타입캐스팅 수정
lepitaaar Oct 3, 2025
e476c11
refactor: as const 를 사용하여 타입 안전성 증가
lepitaaar Oct 3, 2025
972d708
refactor: add optional chaining
lepitaaar Oct 3, 2025
3af21b0
refactor: dropdownRef 오타 수정
lepitaaar Oct 3, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,58 +3,45 @@ import styled from 'styled-components';
export const DropDownWrapper = styled.div`
position: relative;
width: 100%;
`;

export const Selected = styled.div<{ open: boolean }>`
padding: 12px 16px;
border-radius: 0.375rem;
background: ${({ open }) => (open ? '#fff' : '#f5f5f5')};
color: #787878;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
border: 1px solid ${({ open }) => (open ? '#c5c5c5' : 'transparent')};
transition:
border-color 0.2s ease,
background-color 0.2s ease;

user-select: none;
`;

export const OptionList = styled.ul`
export const OptionList = styled.ul<{
$top?: string;
$width?: string;
$right?: string;
}>`
position: absolute;
top: 100%;
left: 0;
width: 100%;
background: #fff;
top: ${({ $top }) => $top || '110%'};
left: ${({ $right }) => ($right ? 'auto' : '0')};
width: ${({ $width }) => $width || '100%'};
right: ${({ $right }) => $right || 'auto'};
border-radius: 6px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.05);
border: 1px solid #dcdcdc;
background: #fff;
box-shadow: 0 1px 8px 0 rgba(0, 0, 0, 0.12);
padding: 6px 0;
z-index: 10;
height: auto;
list-style: none;
`;

export const OptionItem = styled.li<{ isSelected: boolean }>`
export const OptionItem = styled.li<{ $isSelected: boolean }>`
display: flex;
align-items: center;
justify-content: center;
text-align: center;
padding: 10px;
margin: 4px;
font-weight: 600;
border-radius: 6px;
color: #787878;
background-color: ${({ isSelected }) => (isSelected ? '#DCDCDC' : '#fff')};
background-color: ${({ $isSelected }) => ($isSelected ? '#f5f5f5' : '#fff')};
cursor: pointer;
padding: 8px 13px;
height: 27px;

&:hover {
background-color: #dcdcdc;
background-color: #f5f5f5;
}

transition: background-color 0.2s ease;
user-select: none;
`;

export const Icon = styled.img`
position: absolute;
top: 50%;
right: 19px;
transform: translateY(-50%);
pointer-events: none;
`;
139 changes: 104 additions & 35 deletions frontend/src/components/common/CustomDropDown/CustomDropDown.tsx
Original file line number Diff line number Diff line change
@@ -1,50 +1,119 @@
import { useState } from 'react';
import React, { createContext, useContext, ReactNode } from 'react';
import * as Styled from './CustomDropDown.styles';
import dropdown_icon from '@/assets/images/icons/drop_button_icon.svg';

interface DropdownOption {
interface DropdownOption<TValue> {
label: string;
value: string;
value: TValue;
}

interface DropdownProps {
options: DropdownOption[];
selected: string;
onSelect: (value: string) => void;
interface CustomDropDownContextProps<TValue> {
open: boolean;
selected?: TValue;
options: readonly DropdownOption<TValue>[];
onToggle: (isOpen: boolean) => void;
handleSelect: (value: TValue) => void;
}

const CustomDropdown = ({ options, selected, onSelect }: DropdownProps) => {
const [open, setOpen] = useState(false);
interface CustomDropDownProps<TValue> {
children: ReactNode;
options: readonly DropdownOption<TValue>[];
selected?: TValue;
onSelect: (value: TValue) => void;
open: boolean;
onToggle: (isOpen: boolean) => void;
style?: React.CSSProperties;
}

interface ItemProps<TValue> {
value: TValue;
children: ReactNode;
style?: React.CSSProperties;
}

const CustomDropDownContext = createContext<
CustomDropDownContextProps<any> | undefined
>(undefined);
Comment on lines +33 to +35
Copy link
Member

Choose a reason for hiding this comment

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

context 쓰신 이유가 각각의 드롭다운 상태를 독립적으로 유지하기 위해서인가요?

Copy link
Contributor Author

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.

그럼 각각 다른 드롭다운 컴포넌트 두개를 생성하면 서로 상태를 공유하나요?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

서로 상태를 공유하지않고 각자 상태를 가지게됩니다


const useDropDownContext = () => {
const context = useContext(CustomDropDownContext);
if (!context) {
throw new Error(
'useDropDownContext는 CustomDropDownContextProvider 내부에서 사용할 수 있습니다.',
);
}
return context;
};

const Trigger = ({ children }: { children: ReactNode }) => {
const { onToggle, open } = useDropDownContext();
return (
<div
onClick={() => {
onToggle(open);
}}
>
{children}
</div>
);
};

const handleSelect = (value: string) => {
interface MenuProps {
children: ReactNode;
top?: string;
width?: string;
right?: string;
}

const Menu = ({ children, top, width, right }: MenuProps) => {
const { open } = useDropDownContext();
return open ? (
<Styled.OptionList role='listbox' $top={top} $width={width} $right={right}>
{children}
</Styled.OptionList>
) : null;
};

const Item = <TValue extends string | number = string>({
value,
children,
style,
}: ItemProps<TValue>) => {
const { selected, handleSelect } = useDropDownContext();
return (
<Styled.OptionItem
role='option'
$isSelected={value === selected}
onClick={() => handleSelect(value)}
style={style}
>
{children}
</Styled.OptionItem>
);
};

export function CustomDropDown<T extends string | number = string>({
children,
options,
selected,
onSelect,
open,
onToggle,
style,
}: CustomDropDownProps<T>) {
const handleSelect = (value: T) => {
onSelect(value);
setOpen(false);
onToggle(open);
};

const selectedLabel =
options.find((option) => option.value === selected)?.label || '선택하세요';
const value = { open, selected, options, onToggle, handleSelect };

return (
<Styled.DropDownWrapper>
<Styled.Selected onClick={() => setOpen((prev) => !prev)} open={open}>
<span>{selectedLabel}</span>
<Styled.Icon src={dropdown_icon} alt='드롭다운 버튼' />
</Styled.Selected>
{open && (
<Styled.OptionList>
{options.map(({ label, value }) => (
<Styled.OptionItem
key={value}
isSelected={value === selected}
onClick={() => handleSelect(value)}
>
{label}
</Styled.OptionItem>
))}
</Styled.OptionList>
)}
</Styled.DropDownWrapper>
<CustomDropDownContext.Provider value={value}>
<Styled.DropDownWrapper style={style}>{children}</Styled.DropDownWrapper>
</CustomDropDownContext.Provider>
);
};
}

export default CustomDropdown;
CustomDropDown.Trigger = Trigger;
CustomDropDown.Menu = Menu;
CustomDropDown.Item = Item;
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,30 @@ export const SelectionToggleButton = styled.button<{ active: boolean }>`
color 0.2s ease;
`;

export const Selected = styled.div<{ open: boolean }>`
padding: 12px 16px;
border-radius: 0.375rem;
background: ${({ open }) => (open ? '#fff' : '#f5f5f5')};
color: #787878;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
border: 1px solid ${({ open }) => (open ? '#c5c5c5' : 'transparent')};
transition:
border-color 0.2s ease,
background-color 0.2s ease;

user-select: none;
`;

export const Icon = styled.img`
position: absolute;
top: 50%;
right: 19px;
transform: translateY(-50%);
pointer-events: none;
`;

export const DeleteButton = styled.button`
display: flex;
align-items: center;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@ import { QuestionBuilderProps } from '@/types/application';
import { QUESTION_LABEL_MAP } from '@/constants/APPLICATION_FORM';
import { DROPDOWN_OPTIONS } from '@/constants/APPLICATION_FORM';
import * as Styled from './QuestionBuilder.styles';
import CustomDropdown from '@/components/common/CustomDropDown/CustomDropDown';
import DeleteIcon from '@/assets/images/icons/delete_question.svg';
import { CustomDropDown } from '@/components/common/CustomDropDown/CustomDropDown';
import dropdown_icon from '@/assets/images/icons/drop_button_icon.svg';

const QuestionBuilder = ({
id,
Expand All @@ -33,6 +34,8 @@ const QuestionBuilder = ({
type === 'MULTI_CHOICE' ? 'multi' : 'single',
);

const [isDropdownOpen, setIsDropdownOpen] = useState(false);

useEffect(() => {
if (type === 'MULTI_CHOICE') {
setSelectionType('multi');
Expand Down Expand Up @@ -118,6 +121,11 @@ const QuestionBuilder = ({
);
};

const selectedType = type === 'MULTI_CHOICE' ? 'CHOICE' : type;
const selectedLabel = DROPDOWN_OPTIONS.find(
(option) => option.value === selectedType,
)?.label;

return (
<Styled.QuestionWrapper readOnly={readOnly}>
<Styled.QuestionMenu>
Expand All @@ -127,13 +135,36 @@ const QuestionBuilder = ({
답변 필수
<Styled.RequiredToggleCircle active={options?.required} />
</Styled.RequiredToggleButton>
<CustomDropdown
selected={type === 'MULTI_CHOICE' ? 'CHOICE' : type}
<CustomDropDown
selected={selectedType}
options={DROPDOWN_OPTIONS}
onSelect={(value) => {
onTypeChange?.(value as QuestionType);
}}
/>
open={isDropdownOpen}
onToggle={(isOpen) => setIsDropdownOpen(!isOpen)}
>
<CustomDropDown.Trigger>
<Styled.Selected open={isDropdownOpen}>
<span>{selectedLabel}</span>
<Styled.Icon src={dropdown_icon} alt='드롭다운 버튼' />
</Styled.Selected>
</CustomDropDown.Trigger>
<CustomDropDown.Menu>
{DROPDOWN_OPTIONS.map(({ label, value }) => (
<CustomDropDown.Item
key={value}
value={value}
style={{
fontSize: '14px',
height: '35px',
}}
>
{label}
</CustomDropDown.Item>
))}
</CustomDropDown.Menu>
</CustomDropDown>
{renderSelectionToggle()}
{!readOnly && (
<Styled.DeleteButton type='button' onClick={() => onRemoveQuestion()}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -153,13 +153,16 @@ export const StatusSelectMenuItem = styled.div`
}
`;

export const ApplicantFilterSelect = styled.select`
export const ApplicantFilterSelect = styled.div`
display: flex;
align-items: center;
height: 35px;
padding: 4px 32px 4px 14px;
border-radius: 8px;
border: none;
background: var(--f5, #f5f5f5);
font-size: 16px;
color: #000;

-webkit-appearance: none;
-moz-appearance: none;
Expand Down Expand Up @@ -282,7 +285,8 @@ export const ApplicantAllSelectWrapper = styled.div`

export const ApplicantAllSelectArrow = styled.img`
position: absolute;
right: -1px;
right: -18px;
top: -7px;
width: 16px;
height: 16px;
object-fit: none;
Expand Down
Loading