From 13edc512011c9401618f14dce7544417fd1e5c66 Mon Sep 17 00:00:00 2001 From: jieunsse Date: Tue, 27 Jan 2026 01:21:36 +0900 Subject: [PATCH 1/5] =?UTF-8?q?chore:=20ignore=20=EC=84=B8=ED=8C=85=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 6edcd94..58ceba1 100644 --- a/.gitignore +++ b/.gitignore @@ -43,4 +43,5 @@ next-env.d.ts # personal doc +.documents/* .documents/daily-report.md From 7dea8691df7092011052c267bcd9a3a71ef8f3e3 Mon Sep 17 00:00:00 2001 From: jieunsse Date: Tue, 27 Jan 2026 01:23:32 +0900 Subject: [PATCH 2/5] =?UTF-8?q?feat:=20=EB=93=9C=EB=A1=AD=EB=8B=A4?= =?UTF-8?q?=EC=9A=B4=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/dropdown/Dropdown.tsx | 74 +++++++++++++++ src/components/dropdown/hooks/useDropdown.ts | 58 ++++++++++++ .../dropdown/styles/Dropdown.module.css | 94 +++++++++++++++++++ src/components/dropdown/types/types.ts | 38 ++++++++ src/components/dropdown/utils/dropdown.ts | 12 +++ 5 files changed, 276 insertions(+) create mode 100644 src/components/dropdown/Dropdown.tsx create mode 100644 src/components/dropdown/hooks/useDropdown.ts create mode 100644 src/components/dropdown/styles/Dropdown.module.css create mode 100644 src/components/dropdown/types/types.ts create mode 100644 src/components/dropdown/utils/dropdown.ts diff --git a/src/components/dropdown/Dropdown.tsx b/src/components/dropdown/Dropdown.tsx new file mode 100644 index 0000000..039427b --- /dev/null +++ b/src/components/dropdown/Dropdown.tsx @@ -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 = { + 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 ( +
+ + {isOpen ? ( +
+ {items.map((item) => ( + handleSelect(item.value)} + /> + ))} +
+ ) : null} +
+ ); +} diff --git a/src/components/dropdown/hooks/useDropdown.ts b/src/components/dropdown/hooks/useDropdown.ts new file mode 100644 index 0000000..bcfdb2f --- /dev/null +++ b/src/components/dropdown/hooks/useDropdown.ts @@ -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) => { + const nextFocus = event.relatedTarget as Node | null; + if (nextFocus && event.currentTarget.contains(nextFocus)) return; + setIsOpen(false); + }; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key !== 'Escape') return; + event.stopPropagation(); + setIsOpen(false); + }; + + return { + listboxId, + isOpen, + selectedItem, + handleSelect, + handleToggle, + handleBlur, + handleKeyDown, + }; +} diff --git a/src/components/dropdown/styles/Dropdown.module.css b/src/components/dropdown/styles/Dropdown.module.css new file mode 100644 index 0000000..56d9ecf --- /dev/null +++ b/src/components/dropdown/styles/Dropdown.module.css @@ -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; + } +} diff --git a/src/components/dropdown/types/types.ts b/src/components/dropdown/types/types.ts new file mode 100644 index 0000000..d86ef29 --- /dev/null +++ b/src/components/dropdown/types/types.ts @@ -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; +} diff --git a/src/components/dropdown/utils/dropdown.ts b/src/components/dropdown/utils/dropdown.ts new file mode 100644 index 0000000..2400816 --- /dev/null +++ b/src/components/dropdown/utils/dropdown.ts @@ -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]; +} From 9a51b24d0d82338ac30d4b70aaaaf3b0db1676b3 Mon Sep 17 00:00:00 2001 From: jieunsse Date: Tue, 27 Jan 2026 01:25:03 +0900 Subject: [PATCH 3/5] =?UTF-8?q?feat:=20=EB=93=9C=EB=A1=AD=EB=8B=A4?= =?UTF-8?q?=EC=9A=B4=20=EB=A9=94=EB=89=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/dropdown/DropdownItem.tsx | 24 ++++++++++++ .../dropdown/styles/DropdownItem.module.css | 37 +++++++++++++++++++ 2 files changed, 61 insertions(+) create mode 100644 src/components/dropdown/DropdownItem.tsx create mode 100644 src/components/dropdown/styles/DropdownItem.module.css diff --git a/src/components/dropdown/DropdownItem.tsx b/src/components/dropdown/DropdownItem.tsx new file mode 100644 index 0000000..526cd12 --- /dev/null +++ b/src/components/dropdown/DropdownItem.tsx @@ -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 ( + + ); +} diff --git a/src/components/dropdown/styles/DropdownItem.module.css b/src/components/dropdown/styles/DropdownItem.module.css new file mode 100644 index 0000000..7224b3f --- /dev/null +++ b/src/components/dropdown/styles/DropdownItem.module.css @@ -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; +} From d61405eee3bd78682d62cc27addcdc9363bb9a9a Mon Sep 17 00:00:00 2001 From: jieunsse Date: Tue, 27 Jan 2026 02:15:18 +0900 Subject: [PATCH 4/5] =?UTF-8?q?fix:=20=EB=B0=B0=ED=8F=AC=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 83ddd7a..3d4815f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,17 +14,17 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 9 + - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: 20 cache: 'pnpm' - - name: Setup pnpm - uses: pnpm/action-setup@v3 - with: - version: 9 - - name: Install dependencies run: pnpm install --frozen-lockfile From 018647742a7b299930b7ea29b119b0ab56601b94 Mon Sep 17 00:00:00 2001 From: jieunsse Date: Tue, 27 Jan 2026 16:55:25 +0900 Subject: [PATCH 5/5] =?UTF-8?q?refactor:=20=EC=A0=9C=EB=AF=B8=EB=82=98?= =?UTF-8?q?=EC=9D=B4=20=EC=BD=94=EB=93=9C=EB=A6=AC=EB=B7=B0=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/dropdown/Dropdown.tsx | 46 ++++++- src/components/dropdown/DropdownItem.tsx | 21 +-- src/components/dropdown/hooks/useDropdown.ts | 122 +++++++++++++++++- .../dropdown/styles/Dropdown.module.css | 4 + src/components/dropdown/types/types.ts | 3 + src/components/dropdown/utils/dropdown.ts | 2 +- 6 files changed, 179 insertions(+), 19 deletions(-) diff --git a/src/components/dropdown/Dropdown.tsx b/src/components/dropdown/Dropdown.tsx index 039427b..4368812 100644 --- a/src/components/dropdown/Dropdown.tsx +++ b/src/components/dropdown/Dropdown.tsx @@ -1,6 +1,7 @@ 'use client'; import clsx from 'clsx'; +import { useEffect, useRef } from 'react'; import DropdownItem from './DropdownItem'; import useDropdown from './hooks/useDropdown'; @@ -18,6 +19,7 @@ export default function Dropdown({ items, defaultValue, value, + placeholder, size = DEFAULT_SIZE, disabled = false, ariaLabel, @@ -27,13 +29,40 @@ export default function Dropdown({ itemClassName, onChange, }: DropdownProps) { - const { listboxId, isOpen, selectedItem, handleSelect, handleToggle, handleBlur, handleKeyDown } = - useDropdown({ items, defaultValue, value, disabled, onChange }); - const triggerAriaLabel = selectedItem ? undefined : ariaLabel; + const { + listboxId, + isOpen, + selectedItem, + activeIndex, + openByKeyboard, + shouldRestoreFocus, + setActiveIndex, + clearRestoreFocus, + handleSelect, + handleToggle, + handleBlur, + handleKeyDown, + } = useDropdown({ items, defaultValue, value, disabled, onChange }); + const triggerAriaLabel = selectedItem || placeholder ? undefined : ariaLabel; + const displayLabel = selectedItem?.label ?? placeholder; + const triggerRef = useRef(null); + const optionRefs = useRef>([]); + + useEffect(() => { + if (!isOpen || !openByKeyboard || activeIndex < 0) return; + optionRefs.current[activeIndex]?.focus(); + }, [activeIndex, isOpen, openByKeyboard]); + + useEffect(() => { + if (isOpen || !shouldRestoreFocus) return; + triggerRef.current?.focus(); + clearRestoreFocus(); + }, [clearRestoreFocus, isOpen, shouldRestoreFocus]); return (
diff --git a/src/components/dropdown/DropdownItem.tsx b/src/components/dropdown/DropdownItem.tsx index 526cd12..402e1dc 100644 --- a/src/components/dropdown/DropdownItem.tsx +++ b/src/components/dropdown/DropdownItem.tsx @@ -1,24 +1,29 @@ import clsx from 'clsx'; +import { forwardRef } from 'react'; import styles from './styles/DropdownItem.module.css'; import type { DropdownItemProps } from './types/types'; -export default function DropdownItem({ - label, - isSelected, - size, - className, - onSelect, -}: DropdownItemProps) { +const DropdownItem = forwardRef(function DropdownItem( + { label, isSelected, size, className, onSelect, tabIndex, onFocus }, + ref, +) { return ( ); -} +}); + +DropdownItem.displayName = 'DropdownItem'; + +export default DropdownItem; diff --git a/src/components/dropdown/hooks/useDropdown.ts b/src/components/dropdown/hooks/useDropdown.ts index bcfdb2f..3650c0a 100644 --- a/src/components/dropdown/hooks/useDropdown.ts +++ b/src/components/dropdown/hooks/useDropdown.ts @@ -1,5 +1,5 @@ import type { FocusEvent, KeyboardEvent } from 'react'; -import { useId, useState } from 'react'; +import { useId, useMemo, useState } from 'react'; import type { UseDropdownOptions } from '../types/types'; import { getSelectedItem, resolveInitialValue } from '../utils/dropdown'; @@ -13,12 +13,29 @@ export default function useDropdown({ }: UseDropdownOptions) { const listboxId = useId(); const [isOpen, setIsOpen] = useState(false); + const [openByKeyboard, setOpenByKeyboard] = useState(false); + const [shouldRestoreFocus, setShouldRestoreFocus] = useState(false); const [uncontrolledValue, setUncontrolledValue] = useState(() => resolveInitialValue(items, defaultValue), ); + const [activeIndex, setActiveIndex] = useState(null); const currentValue = value ?? uncontrolledValue; const selectedItem = getSelectedItem(items, currentValue); + const selectedIndex = useMemo( + () => items.findIndex((item) => item.value === currentValue), + [items, currentValue], + ); + const fallbackIndex = useMemo( + () => (selectedIndex >= 0 ? selectedIndex : items.length > 0 ? 0 : -1), + [items.length, selectedIndex], + ); + const resolvedActiveIndex = useMemo(() => { + if (items.length === 0) return -1; + const base = activeIndex ?? fallbackIndex; + if (base < 0) return 0; + return Math.min(base, items.length - 1); + }, [activeIndex, fallbackIndex, items.length]); const handleSelect = (nextValue: string) => { if (disabled) return; @@ -27,29 +44,124 @@ export default function useDropdown({ } onChange?.(nextValue); setIsOpen(false); + setActiveIndex(null); + setShouldRestoreFocus(true); }; const handleToggle = () => { if (disabled) return; - setIsOpen((prev) => !prev); + setIsOpen((prev) => { + const next = !prev; + if (next) { + setOpenByKeyboard(false); + setActiveIndex(fallbackIndex); + } else { + setActiveIndex(null); + } + return next; + }); }; const handleBlur = (event: FocusEvent) => { const nextFocus = event.relatedTarget as Node | null; if (nextFocus && event.currentTarget.contains(nextFocus)) return; setIsOpen(false); + setActiveIndex(null); + setShouldRestoreFocus(false); }; const handleKeyDown = (event: KeyboardEvent) => { - if (event.key !== 'Escape') return; - event.stopPropagation(); - setIsOpen(false); + if (disabled) return; + const target = event.target as HTMLElement | null; + const isTrigger = + target instanceof HTMLButtonElement && target.getAttribute('aria-haspopup') === 'listbox'; + + if (items.length === 0) { + if (event.key === 'Escape') { + event.stopPropagation(); + setIsOpen(false); + setActiveIndex(null); + setShouldRestoreFocus(true); + } + return; + } + + if (!isOpen) { + if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { + event.preventDefault(); + setIsOpen(true); + setOpenByKeyboard(true); + setActiveIndex(fallbackIndex); + } + return; + } + + if (isTrigger) { + if (event.key === 'Escape') { + event.stopPropagation(); + setIsOpen(false); + setActiveIndex(null); + setShouldRestoreFocus(true); + } + return; + } + + const maxIndex = items.length - 1; + setOpenByKeyboard(true); + + switch (event.key) { + case 'ArrowDown': { + event.preventDefault(); + const nextIndex = resolvedActiveIndex + 1 > maxIndex ? 0 : resolvedActiveIndex + 1; + setActiveIndex(nextIndex); + break; + } + case 'ArrowUp': { + event.preventDefault(); + const nextIndex = resolvedActiveIndex <= 0 ? maxIndex : resolvedActiveIndex - 1; + setActiveIndex(nextIndex); + break; + } + case 'Home': { + event.preventDefault(); + setActiveIndex(0); + break; + } + case 'End': { + event.preventDefault(); + setActiveIndex(maxIndex); + break; + } + case 'Enter': + case ' ': { + event.preventDefault(); + const item = items[resolvedActiveIndex]; + if (item) { + handleSelect(item.value); + } + break; + } + case 'Escape': { + event.stopPropagation(); + setIsOpen(false); + setActiveIndex(null); + setShouldRestoreFocus(true); + break; + } + default: + break; + } }; return { listboxId, isOpen, selectedItem, + activeIndex: resolvedActiveIndex, + openByKeyboard, + shouldRestoreFocus, + setActiveIndex, + clearRestoreFocus: () => setShouldRestoreFocus(false), handleSelect, handleToggle, handleBlur, diff --git a/src/components/dropdown/styles/Dropdown.module.css b/src/components/dropdown/styles/Dropdown.module.css index 56d9ecf..5508514 100644 --- a/src/components/dropdown/styles/Dropdown.module.css +++ b/src/components/dropdown/styles/Dropdown.module.css @@ -36,6 +36,10 @@ white-space: nowrap; } +.placeholder { + color: var(--Text-Placeholder, #94a3b8); +} + .icon { display: inline-flex; align-items: center; diff --git a/src/components/dropdown/types/types.ts b/src/components/dropdown/types/types.ts index d86ef29..75b086d 100644 --- a/src/components/dropdown/types/types.ts +++ b/src/components/dropdown/types/types.ts @@ -11,6 +11,7 @@ export interface DropdownProps { items: DropdownItemData[]; defaultValue?: string; value?: string; + placeholder?: string; size?: DropdownMenuSize; disabled?: boolean; ariaLabel?: string; @@ -27,6 +28,8 @@ export interface DropdownItemProps { size: DropdownMenuSize; className?: string; onSelect: () => void; + tabIndex?: number; + onFocus?: () => void; } export interface UseDropdownOptions { diff --git a/src/components/dropdown/utils/dropdown.ts b/src/components/dropdown/utils/dropdown.ts index 2400816..e55ed9a 100644 --- a/src/components/dropdown/utils/dropdown.ts +++ b/src/components/dropdown/utils/dropdown.ts @@ -8,5 +8,5 @@ export function resolveInitialValue(items: DropdownItemData[], defaultValue?: st } export function getSelectedItem(items: DropdownItemData[], currentValue: string) { - return items.find((item) => item.value === currentValue) ?? items[0]; + return items.find((item) => item.value === currentValue); }