-
Notifications
You must be signed in to change notification settings - Fork 0
[FEAT] Dropdown 컴포넌트 구현 #78
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
base: develop
Are you sure you want to change the base?
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 |
|---|---|---|
| @@ -1,4 +1,8 @@ | ||
| @import "./tokens/colors.css"; | ||
| @import "./tokens/typography.css"; | ||
| @import "./tokens/radius.css"; | ||
| @import "./scrollbar.css"; | ||
| @import "./scrollbar.css"; | ||
|
|
||
| @theme { | ||
| --shadow-timo: 0px 0px 3px 0px rgba(0, 0, 0, 0.12); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,24 @@ | ||
| import { useState } from "react"; | ||
|
|
||
| import { Dropdown } from "./Dropdown"; | ||
|
Member
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. 요거 절대 경로로 통일해 줍시당 |
||
|
|
||
| import type { Meta, StoryObj } from "@storybook/react"; | ||
|
|
||
| const meta = { | ||
| title: "Components/Dropdown", | ||
| component: Dropdown, | ||
| parameters: { layout: "centered" }, | ||
| } satisfies Meta<typeof Dropdown>; | ||
|
|
||
| export default meta; | ||
| type Story = StoryObj<typeof meta>; | ||
|
|
||
| const DefaultStory = () => { | ||
| const [value, setValue] = useState(""); | ||
| return <Dropdown items={["기본", "7일"]} value={value} onChange={setValue} />; | ||
| }; | ||
|
|
||
| export const Default: Story = { | ||
| args: { items: [], value: "", onChange: () => {} }, | ||
| render: () => <DefaultStory />, | ||
| }; | ||
|
ehye1 marked this conversation as resolved.
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,122 @@ | ||||||||||||||||||||||||||||||||||
| import { cn } from "@lib"; | ||||||||||||||||||||||||||||||||||
| import { useEffect, useRef, useState } from "react"; | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| export interface DropdownProps { | ||||||||||||||||||||||||||||||||||
| items: string[]; | ||||||||||||||||||||||||||||||||||
| value: string; | ||||||||||||||||||||||||||||||||||
| onChange: (value: string) => void; | ||||||||||||||||||||||||||||||||||
| placeholder?: string; | ||||||||||||||||||||||||||||||||||
| className?: string; | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| export const Dropdown = ({ | ||||||||||||||||||||||||||||||||||
|
Member
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. 제가 작성해 둔 이전 PR을 구현해 놓고 보니 해당 뷰 드롭다운 컴포넌트도 컴파운드 패턴 적용해서 구현해 볼 수 있을 것 같네요! 구현해 주신 선택 시 자동 닫힘 & 닫힌 후 트리거로 포커스 복귀 기능은 컴파운드 패턴 전용 컴포넌트에 제가 구현해 놓겠습니다!
Contributor
Author
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. 그렇네요 너무 좋습니다🫰🏻💍 폴더도 그에 맞춰 수정하겠습니다! |
||||||||||||||||||||||||||||||||||
| items, | ||||||||||||||||||||||||||||||||||
| value, | ||||||||||||||||||||||||||||||||||
| onChange, | ||||||||||||||||||||||||||||||||||
| placeholder = "기본", | ||||||||||||||||||||||||||||||||||
| className, | ||||||||||||||||||||||||||||||||||
| }: DropdownProps) => { | ||||||||||||||||||||||||||||||||||
| const [isOpen, setIsOpen] = useState(false); | ||||||||||||||||||||||||||||||||||
| const containerRef = useRef<HTMLDivElement>(null); | ||||||||||||||||||||||||||||||||||
| const triggerRef = useRef<HTMLButtonElement>(null); | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| const handleSelect = (item: string) => { | ||||||||||||||||||||||||||||||||||
| onChange(item); | ||||||||||||||||||||||||||||||||||
| setIsOpen(false); | ||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||
|
Comment on lines
+23
to
+26
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. 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win 옵션 선택 후 포커스가 돌아오지 않아요. Escape/외부 클릭 시에는 🎯 제안 diff const handleSelect = (item: string) => {
onChange(item);
- setIsOpen(false);
+ closeWithFocus();
};(단, 관련 문서: Accessible Combobox Pattern: ARIA + Keyboard Guide 🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| const closeWithFocus = () => { | ||||||||||||||||||||||||||||||||||
| setIsOpen(false); | ||||||||||||||||||||||||||||||||||
| triggerRef.current?.focus(); | ||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| useEffect(() => { | ||||||||||||||||||||||||||||||||||
| if (!isOpen) return; | ||||||||||||||||||||||||||||||||||
| const handleKeyDown = (e: KeyboardEvent) => { | ||||||||||||||||||||||||||||||||||
| if (e.key === "Escape") closeWithFocus(); | ||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||
| const handleClickOutside = (e: MouseEvent) => { | ||||||||||||||||||||||||||||||||||
| if ( | ||||||||||||||||||||||||||||||||||
| containerRef.current && | ||||||||||||||||||||||||||||||||||
| !containerRef.current.contains(e.target as Node) | ||||||||||||||||||||||||||||||||||
| ) { | ||||||||||||||||||||||||||||||||||
| closeWithFocus(); | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||
|
Comment on lines
+38
to
+45
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. 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win 외부 클릭 시 강제 포커스 복귀가 오히려 사용자 의도를 방해할 수 있어요.
💡 제안 diff const handleClickOutside = (e: MouseEvent) => {
if (
containerRef.current &&
!containerRef.current.contains(e.target as Node)
) {
- closeWithFocus();
+ setIsOpen(false);
}
};📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||
| document.addEventListener("keydown", handleKeyDown); | ||||||||||||||||||||||||||||||||||
| document.addEventListener("mousedown", handleClickOutside); | ||||||||||||||||||||||||||||||||||
| return () => { | ||||||||||||||||||||||||||||||||||
| document.removeEventListener("keydown", handleKeyDown); | ||||||||||||||||||||||||||||||||||
| document.removeEventListener("mousedown", handleClickOutside); | ||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||
| }, [isOpen]); | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| return ( | ||||||||||||||||||||||||||||||||||
| <div | ||||||||||||||||||||||||||||||||||
| ref={containerRef} | ||||||||||||||||||||||||||||||||||
| className={cn("inline-flex flex-col", isOpen && "shadow-timo", className)} | ||||||||||||||||||||||||||||||||||
| > | ||||||||||||||||||||||||||||||||||
| <button | ||||||||||||||||||||||||||||||||||
| ref={triggerRef} | ||||||||||||||||||||||||||||||||||
| type="button" | ||||||||||||||||||||||||||||||||||
| onClick={() => setIsOpen((prev) => !prev)} | ||||||||||||||||||||||||||||||||||
| aria-haspopup="listbox" | ||||||||||||||||||||||||||||||||||
| aria-expanded={isOpen} | ||||||||||||||||||||||||||||||||||
| className={cn( | ||||||||||||||||||||||||||||||||||
| "flex h-8 items-center gap-2.5 bg-white px-2", | ||||||||||||||||||||||||||||||||||
| "border-timo-gray-500 border", | ||||||||||||||||||||||||||||||||||
| isOpen ? "rounded-t-[4px]" : "rounded-[4px]", | ||||||||||||||||||||||||||||||||||
| )} | ||||||||||||||||||||||||||||||||||
| > | ||||||||||||||||||||||||||||||||||
| <span className="typo-headline-m-14 text-timo-gray-900 whitespace-nowrap"> | ||||||||||||||||||||||||||||||||||
| {value || placeholder} | ||||||||||||||||||||||||||||||||||
| </span> | ||||||||||||||||||||||||||||||||||
| <div className="flex size-6 shrink-0 items-center justify-center"> | ||||||||||||||||||||||||||||||||||
| <svg | ||||||||||||||||||||||||||||||||||
| width="16" | ||||||||||||||||||||||||||||||||||
| height="8" | ||||||||||||||||||||||||||||||||||
| viewBox="0 0 16 8" | ||||||||||||||||||||||||||||||||||
| fill="none" | ||||||||||||||||||||||||||||||||||
| aria-hidden="true" | ||||||||||||||||||||||||||||||||||
| className="text-timo-gray-900" | ||||||||||||||||||||||||||||||||||
| > | ||||||||||||||||||||||||||||||||||
| <path | ||||||||||||||||||||||||||||||||||
| d={isOpen ? "M1 7L8 1L15 7" : "M1 1L8 7L15 1"} | ||||||||||||||||||||||||||||||||||
| stroke="currentColor" | ||||||||||||||||||||||||||||||||||
| strokeWidth="1.5" | ||||||||||||||||||||||||||||||||||
| strokeLinecap="round" | ||||||||||||||||||||||||||||||||||
| strokeLinejoin="round" | ||||||||||||||||||||||||||||||||||
| /> | ||||||||||||||||||||||||||||||||||
| </svg> | ||||||||||||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||||||||||||
| </button> | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| {isOpen && ( | ||||||||||||||||||||||||||||||||||
| <ul | ||||||||||||||||||||||||||||||||||
| role="listbox" | ||||||||||||||||||||||||||||||||||
| className="border-timo-gray-500 flex flex-col gap-1.5 rounded-b-[4px] border-x border-b bg-white px-2 py-1" | ||||||||||||||||||||||||||||||||||
| > | ||||||||||||||||||||||||||||||||||
| {items.map((item, i) => ( | ||||||||||||||||||||||||||||||||||
| <li key={item} className="flex flex-col gap-1"> | ||||||||||||||||||||||||||||||||||
|
Comment on lines
+99
to
+100
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. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
As per path instructions, "동적 리스트의 key prop: 반드시 고유 id 사용 (index 금지)" — 현재 문자열 자체를 key로 쓰고 있어 고유성이 보장되지 않습니다. 🔑 제안 diff (임시 방어)- {items.map((item, i) => (
- <li key={item} className="flex flex-col gap-1">
+ {items.map((item, i) => (
+ <li key={`${item}-${i}`} className="flex flex-col gap-1">근본적으로는 📝 Committable suggestion
Suggested change
🤖 Prompt for AI AgentsSource: Path instructions |
||||||||||||||||||||||||||||||||||
| <span | ||||||||||||||||||||||||||||||||||
| role="option" | ||||||||||||||||||||||||||||||||||
| aria-selected={item === value} | ||||||||||||||||||||||||||||||||||
| tabIndex={0} | ||||||||||||||||||||||||||||||||||
| onClick={() => handleSelect(item)} | ||||||||||||||||||||||||||||||||||
| onKeyDown={(e) => { | ||||||||||||||||||||||||||||||||||
| if (e.key === "Enter" || e.key === " ") handleSelect(item); | ||||||||||||||||||||||||||||||||||
| }} | ||||||||||||||||||||||||||||||||||
| className="typo-body-m-12 text-timo-gray-900 cursor-pointer" | ||||||||||||||||||||||||||||||||||
| > | ||||||||||||||||||||||||||||||||||
| {item} | ||||||||||||||||||||||||||||||||||
| </span> | ||||||||||||||||||||||||||||||||||
| {i < items.length - 1 && ( | ||||||||||||||||||||||||||||||||||
| <div className="bg-timo-gray-500 h-px w-full" /> | ||||||||||||||||||||||||||||||||||
| )} | ||||||||||||||||||||||||||||||||||
| </li> | ||||||||||||||||||||||||||||||||||
| ))} | ||||||||||||||||||||||||||||||||||
| </ul> | ||||||||||||||||||||||||||||||||||
| )} | ||||||||||||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||
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.
📐 Maintainability & Code Quality | 🔵 Trivial
🧩 Analysis chain
🏁 Script executed:
Repository: Team-Timo/Timo-client
Length of output: 418
🏁 Script executed:
Repository: Team-Timo/Timo-client
Length of output: 2160
Biome/Stylelint 설정을 Tailwind v4 기준으로 맞춰주세요.
이 저장소는
tailwindcss@^4.3.1를 사용하고,packages/tailwind-config의 다른 token CSS들도 모두@theme문법을 씁니다. 따라서 경고는 코드보다는 파서/플러그인 설정 문제일 가능성이 큽니다.Tailwind v4
@theme문서: https://tailwindcss.com/docs/theme🧰 Tools
🪛 Biome (2.5.1)
[error] 6-8: Tailwind-specific syntax is disabled.
(parse)
🪛 Stylelint (17.14.0)
[error] 6-6: Unexpected unknown at-rule "
@theme" (scss/at-rule-no-unknown)(scss/at-rule-no-unknown)
🤖 Prompt for AI Agents
Source: Linters/SAST tools