-
Notifications
You must be signed in to change notification settings - Fork 3
Feature/dropdown 테스트 PR #2
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
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -43,4 +43,5 @@ next-env.d.ts | |
|
|
||
|
|
||
| # personal doc | ||
| .documents/* | ||
| .documents/daily-report.md | ||
| 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}> | ||
| <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> | ||
| ); | ||
| } | ||
| 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> | ||
| ); | ||
| } |
| 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, | ||
| }; | ||
| } |
| 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; | ||
| } | ||
| } |
| 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; | ||
| } |
| 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; | ||
| } |
| 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]; | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 현재 일치하는 아이템이 없을 경우
Suggested change
|
||||||
| } | ||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
현재 드롭다운 컴포넌트는 키보드 탐색 기능이 부족하여 웹 접근성을 저해할 수 있습니다. 'Escape' 키 외에도 다음과 같은 키보드 상호작용을 지원해야 합니다:
이러한 기능을 구현하려면
useDropdown훅에서 현재 포커스된 항목의 인덱스를 상태로 관리하고,handleKeyDown로직을 확장해야 합니다. 이는 WAI-ARIA 가이드라인을 준수하는 데 필수적입니다.