From 7537042e81856e0344c5f0625c460be78b036bdc Mon Sep 17 00:00:00 2001 From: kimminna Date: Sat, 11 Jul 2026 00:34:52 +0900 Subject: [PATCH 1/6] =?UTF-8?q?chore(web):=20=ED=88=AC=EB=91=90=20?= =?UTF-8?q?=EB=AA=A8=EB=8B=AC=20=EA=B4=80=EB=A0=A8=20=EC=9D=98=EC=A1=B4?= =?UTF-8?q?=EC=84=B1=20=EC=B6=94=EA=B0=80=20(#137)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - overlay-kit, @dnd-kit/modifiers 의존성을 추가했습니다 --- apps/timo-web/package.json | 4 +++ pnpm-lock.yaml | 59 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+) diff --git a/apps/timo-web/package.json b/apps/timo-web/package.json index 92b8707a..c55622d8 100644 --- a/apps/timo-web/package.json +++ b/apps/timo-web/package.json @@ -12,8 +12,10 @@ }, "dependencies": { "@dnd-kit/core": "^6.3.1", + "@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", + "@hookform/resolvers": "^5.4.0", "@repo/timo-design-system": "workspace:*", "@sentry/nextjs": "^10.61.0", "@tanstack/react-query": "^5.101.1", @@ -21,8 +23,10 @@ "axios": "^1.18.1", "next": "16.2.0", "next-intl": "^4.13.1", + "overlay-kit": "^1.9.0", "react": "^19.2.0", "react-dom": "^19.2.0", + "react-hook-form": "^7.81.0", "zod": "^4.4.3", "zustand": "^5.0.14" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a7e9c27e..edf4ba52 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,12 +35,18 @@ importers: '@dnd-kit/core': specifier: ^6.3.1 version: 6.3.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@dnd-kit/modifiers': + specifier: ^9.0.0 + version: 9.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0) '@dnd-kit/sortable': specifier: ^10.0.0 version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0) '@dnd-kit/utilities': specifier: ^3.2.2 version: 3.2.2(react@19.2.0) + '@hookform/resolvers': + specifier: ^5.4.0 + version: 5.4.0(react-hook-form@7.81.0(react@19.2.0)) '@repo/timo-design-system': specifier: workspace:* version: link:../../packages/timo-design-system @@ -62,12 +68,18 @@ importers: next-intl: specifier: ^4.13.1 version: 4.13.1(@swc/helpers@0.5.15)(next@16.2.0(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(typescript@5.9.2) + overlay-kit: + specifier: ^1.9.0 + version: 1.9.0(react@19.2.0) react: specifier: ^19.2.0 version: 19.2.0 react-dom: specifier: ^19.2.0 version: 19.2.0(react@19.2.0) + react-hook-form: + specifier: ^7.81.0 + version: 7.81.0(react@19.2.0) zod: specifier: ^4.4.3 version: 4.4.3 @@ -337,6 +349,12 @@ packages: react: '>=16.8.0' react-dom: '>=16.8.0' + '@dnd-kit/modifiers@9.0.0': + resolution: {integrity: sha512-ybiLc66qRGuZoC20wdSSG6pDXFikui/dCNGthxv4Ndy8ylErY0N3KVxY2bgo7AWwIbxDmXDg3ylAFmnrjcbVvw==} + peerDependencies: + '@dnd-kit/core': ^6.3.0 + react: '>=16.8.0' + '@dnd-kit/sortable@10.0.0': resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==} peerDependencies: @@ -566,6 +584,11 @@ packages: '@formatjs/intl-localematcher@0.8.10': resolution: {integrity: sha512-P/IC3qws3jH+1fEs+o0RIFgXKRaQlFehjS5W0FPAqdo6hgzawLl+eD0q0JjheQ3XtoOe5n8WSYfX06KQZI/QJA==} + '@hookform/resolvers@5.4.0': + resolution: {integrity: sha512-EIsqr/t/qbinPIhGjMdtvutIN1Kk4uwbROE9/UQ93CAVGR7GkA7Y92+fX80OzXi/OB67jVFYwKGO1WzkxmkFZw==} + peerDependencies: + react-hook-form: ^7.55.0 + '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} engines: {node: '>=18.18.0'} @@ -1476,6 +1499,9 @@ packages: peerDependencies: webpack: '>=5.0.0' + '@standard-schema/utils@0.3.0': + resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} + '@storybook/addon-a11y@8.6.14': resolution: {integrity: sha512-fozv6enO9IgpWq2U8qqS8MZ21Nt+MVHiRQe3CjnCpBOejTyo/ATm690PeYYRVHVG6M/15TVePb0h3ngKQbrrzQ==} peerDependencies: @@ -3682,6 +3708,11 @@ packages: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} + overlay-kit@1.9.0: + resolution: {integrity: sha512-kwzAOhWEDsrmgJzQKuI0v3xCwyWTSaNe3qI0t+7OTTj05ciRpg3j+NWz8FRPVt7rHBFL/jAjKyQf4xGOHcRXfg==} + peerDependencies: + react: ^16.8 || ^17 || ^18 || ^19 + own-keys@1.0.1: resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} engines: {node: '>= 0.4'} @@ -3877,6 +3908,12 @@ packages: peerDependencies: react: ^19.2.0 + react-hook-form@7.81.0: + resolution: {integrity: sha512-ocbmr2p5KBMoAfj4WCUvped33lVi1Kd5DuDUvQDnB6VEAacOjPI/jMbtDdbhco4y9ct4xUuCmMY0b/C9L0QHjw==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: ^16.8.0 || ^17 || ^18 || ^19 + react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} @@ -4623,6 +4660,13 @@ snapshots: react-dom: 19.2.0(react@19.2.0) tslib: 2.8.1 + '@dnd-kit/modifiers@9.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)': + dependencies: + '@dnd-kit/core': 6.3.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@dnd-kit/utilities': 3.2.2(react@19.2.0) + react: 19.2.0 + tslib: 2.8.1 + '@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)': dependencies: '@dnd-kit/core': 6.3.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0) @@ -4792,6 +4836,11 @@ snapshots: dependencies: '@formatjs/fast-memoize': 3.1.6 + '@hookform/resolvers@5.4.0(react-hook-form@7.81.0(react@19.2.0))': + dependencies: + '@standard-schema/utils': 0.3.0 + react-hook-form: 7.81.0(react@19.2.0) + '@humanfs/core@0.19.1': {} '@humanfs/node@0.16.7': @@ -5552,6 +5601,8 @@ snapshots: - encoding - supports-color + '@standard-schema/utils@0.3.0': {} + '@storybook/addon-a11y@8.6.14(storybook@8.6.14(prettier@3.7.4))': dependencies: '@storybook/addon-highlight': 8.6.14(storybook@8.6.14(prettier@3.7.4)) @@ -7926,6 +7977,10 @@ snapshots: type-check: 0.4.0 word-wrap: 1.2.5 + overlay-kit@1.9.0(react@19.2.0): + dependencies: + react: 19.2.0 + own-keys@1.0.1: dependencies: get-intrinsic: 1.3.0 @@ -8059,6 +8114,10 @@ snapshots: react: 19.2.0 scheduler: 0.27.0 + react-hook-form@7.81.0(react@19.2.0): + dependencies: + react: 19.2.0 + react-is@16.13.1: {} react-is@17.0.2: {} From ab714dea5eeee9a349be9ffb10d4eda78bde9fef Mon Sep 17 00:00:00 2001 From: kimminna Date: Sat, 11 Jul 2026 00:35:30 +0900 Subject: [PATCH 2/6] =?UTF-8?q?feat(ui):=20=ED=88=AC=EB=91=90=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20=EB=AA=A8=EB=8B=AC=EC=9A=A9=20=EB=94=94=EC=9E=90?= =?UTF-8?q?=EC=9D=B8=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20=EC=B6=94=EA=B0=80=20(#137)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 아이콘 선택기, 날짜 선택기, 태그 입력, 투두 툴바 컴포넌트를 추가했습니다 - 우선순위/반복/태그/시간 선택기를 투두 모달 요구사항에 맞게 수정했습니다 - 투두 아이콘 SVG 8종을 추가했습니다 --- .../date-selector/DateSelector.stories.tsx | 35 +++++ .../calendar/date-selector/DateSelector.tsx | 26 ++++ .../icon/icon-graphic/IconGraphic.stories.tsx | 44 ++++++ .../icon/icon-graphic/IconGraphic.tsx | 70 +++++++++ .../icon-selector/IconSelector.stories.tsx | 78 ++++++++++ .../icon/icon-selector/IconSelector.tsx | 56 +++++++ .../src/components/index.ts | 29 ++-- .../priority-icon/PriorityIcon.stories.tsx | 28 ++-- .../priority/priority-icon/PriorityIcon.tsx | 16 +- .../PrioritySelector.stories.tsx | 10 +- .../priority-selector/PrioritySelector.tsx | 22 +-- .../RepeatSelector.stories.tsx | 18 +-- .../repeat/repeat-selector/RepeatSelector.tsx | 14 +- .../tag-name-input/TagNameInput.stories.tsx | 54 +++++++ .../tag/tag-name-input/TagNameInput.tsx | 54 +++++++ .../tag/tag-selector/TagSelector.tsx | 2 +- .../time/time-selector/TimeSelector.tsx | 2 +- .../todo/todo-toolbar/TodoToolbar.tsx | 144 ++++++++++++++++++ .../src/icons/source/todo-icon-daily.svg | 6 + .../src/icons/source/todo-icon-etc.svg | 5 + .../src/icons/source/todo-icon-plus.svg | 6 + .../icons/source/todo-icon-relationship.svg | 9 ++ .../src/icons/source/todo-icon-rest.svg | 8 + .../src/icons/source/todo-icon-study.svg | 6 + .../src/icons/source/todo-icon-task.svg | 7 + .../src/icons/source/todo-icon-work.svg | 7 + 26 files changed, 691 insertions(+), 65 deletions(-) create mode 100644 packages/timo-design-system/src/components/calendar/date-selector/DateSelector.stories.tsx create mode 100644 packages/timo-design-system/src/components/calendar/date-selector/DateSelector.tsx create mode 100644 packages/timo-design-system/src/components/icon/icon-graphic/IconGraphic.stories.tsx create mode 100644 packages/timo-design-system/src/components/icon/icon-graphic/IconGraphic.tsx create mode 100644 packages/timo-design-system/src/components/icon/icon-selector/IconSelector.stories.tsx create mode 100644 packages/timo-design-system/src/components/icon/icon-selector/IconSelector.tsx create mode 100644 packages/timo-design-system/src/components/tag/tag-name-input/TagNameInput.stories.tsx create mode 100644 packages/timo-design-system/src/components/tag/tag-name-input/TagNameInput.tsx create mode 100644 packages/timo-design-system/src/components/todo/todo-toolbar/TodoToolbar.tsx create mode 100644 packages/timo-design-system/src/icons/source/todo-icon-daily.svg create mode 100644 packages/timo-design-system/src/icons/source/todo-icon-etc.svg create mode 100644 packages/timo-design-system/src/icons/source/todo-icon-plus.svg create mode 100644 packages/timo-design-system/src/icons/source/todo-icon-relationship.svg create mode 100644 packages/timo-design-system/src/icons/source/todo-icon-rest.svg create mode 100644 packages/timo-design-system/src/icons/source/todo-icon-study.svg create mode 100644 packages/timo-design-system/src/icons/source/todo-icon-task.svg create mode 100644 packages/timo-design-system/src/icons/source/todo-icon-work.svg diff --git a/packages/timo-design-system/src/components/calendar/date-selector/DateSelector.stories.tsx b/packages/timo-design-system/src/components/calendar/date-selector/DateSelector.stories.tsx new file mode 100644 index 00000000..f2c43b09 --- /dev/null +++ b/packages/timo-design-system/src/components/calendar/date-selector/DateSelector.stories.tsx @@ -0,0 +1,35 @@ +import { useState } from "react"; + +import { DateSelector } from "./DateSelector"; + +import type { Meta, StoryObj } from "@storybook/react"; + +const TRIGGER = ( + + 날짜 선택 + +); + +const meta = { + title: "Components/Calendar/DateSelector", + component: DateSelector, + parameters: { + layout: "centered", + }, + args: { + trigger: TRIGGER, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const DateSelectorDemo = () => { + const [value, setValue] = useState(); + + return ; +}; + +export const Default: Story = { + render: () => , +}; diff --git a/packages/timo-design-system/src/components/calendar/date-selector/DateSelector.tsx b/packages/timo-design-system/src/components/calendar/date-selector/DateSelector.tsx new file mode 100644 index 00000000..9cd00ed1 --- /dev/null +++ b/packages/timo-design-system/src/components/calendar/date-selector/DateSelector.tsx @@ -0,0 +1,26 @@ +import { Dropdown } from "../../layout/dropdown/Dropdown"; +import { Calendar } from "../Calendar"; + +import type { ReactNode } from "react"; + +export interface DateSelectorProps { + trigger: ReactNode; + value?: Date; + onChange?: (date: Date) => void; +} + +export const DateSelector = ({ + trigger, + value, + onChange, +}: DateSelectorProps) => { + return ( + + {trigger} + + + + + + ); +}; diff --git a/packages/timo-design-system/src/components/icon/icon-graphic/IconGraphic.stories.tsx b/packages/timo-design-system/src/components/icon/icon-graphic/IconGraphic.stories.tsx new file mode 100644 index 00000000..891e809b --- /dev/null +++ b/packages/timo-design-system/src/components/icon/icon-graphic/IconGraphic.stories.tsx @@ -0,0 +1,44 @@ +import { IconGraphic } from "./IconGraphic"; + +import type { Meta, StoryObj } from "@storybook/react"; + +const meta = { + title: "Components/Icon/IconGraphic", + component: IconGraphic, + parameters: { + layout: "centered", + backgrounds: { + default: "light-gray", + values: [ + { name: "light-gray", value: "#F5F5F5" }, + { name: "white", value: "#FFFFFF" }, + ], + }, + }, + argTypes: { + icon: { + control: "select", + options: [ + "ICON_1", + "ICON_2", + "ICON_3", + "ICON_4", + "ICON_5", + "ICON_6", + "ICON_7", + "ICON_8", + ], + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { icon: "ICON_1" }, +}; + +export const 관계: Story = { + args: { icon: "ICON_4" }, +}; diff --git a/packages/timo-design-system/src/components/icon/icon-graphic/IconGraphic.tsx b/packages/timo-design-system/src/components/icon/icon-graphic/IconGraphic.tsx new file mode 100644 index 00000000..a456fab9 --- /dev/null +++ b/packages/timo-design-system/src/components/icon/icon-graphic/IconGraphic.tsx @@ -0,0 +1,70 @@ +import { + TodoIconDailyIcon, + TodoIconEtcIcon, + TodoIconPlusIcon, + TodoIconRelationshipIcon, + TodoIconRestIcon, + TodoIconStudyIcon, + TodoIconTaskIcon, + TodoIconWorkIcon, +} from "../../../icons"; +import { cn } from "../../../lib"; + +import type { ComponentType, SVGProps } from "react"; + +export type TodoIconValue = + | "ICON_1" + | "ICON_2" + | "ICON_3" + | "ICON_4" + | "ICON_5" + | "ICON_6" + | "ICON_7" + | "ICON_8"; + +export const TODO_ICON_OPTIONS: TodoIconValue[] = [ + "ICON_1", + "ICON_2", + "ICON_3", + "ICON_4", + "ICON_5", + "ICON_6", + "ICON_7", + "ICON_8", +]; + +const ICON_GRAPHIC: Record< + TodoIconValue, + ComponentType> +> = { + ICON_1: TodoIconDailyIcon, + ICON_2: TodoIconPlusIcon, + ICON_3: TodoIconEtcIcon, + ICON_4: TodoIconRelationshipIcon, + ICON_5: TodoIconRestIcon, + ICON_6: TodoIconTaskIcon, + ICON_7: TodoIconWorkIcon, + ICON_8: TodoIconStudyIcon, +}; + +export const TODO_ICON_LABEL: Record = { + ICON_1: "일상", + ICON_2: "기본", + ICON_3: "기타", + ICON_4: "관계", + ICON_5: "휴식", + ICON_6: "할일", + ICON_7: "업무", + ICON_8: "학습", +}; + +export interface IconGraphicProps { + icon: TodoIconValue; + className?: string; +} + +export const IconGraphic = ({ icon, className }: IconGraphicProps) => { + const Icon = ICON_GRAPHIC[icon]; + + return ; +}; diff --git a/packages/timo-design-system/src/components/icon/icon-selector/IconSelector.stories.tsx b/packages/timo-design-system/src/components/icon/icon-selector/IconSelector.stories.tsx new file mode 100644 index 00000000..e9dfb6be --- /dev/null +++ b/packages/timo-design-system/src/components/icon/icon-selector/IconSelector.stories.tsx @@ -0,0 +1,78 @@ +import { useState } from "react"; + +import { IconSelector } from "./IconSelector"; + +import type { IconSelectorProps, TodoIconValue } from "./IconSelector"; +import type { Meta, StoryObj } from "@storybook/react"; + +const InteractiveDemo = (args: IconSelectorProps) => { + const [selected, setSelected] = useState( + args.selected ?? null, + ); + + return ( + setSelected(null)} + /> + ); +}; + +const meta = { + title: "Components/Icon/IconSelector", + component: IconSelector, + parameters: { + layout: "centered", + backgrounds: { + default: "light-gray", + values: [ + { name: "light-gray", value: "#F5F5F5" }, + { name: "white", value: "#FFFFFF" }, + ], + }, + }, + argTypes: { + selected: { + control: "select", + options: [ + "ICON_1", + "ICON_2", + "ICON_3", + "ICON_4", + "ICON_5", + "ICON_6", + "ICON_7", + "ICON_8", + ], + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + selected: null, + onSelect: () => {}, + onRemove: () => {}, + }, +}; + +export const 선택됨: Story = { + args: { + selected: "ICON_1", + onSelect: () => {}, + onRemove: () => {}, + }, +}; + +export const Interactive: Story = { + args: { + selected: null, + onSelect: () => {}, + onRemove: () => {}, + }, + render: (args) => , +}; diff --git a/packages/timo-design-system/src/components/icon/icon-selector/IconSelector.tsx b/packages/timo-design-system/src/components/icon/icon-selector/IconSelector.tsx new file mode 100644 index 00000000..efd318c4 --- /dev/null +++ b/packages/timo-design-system/src/components/icon/icon-selector/IconSelector.tsx @@ -0,0 +1,56 @@ +import { cn } from "../../../lib"; +import { + IconGraphic, + TODO_ICON_LABEL, + TODO_ICON_OPTIONS, +} from "../icon-graphic/IconGraphic"; + +import type { TodoIconValue } from "../icon-graphic/IconGraphic"; + +export type { TodoIconValue }; + +export interface IconSelectorProps { + selected?: TodoIconValue | null; + onSelect: (icon: TodoIconValue) => void; + onRemove: () => void; +} + +export const IconSelector = ({ + selected, + onSelect, + onRemove, +}: IconSelectorProps) => { + return ( +
+ {TODO_ICON_OPTIONS.map((icon) => { + const isSelected = icon === selected; + + return ( + + ); + })} + + +
+ ); +}; diff --git a/packages/timo-design-system/src/components/index.ts b/packages/timo-design-system/src/components/index.ts index 65223ba0..4b2870ff 100644 --- a/packages/timo-design-system/src/components/index.ts +++ b/packages/timo-design-system/src/components/index.ts @@ -5,8 +5,10 @@ export { Color } from "./tokens/color/Color"; export { Typography } from "./tokens/typography/Typography"; export { TagIcon } from "./tag/tag-icon/TagIcon"; export { TagSelector } from "./tag/tag-selector/TagSelector"; +export { TagNameInput } from "./tag/tag-name-input/TagNameInput"; export { PriorityIcon } from "./priority/priority-icon/PriorityIcon"; export { PrioritySelector } from "./priority/priority-selector/PrioritySelector"; +export type { PriorityLevel } from "./priority/priority-selector/PrioritySelector"; export { CreateButton } from "./button/create-button/CreateButton"; export { DeleteButton } from "./button/delete-button/DeleteButton"; export { AddTaskButton } from "./button/add-task-button/AddTaskButton"; @@ -17,23 +19,24 @@ export { PlayButton } from "./button/play-button/PlayButton"; export { TabButton } from "./button/tab-button/TabButton"; export { Toast } from "./toast/Toast"; export { ModalButton } from "./button/modal-button/ModalButton"; -export type { ModalButtonVariantTypes } from "./button/modal-button/ModalButton"; export { Modal } from "./layout/modal/Modal"; -export type { - ModalProps, - ModalTriggerProps, - ModalOverlayProps, - ModalPanelProps, - ModalIconProps, - ModalTitleProps, - ModalDescriptionProps, - ModalFooterProps, - ModalBorderButtonProps, - ModalFillButtonProps, -} from "./layout/modal/Modal"; export { SidebarButton } from "./button/sidebar-button/SidebarButton"; export { TodayButton } from "./button/today-button/TodayButton"; export { ChevronButton } from "./button/chevron-button/ChevronButton"; export { TimeSelector } from "./time/time-selector/TimeSelector"; +export type { + TimeSelection, + TimeOption, +} from "./time/time-selector/TimeSelector"; export { RepeatSelector } from "./repeat/repeat-selector/RepeatSelector"; +export type { + RepeatFrequency, + RepeatOption, + WeekdayOption, +} from "./repeat/repeat-selector/RepeatSelector"; export { Calendar } from "./calendar/Calendar"; +export { DateSelector } from "./calendar/date-selector/DateSelector"; +export { TodoToolbar } from "./todo/todo-toolbar/TodoToolbar"; +export { IconSelector } from "./icon/icon-selector/IconSelector"; +export type { TodoIconValue } from "./icon/icon-selector/IconSelector"; +export { IconGraphic } from "./icon/icon-graphic/IconGraphic"; diff --git a/packages/timo-design-system/src/components/priority/priority-icon/PriorityIcon.stories.tsx b/packages/timo-design-system/src/components/priority/priority-icon/PriorityIcon.stories.tsx index 73b72b62..4e6263ff 100644 --- a/packages/timo-design-system/src/components/priority/priority-icon/PriorityIcon.stories.tsx +++ b/packages/timo-design-system/src/components/priority/priority-icon/PriorityIcon.stories.tsx @@ -11,7 +11,15 @@ const meta = { argTypes: { priority: { control: "select", - options: ["urgent", "high", "medium", "low", "Disable", "white", "blue"], + options: [ + "VERY_HIGH", + "HIGH", + "MEDIUM", + "LOW", + "Disable", + "white", + "blue", + ], }, }, } satisfies Meta; @@ -20,19 +28,19 @@ export default meta; type Story = StoryObj; export const 매우중요: Story = { - args: { priority: "urgent" }, + args: { priority: "VERY_HIGH" }, }; export const 중요: Story = { - args: { priority: "high" }, + args: { priority: "HIGH" }, }; export const 보통: Story = { - args: { priority: "medium" }, + args: { priority: "MEDIUM" }, }; export const 낮음: Story = { - args: { priority: "low" }, + args: { priority: "LOW" }, }; export const Disable: Story = { @@ -57,17 +65,17 @@ export const blue: Story = { }; const ALL_PRIORITIES: Priority[] = [ - "urgent", - "high", - "medium", - "low", + "VERY_HIGH", + "HIGH", + "MEDIUM", + "LOW", "Disable", "white", "blue", ]; export const AllPriorities: Story = { - args: { priority: "urgent" }, + args: { priority: "VERY_HIGH" }, parameters: { backgrounds: { default: "light-gray", diff --git a/packages/timo-design-system/src/components/priority/priority-icon/PriorityIcon.tsx b/packages/timo-design-system/src/components/priority/priority-icon/PriorityIcon.tsx index f4df4994..a4351e3f 100644 --- a/packages/timo-design-system/src/components/priority/priority-icon/PriorityIcon.tsx +++ b/packages/timo-design-system/src/components/priority/priority-icon/PriorityIcon.tsx @@ -1,19 +1,19 @@ import { cn } from "../../../lib"; export type Priority = - | "urgent" - | "high" - | "medium" - | "low" + | "VERY_HIGH" + | "HIGH" + | "MEDIUM" + | "LOW" | "Disable" | "white" | "blue"; const PRIORITY_COLOR: Record = { - urgent: "bg-timo-red", - high: "bg-timo-orange", - medium: "bg-timo-gray-600", - low: "bg-timo-black", + VERY_HIGH: "bg-timo-red", + HIGH: "bg-timo-orange", + MEDIUM: "bg-timo-gray-600", + LOW: "bg-timo-black", Disable: "bg-timo-gray-500", white: "bg-white", blue: "bg-timo-blue-300", diff --git a/packages/timo-design-system/src/components/priority/priority-selector/PrioritySelector.stories.tsx b/packages/timo-design-system/src/components/priority/priority-selector/PrioritySelector.stories.tsx index ac1b5f03..a27f6802 100644 --- a/packages/timo-design-system/src/components/priority/priority-selector/PrioritySelector.stories.tsx +++ b/packages/timo-design-system/src/components/priority/priority-selector/PrioritySelector.stories.tsx @@ -19,7 +19,7 @@ const meta = { argTypes: { selected: { control: "select", - options: ["urgent", "high", "medium", "low"], + options: ["VERY_HIGH", "HIGH", "MEDIUM", "LOW"], }, }, } satisfies Meta; @@ -38,17 +38,17 @@ export const Default: Story = { }; export const 매우중요: Story = { - args: { trigger: TRIGGER, selected: "urgent" }, + args: { trigger: TRIGGER, selected: "VERY_HIGH" }, }; export const 중요: Story = { - args: { trigger: TRIGGER, selected: "high" }, + args: { trigger: TRIGGER, selected: "HIGH" }, }; export const 보통: Story = { - args: { trigger: TRIGGER, selected: "medium" }, + args: { trigger: TRIGGER, selected: "MEDIUM" }, }; export const 낮음: Story = { - args: { trigger: TRIGGER, selected: "low" }, + args: { trigger: TRIGGER, selected: "LOW" }, }; diff --git a/packages/timo-design-system/src/components/priority/priority-selector/PrioritySelector.tsx b/packages/timo-design-system/src/components/priority/priority-selector/PrioritySelector.tsx index ee4efe5e..119568b0 100644 --- a/packages/timo-design-system/src/components/priority/priority-selector/PrioritySelector.tsx +++ b/packages/timo-design-system/src/components/priority/priority-selector/PrioritySelector.tsx @@ -4,22 +4,22 @@ import { PriorityIcon } from "../priority-icon/PriorityIcon"; import type { ReactNode } from "react"; -export type PriorityLevel = "urgent" | "high" | "medium" | "low"; +export type PriorityLevel = "VERY_HIGH" | "HIGH" | "MEDIUM" | "LOW"; -const PRIORITY_LEVELS: PriorityLevel[] = ["urgent", "high", "medium", "low"]; +const PRIORITY_LEVELS: PriorityLevel[] = ["VERY_HIGH", "HIGH", "MEDIUM", "LOW"]; const PRIORITY_LABEL: Record = { - urgent: "매우중요", - high: "중요", - medium: "보통", - low: "낮음", + VERY_HIGH: "매우중요", + HIGH: "중요", + MEDIUM: "보통", + LOW: "낮음", }; const PRIORITY_BG_COLOR: Record = { - urgent: "bg-timo-red", - high: "bg-timo-orange", - medium: "bg-timo-gray", - low: "bg-timo-gray-900", + VERY_HIGH: "bg-timo-red", + HIGH: "bg-timo-orange", + MEDIUM: "bg-timo-gray", + LOW: "bg-timo-gray-900", }; export interface PrioritySelectorProps { @@ -34,7 +34,7 @@ export const PrioritySelector = ({ onSelect, }: PrioritySelectorProps) => { return ( - + {trigger} diff --git a/packages/timo-design-system/src/components/repeat/repeat-selector/RepeatSelector.stories.tsx b/packages/timo-design-system/src/components/repeat/repeat-selector/RepeatSelector.stories.tsx index 536dd72e..781fb9b5 100644 --- a/packages/timo-design-system/src/components/repeat/repeat-selector/RepeatSelector.stories.tsx +++ b/packages/timo-design-system/src/components/repeat/repeat-selector/RepeatSelector.stories.tsx @@ -11,9 +11,9 @@ const TRIGGER = ( ); const KOREAN_OPTIONS = [ - { frequency: "daily", label: "매일" }, - { frequency: "weekly", label: "매주" }, - { frequency: "monthly", label: "매달" }, + { frequency: "DAILY", label: "매일" }, + { frequency: "WEEKLY", label: "매주" }, + { frequency: "MONTHLY", label: "매달" }, ] as const; const WEEKDAYS_KO = [ @@ -43,7 +43,7 @@ const meta = { argTypes: { frequency: { control: "select", - options: ["daily", "weekly", "monthly"], + options: ["DAILY", "WEEKLY", "MONTHLY"], }, }, args: { @@ -66,7 +66,7 @@ type Story = StoryObj; export const Daily: Story = { args: { - frequency: "daily", + frequency: "DAILY", }, }; @@ -76,7 +76,7 @@ const WeeklyDemo = (args: RepeatSelectorProps) => { return ( { export const Weekly: Story = { args: { - frequency: "weekly", + frequency: "WEEKLY", }, render: (args) => , }; @@ -105,7 +105,7 @@ const MonthlyDemo = (args: RepeatSelectorProps) => { return ( { export const Monthly: Story = { args: { - frequency: "monthly", + frequency: "MONTHLY", }, render: (args) => , }; diff --git a/packages/timo-design-system/src/components/repeat/repeat-selector/RepeatSelector.tsx b/packages/timo-design-system/src/components/repeat/repeat-selector/RepeatSelector.tsx index 07eb816e..9a6b2169 100644 --- a/packages/timo-design-system/src/components/repeat/repeat-selector/RepeatSelector.tsx +++ b/packages/timo-design-system/src/components/repeat/repeat-selector/RepeatSelector.tsx @@ -9,7 +9,7 @@ import { Dropdown } from "../../layout/dropdown/Dropdown"; import type { ReactNode } from "react"; -export type RepeatFrequency = "daily" | "weekly" | "monthly"; +export type RepeatFrequency = "DAILY" | "WEEKLY" | "MONTHLY"; export interface RepeatOption { frequency: RepeatFrequency; @@ -34,9 +34,9 @@ export interface RepeatMonthlyDetail { } const DETAIL_ALIGN: Record = { - daily: "items-start", - weekly: "items-start", - monthly: "items-end", + DAILY: "items-start", + WEEKLY: "items-start", + MONTHLY: "items-end", }; export interface RepeatSelectorProps { @@ -161,7 +161,7 @@ export const RepeatSelector = ({ }; return ( - + {trigger} @@ -205,10 +205,10 @@ export const RepeatSelector = ({ {detailHeading} - {selectedFrequency === "weekly" && ( + {selectedFrequency === "WEEKLY" && ( )} - {selectedFrequency === "monthly" && ( + {selectedFrequency === "MONTHLY" && ( { + const [value, setValue] = useState(args.value); + + return ; +}; + +const meta = { + title: "Components/Tag/TagNameInput", + component: TagNameInput, + parameters: { + layout: "centered", + backgrounds: { + default: "light-gray", + values: [ + { name: "light-gray", value: "#F5F5F5" }, + { name: "white", value: "#FFFFFF" }, + ], + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + value: "", + onChange: () => {}, + ...HINTS, + }, + render: (args) => , +}; + +export const Error: Story = { + args: { + value: "일상", + onChange: () => {}, + isError: true, + ...HINTS, + }, + render: (args) => , +}; diff --git a/packages/timo-design-system/src/components/tag/tag-name-input/TagNameInput.tsx b/packages/timo-design-system/src/components/tag/tag-name-input/TagNameInput.tsx new file mode 100644 index 00000000..ee9f81df --- /dev/null +++ b/packages/timo-design-system/src/components/tag/tag-name-input/TagNameInput.tsx @@ -0,0 +1,54 @@ +import { cn } from "../../../lib"; + +const DEFAULT_MAX_LENGTH = 10; + +export interface TagNameInputProps { + value: string; + onChange: (value: string) => void; + maxLength?: number; + isError?: boolean; + maxLengthHint: string; + duplicateHint: string; +} + +export const TagNameInput = ({ + value, + onChange, + maxLength = DEFAULT_MAX_LENGTH, + isError = false, + maxLengthHint, + duplicateHint, +}: TagNameInputProps) => { + return ( +
+
+ onChange(event.target.value.slice(0, maxLength))} + maxLength={maxLength} + className="typo-headline-m-14 text-timo-gray-900 min-w-0 flex-1 outline-none" + /> + + {value.length}/{maxLength} + +
+ +
+

{maxLengthHint}

+

+ {duplicateHint} +

+
+
+ ); +}; diff --git a/packages/timo-design-system/src/components/tag/tag-selector/TagSelector.tsx b/packages/timo-design-system/src/components/tag/tag-selector/TagSelector.tsx index efb0c329..a3e847e2 100644 --- a/packages/timo-design-system/src/components/tag/tag-selector/TagSelector.tsx +++ b/packages/timo-design-system/src/components/tag/tag-selector/TagSelector.tsx @@ -20,7 +20,7 @@ export const TagSelector = ({ onAddClick, }: TagSelectorProps) => { return ( - + {trigger} diff --git a/packages/timo-design-system/src/components/time/time-selector/TimeSelector.tsx b/packages/timo-design-system/src/components/time/time-selector/TimeSelector.tsx index 232b50b8..7001e975 100644 --- a/packages/timo-design-system/src/components/time/time-selector/TimeSelector.tsx +++ b/packages/timo-design-system/src/components/time/time-selector/TimeSelector.tsx @@ -32,7 +32,7 @@ export const TimeSelector = ({ const isAiSelected = selected === "ai"; return ( - + {trigger} diff --git a/packages/timo-design-system/src/components/todo/todo-toolbar/TodoToolbar.tsx b/packages/timo-design-system/src/components/todo/todo-toolbar/TodoToolbar.tsx new file mode 100644 index 00000000..310f8054 --- /dev/null +++ b/packages/timo-design-system/src/components/todo/todo-toolbar/TodoToolbar.tsx @@ -0,0 +1,144 @@ +import { + CalendarDisableIcon, + CalendarOnIcon, + ClockDisableIcon, + ClockOnIcon, + MemoDisableIcon, + MemoOnIcon, + RepeatDisableIcon, + RepeatOnIcon, +} from "../../../icons"; +import { cn } from "../../../lib"; +import { DateSelector } from "../../calendar/date-selector/DateSelector"; +import { PriorityIcon } from "../../priority/priority-icon/PriorityIcon"; +import { + PrioritySelector, + type PriorityLevel, +} from "../../priority/priority-selector/PrioritySelector"; +import { + RepeatSelector, + type RepeatSelectorProps, +} from "../../repeat/repeat-selector/RepeatSelector"; +import { TagIcon } from "../../tag/tag-icon/TagIcon"; +import { TagSelector } from "../../tag/tag-selector/TagSelector"; +import { + TimeSelector, + type TimeOption, + type TimeSelection, +} from "../../time/time-selector/TimeSelector"; + +export interface TodoToolbarProps { + dateLabel: string; + date?: Date; + onDateChange?: (date: Date) => void; + + timeLabel: string; + timeOptions: TimeOption[]; + time?: string; + onTimeChange?: (time: string) => void; + selectedTime?: TimeSelection; + onSelectTime?: (value: TimeSelection) => void; + + priority?: PriorityLevel; + onSelectPriority?: (priority: PriorityLevel) => void; + + tagLabel: string; + tags: string[]; + selectedTag?: string; + onSelectTag?: (tag: string) => void; + onAddTagClick?: () => void; + + hasMemo?: boolean; + + isRepeatActive?: boolean; + repeat: Omit; + + className?: string; +} + +export const TodoToolbar = ({ + dateLabel, + date, + onDateChange, + timeLabel, + timeOptions, + time, + onTimeChange, + selectedTime, + onSelectTime, + priority, + onSelectPriority, + tagLabel, + tags, + selectedTag, + onSelectTag, + onAddTagClick, + hasMemo = false, + isRepeatActive = false, + repeat, + className, +}: TodoToolbarProps) => { + return ( +
+ + {date ? : } + + {dateLabel} + + + } + /> + + + {time ? : } + + {timeLabel} + + + } + time={time} + onTimeChange={onTimeChange} + times={timeOptions} + selected={selectedTime} + onSelect={onSelectTime} + /> + + } + selected={priority} + onSelect={onSelectPriority} + /> + + } + tags={tags} + selected={selectedTag} + onSelect={onSelectTag} + onAddClick={onAddTagClick} + /> + + {hasMemo ? : } + + : } + /> +
+ ); +}; diff --git a/packages/timo-design-system/src/icons/source/todo-icon-daily.svg b/packages/timo-design-system/src/icons/source/todo-icon-daily.svg new file mode 100644 index 00000000..3a5d4b43 --- /dev/null +++ b/packages/timo-design-system/src/icons/source/todo-icon-daily.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/packages/timo-design-system/src/icons/source/todo-icon-etc.svg b/packages/timo-design-system/src/icons/source/todo-icon-etc.svg new file mode 100644 index 00000000..5172b000 --- /dev/null +++ b/packages/timo-design-system/src/icons/source/todo-icon-etc.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/timo-design-system/src/icons/source/todo-icon-plus.svg b/packages/timo-design-system/src/icons/source/todo-icon-plus.svg new file mode 100644 index 00000000..68bdb129 --- /dev/null +++ b/packages/timo-design-system/src/icons/source/todo-icon-plus.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/packages/timo-design-system/src/icons/source/todo-icon-relationship.svg b/packages/timo-design-system/src/icons/source/todo-icon-relationship.svg new file mode 100644 index 00000000..de5fec08 --- /dev/null +++ b/packages/timo-design-system/src/icons/source/todo-icon-relationship.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/packages/timo-design-system/src/icons/source/todo-icon-rest.svg b/packages/timo-design-system/src/icons/source/todo-icon-rest.svg new file mode 100644 index 00000000..a8d61083 --- /dev/null +++ b/packages/timo-design-system/src/icons/source/todo-icon-rest.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/packages/timo-design-system/src/icons/source/todo-icon-study.svg b/packages/timo-design-system/src/icons/source/todo-icon-study.svg new file mode 100644 index 00000000..6b27fded --- /dev/null +++ b/packages/timo-design-system/src/icons/source/todo-icon-study.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/packages/timo-design-system/src/icons/source/todo-icon-task.svg b/packages/timo-design-system/src/icons/source/todo-icon-task.svg new file mode 100644 index 00000000..386b15f7 --- /dev/null +++ b/packages/timo-design-system/src/icons/source/todo-icon-task.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/packages/timo-design-system/src/icons/source/todo-icon-work.svg b/packages/timo-design-system/src/icons/source/todo-icon-work.svg new file mode 100644 index 00000000..29f627b7 --- /dev/null +++ b/packages/timo-design-system/src/icons/source/todo-icon-work.svg @@ -0,0 +1,7 @@ + + + + + + + From b5c3cb921d44e80c268cd8b38410f4e6706fe8cc Mon Sep 17 00:00:00 2001 From: kimminna Date: Sat, 11 Jul 2026 00:36:19 +0900 Subject: [PATCH 3/6] =?UTF-8?q?feat(web):=20=ED=88=AC=EB=91=90=20API=20?= =?UTF-8?q?=EC=8A=A4=ED=82=A4=EB=A7=88=20=EB=B0=8F=20=EC=98=A4=EB=B2=84?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=20=EB=AA=A8=EB=8B=AC=20=EC=9D=B8=ED=94=84?= =?UTF-8?q?=EB=9D=BC=20=EC=B6=94=EA=B0=80=20(#137)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 투두 생성 요청 스키마를 정의했습니다 - 오버레이 기반 모달 컴포넌트와 프로바이더를 추가하고 루트 레이아웃에 연결했습니다 --- apps/timo-web/api/todo/todo-schema.ts | 91 +++++++++++++++++++++ apps/timo-web/app/[locale]/layout.tsx | 5 +- apps/timo-web/components/modal/Modal.tsx | 91 +++++++++++++++++++++ apps/timo-web/providers/OverlayProvider.tsx | 11 +++ 4 files changed, 197 insertions(+), 1 deletion(-) create mode 100644 apps/timo-web/api/todo/todo-schema.ts create mode 100644 apps/timo-web/components/modal/Modal.tsx create mode 100644 apps/timo-web/providers/OverlayProvider.tsx diff --git a/apps/timo-web/api/todo/todo-schema.ts b/apps/timo-web/api/todo/todo-schema.ts new file mode 100644 index 00000000..dd2e590f --- /dev/null +++ b/apps/timo-web/api/todo/todo-schema.ts @@ -0,0 +1,91 @@ +import { z } from "zod"; + +import { apiResponseSchema } from "@/api/schema/response"; + +export const todoIconSchema = z.enum([ + "ICON_1", + "ICON_2", + "ICON_3", + "ICON_4", + "ICON_5", + "ICON_6", + "ICON_7", + "ICON_8", +]); + +export const todoPrioritySchema = z.enum([ + "VERY_HIGH", + "HIGH", + "MEDIUM", + "LOW", +]); + +export const todoRepeatTypeSchema = z.enum([ + "NONE", + "DAILY", + "WEEKLY", + "MONTHLY", +]); + +export const todoRepeatWeekdaySchema = z.enum([ + "MON", + "TUE", + "WED", + "THU", + "FRI", + "SAT", + "SUN", +]); + +const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/; +const DURATION_PATTERN = /^\d{1,3}:[0-5]\d$/; + +export const createTodoRequestSchema = z + .object({ + icon: todoIconSchema.nullable(), + title: z.string().trim().min(1), + subtasks: z.array(z.string().trim().min(1)).nullable(), + date: z.string().regex(DATE_PATTERN), + duration: z.string().regex(DURATION_PATTERN), + priority: todoPrioritySchema.nullable(), + tagId: z.number().int().positive().nullable(), + repeatType: todoRepeatTypeSchema, + repeatWeekdays: z.array(todoRepeatWeekdaySchema).nullable(), + repeatDayOfMonth: z.number().int().min(1).max(31).nullable(), + memo: z.string().nullable(), + }) + .superRefine((value, ctx) => { + if ( + value.repeatType === "WEEKLY" && + (!value.repeatWeekdays || value.repeatWeekdays.length === 0) + ) { + ctx.addIssue({ + code: "custom", + path: ["repeatWeekdays"], + message: "반복할 요일을 선택해주세요.", + }); + } + + if (value.repeatType === "MONTHLY" && !value.repeatDayOfMonth) { + ctx.addIssue({ + code: "custom", + path: ["repeatDayOfMonth"], + message: "반복할 날짜를 선택해주세요.", + }); + } + }); + +export type CreateTodoRequest = z.infer; + +export type TodoIcon = z.infer; +export type TodoPriority = z.infer; +export type TodoRepeatType = z.infer; +export type TodoRepeatWeekday = z.infer; + +export const createTodoResponseSchema = apiResponseSchema( + z.object({ + todoId: z.number(), + }), +); + +export type CreateTodoResponse = z.infer; diff --git a/apps/timo-web/app/[locale]/layout.tsx b/apps/timo-web/app/[locale]/layout.tsx index 1f7276c4..640216c1 100644 --- a/apps/timo-web/app/[locale]/layout.tsx +++ b/apps/timo-web/app/[locale]/layout.tsx @@ -6,6 +6,7 @@ import { hasLocale, NextIntlClientProvider } from "next-intl"; import type { Metadata } from "next"; import { routing } from "@/i18n/routing"; +import { OverlayProvider } from "@/providers/OverlayProvider"; import { QueryProvider } from "@/providers/query/QueryProvider"; const pretendard = localFont({ @@ -43,7 +44,9 @@ export default async function RootLayout({ - {children} + + {children} + diff --git a/apps/timo-web/components/modal/Modal.tsx b/apps/timo-web/components/modal/Modal.tsx new file mode 100644 index 00000000..f1d34768 --- /dev/null +++ b/apps/timo-web/components/modal/Modal.tsx @@ -0,0 +1,91 @@ +"use client"; + +import { cn } from "@repo/timo-design-system/utils"; +import { useEffect, useState } from "react"; +import { createPortal } from "react-dom"; + +import type { ReactNode } from "react"; + +const EXIT_ANIMATION_DURATION = 200; + +export interface ModalProps { + isOpen: boolean; + onClose: () => void; + onExited?: () => void; + children: ReactNode; + className?: string; +} + +export const Modal = ({ + isOpen, + onClose, + onExited, + children, + className, +}: ModalProps) => { + const [shouldRender, setShouldRender] = useState(isOpen); + const [isVisible, setIsVisible] = useState(false); + + useEffect(() => { + if (!isOpen) { + setIsVisible(false); + + const hideTimer = setTimeout(() => { + setShouldRender(false); + onExited?.(); + }, EXIT_ANIMATION_DURATION); + + return () => clearTimeout(hideTimer); + } + + setShouldRender(true); + + const showFrame = requestAnimationFrame(() => setIsVisible(true)); + + return () => cancelAnimationFrame(showFrame); + }, [isOpen, onExited]); + + useEffect(() => { + if (!shouldRender) return; + + document.body.style.overflow = "hidden"; + + const handleEscape = (event: KeyboardEvent) => { + if (event.key === "Escape") onClose(); + }; + + document.addEventListener("keydown", handleEscape); + + return () => { + document.body.style.overflow = ""; + document.removeEventListener("keydown", handleEscape); + }; + }, [shouldRender, onClose]); + + if (!shouldRender) return null; + + return createPortal( + <> +