-
Notifications
You must be signed in to change notification settings - Fork 0
[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 all 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,8 +1,11 @@ | ||
| 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 { AddTaskButton } from "@components/button/add-task-button/AddTaskButton"; | ||
| export { TodayBadge } from "@components/badge/today-badge/TodayBadge"; | ||
| export { PlayButton } from "@components/button/play-button/PlayButton"; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,44 @@ | ||
| import { Dropdown } from "@components/layout/dropdown/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,136 @@ | ||
| "use client"; | ||
|
|
||
| import { cn } from "@lib"; | ||
| import { | ||
| createContext, | ||
| useContext, | ||
| useEffect, | ||
| useRef, | ||
| useState, | ||
| type ButtonHTMLAttributes, | ||
| type ReactNode, | ||
| } from "react"; | ||
|
|
||
| interface DropdownContextValue { | ||
| isOpen: boolean; | ||
| toggle: () => void; | ||
| close: () => void; | ||
| } | ||
|
|
||
| const DropdownContext = createContext<DropdownContextValue | null>(null); | ||
|
|
||
| const useDropdownContext = (): DropdownContextValue => { | ||
|
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. 방어코드 에러 메세지 처리까지 신경 쓰신 부분이 좋아요!! 잘못 써도 원인을 바로 알 수 있겠네요 👍 |
||
| 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); | ||
| } | ||
| }; | ||
|
|
||
| const handleEscapeKeydown = (event: KeyboardEvent) => { | ||
| if (event.key === "Escape") { | ||
| setIsOpen(false); | ||
|
Comment on lines
+53
to
+54
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. Esc 키로 닫히는 기능까지!!👍👍 굿굿입니당 |
||
| } | ||
| }; | ||
|
|
||
| document.addEventListener("mousedown", handleOutsideClick); | ||
| document.addEventListener("keydown", handleEscapeKeydown); | ||
|
|
||
| return () => { | ||
| document.removeEventListener("mousedown", handleOutsideClick); | ||
| document.removeEventListener("keydown", handleEscapeKeydown); | ||
| }; | ||
| }, [isOpen]); | ||
|
kimminna marked this conversation as 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 type DropdownTriggerProps = ButtonHTMLAttributes<HTMLButtonElement>; | ||
|
|
||
| const DropdownTrigger = ({ className, ...rest }: DropdownTriggerProps) => { | ||
| const { isOpen, toggle } = useDropdownContext(); | ||
|
|
||
| return ( | ||
| <button | ||
| type="button" | ||
| {...rest} | ||
| onClick={toggle} | ||
| aria-expanded={isOpen} | ||
| className={className} | ||
| /> | ||
| ); | ||
| }; | ||
|
|
||
| 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 type DropdownItemProps = ButtonHTMLAttributes<HTMLButtonElement>; | ||
|
|
||
| const DropdownItem = ({ className, ...rest }: DropdownItemProps) => { | ||
| return ( | ||
| <button | ||
| type="button" | ||
| {...rest} | ||
| className={cn( | ||
| "rounded-4 flex w-full items-center transition-colors duration-200 ease-in-out", | ||
| className, | ||
| )} | ||
| /> | ||
| ); | ||
| }; | ||
|
|
||
| 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: ["urgent", "high", "medium", "low"], | ||
| }, | ||
| }, | ||
| } 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: "urgent" }, | ||
| }; | ||
|
|
||
| export const 중요: Story = { | ||
| args: { trigger: TRIGGER, selected: "high" }, | ||
| }; | ||
|
|
||
| export const 보통: Story = { | ||
| args: { trigger: TRIGGER, selected: "medium" }, | ||
| }; | ||
|
|
||
| export const 낮음: Story = { | ||
| args: { trigger: TRIGGER, selected: "low" }, | ||
| }; |
Uh oh!
There was an error while loading. Please reload this page.