Repository navigation
[FEAT] PrioritySelector & TagSelector & 드롭다운 셸 컴포넌트 구현 #75
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 5 commits
4310446
ca05434
36cc977
f433393
e93c916
b9d9d6c
36e2e78
68c7e4f
0a553a3
9ec2f46
7a9622a
a625897
8950c6e
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,7 +1,9 @@ | ||
| export { Checkbox } from "@components/checkbox/Checkbox"; | ||
| export { Color } from "@components/color/Color"; | ||
| export { Typography } from "@components/typography/Typography"; | ||
| export { Tag } from "@components/tag/Tag"; | ||
| export { PriorityIcon } from "@components/priority-icon/PriorityIcon"; | ||
| export { Color } from "@components/tokens/color/Color"; | ||
| export { Typography } from "@components/tokens/typography/Typography"; | ||
| export { TagIcon } from "@components/tag/tag-icon/TagIcon"; | ||
| export { TagSelector } from "@components/tag/tag-selector/TagSelector"; | ||
| export { PriorityIcon } from "@components/priority/priority-icon/PriorityIcon"; | ||
| export { PrioritySelector } from "@components/priority/priority-selector/PrioritySelector"; | ||
| export { CreateButton } from "@components/button/create-button/CreateButton"; | ||
| export { TodayBadge } from "@components/badge/today-badge/TodayBadge"; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,44 @@ | ||
| import { Dropdown } from "./Dropdown"; | ||
|
|
||
| import type { Meta, StoryObj } from "@storybook/react"; | ||
|
|
||
| const meta = { | ||
| title: "Components/layout/Dropdown", | ||
| parameters: { | ||
| layout: "centered", | ||
| backgrounds: { | ||
| default: "light-gray", | ||
| values: [ | ||
| { name: "light-gray", value: "#F5F5F5" }, | ||
| { name: "dark", value: "#333333" }, | ||
| { name: "white", value: "#FFFFFF" }, | ||
| ], | ||
| }, | ||
| }, | ||
| } satisfies Meta; | ||
|
|
||
| export default meta; | ||
| type Story = StoryObj<typeof meta>; | ||
|
|
||
| const SAMPLE_ITEMS = ["옵션 1", "옵션 2", "옵션 3"]; | ||
|
|
||
| export const Default: Story = { | ||
| name: "Basic Usage", | ||
| render: () => ( | ||
| <Dropdown> | ||
| <Dropdown.Trigger className="typo-headline-r-14 text-timo-black rounded-4 bg-timo-gray-300 px-3 py-1.5"> | ||
| 트리거 | ||
| </Dropdown.Trigger> | ||
|
|
||
| <Dropdown.Panel className="gap-1"> | ||
| {SAMPLE_ITEMS.map((item) => ( | ||
| <Dropdown.Item key={item} className="px-1.5 py-1"> | ||
| <span className="typo-headline-r-14 text-timo-black whitespace-nowrap"> | ||
| {item} | ||
| </span> | ||
| </Dropdown.Item> | ||
| ))} | ||
| </Dropdown.Panel> | ||
| </Dropdown> | ||
| ), | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,130 @@ | ||
| "use client"; | ||
|
|
||
| import { cn } from "@lib"; | ||
| import { | ||
| createContext, | ||
| useContext, | ||
| useEffect, | ||
| useRef, | ||
| useState, | ||
| type ReactNode, | ||
| } from "react"; | ||
|
|
||
| interface DropdownContextValue { | ||
| isOpen: boolean; | ||
| toggle: () => void; | ||
| close: () => void; | ||
| } | ||
|
|
||
| const DropdownContext = createContext<DropdownContextValue | null>(null); | ||
|
|
||
| const useDropdownContext = (): DropdownContextValue => { | ||
| const context = useContext(DropdownContext); | ||
|
|
||
| if (!context) { | ||
| throw new Error( | ||
| "Dropdown.Trigger, Dropdown.Panel은 Dropdown 내부에서만 사용할 수 있습니다.", | ||
| ); | ||
| } | ||
|
|
||
| return context; | ||
| }; | ||
|
|
||
| export interface DropdownProps { | ||
| children: ReactNode; | ||
| className?: string; | ||
| } | ||
|
|
||
| const DropdownRoot = ({ children, className }: DropdownProps) => { | ||
| const [isOpen, setIsOpen] = useState<boolean>(false); | ||
| const rootRef = useRef<HTMLDivElement>(null); | ||
|
|
||
| useEffect(() => { | ||
| if (!isOpen) return; | ||
|
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. 열려있을 때만 이벤트 리스너를 등록하고, cleanup 함수로 확실히 지워주는 패턴도 새롭네요! |
||
|
|
||
| const handleOutsideClick = (event: MouseEvent) => { | ||
| if (!rootRef.current?.contains(event.target as Node)) { | ||
| setIsOpen(false); | ||
| } | ||
| }; | ||
|
|
||
| document.addEventListener("mousedown", handleOutsideClick); | ||
|
|
||
| return () => document.removeEventListener("mousedown", handleOutsideClick); | ||
| }, [isOpen]); | ||
|
kimminna marked this conversation as resolved.
Resolved
|
||
|
|
||
| const toggle = () => setIsOpen((prev) => !prev); | ||
| const close = () => setIsOpen(false); | ||
|
|
||
| return ( | ||
| <DropdownContext.Provider value={{ isOpen, toggle, close }}> | ||
| <div ref={rootRef} className={cn("relative inline-block", className)}> | ||
| {children} | ||
| </div> | ||
| </DropdownContext.Provider> | ||
| ); | ||
| }; | ||
|
|
||
| export interface DropdownTriggerProps { | ||
| children: ReactNode; | ||
| className?: string; | ||
| } | ||
|
|
||
| const DropdownTrigger = ({ children, className }: DropdownTriggerProps) => { | ||
| const { toggle } = useDropdownContext(); | ||
|
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.
Member
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.
실제로 TagSelector는 태그 옵션들 사이에 "추가" 액션 버튼이 섞여 있어서, 이 경우 listbox 패턴(옵션들 + 화살표 키 이동 + aria-selected)과 맞지 않게 됩니다. 그래서 aria-haspopup 값은 Dropdown 공통 레벨에서 고정하기보다, 각 소비 컴포넌트가 자기 콘텐츠 구조(순수 옵션 목록인지, 액션이 섞인 메뉴인지)에 맞게 결정하는 게 스크린 리더 사용자에게 더 정확한 정보를 준다고 생각합니다! 그래서 해당 방향으로 커밋 올려두겠습니다 😁 |
||
|
|
||
| return ( | ||
| <button type="button" onClick={toggle} className={className}> | ||
| {children} | ||
| </button> | ||
| ); | ||
| }; | ||
|
|
||
| export interface DropdownPanelProps { | ||
| children: ReactNode; | ||
| className?: string; | ||
| } | ||
|
|
||
| const DropdownPanel = ({ children, className }: DropdownPanelProps) => { | ||
| const { isOpen } = useDropdownContext(); | ||
|
|
||
| if (!isOpen) return null; | ||
|
|
||
| return ( | ||
| <div | ||
| className={cn( | ||
| "rounded-4 absolute top-full left-0 z-10 mt-1 flex flex-col items-start bg-white p-2", | ||
| className, | ||
| )} | ||
| > | ||
| {children} | ||
| </div> | ||
| ); | ||
| }; | ||
|
|
||
| export interface DropdownItemProps { | ||
| children: ReactNode; | ||
| className?: string; | ||
| onClick?: () => void; | ||
| } | ||
|
|
||
| const DropdownItem = ({ children, className, onClick }: DropdownItemProps) => { | ||
| return ( | ||
| <button | ||
| type="button" | ||
| onClick={onClick} | ||
| className={cn( | ||
| "rounded-4 flex w-full items-center transition-colors duration-200 ease-in-out", | ||
| className, | ||
| )} | ||
| > | ||
| {children} | ||
| </button> | ||
| ); | ||
| }; | ||
|
kimminna marked this conversation as resolved.
Resolved
|
||
|
|
||
| export const Dropdown = Object.assign(DropdownRoot, { | ||
| Trigger: DropdownTrigger, | ||
| Panel: DropdownPanel, | ||
| Item: DropdownItem, | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,54 @@ | ||
| import { PrioritySelector } from "./PrioritySelector"; | ||
|
|
||
| import type { Meta, StoryObj } from "@storybook/react"; | ||
|
|
||
| const meta = { | ||
| title: "Components/Priority/PrioritySelector", | ||
| component: PrioritySelector, | ||
| parameters: { | ||
| layout: "centered", | ||
| backgrounds: { | ||
| default: "light-gray", | ||
| values: [ | ||
| { name: "light-gray", value: "#F5F5F5" }, | ||
| { name: "dark", value: "#333333" }, | ||
| { name: "white", value: "#FFFFFF" }, | ||
| ], | ||
| }, | ||
| }, | ||
| argTypes: { | ||
| selected: { | ||
| control: "select", | ||
| options: ["매우중요", "중요", "보통", "낮음"], | ||
| }, | ||
| }, | ||
| } satisfies Meta<typeof PrioritySelector>; | ||
|
|
||
| export default meta; | ||
| type Story = StoryObj<typeof meta>; | ||
|
|
||
| const TRIGGER = ( | ||
| <span className="typo-headline-r-14 text-timo-black rounded-4 bg-timo-gray-300 px-3 py-1.5"> | ||
| 우선순위 | ||
| </span> | ||
| ); | ||
|
|
||
| export const Default: Story = { | ||
| args: { trigger: TRIGGER }, | ||
| }; | ||
|
|
||
| export const 매우중요: Story = { | ||
| args: { trigger: TRIGGER, selected: "매우중요" }, | ||
| }; | ||
|
|
||
| export const 중요: Story = { | ||
| args: { trigger: TRIGGER, selected: "중요" }, | ||
| }; | ||
|
|
||
| export const 보통: Story = { | ||
| args: { trigger: TRIGGER, selected: "보통" }, | ||
| }; | ||
|
|
||
| export const 낮음: Story = { | ||
| args: { trigger: TRIGGER, selected: "낮음" }, | ||
| }; |
|
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. 저도 Tab 컴포넌트를 구현할 때 부모 컴포넌트로부터 선택 여부를 props로 받았는데, 왜 제 경우에는 isSelected/onClick을 쓰고 이 컴포넌트에서는 selected/onSelect를 쓰는지 궁금해서 찾아봤습니다. 정리해보니 이 컴포넌트는 여러 항목 중 하나를 고르는 선택기라서 selected 값과 onSelect이 자연스럽고, 제가 만든 Tab은 단일 항목이라 isSelected와 onClick이 더 적절하다는 차이가 있더라구요!! (제가 이해한게 맞을까..요?..) 덕분에 제어 컴포넌트 패턴을 조금 더 이해하게 됐습니다.!! 민아님 코드 보면서 많이 배우네요🫰🏻👍😍
Member
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. 맞습니다! 탭 -> 단일 항목 토글 선택 이라서 props로 전달할 속성이 다를 것 같네요! |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,62 @@ | ||
| import { Dropdown } from "@components/layout/dropdown/Dropdown"; | ||
| import { PriorityIcon } from "@components/priority/priority-icon/PriorityIcon"; | ||
| import { cn } from "@lib"; | ||
|
|
||
| import type { ReactNode } from "react"; | ||
|
|
||
| export type PriorityLevel = "매우중요" | "중요" | "보통" | "낮음"; | ||
|
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. PriorityLevel 타입이 현재 한글 문자열을 값으로 직접 사용하고 있는데, 값과 라벨을 분리하는 건 어떨까요?
Member
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. 그렇네요! 영문 타입으로 변경해 두겠습니다 |
||
|
|
||
| const PRIORITY_LEVELS: PriorityLevel[] = ["매우중요", "중요", "보통", "낮음"]; | ||
|
|
||
| const PRIORITY_BG_COLOR: Record<PriorityLevel, string> = { | ||
| 매우중요: "bg-timo-red", | ||
| 중요: "bg-timo-orange", | ||
| 보통: "bg-timo-gray", | ||
| 낮음: "bg-timo-gray-900", | ||
| }; | ||
|
kimminna marked this conversation as resolved.
Resolved
|
||
|
|
||
| export interface PrioritySelectorProps { | ||
| trigger: ReactNode; | ||
| selected?: PriorityLevel; | ||
| onSelect?: (priority: PriorityLevel) => void; | ||
| } | ||
|
|
||
| export const PrioritySelector = ({ | ||
| trigger, | ||
| selected, | ||
| onSelect, | ||
| }: PrioritySelectorProps) => { | ||
| return ( | ||
| <Dropdown> | ||
| <Dropdown.Trigger>{trigger}</Dropdown.Trigger> | ||
|
|
||
| <Dropdown.Panel className="gap-1"> | ||
| {PRIORITY_LEVELS.map((priority) => { | ||
| const isSelected = priority === selected; | ||
|
|
||
| return ( | ||
| <Dropdown.Item | ||
| key={priority} | ||
| onClick={() => onSelect?.(priority)} | ||
| className={cn( | ||
| "gap-2.25 py-0.5 pr-1 pl-2.75", | ||
| isSelected && PRIORITY_BG_COLOR[priority], | ||
| )} | ||
| > | ||
| <PriorityIcon priority={isSelected ? "white" : priority} /> | ||
|
|
||
| <span | ||
| className={cn( | ||
| "typo-headline-r-14 whitespace-nowrap transition-colors duration-200 ease-in-out", | ||
| isSelected ? "text-white" : "text-timo-black", | ||
| )} | ||
| > | ||
| {priority} | ||
| </span> | ||
| </Dropdown.Item> | ||
| ); | ||
| })} | ||
| </Dropdown.Panel> | ||
| </Dropdown> | ||
| ); | ||
| }; | ||
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.
방어코드 에러 메세지 처리까지 신경 쓰신 부분이 좋아요!! 잘못 써도 원인을 바로 알 수 있겠네요 👍