Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -43,4 +43,5 @@ next-env.d.ts


# personal doc
.documents/*
.documents/daily-report.md
74 changes: 74 additions & 0 deletions src/components/dropdown/Dropdown.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
'use client';

import clsx from 'clsx';

import DropdownItem from './DropdownItem';
import useDropdown from './hooks/useDropdown';
import styles from './styles/Dropdown.module.css';
import type { DropdownMenuSize, DropdownProps } from './types/types';

const DEFAULT_SIZE: DropdownMenuSize = 'default';
const MENU_SIZE_CLASS: Record<DropdownMenuSize, string> = {
default: styles.menuDefault,
small: styles.menuSmall,
repeat: styles.menuRepeat,
};

export default function Dropdown({
items,
defaultValue,
value,
size = DEFAULT_SIZE,
disabled = false,
ariaLabel,
className,
buttonClassName,
menuClassName,
itemClassName,
onChange,
}: DropdownProps) {
const { listboxId, isOpen, selectedItem, handleSelect, handleToggle, handleBlur, handleKeyDown } =
useDropdown({ items, defaultValue, value, disabled, onChange });
const triggerAriaLabel = selectedItem ? undefined : ariaLabel;

return (
<div className={clsx(styles.dropdown, className)} onBlur={handleBlur} onKeyDown={handleKeyDown}>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

현재 드롭다운 컴포넌트는 키보드 탐색 기능이 부족하여 웹 접근성을 저해할 수 있습니다. 'Escape' 키 외에도 다음과 같은 키보드 상호작용을 지원해야 합니다:

  • 방향키 (위/아래): 드롭다운 메뉴의 항목들을 탐색합니다.
  • Enter / Space: 포커스된 항목을 선택합니다.
  • Home / End: 각각 첫 번째와 마지막 항목으로 이동합니다.

이러한 기능을 구현하려면 useDropdown 훅에서 현재 포커스된 항목의 인덱스를 상태로 관리하고, handleKeyDown 로직을 확장해야 합니다. 이는 WAI-ARIA 가이드라인을 준수하는 데 필수적입니다.

<button
type="button"
className={clsx(styles.button, disabled && styles.disabled, buttonClassName)}
aria-haspopup="listbox"
aria-expanded={isOpen}
aria-controls={listboxId}
aria-label={triggerAriaLabel}
disabled={disabled}
onClick={handleToggle}
>
<span className={styles.label}>{selectedItem?.label}</span>
<span className={clsx(styles.icon, isOpen && styles.iconOpen)} aria-hidden="true">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M3.5 6L8 10.5L12.5 6" stroke="currentColor" strokeWidth="1.6" />
</svg>
</span>
</button>
{isOpen ? (
<div
id={listboxId}
className={clsx(styles.menu, MENU_SIZE_CLASS[size], menuClassName)}
role="listbox"
aria-label={ariaLabel}
>
{items.map((item) => (
<DropdownItem
key={item.value}
label={item.label}
isSelected={item.value === selectedItem?.value}
size={size}
className={itemClassName}
onSelect={() => handleSelect(item.value)}
/>
))}
</div>
) : null}
</div>
);
}
24 changes: 24 additions & 0 deletions src/components/dropdown/DropdownItem.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import clsx from 'clsx';

import styles from './styles/DropdownItem.module.css';
import type { DropdownItemProps } from './types/types';

export default function DropdownItem({
label,
isSelected,
size,
className,
onSelect,
}: DropdownItemProps) {
return (
<button
type="button"
className={clsx(styles.item, styles[size], isSelected && styles.selected, className)}
role="option"
aria-selected={isSelected}
onClick={onSelect}
>
{label}
</button>
);
}
58 changes: 58 additions & 0 deletions src/components/dropdown/hooks/useDropdown.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import type { FocusEvent, KeyboardEvent } from 'react';
import { useId, useState } from 'react';

import type { UseDropdownOptions } from '../types/types';
import { getSelectedItem, resolveInitialValue } from '../utils/dropdown';

export default function useDropdown({
items,
defaultValue,
value,
disabled = false,
onChange,
}: UseDropdownOptions) {
const listboxId = useId();
const [isOpen, setIsOpen] = useState(false);
const [uncontrolledValue, setUncontrolledValue] = useState(() =>
resolveInitialValue(items, defaultValue),
);

const currentValue = value ?? uncontrolledValue;
const selectedItem = getSelectedItem(items, currentValue);

const handleSelect = (nextValue: string) => {
if (disabled) return;
if (value === undefined) {
setUncontrolledValue(nextValue);
}
onChange?.(nextValue);
setIsOpen(false);
};

const handleToggle = () => {
if (disabled) return;
setIsOpen((prev) => !prev);
};

const handleBlur = (event: FocusEvent<HTMLDivElement>) => {
const nextFocus = event.relatedTarget as Node | null;
if (nextFocus && event.currentTarget.contains(nextFocus)) return;
setIsOpen(false);
};

const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
if (event.key !== 'Escape') return;
event.stopPropagation();
setIsOpen(false);
};

return {
listboxId,
isOpen,
selectedItem,
handleSelect,
handleToggle,
handleBlur,
handleKeyDown,
};
}
94 changes: 94 additions & 0 deletions src/components/dropdown/styles/Dropdown.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
.dropdown {
position: relative;
display: inline-flex;
flex-direction: column;
}

.button {
display: inline-flex;
align-items: center;
justify-content: space-between;
gap: 10px;
width: 120px;
height: 44px;
padding: 10px 14px;
border-radius: 12px;
border: 1px solid var(--Border-Primary, #e2e8f0);
background: var(--Background-Primary, #ffffff);
color: var(--Text-Default, #64748b);
font-family: Pretendard, sans-serif;
font-weight: 500;
font-size: 14px;
line-height: 17px;
letter-spacing: 0%;
box-sizing: border-box;
cursor: pointer;
}

.button:focus-visible {
outline: 2px solid #94a3b8;
outline-offset: 2px;
}

.label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

.icon {
display: inline-flex;
align-items: center;
justify-content: center;
transition: transform 120ms ease;
}

.iconOpen {
transform: rotate(180deg);
}

.menu {
position: absolute;
top: calc(100% + 6px);
left: 0;
z-index: 10;
display: flex;
flex-direction: column;
align-items: stretch;
padding: 8px 0;
border-radius: 12px;
border: 1px solid var(--Border-Primary, #e2e8f0);
background: var(--Background-Primary, #ffffff);
box-sizing: border-box;
}

.menuDefault {
width: 135px;
min-height: 184px;
}

.menuSmall {
width: 120px;
min-height: 160px;
}

.menuRepeat {
width: 109px;
min-height: 160px;
}

.disabled {
cursor: not-allowed;
opacity: 0.6;
}

@media (max-width: 480px) {
.button {
width: 94px;
height: 40px;
padding: 8px;
border-radius: 8px;
font-size: 12px;
line-height: 14px;
}
}
37 changes: 37 additions & 0 deletions src/components/dropdown/styles/DropdownItem.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
.item {
width: 100%;
border: none;
background: transparent;
padding: 8px 12px;
text-align: center;
color: #111827;
font-family: Pretendard, sans-serif;
font-weight: 400;
cursor: pointer;
}

.item:focus-visible {
outline: 2px solid #94a3b8;
outline-offset: -2px;
}

.default {
font-size: 16px;
line-height: 19px;
}

.small {
font-size: 14px;
line-height: 17px;
}

.repeat {
font-size: 14px;
line-height: 17px;
text-align: left;
margin-left: 16px;
}

.selected {
font-weight: 600;
}
38 changes: 38 additions & 0 deletions src/components/dropdown/types/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import type { ReactNode } from 'react';

export type DropdownMenuSize = 'default' | 'small' | 'repeat';

export interface DropdownItemData {
value: string;
label: ReactNode;
}

export interface DropdownProps {
items: DropdownItemData[];
defaultValue?: string;
value?: string;
size?: DropdownMenuSize;
disabled?: boolean;
ariaLabel?: string;
className?: string;
buttonClassName?: string;
menuClassName?: string;
itemClassName?: string;
onChange?: (value: string) => void;
}

export interface DropdownItemProps {
label: ReactNode;
isSelected: boolean;
size: DropdownMenuSize;
className?: string;
onSelect: () => void;
}

export interface UseDropdownOptions {
items: DropdownItemData[];
defaultValue?: string;
value?: string;
disabled?: boolean;
onChange?: (value: string) => void;
}
12 changes: 12 additions & 0 deletions src/components/dropdown/utils/dropdown.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import type { DropdownItemData } from '../types/types';

export function resolveInitialValue(items: DropdownItemData[], defaultValue?: string) {
if (defaultValue && items.some((item) => item.value === defaultValue)) {
return defaultValue;
}
return items[0]?.value ?? '';
}

export function getSelectedItem(items: DropdownItemData[], currentValue: string) {
return items.find((item) => item.value === currentValue) ?? items[0];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

현재 getSelectedItem 함수는 currentValue에 해당하는 아이템이 없을 경우 첫 번째 아이템(items[0])을 반환합니다. 이로 인해 부모 컴포넌트에서 전달한 value prop과 실제 드롭다운에 표시되는 값이 달라지는 불일치가 발생할 수 있습니다. 이는 사용자 및 개발자에게 혼란을 줄 수 있습니다.

일치하는 아이템이 없을 경우 undefined를 반환하도록 수정하고, useDropdown 훅에서 이 경우를 처리하는 것이 더 견고한 설계입니다. 예를 들어, 개발 환경에서 경고를 출력할 수 있습니다.

Suggested change
return items.find((item) => item.value === currentValue) ?? items[0];
return items.find((item) => item.value === currentValue);

}
Loading